425
src/components/CustomWorldEntityCatalog.tsx
Normal file
425
src/components/CustomWorldEntityCatalog.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import { type ReactNode,useDeferredValue, useMemo, useState } from 'react';
|
||||
|
||||
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' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
interface CustomWorldEntityCatalogProps {
|
||||
profile: CustomWorldProfile;
|
||||
previewCharacters: Character[];
|
||||
activeTab: ResultTab;
|
||||
onActiveTabChange: (tab: ResultTab) => void;
|
||||
onEditTarget: (target: CustomWorldEditorTarget) => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
}
|
||||
|
||||
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||||
{ id: 'world', label: '世界' },
|
||||
{ id: 'playable', label: '可扮演角色' },
|
||||
{ id: 'story', label: '场景角色' },
|
||||
{ id: 'landmarks', label: '场景' },
|
||||
];
|
||||
|
||||
function Section({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="mt-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallButton({
|
||||
onClick,
|
||||
children,
|
||||
tone = 'default',
|
||||
}: {
|
||||
onClick: () => void;
|
||||
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';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
|
||||
<input
|
||||
value={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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageFrame({
|
||||
src,
|
||||
alt,
|
||||
fallbackLabel,
|
||||
tone = 'square',
|
||||
}: {
|
||||
src?: string;
|
||||
alt: string;
|
||||
fallbackLabel: string;
|
||||
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'}`}>
|
||||
{src ? (
|
||||
<img src={src} alt={alt} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
|
||||
{fallbackLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-5 py-6 text-center">
|
||||
<div className="text-sm text-zinc-300">{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function matchText(text: string, query: string) {
|
||||
return text.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
|
||||
function getSearchPlaceholder(tab: ResultTab) {
|
||||
if (tab === 'playable') return '搜索角色名称、称号、标签';
|
||||
if (tab === 'story') return '搜索场景角色名称、身份、动机';
|
||||
if (tab === 'landmarks') return '搜索场景名称、描述';
|
||||
return '搜索';
|
||||
}
|
||||
|
||||
export function CustomWorldEntityCatalog({
|
||||
profile,
|
||||
previewCharacters,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
onEditTarget,
|
||||
onProfileChange,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||||
|
||||
const previewCharacterById = useMemo(
|
||||
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
|
||||
[previewCharacters, profile.playableNpcs],
|
||||
);
|
||||
|
||||
const filteredPlayable = useMemo(
|
||||
() => profile.playableNpcs.filter(role =>
|
||||
!deferredSearch
|
||||
|| matchText([role.name, role.title, role.description, role.backstory, role.personality, ...role.tags].join(' '), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.playableNpcs],
|
||||
);
|
||||
|
||||
const filteredStory = useMemo(
|
||||
() => profile.storyNpcs.filter(npc =>
|
||||
!deferredSearch
|
||||
|| matchText([npc.name, npc.role, npc.description, npc.motivation, ...npc.relationshipHooks].join(' '), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
|
||||
const filteredLandmarks = useMemo(
|
||||
() => profile.landmarks.filter(landmark =>
|
||||
!deferredSearch
|
||||
|| matchText([landmark.name, landmark.description].join(' '), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.landmarks],
|
||||
);
|
||||
|
||||
const counts = {
|
||||
world: 1,
|
||||
playable: profile.playableNpcs.length,
|
||||
story: profile.storyNpcs.length,
|
||||
landmarks: profile.landmarks.length,
|
||||
} satisfies Record<ResultTab, number>;
|
||||
|
||||
const removePlayable = (id: string, name: string) => {
|
||||
if (profile.playableNpcs.length <= 1) {
|
||||
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return;
|
||||
onProfileChange({
|
||||
...profile,
|
||||
playableNpcs: profile.playableNpcs.filter(role => role.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const removeStoryNpc = (id: string, name: string) => {
|
||||
if (!window.confirm(`确认删除场景角色「${name}」吗?`)) return;
|
||||
onProfileChange({
|
||||
...profile,
|
||||
storyNpcs: profile.storyNpcs.filter(npc => npc.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const removeLandmark = (id: string, name: string) => {
|
||||
if (!window.confirm(`确认删除场景「${name}」吗?`)) return;
|
||||
onProfileChange({
|
||||
...profile,
|
||||
landmarks: profile.landmarks.filter(landmark => landmark.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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 => (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActiveTabChange(tab.id)}
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab !== 'world' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
|
||||
</div>
|
||||
{createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeTab === 'world' ? (
|
||||
<>
|
||||
<Section title="世界概述" actions={<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>
|
||||
</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}</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>
|
||||
</>
|
||||
) : 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">
|
||||
可扮演角色支持新增、删除与更换外观模板。
|
||||
</div>
|
||||
{filteredPlayable.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
|
||||
) : (
|
||||
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>
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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 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.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 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">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'story' ? (
|
||||
<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">
|
||||
每个场景角色都可以单独组合中世纪奇幻角色形象,并同步到进入世界后的展示效果。
|
||||
</div>
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
) : (
|
||||
filteredStory.map(npc => (
|
||||
<div key={npc.id}>
|
||||
<Section
|
||||
title={npc.name}
|
||||
subtitle={npc.role}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removeStoryNpc(npc.id, npc.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<CustomWorldNpcPortrait
|
||||
npc={{
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
}}
|
||||
visual={npc.visual}
|
||||
className="aspect-square"
|
||||
scale={2.18}
|
||||
/>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">动机:{npc.motivation}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{npc.relationshipHooks.map(hook => (
|
||||
<span key={`${npc.id}-${hook}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
{hook}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<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">
|
||||
场景图会同步用于结果页和正式世界中的背景展示。
|
||||
</div>
|
||||
{filteredLandmarks.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
filteredLandmarks.map(landmark => (
|
||||
<div key={landmark.id}>
|
||||
<Section
|
||||
title={landmark.name}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removeLandmark(landmark.id, landmark.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ImageFrame src={landmark.imageSrc} alt={landmark.name} fallbackLabel={landmark.name.slice(0, 4) || '场景'} tone="landscape" />
|
||||
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user