1
This commit is contained in:
@@ -1,21 +1,28 @@
|
||||
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
type ReactNode,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
resolveCustomWorldCampSceneImage,
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
export type ResultTab = 'world' | 'anchors' | 'playable' | 'story' | 'landmarks';
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
interface CustomWorldEntityCatalogProps {
|
||||
profile: CustomWorldProfile;
|
||||
@@ -28,11 +35,11 @@ interface CustomWorldEntityCatalogProps {
|
||||
onDeleteLandmarks?: (ids: string[]) => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||||
{ id: 'world', label: '世界' },
|
||||
{ id: 'anchors', label: '锚点' },
|
||||
{ id: 'playable', label: '可扮演角色' },
|
||||
{ id: 'story', label: '场景角色' },
|
||||
{ id: 'landmarks', label: '场景' },
|
||||
@@ -50,11 +57,20 @@ function Section({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">{title}</div>
|
||||
{subtitle ? <div className="mt-1 text-xs leading-6 text-zinc-500">{subtitle}</div> : null}
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-500">
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
@@ -72,11 +88,12 @@ function SmallButton({
|
||||
children: ReactNode;
|
||||
tone?: 'default' | 'sky' | 'rose';
|
||||
}) {
|
||||
const toneClassName = tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: tone === 'rose'
|
||||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
|
||||
const toneClassName =
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: tone === 'rose'
|
||||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -102,7 +119,7 @@ function SearchBox({
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
|
||||
<input
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
@@ -122,7 +139,9 @@ function ImageFrame({
|
||||
tone?: 'square' | 'landscape';
|
||||
}) {
|
||||
return (
|
||||
<div className={`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))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}>
|
||||
<div
|
||||
className={`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))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={alt} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
@@ -149,6 +168,7 @@ function CatalogCard({
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -156,15 +176,19 @@ function CatalogCard({
|
||||
isSelectionMode: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
: disabled
|
||||
? 'border-white/10 bg-black/20'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -172,7 +196,9 @@ function CatalogCard({
|
||||
{media}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-base font-semibold text-white">{title}</div>
|
||||
<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] ${
|
||||
@@ -204,6 +230,96 @@ function getSearchPlaceholder(tab: ResultTab) {
|
||||
return '搜索';
|
||||
}
|
||||
|
||||
function compactTextList(values: Array<string | null | undefined>) {
|
||||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
||||
}
|
||||
|
||||
function buildOpeningSceneSearchText(
|
||||
profile: CustomWorldProfile,
|
||||
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
|
||||
) {
|
||||
return [
|
||||
campScene.name,
|
||||
campScene.description,
|
||||
campScene.dangerLevel,
|
||||
profile.playerGoal,
|
||||
profile.summary,
|
||||
'开局场景',
|
||||
'开局归处',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
|
||||
const relationshipSeed = creatorIntent?.keyCharacters[0];
|
||||
const relationshipText = relationshipSeed
|
||||
? compactTextList([
|
||||
relationshipSeed.name,
|
||||
relationshipSeed.role,
|
||||
relationshipSeed.relationToPlayer
|
||||
? `与玩家:${relationshipSeed.relationToPlayer}`
|
||||
: '',
|
||||
relationshipSeed.hiddenHook
|
||||
? `暗线:${relationshipSeed.hiddenHook}`
|
||||
: '',
|
||||
]).join(' · ')
|
||||
: '';
|
||||
const themeToneText = compactTextList([
|
||||
creatorIntent?.themeKeywords.join('、') || '',
|
||||
creatorIntent?.toneDirectives.join('、') || '',
|
||||
]).join(' / ');
|
||||
const playerOpeningText = compactTextList([
|
||||
creatorIntent?.playerPremise || '',
|
||||
creatorIntent?.openingSituation || '',
|
||||
]).join(';');
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'world-hook',
|
||||
label: '世界核心',
|
||||
value:
|
||||
creatorIntent?.worldHook ||
|
||||
profile.anchorPack?.worldSummary ||
|
||||
profile.summary,
|
||||
},
|
||||
{
|
||||
id: 'player-opening',
|
||||
label: '玩家开局',
|
||||
value: playerOpeningText || profile.playerGoal,
|
||||
},
|
||||
{
|
||||
id: 'theme-tone',
|
||||
label: '主题气质',
|
||||
value: themeToneText || profile.tone,
|
||||
},
|
||||
{
|
||||
id: 'core-conflict',
|
||||
label: '核心冲突',
|
||||
value:
|
||||
creatorIntent?.coreConflicts.join(';') ||
|
||||
profile.coreConflicts.join(';') ||
|
||||
profile.summary,
|
||||
},
|
||||
{
|
||||
id: 'relationship-seed',
|
||||
label: '关键关系',
|
||||
value:
|
||||
relationshipText ||
|
||||
profile.playableNpcs[0]?.relationshipHooks.join(';') ||
|
||||
profile.storyNpcs[0]?.relationshipHooks.join(';') ||
|
||||
'待补充',
|
||||
},
|
||||
{
|
||||
id: 'iconic-elements',
|
||||
label: '标志元素',
|
||||
value:
|
||||
creatorIntent?.iconicElements.join('、') ||
|
||||
profile.anchorPack?.motifDirectives.join('、') ||
|
||||
'待补充',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type CatalogRole =
|
||||
| CustomWorldProfile['playableNpcs'][number]
|
||||
| CustomWorldProfile['storyNpcs'][number];
|
||||
@@ -268,9 +384,12 @@ export function CustomWorldEntityCatalog({
|
||||
onDeleteLandmarks,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
readOnly = false,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(null);
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
|
||||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||||
|
||||
@@ -286,45 +405,101 @@ export function CustomWorldEntityCatalog({
|
||||
() => resolveCustomWorldLandmarkImageMap(profile),
|
||||
[profile],
|
||||
);
|
||||
const resolvedCampScene = useMemo(() => resolveCustomWorldCampScene(profile), [profile]);
|
||||
const resolvedCampScene = useMemo(
|
||||
() => resolveCustomWorldCampScene(profile),
|
||||
[profile],
|
||||
);
|
||||
const resolvedCampImageSrc = useMemo(
|
||||
() => resolveCustomWorldCampSceneImage(profile),
|
||||
[profile],
|
||||
);
|
||||
const previewCharacterById = useMemo(
|
||||
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
|
||||
() =>
|
||||
new Map(
|
||||
profile.playableNpcs.map((role, index) => [
|
||||
role.id,
|
||||
previewCharacters[index] ?? null,
|
||||
]),
|
||||
),
|
||||
[previewCharacters, profile.playableNpcs],
|
||||
);
|
||||
|
||||
const filteredPlayable = useMemo(
|
||||
() => profile.playableNpcs.filter(role =>
|
||||
!deferredSearch
|
||||
|| matchText(buildRoleSearchText(role), deferredSearch),
|
||||
),
|
||||
() =>
|
||||
profile.playableNpcs.filter(
|
||||
(role) =>
|
||||
!deferredSearch ||
|
||||
matchText(buildRoleSearchText(role), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.playableNpcs],
|
||||
);
|
||||
|
||||
const filteredStory = useMemo(
|
||||
() => profile.storyNpcs.filter(npc =>
|
||||
!deferredSearch
|
||||
|| matchText(buildRoleSearchText(npc), deferredSearch),
|
||||
),
|
||||
() =>
|
||||
profile.storyNpcs.filter(
|
||||
(npc) =>
|
||||
!deferredSearch ||
|
||||
matchText(buildRoleSearchText(npc), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
|
||||
const filteredLandmarks = useMemo(
|
||||
() => profile.landmarks.filter(landmark =>
|
||||
!deferredSearch
|
||||
|| matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
() =>
|
||||
profile.landmarks.filter(
|
||||
(landmark) =>
|
||||
!deferredSearch ||
|
||||
matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
),
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
const structuredFoundationEntries = useMemo(
|
||||
() => buildStructuredFoundationEntries(profile),
|
||||
[profile],
|
||||
);
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneEntry = {
|
||||
id: 'custom-world-opening-scene',
|
||||
kind: 'camp' as const,
|
||||
name: resolvedCampScene.name,
|
||||
description: resolvedCampScene.description,
|
||||
imageSrc: resolvedCampImageSrc,
|
||||
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
};
|
||||
const landmarkEntries = filteredLandmarks.map((landmark) => ({
|
||||
id: landmark.id,
|
||||
kind: 'landmark' as const,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
}));
|
||||
const allEntries = [openingSceneEntry, ...landmarkEntries];
|
||||
|
||||
if (!deferredSearch) {
|
||||
return allEntries;
|
||||
}
|
||||
|
||||
return allEntries.filter((entry) =>
|
||||
matchText(entry.searchText, deferredSearch),
|
||||
);
|
||||
}, [
|
||||
deferredSearch,
|
||||
filteredLandmarks,
|
||||
landmarkById,
|
||||
landmarkImageById,
|
||||
profile,
|
||||
resolvedCampImageSrc,
|
||||
resolvedCampScene,
|
||||
storyNpcById,
|
||||
]);
|
||||
|
||||
const creatorIntentSummary = useMemo(
|
||||
() => buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
() =>
|
||||
buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedCharacterNames = useMemo(
|
||||
@@ -340,15 +515,15 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
const counts = {
|
||||
world: 1,
|
||||
anchors: 1,
|
||||
playable: profile.playableNpcs.length,
|
||||
story: profile.storyNpcs.length,
|
||||
landmarks: profile.landmarks.length,
|
||||
landmarks: profile.landmarks.length + 1,
|
||||
} satisfies Record<ResultTab, number>;
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||||
const isBulkDeleteMode = bulkDeleteMode === bulkDeleteTab;
|
||||
const isBulkDeleteMode =
|
||||
bulkDeleteTab !== null && bulkDeleteMode === bulkDeleteTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
|
||||
@@ -365,7 +540,7 @@ export function CustomWorldEntityCatalog({
|
||||
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return;
|
||||
onProfileChange({
|
||||
...profile,
|
||||
playableNpcs: profile.playableNpcs.filter(role => role.id !== id),
|
||||
playableNpcs: profile.playableNpcs.filter((role) => role.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -411,14 +586,20 @@ export function CustomWorldEntityCatalog({
|
||||
return (
|
||||
<div 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">世界档案</div>
|
||||
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">{profile.name}</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">{profile.subtitle}</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">
|
||||
{profile.name}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
|
||||
{profile.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{RESULT_TABS.map(tab => (
|
||||
{RESULT_TABS.map((tab) => (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -426,16 +607,22 @@ export function CustomWorldEntityCatalog({
|
||||
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`}
|
||||
>
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">{counts[tab.id]}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">
|
||||
{counts[tab.id]}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab !== 'world' && activeTab !== 'anchors' ? (
|
||||
{activeTab !== 'world' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
|
||||
<SearchBox
|
||||
value={searchDraft}
|
||||
onChange={setSearchDraft}
|
||||
placeholder={getSearchPlaceholder(activeTab)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{isBulkDeleteMode ? (
|
||||
@@ -444,20 +631,25 @@ export function CustomWorldEntityCatalog({
|
||||
已选 {selectedBulkIds.length}
|
||||
</div>
|
||||
<SmallButton onClick={cancelBulkDelete}>取消</SmallButton>
|
||||
<SmallButton
|
||||
onClick={confirmBulkDelete}
|
||||
tone="rose"
|
||||
>
|
||||
<SmallButton onClick={confirmBulkDelete} tone="rose">
|
||||
删除选中
|
||||
</SmallButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
|
||||
{!readOnly && createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">
|
||||
{createActionLabel}
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||||
<SmallButton onClick={() => startBulkDelete(bulkDeleteTab)} tone="rose">
|
||||
{!readOnly &&
|
||||
bulkDeleteTab &&
|
||||
((bulkDeleteTab === 'story' && onDeleteStoryNpcs) ||
|
||||
(bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||||
<SmallButton
|
||||
onClick={() => startBulkDelete(bulkDeleteTab)}
|
||||
tone="rose"
|
||||
>
|
||||
批量删除
|
||||
</SmallButton>
|
||||
) : null}
|
||||
@@ -470,52 +662,91 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'world' ? (
|
||||
<>
|
||||
<Section title="世界概述" actions={<SmallButton onClick={() => onEditTarget({ kind: 'world' })} tone="sky">编辑</SmallButton>}>
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||||
<p>{profile.summary}</p>
|
||||
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">主线目标:{profile.playerGoal}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">世界基调:{profile.tone}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-zinc-400">原始设定:{profile.settingText}</div>
|
||||
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">
|
||||
主线目标:{profile.playerGoal}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
世界基调:{profile.tone}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{creatorIntentSummary ? (
|
||||
<Section title="创作锚点" subtitle="这部分来自创作者输入,AI 会围绕它继续展开世界。">
|
||||
<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>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section title="开局归处" subtitle="玩家进入自定义世界后的第一处落脚点,也会直接作为开场场景背景。">
|
||||
<Section
|
||||
title="原始设定"
|
||||
subtitle="把开局最关键的 6 个原始锚点拆开看,后续精修会更顺。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ImageFrame
|
||||
src={resolvedCampImageSrc}
|
||||
alt={resolvedCampScene.name}
|
||||
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
|
||||
tone="landscape"
|
||||
/>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-200">
|
||||
{resolvedCampScene.name}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-7 text-zinc-300">
|
||||
{resolvedCampScene.description}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{structuredFoundationEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
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">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-7 text-zinc-100">
|
||||
{entry.value || '待补充'}
|
||||
</div>
|
||||
</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}
|
||||
</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="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
|
||||
<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 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 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}</div>
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.landmarks.length + 1}
|
||||
</div>
|
||||
<div>场景</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,128 +757,72 @@ export function CustomWorldEntityCatalog({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'anchors' ? (
|
||||
<div className="space-y-3">
|
||||
<Section
|
||||
title="创作者输入"
|
||||
subtitle="这些内容来自创作者工作台,会作为 AI 继续展开世界的锚点。"
|
||||
>
|
||||
<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>
|
||||
</Section>
|
||||
|
||||
<Section title="关键势力">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyFactions.length ? (
|
||||
profile.creatorIntent.keyFactions.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名势力'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.publicGoal || '暂无目标说明'}</div>
|
||||
{entry.tension ? <div className="mt-1 text-zinc-400">冲突:{entry.tension}</div> : null}
|
||||
{entry.notes ? <div className="mt-1 text-zinc-500">补充:{entry.notes}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键势力锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键角色">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyCharacters.length ? (
|
||||
profile.creatorIntent.keyCharacters.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名角色'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.role || '未填写身份'}</div>
|
||||
{entry.publicMask ? <div className="mt-1 text-zinc-400">表面:{entry.publicMask}</div> : null}
|
||||
{entry.hiddenHook ? <div className="mt-1 text-zinc-400">暗线:{entry.hiddenHook}</div> : null}
|
||||
{entry.relationToPlayer ? <div className="mt-1 text-zinc-500">与玩家:{entry.relationToPlayer}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键角色锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键地点">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyLandmarks.length ? (
|
||||
profile.creatorIntent.keyLandmarks.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名地点'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.purpose || '未填写作用'}</div>
|
||||
{entry.mood ? <div className="mt-1 text-zinc-400">氛围:{entry.mood}</div> : null}
|
||||
{entry.secret ? <div className="mt-1 text-zinc-500">秘密:{entry.secret}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键地点锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
可扮演角色支持新增、删除与更换外观模板。
|
||||
{readOnly
|
||||
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
|
||||
: '可扮演角色支持新增、删除与更换外观模板。'}
|
||||
</div>
|
||||
{filteredPlayable.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
|
||||
) : (
|
||||
filteredPlayable.map(role => {
|
||||
const previewCharacter = previewCharacterById.get(role.id) ?? null;
|
||||
filteredPlayable.map((role) => {
|
||||
const previewCharacter =
|
||||
previewCharacterById.get(role.id) ?? null;
|
||||
|
||||
return (
|
||||
<div key={role.id}>
|
||||
<Section
|
||||
title={role.name}
|
||||
subtitle={role.title}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
)}
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
<SmallButton
|
||||
onClick={() => removePlayable(role.id, role.name)}
|
||||
tone="rose"
|
||||
>
|
||||
删除
|
||||
</SmallButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
|
||||
{previewCharacter ? (
|
||||
<CharacterAnimator state={AnimationState.RUN} character={previewCharacter} className="h-full w-full" imageClassName="object-bottom" />
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={previewCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -656,51 +831,86 @@ export function CustomWorldEntityCatalog({
|
||||
创作者锁定角色
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
|
||||
<div className="text-sm leading-6 text-zinc-300">
|
||||
{role.description}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||||
{role.backstory}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
|
||||
公开背景:{role.backstoryReveal.publicSummary || '未填写'}
|
||||
公开背景:
|
||||
{role.backstoryReveal.publicSummary || '未填写'}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">身份:{role.role}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">初始好感:{role.initialAffinity}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">性格:{role.personality}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">战斗:{role.combatStyle}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
身份:{role.role}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
初始好感:{role.initialAffinity}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
性格:{role.personality}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
战斗:{role.combatStyle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
动机:{role.motivation}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">动机:{role.motivation}</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">好感背景章节</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
好感背景章节
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.backstoryReveal.chapters.map(chapter => (
|
||||
<div key={`${role.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser}
|
||||
{role.backstoryReveal.chapters.map((chapter) => (
|
||||
<div
|
||||
key={`${role.id}-${chapter.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{chapter.affinityRequired} 好感 ·{' '}
|
||||
{chapter.title}:{chapter.teaser}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">技能</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
技能
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.skills.map(skill => (
|
||||
<div key={`${role.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{role.skills.map((skill) => (
|
||||
<div
|
||||
key={`${role.id}-${skill.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{skill.name} · {skill.style}:{skill.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">初始物品</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
初始物品
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.initialItems.map(item => (
|
||||
<div key={`${role.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description}
|
||||
{role.initialItems.map((item) => (
|
||||
<div
|
||||
key={`${role.id}-${item.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{item.name} x{item.quantity} · {item.category} ·{' '}
|
||||
{item.rarity}:{item.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{role.tags.map(tag => (
|
||||
<span key={`${role.id}-${tag}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
{role.tags.map((tag) => (
|
||||
<span
|
||||
key={`${role.id}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -720,7 +930,7 @@ export function CustomWorldEntityCatalog({
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
) : (
|
||||
filteredStory.map(npc => (
|
||||
filteredStory.map((npc) => (
|
||||
<div key={npc.id}>
|
||||
<CatalogCard
|
||||
title={npc.name}
|
||||
@@ -730,9 +940,19 @@ export function CustomWorldEntityCatalog({
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(npc.id)
|
||||
: onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })
|
||||
: readOnly
|
||||
? onEditTarget({
|
||||
kind: 'story',
|
||||
mode: 'edit',
|
||||
id: npc.id,
|
||||
})
|
||||
: onEditTarget({
|
||||
kind: 'story',
|
||||
mode: 'edit',
|
||||
id: npc.id,
|
||||
})
|
||||
}
|
||||
media={(
|
||||
media={
|
||||
<CustomWorldNpcPortrait
|
||||
npc={npc}
|
||||
profile={profile}
|
||||
@@ -741,7 +961,7 @@ export function CustomWorldEntityCatalog({
|
||||
scale={2.18}
|
||||
preferImageSrc
|
||||
/>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -751,29 +971,43 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<div className="space-y-3">
|
||||
{filteredLandmarks.length === 0 ? (
|
||||
{filteredSceneEntries.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
filteredLandmarks.map(landmark => (
|
||||
<div key={landmark.id}>
|
||||
filteredSceneEntries.map((scene) => (
|
||||
<div key={scene.id}>
|
||||
<CatalogCard
|
||||
title={landmark.name}
|
||||
description={landmark.description}
|
||||
isSelectionMode={isBulkDeleteMode}
|
||||
isSelected={selectedBulkIds.includes(landmark.id)}
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(landmark.id)
|
||||
: onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })
|
||||
title={scene.name}
|
||||
description={
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
}
|
||||
media={(
|
||||
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
|
||||
isSelected={
|
||||
scene.kind === 'landmark' &&
|
||||
selectedBulkIds.includes(scene.id)
|
||||
}
|
||||
onClick={() =>
|
||||
scene.kind === 'camp'
|
||||
? onEditTarget({ kind: 'world' })
|
||||
: isBulkDeleteMode
|
||||
? toggleBulkSelected(scene.id)
|
||||
: onEditTarget({
|
||||
kind: 'landmark',
|
||||
mode: 'edit',
|
||||
id: scene.id,
|
||||
})
|
||||
}
|
||||
media={
|
||||
<ImageFrame
|
||||
src={landmarkImageById.get(landmark.id) ?? landmark.imageSrc}
|
||||
alt={landmark.name}
|
||||
fallbackLabel={landmark.name.slice(0, 4) || '场景'}
|
||||
src={scene.imageSrc}
|
||||
alt={scene.name}
|
||||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||||
tone="landscape"
|
||||
/>
|
||||
)}
|
||||
}
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user