@@ -3,6 +3,7 @@ import {
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
@@ -24,6 +25,16 @@ import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
type PendingGeneratedEntity = {
|
||||
id: string;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
title: string;
|
||||
progress: number;
|
||||
phaseLabel: string;
|
||||
};
|
||||
|
||||
type RecentGeneratedIds = Record<'playable' | 'story' | 'landmark', string[]>;
|
||||
|
||||
interface CustomWorldEntityCatalogProps {
|
||||
profile: CustomWorldProfile;
|
||||
previewCharacters: Character[];
|
||||
@@ -35,6 +46,9 @@ interface CustomWorldEntityCatalogProps {
|
||||
onDeleteLandmarks?: (ids: string[]) => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
createActionDisabled?: boolean;
|
||||
pendingGeneratedEntity?: PendingGeneratedEntity | null;
|
||||
recentGeneratedIds?: RecentGeneratedIds;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -48,11 +62,13 @@ const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||||
function Section({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
@@ -72,7 +88,10 @@ function Section({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions}
|
||||
<div className="flex items-center gap-2">
|
||||
{badge}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">{children}</div>
|
||||
</div>
|
||||
@@ -83,10 +102,12 @@ function SmallButton({
|
||||
onClick,
|
||||
children,
|
||||
tone = 'default',
|
||||
disabled = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
tone?: 'default' | 'sky' | 'rose';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'sky'
|
||||
@@ -99,7 +120,8 @@ function SmallButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName}`}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -161,23 +183,120 @@ function EmptyState({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NewBadge() {
|
||||
return (
|
||||
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
|
||||
新
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingEntityCard({
|
||||
title,
|
||||
phaseLabel,
|
||||
progress,
|
||||
}: {
|
||||
title: string;
|
||||
phaseLabel: string;
|
||||
progress: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-sky-300/18 bg-sky-500/10 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
<div className="mt-1 text-xs leading-6 text-sky-50/90">
|
||||
{phaseLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-sky-300/20 bg-black/20 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
{Math.round(progress)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_100%)] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogCard({
|
||||
title,
|
||||
description,
|
||||
media,
|
||||
badge,
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onClick,
|
||||
layout = 'stacked',
|
||||
mediaClassName,
|
||||
disabled = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
media: ReactNode;
|
||||
badge?: ReactNode;
|
||||
isSelectionMode: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
layout?: 'stacked' | 'compact';
|
||||
mediaClassName?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const selectionBadge = isSelectionMode ? (
|
||||
<div
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
isSelected
|
||||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '已选' : '选择'}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (layout === 'compact') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: disabled
|
||||
? 'border-white/10 bg-black/20'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`shrink-0 overflow-hidden rounded-[1rem] border border-white/8 bg-black/25 ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{badge}
|
||||
{selectionBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm leading-5 text-zinc-300">
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -192,24 +311,19 @@ function CatalogCard({
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25">
|
||||
<div
|
||||
className={`overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25 ${mediaClassName ?? ''}`}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-base font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
{isSelectionMode ? (
|
||||
<div
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
isSelected
|
||||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '已选' : '选择'}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
{badge}
|
||||
{selectionBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-zinc-300">
|
||||
{description || '暂无描述'}
|
||||
@@ -276,7 +390,7 @@ function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
|
||||
return [
|
||||
{
|
||||
id: 'world-hook',
|
||||
label: '世界核心',
|
||||
label: '世界一句话',
|
||||
value:
|
||||
creatorIntent?.worldHook ||
|
||||
profile.anchorPack?.worldSummary ||
|
||||
@@ -384,8 +498,16 @@ export function CustomWorldEntityCatalog({
|
||||
onDeleteLandmarks,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
createActionDisabled = false,
|
||||
pendingGeneratedEntity = null,
|
||||
recentGeneratedIds = {
|
||||
playable: [],
|
||||
story: [],
|
||||
landmark: [],
|
||||
},
|
||||
readOnly = false,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(
|
||||
null,
|
||||
@@ -423,6 +545,18 @@ export function CustomWorldEntityCatalog({
|
||||
),
|
||||
[previewCharacters, profile.playableNpcs],
|
||||
);
|
||||
const recentPlayableIdSet = useMemo(
|
||||
() => new Set(recentGeneratedIds.playable),
|
||||
[recentGeneratedIds.playable],
|
||||
);
|
||||
const recentStoryIdSet = useMemo(
|
||||
() => new Set(recentGeneratedIds.story),
|
||||
[recentGeneratedIds.story],
|
||||
);
|
||||
const recentLandmarkIdSet = useMemo(
|
||||
() => new Set(recentGeneratedIds.landmark),
|
||||
[recentGeneratedIds.landmark],
|
||||
);
|
||||
|
||||
const filteredPlayable = useMemo(
|
||||
() =>
|
||||
@@ -460,6 +594,16 @@ export function CustomWorldEntityCatalog({
|
||||
() => buildStructuredFoundationEntries(profile),
|
||||
[profile],
|
||||
);
|
||||
const structuredFoundationSourceText = useMemo(
|
||||
() =>
|
||||
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
|
||||
profile.settingText.trim(),
|
||||
[profile.creatorIntent, profile.settingText],
|
||||
);
|
||||
const normalizedCreatorIntent = useMemo(
|
||||
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneEntry = {
|
||||
id: 'custom-world-opening-scene',
|
||||
@@ -477,7 +621,13 @@ export function CustomWorldEntityCatalog({
|
||||
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
}));
|
||||
const allEntries = [openingSceneEntry, ...landmarkEntries];
|
||||
const recentEntries = landmarkEntries.filter((entry) =>
|
||||
recentLandmarkIdSet.has(entry.id),
|
||||
);
|
||||
const restEntries = landmarkEntries.filter(
|
||||
(entry) => !recentLandmarkIdSet.has(entry.id),
|
||||
);
|
||||
const allEntries = [...recentEntries, openingSceneEntry, ...restEntries];
|
||||
|
||||
if (!deferredSearch) {
|
||||
return allEntries;
|
||||
@@ -492,32 +642,35 @@ export function CustomWorldEntityCatalog({
|
||||
landmarkById,
|
||||
landmarkImageById,
|
||||
profile,
|
||||
recentLandmarkIdSet,
|
||||
resolvedCampImageSrc,
|
||||
resolvedCampScene,
|
||||
storyNpcById,
|
||||
]);
|
||||
|
||||
const creatorIntentSummary = useMemo(
|
||||
() =>
|
||||
buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedCharacterNames = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
profile.creatorIntent?.keyCharacters
|
||||
normalizedCreatorIntent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
),
|
||||
[profile.creatorIntent],
|
||||
[normalizedCreatorIntent],
|
||||
);
|
||||
|
||||
const counts = {
|
||||
world: 1,
|
||||
playable: profile.playableNpcs.length,
|
||||
story: profile.storyNpcs.length,
|
||||
landmarks: profile.landmarks.length + 1,
|
||||
playable:
|
||||
profile.playableNpcs.length +
|
||||
(pendingGeneratedEntity?.kind === 'playable' ? 1 : 0),
|
||||
story:
|
||||
profile.storyNpcs.length +
|
||||
(pendingGeneratedEntity?.kind === 'story' ? 1 : 0),
|
||||
landmarks:
|
||||
profile.landmarks.length +
|
||||
1 +
|
||||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||||
} satisfies Record<ResultTab, number>;
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
@@ -532,6 +685,16 @@ export function CustomWorldEntityCatalog({
|
||||
}
|
||||
}, [activeTab, bulkDeleteMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({ top: 0, behavior: 'auto' });
|
||||
return;
|
||||
}
|
||||
container.scrollTop = 0;
|
||||
}, [activeTab]);
|
||||
|
||||
const removePlayable = (id: string, name: string) => {
|
||||
if (profile.playableNpcs.length <= 1) {
|
||||
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
|
||||
@@ -584,7 +747,10 @@ export function CustomWorldEntityCatalog({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide"
|
||||
>
|
||||
<div className="px-1 pb-1 text-center">
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
@@ -638,7 +804,11 @@ export function CustomWorldEntityCatalog({
|
||||
) : (
|
||||
<>
|
||||
{!readOnly && createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">
|
||||
<SmallButton
|
||||
onClick={onCreateAction}
|
||||
tone="sky"
|
||||
disabled={createActionDisabled}
|
||||
>
|
||||
{createActionLabel}
|
||||
</SmallButton>
|
||||
) : null}
|
||||
@@ -662,6 +832,29 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'world' ? (
|
||||
<>
|
||||
<Section title="档案规模">
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.playableNpcs.length}
|
||||
</div>
|
||||
<div>可扮演角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.storyNpcs.length}
|
||||
</div>
|
||||
<div>场景角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.landmarks.length + 1}
|
||||
</div>
|
||||
<div>场景</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
@@ -694,10 +887,29 @@ export function CustomWorldEntityCatalog({
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="原始设定"
|
||||
subtitle="把开局最关键的 6 个原始锚点拆开看,后续精修会更顺。"
|
||||
title="基本设定"
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
解析字段
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{structuredFoundationEntries.map((entry) => (
|
||||
<div
|
||||
@@ -713,45 +925,16 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{profile.settingText ? (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-7 text-zinc-200">
|
||||
{profile.settingText}
|
||||
{structuredFoundationSourceText ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
锚点原文
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-200">
|
||||
{structuredFoundationSourceText}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{creatorIntentSummary && creatorIntentSummary !== profile.settingText ? (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="档案规模"
|
||||
subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.playableNpcs.length}
|
||||
</div>
|
||||
<div>可扮演角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.storyNpcs.length}
|
||||
</div>
|
||||
<div>场景角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.landmarks.length + 1}
|
||||
</div>
|
||||
<div>场景</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-3 text-sm leading-6 text-sky-50/90">
|
||||
自定义世界不再预生成物品档案。进入世界后的交易、掉落和初始装备会按当前世界主题即时生成。
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
@@ -759,6 +942,13 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3">
|
||||
{pendingGeneratedEntity?.kind === 'playable' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||||
progress={pendingGeneratedEntity.progress}
|
||||
/>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{readOnly
|
||||
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
|
||||
@@ -776,6 +966,7 @@ export function CustomWorldEntityCatalog({
|
||||
<Section
|
||||
title={role.name}
|
||||
subtitle={role.title}
|
||||
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
@@ -927,6 +1118,13 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'story' ? (
|
||||
<div className="space-y-3">
|
||||
{pendingGeneratedEntity?.kind === 'story' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||||
progress={pendingGeneratedEntity.progress}
|
||||
/>
|
||||
) : null}
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
) : (
|
||||
@@ -935,8 +1133,11 @@ export function CustomWorldEntityCatalog({
|
||||
<CatalogCard
|
||||
title={npc.name}
|
||||
description={npc.description}
|
||||
badge={recentStoryIdSet.has(npc.id) ? <NewBadge /> : null}
|
||||
isSelectionMode={isBulkDeleteMode}
|
||||
isSelected={selectedBulkIds.includes(npc.id)}
|
||||
layout="compact"
|
||||
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(npc.id)
|
||||
@@ -957,8 +1158,9 @@ export function CustomWorldEntityCatalog({
|
||||
npc={npc}
|
||||
profile={profile}
|
||||
visual={npc.visual}
|
||||
className="aspect-square"
|
||||
scale={2.18}
|
||||
className="h-full w-full"
|
||||
contentClassName="min-h-0 p-2"
|
||||
scale={1.82}
|
||||
preferImageSrc
|
||||
/>
|
||||
}
|
||||
@@ -971,6 +1173,13 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<div className="space-y-3">
|
||||
{pendingGeneratedEntity?.kind === 'landmark' ? (
|
||||
<PendingEntityCard
|
||||
title={pendingGeneratedEntity.title}
|
||||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||||
progress={pendingGeneratedEntity.progress}
|
||||
/>
|
||||
) : null}
|
||||
{filteredSceneEntries.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
@@ -983,6 +1192,11 @@ export function CustomWorldEntityCatalog({
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
}
|
||||
badge={
|
||||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||||
<NewBadge />
|
||||
) : null
|
||||
}
|
||||
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
|
||||
isSelected={
|
||||
scene.kind === 'landmark' &&
|
||||
@@ -990,7 +1204,7 @@ export function CustomWorldEntityCatalog({
|
||||
}
|
||||
onClick={() =>
|
||||
scene.kind === 'camp'
|
||||
? onEditTarget({ kind: 'world' })
|
||||
? onEditTarget({ kind: 'camp' })
|
||||
: isBulkDeleteMode
|
||||
? toggleBulkSelected(scene.id)
|
||||
: onEditTarget({
|
||||
|
||||
181
src/components/CustomWorldEntityEditorModal.test.tsx
Normal file
181
src/components/CustomWorldEntityEditorModal.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div>角色预览</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
||||
<div>{npc.name}</div>
|
||||
),
|
||||
CustomWorldNpcVisualEditor: () => <div>预设形象编辑器</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
|
||||
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
|
||||
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
|
||||
visualPromptText: '自动生成的形象提示词',
|
||||
animationPromptText: '自动生成的动作提示词',
|
||||
}),
|
||||
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
|
||||
generateCharacterVisualCandidates: vi.fn(),
|
||||
publishCharacterVisualAsset: vi.fn(),
|
||||
generateCharacterAnimationDraft: vi.fn(),
|
||||
publishCharacterAnimationAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: 6,
|
||||
teaser: '表层来意',
|
||||
content: '表层来意内容',
|
||||
contextSnippet: '表层来意摘要',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 12,
|
||||
teaser: '旧事裂痕',
|
||||
content: '旧事裂痕内容',
|
||||
contextSnippet: '旧事裂痕摘要',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 18,
|
||||
teaser: '隐藏执念',
|
||||
content: '隐藏执念内容',
|
||||
contextSnippet: '隐藏执念摘要',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: 24,
|
||||
teaser: '最终底牌',
|
||||
content: '最终底牌内容',
|
||||
contextSnippet: '最终底牌摘要',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: '同行者',
|
||||
role: '协作战力',
|
||||
description: `${name}的定位描述`,
|
||||
backstory: `${name}的背景`,
|
||||
personality: `${name}的性格`,
|
||||
motivation: `${name}的动机`,
|
||||
combatStyle: `${name}的战斗风格`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['关系钩子'],
|
||||
relations: [],
|
||||
tags: ['测试'],
|
||||
backstoryReveal: createBackstoryReveal(),
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
templateCharacterId: 'knight-female-1',
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryRole(id: string, name: string): CustomWorldNpc {
|
||||
return {
|
||||
...createPlayableRole(id, name),
|
||||
initialAffinity: 6,
|
||||
visual: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createProfile(): CustomWorldProfile {
|
||||
return {
|
||||
id: 'world-1',
|
||||
settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '旧航道与沉钟回响',
|
||||
summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。',
|
||||
tone: '压抑、潮湿、带着未解旧伤。',
|
||||
playerGoal: '找到能让群岛重新稳定的关键节点。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守潮盟', '沉钟会'],
|
||||
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
|
||||
attributeSchema: {},
|
||||
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
|
||||
storyNpcs: [createStoryRole('story-1', '顾潮音')],
|
||||
items: [],
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
},
|
||||
landmarks: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
ownedSettingLayers: null,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
test('playable角色打开AI工坊后不会自动关闭', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
|
||||
onClose={handleClose}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI角色形象生成')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('场景角色打开AI工坊后不会自动关闭', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
|
||||
onClose={handleClose}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI角色形象生成')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -284,6 +284,12 @@ function ActionButton({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
tone === 'sky'
|
||||
@@ -301,6 +307,7 @@ export function CustomWorldNpcPortrait({
|
||||
profile,
|
||||
visual,
|
||||
className = '',
|
||||
contentClassName = 'min-h-[7rem] p-3',
|
||||
scale = 2.05,
|
||||
preferImageSrc = false,
|
||||
}: {
|
||||
@@ -308,6 +315,7 @@ export function CustomWorldNpcPortrait({
|
||||
profile?: CustomWorldProfile | null;
|
||||
visual?: CustomWorldNpcVisual;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
scale?: number;
|
||||
preferImageSrc?: boolean;
|
||||
}) {
|
||||
@@ -321,7 +329,9 @@ export function CustomWorldNpcPortrait({
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
|
||||
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
|
||||
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
|
||||
<div
|
||||
className={`relative flex h-full items-center justify-center ${contentClassName}`}
|
||||
>
|
||||
{preferredImageSrc ? (
|
||||
<img
|
||||
src={preferredImageSrc}
|
||||
|
||||
244
src/components/CustomWorldResultView.test.tsx
Normal file
244
src/components/CustomWorldResultView.test.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
|
||||
import { CustomWorldResultView } from './CustomWorldResultView';
|
||||
|
||||
vi.mock('../services/aiService', () => ({
|
||||
generateCustomWorldPlayableNpc: vi.fn(),
|
||||
generateCustomWorldStoryNpc: vi.fn(),
|
||||
generateCustomWorldLandmark: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div>角色预览</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
||||
<div>{npc.name}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldEntityEditorModal', () => ({
|
||||
CustomWorldEntityEditorModal: () => null,
|
||||
}));
|
||||
|
||||
async function loadAiService() {
|
||||
return import('../services/aiService');
|
||||
}
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
chapters: [
|
||||
{
|
||||
id: 'surface',
|
||||
title: '表层来意',
|
||||
affinityRequired: 6,
|
||||
teaser: '表层来意',
|
||||
content: '表层来意内容',
|
||||
contextSnippet: '表层来意摘要',
|
||||
},
|
||||
{
|
||||
id: 'scar',
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 12,
|
||||
teaser: '旧事裂痕',
|
||||
content: '旧事裂痕内容',
|
||||
contextSnippet: '旧事裂痕摘要',
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 18,
|
||||
teaser: '隐藏执念',
|
||||
content: '隐藏执念内容',
|
||||
contextSnippet: '隐藏执念摘要',
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
title: '最终底牌',
|
||||
affinityRequired: 24,
|
||||
teaser: '最终底牌',
|
||||
content: '最终底牌内容',
|
||||
contextSnippet: '最终底牌摘要',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: '同行者',
|
||||
role: '协作战力',
|
||||
description: `${name}的定位描述`,
|
||||
backstory: `${name}的背景`,
|
||||
personality: `${name}的性格`,
|
||||
motivation: `${name}的动机`,
|
||||
combatStyle: `${name}的战斗风格`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['关系钩子'],
|
||||
relations: [],
|
||||
tags: ['测试'],
|
||||
backstoryReveal: createBackstoryReveal(),
|
||||
skills: [
|
||||
{
|
||||
id: `${id}-skill-1`,
|
||||
name: '技能一',
|
||||
summary: '技能说明一',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: `${id}-skill-2`,
|
||||
name: '技能二',
|
||||
summary: '技能说明二',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: `${id}-skill-3`,
|
||||
name: '技能三',
|
||||
summary: '技能说明三',
|
||||
style: '爆发终结',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: `${id}-item-1`,
|
||||
name: '物品一',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明一',
|
||||
tags: ['测试'],
|
||||
},
|
||||
{
|
||||
id: `${id}-item-2`,
|
||||
name: '物品二',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品说明二',
|
||||
tags: ['测试'],
|
||||
},
|
||||
{
|
||||
id: `${id}-item-3`,
|
||||
name: '物品三',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '物品说明三',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const baseProfile = {
|
||||
id: 'world-1',
|
||||
settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '旧航道与沉钟回响',
|
||||
summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。',
|
||||
tone: '压抑、潮湿、带着未解旧伤。',
|
||||
playerGoal: '找到能让群岛重新稳定的关键节点。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守潮盟', '沉钟会'],
|
||||
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
|
||||
attributeSchema: {},
|
||||
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
|
||||
storyNpcs: [
|
||||
{
|
||||
...createPlayableRole('story-1', '顾潮音'),
|
||||
initialAffinity: 6,
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '沉钟栈桥',
|
||||
description: '旧钟与潮声常年相撞的码头栈桥。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
ownedSettingLayers: null,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} as unknown as CustomWorldProfile;
|
||||
|
||||
function ResultViewHarness() {
|
||||
const [profile, setProfile] = useState(baseProfile);
|
||||
|
||||
return (
|
||||
<CustomWorldResultView
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={setProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
|
||||
const aiService = await loadAiService();
|
||||
const user = userEvent.setup();
|
||||
|
||||
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null;
|
||||
vi.mocked(aiService.generateCustomWorldPlayableNpc).mockImplementation(
|
||||
() =>
|
||||
new Promise<CustomWorldPlayableNpc>((resolve) => {
|
||||
resolveGeneration = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /可扮演角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: '新增可扮演角色' }));
|
||||
|
||||
expect(screen.getByText('新可扮演角色')).toBeTruthy();
|
||||
expect(screen.getByText('正在整理世界上下文')).toBeTruthy();
|
||||
|
||||
const createButton = screen.getByRole('button', { name: '新增可扮演角色' });
|
||||
expect((createButton as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const finishGeneration = resolveGeneration;
|
||||
if (!finishGeneration) {
|
||||
throw new Error('expected pending playable generation resolver');
|
||||
}
|
||||
|
||||
(finishGeneration as (value: CustomWorldPlayableNpc) => void)(
|
||||
createPlayableRole('playable-2', '云止'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('云止')).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('新可扮演角色')).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { Character, CustomWorldProfile } from '../types';
|
||||
import {
|
||||
generateCustomWorldLandmark,
|
||||
generateCustomWorldPlayableNpc,
|
||||
generateCustomWorldStoryNpc,
|
||||
} from '../services/aiService';
|
||||
import {
|
||||
Character,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
@@ -23,15 +34,28 @@ interface CustomWorldResultViewProps {
|
||||
onEditSetting?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onSave?: () => void;
|
||||
onEnterWorld?: () => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
readOnly?: boolean;
|
||||
backLabel?: string;
|
||||
editActionLabel?: string;
|
||||
regenerateActionLabel?: string;
|
||||
saveActionLabel?: string;
|
||||
enterWorldActionLabel?: string;
|
||||
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
|
||||
}
|
||||
|
||||
type EntityGenerationKind = 'playable' | 'story' | 'landmark';
|
||||
|
||||
type PendingGeneratedEntity = {
|
||||
id: string;
|
||||
kind: EntityGenerationKind;
|
||||
title: string;
|
||||
progress: number;
|
||||
phaseLabel: string;
|
||||
};
|
||||
|
||||
type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
|
||||
|
||||
function SmallButton({
|
||||
onClick,
|
||||
children,
|
||||
@@ -75,6 +99,66 @@ function getCreateLabelByTab(activeTab: ResultTab) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function createPendingGeneratedEntity(
|
||||
kind: EntityGenerationKind,
|
||||
): PendingGeneratedEntity {
|
||||
return {
|
||||
id: `pending-${kind}-${Date.now()}`,
|
||||
kind,
|
||||
title:
|
||||
kind === 'playable'
|
||||
? '新可扮演角色'
|
||||
: kind === 'story'
|
||||
? '新场景角色'
|
||||
: '新场景',
|
||||
progress: 8,
|
||||
phaseLabel: '正在整理世界上下文',
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePendingPhaseLabel(
|
||||
kind: EntityGenerationKind,
|
||||
progress: number,
|
||||
) {
|
||||
if (progress < 28) {
|
||||
return '正在整理世界上下文';
|
||||
}
|
||||
if (progress < 72) {
|
||||
return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构';
|
||||
}
|
||||
return '正在回写结果';
|
||||
}
|
||||
|
||||
function prependPlayableNpc(
|
||||
profile: CustomWorldProfile,
|
||||
npc: CustomWorldPlayableNpc,
|
||||
) {
|
||||
return {
|
||||
...profile,
|
||||
playableNpcs: [npc, ...profile.playableNpcs],
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) {
|
||||
return {
|
||||
...profile,
|
||||
storyNpcs: [npc, ...profile.storyNpcs],
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function prependLandmark(
|
||||
profile: CustomWorldProfile,
|
||||
landmark: CustomWorldLandmark,
|
||||
) {
|
||||
return {
|
||||
...profile,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: [landmark, ...profile.landmarks],
|
||||
storyNpcs: profile.storyNpcs,
|
||||
}),
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function removeStoryNpcsFromProfile(
|
||||
profile: CustomWorldProfile,
|
||||
ids: string[],
|
||||
@@ -129,17 +213,31 @@ export function CustomWorldResultView({
|
||||
onEditSetting,
|
||||
onRegenerate: triggerRegenerate,
|
||||
onContinueExpand,
|
||||
onSave,
|
||||
onEnterWorld,
|
||||
onProfileChange,
|
||||
readOnly = false,
|
||||
backLabel = '返回',
|
||||
editActionLabel = '修改设定',
|
||||
regenerateActionLabel = '重新生成',
|
||||
saveActionLabel = '保存到我的作品',
|
||||
enterWorldActionLabel = '进入世界',
|
||||
autoSaveState = 'idle',
|
||||
}: CustomWorldResultViewProps) {
|
||||
const [editorTarget, setEditorTarget] =
|
||||
useState<CustomWorldEditorTarget | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||||
const [pendingGeneratedEntity, setPendingGeneratedEntity] =
|
||||
useState<PendingGeneratedEntity | null>(null);
|
||||
const [recentGeneratedIds, setRecentGeneratedIds] = useState<RecentGeneratedIds>(
|
||||
{
|
||||
playable: [],
|
||||
story: [],
|
||||
landmark: [],
|
||||
},
|
||||
);
|
||||
const [localGenerationError, setLocalGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const pendingProgressTimerRef = useRef<number | null>(null);
|
||||
|
||||
const createTarget = useMemo(
|
||||
() => getCreateTargetByTab(activeTab),
|
||||
@@ -149,6 +247,89 @@ export function CustomWorldResultView({
|
||||
() => getCreateLabelByTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
const stopPendingProgressTimer = () => {
|
||||
if (pendingProgressTimerRef.current !== null) {
|
||||
window.clearInterval(pendingProgressTimerRef.current);
|
||||
pendingProgressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => () => stopPendingProgressTimer(), []);
|
||||
|
||||
const startPendingProgress = (kind: EntityGenerationKind) => {
|
||||
stopPendingProgressTimer();
|
||||
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
|
||||
pendingProgressTimerRef.current = window.setInterval(() => {
|
||||
setPendingGeneratedEntity((current) => {
|
||||
if (!current || current.kind !== kind) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextProgress = Math.min(
|
||||
current.progress + (current.progress < 56 ? 11 : 5),
|
||||
88,
|
||||
);
|
||||
|
||||
return {
|
||||
...current,
|
||||
progress: nextProgress,
|
||||
phaseLabel: resolvePendingPhaseLabel(kind, nextProgress),
|
||||
};
|
||||
});
|
||||
}, 520);
|
||||
};
|
||||
|
||||
const finishPendingProgress = () => {
|
||||
stopPendingProgressTimer();
|
||||
setPendingGeneratedEntity(null);
|
||||
};
|
||||
|
||||
const markGeneratedAsRecent = (
|
||||
kind: EntityGenerationKind,
|
||||
generatedId: string,
|
||||
) => {
|
||||
setRecentGeneratedIds((current) => ({
|
||||
...current,
|
||||
[kind]: [generatedId, ...current[kind].filter((id) => id !== generatedId)].slice(
|
||||
0,
|
||||
6,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGenerateEntity = async (kind: EntityGenerationKind) => {
|
||||
if (readOnly || isGenerating || pendingGeneratedEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalGenerationError(null);
|
||||
startPendingProgress(kind);
|
||||
|
||||
try {
|
||||
if (kind === 'playable') {
|
||||
const nextNpc = await generateCustomWorldPlayableNpc({ profile });
|
||||
onProfileChange(prependPlayableNpc(profile, nextNpc));
|
||||
markGeneratedAsRecent('playable', nextNpc.id);
|
||||
} else if (kind === 'story') {
|
||||
const nextNpc = await generateCustomWorldStoryNpc({ profile });
|
||||
onProfileChange(prependStoryNpc(profile, nextNpc));
|
||||
markGeneratedAsRecent('story', nextNpc.id);
|
||||
} else {
|
||||
const nextLandmark = await generateCustomWorldLandmark({ profile });
|
||||
onProfileChange(prependLandmark(profile, nextLandmark));
|
||||
markGeneratedAsRecent('landmark', nextLandmark.id);
|
||||
}
|
||||
} catch (generationError) {
|
||||
setLocalGenerationError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: '生成失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
finishPendingProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const onRegenerate = () => {
|
||||
if (isGenerating || !triggerRegenerate) return;
|
||||
|
||||
@@ -169,10 +350,24 @@ export function CustomWorldResultView({
|
||||
if (ids.length === 0) return;
|
||||
onProfileChange(removeLandmarksFromProfile(profile, ids));
|
||||
};
|
||||
const autoSaveBadge =
|
||||
autoSaveState === 'saved' ? (
|
||||
<div className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
已自动保存
|
||||
</div>
|
||||
) : autoSaveState === 'saving' ? (
|
||||
<div className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
保存中
|
||||
</div>
|
||||
) : autoSaveState === 'error' ? (
|
||||
<div className="rounded-full border border-rose-300/20 bg-rose-500/10 px-3 py-1 text-[11px] text-rose-100">
|
||||
保存失败
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4 flex justify-start">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -181,6 +376,7 @@ export function CustomWorldResultView({
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
{autoSaveBadge}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
@@ -197,8 +393,27 @@ export function CustomWorldResultView({
|
||||
onCreateAction={
|
||||
readOnly || !createTarget
|
||||
? undefined
|
||||
: () => setEditorTarget(createTarget)
|
||||
: () => {
|
||||
if (activeTab === 'playable') {
|
||||
void handleGenerateEntity('playable');
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'story') {
|
||||
void handleGenerateEntity('story');
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'landmarks') {
|
||||
void handleGenerateEntity('landmark');
|
||||
return;
|
||||
}
|
||||
setEditorTarget(createTarget);
|
||||
}
|
||||
}
|
||||
createActionDisabled={Boolean(
|
||||
isGenerating || pendingGeneratedEntity,
|
||||
)}
|
||||
pendingGeneratedEntity={pendingGeneratedEntity}
|
||||
recentGeneratedIds={recentGeneratedIds}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
@@ -225,6 +440,11 @@ export function CustomWorldResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && localGenerationError ? (
|
||||
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile.generationStatus === 'key_only' ? (
|
||||
@@ -250,10 +470,10 @@ export function CustomWorldResultView({
|
||||
继续补全世界
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{onSave ? (
|
||||
{onEnterWorld ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
onClick={onEnterWorld}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
@@ -263,7 +483,7 @@ export function CustomWorldResultView({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{saveActionLabel}
|
||||
{enterWorldActionLabel}
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import {
|
||||
} from '../../types';
|
||||
|
||||
export const MASTER_VISUAL_WIDTH = 1024;
|
||||
export const MASTER_VISUAL_HEIGHT = 1536;
|
||||
export const MASTER_VISUAL_HEIGHT = 1024;
|
||||
export const GENERATED_FRAME_WIDTH = 192;
|
||||
export const GENERATED_FRAME_HEIGHT = 256;
|
||||
|
||||
@@ -769,6 +769,34 @@ async function normalizeFrameSourceToDataUrl(
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function normalizeMasterVisualSourceToDataUrl(
|
||||
source: string,
|
||||
options: {
|
||||
applyChromaKey?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const image = await loadImageFromSource(source);
|
||||
const { canvas, context } = createCanvas(
|
||||
MASTER_VISUAL_WIDTH,
|
||||
MASTER_VISUAL_HEIGHT,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey !== false) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function seekVideo(video: HTMLVideoElement, targetTime: number) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (Math.abs(video.currentTime - targetTime) < 0.001) {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { fetchJson } from '../../editor/shared/jsonClient';
|
||||
|
||||
export const CHARACTER_VISUAL_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualGenerate;
|
||||
export const CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterPromptBundleGenerate;
|
||||
export const CHARACTER_WORKFLOW_CACHE_API_PATH =
|
||||
ASSET_API_PATHS.characterWorkflowCache;
|
||||
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
|
||||
ASSET_API_PATHS.characterVisualPublish;
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs;
|
||||
@@ -43,6 +47,43 @@ export type CharacterVisualDraft = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type CharacterPromptBundlePayload = {
|
||||
roleKind: 'playable' | 'story';
|
||||
characterName: string;
|
||||
roleTitle?: string;
|
||||
roleLabel?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
characterBriefText: string;
|
||||
};
|
||||
|
||||
export type CharacterPromptBundleResult = {
|
||||
ok: true;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
export type CharacterAssetWorkflowCache = {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
visualDrafts: CharacterVisualDraft[];
|
||||
selectedVisualDraftId: string;
|
||||
selectedAnimation: string;
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type CharacterVisualGenerationPayload = {
|
||||
characterId: string;
|
||||
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
|
||||
@@ -129,7 +170,41 @@ export async function generateCharacterVisualCandidates(
|
||||
model: string;
|
||||
prompt: string;
|
||||
drafts: CharacterVisualDraft[];
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象候选失败');
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
|
||||
}
|
||||
|
||||
export async function generateCharacterPromptBundle(
|
||||
payload: CharacterPromptBundlePayload,
|
||||
) {
|
||||
return postApiJson<CharacterPromptBundleResult>(
|
||||
CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH,
|
||||
payload,
|
||||
'生成默认提示词失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCharacterWorkflowCache(characterId: string) {
|
||||
return fetchJson<{
|
||||
ok: true;
|
||||
cache: CharacterAssetWorkflowCache | null;
|
||||
}>(
|
||||
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}`,
|
||||
'读取角色形象生成缓存失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveCharacterWorkflowCache(
|
||||
payload: CharacterAssetWorkflowCache,
|
||||
) {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
cache: CharacterAssetWorkflowCache;
|
||||
saveMessage: string;
|
||||
}>(
|
||||
CHARACTER_WORKFLOW_CACHE_API_PATH,
|
||||
payload,
|
||||
'保存角色形象生成缓存失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCharacterVisualJobStatus(taskId: string) {
|
||||
|
||||
76
src/components/asset-studio/customWorldRolePromptDefaults.ts
Normal file
76
src/components/asset-studio/customWorldRolePromptDefaults.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type CustomWorldRolePromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
const characterName = cleanSeedText(role.name, 40) || '该角色';
|
||||
const roleAnchor =
|
||||
[cleanSeedText(role.title, 60), cleanSeedText(role.role, 60)]
|
||||
.filter(Boolean)
|
||||
.join(' / ') || '关键角色';
|
||||
const descriptionAnchor =
|
||||
cleanSeedText(role.description, 220) ||
|
||||
cleanSeedText(role.backstory, 260) ||
|
||||
cleanSeedText(role.personality, 160) ||
|
||||
'识别度鲜明';
|
||||
const combatAnchor =
|
||||
cleanSeedText(role.combatStyle, 180) ||
|
||||
cleanSeedText(role.motivation, 180) ||
|
||||
'动作重心稳定';
|
||||
const tagAnchor =
|
||||
role.tags && role.tags.length > 0
|
||||
? `保留 ${role.tags.slice(0, 8).join('、')} 的角色识别点。`
|
||||
: '';
|
||||
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterName},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
`动作识别点参考:${combatAnchor}。`,
|
||||
tagAnchor,
|
||||
'构图干净,主体明确,不做正面立绘,不做夸张透视。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterName}核心动作试片。`,
|
||||
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。',
|
||||
`动作气质参考:${combatAnchor}。`,
|
||||
role.personality ? `角色状态补充:${cleanSeedText(role.personality, 160)}。` : '',
|
||||
'起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
scenePromptText: [
|
||||
`${characterName}关联主场景,适合作为首次登场区域或常驻活动空间。`,
|
||||
'16:9 横版 RPG 场景背景,上半部分突出中远景氛围,下半部分是清晰可站立地面。',
|
||||
`场景叙事气质围绕:${descriptionAnchor}。`,
|
||||
role.backstory ? `环境背景可埋入:${cleanSeedText(role.backstory, 260)}。` : '',
|
||||
role.motivation ? `场景目标暗示可参考:${cleanSeedText(role.motivation, 160)}。` : '',
|
||||
'整体风格统一克制,适合作为剧情探索与战斗底图。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import { type ComponentType, useMemo } from 'react';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
@@ -72,7 +74,11 @@ function WorldCard({
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const tags = [
|
||||
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
|
||||
...new Set(
|
||||
buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
|
||||
return (
|
||||
@@ -224,19 +230,53 @@ function describeBindingStatus(bindingStatus: AuthUser['bindingStatus']) {
|
||||
return bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '正常';
|
||||
}
|
||||
|
||||
function formatPlayTime(playTimeMs: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(playTimeMs / 1000));
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
function formatCompactPlayTime(playTimeMs: number) {
|
||||
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
|
||||
const days = totalMinutes / 1440;
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`;
|
||||
if (days >= 10) {
|
||||
return `${Math.floor(days)}天`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分`;
|
||||
if (days >= 1) {
|
||||
return `${days.toFixed(days >= 3 ? 0 : 1)}天`;
|
||||
}
|
||||
return `${minutes}分`;
|
||||
|
||||
const hours = totalMinutes / 60;
|
||||
if (hours >= 1) {
|
||||
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
|
||||
}
|
||||
|
||||
return `${Math.max(0, totalMinutes)}分`;
|
||||
}
|
||||
|
||||
function formatDashboardCount(value: number) {
|
||||
const normalizedValue = Math.max(0, Math.round(value));
|
||||
if (normalizedValue >= 100000000) {
|
||||
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
|
||||
}
|
||||
if (normalizedValue >= 10000) {
|
||||
return `${(normalizedValue / 10000).toFixed(1)}万`;
|
||||
}
|
||||
|
||||
return normalizedValue.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatDashboardUpdatedAt(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '暂无更新记录';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
@@ -249,7 +289,9 @@ function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
}
|
||||
|
||||
function getUserAvatarLabel(user: AuthUser | null | undefined) {
|
||||
return (user?.displayName || user?.username || '叙').slice(0, 1).toUpperCase();
|
||||
return (user?.displayName || user?.username || '叙')
|
||||
.slice(0, 1)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
@@ -261,23 +303,40 @@ function copyText(value: string) {
|
||||
}
|
||||
|
||||
function ProfileStatCard({
|
||||
cardKey,
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
cardKey: ProfileDashboardCardKey;
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3 text-left transition hover:border-white/20 hover:bg-white/6"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-[11px] tracking-[0.16em]">{label}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-black text-white">{value}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileStatCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
|
||||
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" />
|
||||
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -316,13 +375,20 @@ export function PlatformHomeView({
|
||||
latestEntries,
|
||||
myEntries,
|
||||
historyEntries,
|
||||
historyError,
|
||||
profileDashboard,
|
||||
isLoadingPlatform,
|
||||
isLoadingDashboard,
|
||||
isClearingHistory,
|
||||
platformError,
|
||||
dashboardError,
|
||||
onContinueGame,
|
||||
onClearHistory,
|
||||
onOpenCreateWorld,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
onOpenProfileDashboardCard,
|
||||
}: {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
@@ -332,15 +398,22 @@ export function PlatformHomeView({
|
||||
latestEntries: CustomWorldGalleryCard[];
|
||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
historyEntries: PlatformBrowseHistoryEntry[];
|
||||
historyError: string | null;
|
||||
profileDashboard: ProfileDashboardSummary | null;
|
||||
isLoadingPlatform: boolean;
|
||||
isLoadingDashboard: boolean;
|
||||
isClearingHistory: boolean;
|
||||
platformError: string | null;
|
||||
dashboardError: string | null;
|
||||
onContinueGame: () => void;
|
||||
onClearHistory: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const featuredShelf = useMemo(
|
||||
@@ -362,11 +435,11 @@ export function PlatformHomeView({
|
||||
'上一次冒险已经保存,可以从这里继续推进故事。';
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||||
const remainingNarrativeCoins = savedSnapshot?.gameState.playerCurrency ?? 0;
|
||||
const totalPlayTime = formatPlayTime(
|
||||
savedSnapshot?.gameState.runtimeStats.playTimeMs ?? 0,
|
||||
const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
|
||||
const totalPlayTime = formatCompactPlayTime(
|
||||
profileDashboard?.totalPlayTimeMs ?? 0,
|
||||
);
|
||||
const playedWorkCount = hasSavedGame ? 1 : 0;
|
||||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||||
const tabIcons = {
|
||||
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
|
||||
create: '/Icons/01_Scroll.png',
|
||||
@@ -647,21 +720,66 @@ export function PlatformHomeView({
|
||||
})}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileStatCard
|
||||
label="剩余叙世币"
|
||||
value={`${remainingNarrativeCoins}`}
|
||||
icon={Coins}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
label="总游戏时长"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
label="玩过作品"
|
||||
value={`${playedWorkCount}`}
|
||||
icon={BookOpen}
|
||||
/>
|
||||
{isLoadingDashboard ? (
|
||||
<>
|
||||
<ProfileStatCardSkeleton />
|
||||
<ProfileStatCardSkeleton />
|
||||
<ProfileStatCardSkeleton />
|
||||
</>
|
||||
) : dashboardError ? (
|
||||
<>
|
||||
<ProfileStatCard
|
||||
cardKey="wallet"
|
||||
label="剩余叙世币"
|
||||
value="暂不可用"
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="总游戏时长"
|
||||
value="暂不可用"
|
||||
icon={Clock3}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="玩过作品"
|
||||
value="暂不可用"
|
||||
icon={BookOpen}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileStatCard
|
||||
cardKey="wallet"
|
||||
label="剩余叙世币"
|
||||
value={formatDashboardCount(remainingNarrativeCoins)}
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="总游戏时长"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="玩过作品"
|
||||
value={formatDashboardCount(playedWorkCount)}
|
||||
icon={BookOpen}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-zinc-500">
|
||||
{dashboardError
|
||||
? dashboardError
|
||||
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -719,8 +837,27 @@ export function PlatformHomeView({
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<SectionHeader title="历史浏览" detail="最近看过的作品" />
|
||||
{historyEntries.length > 0 ? (
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<SectionHeader title="历史浏览" detail="最近看过的作品" />
|
||||
{historyEntries.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearHistory}
|
||||
disabled={isClearingHistory}
|
||||
className="shrink-0 rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{isClearingHistory ? '清空中' : '清空'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{historyError ? (
|
||||
<div className="mb-3 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{historyError}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoadingPlatform && historyEntries.length === 0 ? (
|
||||
<EmptyShelf text="正在读取浏览历史..." />
|
||||
) : historyEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{historyEntries.map((entry) => (
|
||||
<button
|
||||
@@ -771,7 +908,9 @@ export function PlatformHomeView({
|
||||
作者:{entry.authorDisplayName}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
|
||||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||||
{entry.summaryText ||
|
||||
entry.subtitle ||
|
||||
'等待补充世界摘要。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -815,7 +954,9 @@ export function PlatformHomeView({
|
||||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">设置</div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
设置
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">账号与安全</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,18 @@ import {
|
||||
getCustomWorldAgentOperation,
|
||||
getCustomWorldAgentSession,
|
||||
} from '../../services/aiService';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
getProfileDashboard,
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
listProfileBrowseHistory,
|
||||
upsertCustomWorldProfile,
|
||||
upsertProfileBrowseHistory,
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
PreGameSelectionFlow,
|
||||
type SelectionStage,
|
||||
@@ -33,11 +39,16 @@ vi.mock('../../services/aiService', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/storageService', () => ({
|
||||
clearProfileBrowseHistory: vi.fn(),
|
||||
getCustomWorldGalleryDetail: vi.fn(),
|
||||
getProfileDashboard: vi.fn(),
|
||||
listCustomWorldGallery: vi.fn(),
|
||||
listCustomWorldLibrary: vi.fn(),
|
||||
listProfileBrowseHistory: vi.fn(),
|
||||
publishCustomWorldProfile: vi.fn(),
|
||||
syncProfileBrowseHistory: vi.fn(),
|
||||
unpublishCustomWorldProfile: vi.fn(),
|
||||
upsertProfileBrowseHistory: vi.fn(),
|
||||
upsertCustomWorldProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -108,11 +119,21 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
};
|
||||
|
||||
function TestWrapper() {
|
||||
const mockAuthUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
@@ -124,14 +145,41 @@ function TestWrapper() {
|
||||
handleCustomWorldSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!withAuth) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: mockAuthUser,
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
});
|
||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
@@ -350,26 +398,56 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /保存到我的作品|自动保存中|已保存到我的作品/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('已自动保存')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
||||
expect(screen.getByText(/原始设定/u)).toBeTruthy();
|
||||
expect(screen.getByText(/基本设定/u)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: /顾潮音/u }));
|
||||
|
||||
expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /AI生成形象与动作/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy();
|
||||
expect(screen.getByText('技能')).toBeTruthy();
|
||||
|
||||
});
|
||||
|
||||
test('profile tab loads server browse history and can clear it after confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([
|
||||
{
|
||||
ownerUserId: 'author-1',
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '最近浏览过的公开作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '我的' }));
|
||||
|
||||
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '清空' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('你最近还没有浏览过作品详情,去首页或发现逛一逛吧。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ import {
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
CustomWorldRoleRelation,
|
||||
CustomWorldRoleSkill,
|
||||
EquipmentSlotId,
|
||||
ItemAttributeResonance,
|
||||
@@ -39,8 +40,8 @@ import {
|
||||
SceneNarrativeResidue,
|
||||
ThemePack,
|
||||
ThreadContract,
|
||||
WorldType,
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
@@ -330,6 +331,35 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
|
||||
] satisfies CustomWorldRoleSkill[];
|
||||
}
|
||||
|
||||
function normalizeRoleRelations(value: unknown, fallbackHooks: string[]) {
|
||||
const normalized = Array.isArray(value)
|
||||
? value
|
||||
.filter(isRecord)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
({
|
||||
id: toText(entry.id, `saved-role-relation-${index + 1}`),
|
||||
targetRoleId: toText(entry.targetRoleId),
|
||||
summary: toText(entry.summary),
|
||||
}) satisfies CustomWorldRoleRelation,
|
||||
)
|
||||
.filter((entry) => entry.summary)
|
||||
.slice(0, 8)
|
||||
: [];
|
||||
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return fallbackHooks
|
||||
.map((summary, index) => ({
|
||||
id: `saved-role-relation-${index + 1}`,
|
||||
targetRoleId: '',
|
||||
summary,
|
||||
}))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function normalizeRoleSkills(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
@@ -344,6 +374,10 @@ function normalizeRoleSkills(
|
||||
name: toText(entry.name),
|
||||
summary: toText(entry.summary, toText(entry.description)),
|
||||
style: toText(entry.style, toText(entry.category, '常用')),
|
||||
actionPromptText: toText(entry.actionPromptText) || undefined,
|
||||
actionPreviewConfig:
|
||||
normalizeCharacterAnimationConfig(entry.actionPreviewConfig) ??
|
||||
undefined,
|
||||
}) satisfies CustomWorldRoleSkill,
|
||||
)
|
||||
.filter((entry) => entry.name)
|
||||
@@ -424,6 +458,7 @@ function normalizeRoleInitialItems(
|
||||
: 'rare',
|
||||
description: toText(entry.description),
|
||||
tags: toStringArray(entry.tags),
|
||||
iconSrc: toText(entry.iconSrc) || undefined,
|
||||
}) satisfies CustomWorldRoleInitialItem,
|
||||
)
|
||||
.filter((entry) => entry.name)
|
||||
@@ -587,6 +622,11 @@ function normalizePlayableNpc(
|
||||
const title = toText(value.title, toText(value.role, '未命名角色'));
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const relations = normalizeRoleRelations(value.relations, relationshipHooks);
|
||||
const relationSummaries = relations
|
||||
.map((entry) => entry.summary)
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
const tags = toStringArray(value.tags);
|
||||
const fallbackSource = {
|
||||
name,
|
||||
@@ -616,7 +656,11 @@ function normalizePlayableNpc(
|
||||
value.initialAffinity,
|
||||
DEFAULT_PLAYABLE_INITIAL_AFFINITY,
|
||||
),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
relationshipHooks:
|
||||
relationSummaries.length > 0
|
||||
? relationSummaries
|
||||
: fallbackSource.relationshipHooks,
|
||||
relations,
|
||||
tags: fallbackSource.tags,
|
||||
backstoryReveal: normalizeBackstoryReveal(
|
||||
value.backstoryReveal,
|
||||
@@ -650,6 +694,11 @@ function normalizeStoryNpc(
|
||||
const title = toText(value.title, toText(value.role, '未命名场景角色'));
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const relations = normalizeRoleRelations(value.relations, relationshipHooks);
|
||||
const relationSummaries = relations
|
||||
.map((entry) => entry.summary)
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
const tags = toStringArray(value.tags);
|
||||
const fallbackSource = {
|
||||
name,
|
||||
@@ -679,7 +728,11 @@ function normalizeStoryNpc(
|
||||
value.initialAffinity,
|
||||
DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
relationshipHooks:
|
||||
relationSummaries.length > 0
|
||||
? relationSummaries
|
||||
: fallbackSource.relationshipHooks,
|
||||
relations,
|
||||
tags: fallbackSource.tags,
|
||||
backstoryReveal: normalizeBackstoryReveal(
|
||||
value.backstoryReveal,
|
||||
|
||||
@@ -18,6 +18,8 @@ export type EditorJsonResourceId =
|
||||
(typeof EDITOR_JSON_RESOURCE_IDS)[keyof typeof EDITOR_JSON_RESOURCE_IDS];
|
||||
|
||||
export const ASSET_API_PATHS = {
|
||||
characterPromptBundleGenerate: `${ASSETS_API_BASE_PATH}/character-prompts/generate`,
|
||||
characterWorkflowCache: `${ASSETS_API_BASE_PATH}/character-workflow-cache`,
|
||||
characterVisualGenerate: `${ASSETS_API_BASE_PATH}/character-visual/generate`,
|
||||
characterVisualPublish: `${ASSETS_API_BASE_PATH}/character-visual/publish`,
|
||||
characterVisualJobs: `${ASSETS_API_BASE_PATH}/character-visual/jobs`,
|
||||
|
||||
@@ -899,13 +899,21 @@ describe('ai orchestration fallbacks', () => {
|
||||
ok: true,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||||
assetId: 'custom-scene-1',
|
||||
model: 'wan2.7-image',
|
||||
size: '1280*720',
|
||||
taskId: 'task-123',
|
||||
prompt: '系统整理后的提示词',
|
||||
actualPrompt: '扩写后的提示词',
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||||
assetId: 'custom-scene-1',
|
||||
model: 'wan2.7-image',
|
||||
size: '1280*720',
|
||||
taskId: 'task-123',
|
||||
prompt: '系统整理后的提示词',
|
||||
actualPrompt: '扩写后的提示词',
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
CustomWorldGenerationStep,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import { unwrapApiResponse } from '../../packages/shared/src/http';
|
||||
import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs';
|
||||
import {
|
||||
buildEncounterFromSceneNpc,
|
||||
@@ -26,12 +32,6 @@ import {
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldGenerationStep,
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback,
|
||||
buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback,
|
||||
@@ -136,7 +136,6 @@ export type {
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
@@ -2018,8 +2017,8 @@ export async function generateCustomWorldSceneImage({
|
||||
);
|
||||
}
|
||||
|
||||
const data = JSON.parse(
|
||||
responseText,
|
||||
const data = unwrapApiResponse(
|
||||
JSON.parse(responseText) as Partial<CustomWorldSceneImageResult>,
|
||||
) as Partial<CustomWorldSceneImageResult>;
|
||||
if (
|
||||
!data.imageSrc ||
|
||||
|
||||
@@ -30,6 +30,9 @@ import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
@@ -49,6 +52,7 @@ import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const CUSTOM_WORLD_API_BASE = '/api';
|
||||
|
||||
type LegacyAiModule = typeof import('./ai');
|
||||
|
||||
@@ -490,6 +494,74 @@ export async function generateCustomWorldSceneImage(
|
||||
return aiClient.generateCustomWorldSceneImage(...args);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
landmarkId: string;
|
||||
}) {
|
||||
const response = await requestPostJson<{ npc: CustomWorldNpc }>(
|
||||
`${CUSTOM_WORLD_API_BASE}/custom-world/scene-npc`,
|
||||
payload,
|
||||
'生成场景 NPC 失败',
|
||||
);
|
||||
|
||||
return response.npc;
|
||||
}
|
||||
|
||||
async function requestCustomWorldEntity<T>(
|
||||
payload: {
|
||||
profile: CustomWorldProfile;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
},
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestPostJson<{
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
entity: T;
|
||||
}>(`${CUSTOM_WORLD_API_BASE}/custom-world/entity`, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldPlayableNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldPlayableNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'playable',
|
||||
},
|
||||
'生成可扮演角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldStoryNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'story',
|
||||
},
|
||||
'生成场景角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldLandmark(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldLandmark>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'landmark',
|
||||
},
|
||||
'生成场景失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function createCustomWorldSession(payload: {
|
||||
settingText: string;
|
||||
creatorIntent?: Record<string, unknown> | null;
|
||||
|
||||
@@ -2354,7 +2354,7 @@ export function buildCustomWorldSceneImagePrompt(
|
||||
'下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。',
|
||||
'下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。',
|
||||
options.hasReferenceImage
|
||||
? '已提供一张自定义参考图,可适度参考其构图、镜头或氛围,但仍以本次场景需求为准,不要生硬照搬。'
|
||||
? '已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。'
|
||||
: '',
|
||||
`世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`,
|
||||
worldSetting ? `玩家设定:${worldSetting}。` : '',
|
||||
|
||||
@@ -116,9 +116,9 @@ test('marks all legacy progress steps complete when draft foundation finishes',
|
||||
test('builds readable draft setting text from creator intent first', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText(baseSession);
|
||||
|
||||
expect(settingText).toContain('世界核心命题');
|
||||
expect(settingText).toContain('玩家身份');
|
||||
expect(settingText).toContain('标志性要素');
|
||||
expect(settingText).toContain('世界一句话');
|
||||
expect(settingText).toContain('玩家开局');
|
||||
expect(settingText).toContain('标志元素');
|
||||
});
|
||||
|
||||
test('falls back to latest user message when creator intent is unavailable', () => {
|
||||
|
||||
@@ -7,8 +7,7 @@ import type {
|
||||
CustomWorldGenerationStep,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
|
||||
@@ -177,17 +176,11 @@ export function buildAgentDraftFoundationSettingText(
|
||||
);
|
||||
|
||||
if (creatorIntent) {
|
||||
const generationText =
|
||||
buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim();
|
||||
const displayText =
|
||||
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
|
||||
const foundationText =
|
||||
buildCustomWorldCreatorIntentFoundationText(creatorIntent).trim();
|
||||
|
||||
if (generationText) {
|
||||
return generationText;
|
||||
}
|
||||
|
||||
if (displayText) {
|
||||
return displayText;
|
||||
if (foundationText) {
|
||||
return foundationText;
|
||||
}
|
||||
|
||||
if (creatorIntent.rawSettingText.trim()) {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
buildPendingClarifications,
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
buildPendingClarifications,
|
||||
createEmptyCustomWorldCreatorIntent,
|
||||
evaluateCustomWorldCreatorIntentReadiness,
|
||||
mergeCustomWorldCreatorIntent,
|
||||
@@ -42,6 +43,41 @@ describe('customWorldCreatorIntent', () => {
|
||||
expect(summary).toContain('关键角色:沈砺 / 灰炬向导');
|
||||
});
|
||||
|
||||
it('builds six-anchor foundation text from structured creator intent', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
worldHook: '一个会被灵潮反复改写地形的边境世界。',
|
||||
themeKeywords: ['边境', '灵潮'],
|
||||
toneDirectives: ['紧张', '潮湿'],
|
||||
playerPremise: '玩家是带着旧名单回来的前巡夜人。',
|
||||
openingSituation: '返乡第一夜,封锁线外出现了本不该存在的灯火。',
|
||||
coreConflicts: ['旧案名单再次出现'],
|
||||
keyCharacters: [
|
||||
{
|
||||
id: 'character-1',
|
||||
name: '沈砺',
|
||||
role: '灰炬向导',
|
||||
publicMask: '看起来只是熟路的带路人',
|
||||
hiddenHook: '他一直在追查撤离线失控真相',
|
||||
relationToPlayer: '会先怀疑玩家身份',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
iconicElements: ['会逆向蔓延的潮雾'],
|
||||
};
|
||||
|
||||
const foundationText = buildCustomWorldCreatorIntentFoundationText(intent);
|
||||
|
||||
expect(foundationText).toContain(
|
||||
'世界一句话:一个会被灵潮反复改写地形的边境世界。',
|
||||
);
|
||||
expect(foundationText).toContain('玩家开局:玩家是带着旧名单回来的前巡夜人。');
|
||||
expect(foundationText).toContain('主题气质:边境、灵潮 / 紧张、潮湿');
|
||||
expect(foundationText).toContain('关键关系:沈砺 · 灰炬向导');
|
||||
expect(foundationText).toContain('标志元素:会逆向蔓延的潮雾');
|
||||
});
|
||||
|
||||
it('builds anchor pack from creator intent and keeps locked ids', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
|
||||
@@ -710,6 +710,48 @@ function buildAnchorLine(label: string, content: string) {
|
||||
return content ? `${label}:${content}` : '';
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentFoundationText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const relationshipSeed = intent?.keyCharacters[0];
|
||||
const relationshipText = relationshipSeed
|
||||
? [
|
||||
relationshipSeed.name,
|
||||
relationshipSeed.role,
|
||||
relationshipSeed.relationToPlayer
|
||||
? `与玩家 ${relationshipSeed.relationToPlayer}`
|
||||
: '',
|
||||
relationshipSeed.hiddenHook ? `暗线 ${relationshipSeed.hiddenHook}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: '';
|
||||
const playerOpeningText = [intent?.playerPremise || '', intent?.openingSituation || '']
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
const themeToneText = [
|
||||
intent?.themeKeywords.join('、') || '',
|
||||
intent?.toneDirectives.join('、') || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
return [
|
||||
buildAnchorLine('世界一句话', intent?.worldHook || ''),
|
||||
buildAnchorLine('玩家开局', playerOpeningText),
|
||||
buildAnchorLine('主题气质', themeToneText),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join(';') || ''),
|
||||
buildAnchorLine('关键关系', relationshipText),
|
||||
buildAnchorLine('标志元素', intent?.iconicElements.join('、') || ''),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentDisplayText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import type { CustomWorldGalleryCard } from '../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthUser } from './authService';
|
||||
|
||||
export type PlatformBrowseHistoryEntry = {
|
||||
ownerUserId: string;
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeMode: CustomWorldGalleryCard['themeMode'];
|
||||
authorDisplayName: string;
|
||||
visitedAt: string;
|
||||
};
|
||||
export type { PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry };
|
||||
|
||||
const HISTORY_STORAGE_KEY_PREFIX = 'genarrative.platform.browse-history.v1';
|
||||
const HISTORY_SYNC_KEY_PREFIX = 'genarrative.platform.browse-history.synced.v1';
|
||||
const MAX_HISTORY_ENTRIES = 20;
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
return (
|
||||
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
function buildHistoryStorageKey(user: AuthUser | null | undefined) {
|
||||
@@ -25,6 +21,11 @@ function buildHistoryStorageKey(user: AuthUser | null | undefined) {
|
||||
return `${HISTORY_STORAGE_KEY_PREFIX}:${accountId}`;
|
||||
}
|
||||
|
||||
function buildHistorySyncKey(user: AuthUser | null | undefined) {
|
||||
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
|
||||
return `${HISTORY_SYNC_KEY_PREFIX}:${accountId}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -33,7 +34,9 @@ function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeHistoryEntry(value: unknown): PlatformBrowseHistoryEntry | null {
|
||||
function normalizeHistoryEntry(
|
||||
value: unknown,
|
||||
): PlatformBrowseHistoryEntry | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
@@ -42,12 +45,11 @@ function normalizeHistoryEntry(value: unknown): PlatformBrowseHistoryEntry | nul
|
||||
const profileId = readString(value.profileId);
|
||||
const worldName = readString(value.worldName);
|
||||
const visitedAt = readString(value.visitedAt);
|
||||
|
||||
if (!ownerUserId || !profileId || !worldName || !visitedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themeMode = readString(value.themeMode) as PlatformBrowseHistoryEntry['themeMode'];
|
||||
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
@@ -55,7 +57,10 @@ function normalizeHistoryEntry(value: unknown): PlatformBrowseHistoryEntry | nul
|
||||
subtitle: readString(value.subtitle),
|
||||
summaryText: readString(value.summaryText),
|
||||
coverImageSrc: readString(value.coverImageSrc) || null,
|
||||
themeMode: themeMode || 'mythic',
|
||||
themeMode:
|
||||
(readString(
|
||||
value.themeMode,
|
||||
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
|
||||
authorDisplayName: readString(value.authorDisplayName) || '玩家',
|
||||
visitedAt,
|
||||
};
|
||||
@@ -97,19 +102,20 @@ export function readPlatformBrowseHistory(user: AuthUser | null | undefined) {
|
||||
|
||||
export function writePlatformBrowseHistory(
|
||||
user: AuthUser | null | undefined,
|
||||
entry: Omit<PlatformBrowseHistoryEntry, 'visitedAt'> & {
|
||||
visitedAt?: string;
|
||||
},
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return [] as PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
|
||||
const nextEntry: PlatformBrowseHistoryEntry = {
|
||||
...entry,
|
||||
ownerUserId: entry.ownerUserId.trim(),
|
||||
profileId: entry.profileId.trim(),
|
||||
worldName: entry.worldName.trim(),
|
||||
subtitle: entry.subtitle?.trim() || '',
|
||||
summaryText: entry.summaryText?.trim() || '',
|
||||
coverImageSrc: entry.coverImageSrc?.trim() || null,
|
||||
themeMode: entry.themeMode || 'mythic',
|
||||
authorDisplayName: entry.authorDisplayName?.trim() || '玩家',
|
||||
visitedAt: entry.visitedAt?.trim() || new Date().toISOString(),
|
||||
};
|
||||
@@ -129,5 +135,38 @@ export function writePlatformBrowseHistory(
|
||||
buildHistoryStorageKey(user),
|
||||
JSON.stringify(nextEntries),
|
||||
);
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function clearPlatformBrowseHistory(user: AuthUser | null | undefined) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildHistoryStorageKey(user));
|
||||
window.localStorage.removeItem(buildHistorySyncKey(user));
|
||||
}
|
||||
|
||||
export function hasPendingPlatformBrowseHistoryMigration(
|
||||
user: AuthUser | null | undefined,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
readPlatformBrowseHistory(user).length > 0 &&
|
||||
window.localStorage.getItem(buildHistorySyncKey(user)) !== '1'
|
||||
);
|
||||
}
|
||||
|
||||
export function markPlatformBrowseHistoryMigrated(
|
||||
user: AuthUser | null | undefined,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(buildHistorySyncKey(user), '1');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type {
|
||||
ListCustomWorldWorksResponse,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { ListCustomWorldWorksResponse } from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
BasicOkResult,
|
||||
CustomWorldGalleryDetailResponse,
|
||||
@@ -8,15 +6,20 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
SavedGameSnapshotInput,
|
||||
} from '../persistence/gameSaveStorage';
|
||||
import type { SavedGameSnapshotInput } from '../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import { type ApiRetryOptions,requestJson } from './apiClient';
|
||||
import { type ApiRetryOptions, requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -58,6 +61,28 @@ function requestRuntimeJson<T>(
|
||||
);
|
||||
}
|
||||
|
||||
function requestProfileJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`/api/profile${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
@@ -105,6 +130,35 @@ export async function getSettings(options: RuntimeRequestOptions = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfileDashboardSummary>(
|
||||
'/profile/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
{ method: 'GET' },
|
||||
'读取游玩统计失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
@@ -121,8 +175,12 @@ export async function putSettings(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
export async function listCustomWorldLibrary(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
@@ -132,7 +190,9 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorks(options: RuntimeRequestOptions = {}) {
|
||||
export async function listCustomWorldWorks(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<ListCustomWorldWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
@@ -147,7 +207,9 @@ export async function upsertCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@@ -170,7 +232,9 @@ export async function deleteCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
@@ -184,7 +248,9 @@ export async function publishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
@@ -201,7 +267,9 @@ export async function unpublishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
@@ -214,7 +282,9 @@ export async function unpublishCustomWorldProfile(
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCustomWorldGallery(options: RuntimeRequestOptions = {}) {
|
||||
export async function listCustomWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
@@ -230,7 +300,9 @@ export async function getCustomWorldGalleryDetail(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldGalleryDetailResponse<CustomWorldProfile>>(
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
@@ -240,12 +312,79 @@ export async function getCustomWorldGalleryDetail(
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function listProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
|
||||
'/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertProfileBrowseHistory(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
|
||||
'/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function syncProfileBrowseHistory(
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
|
||||
'/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries,
|
||||
} satisfies PlatformBrowseHistoryBatchSyncRequest),
|
||||
},
|
||||
'同步浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function clearProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
|
||||
'/browse-history',
|
||||
{ method: 'DELETE' },
|
||||
'清空浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export const runtimeStorageClient = {
|
||||
getSaveSnapshot,
|
||||
putSaveSnapshot,
|
||||
deleteSaveSnapshot,
|
||||
getSettings,
|
||||
putSettings,
|
||||
getProfileDashboard,
|
||||
getProfileWalletLedger,
|
||||
getProfilePlayStats,
|
||||
listCustomWorldLibrary,
|
||||
listCustomWorldWorks,
|
||||
upsertCustomWorldProfile,
|
||||
@@ -254,6 +393,11 @@ export const runtimeStorageClient = {
|
||||
unpublishCustomWorldProfile,
|
||||
listCustomWorldGallery,
|
||||
getCustomWorldGalleryDetail,
|
||||
listProfileBrowseHistory,
|
||||
upsertProfileBrowseHistory,
|
||||
syncProfileBrowseHistory,
|
||||
clearProfileBrowseHistory,
|
||||
};
|
||||
|
||||
export type { CustomWorldLibraryEntry };
|
||||
export type { PlatformBrowseHistoryEntry };
|
||||
|
||||
@@ -70,6 +70,8 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
expect(prompt).toContain('1:1 正方形画布');
|
||||
expect(prompt).toContain('大头身');
|
||||
expect(prompt).toContain('2 到 3 头身');
|
||||
expect(prompt).toContain('不是完全 90 度纯右视图');
|
||||
expect(prompt).toContain('背景固定为纯绿色绿幕');
|
||||
});
|
||||
|
||||
it('strengthens non-human species traits for siren-like characters', () => {
|
||||
@@ -81,16 +83,16 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
expect(prompt).toContain('如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色');
|
||||
expect(prompt).toContain('严格约束身体结构骨架');
|
||||
expect(prompt).toContain('沿用参考图的人形动作角色身体结构');
|
||||
expect(prompt).toContain('主题词默认只作用在角色自身');
|
||||
expect(negativePrompt).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
});
|
||||
|
||||
it('teaches the model how to interpret jellyfish king concepts', () => {
|
||||
const prompt = buildMasterPrompt('水母国王,半透明伞盖,荧光斑点,权杖。');
|
||||
it('keeps theme words on the character instead of leaking into the background', () => {
|
||||
const prompt = buildMasterPrompt('机械祭司,冷白金属外套,环形圣徽。');
|
||||
|
||||
expect(prompt).toContain('示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色');
|
||||
expect(prompt).toContain('水母主题的服装和配饰');
|
||||
expect(prompt).toContain('水母权杖');
|
||||
expect(prompt).toContain('而不是完整水母怪物本体');
|
||||
expect(prompt).toContain('主题词默认只作用在角色自身');
|
||||
expect(prompt).toContain('不要把主题词自动扩写成背景建筑');
|
||||
expect(prompt).not.toContain('水母国王');
|
||||
});
|
||||
|
||||
it('builds a repair prompt that keeps chibi ratio', () => {
|
||||
@@ -112,7 +114,7 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain('动作视频');
|
||||
expect(prompt).toContain('侧身朝右');
|
||||
expect(prompt).toContain('右向斜侧身动作视角');
|
||||
expect(prompt).toContain('像素风');
|
||||
expect(prompt).toContain('绿幕');
|
||||
expect(prompt).toContain('默认优先生成人形拟人化角色');
|
||||
@@ -122,6 +124,7 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
it('builds generic theme over-literalization negatives', () => {
|
||||
expect(buildSheetNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
expect(buildRepairNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
expect(buildMasterNegativePrompt('机械祭司')).toContain('不要把主题词自动扩写成角色以外的场景元素');
|
||||
});
|
||||
|
||||
it('contains built-in playable character style reference sources', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export type QwenSpriteActionTemplate = {
|
||||
};
|
||||
|
||||
export const DEFAULT_MASTER_NEGATIVE_PROMPT =
|
||||
'正面视角,左朝向,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
'正面视角,左朝向,完全 90 度纯右视图,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,建筑场景,道具堆叠,漂浮物,烟雾环境,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
|
||||
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
|
||||
'多角色,左右朝向混乱,前视图,背视图,镜头切换,景别变化,特写,脚底裁切,头顶裁切,缺手,缺脚,额外肢体,武器消失,武器换手,服装变化,脸部变化,发型变化,动作不连续,重复帧过多,构图混乱,背景复杂,强透视,运动模糊,残影,文字,水印,UI,边框覆盖角色';
|
||||
@@ -26,19 +26,25 @@ export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
|
||||
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头部占比明显更大,约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
const SIDE_FACING_RIGHT_TEXT =
|
||||
'角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。';
|
||||
const SUBJECT_ONLY_TEXT =
|
||||
'画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。';
|
||||
const CLEAN_BACKGROUND_TEXT =
|
||||
'背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。';
|
||||
const STYLE_REFERENCE_SCOPE_TEXT =
|
||||
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
|
||||
const CONCEPT_INTERPRETATION_TEXT =
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
|
||||
const HUMANLIKE_PRIORITY_TEXT =
|
||||
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。';
|
||||
const JELLYFISH_THEME_EXAMPLE_TEXT =
|
||||
'示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。';
|
||||
const CONCEPT_HIERARCHY_TEXT =
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
|
||||
const THEME_APPLICATION_BOUNDARY_TEXT =
|
||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。';
|
||||
const CHIBI_CHARACTER_TEXT =
|
||||
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。';
|
||||
|
||||
@@ -417,13 +423,15 @@ export async function buildPlayableCharacterStyleReferenceBoard(
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'单人,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
'画面要求:1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
|
||||
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
`视角要求:${SIDE_FACING_RIGHT_TEXT}`,
|
||||
`主体要求:${SUBJECT_ONLY_TEXT}`,
|
||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。${CLEAN_BACKGROUND_TEXT}`,
|
||||
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
JELLYFISH_THEME_EXAMPLE_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -436,11 +444,11 @@ export function buildSheetPrompt(options: {
|
||||
extraDirection: string;
|
||||
}) {
|
||||
return [
|
||||
`使用图1作为风格参考。生成一张 4x4 的 sprite sheet,共 16 帧,展示同一个角色的连续动作。角色始终朝右,主体完整出现在每一个格子里,底部轮廓稳定,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
`使用图1作为风格参考。生成一张 4x4 的 sprite sheet,共 16 帧,展示同一个角色的连续动作。角色保持右向斜侧身动作视角,主体完整出现在每一个格子里,底部轮廓稳定,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转,也不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
JELLYFISH_THEME_EXAMPLE_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`动作名:${options.actionTemplate.label}`,
|
||||
`是否循环:${options.actionTemplate.loop ? '是' : '否'}`,
|
||||
`身体位移:${options.actionTemplate.bodyTravel}`,
|
||||
@@ -461,7 +469,7 @@ export function buildRepairPrompt(options: {
|
||||
}) {
|
||||
return [
|
||||
`使用图1作为风格参考,参考图2的动作连续性,修复图3这一个单帧。图2代表${options.useNeighborLabel}。`,
|
||||
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色始终朝右,主体完整,底部结构稳定,保持与图2连续,并且与图1是同一个角色。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${JELLYFISH_THEME_EXAMPLE_TEXT} 修复图3中的错误,使这一帧适合插回原来的 sprite sheet 中。`,
|
||||
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色保持右向斜侧身动作视角,主体完整,底部结构稳定,保持与图2连续,并且与图1是同一个角色,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${THEME_APPLICATION_BOUNDARY_TEXT} 修复图3中的错误,使这一帧适合插回原来的 sprite sheet 中。`,
|
||||
'保持不变:发型、服装结构、主配色、武器类型、朝向。',
|
||||
`重点修复:${options.issueText.trim() || '修复手脚畸形、武器错误或朝向不一致问题。'}`,
|
||||
].join('\n');
|
||||
@@ -475,11 +483,11 @@ export function buildVideoActionPrompt(options: {
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`,
|
||||
`角色固定为图1同一角色,始终侧身朝右,镜头稳定,轮廓清晰。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
JELLYFISH_THEME_EXAMPLE_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`,
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
||||
@@ -525,7 +533,7 @@ export function restoreAllFrames(frameCount: number) {
|
||||
}
|
||||
|
||||
export function buildMasterNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,也不要把主题词自动扩写成角色以外的场景元素,除非文字设定明确要求`;
|
||||
}
|
||||
|
||||
export function buildSheetNegativePrompt(_characterBrief: string) {
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface CustomWorldRoleSkill {
|
||||
name: string;
|
||||
summary: string;
|
||||
style: string;
|
||||
actionPromptText?: string;
|
||||
actionPreviewConfig?: CharacterAnimationConfig;
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleInitialItem {
|
||||
@@ -202,6 +204,13 @@ export interface CustomWorldRoleInitialItem {
|
||||
rarity: ItemRarity;
|
||||
description: string;
|
||||
tags: string[];
|
||||
iconSrc?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleRelation {
|
||||
id: string;
|
||||
targetRoleId: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleProfile {
|
||||
@@ -216,6 +225,7 @@ export interface CustomWorldRoleProfile {
|
||||
combatStyle: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
relations?: CustomWorldRoleRelation[];
|
||||
tags: string[];
|
||||
backstoryReveal: CharacterBackstoryRevealConfig;
|
||||
skills: CustomWorldRoleSkill[];
|
||||
|
||||
Reference in New Issue
Block a user