4425 lines
132 KiB
TypeScript
4425 lines
132 KiB
TypeScript
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;
|
||
}
|