feat: complete bark battle draft publish flow

This commit is contained in:
2026-05-19 15:27:50 +08:00
parent 804f1e32be
commit 23fb895e82
24 changed files with 1710 additions and 159 deletions

View File

@@ -7,54 +7,74 @@ 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} />);
it('allows creators to edit lightweight config and compile a Bark Battle draft', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
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.clear(screen.getByLabelText('玩家角色设定'));
await userEvent.type(screen.getByLabelText('玩家角色设定'), '主角');
await userEvent.clear(screen.getByLabelText('对手角色设定'));
await userEvent.type(screen.getByLabelText('对手角色设定'), '对手');
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
await userEvent.click(screen.getByLabelText('开启排行榜'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
await userEvent.type(
screen.getByLabelText('玩家形象'),
'/generated-bark-battle/player/image.png',
);
await userEvent.type(
screen.getByLabelText('对手形象'),
'https://example.test/opponent.png',
);
await userEvent.type(
screen.getByLabelText('UI背景'),
'/generated-bark-battle/ui/background.png',
);
await userEvent.type(
screen.getByLabelText('狗叫音效'),
'/generated-bark-battle/audio/bark.mp3',
);
await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
expect(onPublish).toHaveBeenCalledWith({
expect(onPreview).toHaveBeenCalledWith({
title: '周末狗狗杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
playerDogSkinPreset: '主角',
opponentDogSkinPreset: '对手',
playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
barkSoundSrc: '/generated-bark-battle/audio/bark.mp3',
difficultyPreset: 'hard',
leaderboardEnabled: false,
leaderboardEnabled: true,
});
});
it('requires a non-empty title before publishing', async () => {
const onPublish = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
it('requires a non-empty title before compiling a draft', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
expect(onPublish).not.toHaveBeenCalled();
expect(onPreview).not.toHaveBeenCalled();
expect(screen.getByText('请先填写作品标题')).toBeTruthy();
});
it('can render as an embedded creation form without a local page header', () => {
const onPublish = vi.fn();
const onPreview = vi.fn();
render(
<BarkBattleConfigEditor
error="发布失败"
isBusy={false}
onPublish={onPublish}
onPreview={onPreview}
showBackButton={false}
title={null}
/>,

View File

@@ -1,4 +1,4 @@
import { ArrowLeft, Loader2, Trophy, WandSparkles } from 'lucide-react';
import { ArrowLeft, Loader2, Play } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
@@ -8,7 +8,7 @@ import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
isBusy?: boolean;
error?: string | null;
onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onPreview: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onBack?: () => void;
showBackButton?: boolean;
title?: string | null;
@@ -20,12 +20,6 @@ const THEME_OPTIONS = [
{ 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: '标准' },
@@ -35,7 +29,7 @@ const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: stri
export function BarkBattleConfigEditor({
isBusy = false,
error: externalError = null,
onPublish,
onPreview,
onBack,
showBackButton = true,
title: headingTitle = '汪汪声浪大作战',
@@ -43,10 +37,13 @@ export function BarkBattleConfigEditor({
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 [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('主角');
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('对手');
const [playerCharacterImageSrc, setPlayerCharacterImageSrc] = useState('');
const [opponentCharacterImageSrc, setOpponentCharacterImageSrc] = useState('');
const [uiBackgroundImageSrc, setUiBackgroundImageSrc] = useState('');
const [barkSoundSrc, setBarkSoundSrc] = useState('');
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [leaderboardEnabled, setLeaderboardEnabled] = useState(true);
const [localError, setLocalError] = useState<string | null>(null);
const payload = useMemo<BarkBattleConfigEditorPayload>(
@@ -56,8 +53,18 @@ export function BarkBattleConfigEditor({
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
...(playerCharacterImageSrc.trim()
? { playerCharacterImageSrc: playerCharacterImageSrc.trim() }
: {}),
...(opponentCharacterImageSrc.trim()
? { opponentCharacterImageSrc: opponentCharacterImageSrc.trim() }
: {}),
...(uiBackgroundImageSrc.trim()
? { uiBackgroundImageSrc: uiBackgroundImageSrc.trim() }
: {}),
...(barkSoundSrc.trim() ? { barkSoundSrc: barkSoundSrc.trim() } : {}),
difficultyPreset,
leaderboardEnabled,
leaderboardEnabled: true,
}),
[
title,
@@ -65,24 +72,29 @@ export function BarkBattleConfigEditor({
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
playerCharacterImageSrc,
opponentCharacterImageSrc,
uiBackgroundImageSrc,
barkSoundSrc,
difficultyPreset,
leaderboardEnabled,
],
);
const handlePublish = () => {
const runValidatedAction = (
action: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>,
) => {
if (!payload.title) {
setLocalError('请先填写作品标题');
return;
}
setLocalError(null);
void onPublish(payload);
void action(payload);
};
const visibleError = localError ?? externalError;
return (
<section
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden"
className="platform-remap-surface mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col overflow-y-auto overscroll-y-contain pr-0.5"
aria-label="汪汪声浪轻配置编辑器"
>
{showBackButton && onBack ? (
@@ -101,7 +113,7 @@ export function BarkBattleConfigEditor({
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col">
{headingTitle ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
@@ -116,9 +128,9 @@ export function BarkBattleConfigEditor({
) : null}
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
className={`grid flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
@@ -193,63 +205,88 @@ export function BarkBattleConfigEditor({
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
<input
value={playerDogSkinPreset}
disabled={isBusy}
onChange={(event) =>
setPlayerDogSkinPreset(event.target.value)
}
onChange={(event) => setPlayerDogSkinPreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="玩家狗狗"
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
aria-label="玩家角色设定"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
<input
value={opponentDogSkinPreset}
disabled={isBusy}
onChange={(event) =>
setOpponentDogSkinPreset(event.target.value)
}
onChange={(event) => setOpponentDogSkinPreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="对手狗狗"
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
aria-label="对手角色设定"
/>
</label>
</div>
<label className="flex shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3 text-sm font-black text-[var(--platform-text-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]">
<span className="inline-flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
</span>
<input
aria-label="开启排行榜"
type="checkbox"
checked={leaderboardEnabled}
disabled={isBusy}
onChange={(event) =>
setLeaderboardEnabled(event.target.checked)
}
className="h-5 w-5 accent-[#ff4f6a]"
/>
</label>
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={playerCharacterImageSrc}
disabled={isBusy}
onChange={(event) => setPlayerCharacterImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="玩家形象"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={opponentCharacterImageSrc}
disabled={isBusy}
onChange={(event) => setOpponentCharacterImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="对手形象"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
UI背景
</span>
<input
value={uiBackgroundImageSrc}
disabled={isBusy}
onChange={(event) => setUiBackgroundImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="UI背景"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={barkSoundSrc}
disabled={isBusy}
onChange={(event) => setBarkSoundSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="狗叫音效"
/>
</label>
</div>
{visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
@@ -262,20 +299,20 @@ export function BarkBattleConfigEditor({
</div>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<div className="mt-3 flex shrink-0 flex-wrap justify-center gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-4">
<button
type="button"
disabled={isBusy}
onClick={handlePublish}
onClick={() => runValidatedAction(onPreview)}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<WandSparkles className="h-4 w-4" />
<Play className="h-4 w-4" />
)}
<span>{isBusy ? '发布中' : '发布并试玩'}</span>
<span>{isBusy ? '处理中' : '生成草稿'}</span>
</span>
</button>
</div>

View File

@@ -1,4 +1,5 @@
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BarkBattlePreviewCardProps = {
config: BarkBattleConfigEditorPayload;
@@ -10,12 +11,6 @@ const THEME_LABELS: Record<string, string> = {
'moonlight-rooftop': '月光天台',
};
const DOG_LABELS: Record<string, string> = {
corgi: '柯基',
shiba: '柴犬',
husky: '哈士奇',
};
const DIFFICULTY_LABELS = {
easy: '轻松',
normal: '标准',
@@ -23,6 +18,8 @@ const DIFFICULTY_LABELS = {
};
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
const hasCustomSound = Boolean(config.barkSoundSrc?.trim());
return (
<aside
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 sm:p-4"
@@ -30,10 +27,41 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
>
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<div
className="mb-4 flex min-h-[8.5rem] items-center justify-center rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] text-5xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]"
className="relative mb-4 grid min-h-[8.5rem] grid-cols-[1fr_auto_1fr] items-center gap-3 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-4 text-center text-3xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]"
aria-hidden="true"
>
<span> VS </span>
{config.uiBackgroundImageSrc ? (
<ResolvedAssetImage
src={config.uiBackgroundImageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-70"
/>
) : null}
<span className="relative grid place-items-center">
{config.playerCharacterImageSrc ? (
<ResolvedAssetImage
src={config.playerCharacterImageSrc}
alt=""
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/>
) : (
<span className="text-5xl sm:text-6xl">🐕</span>
)}
</span>
<span className="relative rounded-full bg-white/70 px-3 py-1 text-base font-black text-[var(--platform-text-strong)]">
VS
</span>
<span className="relative grid place-items-center">
{config.opponentCharacterImageSrc ? (
<ResolvedAssetImage
src={config.opponentCharacterImageSrc}
alt=""
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/>
) : (
<span className="text-5xl sm:text-6xl">🐶</span>
)}
</span>
</div>
<h2 className="text-lg font-black leading-tight text-[var(--platform-text-strong)]">
{config.title || '未命名声浪竞技场'}
@@ -51,9 +79,9 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{DOG_LABELS[config.playerDogSkinPreset] ?? config.playerDogSkinPreset}
{config.playerDogSkinPreset || '主角'}
{' vs '}
{DOG_LABELS[config.opponentDogSkinPreset] ?? config.opponentDogSkinPreset}
{config.opponentDogSkinPreset || '对手'}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
@@ -63,9 +91,13 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.leaderboardEnabled ? '开启' : '关闭'}
{[
config.playerCharacterImageSrc || config.opponentCharacterImageSrc ? '形象' : '',
config.uiBackgroundImageSrc ? 'UI' : '',
hasCustomSound ? '狗叫' : '',
].filter(Boolean).join(' / ') || '预设'}
</dd>
</div>
</dl>

View File

@@ -0,0 +1,110 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { uploadBarkBattleAsset } from '../../services/bark-battle-creation';
import { BarkBattleResultView } from './BarkBattleResultView';
vi.mock('../../services/bark-battle-creation', () => ({
regenerateBarkBattleImageAsset: vi.fn(),
uploadBarkBattleAsset: vi.fn(),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
...props
}: {
src?: string | null;
alt?: string;
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
}));
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: '主角',
opponentDogSkinPreset: '对手',
difficultyPreset: 'normal' as const,
leaderboardEnabled: true,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
};
describe('BarkBattleResultView', () => {
it('exposes draft preview actions before publish', async () => {
const user = userEvent.setup();
const onStartTestRun = vi.fn();
const onPublish = vi.fn();
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={onStartTestRun}
onPublish={onPublish}
/>,
);
expect(screen.getByText('草稿编译')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(draft);
expect(onPublish).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '发布' }));
expect(onPublish).toHaveBeenCalledWith(draft);
});
it('uploads replacement assets into the selected slot', async () => {
const user = userEvent.setup();
const onDraftChange = vi.fn();
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
assetObjectId: 'asset-player-1',
assetKind: 'bark_battle_player_character_image',
objectKey: 'generated-bark-battle-assets/player.png',
assetSrc: '/generated-bark-battle-assets/player.png',
});
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={onDraftChange}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen.getByText('玩家形象').closest('article');
expect(playerSlot).toBeTruthy();
const fileInput = within(playerSlot as HTMLElement).getByLabelText(
'上传玩家形象文件',
) as HTMLInputElement;
await user.upload(
fileInput,
new File(['image-bytes'], 'player.png', { type: 'image/png' }),
);
await waitFor(() => {
expect(uploadBarkBattleAsset).toHaveBeenCalledWith(
expect.objectContaining({
slot: 'player-character',
draftId: 'bark-battle-draft-1',
}),
);
});
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
}),
);
});
});

View File

@@ -0,0 +1,339 @@
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
RefreshCw,
Upload,
Volume2,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react';
import type {
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
type BarkBattleAssetSlot,
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
type BarkBattleResultViewProps = {
draft: BarkBattleDraftConfig;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onDraftChange: (draft: BarkBattleDraftConfig) => void;
onStartTestRun: (draft: BarkBattleDraftConfig) => void;
onPublish: (draft: BarkBattleDraftConfig) => void;
};
type BarkBattleImageSlot = Exclude<BarkBattleAssetSlot, 'bark-sound'>;
const SLOT_LABELS = {
'player-character': '玩家形象',
'opponent-character': '对手形象',
'ui-background': 'UI背景',
'bark-sound': '狗叫音效',
} satisfies Record<BarkBattleAssetSlot, string>;
function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload {
return {
title: draft.title,
description: draft.description,
themePreset: draft.themePreset,
playerDogSkinPreset: draft.playerDogSkinPreset,
opponentDogSkinPreset: draft.opponentDogSkinPreset,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
...(draft.opponentCharacterImageSrc
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
: {}),
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
...(draft.barkSoundSrc ? { barkSoundSrc: draft.barkSoundSrc } : {}),
difficultyPreset: draft.difficultyPreset,
leaderboardEnabled: draft.leaderboardEnabled,
};
}
function applyAssetToDraft(
draft: BarkBattleDraftConfig,
slot: BarkBattleAssetSlot,
assetSrc: string,
): BarkBattleDraftConfig {
const updatedAt = new Date().toISOString();
if (slot === 'player-character') {
return { ...draft, playerCharacterImageSrc: assetSrc, updatedAt };
}
if (slot === 'opponent-character') {
return { ...draft, opponentCharacterImageSrc: assetSrc, updatedAt };
}
if (slot === 'ui-background') {
return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt };
}
return { ...draft, barkSoundSrc: assetSrc, updatedAt };
}
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
if (slot === 'player-character') {
return draft.playerCharacterImageSrc ?? '';
}
if (slot === 'opponent-character') {
return draft.opponentCharacterImageSrc ?? '';
}
if (slot === 'ui-background') {
return draft.uiBackgroundImageSrc ?? '';
}
return draft.barkSoundSrc ?? '';
}
function ResultActionButton({
children,
disabled,
onClick,
tone = 'secondary',
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
tone?: 'primary' | 'secondary';
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`platform-button ${
tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary'
} min-h-11 justify-center disabled:cursor-not-allowed disabled:opacity-55`}
>
{children}
</button>
);
}
function BarkBattleAssetSlotControl({
draft,
slot,
disabled,
onChange,
onError,
}: {
draft: BarkBattleDraftConfig;
slot: BarkBattleAssetSlot;
disabled: boolean;
onChange: (draft: BarkBattleDraftConfig) => void;
onError: (message: string | null) => void;
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const assetSrc = getSlotAssetSrc(draft, slot);
const isImageSlot = slot !== 'bark-sound';
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
setIsUploading(true);
onError(null);
try {
const asset = await uploadBarkBattleAsset({
slot,
file,
draftId: draft.draftId,
});
onChange(applyAssetToDraft(draft, slot, asset.assetSrc));
} catch (error) {
onError(error instanceof Error ? error.message : '上传素材失败。');
} finally {
setIsUploading(false);
}
};
const handleRegenerate = async () => {
if (!isImageSlot) {
onError('狗叫音效暂未接入自动生成,请先手动上传音频。');
return;
}
setIsRegenerating(true);
onError(null);
try {
const result = await regenerateBarkBattleImageAsset({
slot: slot as BarkBattleImageSlot,
config: mapDraftToConfig(draft),
draftId: draft.draftId,
});
onChange(applyAssetToDraft(draft, slot, result.imageSrc));
} catch (error) {
onError(error instanceof Error ? error.message : '重新生成素材失败。');
} finally {
setIsRegenerating(false);
}
};
const isSlotBusy = isUploading || isRegenerating;
return (
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="m-0 text-sm font-black text-[var(--platform-text-strong)]">
{SLOT_LABELS[slot]}
</h3>
<div className="mt-1 truncate text-xs font-semibold text-[var(--platform-text-soft)]">
{assetSrc || '未替换'}
</div>
</div>
{isSlotBusy ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-[var(--platform-text-soft)]" />
) : isImageSlot ? (
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
) : (
<Volume2 className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
)}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<input
ref={fileInputRef}
type="file"
accept={isImageSlot ? 'image/png,image/jpeg,image/webp' : 'audio/mpeg,audio/wav,audio/ogg,audio/webm'}
className="hidden"
aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload}
/>
<button
type="button"
disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55"
>
<Upload className="h-3.5 w-3.5" />
</button>
<button
type="button"
disabled={disabled || isSlotBusy}
onClick={handleRegenerate}
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</article>
);
}
export function BarkBattleResultView({
draft,
isBusy = false,
error = null,
onBack,
onDraftChange,
onStartTestRun,
onPublish,
}: BarkBattleResultViewProps) {
const [localError, setLocalError] = useState<string | null>(null);
const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]);
const visibleError = localError ?? error;
return (
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
稿
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
<div className="grid gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
<div className="text-sm font-black text-[var(--platform-text-soft)]">
稿
</div>
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
{draft.title || '未命名声浪竞技场'}
</h1>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
'player-character',
'opponent-character',
'ui-background',
'bark-sound',
] as const
).map((slot) => (
<BarkBattleAssetSlotControl
key={slot}
draft={draft}
slot={slot}
disabled={isBusy}
onChange={(nextDraft) => {
setLocalError(null);
onDraftChange(nextDraft);
}}
onError={setLocalError}
/>
))}
</div>
</div>
<BarkBattlePreviewCard config={previewConfig} />
</section>
{visibleError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{visibleError}
</div>
) : null}
</div>
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-2">
<ResultActionButton
disabled={isBusy}
onClick={() => onStartTestRun(draft)}
>
<Play className="h-4 w-4" />
</ResultActionButton>
<ResultActionButton
tone="primary"
disabled={isBusy}
onClick={() => onPublish(draft)}
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</ResultActionButton>
</div>
</div>
</div>
);
}
export default BarkBattleResultView;

View File

@@ -15,6 +15,7 @@ import {
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
BarkBattlePublishedConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import type {
@@ -409,6 +410,7 @@ type PuzzleOnboardingDraft = {
};
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
type BarkBattleRuntimeReturnStage = 'bark-battle-result' | 'platform';
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
type RecommendRuntimeKind =
| 'big-fish'
@@ -2092,6 +2094,13 @@ const BarkBattleConfigEditor = lazy(async () => {
};
});
const BarkBattleResultView = lazy(async () => {
const module = await import('../bark-battle-creation/BarkBattleResultView');
return {
default: module.BarkBattleResultView,
};
});
const BarkBattleRuntimeShell = lazy(async () => {
const module = await import('../../games/bark-battle/ui/BarkBattleRuntimeShell');
return {
@@ -2319,6 +2328,10 @@ export function PlatformEntryFlowShellImpl({
useState<MiniGameDraftGenerationState | null>(null);
const [barkBattlePublishedConfig, setBarkBattlePublishedConfig] =
useState<BarkBattlePublishedConfig | null>(null);
const [barkBattleDraftConfig, setBarkBattleDraftConfig] =
useState<BarkBattleDraftConfig | null>(null);
const [barkBattleRuntimeReturnStage, setBarkBattleRuntimeReturnStage] =
useState<BarkBattleRuntimeReturnStage>('platform');
const [barkBattleError, setBarkBattleError] = useState<string | null>(null);
const [isBarkBattleBusy, setIsBarkBattleBusy] = useState(false);
const [bigFishRun, setBigFishRun] =
@@ -5540,24 +5553,104 @@ export function PlatformEntryFlowShellImpl({
}, [squareHoleFlow]);
const leaveBarkBattleFlow = useCallback(() => {
setBarkBattleDraftConfig(null);
setBarkBattlePublishedConfig(null);
setBarkBattleRuntimeReturnStage('platform');
setBarkBattleError(null);
setIsBarkBattleBusy(false);
selectionStageRef.current = 'platform';
setSelectionStage('platform');
}, [setSelectionStage]);
const publishBarkBattleConfig = useCallback(
const createBarkBattleResultDraft = useCallback(
async (payload: BarkBattleConfigEditorPayload) => {
setBarkBattleError(null);
setIsBarkBattleBusy(true);
try {
const draft = await createBarkBattleDraft(payload);
setBarkBattleDraftConfig(draft);
setBarkBattlePublishedConfig(null);
setSelectionStage('bark-battle-result');
} catch (error) {
setBarkBattleError(
resolvePuzzleErrorMessage(error, '生成汪汪声浪草稿失败。'),
);
} finally {
setIsBarkBattleBusy(false);
}
},
[resolvePuzzleErrorMessage, setSelectionStage],
);
const buildBarkBattleDraftRuntimeConfig = useCallback(
(draft: BarkBattleDraftConfig): BarkBattlePublishedConfig => ({
workId: draft.workId ?? draft.draftId,
draftId: draft.draftId,
configVersion: draft.configVersion ?? 1,
rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: draft.title,
description: draft.description,
themePreset: draft.themePreset,
playerDogSkinPreset: draft.playerDogSkinPreset,
opponentDogSkinPreset: draft.opponentDogSkinPreset,
playerCharacterImageSrc: draft.playerCharacterImageSrc,
opponentCharacterImageSrc: draft.opponentCharacterImageSrc,
uiBackgroundImageSrc: draft.uiBackgroundImageSrc,
barkSoundSrc: draft.barkSoundSrc,
difficultyPreset: draft.difficultyPreset,
leaderboardEnabled: draft.leaderboardEnabled,
updatedAt: draft.updatedAt,
publishedAt: draft.updatedAt,
}),
[],
);
const testBarkBattleDraft = useCallback(
(draft: BarkBattleDraftConfig) => {
setBarkBattleError(null);
setBarkBattleRuntimeReturnStage('bark-battle-result');
setBarkBattlePublishedConfig(buildBarkBattleDraftRuntimeConfig(draft));
setSelectionStage('bark-battle-runtime');
},
[buildBarkBattleDraftRuntimeConfig, setSelectionStage],
);
const publishBarkBattleDraft = useCallback(
async (draft: BarkBattleDraftConfig) => {
setBarkBattleError(null);
const workId = draft.workId?.trim();
if (!workId) {
setBarkBattleError('这份汪汪声浪草稿缺少作品ID请重新生成草稿后再发布。');
return;
}
setIsBarkBattleBusy(true);
try {
const publishedSnapshot: BarkBattleConfigEditorPayload = {
title: draft.title,
description: draft.description,
themePreset: draft.themePreset,
playerDogSkinPreset: draft.playerDogSkinPreset,
opponentDogSkinPreset: draft.opponentDogSkinPreset,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
...(draft.opponentCharacterImageSrc
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
: {}),
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
...(draft.barkSoundSrc ? { barkSoundSrc: draft.barkSoundSrc } : {}),
difficultyPreset: draft.difficultyPreset,
leaderboardEnabled: draft.leaderboardEnabled,
};
const published = await publishBarkBattleWork({
draftId: draft.draftId,
workId: draft.workId,
publishedSnapshot: payload,
workId,
publishedSnapshot,
});
setBarkBattleRuntimeReturnStage('platform');
setBarkBattlePublishedConfig(published);
setSelectionStage('bark-battle-runtime');
} catch (error) {
@@ -10855,7 +10948,7 @@ export function PlatformEntryFlowShellImpl({
</div>
</div>
<div className="mt-3 min-h-0 flex-1 overflow-hidden">
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
{activeCreationFormType === 'match3d' ? (
<Suspense
fallback={<LazyPanelFallback label="正在加载抓大鹅创作..." />}
@@ -10921,8 +11014,8 @@ export function PlatformEntryFlowShellImpl({
isBusy={isBarkBattleBusy}
error={barkBattleError}
onBack={leaveBarkBattleFlow}
onPublish={(payload) => {
void publishBarkBattleConfig(payload);
onPreview={(payload) => {
void createBarkBattleResultDraft(payload);
}}
showBackButton={false}
title={null}
@@ -12544,6 +12637,36 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'bark-battle-result' && barkBattleDraftConfig && (
<motion.div
key="bark-battle-result"
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="正在加载汪汪声浪草稿..." />}
>
<BarkBattleResultView
draft={barkBattleDraftConfig}
isBusy={isBarkBattleBusy}
error={barkBattleError}
onBack={() => {
enterCreateTab();
setActiveCreationFormType('bark-battle');
setSelectionStage('platform');
}}
onDraftChange={setBarkBattleDraftConfig}
onStartTestRun={testBarkBattleDraft}
onPublish={(draft) => {
void publishBarkBattleDraft(draft);
}}
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && (
<motion.div
key="bark-battle-runtime"
@@ -12560,9 +12683,16 @@ export function PlatformEntryFlowShellImpl({
workId={barkBattlePublishedConfig.workId}
publishedConfig={barkBattlePublishedConfig}
onExit={() => {
enterCreateTab();
setActiveCreationFormType('bark-battle');
setSelectionStage('platform');
if (
barkBattleRuntimeReturnStage === 'bark-battle-result' &&
barkBattleDraftConfig
) {
setSelectionStage('bark-battle-result');
} else {
enterCreateTab();
setActiveCreationFormType('bark-battle');
setSelectionStage('platform');
}
}}
/>
</Suspense>

View File

@@ -31,6 +31,7 @@ export type SelectionStage =
| 'square-hole-generating'
| 'square-hole-result'
| 'square-hole-runtime'
| 'bark-battle-result'
| 'bark-battle-runtime'
| 'creative-agent-workspace'
| 'visual-novel-agent-workspace'

View File

@@ -7,22 +7,22 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
@@ -42,13 +42,9 @@ import {
import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
createBarkBattleDraft,
publishBarkBattleWork,
} from '../../services/bark-battle-creation';
import {
createBigFishCreationSession,
getBigFishCreationSession,
@@ -60,10 +56,6 @@ import {
submitBigFishInput,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
createBarkBattleDraft,
publishBarkBattleWork,
} from '../../services/bark-battle-creation';
import {
type CreationEntryConfig,
fetchCreationEntryConfig,
@@ -75,6 +67,14 @@ import {
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from '../../services/creative-agent';
import {
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
@@ -476,6 +476,8 @@ vi.mock('../../services/big-fish-runtime', () => ({
vi.mock('../../services/bark-battle-creation', () => ({
createBarkBattleDraft: vi.fn(),
publishBarkBattleWork: vi.fn(),
regenerateBarkBattleImageAsset: vi.fn(),
uploadBarkBattleAsset: vi.fn(),
}));
vi.mock('../../services/edutainment-baby-object', () => ({
@@ -989,13 +991,13 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
isBusy,
showBackButton,
title,
onPublish,
onPreview,
}: {
error?: string | null;
isBusy?: boolean;
showBackButton?: boolean;
title?: string | null;
onPublish: (payload: {
onPreview: (payload: {
title: string;
description: string;
themePreset: string;
@@ -1021,7 +1023,7 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
type="button"
disabled={isBusy}
onClick={() => {
onPublish({
onPreview({
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
@@ -1032,7 +1034,40 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
});
}}
>
稿
</button>
</div>
),
}));
vi.mock('../bark-battle-creation/BarkBattleResultView', () => ({
BarkBattleResultView: ({
draft,
onBack,
onPublish,
onStartTestRun,
}: {
draft: {
title: string;
draftId: string;
workId?: string;
};
onBack: () => void;
onPublish: (draft: unknown) => void;
onStartTestRun: (draft: unknown) => void;
}) => (
<div className="bark-battle-result-view-mock">
<div>{draft.title}</div>
<div>稿ID{draft.draftId}</div>
<div>ID{draft.workId ?? 'missing-work'}</div>
<button type="button" onClick={() => onStartTestRun(draft)}>
</button>
<button type="button" onClick={() => onPublish(draft)}>
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
@@ -3201,14 +3236,14 @@ test('create tab switches bark battle into the embedded config form', async () =
expect(publishBarkBattleWork).not.toHaveBeenCalled();
});
test('bark battle publish preview returns to the embedded config form', async () => {
test('bark battle draft result can test before publish and return to the embedded form', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '发布并试玩' }));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(createBarkBattleDraft).toHaveBeenCalledWith({
title: '汪汪测试杯',
@@ -3219,6 +3254,27 @@ test('bark battle publish preview returns to the embedded config form', async ()
difficultyPreset: 'normal',
leaderboardEnabled: true,
});
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
expect(await screen.findByText('作品IDbark-battle-work-1')).toBeTruthy();
expect(publishBarkBattleWork).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回配置' }));
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '发布' }));
expect(publishBarkBattleWork).toHaveBeenCalledWith({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
publishedSnapshot: expect.objectContaining({
title: '汪汪测试杯',
leaderboardEnabled: true,
}),
});
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回配置' }));

View File

@@ -1,4 +1,5 @@
.bark-battle-hud {
position: relative;
min-height: 100svh;
color: #fff7ed;
background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%);
@@ -10,7 +11,18 @@
overflow: hidden;
}
.bark-battle-hud__background-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.82;
}
.bark-battle-hud__topline {
position: relative;
z-index: 1;
display: grid;
gap: 10px;
}
@@ -38,6 +50,8 @@
.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); }
.bark-battle-arena {
position: relative;
z-index: 1;
flex: 1;
min-height: 0;
display: grid;
@@ -54,6 +68,10 @@
}
.bark-battle-dog__body {
display: grid;
width: clamp(112px, 34vw, 170px);
aspect-ratio: 1;
place-items: center;
font-size: clamp(92px, 30vw, 150px);
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42));
}
@@ -62,6 +80,12 @@
transform: rotateY(180deg) translateY(4px);
}
.bark-battle-dog__image {
width: 100%;
height: 100%;
object-fit: contain;
}
.bark-battle-dog__label,
.bark-battle-dog__burst,
.bark-battle-vs {
@@ -94,6 +118,8 @@
.bark-battle-controls,
.bark-battle-result__stats {
position: relative;
z-index: 1;
display: flex;
gap: 10px;
justify-content: center;

View File

@@ -1,5 +1,6 @@
import './BarkBattleHud.css';
import { ResolvedAssetImage } from '../../../components/ResolvedAssetImage';
import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes';
type BarkBattleHudProps = {
@@ -10,6 +11,9 @@ type BarkBattleHudProps = {
onMockBark?: () => void;
onMockQuiet?: () => void;
onRestart?: () => void;
playerCharacterImageSrc?: string | null;
opponentCharacterImageSrc?: string | null;
uiBackgroundImageSrc?: string | null;
};
const failureText = {
@@ -32,6 +36,9 @@ export function BarkBattleHud({
onMockBark,
onMockQuiet,
onRestart,
playerCharacterImageSrc,
opponentCharacterImageSrc,
uiBackgroundImageSrc,
}: BarkBattleHudProps) {
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
@@ -39,6 +46,14 @@ export function BarkBattleHud({
return (
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
{uiBackgroundImageSrc ? (
<ResolvedAssetImage
src={uiBackgroundImageSrc}
alt=""
aria-hidden="true"
className="bark-battle-hud__background-image"
/>
) : null}
<header className="bark-battle-hud__topline">
<div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div>
<div
@@ -65,17 +80,37 @@ export function BarkBattleHud({
</div>
) : (
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true"></span>
<span className="bark-battle-dog__body">🐕</span>
<span className="bark-battle-dog__label"> · {snapshot.player.barkCount}</span>
</div>
<div className="bark-battle-vs">VS</div>
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true"></span>
<span className="bark-battle-dog__body">🐶</span>
<span className="bark-battle-dog__body">
{opponentCharacterImageSrc ? (
<ResolvedAssetImage
src={opponentCharacterImageSrc}
alt=""
className="bark-battle-dog__image"
/>
) : (
'🐶'
)}
</span>
<span className="bark-battle-dog__label"> · {snapshot.opponent.barkCount}</span>
</div>
<div className="bark-battle-vs">VS</div>
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true"></span>
<span className="bark-battle-dog__body">
{playerCharacterImageSrc ? (
<ResolvedAssetImage
src={playerCharacterImageSrc}
alt=""
className="bark-battle-dog__image"
/>
) : (
'🐕'
)}
</span>
<span className="bark-battle-dog__label"> · {snapshot.player.barkCount}</span>
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle';
import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl';
import {
type BarkBattleConfig,
DEFAULT_BARK_BATTLE_CONFIG,
@@ -113,11 +114,25 @@ export function BarkBattleRuntimeShell({
const [playerPulseKey, setPlayerPulseKey] = useState(0);
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
const barkAudioRef = useRef<HTMLAudioElement | null>(null);
const heldRef = useRef(false);
const lastPlayerBarkCountRef = useRef(0);
const lastOpponentPowerRef = useRef(0);
const debugEventIdRef = useRef(0);
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
const replacementConfig = publishedConfig ?? null;
const { resolvedUrl: resolvedBarkSoundSrc } = useResolvedAssetReadUrl(
replacementConfig?.barkSoundSrc ?? null,
);
const playBarkSound = useCallback(() => {
const audio = barkAudioRef.current;
if (!audio || !resolvedBarkSoundSrc) {
return;
}
audio.currentTime = 0;
void audio.play().catch(() => {});
}, [resolvedBarkSoundSrc]);
const appendDebugEvent = useCallback((text: string) => {
debugEventIdRef.current += 1;
@@ -129,16 +144,18 @@ export function BarkBattleRuntimeShell({
const nextSnapshot = controller.getSnapshot();
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
setPlayerPulseKey((current) => current + 1);
playBarkSound();
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
}
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) {
setOpponentPulseKey((current) => current + 1);
playBarkSound();
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
}
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
setSnapshot(nextSnapshot);
}, [appendDebugEvent, controller]);
}, [appendDebugEvent, controller, playBarkSound]);
const stopMicrophone = useCallback(() => {
microphoneSamplerRef.current?.stop();
@@ -237,10 +254,16 @@ export function BarkBattleRuntimeShell({
return (
<main className="bark-battle-runtime" aria-label={title}>
{resolvedBarkSoundSrc ? (
<audio ref={barkAudioRef} src={resolvedBarkSoundSrc} preload="auto" />
) : null}
<BarkBattleHud
snapshot={snapshot}
playerPulseKey={playerPulseKey}
opponentPulseKey={opponentPulseKey}
playerCharacterImageSrc={replacementConfig?.playerCharacterImageSrc}
opponentCharacterImageSrc={replacementConfig?.opponentCharacterImageSrc}
uiBackgroundImageSrc={replacementConfig?.uiBackgroundImageSrc}
onStartMicrophone={startMicrophone}
onMockBark={bark}
onMockQuiet={() => {

View File

@@ -1,11 +1,22 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes';
import { BarkBattleHud } from '../BarkBattleHud';
vi.mock('../../../../components/ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
...props
}: {
src?: string | null;
alt?: string;
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
}));
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
return {
phase: 'playing',
@@ -33,6 +44,8 @@ describe('BarkBattleHud', () => {
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
const arenaText = screen.getByLabelText('竖屏声浪竞技场').textContent ?? '';
expect(arenaText.indexOf('对手 · 1')).toBeLessThan(arenaText.indexOf('你 · 3'));
});
it('energy 正负值会改变玩家侧和对手侧占比', () => {
@@ -54,4 +67,19 @@ describe('BarkBattleHud', () => {
);
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
});
it('展示自定义角色形象和 UI 背景', () => {
render(
<BarkBattleHud
snapshot={buildSnapshot()}
playerCharacterImageSrc="/generated-bark-battle/player.png"
opponentCharacterImageSrc="https://example.test/opponent.png"
uiBackgroundImageSrc="/generated-bark-battle/ui.png"
/>,
);
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
expect(document.querySelector('img[src="https://example.test/opponent.png"]')).toBeTruthy();
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
});
});

View File

@@ -2,11 +2,60 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
vi.mock('../../../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
resolvedUrl: source ?? '',
isResolving: false,
shouldResolve: false,
}),
}));
vi.mock('../../../../components/ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
...props
}: {
src?: string | null;
alt?: string;
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
}));
describe('BarkBattleRuntimeShell 调试面板', () => {
it('从发布配置加载自定义狗叫音效资源', () => {
render(
<BarkBattleRuntimeShell
publishedConfig={{
workId: 'work-bark-1',
draftId: 'draft-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: '周末狗狗杯',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
difficultyPreset: 'hard',
leaderboardEnabled: true,
updatedAt: '2026-05-13T03:00:00.000Z',
publishedAt: '2026-05-13T03:00:00.000Z',
}}
/>,
);
expect(document.querySelector('audio[src="/generated-bark-battle/bark.mp3"]')).toBeTruthy();
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
});
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
render(<BarkBattleRuntimeShell />);

View File

@@ -22,6 +22,10 @@ describe('barkBattleCreationClient', () => {
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
difficultyPreset: 'hard',
leaderboardEnabled: true,
});
@@ -37,6 +41,10 @@ describe('barkBattleCreationClient', () => {
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
difficultyPreset: 'hard',
leaderboardEnabled: true,
}),

View File

@@ -1,16 +1,21 @@
import type {
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
BarkBattleDraftCreateRequest,
BarkBattlePublishedConfig,
BarkBattleWorkPublishRequest,
} from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldSceneImageResult } from '../aiTypes';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient';
const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle';
const BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES = 20 * 1024 * 1024;
const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
@@ -27,6 +32,171 @@ export type BarkBattleCreationRequestOptions = Pick<
| 'clearAuthOnUnauthorized'
>;
export type BarkBattleAssetSlot =
| 'player-character'
| 'opponent-character'
| 'ui-background'
| 'bark-sound';
export type BarkBattleUploadedAsset = {
assetObjectId: string;
assetKind: string;
objectKey: string;
assetSrc: string;
};
type DirectUploadTicketResponse = {
upload: {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
formFields: Record<string, string | null | undefined>;
};
};
type ConfirmAssetObjectResponse = {
assetObject: {
assetObjectId: string;
objectKey: string;
assetKind: string;
};
};
const SLOT_UPLOAD_CONFIG = {
'player-character': {
acceptKind: 'image',
assetKind: 'bark_battle_player_character_image',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
},
'opponent-character': {
acceptKind: 'image',
assetKind: 'bark_battle_opponent_character_image',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
},
'ui-background': {
acceptKind: 'image',
assetKind: 'bark_battle_ui_background_image',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
},
'bark-sound': {
acceptKind: 'audio',
assetKind: 'bark_battle_bark_sound',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES,
},
} satisfies Record<
BarkBattleAssetSlot,
{
acceptKind: 'image' | 'audio';
assetKind: string;
legacyPrefix: string;
maxSizeBytes: number;
}
>;
const MIME_BY_EXTENSION: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
png: 'image/png',
wav: 'audio/wav',
webm: 'audio/webm',
webp: 'image/webp',
};
function resolveUploadContentType(file: File) {
if (file.type.trim()) {
return file.type.trim();
}
const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? '';
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
}
function validateBarkBattleUploadFile(slot: BarkBattleAssetSlot, file: File) {
const config = SLOT_UPLOAD_CONFIG[slot];
const contentType = resolveUploadContentType(file);
if (file.size <= 0) {
throw new Error('素材文件为空,请重新选择。');
}
if (file.size > config.maxSizeBytes) {
throw new Error('素材文件过大,请压缩后再上传。');
}
if (config.acceptKind === 'image' && !contentType.startsWith('image/')) {
throw new Error('请选择图片素材。');
}
if (config.acceptKind === 'audio' && !contentType.startsWith('audio/')) {
throw new Error('请选择音频素材。');
}
return contentType;
}
function normalizeAssetPathSegment(value: string) {
return value
.trim()
.replace(/[^a-zA-Z0-9_-]+/gu, '-')
.replace(/^-+|-+$/gu, '')
.slice(0, 72);
}
function buildUploadPathSegments(slot: BarkBattleAssetSlot, draftId?: string) {
return [
'bark-battle',
normalizeAssetPathSegment(draftId || 'draft') || 'draft',
slot,
String(Date.now()),
];
}
async function postDirectUploadFile(
upload: DirectUploadTicketResponse['upload'],
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file);
const response = await fetch(upload.host, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传平台资产失败。');
}
}
function buildBarkBattleImagePrompt(
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>,
payload: BarkBattleConfigEditorPayload,
) {
const slotPrompt = {
'player-character': `玩家角色形象:${payload.playerDogSkinPreset}`,
'opponent-character': `对手角色形象:${payload.opponentDogSkinPreset}`,
'ui-background': `游戏 UI 背景:${payload.themePreset}`,
} satisfies Record<Exclude<BarkBattleAssetSlot, 'bark-sound'>, string>;
return [
`汪汪声浪大作战《${payload.title}`,
payload.description ?? '',
slotPrompt[slot],
slot === 'ui-background'
? '竖屏移动端游戏背景,无文字,无按钮,无角色遮挡'
: '游戏角色立绘,完整主体,透明感背景,无文字,无 UI',
]
.map((part) => part.trim())
.filter(Boolean)
.join('');
}
export function createBarkBattleDraft(
payload: BarkBattleDraftCreateRequest,
options: BarkBattleCreationRequestOptions = {},
@@ -71,7 +241,106 @@ export function publishBarkBattleWork(
);
}
export async function uploadBarkBattleAsset(payload: {
slot: BarkBattleAssetSlot;
file: File;
draftId?: string | null;
}): Promise<BarkBattleUploadedAsset> {
const contentType = validateBarkBattleUploadFile(payload.slot, payload.file);
const config = SLOT_UPLOAD_CONFIG[payload.slot];
const ticket = await requestJson<DirectUploadTicketResponse>(
'/api/assets/direct-upload-tickets',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: config.legacyPrefix,
pathSegments: buildUploadPathSegments(
payload.slot,
payload.draftId ?? undefined,
),
fileName: payload.file.name,
contentType,
access: 'private',
maxSizeBytes: config.maxSizeBytes,
metadata: {
asset_kind: config.assetKind,
bark_battle_slot: payload.slot,
},
}),
},
'创建汪汪声浪素材上传凭证失败',
);
await postDirectUploadFile(ticket.upload, payload.file);
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
'/api/assets/objects/confirm',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bucket: ticket.upload.bucket,
objectKey: ticket.upload.objectKey,
contentType,
contentLength: payload.file.size,
assetKind: config.assetKind,
accessPolicy: 'private',
profileId: payload.draftId?.trim() || null,
entityId: payload.slot,
}),
},
'确认汪汪声浪素材失败',
);
return {
assetObjectId: confirmed.assetObject.assetObjectId,
assetKind: confirmed.assetObject.assetKind,
objectKey: confirmed.assetObject.objectKey,
assetSrc: ticket.upload.legacyPublicPath,
};
}
export function regenerateBarkBattleImageAsset(payload: {
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>;
config: BarkBattleConfigEditorPayload;
draftId?: string | null;
}): Promise<CustomWorldSceneImageResult> {
return generateRpgWorldSceneImage({
profile: {
id: payload.draftId?.trim() || 'bark-battle-draft',
name: payload.config.title.trim() || '汪汪声浪大作战',
subtitle: '汪汪声浪',
summary: payload.config.description?.trim() || payload.config.themePreset,
tone: payload.config.themePreset,
playerGoal: '用声浪压过对手',
settingText: [
payload.config.themePreset,
payload.config.playerDogSkinPreset,
payload.config.opponentDogSkinPreset,
]
.map((part) => part.trim())
.filter(Boolean)
.join('\n'),
},
landmark: {
id: payload.slot,
name:
payload.slot === 'ui-background'
? '声浪竞技 UI 背景'
: payload.slot === 'player-character'
? payload.config.playerDogSkinPreset || '玩家角色'
: payload.config.opponentDogSkinPreset || '对手角色',
description: buildBarkBattleImagePrompt(payload.slot, payload.config),
},
userPrompt: buildBarkBattleImagePrompt(payload.slot, payload.config),
size: payload.slot === 'ui-background' ? '1024*1792' : '1024*1024',
});
}
export const barkBattleCreationClient = {
createDraft: createBarkBattleDraft,
regenerateImageAsset: regenerateBarkBattleImageAsset,
publish: publishBarkBattleWork,
uploadAsset: uploadBarkBattleAsset,
};

View File

@@ -1,6 +1,10 @@
export {
type BarkBattleAssetSlot,
barkBattleCreationClient,
type BarkBattleCreationRequestOptions,
type BarkBattleUploadedAsset,
createBarkBattleDraft,
publishBarkBattleWork,
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from './barkBattleCreationClient';