Files
Genarrative/src/components/CustomWorldEntityEditorModal.tsx
高物 09d4c0c31b
Some checks failed
CI / verify (push) Has been cancelled
11
2026-04-16 21:47:20 +08:00

4425 lines
132 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ChangeEvent } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
ROLE_TEMPLATE_CHARACTERS,
} from '../data/characterPresets';
import {
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../data/customWorldVisuals';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../editor/shared/editorApiClient';
import { fetchJson } from '../editor/shared/jsonClient';
import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
generateCustomWorldSceneNpc,
} from '../services/aiService';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
AnimationState,
type Character,
type CharacterAnimationConfig,
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
type CustomWorldRoleInitialItem,
type CustomWorldRoleRelation,
type CustomWorldRoleSkill,
CustomWorldSceneConnection,
type ItemRarity,
} from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel';
import {
type CharacterAnimationGenerationPayload,
generateCharacterAnimationDraft,
publishCharacterAnimationAssets,
} from './asset-studio/characterAssetWorkflowPersistence';
import { CharacterAnimator } from './CharacterAnimator';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import {
CustomWorldNpcPortrait,
CustomWorldNpcVisualEditor,
} from './CustomWorldNpcVisualEditor';
import { CustomWorldRoleAssetStudioModal } from './CustomWorldRoleAssetStudioModal';
import { PixelIcon } from './PixelIcon';
export type CustomWorldEditorTarget =
| { kind: 'world' }
| { kind: 'camp' }
| { kind: 'playable'; mode: 'create' }
| { kind: 'playable'; mode: 'edit'; id: string }
| { kind: 'story'; mode: 'create' }
| { kind: 'story'; mode: 'edit'; id: string }
| { kind: 'landmark'; mode: 'create' }
| { kind: 'landmark'; mode: 'edit'; id: string };
interface CustomWorldEntityEditorModalProps {
profile: CustomWorldProfile;
target: CustomWorldEditorTarget | null;
onClose: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
const [
BACKSTORY_UNLOCK_AFFINITY_EASED,
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [
{ value: 'common', label: '普通' },
{ value: 'uncommon', label: '优秀' },
{ value: 'rare', label: '稀有' },
{ value: 'epic', label: '史诗' },
{ value: 'legendary', label: '传说' },
];
const ITEM_RARITY_LABELS: Record<ItemRarity, string> = {
common: '普通',
uncommon: '优秀',
rare: '稀有',
epic: '史诗',
legendary: '传说',
};
const CARDINAL_CONNECTION_DIRECTIONS = [
'north',
'east',
'south',
'west',
] as const;
type CardinalConnectionDirection =
(typeof CARDINAL_CONNECTION_DIRECTIONS)[number];
const CARDINAL_CONNECTION_LABELS: Record<CardinalConnectionDirection, string> = {
north: '北',
east: '东',
south: '南',
west: '西',
};
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'entry';
}
function createEntryId(prefix: string, label: string, seed: number) {
return `${prefix}-${slugify(label || `${prefix}-${seed}`)}-${seed.toString(36)}`;
}
function parseCommaText(value: string) {
return [
...new Set(
value
.split(/[\n,]/u)
.map((item) => item.trim())
.filter(Boolean),
),
];
}
function normalizeConnectionDirection(
value: CustomWorldSceneConnection['relativePosition'],
): CardinalConnectionDirection | null {
switch (value) {
case 'north':
case 'forward':
return 'north';
case 'east':
case 'right':
return 'east';
case 'south':
case 'back':
return 'south';
case 'west':
case 'left':
return 'west';
default:
return null;
}
}
function buildConnectionSummary(
direction: CardinalConnectionDirection,
targetName?: string,
) {
if (!targetName) {
return '';
}
return `${CARDINAL_CONNECTION_LABELS[direction]}侧可前往${targetName}`;
}
function buildDirectionalConnections(
connections: CustomWorldSceneConnection[],
landmarks: Array<Pick<CustomWorldLandmark, 'id' | 'name'>>,
) {
const directionMap = new Map<CardinalConnectionDirection, CustomWorldSceneConnection>();
connections.forEach((connection) => {
const direction = normalizeConnectionDirection(connection.relativePosition);
if (!direction || directionMap.has(direction)) {
return;
}
const targetName =
landmarks.find((entry) => entry.id === connection.targetLandmarkId)?.name ??
'';
directionMap.set(direction, {
targetLandmarkId: connection.targetLandmarkId,
relativePosition: direction,
summary:
connection.summary ||
buildConnectionSummary(direction, targetName),
});
});
return CARDINAL_CONNECTION_DIRECTIONS.flatMap((direction) => {
const connection = directionMap.get(direction);
return connection ? [connection] : [];
});
}
function commaText(value: string[]) {
return value.join(', ');
}
function clampInitialAffinity(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(-40, Math.min(90, Math.round(parsed)));
}
function parseOptionalNumber(value: string) {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}
function getItemRarityLabel(rarity: ItemRarity) {
return ITEM_RARITY_LABELS[rarity] ?? '普通';
}
function getItemRarityCardClass(rarity: ItemRarity) {
switch (rarity) {
case 'legendary':
return 'border-amber-400/45 bg-amber-500/10';
case 'epic':
return 'border-fuchsia-400/40 bg-fuchsia-500/10';
case 'rare':
return 'border-sky-400/40 bg-sky-500/10';
case 'uncommon':
return 'border-emerald-400/35 bg-emerald-500/10';
default:
return 'border-white/10 bg-black/20';
}
}
function inferSkillActionTemplateId(skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>) {
const source = `${skill.name} ${skill.summary}`;
if (/[]/u.test(source)) {
return 'run';
}
if (/[]/u.test(source)) {
return 'hurt';
}
if (/[]/u.test(source)) {
return 'die';
}
if (/[]/u.test(source)) {
return 'idle';
}
return 'attack_slash';
}
function buildSkillActionPrompt(params: {
role: Pick<CustomWorldPlayableNpc | CustomWorldNpc, 'name' | 'title' | 'role' | 'description' | 'backstory' | 'personality' | 'motivation'>;
skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>;
}) {
const { role, skill } = params;
return [
`${role.name}${role.title || role.role}`,
`技能名称:${skill.name}`,
skill.summary ? `技能表现:${skill.summary}` : '',
role.description ? `角色气质:${role.description}` : '',
role.personality ? `性格补充:${role.personality}` : '',
role.motivation ? `动作目标:${role.motivation}` : '',
'横版 RPG 角色技能动作,角色轮廓稳定,动作起手明确,过程连贯,收招干净,镜头稳定。',
]
.filter(Boolean)
.join(' ');
}
function createRoleRelationDraft(seedLabel: string, index: number): CustomWorldRoleRelation {
return {
id: createEntryId('relation', seedLabel, Date.now() + index),
targetRoleId: '',
summary: '',
};
}
function deriveRelationshipHooksFromRelations(relations: CustomWorldRoleRelation[]) {
return relations
.map((item) => item.summary.trim())
.filter(Boolean)
.slice(0, 8);
}
function createRoleSkillDraft(seedLabel: string, index: number) {
return {
id: createEntryId('skill', seedLabel, Date.now() + index),
name: `新技能${index + 1}`,
summary: '',
style: '起手压制',
actionPromptText: '',
};
}
function createRoleInitialItemDraft(seedLabel: string, index: number) {
return {
id: createEntryId('item', seedLabel, Date.now() + index),
name: `新物品${index + 1}`,
category: '材料',
quantity: 1,
rarity: 'rare' as ItemRarity,
description: '',
tags: [],
iconSrc: undefined,
};
}
function createBackstoryChapterDraft(seedLabel: string, index: number) {
return {
id: createEntryId('backstory-chapter', seedLabel, Date.now() + index),
title: `背景片段${index + 1}`,
affinityRequired:
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[
Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1)
] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
};
}
function syncLandmarksWithStoryNpcs(
landmarks: CustomWorldLandmark[],
storyNpcs: CustomWorldProfile['storyNpcs'],
) {
return normalizeCustomWorldLandmarks({
landmarks,
storyNpcs,
});
}
function useDraft<T>(value: T) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
return [draft, setDraft] as const;
}
function readImageFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败。'));
reader.readAsDataURL(file);
});
}
function ModalShell({
title,
subtitle,
onClose,
children,
panelClassName = 'sm:max-w-2xl',
overlayClassName = 'z-[98]',
bodyClassName = '',
disableClose = false,
usePixelFont = false,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
className={`fixed inset-0 ${overlayClassName} flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={
disableClose
? undefined
: (event) => {
if (event.target === event.currentTarget) {
onClose();
}
}
}
>
<div
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4 sm:px-5">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">
{title}
</div>
{subtitle ? (
<div className="mt-1 text-xs leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div
className={`min-h-0 flex-1 overflow-y-auto p-4 sm:p-5 ${bodyClassName}`}
>
{children}
</div>
</div>
</div>
);
}
function _PortalModalShell(props: {
title: string;
subtitle?: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<ModalShell {...props} />, document.body);
}
function CompactDialogShell({
title,
onClose,
children,
overlayClassName = 'z-[140]',
disableClose = false,
usePixelFont = false,
}: {
title: string;
onClose: () => void;
children: ReactNode;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`}
onClick={
disableClose
? undefined
: (event) => {
if (event.target === event.currentTarget) {
onClose();
}
}
}
>
<div
className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
<div className="min-w-0 text-sm font-semibold text-white">
{title}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
function PortalCompactDialogShell(props: {
title: string;
onClose: () => void;
children: ReactNode;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<CompactDialogShell {...props} />, document.body);
}
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
const hasVisibleChildren = Children.toArray(children).some(
(child) => !(typeof child === 'string' && child.trim().length === 0),
);
if (!hasVisibleChildren) return null;
return (
<label className="block">
<div className="mb-2 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
{label}
</div>
{children}
</label>
);
}
function LabelWithInfo({
label,
info,
}: {
label: string;
info: string;
}) {
const [open, setOpen] = useState(false);
return (
<span className="flex flex-wrap items-center gap-2">
<span>{label}</span>
<button
type="button"
onClick={(event) => {
event.preventDefault();
setOpen((current) => !current);
}}
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/16 bg-black/20 text-[10px] text-zinc-200 transition-colors hover:text-white"
aria-label={`${label}说明`}
>
?
</button>
{open ? (
<span className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-[11px] font-normal tracking-normal text-zinc-300">
{info}
</span>
) : null}
</span>
);
}
function TextInput({
value,
onChange,
type = 'text',
placeholder,
}: {
value: string | number;
onChange: (value: string) => void;
type?: 'text' | 'number';
placeholder?: string;
}) {
return (
<input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
);
}
function TextArea({
value,
onChange,
rows = 4,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
<textarea
rows={rows}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
);
}
function SelectField({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors focus:border-sky-300/35"
>
{options.map((option) => (
<option key={`${option.value}-${option.label}`} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
function ImagePreview({
src,
alt,
fallbackLabel,
tone = 'square',
children,
}: {
src?: string;
alt: string;
fallbackLabel: string;
tone?: 'square' | 'landscape';
children?: ReactNode;
}) {
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.95),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
>
{src ? (
<img
src={src}
alt={alt}
loading="lazy"
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>
)}
{children ? (
<div className="pointer-events-none absolute inset-0">{children}</div>
) : null}
</div>
);
}
function ImageField({
label,
value,
onChange,
fallbackLabel,
tone = 'square',
showInput = true,
previewOverlay,
footer,
}: {
label: string;
value?: string;
onChange: (value: string) => void;
fallbackLabel: string;
tone?: 'square' | 'landscape';
showInput?: boolean;
previewOverlay?: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="space-y-3">
<div className="text-[11px] font-bold tracking-[0.14em] text-zinc-300">
{label}
</div>
<ImagePreview
src={value}
alt={label}
fallbackLabel={fallbackLabel}
tone={tone}
>
{previewOverlay}
</ImagePreview>
{showInput ? (
<TextInput
value={value ?? ''}
onChange={onChange}
placeholder="支持填写项目内图片路径或外链地址"
/>
) : null}
{footer}
</div>
);
}
function ActionButton({
label,
onClick,
tone = 'default',
disabled = false,
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'sky' | 'rose';
disabled?: boolean;
}) {
const toneClassName =
tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: tone === 'rose'
? 'border-rose-300/22 bg-rose-500/12 text-rose-50 hover:border-rose-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
return (
<button
type="button"
onPointerDown={(event) => {
event.stopPropagation();
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
disabled={disabled}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{label}
</button>
);
}
function SceneSparringPreview({ profile }: { profile: CustomWorldProfile }) {
const sparringCharacters = useMemo(() => {
const candidates = buildCustomWorldPlayableCharacters(profile);
if (candidates.length >= 2) {
return candidates.slice(0, 2);
}
if (candidates.length === 1) {
const firstCandidate = candidates[0];
if (!firstCandidate) {
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
}
const fallback =
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id !== firstCandidate.id,
) ??
ROLE_TEMPLATE_CHARACTERS[0] ??
firstCandidate;
return [firstCandidate, fallback];
}
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
}, [profile]);
const [leftCharacter, rightCharacter] = sparringCharacters;
if (!leftCharacter || !rightCharacter) {
return null;
}
return (
<>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,17,0.02)_0%,rgba(8,10,17,0.08)_42%,rgba(8,10,17,0.68)_100%)]" />
<div className="absolute left-3 top-3 rounded-full border border-white/12 bg-black/46 px-3 py-1 text-[10px] font-bold tracking-[0.18em] text-zinc-100">
</div>
<div className="absolute left-1/2 top-[58%] h-16 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-300/30 blur-2xl animate-pulse" />
<div className="absolute inset-x-0 bottom-0 h-20 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.72)_100%)]" />
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between px-2 pb-1 sm:px-4 sm:pb-2">
<div className="flex flex-col items-center">
<div className="h-24 w-24 sm:h-28 sm:w-28">
<CharacterAnimator
state={AnimationState.ATTACK}
character={leftCharacter}
className="h-full w-full"
imageClassName="object-bottom drop-shadow-[0_10px_18px_rgba(0,0,0,0.45)]"
/>
</div>
</div>
<div className="h-8 w-8 rounded-full bg-amber-300/18 blur-xl" />
<div className="flex flex-col items-center">
<div className="h-24 w-24 scale-x-[-1] sm:h-28 sm:w-28">
<CharacterAnimator
state={AnimationState.ATTACK}
character={rightCharacter}
className="h-full w-full"
imageClassName="object-bottom drop-shadow-[0_10px_18px_rgba(0,0,0,0.45)]"
/>
</div>
</div>
</div>
</>
);
}
function ScenePresetPickerModal({
selectedSrc,
presetImages,
onSelect,
onClose,
}: {
selectedSrc?: string;
presetImages: string[];
onSelect: (value: string) => void;
onClose: () => void;
}) {
return (
<ModalShell
title="基于预设素材修改"
onClose={onClose}
>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{presetImages.map((src, index) => {
const isSelected = src === selectedSrc;
return (
<button
key={src}
type="button"
onClick={() => {
onSelect(src);
onClose();
}}
className={`overflow-hidden rounded-2xl border text-left transition-colors ${
isSelected
? 'border-sky-300/55 bg-sky-500/10'
: 'border-white/10 bg-black/20 hover:border-white/25'
}`}
>
<div className="relative aspect-[16/9] overflow-hidden">
<img
src={src}
alt={`Scene preset ${index + 1}`}
loading="lazy"
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.82)_100%)] px-3 py-2 text-[11px] text-zinc-100">
#{(index + 1).toString().padStart(3, '0')}
</div>
{isSelected ? (
<div className="absolute right-2 top-2 rounded-full border border-sky-200/40 bg-black/55 px-2 py-0.5 text-[10px] font-semibold text-sky-50">
</div>
) : null}
</div>
</button>
);
})}
</div>
</ModalShell>
);
}
function SceneNpcPickerModal({
storyNpcs,
selectedNpcIds,
onApply,
onClose,
}: {
storyNpcs: CustomWorldNpc[];
selectedNpcIds: string[];
onApply: (npcIds: string[]) => void;
onClose: () => void;
}) {
const [draftSelection, setDraftSelection] = useState<string[]>(selectedNpcIds);
useEffect(() => {
setDraftSelection(selectedNpcIds);
}, [selectedNpcIds]);
return (
<ModalShell title="添加场景角色" onClose={onClose} panelClassName="sm:max-w-3xl">
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
{storyNpcs.map((npc) => {
const isSelected = draftSelection.includes(npc.id);
return (
<button
key={npc.id}
type="button"
onClick={() =>
setDraftSelection((current) =>
current.includes(npc.id)
? current.filter((entry) => entry !== npc.id)
: [...current, npc.id],
)
}
className={`rounded-2xl border px-4 py-4 text-left transition-colors ${
isSelected
? 'border-sky-300/35 bg-sky-500/10'
: 'border-white/8 bg-black/20 hover:border-white/20'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">
{npc.name}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{npc.title || npc.role || '未填写身份'}
</div>
{npc.description ? (
<div className="mt-2 text-xs leading-6 text-zinc-400">
{npc.description}
</div>
) : null}
</div>
<div
className={`rounded-full border px-2.5 py-1 text-[10px] ${
isSelected
? 'border-sky-300/30 bg-sky-500/12 text-sky-50'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{isSelected ? '已选择' : '未选择'}
</div>
</div>
</button>
);
})}
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label={`加入场景(${draftSelection.length}`}
onClick={() => {
onApply(draftSelection);
onClose();
}}
tone="sky"
/>
</div>
</div>
</ModalShell>
);
}
function ConnectionDirectionSlot({
direction,
targetName,
compact = false,
onClick,
}: {
direction: CardinalConnectionDirection;
targetName?: string;
compact?: boolean;
onClick?: (() => void) | null;
}) {
const content = (
<div
className={`rounded-2xl border px-3 py-3 text-center transition-colors ${
targetName
? 'border-sky-300/26 bg-sky-500/10 text-sky-50'
: 'border-dashed border-white/12 bg-black/20 text-zinc-500'
} ${compact ? 'min-h-[4.5rem]' : 'min-h-[5.5rem]'} ${
onClick ? 'hover:border-white/25 hover:text-white' : ''
}`}
>
<div className="text-[10px] font-bold tracking-[0.18em] text-zinc-400">
{CARDINAL_CONNECTION_LABELS[direction]}
</div>
<div
className={`mt-2 font-semibold leading-5 ${
compact ? 'text-xs' : 'text-sm'
}`}
>
{targetName || '空'}
</div>
</div>
);
if (!onClick) {
return content;
}
return (
<button type="button" onClick={onClick} className="w-full text-left">
{content}
</button>
);
}
function DirectionalSceneConnectionCompass({
centerName,
directionTargets,
compact = false,
onDirectionClick,
}: {
centerName: string;
directionTargets: Partial<Record<CardinalConnectionDirection, string>>;
compact?: boolean;
onDirectionClick?: ((direction: CardinalConnectionDirection) => void) | null;
}) {
return (
<div className="grid grid-cols-3 gap-3">
<div />
<ConnectionDirectionSlot
direction="north"
targetName={directionTargets.north}
compact={compact}
onClick={
onDirectionClick ? () => onDirectionClick('north') : undefined
}
/>
<div />
<ConnectionDirectionSlot
direction="west"
targetName={directionTargets.west}
compact={compact}
onClick={onDirectionClick ? () => onDirectionClick('west') : undefined}
/>
<div
className={`rounded-[1.6rem] border border-amber-300/22 bg-amber-500/10 px-4 py-4 text-center ${
compact ? 'min-h-[4.5rem]' : 'min-h-[5.5rem]'
}`}
>
<div className="text-[10px] font-bold tracking-[0.18em] text-amber-100/75">
</div>
<div
className={`mt-2 font-semibold leading-5 text-white ${
compact ? 'text-xs' : 'text-sm'
}`}
>
{centerName}
</div>
</div>
<ConnectionDirectionSlot
direction="east"
targetName={directionTargets.east}
compact={compact}
onClick={onDirectionClick ? () => onDirectionClick('east') : undefined}
/>
<div />
<ConnectionDirectionSlot
direction="south"
targetName={directionTargets.south}
compact={compact}
onClick={
onDirectionClick ? () => onDirectionClick('south') : undefined
}
/>
<div />
</div>
);
}
function SceneConnectionTargetPickerModal({
direction,
landmarks,
currentTargetId,
onSelect,
onRemove,
onClose,
}: {
direction: CardinalConnectionDirection;
landmarks: CustomWorldLandmark[];
currentTargetId?: string;
onSelect: (landmarkId: string) => void;
onRemove: () => void;
onClose: () => void;
}) {
return (
<ModalShell
title={`${CARDINAL_CONNECTION_LABELS[direction]}侧连接`}
onClose={onClose}
panelClassName="sm:max-w-xl"
>
<div className="space-y-3">
{landmarks.map((landmark) => {
const isSelected = landmark.id === currentTargetId;
return (
<button
key={landmark.id}
type="button"
onClick={() => {
onSelect(landmark.id);
onClose();
}}
className={`w-full rounded-2xl border px-4 py-4 text-left transition-colors ${
isSelected
? 'border-sky-300/35 bg-sky-500/10'
: 'border-white/8 bg-black/20 hover:border-white/20'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{landmark.name}
</div>
{landmark.description ? (
<div className="mt-1 text-xs leading-6 text-zinc-400">
{landmark.description}
</div>
) : null}
</div>
{isSelected ? (
<div className="rounded-full border border-sky-300/30 bg-sky-500/12 px-2.5 py-1 text-[10px] text-sky-50">
</div>
) : null}
</div>
</button>
);
})}
<div className="flex flex-col-reverse gap-3 pt-1 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
{currentTargetId ? (
<ActionButton
label="清空连接"
onClick={() => {
onRemove();
onClose();
}}
/>
) : null}
</div>
</div>
</ModalShell>
);
}
type WorldMapNodeLayout = {
id: string;
name: string;
description: string;
left: number;
top: number;
centerX: number;
centerY: number;
};
type WorldMapEdgeLayout = {
fromId: string;
toId: string;
};
const WORLD_MAP_NODE_WIDTH = 152;
const WORLD_MAP_NODE_HEIGHT = 88;
const WORLD_MAP_GRID_WIDTH = 196;
const WORLD_MAP_GRID_HEIGHT = 132;
const WORLD_MAP_PADDING = 48;
function getWorldMapDirectionOffset(direction: CardinalConnectionDirection) {
switch (direction) {
case 'north':
return { x: 0, y: -1 };
case 'east':
return { x: 1, y: 0 };
case 'south':
return { x: 0, y: 1 };
case 'west':
return { x: -1, y: 0 };
}
}
function buildWorldMapLayout(landmarks: CustomWorldLandmark[]) {
const directionalConnectionMap = new Map(
landmarks.map((landmark) => [
landmark.id,
buildDirectionalConnections(landmark.connections, landmarks),
]),
);
const landmarkById = new Map(landmarks.map((landmark) => [landmark.id, landmark]));
const positions = new Map<string, { x: number; y: number }>();
const occupied = new Map<string, string>();
const queue: string[] = [];
let clusterAnchorX = 0;
const reservePosition = (landmarkId: string, x: number, y: number) => {
const key = `${x},${y}`;
const occupiedBy = occupied.get(key);
if (!occupiedBy || occupiedBy === landmarkId) {
occupied.set(key, landmarkId);
positions.set(landmarkId, { x, y });
return { x, y };
}
for (let step = 1; step <= 6; step += 1) {
const candidates = [
{ x, y: y + step },
{ x, y: y - step },
{ x: x + step, y },
{ x: x - step, y },
];
const available = candidates.find(
(candidate) => !occupied.has(`${candidate.x},${candidate.y}`),
);
if (available) {
occupied.set(`${available.x},${available.y}`, landmarkId);
positions.set(landmarkId, available);
return available;
}
}
occupied.set(key, landmarkId);
positions.set(landmarkId, { x, y });
return { x, y };
};
landmarks.forEach((landmark) => {
if (positions.has(landmark.id)) {
return;
}
reservePosition(landmark.id, clusterAnchorX, 0);
queue.push(landmark.id);
let clusterMaxX = clusterAnchorX;
while (queue.length > 0) {
const currentId = queue.shift();
if (!currentId) {
continue;
}
const currentPosition = positions.get(currentId);
if (!currentPosition) {
continue;
}
const connections = directionalConnectionMap.get(currentId) ?? [];
connections.forEach((connection) => {
const target = landmarkById.get(connection.targetLandmarkId);
if (!target || positions.has(target.id)) {
return;
}
const offset = getWorldMapDirectionOffset(
connection.relativePosition as CardinalConnectionDirection,
);
const nextPosition = reservePosition(
target.id,
currentPosition.x + offset.x,
currentPosition.y + offset.y,
);
clusterMaxX = Math.max(clusterMaxX, nextPosition.x);
queue.push(target.id);
});
}
clusterAnchorX = clusterMaxX + 3;
});
const coordinateEntries = landmarks.map((landmark) => {
const position = positions.get(landmark.id) ?? reservePosition(landmark.id, 0, 0);
return { landmark, x: position.x, y: position.y };
});
const minX = Math.min(...coordinateEntries.map((entry) => entry.x), 0);
const maxX = Math.max(...coordinateEntries.map((entry) => entry.x), 0);
const minY = Math.min(...coordinateEntries.map((entry) => entry.y), 0);
const maxY = Math.max(...coordinateEntries.map((entry) => entry.y), 0);
const nodes: WorldMapNodeLayout[] = coordinateEntries.map(({ landmark, x, y }) => {
const left = WORLD_MAP_PADDING + (x - minX) * WORLD_MAP_GRID_WIDTH;
const top = WORLD_MAP_PADDING + (y - minY) * WORLD_MAP_GRID_HEIGHT;
return {
id: landmark.id,
name: landmark.name,
description: landmark.description,
left,
top,
centerX: left + WORLD_MAP_NODE_WIDTH / 2,
centerY: top + WORLD_MAP_NODE_HEIGHT / 2,
};
});
const edgeMap = new Map<string, WorldMapEdgeLayout>();
directionalConnectionMap.forEach((connections, sourceId) => {
connections.forEach((connection) => {
if (!landmarkById.has(connection.targetLandmarkId)) {
return;
}
const pairKey = [sourceId, connection.targetLandmarkId].sort().join('::');
if (!edgeMap.has(pairKey)) {
edgeMap.set(pairKey, {
fromId: sourceId,
toId: connection.targetLandmarkId,
});
}
});
});
return {
nodes,
edges: [...edgeMap.values()],
width:
WORLD_MAP_PADDING * 2 +
(maxX - minX) * WORLD_MAP_GRID_WIDTH +
WORLD_MAP_NODE_WIDTH,
height:
WORLD_MAP_PADDING * 2 +
(maxY - minY) * WORLD_MAP_GRID_HEIGHT +
WORLD_MAP_NODE_HEIGHT,
};
}
function WorldMapOverviewModal({
landmarks,
onClose,
}: {
landmarks: CustomWorldLandmark[];
onClose: () => void;
}) {
const { nodes, edges, width, height } = useMemo(
() => buildWorldMapLayout(landmarks),
[landmarks],
);
const nodeById = useMemo(
() => new Map(nodes.map((node) => [node.id, node])),
[nodes],
);
return (
<ModalShell title="世界地图" onClose={onClose} panelClassName="sm:max-w-6xl">
<div className="max-h-[72vh] overflow-auto rounded-3xl border border-white/8 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.08),transparent_42%),linear-gradient(180deg,rgba(5,8,15,0.96),rgba(6,10,18,0.92))] p-4">
<div
className="relative"
style={{
width: `${width}px`,
height: `${height}px`,
minWidth: '100%',
}}
>
<svg
className="absolute inset-0"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
>
{edges.map((edge) => {
const fromNode = nodeById.get(edge.fromId);
const toNode = nodeById.get(edge.toId);
if (!fromNode || !toNode) {
return null;
}
return (
<line
key={`${edge.fromId}-${edge.toId}`}
x1={fromNode.centerX}
y1={fromNode.centerY}
x2={toNode.centerX}
y2={toNode.centerY}
stroke="rgba(125, 211, 252, 0.45)"
strokeWidth="2"
strokeLinecap="round"
/>
);
})}
</svg>
{nodes.map((node) => (
<div
key={node.id}
className="absolute rounded-[1.6rem] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.94),rgba(9,13,24,0.98))] px-4 py-3 shadow-[0_16px_40px_rgba(0,0,0,0.35)]"
style={{
left: `${node.left}px`,
top: `${node.top}px`,
width: `${WORLD_MAP_NODE_WIDTH}px`,
minHeight: `${WORLD_MAP_NODE_HEIGHT}px`,
}}
>
<div className="text-sm font-semibold text-white">{node.name}</div>
{node.description ? (
<div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-400">
{node.description}
</div>
) : null}
</div>
))}
</div>
</div>
</ModalShell>
);
}
const FIXED_SCENE_IMAGE_SIZE = '1280*720';
function SceneImageGenerationModal({
profile,
landmark,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
onApply: (result: CustomWorldSceneImageResult) => void;
onClose: () => void;
}) {
const [userPrompt, setUserPrompt] = useDraft(
landmark.name.trim() || landmark.description.trim(),
);
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestResult, setLatestResult] =
useState<CustomWorldSceneImageResult | null>(null);
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
const originalImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
return resolveCustomWorldLandmarkImage(
profile,
landmark,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== landmark.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [landmark, profile]);
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageFileAsDataUrl(file);
setReferenceImageSrc(dataUrl);
setError(null);
} catch (uploadError) {
setError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const handleRequestClose = () => {
if (isGenerating) {
return;
}
if (latestResult) {
setIsExitConfirmOpen(true);
return;
}
onClose();
};
const handleGenerate = async () => {
if (!userPrompt.trim()) {
setError('请先描述想要生成的画面内容。');
return;
}
setIsGenerating(true);
setError(null);
try {
const result = await generateCustomWorldSceneImage({
profile,
landmark,
userPrompt,
size: FIXED_SCENE_IMAGE_SIZE,
...(referenceImageSrc ? { referenceImageSrc } : {}),
});
setLatestResult(result);
} catch (generationError) {
setError(
generationError instanceof Error
? generationError.message
: '场景图片生成失败,请稍后重试。',
);
} finally {
setIsGenerating(false);
}
};
const handleSave = () => {
if (!latestResult || isGenerating) {
return;
}
onApply(latestResult);
onClose();
};
return (
<>
<ModalShell
title={`智能生成:${landmark.name || '当前场景'}`}
onClose={handleRequestClose}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
usePixelFont
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
<div className="space-y-4">
<Field label="画面内容描述">
<TextArea
value={userPrompt}
onChange={(value) => setUserPrompt(value)}
rows={8}
placeholder="例如:雨夜的悬桥横跨黑色峡谷,桥下翻涌蓝绿色雾潮,远处有半坍塌塔楼与零星灯火。"
/>
</Field>
<Field label="自定义参考图(可选)">
<div className="space-y-3">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
{referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
<img
src={referenceImageSrc}
alt="自定义参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
</div>
<ActionButton
label="移除"
onClick={() => setReferenceImageSrc('')}
disabled={isGenerating}
/>
</div>
) : null}
</div>
</Field>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={previewImageSrc}
alt={landmark.name || '场景预览'}
fallbackLabel={
landmark.name ? landmark.name.slice(0, 4) : '场景'
}
tone="landscape"
/>
</div>
{latestResult ? (
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
退
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="保存"
onClick={handleSave}
disabled={!latestResult || isGenerating}
/>
<ActionButton
label={
isGenerating
? '正在生成...'
: latestResult
? '重新生成'
: '开始生成'
}
onClick={() => {
void handleGenerate();
}}
tone="sky"
disabled={isGenerating}
/>
</div>
</div>
</div>
</ModalShell>
{isExitConfirmOpen ? (
<PortalCompactDialogShell
title="确认退出"
onClose={() => setIsExitConfirmOpen(false)}
overlayClassName="z-[140]"
usePixelFont
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
退退
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsExitConfirmOpen(false)}
/>
<ActionButton
label="仍然退出"
onClick={() => {
setIsExitConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
function SaveBar({
onClose,
onSave,
extraAction,
showClose = true,
}: {
onClose: () => void;
onSave: () => void;
extraAction?: ReactNode;
showClose?: boolean;
}) {
return (
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div
className={`flex flex-col gap-3 ${
extraAction
? 'sm:flex-row sm:items-center sm:justify-between'
: 'sm:flex-row sm:justify-end'
}`}
>
{extraAction ? (
<div className="flex flex-col gap-3 sm:flex-row">{extraAction}</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
{showClose ? (
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
) : null}
<button
type="button"
onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
</div>
);
}
function SectionPanel({
title,
subtitle,
actions,
children,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
{title}
</div>
{subtitle ? (
<div className="mt-2 text-sm leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
{actions}
</div>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function buildRolePreviewCharacter(
role: CustomWorldPlayableNpc | CustomWorldNpc,
): Character | null {
const template =
'templateCharacterId' in role && role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(entry) => entry.id === role.templateCharacterId,
) ?? null
: null;
const portrait = role.imageSrc || template?.portrait;
if (!portrait) {
return null;
}
return {
id: role.id,
name: role.name,
title: role.title,
description: role.description,
backstory: role.backstory,
avatar: portrait,
portrait,
assetFolder: template?.assetFolder ?? 'custom-world',
assetVariant: template?.assetVariant ?? 'generated',
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: template?.animationMap ?? role.animationMap,
attributes: template?.attributes ?? {},
personality: role.personality,
skills: template?.skills ?? [],
adventureOpenings: template?.adventureOpenings ?? {},
} as Character;
}
function BackstoryRevealEditor({
value,
onChange,
}: {
value: CustomWorldPlayableNpc['backstoryReveal'];
onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void;
}) {
const updateChapter = (
index: number,
updater: (
chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => {
onChange({
...value,
chapters: value.chapters.map((chapter, chapterIndex) =>
chapterIndex === index ? updater(chapter) : chapter,
),
});
};
const addChapter = () => {
onChange({
...value,
chapters: [
...value.chapters,
createBackstoryChapterDraft('custom-role', value.chapters.length),
],
});
};
const removeChapter = (index: number) => {
if (value.chapters.length <= 1) {
window.alert('至少保留一个背景章节。');
return;
}
onChange({
...value,
chapters: value.chapters.filter(
(_chapter, chapterIndex) => chapterIndex !== index,
),
});
};
return (
<SectionPanel
title="背景故事"
actions={
<ActionButton label="新增章节" onClick={addChapter} tone="sky" />
}
>
<Field label="对外摘要">
<TextArea
value={value.publicSummary}
onChange={(nextValue) =>
onChange({
...value,
publicSummary: nextValue,
})
}
rows={3}
/>
</Field>
{value.chapters.map((chapter, index) => (
<div
key={`${chapter.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除章节"
onClick={() => removeChapter(index)}
/>
</div>
<Field label="章节标题">
<TextInput
value={chapter.title}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
title: nextValue,
}))
}
/>
</Field>
<Field label="解锁好感">
<TextInput
type="number"
value={chapter.affinityRequired}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
affinityRequired: clampInitialAffinity(
nextValue,
current.affinityRequired,
),
}))
}
/>
</Field>
<Field
label={
<LabelWithInfo
label="章节提示"
info="作用:作为该段背景尚未完全公开时的提示线索,帮助系统在关系推进或试探阶段提前埋钩子。是否展示给用户:会。展示位置:角色相关结果页、关系推进展示,以及后续部分剧情中的悬念化提示。"
/>
}
>
<TextArea
value={chapter.teaser}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
teaser: nextValue,
}))
}
rows={2}
/>
</Field>
<Field
label={
<LabelWithInfo
label="章节内容"
info="作用:作为该段背景真正解锁后的完整内容,提供系统后续剧情、关系推进和角色理解所需的核心信息。是否展示给用户:会。展示位置:对应背景片段被解锁后的角色内容面板,以及相关剧情正式揭露时。"
/>
}
>
<TextArea
value={chapter.content}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
content: nextValue,
}))
}
rows={3}
/>
</Field>
<Field
label={
<LabelWithInfo
label="剧情引用摘要"
info="作用:给叙事系统提供一段可被剧情直接抽取引用的压缩摘要,用来在剧情里自然提到这段背景,而不是整段照搬。是否展示给用户:通常不直接完整展示。展示位置:主要用于后续剧情文案、角色对话、事件摘要中的引用表达。"
/>
}
>
<TextArea
value={chapter.contextSnippet}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
contextSnippet: nextValue,
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function RoleRelationsEditor({
value,
onChange,
roleOptions,
labelSeed,
}: {
value: CustomWorldRoleRelation[];
onChange: (value: CustomWorldRoleRelation[]) => void;
roleOptions: Array<{ value: string; label: string }>;
labelSeed: string;
}) {
const updateRelation = (
index: number,
updater: (
relation: CustomWorldRoleRelation,
) => CustomWorldRoleRelation,
) => {
const nextRelations = value.map((relation, relationIndex) =>
relationIndex === index ? updater(relation) : relation,
);
onChange(nextRelations);
};
return (
<SectionPanel
title="与其他角色的关系"
actions={
<ActionButton
label="新增关系"
onClick={() =>
onChange([...value, createRoleRelationDraft(labelSeed, value.length)])
}
tone="sky"
/>
}
>
{value.length > 0 ? (
value.map((relation, index) => (
<div
key={`${relation.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除关系"
onClick={() =>
onChange(
value.filter(
(_relation, relationIndex) => relationIndex !== index,
),
)
}
/>
</div>
<Field label="关联角色">
<SelectField
value={relation.targetRoleId}
onChange={(nextValue) =>
updateRelation(index, (current) => ({
...current,
targetRoleId: nextValue,
}))
}
options={[
{ value: '', label: '未指定' },
...roleOptions,
]}
/>
</Field>
<Field label="关系文本">
<TextArea
value={relation.summary}
onChange={(nextValue) =>
updateRelation(index, (current) => ({
...current,
summary: nextValue,
}))
}
rows={2}
placeholder="例如:她与沈砺曾共同守过旧灯塔,但在沉船事件后分道扬镳。"
/>
</Field>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
</SectionPanel>
);
}
function RoleSkillEditorModal({
role,
skill,
onSave,
onClose,
}: {
role: CustomWorldPlayableNpc | CustomWorldNpc;
skill: CustomWorldRoleSkill;
onSave: (skill: CustomWorldRoleSkill) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(skill);
const [status, setStatus] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const previewCharacter = useMemo(() => {
const base = buildRolePreviewCharacter(role);
if (!base || !draft.actionPreviewConfig) {
return base;
}
return {
...base,
animationMap: {
...(base.animationMap ?? {}),
[AnimationState.ATTACK]: draft.actionPreviewConfig,
},
} satisfies Character;
}, [draft.actionPreviewConfig, role]);
const handleGenerateAction = async () => {
if (!role.imageSrc || !role.generatedVisualAssetId) {
setStatus('请先为角色生成并保存主图后,再生成技能动作。');
return;
}
setIsGenerating(true);
setStatus(null);
try {
const promptText =
draft.actionPromptText?.trim() ||
buildSkillActionPrompt({
role,
skill: draft,
});
const actionKey = `skill-${draft.id}`;
const templateId = inferSkillActionTemplateId(draft);
const generationResult = await generateCharacterAnimationDraft({
characterId: role.id,
strategy: 'image-to-video',
animation: actionKey,
promptText,
characterBriefText: [
role.name,
role.title,
role.role,
role.description,
role.backstory,
role.personality,
role.motivation,
]
.filter(Boolean)
.join(' / '),
actionTemplateId: templateId,
visualSource: role.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: 8,
fps: 10,
durationSeconds: 3,
loop: false,
useChromaKey: true,
resolution: '720P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
if (generationResult.strategy !== 'image-to-video') {
throw new Error('当前技能动作预览仅支持图生视频生成。');
}
const clip = await buildAnimationClipFromVideoSource(
generationResult.previewVideoPath,
{
animation: AnimationState.ATTACK,
fps: 10,
loop: false,
frameCount: 8,
applyChromaKey: true,
},
);
const publishResult = await publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
animations: {
[actionKey]: {
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
previewVideoPath: clip.previewVideoPath,
},
},
updateCharacterOverride: false,
});
setDraft((current) => ({
...current,
actionPromptText: promptText,
actionPreviewConfig: publishResult.animationMap[actionKey] as CharacterAnimationConfig,
}));
setStatus('技能动作预览已更新。');
} catch (error) {
setStatus(error instanceof Error ? error.message : '技能动作生成失败。');
} finally {
setIsGenerating(false);
}
};
return (
<ModalShell
title={`编辑技能:${skill.name || '未命名技能'}`}
onClose={onClose}
panelClassName="sm:max-w-3xl"
overlayClassName="z-[130]"
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="flex min-h-[10rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
{previewCharacter && draft.actionPreviewConfig ? (
<div className="h-40 w-40">
<CharacterAnimator
state={AnimationState.ATTACK}
character={previewCharacter}
className="h-full w-full"
/>
</div>
) : role.imageSrc ? (
<img
src={role.imageSrc}
alt={role.name}
className="max-h-40 w-full object-contain"
/>
) : (
<div className="text-sm text-zinc-500"></div>
)}
</div>
</div>
<Field label="技能名称">
<TextInput
value={draft.name}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, name: nextValue }))
}
/>
</Field>
<Field label="技能摘要">
<TextArea
value={draft.summary}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, summary: nextValue }))
}
rows={3}
/>
</Field>
<Field label="技能动作提示词">
<TextArea
value={
draft.actionPromptText ||
buildSkillActionPrompt({
role,
skill: draft,
})
}
onChange={(nextValue) =>
setDraft((current) => ({
...current,
actionPromptText: nextValue,
}))
}
rows={4}
/>
</Field>
{status ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{status}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label={isGenerating ? '生成中...' : '重新生成技能动作'}
onClick={() => {
void handleGenerateAction();
}}
disabled={isGenerating}
tone="sky"
/>
<ActionButton
label="保存"
onClick={() => {
onSave(draft);
onClose();
}}
tone="sky"
/>
</div>
</div>
</ModalShell>
);
}
function SkillListEditor({
role,
value,
onChange,
labelSeed,
}: {
role: CustomWorldPlayableNpc | CustomWorldNpc;
value: CustomWorldPlayableNpc['skills'];
onChange: (value: CustomWorldPlayableNpc['skills']) => void;
labelSeed: string;
}) {
const [editingSkillIndex, setEditingSkillIndex] = useState<number | null>(null);
const rolePreviewCharacter = useMemo(() => buildRolePreviewCharacter(role), [role]);
return (
<SectionPanel
title="技能"
actions={
<ActionButton
label="新增技能"
onClick={() =>
onChange([...value, createRoleSkillDraft(labelSeed, value.length)])
}
tone="sky"
/>
}
>
{value.length > 0 ? (
value.map((skill, index) => {
const previewCharacter =
rolePreviewCharacter && skill.actionPreviewConfig
? ({
...rolePreviewCharacter,
animationMap: {
...(rolePreviewCharacter.animationMap ?? {}),
[AnimationState.ATTACK]: skill.actionPreviewConfig,
},
} satisfies Character)
: rolePreviewCharacter;
return (
<button
key={`${skill.id}-${index}`}
type="button"
onClick={() => setEditingSkillIndex(index)}
className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition-colors hover:border-white/18"
>
<div className="grid gap-3 sm:grid-cols-[6.5rem_minmax(0,1fr)_auto] sm:items-center">
<div className="flex h-24 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/25 p-2">
{previewCharacter && skill.actionPreviewConfig ? (
<div className="h-20 w-20">
<CharacterAnimator
state={AnimationState.ATTACK}
character={previewCharacter}
className="h-full w-full"
/>
</div>
) : role.imageSrc ? (
<img
src={role.imageSrc}
alt={skill.name}
className="max-h-20 w-full object-contain"
/>
) : (
<div className="text-xs text-zinc-500"></div>
)}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{skill.name}
</div>
<div className="mt-2 line-clamp-2 text-sm leading-6 text-zinc-400">
{skill.summary || '点击补充技能摘要与技能动作。'}
</div>
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
<StatusBadge
label={
skill.actionPreviewConfig ? '动作已生成' : '待生成动作'
}
tone={skill.actionPreviewConfig ? 'ready' : 'idle'}
/>
</div>
</div>
</button>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
{editingSkillIndex !== null && value[editingSkillIndex] ? (
<RoleSkillEditorModal
role={role}
skill={value[editingSkillIndex]!}
onSave={(nextSkill) =>
onChange(
value.map((skill, skillIndex) =>
skillIndex === editingSkillIndex ? nextSkill : skill,
),
)
}
onClose={() => setEditingSkillIndex(null)}
/>
) : null}
</SectionPanel>
);
}
function StatusBadge({
label,
tone,
}: {
label: string;
tone: 'ready' | 'idle';
}) {
return (
<span
className={`rounded-full border px-2.5 py-1 text-[10px] ${
tone === 'ready'
? 'border-emerald-400/24 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{label}
</span>
);
}
function RoleInitialItemEditorModal({
item,
onSave,
onClose,
}: {
item: CustomWorldRoleInitialItem;
onSave: (item: CustomWorldRoleInitialItem) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(item);
const [assetPaths, setAssetPaths] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void fetchJson<{ assetPaths: string[] }>(
EDITOR_ITEM_CATALOG_API_PATH,
'读取物品图标目录失败',
)
.then((result) => {
if (!cancelled) {
setAssetPaths(result.assetPaths ?? []);
}
})
.catch((error) => {
if (!cancelled) {
setStatus(error instanceof Error ? error.message : '读取物品图标目录失败。');
}
});
return () => {
cancelled = true;
};
}, []);
const handleRegenerateIcon = () => {
if (assetPaths.length === 0) {
setStatus('当前没有可用的物品图标素材。');
return;
}
const normalizedCategory = draft.category.trim();
const categoryKeywords: Record<string, string[]> = {
: ['weapon', 'sword', 'axe', 'bow', 'wand', 'staff', 'dagger'],
: ['armor', 'helmet', 'shield', 'robe', 'boots', 'cloak'],
: ['ring', 'amulet', 'gem', 'relic', 'necklace'],
: ['potion', 'bottle', 'food', 'mushroom', 'apple', 'bandage'],
: ['ore', 'stone', 'wood', 'leaf', 'flower', 'material'],
: ['scroll', 'book', 'crystal', 'magic', 'bag'],
: ['artifact', 'legend', 'treasure', 'relic'],
};
const pool = assetPaths.filter((assetPath) => {
const lower = assetPath.toLowerCase();
const keywords = categoryKeywords[normalizedCategory] ?? [];
return keywords.length === 0 || keywords.some((keyword) => lower.includes(keyword));
});
const candidates = pool.length > 0 ? pool : assetPaths;
const currentIndex = candidates.findIndex(
(entry) => `/${entry}` === (draft.iconSrc ?? ''),
);
const seed = hashText(
[draft.name, draft.category, draft.description, draft.tags.join('|')].join('::'),
);
const nextIndex =
currentIndex >= 0
? (currentIndex + 1) % candidates.length
: seed % candidates.length;
setDraft((current) => ({
...current,
iconSrc: `/${candidates[nextIndex]!}`,
}));
setStatus('物品图标已更新。');
};
return (
<ModalShell
title={`编辑物品:${item.name || '未命名物品'}`}
onClose={onClose}
panelClassName="sm:max-w-3xl"
overlayClassName="z-[130]"
>
<div className="space-y-4">
<div className={`rounded-2xl border p-4 ${getItemRarityCardClass(draft.rarity)}`}>
<div className="flex items-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
{draft.iconSrc ? (
<PixelIcon src={draft.iconSrc} className="h-12 w-12" />
) : (
<div className="text-xs text-zinc-500"></div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-white">{draft.name}</div>
<div className="mt-1 text-xs text-zinc-300">
{getItemRarityLabel(draft.rarity)} / {draft.quantity}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{draft.tags.map((tag) => (
<span
key={`${draft.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<ActionButton
label="重新生成图标"
onClick={handleRegenerateIcon}
tone="sky"
/>
</div>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, name: nextValue }))
}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="分类">
<TextInput
value={draft.category}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, category: nextValue }))
}
/>
</Field>
<Field label="稀有度">
<SelectField
value={draft.rarity}
onChange={(nextValue) =>
setDraft((current) => ({
...current,
rarity: nextValue as ItemRarity,
}))
}
options={ITEM_RARITY_OPTIONS}
/>
</Field>
</div>
<Field label="数量">
<TextInput
type="number"
value={draft.quantity}
onChange={(nextValue) =>
setDraft((current) => ({
...current,
quantity: Math.max(1, parseOptionalNumber(nextValue) ?? current.quantity),
}))
}
/>
</Field>
<Field label="描述">
<TextArea
value={draft.description}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, description: nextValue }))
}
rows={3}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
onChange={(nextValue) =>
setDraft((current) => ({
...current,
tags: parseCommaText(nextValue),
}))
}
rows={2}
/>
</Field>
{status ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{status}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label="保存"
onClick={() => {
onSave(draft);
onClose();
}}
tone="sky"
/>
</div>
</div>
</ModalShell>
);
}
function InitialItemsEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['initialItems'];
onChange: (value: CustomWorldPlayableNpc['initialItems']) => void;
labelSeed: string;
}) {
const [editingItemIndex, setEditingItemIndex] = useState<number | null>(null);
return (
<SectionPanel
title="物品"
actions={
<ActionButton
label="新增物品"
onClick={() =>
onChange([
...value,
createRoleInitialItemDraft(labelSeed, value.length),
])
}
tone="sky"
/>
}
>
{value.length > 0 ? (
value.map((item, index) => (
<button
key={`${item.id}-${index}`}
type="button"
onClick={() => setEditingItemIndex(index)}
className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors hover:border-white/18 ${getItemRarityCardClass(item.rarity)}`}
>
<div className="grid gap-3 sm:grid-cols-[4.5rem_minmax(0,1fr)_auto] sm:items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-2">
{item.iconSrc ? (
<PixelIcon src={item.iconSrc} className="h-10 w-10" />
) : (
<div className="text-[10px] text-zinc-500"></div>
)}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-white">{item.name}</div>
<div className="mt-1 text-xs text-zinc-300">
{getItemRarityLabel(item.rarity)}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{item.tags.map((tag) => (
<span
key={`${item.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
</div>
</div>
<div className="text-xs text-zinc-200">x{item.quantity}</div>
</div>
</button>
))
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
{editingItemIndex !== null && value[editingItemIndex] ? (
<RoleInitialItemEditorModal
item={value[editingItemIndex]!}
onSave={(nextItem) =>
onChange(
value.map((item, itemIndex) =>
itemIndex === editingItemIndex ? nextItem : item,
),
)
}
onClose={() => setEditingItemIndex(null)}
/>
) : null}
</SectionPanel>
);
}
function StoryNpcVisualEditorModal({
npc,
visual,
onChange,
onOpenAiStudio,
onClose,
}: {
npc: CustomWorldNpc;
visual: NonNullable<CustomWorldNpc['visual']>;
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
onOpenAiStudio?: () => void;
onClose: () => void;
}) {
return (
<ModalShell
title={`修改形象:${npc.name}`}
subtitle="在独立面板中组合中世纪奇幻角色形象,左侧预览会保持吸顶。"
onClose={onClose}
panelClassName="sm:max-w-6xl"
overlayClassName="z-[99]"
>
<CustomWorldNpcVisualEditor
npc={{
id: npc.id,
name: npc.name,
role: npc.role,
description: npc.description,
}}
value={visual}
onChange={onChange}
onAiGenerate={() => {
onClose();
onOpenAiStudio?.();
}}
/>
</ModalShell>
);
}
function WorldEditor({
profile,
onSave,
onClose,
}: {
profile: CustomWorldProfile;
onSave: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(profile);
return (
<ModalShell
title="编辑世界信息"
subtitle="修改后的内容会直接反映在结果页,并会作为进入世界前的最终档案。"
onClose={onClose}
>
<div className="space-y-4">
<Field label="世界名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="副标题">
<TextInput
value={draft.subtitle}
onChange={(value) =>
setDraft((current) => ({ ...current, subtitle: value }))
}
/>
</Field>
<Field label="世界概述">
<TextArea
value={draft.summary}
onChange={(value) =>
setDraft((current) => ({ ...current, summary: value }))
}
rows={4}
/>
</Field>
<Field label="世界基调">
<TextArea
value={draft.tone}
onChange={(value) =>
setDraft((current) => ({ ...current, tone: value }))
}
rows={3}
/>
</Field>
<Field label="主线目标">
<TextArea
value={draft.playerGoal}
onChange={(value) =>
setDraft((current) => ({ ...current, playerGoal: value }))
}
rows={3}
/>
</Field>
<Field label="玩家原始设定">
<TextArea
value={draft.settingText}
onChange={(value) =>
setDraft((current) => ({
...current,
settingText: value,
creatorIntent: current.creatorIntent
? {
...current.creatorIntent,
rawSettingText: value,
}
: current.creatorIntent,
}))
}
rows={4}
/>
</Field>
<SaveBar
onClose={onClose}
onSave={() => {
onSave(draft);
onClose();
}}
/>
</div>
</ModalShell>
);
}
function CampSceneEditor({
profile,
onSaveProfile,
onClose,
}: {
profile: CustomWorldProfile;
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(resolveCustomWorldCampScene(profile));
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedDraftImageSrc = useMemo(
() =>
draft.imageSrc?.trim()
? draft.imageSrc
: resolveCustomWorldCampSceneImage({
...profile,
camp: draft,
}),
[draft, profile],
);
const draftSnapshot = useMemo(
() => JSON.stringify(draft),
[draft],
);
const initialSnapshot = useMemo(
() => JSON.stringify(resolveCustomWorldCampScene(profile)),
[profile],
);
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
const campSceneDraft = useMemo<CustomWorldLandmark>(
() => ({
id: 'custom-scene-camp',
name: draft.name,
description: draft.description,
dangerLevel: draft.dangerLevel,
imageSrc: draft.imageSrc,
sceneNpcIds: [],
connections: [],
}),
[draft],
);
const handleRequestClose = () => {
if (!hasUnsavedChanges) {
onClose();
return;
}
setIsCloseConfirmOpen(true);
};
return (
<>
<ModalShell
title={`编辑场景:${draft.name || '开局场景'}`}
onClose={handleRequestClose}
>
<div className="space-y-4">
<ImageField
label="场景图片"
value={resolvedDraftImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
imageSrc: value || undefined,
}))
}
fallbackLabel={draft.name ? draft.name.slice(0, 4) : '场景'}
tone="landscape"
showInput={false}
previewOverlay={
<SceneSparringPreview
profile={{
...profile,
camp: draft,
}}
/>
}
footer={
<div className="flex flex-wrap gap-3">
<ActionButton
label="基于预设素材修改"
onClick={() => setIsPresetPickerOpen(true)}
tone="sky"
/>
<ActionButton
label="AI生成"
onClick={() => setIsAiGenerateOpen(true)}
/>
</div>
}
/>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="描述">
<TextArea
value={draft.description}
onChange={(value) =>
setDraft((current) => ({ ...current, description: value }))
}
rows={5}
/>
</Field>
<SaveBar
onClose={handleRequestClose}
onSave={() => {
onSaveProfile({
...profile,
camp: draft,
});
onClose();
}}
showClose={false}
/>
{isPresetPickerOpen ? (
<ScenePresetPickerModal
selectedSrc={draft.imageSrc}
presetImages={presetImages}
onSelect={(value) =>
setDraft((current) => ({ ...current, imageSrc: value }))
}
onClose={() => setIsPresetPickerOpen(false)}
/>
) : null}
{isAiGenerateOpen ? (
<SceneImageGenerationModal
profile={{
...profile,
camp: draft,
}}
landmark={campSceneDraft}
onApply={(result) => {
setDraft((current) => ({
...current,
imageSrc: result.imageSrc,
}));
}}
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
</div>
</ModalShell>
{isCloseConfirmOpen ? (
<PortalCompactDialogShell
title="确认关闭"
onClose={() => setIsCloseConfirmOpen(false)}
overlayClassName="z-[140]"
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsCloseConfirmOpen(false)}
/>
<ActionButton
label="确认关闭"
onClick={() => {
setIsCloseConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
function PlayableNpcEditor({
profile,
npc,
mode,
onSave,
onClose,
}: {
profile: CustomWorldProfile;
npc: CustomWorldPlayableNpc;
mode: 'create' | 'edit';
onSave: (npc: CustomWorldPlayableNpc) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(npc);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const selectedTemplate =
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
) ??
ROLE_TEMPLATE_CHARACTERS[0] ??
null;
const roleOptions = useMemo(
() =>
[...profile.playableNpcs, ...profile.storyNpcs]
.filter((role) => role.id !== draft.id)
.map((role) => ({
value: role.id,
label: [role.name, role.title || role.role].filter(Boolean).join(' / '),
})),
[draft.id, profile.playableNpcs, profile.storyNpcs],
);
const roleRelations =
draft.relations ??
draft.relationshipHooks.map((summary, index) => ({
id: createEntryId('relation', draft.id, index),
targetRoleId: '',
summary,
}));
const handleRequestClose = () => {
setIsCloseConfirmOpen(true);
};
return (
<>
<ModalShell
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
onClose={handleRequestClose}
disableClose={isAiAssetStudioOpen || isCloseConfirmOpen}
>
<div className="space-y-4">
{selectedTemplate ? (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
</div>
<div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img
src={draft.imageSrc || selectedTemplate.portrait}
alt={selectedTemplate.name}
className="h-28 w-full object-cover object-top"
/>
</div>
<div className="min-w-0">
<div className="text-base font-semibold text-white">
{selectedTemplate.name}
</div>
<div className="mt-1 text-sm text-zinc-400">
{selectedTemplate.title}
</div>
<div className="mt-3 text-sm leading-6 text-zinc-300">
{selectedTemplate.description}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-3">
<ActionButton
label="AI生成"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
</div>
</div>
</div>
) : null}
<Field label="外观模板">
<SelectField
value={draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id ?? ''}
onChange={(value) =>
setDraft((current) => ({
...current,
templateCharacterId: value,
}))
}
options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({
value: character.id,
label: `${character.name} / ${character.title}`,
}))}
/>
</Field>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="头衔 / 世界身份">
<TextInput
value={draft.title || draft.role}
onChange={(value) =>
setDraft((current) => ({
...current,
title: value,
role: value,
}))
}
/>
</Field>
<Field label="简介">
<TextArea
value={draft.description}
onChange={(value) =>
setDraft((current) => ({ ...current, description: value }))
}
rows={3}
/>
</Field>
<Field label="背景">
<TextArea
value={draft.backstory}
onChange={(value) =>
setDraft((current) => ({ ...current, backstory: value }))
}
rows={5}
/>
</Field>
<Field label="性格">
<TextArea
value={draft.personality}
onChange={(value) =>
setDraft((current) => ({ ...current, personality: value }))
}
rows={3}
/>
</Field>
<Field label="当前动机">
<TextArea
value={draft.motivation}
onChange={(value) =>
setDraft((current) => ({ ...current, motivation: value }))
}
rows={3}
/>
</Field>
<Field label="初始好感">
<TextInput
type="number"
value={draft.initialAffinity}
onChange={(value) =>
setDraft((current) => ({
...current,
initialAffinity: clampInitialAffinity(
value,
current.initialAffinity,
),
}))
}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
onChange={(value) =>
setDraft((current) => ({
...current,
tags: parseCommaText(value),
}))
}
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<RoleRelationsEditor
value={roleRelations}
onChange={(relations) =>
setDraft((current) => ({
...current,
relations,
relationshipHooks: deriveRelationshipHooksFromRelations(relations),
}))
}
roleOptions={roleOptions}
labelSeed={draft.name || draft.id}
/>
<SkillListEditor
role={draft}
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={handleRequestClose}
onSave={() => {
onSave({
...draft,
templateCharacterId:
draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id,
});
onClose();
}}
showClose={false}
/>
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="playable"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
{isCloseConfirmOpen ? (
<PortalCompactDialogShell
title="确认关闭"
onClose={() => setIsCloseConfirmOpen(false)}
overlayClassName="z-[140]"
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsCloseConfirmOpen(false)}
/>
<ActionButton
label="确认关闭"
onClick={() => {
setIsCloseConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
function StoryNpcEditor({
profile,
npc,
mode,
onSave,
onClose,
}: {
profile: CustomWorldProfile;
npc: CustomWorldNpc;
mode: 'create' | 'edit';
onSave: (npc: CustomWorldNpc) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(npc);
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const roleOptions = useMemo(
() =>
[...profile.playableNpcs, ...profile.storyNpcs]
.filter((role) => role.id !== draft.id)
.map((role) => ({
value: role.id,
label: [role.name, role.title || role.role].filter(Boolean).join(' / '),
})),
[draft.id, profile.playableNpcs, profile.storyNpcs],
);
const roleRelations =
draft.relations ??
draft.relationshipHooks.map((summary, index) => ({
id: createEntryId('relation', draft.id, index),
targetRoleId: '',
summary,
}));
const handleRequestClose = () => {
setIsCloseConfirmOpen(true);
};
return (
<>
<ModalShell
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
onClose={handleRequestClose}
disableClose={
isVisualEditorOpen || isAiAssetStudioOpen || isCloseConfirmOpen
}
>
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
</div>
<div className="mt-3 grid gap-4 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
<div className="flex justify-center">
<CustomWorldNpcPortrait
npc={draft}
visual={draft.visual}
className="aspect-square w-full max-w-[9.5rem]"
scale={2.05}
preferImageSrc
/>
</div>
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
label="基于预设素材修改"
onClick={() => setIsVisualEditorOpen(true)}
tone="sky"
/>
<ActionButton
label="AI生成"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
<div className="flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
</div>
</div>
</div>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="头衔 / 世界身份">
<TextInput
value={draft.title || draft.role}
onChange={(value) =>
setDraft((current) => ({
...current,
title: value,
role: value,
}))
}
/>
</Field>
<Field label="描述">
<TextArea
value={draft.description}
onChange={(value) =>
setDraft((current) => ({ ...current, description: value }))
}
rows={4}
/>
</Field>
<Field label="背景">
<TextArea
value={draft.backstory}
onChange={(value) =>
setDraft((current) => ({ ...current, backstory: value }))
}
rows={4}
/>
</Field>
<Field label="性格">
<TextArea
value={draft.personality}
onChange={(value) =>
setDraft((current) => ({ ...current, personality: value }))
}
rows={3}
/>
</Field>
<Field label="动机">
<TextArea
value={draft.motivation}
onChange={(value) =>
setDraft((current) => ({ ...current, motivation: value }))
}
rows={4}
/>
</Field>
<Field label="初始好感">
<TextInput
type="number"
value={draft.initialAffinity}
onChange={(value) =>
setDraft((current) => ({
...current,
initialAffinity: clampInitialAffinity(
value,
current.initialAffinity,
),
}))
}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
onChange={(value) =>
setDraft((current) => ({
...current,
tags: parseCommaText(value),
}))
}
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<RoleRelationsEditor
value={roleRelations}
onChange={(relations) =>
setDraft((current) => ({
...current,
relations,
relationshipHooks: deriveRelationshipHooksFromRelations(relations),
}))
}
roleOptions={roleOptions}
labelSeed={draft.name || draft.id}
/>
<SkillListEditor
role={draft}
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={handleRequestClose}
onSave={() => {
onSave(draft);
onClose();
}}
showClose={false}
/>
{isVisualEditorOpen ? (
<StoryNpcVisualEditorModal
npc={draft}
visual={
draft.visual ??
buildDefaultCustomWorldNpcVisual({
id: draft.id,
name: draft.name,
role: draft.role,
description: draft.description,
})
}
onChange={(visual) =>
setDraft((current) => ({ ...current, visual }))
}
onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
onClose={() => setIsVisualEditorOpen(false)}
/>
) : null}
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="story"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
{isCloseConfirmOpen ? (
<PortalCompactDialogShell
title="确认关闭"
onClose={() => setIsCloseConfirmOpen(false)}
overlayClassName="z-[140]"
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsCloseConfirmOpen(false)}
/>
<ActionButton
label="确认关闭"
onClick={() => {
setIsCloseConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
function LandmarkEditor({
profile,
landmark,
mode,
onSaveProfile,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
mode: 'create' | 'edit';
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(landmark);
const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs);
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const [isNpcPickerOpen, setIsNpcPickerOpen] = useState(false);
const [isWorldMapOpen, setIsWorldMapOpen] = useState(false);
const [isGeneratingSceneNpc, setIsGeneratingSceneNpc] = useState(false);
const [activeConnectionDirection, setActiveConnectionDirection] =
useState<CardinalConnectionDirection | null>(null);
const [npcEditorState, setNpcEditorState] = useState<{
mode: 'create' | 'edit';
npc: CustomWorldNpc;
} | null>(null);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedDraftImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === draft.id,
);
return resolveCustomWorldLandmarkImage(
profile,
draft,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== draft.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [draft, profile]);
const storyNpcById = useMemo(
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
[draftStoryNpcs],
);
const availableTargetLandmarks = useMemo(
() => profile.landmarks.filter((entry) => entry.id !== draft.id),
[draft.id, profile.landmarks],
);
const editableProfile = useMemo(() => {
const nextLandmarks =
mode === 'create'
? [...profile.landmarks, draft]
: profile.landmarks.map((entry) =>
entry.id === draft.id ? draft : entry,
);
return {
...profile,
storyNpcs: draftStoryNpcs,
landmarks: nextLandmarks,
};
}, [draft, draftStoryNpcs, mode, profile]);
const directionalConnections = useMemo(
() => buildDirectionalConnections(draft.connections, availableTargetLandmarks),
[availableTargetLandmarks, draft.connections],
);
const directionTargetLabels = useMemo(
() =>
Object.fromEntries(
directionalConnections.map((connection) => [
connection.relativePosition,
availableTargetLandmarks.find(
(entry) => entry.id === connection.targetLandmarkId,
)?.name || '',
]),
) as Partial<Record<CardinalConnectionDirection, string>>,
[availableTargetLandmarks, directionalConnections],
);
const activeDirectionConnection = useMemo(
() =>
activeConnectionDirection
? directionalConnections.find(
(connection) => connection.relativePosition === activeConnectionDirection,
) || null
: null,
[activeConnectionDirection, directionalConnections],
);
const initialDirectionalConnections = useMemo(
() => buildDirectionalConnections(landmark.connections, availableTargetLandmarks),
[availableTargetLandmarks, landmark.connections],
);
const initialLandmarkSnapshot = useMemo(
() =>
JSON.stringify({
...landmark,
sceneNpcIds: [...new Set(landmark.sceneNpcIds)],
connections: initialDirectionalConnections,
}),
[initialDirectionalConnections, landmark],
);
const currentLandmarkSnapshot = useMemo(
() =>
JSON.stringify({
...draft,
sceneNpcIds: [...new Set(draft.sceneNpcIds)],
connections: directionalConnections,
}),
[directionalConnections, draft],
);
const initialStoryNpcSnapshot = useMemo(
() => JSON.stringify(profile.storyNpcs),
[profile.storyNpcs],
);
const currentStoryNpcSnapshot = useMemo(
() => JSON.stringify(draftStoryNpcs),
[draftStoryNpcs],
);
const hasUnsavedChanges =
initialLandmarkSnapshot !== currentLandmarkSnapshot ||
initialStoryNpcSnapshot !== currentStoryNpcSnapshot;
const handleRequestClose = () => {
if (!hasUnsavedChanges) {
onClose();
return;
}
setIsCloseConfirmOpen(true);
};
const removeSceneNpc = (npcId: string) => {
setDraft((current) => ({
...current,
sceneNpcIds: current.sceneNpcIds.filter((entry) => entry !== npcId),
}));
};
const applySceneNpcSelection = (npcIds: string[]) => {
setDraft((current) => ({
...current,
sceneNpcIds: [...new Set(npcIds)],
}));
};
const updateDirectionalConnection = (
direction: CardinalConnectionDirection,
targetLandmarkId: string,
) => {
const targetName =
availableTargetLandmarks.find((entry) => entry.id === targetLandmarkId)?.name ||
'';
setDraft((current) => {
const nextConnections = buildDirectionalConnections(
current.connections,
availableTargetLandmarks,
).filter((connection) => connection.relativePosition !== direction);
return {
...current,
connections: [
...nextConnections,
{
targetLandmarkId,
relativePosition: direction,
summary: buildConnectionSummary(direction, targetName),
},
],
};
});
};
const removeDirectionalConnection = (direction: CardinalConnectionDirection) => {
setDraft((current) => ({
...current,
connections: buildDirectionalConnections(
current.connections,
availableTargetLandmarks,
).filter((connection) => connection.relativePosition !== direction),
}));
};
const handleOpenDirectionPicker = (direction: CardinalConnectionDirection) => {
if (availableTargetLandmarks.length === 0) {
window.alert('请先保留至少一个其他场景,才能配置连接关系。');
return;
}
setActiveConnectionDirection(direction);
};
const handleGenerateSceneNpc = async () => {
setIsGeneratingSceneNpc(true);
try {
const nextNpc = await generateCustomWorldSceneNpc({
profile: editableProfile,
landmarkId: draft.id,
});
setDraftStoryNpcs((current) => [...current, nextNpc]);
setDraft((current) => ({
...current,
sceneNpcIds: current.sceneNpcIds.includes(nextNpc.id)
? current.sceneNpcIds
: [...current.sceneNpcIds, nextNpc.id],
}));
setNpcEditorState({
mode: 'edit',
npc: nextNpc,
});
} catch (error) {
window.alert(
error instanceof Error ? error.message : '生成场景 NPC 失败,请稍后重试。',
);
} finally {
setIsGeneratingSceneNpc(false);
}
};
const saveLandmarkProfile = () => {
const sanitizedDraft = {
...draft,
sceneNpcIds: [...new Set(draft.sceneNpcIds)],
connections: buildDirectionalConnections(
draft.connections,
availableTargetLandmarks,
).map((connection) => ({
...connection,
summary:
connection.summary ||
buildConnectionSummary(
connection.relativePosition as CardinalConnectionDirection,
availableTargetLandmarks.find(
(entry) => entry.id === connection.targetLandmarkId,
)?.name,
),
})),
};
if (sanitizedDraft.sceneNpcIds.length < 3) {
window.alert('每个场景至少需要分配 3 个 NPC。');
return;
}
const nextLandmarks =
mode === 'create'
? [...profile.landmarks, sanitizedDraft]
: profile.landmarks.map((entry) =>
entry.id === sanitizedDraft.id ? sanitizedDraft : entry,
);
onSaveProfile({
...profile,
storyNpcs: draftStoryNpcs,
landmarks: syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs),
});
onClose();
};
return (
<>
<ModalShell
title={mode === 'create' ? '新增场景' : `编辑场景:${landmark.name}`}
onClose={handleRequestClose}
>
<div className="space-y-4">
<ImageField
label="场景图片"
value={resolvedDraftImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
imageSrc: value || undefined,
}))
}
fallbackLabel={draft.name ? draft.name.slice(0, 4) : '背景'}
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={editableProfile} />}
footer={
<div className="flex flex-wrap gap-3">
<ActionButton
label="基于预设素材修改"
onClick={() => setIsPresetPickerOpen(true)}
tone="sky"
/>
<ActionButton
label="AI生成"
onClick={() => setIsAiGenerateOpen(true)}
/>
</div>
}
/>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="描述">
<TextArea
value={draft.description}
onChange={(value) =>
setDraft((current) => ({ ...current, description: value }))
}
rows={5}
/>
</Field>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
NPC
</div>
<div className="flex flex-wrap gap-3">
<ActionButton
label="添加"
onClick={() => setIsNpcPickerOpen(true)}
tone="sky"
/>
<ActionButton
label={
isGeneratingSceneNpc
? 'AI生成中...'
: 'AI生成新NPC并加入场景'
}
onClick={handleGenerateSceneNpc}
tone="sky"
disabled={isGeneratingSceneNpc}
/>
</div>
</div>
<div className="mt-3 space-y-2">
{draft.sceneNpcIds.length > 0 ? (
draft.sceneNpcIds.map((npcId) => {
const npc = storyNpcById.get(npcId);
return (
<div
key={`${draft.id}-selected-npc-${npcId}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{npc?.name ?? '未匹配场景角色'}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{npc?.role || npc?.title || '未填写身份'}
</div>
</div>
<div className="flex flex-wrap gap-2">
{npc ? (
<ActionButton
label="编辑"
onClick={() =>
setNpcEditorState({
mode: 'edit',
npc,
})
}
tone="sky"
/>
) : null}
<ActionButton
label="移出场景"
onClick={() => removeSceneNpc(npcId)}
/>
</div>
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
NPC
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<ActionButton
label="查看世界地图"
onClick={() => setIsWorldMapOpen(true)}
tone="sky"
/>
</div>
<div className="mt-4">
<DirectionalSceneConnectionCompass
centerName={draft.name || '当前场景'}
directionTargets={directionTargetLabels}
onDirectionClick={handleOpenDirectionPicker}
/>
</div>
</div>
<SaveBar
onClose={handleRequestClose}
onSave={saveLandmarkProfile}
showClose={false}
/>
{isPresetPickerOpen ? (
<ScenePresetPickerModal
selectedSrc={draft.imageSrc}
presetImages={presetImages}
onSelect={(value) =>
setDraft((current) => ({ ...current, imageSrc: value }))
}
onClose={() => setIsPresetPickerOpen(false)}
/>
) : null}
{isAiGenerateOpen ? (
<SceneImageGenerationModal
profile={editableProfile}
landmark={draft}
onApply={(result) => {
setDraft((current) => ({
...current,
imageSrc: result.imageSrc,
}));
}}
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
{isNpcPickerOpen ? (
<SceneNpcPickerModal
storyNpcs={draftStoryNpcs}
selectedNpcIds={draft.sceneNpcIds}
onApply={applySceneNpcSelection}
onClose={() => setIsNpcPickerOpen(false)}
/>
) : null}
{activeConnectionDirection ? (
<SceneConnectionTargetPickerModal
direction={activeConnectionDirection}
landmarks={availableTargetLandmarks}
currentTargetId={activeDirectionConnection?.targetLandmarkId}
onSelect={(landmarkId) =>
updateDirectionalConnection(activeConnectionDirection, landmarkId)
}
onRemove={() =>
removeDirectionalConnection(activeConnectionDirection)
}
onClose={() => setActiveConnectionDirection(null)}
/>
) : null}
{isWorldMapOpen ? (
<WorldMapOverviewModal
landmarks={editableProfile.landmarks}
onClose={() => setIsWorldMapOpen(false)}
/>
) : null}
{npcEditorState ? (
<StoryNpcEditor
profile={{
...profile,
storyNpcs: draftStoryNpcs,
}}
npc={npcEditorState.npc}
mode={npcEditorState.mode}
onSave={(nextNpc) => {
setDraftStoryNpcs((current) =>
npcEditorState.mode === 'create'
? [...current, nextNpc]
: current.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
);
setDraft((current) => ({
...current,
sceneNpcIds: current.sceneNpcIds.includes(nextNpc.id)
? current.sceneNpcIds
: [...current.sceneNpcIds, nextNpc.id],
}));
setNpcEditorState(null);
}}
onClose={() => setNpcEditorState(null)}
/>
) : null}
</div>
</ModalShell>
{isCloseConfirmOpen ? (
<PortalCompactDialogShell
title="确认关闭"
onClose={() => setIsCloseConfirmOpen(false)}
overlayClassName="z-[140]"
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsCloseConfirmOpen(false)}
/>
<ActionButton
label="确认关闭"
onClick={() => {
setIsCloseConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
function createPlayableNpc(
profile: CustomWorldProfile,
): CustomWorldPlayableNpc {
const seed = Date.now() + profile.playableNpcs.length;
const template =
ROLE_TEMPLATE_CHARACTERS[
profile.playableNpcs.length % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
] ?? ROLE_TEMPLATE_CHARACTERS[0];
return {
id: createEntryId(
'playable-npc',
`角色-${profile.playableNpcs.length + 1}`,
seed,
),
name: `自定义角色${profile.playableNpcs.length + 1}`,
title: '自定义身份',
role: '世界中的行动者',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
initialAffinity: 18,
relationshipHooks: ['首次接触', '合作空间'],
relations: [],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
templateCharacterId: template?.id,
};
}
function createStoryNpc(
profile: Pick<CustomWorldProfile, 'storyNpcs'>,
): CustomWorldNpc {
const seed = Date.now() + profile.storyNpcs.length;
const npc = {
id: createEntryId(
'story-npc',
`场景角色-${profile.storyNpcs.length + 1}`,
seed,
),
name: `自定义场景角色${profile.storyNpcs.length + 1}`,
title: '自定义头衔',
role: '自定义身份',
description: '',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
initialAffinity: 6,
relationshipHooks: ['合作', '互动'],
relations: [],
tags: ['自定义'],
backstoryReveal: {
publicSummary: '',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
teaser: '',
content: '',
contextSnippet: '',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
},
],
},
skills: [
{ id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
{ id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
{ id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
],
initialItems: [
{
id: 'item-1',
name: '随身武具',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
{
id: 'item-2',
name: '补给包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '',
tags: ['自定义'],
},
{
id: 'item-3',
name: '私人物件',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '',
tags: ['自定义'],
},
],
} satisfies CustomWorldNpc;
return npc;
}
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
const seed = Date.now() + profile.landmarks.length;
const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
return {
id: createEntryId(
'landmark',
`scene-${profile.landmarks.length + 1}`,
seed,
),
name: `自定义场景${profile.landmarks.length + 1}`,
description: '',
dangerLevel: '中',
imageSrc: undefined,
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
connections: previousLandmark
? [
{
targetLandmarkId: previousLandmark.id,
relativePosition: 'south',
summary: `南侧可回到${previousLandmark.name}`,
},
]
: [],
};
}
export function CustomWorldEntityEditorModal({
profile,
target,
onClose,
onProfileChange,
}: CustomWorldEntityEditorModalProps) {
if (!target) return null;
if (target.kind === 'world') {
return (
<WorldEditor
profile={profile}
onSave={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'camp') {
return (
<CampSceneEditor
profile={profile}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'playable') {
if (target.mode === 'create') {
return (
<PlayableNpcEditor
profile={profile}
npc={createPlayableNpc(profile)}
mode="create"
onSave={(nextNpc) =>
onProfileChange({
...profile,
playableNpcs: [...profile.playableNpcs, nextNpc],
})
}
onClose={onClose}
/>
);
}
const npc = profile.playableNpcs.find((item) => item.id === target.id);
return npc ? (
<PlayableNpcEditor
profile={profile}
npc={npc}
mode="edit"
onSave={(nextNpc) =>
onProfileChange({
...profile,
playableNpcs: profile.playableNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
if (target.kind === 'story') {
if (target.mode === 'create') {
return (
<StoryNpcEditor
profile={profile}
npc={createStoryNpc(profile)}
mode="create"
onSave={(nextNpc) =>
onProfileChange({
...profile,
storyNpcs: [...profile.storyNpcs, nextNpc],
})
}
onClose={onClose}
/>
);
}
const npc = profile.storyNpcs.find((item) => item.id === target.id);
return npc ? (
<StoryNpcEditor
profile={profile}
npc={npc}
mode="edit"
onSave={(nextNpc) =>
onProfileChange({
...profile,
storyNpcs: profile.storyNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
if (target.mode === 'create') {
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
const landmark = profile.landmarks.find((entry) => entry.id === target.id);
return landmark ? (
<LandmarkEditor
profile={profile}
landmark={landmark}
mode="edit"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
) : null;
}