feat: wire bark battle platform loop
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-05-14 18:20:46 +08:00
parent 8c6ec9e6e4
commit 1d7ef7e4b6
73 changed files with 7933 additions and 107 deletions

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -109,6 +113,10 @@ import {
getPublicAuthUserByCode,
getPublicAuthUserById,
} from '../../services/authService';
import {
createBarkBattleDraft,
publishBarkBattleWork,
} from '../../services/bark-battle-creation';
import {
createBigFishCreationSession,
executeBigFishCreationAction,
@@ -1766,6 +1774,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 {
@@ -1944,6 +1966,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<{
@@ -4379,6 +4405,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);
@@ -4395,9 +4430,11 @@ export function PlatformEntryFlowShellImpl({
runProtectedAction,
sessionController,
setActiveCreationFormType,
setBarkBattleError,
setMatch3DError,
setPuzzleCreationError,
setPuzzleError,
setSelectionStage,
setVisualNovelError,
],
);
@@ -4426,6 +4463,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);
@@ -10515,6 +10583,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

View File

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