Files
Genarrative/src/components/match3d-creation/Match3DAgentWorkspace.tsx
2026-05-14 14:21:17 +08:00

547 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
CreateMatch3DSessionRequest,
ExecuteMatch3DActionRequest,
Match3DAgentSessionSnapshot,
SendMatch3DMessageRequest,
} from '../../../packages/shared/src/contracts/match3dAgent';
type Match3DAgentWorkspaceProps = {
session: Match3DAgentSessionSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage?: (payload: SendMatch3DMessageRequest) => void;
onExecuteAction: (payload: ExecuteMatch3DActionRequest) => void;
onCreateFromForm?: (payload: CreateMatch3DSessionRequest) => void;
initialFormPayload?: CreateMatch3DSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
};
type Match3DFormState = {
themeText: string;
difficultyOptionId: Match3DDifficultyOptionId;
assetStyleId: Match3DAssetStyleOptionId;
customAssetStylePrompt: string;
};
const EMPTY_FORM_STATE: Match3DFormState = {
themeText: '',
difficultyOptionId: 'standard',
assetStyleId: 'flat-icon',
customAssetStylePrompt: '',
};
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
const MATCH3D_DIFFICULTY_OPTIONS = [
{ id: 'easy', label: '轻松', clearCount: 8, difficulty: 2 },
{ id: 'standard', label: '标准', clearCount: 12, difficulty: 4 },
{ id: 'advanced', label: '进阶', clearCount: 16, difficulty: 6 },
{ id: 'hardcore', label: '硬核', clearCount: 21, difficulty: 8 },
] as const;
type Match3DDifficultyOptionId =
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
const MATCH3D_ASSET_STYLE_OPTIONS = [
{
id: 'flat-icon',
label: '扁平图标',
imageSrc: '/match3d-style-references/flat-icon.png',
prompt:
'干净扁平的2D游戏道具图标风格正面视角色块清楚边缘硬朗。',
},
{
id: 'cel-cartoon',
label: '赛璐璐卡通',
imageSrc: '/match3d-style-references/cel-cartoon.png',
prompt:
'明亮赛璐璐卡通2D游戏道具风格清晰线稿硬边阴影饱和配色轮廓醒目。',
},
{
id: 'pixel-retro',
label: '像素',
imageSrc: '/match3d-style-references/pixel-retro.png',
prompt:
'像素2D游戏道具sprite风格',
},
{
id: 'watercolor',
label: '手绘水彩',
imageSrc: '/match3d-style-references/watercolor.png',
prompt:
'手绘水彩2D道具素材风格',
},
{
id: 'sticker-outline',
label: '贴纸描边',
imageSrc: '/match3d-style-references/sticker-outline.png',
prompt:
'贴纸描边2D游戏道具素材风格粗白边与深色外轮廓',
},
{
id: 'painterly-icon',
label: '厚涂图标',
imageSrc: '/match3d-style-references/painterly-icon.png',
prompt:
'厚涂2D游戏道具图标风格笔触细腻体积光影明确中心构图保持图标级清晰剪影。',
},
{
id: 'custom',
label: '自定义',
imageSrc: null,
prompt: '',
},
] as const;
type Match3DAssetStyleOptionId =
(typeof MATCH3D_ASSET_STYLE_OPTIONS)[number]['id'];
function normalizeDifficulty(value: number) {
return Math.max(1, Math.min(10, Math.round(value)));
}
function resolveDifficultyOptionId(
difficulty: number | null | undefined,
clearCount: number | null | undefined,
): Match3DDifficultyOptionId {
const clearCountMatchedOption = MATCH3D_DIFFICULTY_OPTIONS.find(
(option) => option.clearCount === clearCount,
);
if (clearCountMatchedOption) {
return clearCountMatchedOption.id;
}
if (typeof difficulty !== 'number' || !Number.isFinite(difficulty)) {
return 'standard';
}
const normalizedDifficulty = normalizeDifficulty(Number(difficulty));
return MATCH3D_DIFFICULTY_OPTIONS.reduce(
(nearestOption, option) =>
Math.abs(option.difficulty - normalizedDifficulty) <
Math.abs(nearestOption.difficulty - normalizedDifficulty)
? option
: nearestOption,
MATCH3D_DIFFICULTY_OPTIONS[1],
).id;
}
function getDifficultyOption(optionId: Match3DDifficultyOptionId) {
return (
MATCH3D_DIFFICULTY_OPTIONS.find((option) => option.id === optionId) ??
MATCH3D_DIFFICULTY_OPTIONS[1]
);
}
function getAssetStyleOption(optionId: Match3DAssetStyleOptionId) {
return (
MATCH3D_ASSET_STYLE_OPTIONS.find((option) => option.id === optionId) ??
MATCH3D_ASSET_STYLE_OPTIONS[0]
);
}
function resolveAssetStyleOptionId(
assetStyleId: string | null | undefined,
assetStylePrompt: string | null | undefined,
): Match3DAssetStyleOptionId {
const matchedOption = MATCH3D_ASSET_STYLE_OPTIONS.find(
(option) => option.id === assetStyleId,
);
if (matchedOption) {
return matchedOption.id;
}
return assetStylePrompt?.trim() ? 'custom' : 'flat-icon';
}
function resolveInitialFormState(
session: Match3DAgentSessionSnapshot | null,
initialFormPayload: CreateMatch3DSessionRequest | null = null,
): Match3DFormState {
const config = session?.config;
const themeText =
initialFormPayload?.themeText?.trim() ||
config?.themeText?.trim() ||
session?.anchorPack.theme.value?.trim() ||
initialFormPayload?.seedText?.trim() ||
'';
const clearCount =
initialFormPayload?.clearCount ?? config?.clearCount ?? null;
const difficulty =
initialFormPayload?.difficulty ?? config?.difficulty ?? null;
const assetStyleId =
initialFormPayload?.assetStyleId ?? config?.assetStyleId ?? null;
const assetStylePrompt =
initialFormPayload?.assetStylePrompt ?? config?.assetStylePrompt ?? '';
return {
...EMPTY_FORM_STATE,
themeText,
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
customAssetStylePrompt: assetStylePrompt,
};
}
/**
* 抓大鹅创作入口已从固定 Agent 追问改成表单式。
* 组件名保留为 Match3DAgentWorkspace兼容现有路由、草稿恢复和父层分流。
*/
export function Match3DAgentWorkspace({
session,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onCreateFromForm,
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
}: Match3DAgentWorkspaceProps) {
const [formState, setFormState] = useState<Match3DFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false);
const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState('');
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const nextInitialFormKey =
session?.sessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setIsCustomStylePanelOpen(false);
setDraftCustomStylePrompt('');
}, [initialFormPayload, session]);
const themeText = formState.themeText.trim();
const selectedDifficultyOption = getDifficultyOption(
formState.difficultyOptionId,
);
const selectedAssetStyleOption = getAssetStyleOption(formState.assetStyleId);
const assetStylePrompt =
formState.assetStyleId === 'custom'
? formState.customAssetStylePrompt.trim()
: selectedAssetStyleOption.prompt;
const assetStyleLabel =
formState.assetStyleId === 'custom'
? '自定义风格'
: selectedAssetStyleOption.label;
const canSubmit = Boolean(
themeText &&
!isBusy &&
(formState.assetStyleId !== 'custom' ||
formState.customAssetStylePrompt.trim()),
);
const formPayload = useMemo<CreateMatch3DSessionRequest>(
() => ({
seedText: themeText
? `${themeText}题材,消除${selectedDifficultyOption.clearCount}次,难度${selectedDifficultyOption.difficulty}`
: themeText,
themeText,
referenceImageSrc: null,
clearCount: selectedDifficultyOption.clearCount,
difficulty: selectedDifficultyOption.difficulty,
assetStyleId: formState.assetStyleId,
assetStyleLabel,
assetStylePrompt,
generateClickSound: false,
}),
[
assetStyleLabel,
assetStylePrompt,
formState.assetStyleId,
selectedDifficultyOption,
themeText,
],
);
const openCustomStylePanel = () => {
setDraftCustomStylePrompt(formState.customAssetStylePrompt);
setIsCustomStylePanelOpen(true);
};
const applyCustomStylePrompt = () => {
setFormState((current) => ({
...current,
assetStyleId: 'custom',
customAssetStylePrompt: draftCustomStylePrompt.trim(),
}));
setIsCustomStylePanelOpen(false);
};
const submitForm = () => {
if (!canSubmit) {
return;
}
if (onCreateFromForm) {
onCreateFromForm(formPayload);
return;
}
if (session) {
onExecuteAction({
action: 'match3d_compile_draft',
generateClickSound: false,
});
}
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
{showBackButton ? (
<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' : ''}`}
>
</button>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
{title ? (
<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">
{title}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
</div>
) : null}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
>
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<textarea
value={formState.themeText}
disabled={isBusy}
rows={5}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
themeText: event.target.value,
}))
}
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] 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 sm:min-h-[9rem] lg:min-h-[14rem]"
aria-label="想做一个什么题材的抓大鹅?"
/>
</label>
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<div className="min-h-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/52 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
2D素材风格
</div>
<div
className="flex snap-x gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
aria-label="2D素材风格"
>
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => {
const selected = formState.assetStyleId === option.id;
const isCustom = option.id === 'custom';
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() => {
if (isCustom) {
openCustomStylePanel();
return;
}
setFormState((current) => ({
...current,
assetStyleId: option.id,
}));
}}
className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 sm:h-[5.45rem] sm:w-[6.4rem] ${
selected
? 'border-rose-300 bg-white shadow-[0_8px_18px_rgba(190,18,60,0.10)] ring-2 ring-rose-100'
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-rose-200 hover:bg-white/95'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
aria-label={option.label}
>
{option.imageSrc ? (
<img
src={option.imageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
) : (
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
)}
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.02)_0%,rgba(255,255,255,0.18)_44%,rgba(255,255,255,0.82)_100%)]" />
{selected ? (
<span className="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
) : null}
{isCustom ? (
<span className="absolute inset-0 flex items-center justify-center text-rose-500">
<span className="grid h-8 w-8 place-items-center rounded-full bg-white/82 shadow-[0_6px_18px_rgba(190,18,60,0.12)]">
<Plus className="h-5 w-5" />
</span>
</span>
) : null}
<span
className={`absolute inset-x-2 bottom-1.5 truncate rounded-full px-1.5 py-0.5 text-center text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.10)] ${
selected
? 'bg-rose-50/95 text-rose-700'
: 'bg-white/88 text-[var(--platform-text-strong)]'
}`}
>
{option.label}
</span>
</button>
);
})}
</div>
</div>
<div className="shrink-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/44 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 lg:grid-cols-2">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected = formState.difficultyOptionId === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() =>
setFormState((current) => ({
...current,
difficultyOptionId: option.id,
}))
}
className={`min-h-10 rounded-[0.85rem] border px-2 text-sm font-black transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 sm:min-h-11 ${
selected
? 'border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]'
: 'border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
{option.label}
</button>
);
})}
</div>
</div>
</div>
</div>
<div className="mt-2 shrink-0 space-y-3">
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={!canSubmit}
onClick={submitForm}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? '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" /> : null}
{session ? (
<Sparkles className="h-4 w-4" />
) : (
<WandSparkles className="h-4 w-4" />
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
10
</span>
</span>
</button>
</div>
{isCustomStylePanelOpen ? (
<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-custom-style-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div className="flex items-center justify-between gap-3">
<div
id="match3d-custom-style-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<button
type="button"
aria-label="关闭自定义风格"
onClick={() => setIsCustomStylePanelOpen(false)}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<textarea
value={draftCustomStylePrompt}
onChange={(event) =>
setDraftCustomStylePrompt(event.target.value)
}
rows={4}
className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] 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"
aria-label="自定义2D素材风格描述"
/>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsCustomStylePanelOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!draftCustomStylePrompt.trim()}
onClick={applyCustomStylePrompt}
className={`platform-button platform-button--primary justify-center ${!draftCustomStylePrompt.trim() ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
export default Match3DAgentWorkspace;