Files
Genarrative/src/components/big-fish-result/BigFishResultView.tsx
2026-04-23 06:01:00 +08:00

528 lines
18 KiB
TypeScript

import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
Sparkles,
Waves,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import type {
BigFishAssetSlotResponse,
BigFishGameDraftResponse,
BigFishLevelBlueprintResponse,
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BigFishAssetStudioTarget =
| {
kind: 'level_main_image';
level: BigFishLevelBlueprintResponse;
}
| {
kind: 'level_motion';
level: BigFishLevelBlueprintResponse;
motionKey: 'idle_float' | 'move_swim';
}
| {
kind: 'stage_background';
};
type BigFishResultViewProps = {
session: BigFishSessionSnapshotResponse;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
onStartTestRun: () => void;
};
function findAssetSlot(
slots: BigFishAssetSlotResponse[],
assetKind: string,
level?: number,
motionKey?: string,
) {
return slots.find((slot) => {
if (slot.assetKind !== assetKind) {
return false;
}
if (level !== undefined && slot.level !== level) {
return false;
}
if (motionKey !== undefined && slot.motionKey !== motionKey) {
return false;
}
return true;
});
}
function assetReadyLabel(slot: BigFishAssetSlotResponse | undefined) {
if (slot?.status !== 'ready') {
return '待生成';
}
return isBigFishPlaceholderAsset(slot) ? '占位已生成' : '已生成';
}
function buildLevelAssetPreview(slot: BigFishAssetSlotResponse | undefined) {
if (slot?.assetUrl) {
return slot.assetUrl;
}
return null;
}
function isBigFishPlaceholderAsset(slot: BigFishAssetSlotResponse | undefined) {
return Boolean(slot?.assetUrl?.includes('/generated-big-fish/'));
}
function buildStudioAssetPreview(
slots: BigFishAssetSlotResponse[],
target: BigFishAssetStudioTarget,
) {
if (target.kind === 'stage_background') {
return buildLevelAssetPreview(findAssetSlot(slots, 'stage_background'));
}
if (target.kind === 'level_main_image') {
return buildLevelAssetPreview(
findAssetSlot(slots, 'level_main_image', target.level.level),
);
}
return buildLevelAssetPreview(
findAssetSlot(
slots,
'level_motion',
target.level.level,
target.motionKey,
),
);
}
function BigFishAssetStudioModal({
draft,
target,
previewUrl,
isBusy,
onClose,
onExecuteAction,
}: {
draft: BigFishGameDraftResponse;
target: BigFishAssetStudioTarget;
previewUrl?: string | null;
isBusy: boolean;
onClose: () => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
}) {
const title =
target.kind === 'stage_background'
? '场地背景工坊'
: target.kind === 'level_main_image'
? `Lv.${target.level.level} 主图工坊`
: `Lv.${target.level.level} 动作工坊`;
const prompt =
target.kind === 'stage_background'
? draft.background.backgroundPromptSeed
: target.kind === 'level_main_image'
? target.level.visualPromptSeed
: `${target.level.motionPromptSeed} / ${target.motionKey}`;
const execute = () => {
if (target.kind === 'stage_background') {
onExecuteAction({ action: 'big_fish_generate_stage_background' });
return;
}
if (target.kind === 'level_main_image') {
onExecuteAction({
action: 'big_fish_generate_level_main_image',
level: target.level.level,
});
return;
}
onExecuteAction({
action: 'big_fish_generate_level_motion',
level: target.level.level,
motionKey: target.motionKey,
});
};
return (
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-xl overflow-hidden rounded-[1.8rem]">
<div className="border-b border-[var(--platform-subpanel-border)] px-4 py-4">
<div className="text-lg font-black text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{target.kind === 'stage_background'
? draft.background.theme
: target.level.oneLineFantasy}
</div>
</div>
<div className="space-y-4 px-4 py-4">
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
PROMPT
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-strong)]">
{prompt}
</div>
</div>
<div className="flex aspect-[9/5] items-center justify-center overflow-hidden rounded-[1.4rem] border border-dashed border-cyan-300/50 bg-cyan-50/40 text-sm text-[var(--platform-text-base)]">
{previewUrl ? (
<ResolvedAssetImage
src={previewUrl}
alt={title}
className="h-full w-full object-cover"
/>
) : (
'AI 资产候选预览'
)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-[var(--platform-subpanel-border)] px-4 py-2 text-sm font-semibold text-[var(--platform-text-base)] disabled:opacity-45"
>
</button>
<button
type="button"
onClick={execute}
disabled={isBusy}
className="inline-flex items-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
</button>
</div>
</div>
</div>
);
}
function BigFishLevelCard({
level,
slots,
isBusy,
onOpenStudio,
}: {
level: BigFishLevelBlueprintResponse;
slots: BigFishAssetSlotResponse[];
isBusy: boolean;
onOpenStudio: (target: BigFishAssetStudioTarget) => void;
}) {
const mainImageSlot = findAssetSlot(
slots,
'level_main_image',
level.level,
);
const idleSlot = findAssetSlot(
slots,
'level_motion',
level.level,
'idle_float',
);
const moveSlot = findAssetSlot(
slots,
'level_motion',
level.level,
'move_swim',
);
const previewUrl = buildLevelAssetPreview(mainImageSlot);
return (
<article className="overflow-hidden rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-white/78">
<div className="flex gap-3 p-3">
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-[1.15rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.28),transparent_68%),linear-gradient(145deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))] text-white">
{previewUrl ? (
<ResolvedAssetImage
src={previewUrl}
alt={level.name}
className="h-full w-full object-cover"
/>
) : (
<Waves className="h-8 w-8 text-cyan-100/72" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-black tracking-[0.18em] text-cyan-700">
LV.{level.level}
</div>
<div className="mt-1 text-lg font-black text-[var(--platform-text-strong)]">
{level.name}
</div>
</div>
{level.isFinalLevel ? (
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs font-bold text-amber-700">
</span>
) : null}
</div>
<div className="mt-2 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
{level.oneLineFantasy}
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-[var(--platform-text-soft)]">
<span> {level.preyWindow.join('/') || '-'}</span>
<span> {level.threatWindow.join('/') || '-'}</span>
<span> {assetReadyLabel(mainImageSlot)}</span>
<span>
{[assetReadyLabel(idleSlot), assetReadyLabel(moveSlot)].join('/')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 border-t border-[var(--platform-subpanel-border)] p-3">
<button
type="button"
disabled={isBusy}
onClick={() => {
onOpenStudio({ kind: 'level_main_image', level });
}}
className="rounded-full bg-cyan-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-45"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
onOpenStudio({
kind: 'level_motion',
level,
motionKey: 'idle_float',
});
}}
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
onOpenStudio({
kind: 'level_motion',
level,
motionKey: 'move_swim',
});
}}
className="rounded-full border border-[var(--platform-subpanel-border)] px-3 py-2 text-xs font-bold text-[var(--platform-text-base)] disabled:opacity-45"
>
</button>
</div>
</article>
);
}
export function BigFishResultView({
session,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onStartTestRun,
}: BigFishResultViewProps) {
const [studioTarget, setStudioTarget] =
useState<BigFishAssetStudioTarget | null>(null);
const draft = session.draft;
const backgroundSlot = findAssetSlot(session.assetSlots, 'stage_background');
const backgroundPreviewUrl = buildLevelAssetPreview(backgroundSlot);
const blockers = useMemo(
() => session.assetCoverage.blockers.filter(Boolean),
[session.assetCoverage.blockers],
);
const studioPreviewUrl = useMemo(() => {
if (!studioTarget) {
return null;
}
return buildStudioAssetPreview(session.assetSlots, studioTarget);
}, [session.assetSlots, studioTarget]);
if (!draft) {
return (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
稿
</div>
</div>
);
}
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div className="relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={() => {
onStartTestRun();
}}
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<Play className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
onExecuteAction({ action: 'big_fish_publish_game' });
}}
className="inline-flex items-center gap-2 rounded-full bg-cyan-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<CheckCircle2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-6">
<div className="text-2xl font-black leading-tight sm:text-3xl">
{draft.title}
</div>
<div className="mt-2 max-w-2xl text-sm leading-6 text-cyan-50/76">
{draft.subtitle}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-cyan-50/78">
<span className="rounded-full bg-white/10 px-3 py-1">
{draft.coreFun}
</span>
<span className="rounded-full bg-white/10 px-3 py-1">
{draft.ecologyTheme}
</span>
<span className="rounded-full bg-white/10 px-3 py-1">
{draft.runtimeParams.levelCount}
</span>
</div>
</div>
{error ? (
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{error}
</div>
) : null}
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-h-0 overflow-y-auto pr-1">
<div className="grid gap-3 sm:grid-cols-2">
{draft.levels.map((level) => (
<BigFishLevelCard
key={level.level}
level={level}
slots={session.assetSlots}
isBusy={isBusy}
onOpenStudio={setStudioTarget}
/>
))}
</div>
</div>
<aside className="min-h-0 space-y-3 overflow-y-auto">
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-soft)]">
{assetReadyLabel(backgroundSlot)}
</div>
</div>
<ImagePlus className="h-5 w-5 text-cyan-600" />
</div>
<div className="mt-3 aspect-[9/16] overflow-hidden rounded-[1.2rem] bg-[radial-gradient(circle_at_center,rgba(34,211,238,0.2),transparent_62%),linear-gradient(180deg,rgba(8,47,73,0.88),rgba(15,23,42,0.94))]">
{backgroundPreviewUrl ? (
<ResolvedAssetImage
src={backgroundPreviewUrl}
alt={`${draft.background.theme} 场地背景`}
className="h-full w-full object-cover"
/>
) : null}
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setStudioTarget({ kind: 'stage_background' });
}}
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-full bg-cyan-600 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<Sparkles className="h-4 w-4" />
</button>
</div>
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 space-y-2 text-sm text-[var(--platform-text-base)]">
<div>
{session.assetCoverage.levelMainImageReadyCount}/
{session.assetCoverage.requiredLevelCount}
</div>
<div>
{session.assetCoverage.levelMotionReadyCount}/
{session.assetCoverage.requiredLevelCount * 2}
</div>
<div>
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
</div>
</div>
{blockers.length > 0 ? (
<div className="mt-3 space-y-1 text-xs leading-5 text-amber-700">
{blockers.slice(0, 4).map((blocker) => (
<div key={blocker}>{blocker}</div>
))}
</div>
) : (
<div className="mt-3 text-sm font-semibold text-emerald-600">
</div>
)}
</div>
</aside>
</div>
{studioTarget ? (
<BigFishAssetStudioModal
draft={draft}
target={studioTarget}
previewUrl={studioPreviewUrl}
isBusy={isBusy}
onClose={() => {
setStudioTarget(null);
}}
onExecuteAction={(payload) => {
onExecuteAction(payload);
setStudioTarget(null);
}}
/>
) : null}
</div>
);
}
export default BigFishResultView;