528 lines
18 KiB
TypeScript
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;
|