313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
import { ArrowLeft, Loader2, Play } from 'lucide-react';
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
|
||
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
|
||
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
|
||
import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig';
|
||
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
|
||
|
||
export type BarkBattleConfigEditorProps = {
|
||
isBusy?: boolean;
|
||
error?: string | null;
|
||
onPreview: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
|
||
onBack?: () => void;
|
||
showBackButton?: boolean;
|
||
title?: string | null;
|
||
};
|
||
|
||
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
|
||
{ value: 'easy', label: '轻松' },
|
||
{ value: 'normal', label: '标准' },
|
||
{ value: 'hard', label: '硬核' },
|
||
];
|
||
const FIELD_LABEL_CLASS =
|
||
'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]';
|
||
const ACCENT_FIELD_LABEL_CLASS =
|
||
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm';
|
||
const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台';
|
||
const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗';
|
||
const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗';
|
||
|
||
function buildDefaultOnomatopoeiaText(params: {
|
||
themeDescription: string;
|
||
playerImageDescription: string;
|
||
opponentImageDescription: string;
|
||
}) {
|
||
return buildBarkBattleDefaultOnomatopoeia(params).join('\n');
|
||
}
|
||
|
||
export function BarkBattleConfigEditor({
|
||
isBusy = false,
|
||
error: externalError = null,
|
||
onPreview,
|
||
onBack,
|
||
showBackButton = true,
|
||
title: headingTitle = '汪汪声浪大作战',
|
||
}: BarkBattleConfigEditorProps) {
|
||
const [title, setTitle] = useState('我的声浪竞技场');
|
||
const [description, setDescription] = useState('');
|
||
const [themeDescription, setThemeDescription] = useState(
|
||
DEFAULT_THEME_DESCRIPTION,
|
||
);
|
||
const [playerImageDescription, setPlayerImageDescription] = useState(
|
||
DEFAULT_PLAYER_IMAGE_DESCRIPTION,
|
||
);
|
||
const [opponentImageDescription, setOpponentImageDescription] = useState(
|
||
DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
|
||
);
|
||
const [isOnomatopoeiaCustomized, setIsOnomatopoeiaCustomized] =
|
||
useState(false);
|
||
const [onomatopoeiaText, setOnomatopoeiaText] = useState(() =>
|
||
buildDefaultOnomatopoeiaText({
|
||
themeDescription: DEFAULT_THEME_DESCRIPTION,
|
||
playerImageDescription: DEFAULT_PLAYER_IMAGE_DESCRIPTION,
|
||
opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
|
||
}),
|
||
);
|
||
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
|
||
const [localError, setLocalError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (isOnomatopoeiaCustomized) {
|
||
return;
|
||
}
|
||
setOnomatopoeiaText(
|
||
buildDefaultOnomatopoeiaText({
|
||
themeDescription,
|
||
playerImageDescription,
|
||
opponentImageDescription,
|
||
}),
|
||
);
|
||
}, [
|
||
isOnomatopoeiaCustomized,
|
||
themeDescription,
|
||
playerImageDescription,
|
||
opponentImageDescription,
|
||
]);
|
||
|
||
const onomatopoeia = useMemo(
|
||
() =>
|
||
onomatopoeiaText
|
||
.split(/[\n,,、/|]+/u)
|
||
.map((word) => word.trim())
|
||
.filter(Boolean)
|
||
.slice(0, 24),
|
||
[onomatopoeiaText],
|
||
);
|
||
|
||
const payload = useMemo<BarkBattleConfigEditorPayload>(
|
||
() => ({
|
||
title: title.trim(),
|
||
description: description.trim(),
|
||
themeDescription: themeDescription.trim(),
|
||
playerImageDescription: playerImageDescription.trim(),
|
||
opponentImageDescription: opponentImageDescription.trim(),
|
||
onomatopoeia,
|
||
difficultyPreset,
|
||
}),
|
||
[
|
||
title,
|
||
description,
|
||
themeDescription,
|
||
playerImageDescription,
|
||
opponentImageDescription,
|
||
onomatopoeia,
|
||
difficultyPreset,
|
||
],
|
||
);
|
||
|
||
const runValidatedAction = (
|
||
action: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>,
|
||
) => {
|
||
if (!payload.title) {
|
||
setLocalError('请先填写作品标题');
|
||
return;
|
||
}
|
||
if (!payload.themeDescription) {
|
||
setLocalError('请先填写主题/场景描述');
|
||
return;
|
||
}
|
||
if (!payload.playerImageDescription || !payload.opponentImageDescription) {
|
||
setLocalError('请先填写双方形象描述');
|
||
return;
|
||
}
|
||
setLocalError(null);
|
||
void action(payload);
|
||
};
|
||
const visibleError = localError ?? externalError;
|
||
|
||
return (
|
||
<section
|
||
className="platform-remap-surface mx-auto flex min-h-full w-full max-w-5xl flex-col overflow-visible lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain lg:pr-0.5"
|
||
aria-label="汪汪声浪轻配置编辑器"
|
||
>
|
||
{showBackButton && onBack ? (
|
||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
disabled={isBusy}
|
||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||
>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<ArrowLeft className="h-3.5 w-3.5" />
|
||
返回
|
||
</span>
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex min-h-0 flex-col lg:flex-1">
|
||
{headingTitle ? (
|
||
<div className="mb-3 shrink-0 sm:mb-5">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||
{headingTitle}
|
||
</h1>
|
||
<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>
|
||
) : null}
|
||
|
||
<div
|
||
className={`grid gap-3 lg:flex-1 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
|
||
>
|
||
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
|
||
<label className="block shrink-0">
|
||
<span className={FIELD_LABEL_CLASS}>作品标题</span>
|
||
<input
|
||
value={title}
|
||
disabled={isBusy}
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
|
||
maxLength={40}
|
||
aria-label="作品标题"
|
||
/>
|
||
</label>
|
||
|
||
<label className="block shrink-0">
|
||
<span className={FIELD_LABEL_CLASS}>简介</span>
|
||
<textarea
|
||
value={description}
|
||
disabled={isBusy}
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
|
||
maxLength={160}
|
||
placeholder=""
|
||
aria-label="简介"
|
||
/>
|
||
</label>
|
||
|
||
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
|
||
<label className="block">
|
||
<span className={FIELD_LABEL_CLASS}>难度预设</span>
|
||
<select
|
||
value={difficultyPreset}
|
||
disabled={isBusy}
|
||
onChange={(event) =>
|
||
setDifficultyPreset(
|
||
event.target.value as BarkBattleDifficultyPreset,
|
||
)
|
||
}
|
||
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-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
|
||
aria-label="难度预设"
|
||
>
|
||
{DIFFICULTY_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<label className="block shrink-0">
|
||
<span className={ACCENT_FIELD_LABEL_CLASS}>
|
||
主题/场景描述
|
||
</span>
|
||
<textarea
|
||
value={themeDescription}
|
||
disabled={isBusy}
|
||
onChange={(event) => setThemeDescription(event.target.value)}
|
||
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||
maxLength={240}
|
||
placeholder=""
|
||
aria-label="主题/场景描述"
|
||
/>
|
||
</label>
|
||
|
||
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
|
||
<label className="block">
|
||
<span className={FIELD_LABEL_CLASS}>玩家形象描述</span>
|
||
<textarea
|
||
value={playerImageDescription}
|
||
disabled={isBusy}
|
||
onChange={(event) => setPlayerImageDescription(event.target.value)}
|
||
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||
maxLength={220}
|
||
aria-label="玩家形象描述"
|
||
/>
|
||
</label>
|
||
|
||
<label className="block">
|
||
<span className={FIELD_LABEL_CLASS}>对手形象描述</span>
|
||
<textarea
|
||
value={opponentImageDescription}
|
||
disabled={isBusy}
|
||
onChange={(event) => setOpponentImageDescription(event.target.value)}
|
||
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||
maxLength={220}
|
||
aria-label="对手形象描述"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<label className="block shrink-0">
|
||
<span className={ACCENT_FIELD_LABEL_CLASS}>拟声词</span>
|
||
<textarea
|
||
value={onomatopoeiaText}
|
||
disabled={isBusy}
|
||
onChange={(event) => {
|
||
setIsOnomatopoeiaCustomized(true);
|
||
setOnomatopoeiaText(event.target.value);
|
||
}}
|
||
className="h-[6.5rem] min-h-[6.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-black leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||
maxLength={260}
|
||
aria-label="拟声词"
|
||
/>
|
||
</label>
|
||
|
||
{visibleError ? (
|
||
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
|
||
{visibleError}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<BarkBattlePreviewCard config={payload} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex shrink-0 flex-wrap justify-center gap-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:mt-4 lg:pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
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" />
|
||
) : (
|
||
<Play className="h-4 w-4" />
|
||
)}
|
||
<span>{isBusy ? '处理中' : '生成草稿'}</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|