Files
Genarrative/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
2026-04-25 13:44:48 +08:00

6004 lines
183 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { X } from 'lucide-react';
import type { ChangeEvent } from 'react';
import type { CSSProperties } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useRef, 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 { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
import { useCombatFlow } from '../../hooks/useCombatFlow';
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story';
import { useRpgSessionBootstrap } from '../../hooks/rpg-session';
import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts';
import type { CustomWorldSceneImageResult } from '../../services/aiTypes';
import { resolveCustomWorldCampScene } from '../../services/customWorldCamp';
import {
buildDefaultCustomWorldCoverProfile,
resolveCustomWorldCoverPresentation,
} from '../../services/customWorldCover';
import {
type CustomWorldCoverAssetResult,
generateCustomWorldCoverImage,
uploadCustomWorldCoverImage,
} from '../../services/customWorldCoverAssetService';
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
type Character,
type CharacterAnimationConfig,
type CustomWorldCoverCropRect,
type CustomWorldCoverProfile,
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
type CustomWorldRoleInitialItem,
type CustomWorldRoleRelation,
type CustomWorldRoleSkill,
CustomWorldSceneConnection,
type ItemRarity,
type SceneActAdvanceRule,
type SceneActBlueprint,
type SceneActStage,
type SceneChapterBlueprint,
type SceneNpc,
type ScenePresetInfo,
WorldType,
} from '../../types';
import {
type CharacterAnimationGenerationPayload,
generateCharacterAnimationDraft,
publishCharacterAnimationAssets,
} from '../asset-studio/characterAssetWorkflowPersistence';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { buildDefaultCustomWorldNpcVisual } from '../customWorldNpcVisualDefaults';
import {
CustomWorldNpcPortrait,
CustomWorldNpcVisualEditor,
} from '../CustomWorldNpcVisualEditor';
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
import {
RoleCharacterSprite,
SceneEncounterNpcSprite,
} from '../game-canvas/GameCanvasShared';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgRuntimeShell } from '../rpg-runtime-shell';
import {
createLandmarkDraft,
createPlayableNpcDraft,
createStoryNpcDraft,
resolveEditableLandmark,
resolveEditablePlayableNpc,
resolveEditableStoryNpc,
} from './rpgCreationResultFormMapper';
export type RpgCreationEditorTarget =
| { kind: 'world' }
| { kind: 'cover' }
| { 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 };
export interface RpgCreationEntityEditorModalProps {
profile: CustomWorldProfile;
target: RpgCreationEditorTarget | null;
onClose: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
function getAnimationPreviewFrameStyle(
_config: CharacterAnimationConfig | null | undefined,
targetSize: number,
) {
return {
width: `${targetSize}px`,
height: `${targetSize}px`,
maxWidth: '100%',
maxHeight: '100%',
} satisfies CSSProperties;
}
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 MIN_SCENE_ACT_COUNT = 2;
const MAX_SCENE_ACT_COUNT = 5;
const DEFAULT_SCENE_ACT_COUNT = 3;
const SCENE_ACT_ROLE_SLOT_COUNT = 3;
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 dedupeTextValues(values: Array<string | null | undefined>) {
return [
...new Set(
values
.map((value) => value?.trim() ?? '')
.filter(Boolean),
),
];
}
function compactTextList(values: Array<string | null | undefined>) {
return values.map((value) => value?.trim() ?? '').filter(Boolean);
}
function moveArrayItem<T>(values: T[], fromIndex: number, toIndex: number) {
if (
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= values.length ||
toIndex >= values.length ||
fromIndex === toIndex
) {
return values;
}
const nextValues = [...values];
const [movedItem] = nextValues.splice(fromIndex, 1);
if (typeof movedItem === 'undefined') {
return values;
}
nextValues.splice(toIndex, 0, movedItem);
return nextValues;
}
function buildSceneActEncounterSlotIds(encounterNpcIds: string[]) {
const compactIds = dedupeTextValues(encounterNpcIds).slice(
0,
SCENE_ACT_ROLE_SLOT_COUNT,
);
return Array.from({ length: SCENE_ACT_ROLE_SLOT_COUNT }, (_unused, index) =>
compactIds[index] ?? null,
) as Array<string | null>;
}
function compactSceneActEncounterSlotIds(slotIds: Array<string | null | undefined>) {
return dedupeTextValues(slotIds).slice(0, SCENE_ACT_ROLE_SLOT_COUNT);
}
function assignSceneActEncounterSlotId(
encounterNpcIds: string[],
slotIndex: number,
nextNpcId: string | null,
) {
const nextSlots = buildSceneActEncounterSlotIds(encounterNpcIds);
const normalizedNpcId = nextNpcId?.trim() || null;
if (slotIndex < 0 || slotIndex >= nextSlots.length) {
return compactSceneActEncounterSlotIds(nextSlots);
}
if (!normalizedNpcId) {
nextSlots.splice(slotIndex, 1);
nextSlots.push(null);
return compactSceneActEncounterSlotIds(nextSlots);
}
return compactSceneActEncounterSlotIds(
nextSlots.map((slotNpcId, index) => {
if (index === slotIndex) {
return normalizedNpcId;
}
return slotNpcId === normalizedNpcId ? null : slotNpcId;
}),
);
}
function buildSceneActStageCoverage(index: number, actCount: number): SceneActStage[] {
if (actCount <= 2) {
return index === 0
? ['opening', 'expansion']
: ['turning_point', 'climax', 'aftermath'];
}
if (actCount === 3) {
if (index === 0) return ['opening'];
if (index === 1) return ['expansion', 'turning_point'];
return ['climax', 'aftermath'];
}
if (actCount === 4) {
if (index === 0) return ['opening'];
if (index === 1) return ['expansion'];
if (index === 2) return ['turning_point'];
return ['climax', 'aftermath'];
}
if (index === 0) return ['opening'];
if (index === 1) return ['expansion'];
if (index === 2) return ['turning_point'];
if (index === 3) return ['climax'];
return ['aftermath'];
}
function buildSceneActAdvanceRule(
index: number,
actCount: number,
): SceneActAdvanceRule {
if (index === 0) {
return 'after_primary_contact';
}
if (index >= actCount - 1) {
return 'after_chapter_resolution';
}
return 'after_active_step_complete';
}
function buildDefaultSceneActTitle(index: number) {
return `${index + 1}`;
}
function buildDefaultSceneActBlueprint(params: {
sceneId: string;
sceneName: string;
sceneSummary: string;
encounterNpcIds: string[];
backgroundImageSrc?: string | null;
linkedThreadIds?: string[];
index: number;
actCount: number;
}): SceneActBlueprint {
const encounterNpcIds = dedupeTextValues(params.encounterNpcIds).slice(0, 1);
const actTitle = buildDefaultSceneActTitle(params.index);
const sceneLabel = params.sceneName.trim() || '当前场景';
const sceneSummary = params.sceneSummary.trim();
const stageCoverage = buildSceneActStageCoverage(params.index, params.actCount);
const actSummary =
params.index === 0
? `玩家会在${sceneLabel}接住这一章的开场入口。`
: params.index >= params.actCount - 1
? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。`
: `${sceneLabel}的主要压力会在这一幕继续加深。`;
return {
id: `${params.sceneId}-act-${params.index + 1}`,
sceneId: params.sceneId,
title: actTitle,
summary: actSummary,
stageCoverage,
// 幕背景画面描述应来自草稿生成阶段的大模型输出,前端缺失时只留空,避免展示规则拼接文本。
backgroundPromptText: '',
backgroundImageSrc: params.backgroundImageSrc || undefined,
encounterNpcIds,
primaryNpcId: encounterNpcIds[0] ?? '',
linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []),
advanceRule: buildSceneActAdvanceRule(params.index, params.actCount),
actGoal:
params.index === 0
? `先在${sceneLabel}接住当前局面`
: params.index >= params.actCount - 1
? `${sceneLabel}这一章收束并抛出下一步`
: `继续推进${sceneLabel}的核心矛盾`,
transitionHook:
params.index === 0
? '和主角色完成首次有效接触后,局势会继续加压。'
: params.index >= params.actCount - 1
? '这一幕结束后,需要把后续方向明确抛给玩家。'
: '完成当前主动推进后,这一幕会转向下一层压力。',
};
}
function buildDefaultSceneChapterBlueprint(params: {
landmark: CustomWorldLandmark;
fallbackImageSrc?: string | null;
chapterId?: string;
chapterTitle?: string;
chapterSummary?: string;
linkedThreadIds?: string[];
linkedLandmarkIds?: string[];
actCount?: number;
}) {
const actCount = Math.min(
MAX_SCENE_ACT_COUNT,
Math.max(MIN_SCENE_ACT_COUNT, params.actCount ?? DEFAULT_SCENE_ACT_COUNT),
);
return {
id: params.chapterId?.trim() || `scene-chapter-${params.landmark.id}`,
sceneId: params.landmark.id,
title: params.chapterTitle?.trim() || params.landmark.name.trim() || '场景章节',
summary: params.chapterSummary?.trim() || params.landmark.description.trim(),
linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []),
linkedLandmarkIds: dedupeTextValues([
params.landmark.id,
...(params.linkedLandmarkIds ?? []),
]),
acts: Array.from({ length: actCount }, (_unused, index) =>
buildDefaultSceneActBlueprint({
sceneId: params.landmark.id,
sceneName: params.landmark.name,
sceneSummary: params.landmark.description,
encounterNpcIds: params.landmark.sceneNpcIds,
backgroundImageSrc: params.fallbackImageSrc,
linkedThreadIds: params.linkedThreadIds,
index,
actCount,
}),
),
} satisfies SceneChapterBlueprint;
}
function sanitizeSceneChapterBlueprint(params: {
chapter: SceneChapterBlueprint | null | undefined;
landmark: CustomWorldLandmark;
fallbackImageSrc?: string | null;
}) {
const fallbackChapter = buildDefaultSceneChapterBlueprint({
landmark: params.landmark,
fallbackImageSrc: params.fallbackImageSrc,
chapterId: params.chapter?.id,
chapterTitle: params.chapter?.title,
chapterSummary: params.chapter?.summary,
linkedThreadIds: params.chapter?.linkedThreadIds,
linkedLandmarkIds: params.chapter?.linkedLandmarkIds,
actCount: params.chapter?.acts.length,
});
const rawActs = params.chapter?.acts ?? [];
const chapterEncounterNpcIds = dedupeTextValues(
rawActs.flatMap((act) => act.encounterNpcIds),
);
const availableSceneNpcIds = dedupeTextValues([
...chapterEncounterNpcIds,
...params.landmark.sceneNpcIds,
]);
const availableSceneNpcIdSet = new Set(availableSceneNpcIds);
const targetActCount = Math.min(
MAX_SCENE_ACT_COUNT,
Math.max(
MIN_SCENE_ACT_COUNT,
rawActs.length > 0 ? rawActs.length : fallbackChapter.acts.length,
),
);
const acts = Array.from({ length: targetActCount }, (_unused, index) => {
const fallbackAct = buildDefaultSceneActBlueprint({
sceneId: params.landmark.id,
sceneName: params.landmark.name,
sceneSummary: params.landmark.description,
encounterNpcIds: availableSceneNpcIds,
backgroundImageSrc: params.fallbackImageSrc,
linkedThreadIds: rawActs[index]?.linkedThreadIds ?? fallbackChapter.linkedThreadIds,
index,
actCount: targetActCount,
});
const currentAct = rawActs[index];
const candidateNpcIds = dedupeTextValues(currentAct?.encounterNpcIds ?? []);
const encounterNpcIds =
availableSceneNpcIdSet.size > 0
? candidateNpcIds.filter((npcId) => availableSceneNpcIdSet.has(npcId))
: candidateNpcIds;
const resolvedEncounterNpcIds =
encounterNpcIds.length > 0
? encounterNpcIds
: availableSceneNpcIds.length > 0
? availableSceneNpcIds.slice(0, 1)
: fallbackAct.encounterNpcIds;
return {
...fallbackAct,
id: currentAct?.id?.trim() || fallbackAct.id,
title: currentAct?.title?.trim() || fallbackAct.title,
summary: currentAct?.summary?.trim() || fallbackAct.summary,
stageCoverage: buildSceneActStageCoverage(index, targetActCount),
backgroundPromptText: currentAct?.backgroundPromptText?.trim() || '',
backgroundImageSrc:
currentAct?.backgroundImageSrc?.trim() ||
params.fallbackImageSrc ||
fallbackAct.backgroundImageSrc,
encounterNpcIds: resolvedEncounterNpcIds,
primaryNpcId: resolvedEncounterNpcIds[0] ?? '',
linkedThreadIds: dedupeTextValues(currentAct?.linkedThreadIds ?? []),
advanceRule: buildSceneActAdvanceRule(index, targetActCount),
actGoal: currentAct?.actGoal?.trim() || fallbackAct.actGoal,
transitionHook:
currentAct?.transitionHook?.trim() || fallbackAct.transitionHook,
} satisfies SceneActBlueprint;
});
return {
...fallbackChapter,
id: params.chapter?.id?.trim() || fallbackChapter.id,
title: params.chapter?.title?.trim() || fallbackChapter.title,
summary: params.chapter?.summary?.trim() || fallbackChapter.summary,
linkedThreadIds: dedupeTextValues(params.chapter?.linkedThreadIds ?? []),
linkedLandmarkIds: dedupeTextValues([
params.landmark.id,
...(params.chapter?.linkedLandmarkIds ?? []),
]),
acts,
} satisfies SceneChapterBlueprint;
}
function collectSceneChapterEncounterNpcIds(chapter: SceneChapterBlueprint) {
return dedupeTextValues(
chapter.acts.flatMap((act) => act.encounterNpcIds),
);
}
function resolveSceneCompatibilityNpcIds(params: {
chapter: SceneChapterBlueprint;
currentNpcIds: string[];
}) {
const chapterNpcIds = collectSceneChapterEncounterNpcIds(params.chapter);
return dedupeTextValues([...chapterNpcIds, ...params.currentNpcIds]);
}
function resolveSceneCompatibilityImageSrc(params: {
chapter: SceneChapterBlueprint;
currentImageSrc?: string | null;
resolvedImageSrc?: string | null;
}) {
const currentImageSrc = params.currentImageSrc?.trim() || '';
const resolvedImageSrc = params.resolvedImageSrc?.trim() || '';
const firstActImageSrc = params.chapter.acts[0]?.backgroundImageSrc?.trim() || '';
if (firstActImageSrc && firstActImageSrc !== resolvedImageSrc) {
return firstActImageSrc;
}
return currentImageSrc || undefined;
}
function resolveSceneChapterBlueprintDraft(params: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
fallbackImageSrc?: string | null;
}) {
const matchedChapter =
params.profile.sceneChapterBlueprints?.find(
(entry) =>
entry.sceneId === params.landmark.id ||
entry.linkedLandmarkIds.includes(params.landmark.id),
) ?? null;
return sanitizeSceneChapterBlueprint({
chapter: matchedChapter,
landmark: params.landmark,
fallbackImageSrc: params.fallbackImageSrc,
});
}
function upsertSceneChapterBlueprint(
chapters: CustomWorldProfile['sceneChapterBlueprints'],
nextChapter: SceneChapterBlueprint,
) {
const nextChapters: SceneChapterBlueprint[] = [];
let hasReplaced = false;
(chapters ?? []).forEach((chapter) => {
const isSameScene =
chapter.id === nextChapter.id ||
chapter.sceneId === nextChapter.sceneId ||
chapter.linkedLandmarkIds.includes(nextChapter.sceneId);
if (isSameScene) {
if (!hasReplaced) {
nextChapters.push(nextChapter);
hasReplaced = true;
}
return;
}
nextChapters.push(chapter);
});
if (!hasReplaced) {
nextChapters.push(nextChapter);
}
return nextChapters;
}
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 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 buildDraftSyncToken(value: unknown) {
try {
const serialized = JSON.stringify(value);
return serialized ?? 'undefined';
} catch {
return String(value);
}
}
function useDraft<T>(value: T) {
const syncToken = useMemo(() => buildDraftSyncToken(value), [value]);
const [draft, setDraft] = useState(value);
const lastSyncedTokenRef = useRef(syncToken);
useEffect(() => {
if (lastSyncedTokenRef.current === syncToken) {
return;
}
lastSyncedTokenRef.current = syncToken;
setDraft(value);
}, [syncToken, 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 loadImageDimensionsFromDataUrl(source: string) {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve({
width: image.naturalWidth,
height: image.naturalHeight,
});
};
image.onerror = () => reject(new Error('读取图片尺寸失败。'));
image.src = source;
});
}
function buildCenteredCoverCropRect(
width: number,
height: number,
): CustomWorldCoverCropRect {
const targetRatio = 16 / 9;
if (width <= 0 || height <= 0) {
return { x: 0, y: 0, width: 1, height: 1 };
}
if (width / height >= targetRatio) {
const cropHeight = height;
const cropWidth = cropHeight * targetRatio;
return {
x: (width - cropWidth) / 2,
y: 0,
width: cropWidth,
height: cropHeight,
};
}
const cropWidth = width;
const cropHeight = cropWidth / targetRatio;
return {
x: 0,
y: (height - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
};
}
function clampCoverCropRect(
cropRect: CustomWorldCoverCropRect,
imageSize: { width: number; height: number },
) {
const width = Math.max(1, Math.min(imageSize.width, cropRect.width));
const height = Math.max(1, Math.min(imageSize.height, cropRect.height));
const x = Math.max(0, Math.min(imageSize.width - width, cropRect.x));
const y = Math.max(0, Math.min(imageSize.height - height, cropRect.y));
return { x, y, width, height };
}
function buildCoverCropPreviewStyle(
cropRect: CustomWorldCoverCropRect,
imageSize: { width: number; height: number },
) {
if (imageSize.width <= 0 || imageSize.height <= 0) {
return {};
}
return {
left: `${(cropRect.x / imageSize.width) * 100}%`,
top: `${(cropRect.y / imageSize.height) * 100}%`,
width: `${(cropRect.width / imageSize.width) * 100}%`,
height: `${(cropRect.height / imageSize.height) * 100}%`,
} satisfies CSSProperties;
}
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;
}) {
const authUi = useAuthUi();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
return (
<div
className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={
disableClose
? undefined
: (event) => {
if (event.target === event.currentTarget) {
onClose();
}
}
}
>
<div
className={`platform-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' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
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}
aria-label="关闭"
className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<X 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;
}) {
const authUi = useAuthUi();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
return (
<div
className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-center justify-center p-4 backdrop-blur-sm`}
onClick={
disableClose
? undefined
: (event) => {
if (event.target === event.currentTarget) {
onClose();
}
}
}
>
<div
className={`platform-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`}`}
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}
aria-label="关闭"
className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<X 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,
previewOverlay,
overlayInteractive = false,
}: {
src?: string;
alt: string;
fallbackLabel: string;
tone?: 'square' | 'landscape';
children?: ReactNode;
previewOverlay?: ReactNode;
overlayInteractive?: boolean;
}) {
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 ? (
<ResolvedAssetImage
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 || previewOverlay ? (
<div
className={`${overlayInteractive ? 'pointer-events-auto' : 'pointer-events-none'} absolute inset-0`}
>
{previewOverlay}
{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>
);
}
const SCENE_ACT_SLOT_LAYOUTS = [
{
left: '68%',
bottom: '11%',
scale: 1.08,
zIndex: 4,
},
{
left: '82%',
bottom: '22%',
scale: 0.84,
zIndex: 3,
},
{
left: '82%',
bottom: '3%',
scale: 0.8,
zIndex: 2,
},
] as const;
const SCENE_ACT_PLAYER_LAYOUT = {
left: '24%',
bottom: '11%',
scale: 1,
zIndex: 3,
} as const;
function SceneActStageNpcSprite({
npc,
slotIndex,
onClick,
}: {
npc: CustomWorldNpc | null;
slotIndex: number;
onClick: () => void;
}) {
const previewEncounter = npc
? buildEncounterFromSceneNpc(
buildSceneActPreviewSceneNpc(npc),
RESOLVED_ENTITY_X_METERS,
)
: null;
return (
<button
type="button"
onClick={onClick}
data-testid="scene-act-slot-button"
aria-label={
npc
? `配置第${slotIndex + 1}个角色:${npc.name}`
: `为第${slotIndex + 1}个角色添加角色`
}
className="group flex flex-col items-center text-center transition-transform hover:scale-[1.02]"
style={{transformOrigin: 'center bottom'}}
>
<div
className={`mb-1 max-w-[5.8rem] truncate text-[11px] font-medium leading-5 [text-shadow:0_2px_10px_rgba(0,0,0,0.82)] ${
npc ? 'text-zinc-100' : 'text-zinc-400'
}`}
>
{npc?.name ?? '添加角色'}
</div>
<div
className={`relative flex h-[4.8rem] w-[4.2rem] items-end justify-center overflow-visible transition-all sm:h-[5.4rem] sm:w-[4.8rem] ${
npc ? 'drop-shadow-[0_8px_16px_rgba(0,0,0,0.34)]' : 'opacity-78'
}`}
>
{previewEncounter ? (
<SceneEncounterNpcSprite
encounter={previewEncounter}
state={AnimationState.IDLE}
facing="left"
/>
) : (
<div className="flex h-[4rem] w-[2.8rem] items-center justify-center rounded-[999px] border border-dashed border-white/28 text-[11px] font-bold tracking-[0.18em] text-zinc-500 sm:h-[4.5rem] sm:w-[3.1rem]">
+
</div>
)}
</div>
</button>
);
}
function SceneActStagePlayerSprite({
character,
}: {
character: Character | null;
}) {
if (!character) {
return null;
}
return (
<div
className="pointer-events-none flex flex-col items-center text-center"
style={{transformOrigin: 'center bottom'}}
>
<div className="mb-1 max-w-[6rem] truncate text-[11px] font-medium leading-5 text-zinc-100 [text-shadow:0_2px_10px_rgba(0,0,0,0.82)]">
{character.name}
</div>
<div className="relative flex h-[4.9rem] w-[4.2rem] items-end justify-center overflow-visible drop-shadow-[0_8px_16px_rgba(0,0,0,0.34)] sm:h-[5.5rem] sm:w-[4.8rem]">
<RoleCharacterSprite
state={AnimationState.IDLE}
character={character}
facing="right"
/>
</div>
</div>
);
}
function SceneActStagePreview({
actLabel,
imageSrc,
fallbackImageSrc,
previewCharacter,
slotNpcs,
onSlotClick,
}: {
actLabel: string;
imageSrc?: string | null;
fallbackImageSrc?: string | null;
previewCharacter: Character | null;
slotNpcs: Array<CustomWorldNpc | null>;
onSlotClick: (slotIndex: number) => void;
}) {
return (
<ImagePreview
src={imageSrc || fallbackImageSrc || undefined}
alt={`${actLabel}幕背景`}
fallbackLabel={actLabel}
tone="landscape"
overlayInteractive
>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,17,0.12)_0%,rgba(8,10,17,0.28)_45%,rgba(8,10,17,0.78)_100%)]" />
<div className="absolute left-3 top-3 rounded-full border border-white/12 bg-black/52 px-3 py-1 text-[10px] font-bold tracking-[0.18em] text-zinc-100">
{actLabel}
</div>
<div className="absolute inset-x-0 bottom-0 h-24 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.74)_100%)]" />
<div className="absolute bottom-[12%] left-[50%] h-16 w-16 -translate-x-1/2 rounded-full bg-amber-300/18 blur-2xl sm:h-20 sm:w-20" />
<div
className="absolute"
style={{
left: SCENE_ACT_PLAYER_LAYOUT.left,
bottom: SCENE_ACT_PLAYER_LAYOUT.bottom,
zIndex: SCENE_ACT_PLAYER_LAYOUT.zIndex,
transform: `translateX(-50%) scale(${SCENE_ACT_PLAYER_LAYOUT.scale})`,
transformOrigin: 'center bottom',
}}
>
<SceneActStagePlayerSprite character={previewCharacter} />
</div>
{slotNpcs.map((npc, slotIndex) => {
const layout = SCENE_ACT_SLOT_LAYOUTS[slotIndex];
if (!layout) {
return null;
}
return (
<div
key={`${actLabel}-slot-${slotIndex + 1}`}
className="absolute"
style={{
left: layout.left,
bottom: layout.bottom,
zIndex: layout.zIndex,
transform: `translateX(-50%) scale(${layout.scale})`,
transformOrigin: 'center bottom',
}}
>
<SceneActStageNpcSprite
slotIndex={slotIndex}
npc={npc}
onClick={() => onSlotClick(slotIndex)}
/>
</div>
);
})}
</ImagePreview>
);
}
function SceneActNpcSlotPickerModal({
actLabel,
slotIndex,
currentNpcId,
availableNpcs,
onApply,
onClose,
}: {
actLabel: string;
slotIndex: number;
currentNpcId?: string | null;
availableNpcs: CustomWorldNpc[];
onApply: (npcId: string | null) => void;
onClose: () => void;
}) {
const [draftNpcId, setDraftNpcId] = useState(currentNpcId?.trim() || '');
const selectedNpc =
availableNpcs.find((npc) => npc.id === draftNpcId) ??
availableNpcs.find((npc) => npc.id === currentNpcId) ??
null;
const slotLabel = slotIndex === 0 ? '主角色槽位' : `角色槽位 ${slotIndex + 1}`;
useEffect(() => {
setDraftNpcId(currentNpcId?.trim() || '');
}, [currentNpcId]);
return (
<ModalShell
title={`配置角色:${actLabel} · ${slotLabel}`}
onClose={onClose}
panelClassName="sm:max-w-4xl"
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<div className="mt-3">
{selectedNpc ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="flex items-center gap-3">
<CustomWorldNpcPortrait
npc={selectedNpc}
visual={selectedNpc.visual}
className="h-20 w-20 shrink-0"
contentClassName="min-h-0 p-2"
scale={1.75}
preferImageSrc
/>
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{selectedNpc.name}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{selectedNpc.title || selectedNpc.role || '未填写身份'}
</div>
</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">
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{availableNpcs.length > 0 ? (
availableNpcs.map((npc) => {
const isSelected = npc.id === draftNpcId;
return (
<button
key={`${slotLabel}-${npc.id}`}
type="button"
onClick={() => setDraftNpcId(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 gap-3">
<CustomWorldNpcPortrait
npc={npc}
visual={npc.visual}
className="h-16 w-16 shrink-0"
contentClassName="min-h-0 p-2"
scale={1.6}
preferImageSrc
/>
<div className="min-w-0 flex-1">
<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>
</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 className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500 sm:col-span-2">
</div>
)}
</div>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
{selectedNpc ? (
<ActionButton
label="移除角色"
onClick={() => {
onApply(null);
onClose();
}}
tone="rose"
/>
) : null}
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label="保存角色"
onClick={() => {
if (!draftNpcId) {
window.alert('请先选择角色。');
return;
}
onApply(draftNpcId);
onClose();
}}
tone="sky"
disabled={!draftNpcId}
/>
</div>
</div>
</ModalShell>
);
}
function buildSceneActPreviewSceneNpc(npc: CustomWorldNpc): SceneNpc {
return {
id: npc.id,
name: npc.name,
title: npc.title,
role: npc.role,
avatar: (npc.imageSrc ?? npc.name.slice(0, 1)) || '?',
description: npc.description || `${npc.name}会在当前幕与你正面相遇。`,
initialAffinity: npc.initialAffinity,
hostile: false,
recruitable: (npc.initialAffinity ?? 0) >= 0,
functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
backstory: npc.backstory,
personality: npc.personality,
motivation: npc.motivation,
combatStyle: npc.combatStyle,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
backstoryReveal: npc.backstoryReveal,
skills: npc.skills.map((skill) => ({ ...skill })),
initialItems: npc.initialItems.map((item) => ({
...item,
tags: [...item.tags],
})),
imageSrc: npc.imageSrc,
visual: npc.visual,
narrativeProfile: npc.narrativeProfile,
attributeProfile: npc.attributeProfile,
};
}
function buildSceneActPreviewScenePreset(params: {
landmark: CustomWorldLandmark;
act: SceneActBlueprint;
encounterNpcs: CustomWorldNpc[];
}): ScenePresetInfo {
return {
id: params.landmark.id,
name: params.landmark.name,
description: params.landmark.description,
imageSrc:
params.act.backgroundImageSrc?.trim() ||
params.landmark.imageSrc?.trim() ||
'',
connectedSceneIds: [],
connections: [],
treasureHints: [],
narrativeResidues: params.landmark.narrativeResidues ?? [],
npcs: params.encounterNpcs.map(buildSceneActPreviewSceneNpc),
};
}
function SceneActPreviewRuntime({
profile,
landmark,
chapter,
actIndex,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
chapter: SceneChapterBlueprint;
actIndex: number;
onClose: () => void;
}) {
const authUi = useAuthUi();
const act = chapter.acts[actIndex] ?? null;
const encounterNpcs = useMemo(
() =>
(act?.encounterNpcIds ?? [])
.map((npcId) =>
profile.storyNpcs.find((entry) => entry.id === npcId) ?? null,
)
.filter((npc): npc is CustomWorldNpc => Boolean(npc)),
[act?.encounterNpcIds, profile.storyNpcs],
);
const previewScenePreset = useMemo(
() =>
act
? buildSceneActPreviewScenePreset({
landmark,
act,
encounterNpcs,
})
: null,
[act, encounterNpcs, landmark],
);
const previewEncounter = useMemo(() => {
const primaryNpc = encounterNpcs[0];
if (!primaryNpc) {
return null;
}
return buildEncounterFromSceneNpc(
buildSceneActPreviewSceneNpc(primaryNpc),
RESOLVED_ENTITY_X_METERS,
);
}, [encounterNpcs]);
const previewCharacter = useMemo(
() =>
buildCustomWorldPlayableCharacters(profile)[0] ??
ROLE_TEMPLATE_CHARACTERS[0] ??
null,
[profile],
);
const previewActRuntimeState = useMemo(
() =>
act
? {
sceneId: chapter.sceneId,
chapterId: chapter.id,
currentActId: act.id,
currentActIndex: actIndex,
completedActIds: chapter.acts
.slice(0, actIndex)
.map((entry) => entry.id),
visitedActIds: chapter.acts
.slice(0, actIndex + 1)
.map((entry) => entry.id),
}
: null,
[act, actIndex, chapter],
);
const hasBootstrappedRef = useRef(false);
const {
gameState,
setGameState,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
resetGame,
handleCustomWorldSelect,
handleCharacterSelect,
} = useRpgSessionBootstrap();
const combatFlow = useCombatFlow({
setGameState,
});
const storyFlow = useRpgRuntimeStory({
gameState,
setGameState,
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
playResolvedChoice: combatFlow.playResolvedChoice,
});
const { companionRenderStates, buildCompanionRenderStates } =
useNpcInteractionFlow(gameState);
const isPreviewReady =
gameState.currentScene === 'Story' &&
Boolean(gameState.playerCharacter) &&
gameState.currentScenePreset?.id === landmark.id;
useEffect(() => {
if (
hasBootstrappedRef.current ||
!act ||
!previewCharacter ||
!previewScenePreset ||
!previewEncounter ||
!previewActRuntimeState
) {
return;
}
hasBootstrappedRef.current = true;
storyFlow.resetStoryState();
setBottomTab('adventure');
setIsMapOpen(false);
handleCustomWorldSelect(profile);
handleCharacterSelect(previewCharacter);
setGameState((current) => ({
...current,
worldType: WorldType.CUSTOM,
customWorldProfile: profile,
currentScene: 'Story',
currentScenePreset: previewScenePreset,
currentEncounter: previewEncounter,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
storyHistory: [],
chapterState: null,
campaignState: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
animationState: AnimationState.IDLE,
activeCombatEffects: [],
activeBuildBuffs: [],
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
storyEngineMemory: {
...(current.storyEngineMemory ?? createEmptyStoryEngineMemoryState()),
currentChapter: null,
currentSceneActState: previewActRuntimeState,
},
}));
}, [
act,
handleCharacterSelect,
handleCustomWorldSelect,
landmark.id,
previewActRuntimeState,
previewCharacter,
previewEncounter,
previewScenePreset,
profile,
setBottomTab,
setGameState,
setIsMapOpen,
storyFlow,
]);
if (!act || !previewCharacter || !previewScenePreset || !previewEncounter) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-300">
</div>
);
}
if (!isPreviewReady) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-300">
...
</div>
);
}
return (
<RpgRuntimeShell
session={{
gameState,
currentStory: storyFlow.currentStory,
isLoading: storyFlow.isLoading,
aiError: storyFlow.aiError,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
}}
story={{
displayedOptions: storyFlow.displayedOptions,
canRefreshOptions: storyFlow.canRefreshOptions,
handleRefreshOptions: storyFlow.handleRefreshOptions,
handleChoice: storyFlow.handleChoice,
handleNpcChatInput: storyFlow.handleNpcChatInput,
exitNpcChat: storyFlow.exitNpcChat,
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
npcUi: storyFlow.npcUi,
characterChatUi: storyFlow.characterChatUi,
inventoryUi: storyFlow.inventoryUi,
battleRewardUi: storyFlow.battleRewardUi,
questUi: storyFlow.questUi,
npcChatQuestOfferUi: storyFlow.npcChatQuestOfferUi,
goalUi: storyFlow.goalUi,
}}
entry={{
hasSavedGame: false,
savedSnapshot: null,
handleContinueGame: () => undefined,
handleStartNewGame: () => {
resetGame();
onClose();
},
handleSaveAndExit: onClose,
handleCustomWorldSelect,
handleBackToWorldSelect: onClose,
handleCharacterSelect,
}}
companions={{
companionRenderStates,
buildCompanionRenderStates,
onBenchCompanion: () => undefined,
onActivateRosterCompanion: () => undefined,
}}
audio={{
musicVolume: authUi?.musicVolume ?? 0.6,
onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}),
}}
/>
);
}
function SceneActPreviewModal({
profile,
landmark,
chapter,
actIndex,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
chapter: SceneChapterBlueprint;
actIndex: number;
onClose: () => void;
}) {
const act = chapter.acts[actIndex] ?? null;
if (!act) {
return null;
}
return createPortal(
<div className="fixed inset-0 z-[160] bg-black">
<div className="absolute inset-x-0 top-0 z-[2] flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.94)_0%,rgba(8,10,17,0.28)_100%)] px-4 py-4 sm:px-6">
<div>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-400">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{act.title.trim() || buildDefaultSceneActTitle(actIndex)}
</div>
</div>
<ActionButton label="关闭预览" onClick={onClose} />
</div>
<SceneActPreviewRuntime
profile={profile}
landmark={landmark}
chapter={chapter}
actIndex={actIndex}
onClose={onClose}
/>
</div>,
document.body,
);
}
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,
initialPromptText,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
initialPromptText?: string;
onApply: (result: CustomWorldSceneImageResult) => void;
onClose: () => void;
}) {
const [userPrompt, setUserPrompt] = useDraft(
initialPromptText?.trim() ||
landmark.visualDescription?.trim() ||
landmark.description.trim() ||
landmark.name.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 rpgCreationAssetClient.generateSceneImage({
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-4xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
usePixelFont
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.15fr)_minmax(17rem,0.85fr)]">
<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 SceneActBackgroundModal({
profile,
landmark,
act,
actLabel,
currentImageSrc,
fallbackImageSrc,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
act: SceneActBlueprint;
actLabel: string;
currentImageSrc?: string | null;
fallbackImageSrc?: string | null;
onApply: (imageSrc?: string | null) => void;
onClose: () => void;
}) {
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const [draftImageSrc, setDraftImageSrc] = useDraft(currentImageSrc?.trim() || '');
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
const previewImageSrc = draftImageSrc || fallbackImageSrc || '';
return (
<>
<ModalShell
title={`配置幕背景:${actLabel}`}
onClose={onClose}
panelClassName="sm:max-w-5xl"
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<ImagePreview
src={previewImageSrc || undefined}
alt={`${actLabel}背景预览`}
fallbackLabel="暂无背景图"
tone="landscape"
/>
<div className="mt-3 flex flex-wrap gap-3">
<ActionButton
label="跟随场景主图"
onClick={() => setDraftImageSrc('')}
tone="sky"
/>
<ActionButton label="AI生成" onClick={() => setIsAiGenerateOpen(true)} />
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
{presetImages.map((src, index) => {
const isSelected = src === draftImageSrc;
return (
<button
key={`${actLabel}-preset-${index}-${src || 'empty'}`}
type="button"
onClick={() => setDraftImageSrc(src)}
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={`幕背景预设 ${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>
</div>
</button>
);
})}
</div>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label="保存背景"
onClick={() => {
onApply(draftImageSrc || fallbackImageSrc || undefined);
onClose();
}}
tone="sky"
/>
</div>
</div>
</ModalShell>
{isAiGenerateOpen ? (
<SceneImageGenerationModal
profile={profile}
landmark={landmark}
initialPromptText={
act.backgroundPromptText?.trim() ||
compactTextList([act.title, act.summary, act.actGoal]).join('')
}
onApply={(result) => {
setDraftImageSrc(result.imageSrc);
}}
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
</>
);
}
const FIXED_COVER_IMAGE_SIZE = '1600*900';
const COVER_IMAGE_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
function buildGeneratedCoverProfile(
result: CustomWorldCoverAssetResult,
): CustomWorldCoverProfile {
return {
sourceType: result.sourceType,
imageSrc: result.imageSrc,
characterRoleIds: [],
};
}
function CoverUploadCropModal({
imageDataUrl,
imageSize,
worldName,
isSubmitting,
onCancel,
onConfirm,
}: {
imageDataUrl: string;
imageSize: { width: number; height: number };
worldName: string;
isSubmitting: boolean;
onCancel: () => void;
onConfirm: (cropRect: CustomWorldCoverCropRect) => void;
}) {
const [zoomPercent, setZoomPercent] = useState(100);
const baseCropRect = useMemo(
() => buildCenteredCoverCropRect(imageSize.width, imageSize.height),
[imageSize],
);
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
useEffect(() => {
setZoomPercent(100);
setOffsetX(0);
setOffsetY(0);
}, [imageDataUrl]);
const cropRect = useMemo(() => {
const scale = Math.max(1, zoomPercent / 100);
const nextCropRect = {
width: baseCropRect.width / scale,
height: baseCropRect.height / scale,
x: baseCropRect.x + offsetX,
y: baseCropRect.y + offsetY,
};
return clampCoverCropRect(nextCropRect, imageSize);
}, [baseCropRect, imageSize, offsetX, offsetY, zoomPercent]);
const previewStyle = useMemo(
() => buildCoverCropPreviewStyle(cropRect, imageSize),
[cropRect, imageSize],
);
const maxOffsetX = Math.max(0, imageSize.width - cropRect.width);
const maxOffsetY = Math.max(0, imageSize.height - cropRect.height);
return (
<ModalShell
title="裁剪上传封面"
onClose={onCancel}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[120]"
disableClose={isSubmitting}
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_20rem]">
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={imageDataUrl}
alt="上传封面裁剪预览"
fallbackLabel={worldName.slice(0, 4) || '封面'}
tone="landscape"
overlayInteractive
previewOverlay={
<>
<div className="absolute inset-0 bg-black/45" />
<div
className="absolute border border-sky-300/90 bg-white/8 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
style={previewStyle}
/>
</>
}
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="缩放">
<input
type="range"
min={100}
max={220}
step={1}
value={zoomPercent}
onChange={(event) => setZoomPercent(Number(event.target.value))}
disabled={isSubmitting}
className="w-full accent-sky-400"
/>
</Field>
<Field label="左右位置">
<input
type="range"
min={0}
max={Math.max(0, Math.floor(maxOffsetX))}
step={1}
value={Math.max(0, Math.floor(offsetX + baseCropRect.x))}
onChange={(event) =>
setOffsetX(Number(event.target.value) - baseCropRect.x)
}
disabled={isSubmitting}
className="w-full accent-sky-400"
/>
</Field>
<Field label="上下位置">
<input
type="range"
min={0}
max={Math.max(0, Math.floor(maxOffsetY))}
step={1}
value={Math.max(0, Math.floor(offsetY + baseCropRect.y))}
onChange={(event) =>
setOffsetY(Number(event.target.value) - baseCropRect.y)
}
disabled={isSubmitting}
className="w-full accent-sky-400"
/>
</Field>
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-6 text-zinc-200">
16:9 1600 × 900
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-xs leading-6 text-zinc-400">
<br />
{`x ${Math.round(cropRect.x)} / y ${Math.round(cropRect.y)} / w ${Math.round(cropRect.width)} / h ${Math.round(cropRect.height)}`}
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="取消"
onClick={onCancel}
disabled={isSubmitting}
/>
<ActionButton
label={isSubmitting ? '正在保存...' : '确认裁剪并上传'}
onClick={() => onConfirm(cropRect)}
tone="sky"
disabled={isSubmitting}
/>
</div>
</div>
</div>
</ModalShell>
);
}
function CoverImageGenerationModal({
profile,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
onApply: (result: CustomWorldCoverAssetResult) => void;
onClose: () => void;
}) {
const initialPresentation = useMemo(
() => resolveCustomWorldCoverPresentation(profile),
[profile],
);
const [userPrompt, setUserPrompt] = useDraft(profile.summary || profile.name);
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestResult, setLatestResult] =
useState<CustomWorldCoverAssetResult | null>(null);
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
const previewImageSrc = latestResult?.imageSrc || initialPresentation.imageSrc;
const openingAct = profile.sceneChapterBlueprints?.[0]?.acts?.[0] ?? null;
const selectedCharacterRoleIds =
profile.cover?.sourceType === 'default'
? profile.cover.characterRoleIds
: buildDefaultCustomWorldCoverProfile(profile).characterRoleIds;
const selectedRoleLabels = profile.playableNpcs
.filter((role) => selectedCharacterRoleIds?.includes(role.id))
.map((role) => role.name)
.filter(Boolean);
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 generateCustomWorldCoverImage({
profile,
userPrompt,
referenceImageSrc,
characterRoleIds: selectedCharacterRoleIds,
size: FIXED_COVER_IMAGE_SIZE,
});
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="AI 生成作品封面"
onClose={handleRequestClose}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
>
<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={5}
placeholder="例如:海雾压进旧码头,主角站在残灯与潮水之间,整体像一张正式 RPG 作品封面。"
/>
</Field>
<div className="rounded-2xl border border-white/8 bg-black/18 px-4 py-4 text-xs leading-6 text-zinc-300">
{openingAct?.title
? `系统会自动带入开局第一幕「${openingAct.title}」的场景素材。`
: '系统会自动带入当前世界的开局场景素材。'}
{selectedRoleLabels.length > 0
? ` 当前默认出镜角色:${selectedRoleLabels.join('、')}`
: ''}
</div>
<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-2.5">
<CustomWorldCoverArtwork
imageSrc={previewImageSrc}
title={profile.name}
fallbackLabel={profile.name.slice(0, 4) || '封面'}
renderMode={
latestResult ? 'image' : initialPresentation.renderMode
}
characterImageSrcs={
latestResult ? [] : initialPresentation.characterImageSrcs
}
className="aspect-[16/9] max-h-[14rem] rounded-2xl"
/>
</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]"
>
<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}
</>
);
}
export function WorldCoverEditor({
profile,
onSaveProfile,
onClose,
}: {
profile: CustomWorldProfile;
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draftCover, setDraftCover] = useDraft(
profile.cover ?? buildDefaultCustomWorldCoverProfile(profile),
);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [pendingUploadImageDataUrl, setPendingUploadImageDataUrl] = useState('');
const [pendingUploadImageSize, setPendingUploadImageSize] = useState<{
width: number;
height: number;
} | null>(null);
const previewProfile = useMemo(
() => ({
...profile,
cover: draftCover,
}),
[draftCover, profile],
);
const previewPresentation = useMemo(
() => resolveCustomWorldCoverPresentation(previewProfile),
[previewProfile],
);
const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
if (file.size > COVER_IMAGE_MAX_UPLOAD_BYTES) {
setUploadError('上传封面原图不能超过 10 MB。');
return;
}
const imageDataUrl = await readImageFileAsDataUrl(file);
const imageSize = await loadImageDimensionsFromDataUrl(imageDataUrl);
setPendingUploadImageDataUrl(imageDataUrl);
setPendingUploadImageSize(imageSize);
setUploadError(null);
} catch (uploadErrorValue) {
setUploadError(
uploadErrorValue instanceof Error
? uploadErrorValue.message
: '上传作品封面失败,请稍后重试。',
);
}
};
const handleConfirmUploadCrop = async (cropRect: CustomWorldCoverCropRect) => {
if (!pendingUploadImageDataUrl) {
return;
}
setIsUploading(true);
setUploadError(null);
try {
const result = await uploadCustomWorldCoverImage({
profileId: profile.id,
worldName: profile.name,
imageDataUrl: pendingUploadImageDataUrl,
cropRect,
});
setDraftCover(buildGeneratedCoverProfile(result));
setPendingUploadImageDataUrl('');
setPendingUploadImageSize(null);
} catch (uploadErrorValue) {
setUploadError(
uploadErrorValue instanceof Error
? uploadErrorValue.message
: '上传作品封面失败,请稍后重试。',
);
} finally {
setIsUploading(false);
}
};
return (
<>
<ModalShell
title="编辑作品封面"
onClose={onClose}
panelClassName="sm:max-w-3xl"
>
<div className="grid gap-4 md:grid-cols-[minmax(0,0.95fr)_minmax(17rem,1.05fr)]">
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
<CustomWorldCoverArtwork
imageSrc={previewPresentation.imageSrc}
title={profile.name}
fallbackLabel={profile.name.slice(0, 4) || '封面'}
renderMode={previewPresentation.renderMode}
characterImageSrcs={previewPresentation.characterImageSrcs}
className="aspect-[16/9] max-h-[13rem] rounded-2xl"
/>
</div>
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
{draftCover.sourceType === 'uploaded'
? '当前为上传封面'
: draftCover.sourceType === 'generated'
? '当前为 AI 封面'
: '当前为默认封面'}
</span>
</div>
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
</div>
<div className="mb-3 text-xs leading-5 text-zinc-400">
pngjpgwebp 16:9
</div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleUploadCover(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>
<div className="flex flex-wrap gap-3">
<ActionButton
label="AI 生成"
onClick={() => setIsGenerating(true)}
tone="sky"
/>
<ActionButton
label="重置为默认"
onClick={() =>
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
}
disabled={draftCover.sourceType === 'default'}
/>
</div>
{uploadError ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{uploadError}
</div>
) : null}
<SaveBar
onClose={onClose}
onSave={() => {
onSaveProfile({
...profile,
cover: draftCover,
});
onClose();
}}
/>
</div>
</div>
</ModalShell>
{isGenerating ? (
<CoverImageGenerationModal
profile={previewProfile}
onApply={(result) => {
setDraftCover(buildGeneratedCoverProfile(result));
setUploadError(null);
setIsGenerating(false);
}}
onClose={() => setIsGenerating(false)}
/>
) : null}
{pendingUploadImageDataUrl && pendingUploadImageSize ? (
<CoverUploadCropModal
imageDataUrl={pendingUploadImageDataUrl}
imageSize={pendingUploadImageSize}
worldName={profile.name}
isSubmitting={isUploading}
onCancel={() => {
if (isUploading) {
return;
}
setPendingUploadImageDataUrl('');
setPendingUploadImageSize(null);
}}
onConfirm={(cropRect) => {
void handleConfirmUploadCrop(cropRect);
}}
/>
) : null}
{isUploading ? (
<PortalCompactDialogShell
title="上传封面中"
onClose={() => {}}
disableClose
>
<div className="rounded-2xl border border-sky-300/18 bg-sky-500/10 px-4 py-4 text-sm leading-6 text-sky-50">
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
export 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)_0%,rgba(8,10,17,0.84)_42%,rgba(8,10,17,0.96)_100%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.2rem)] pt-2 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="platform-button platform-button--primary text-left"
>
</button>
</div>
</div>
</div>
);
}
export 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 portrait = role.imageSrc;
if (!portrait) {
return null;
}
return {
id: role.id,
name: role.name,
title: role.title,
description: role.description,
backstory: role.backstory,
avatar: portrait,
portrait,
assetFolder: 'custom-world',
assetVariant: 'generated',
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap,
attributes: { strength: 0, agility: 0, intelligence: 0, spirit: 0 },
personality: role.personality,
skills: [],
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 actionPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(draft.actionPreviewConfig, 320),
[draft.actionPreviewConfig],
);
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: [],
lastFrameImageDataUrl: role.imageSrc,
frameCount: 8,
fps: 10,
durationSeconds: 4,
loop: false,
useChromaKey: true,
resolution: '480p',
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'doubao-seedance-2-0-fast-260128',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
if (generationResult.strategy !== 'image-to-video') {
throw new Error('当前技能动作预览仅支持图生视频生成。');
}
const publishResult = await publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
animations: {
[actionKey]: {
framesDataUrls: [],
fps: 10,
loop: false,
frameWidth: 192,
frameHeight: 256,
frameCount: 8,
applyChromaKey: true,
previewVideoPath: generationResult.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-[20rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
{previewCharacter && draft.actionPreviewConfig ? (
<div style={actionPreviewFrameStyle}>
<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>
);
}
export 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>
);
}
export function CampSceneEditor({
profile,
onSaveProfile,
onClose,
}: {
profile: CustomWorldProfile;
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
return (
<LandmarkEditor
profile={profile}
landmark={resolveCustomWorldCampScene(profile)}
mode="edit"
sceneKind="camp"
onSaveProfile={onSaveProfile}
onClose={onClose}
/>
);
}
export 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 initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
const previewImageSrc = draft.imageSrc?.trim() ?? '';
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 = () => {
if (!hasUnsavedChanges) {
onClose();
return;
}
setIsCloseConfirmOpen(true);
};
return (
<>
<ModalShell
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
onClose={handleRequestClose}
disableClose={isAiAssetStudioOpen || isCloseConfirmOpen}
>
<div className="space-y-4">
{previewImageSrc ? (
<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={previewImageSrc}
alt={draft.name || '角色形象'}
className="h-28 w-full object-cover object-top"
/>
</div>
<div className="min-w-0">
<div className="text-base font-semibold text-white">
{draft.name || '未命名角色'}
</div>
<div className="mt-1 text-sm text-zinc-400">
{draft.title || draft.role}
</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="名称">
<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);
onClose();
}}
showClose={false}
/>
{isAiAssetStudioOpen ? (
<RpgCreationRoleAssetStudioModal
role={draft}
roleKind="playable"
cacheScopeId={profile.id}
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}
</>
);
}
export 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 initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
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 = () => {
if (!hasUnsavedChanges) {
onClose();
return;
}
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 ? (
<RpgCreationRoleAssetStudioModal
role={draft}
roleKind="story"
cacheScopeId={profile.id}
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}
</>
);
}
export function LandmarkEditor({
profile,
landmark,
mode,
sceneKind = 'landmark',
onSaveProfile,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
mode: 'create' | 'edit';
sceneKind?: 'camp' | 'landmark';
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const isOpeningScene = sceneKind === 'camp';
const [draft, setDraft] = useDraft(landmark);
const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const [isWorldMapOpen, setIsWorldMapOpen] = useState(false);
const [activeConnectionDirection, setActiveConnectionDirection] =
useState<CardinalConnectionDirection | null>(null);
const [activeSceneActSlotPickerState, setActiveSceneActSlotPickerState] =
useState<{
actIndex: number;
slotIndex: number;
} | null>(null);
const [activeSceneActBackgroundIndex, setActiveSceneActBackgroundIndex] =
useState<number | null>(null);
const [activeSceneActPreviewIndex, setActiveSceneActPreviewIndex] =
useState<number | null>(null);
const [npcEditorState, setNpcEditorState] = useState<{
mode: 'create' | 'edit';
npc: CustomWorldNpc;
} | null>(null);
const resolvedInitialLandmarkImageSrc = useMemo(() => {
if (isOpeningScene) {
return resolveCustomWorldCampSceneImage({
...profile,
camp: landmark,
});
}
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)),
);
}, [isOpeningScene, landmark, profile]);
const initialSceneChapterDraft = useMemo(
() =>
resolveSceneChapterBlueprintDraft({
profile,
landmark,
fallbackImageSrc: resolvedInitialLandmarkImageSrc,
}),
[landmark, profile, resolvedInitialLandmarkImageSrc],
);
const [sceneChapterDraft, setSceneChapterDraft] = useDraft(
initialSceneChapterDraft,
);
const resolvedDraftImageSrc = useMemo(() => {
if (isOpeningScene) {
return resolveCustomWorldCampSceneImage({
...profile,
camp: draft,
});
}
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, isOpeningScene, profile]);
const storyNpcById = useMemo(
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
[draftStoryNpcs],
);
const previewPlayableCharacter = useMemo(
() =>
buildCustomWorldPlayableCharacters({
...profile,
storyNpcs: draftStoryNpcs,
})[0] ??
ROLE_TEMPLATE_CHARACTERS[0] ??
null,
[draftStoryNpcs, profile],
);
const renderedSceneChapterDraft = useMemo(
() =>
sanitizeSceneChapterBlueprint({
chapter: sceneChapterDraft,
landmark: {
...draft,
sceneNpcIds: dedupeTextValues(draft.sceneNpcIds),
},
fallbackImageSrc: resolvedDraftImageSrc,
}),
[draft, resolvedDraftImageSrc, sceneChapterDraft],
);
const derivedSceneNpcIds = useMemo(
() => collectSceneChapterEncounterNpcIds(renderedSceneChapterDraft),
[renderedSceneChapterDraft],
);
const compatibilitySceneNpcIds = useMemo(
() =>
resolveSceneCompatibilityNpcIds({
chapter: renderedSceneChapterDraft,
currentNpcIds: draft.sceneNpcIds,
}),
[draft.sceneNpcIds, renderedSceneChapterDraft],
);
const sceneNpcOptions = useMemo(
() =>
compatibilitySceneNpcIds
.map((npcId) => storyNpcById.get(npcId))
.filter((npc): npc is CustomWorldNpc => Boolean(npc)),
[compatibilitySceneNpcIds, storyNpcById],
);
const compatibilityImageSrc = useMemo(
() =>
resolveSceneCompatibilityImageSrc({
chapter: renderedSceneChapterDraft,
currentImageSrc: draft.imageSrc,
resolvedImageSrc: resolvedDraftImageSrc,
}),
[draft.imageSrc, renderedSceneChapterDraft, resolvedDraftImageSrc],
);
const activeSceneActSlotDraft =
activeSceneActSlotPickerState
? renderedSceneChapterDraft.acts[activeSceneActSlotPickerState.actIndex] ?? null
: null;
const activeSceneActBackgroundDraft =
activeSceneActBackgroundIndex !== null
? renderedSceneChapterDraft.acts[activeSceneActBackgroundIndex] ?? null
: null;
const activeSceneActPreviewDraft =
activeSceneActPreviewIndex !== null
? renderedSceneChapterDraft.acts[activeSceneActPreviewIndex] ?? null
: null;
const availableTargetLandmarks = useMemo(
() => profile.landmarks.filter((entry) => entry.id !== draft.id),
[draft.id, profile.landmarks],
);
const editableProfile = useMemo(() => {
const nextLandmarks =
isOpeningScene
? profile.landmarks
: mode === 'create'
? [...profile.landmarks, draft]
: profile.landmarks.map((entry) =>
entry.id === draft.id ? draft : entry,
);
return {
...profile,
camp: isOpeningScene ? draft : profile.camp,
storyNpcs: draftStoryNpcs,
landmarks: nextLandmarks,
};
}, [draft, draftStoryNpcs, isOpeningScene, mode, profile]);
const previewProfile = useMemo(
() => ({
...editableProfile,
sceneChapterBlueprints: upsertSceneChapterBlueprint(
editableProfile.sceneChapterBlueprints,
renderedSceneChapterDraft,
),
}),
[editableProfile, renderedSceneChapterDraft],
);
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 initialSceneChapterSnapshot = useMemo(
() => JSON.stringify(initialSceneChapterDraft),
[initialSceneChapterDraft],
);
const currentSceneChapterSnapshot = useMemo(
() => JSON.stringify(renderedSceneChapterDraft),
[renderedSceneChapterDraft],
);
const hasUnsavedChanges =
initialLandmarkSnapshot !== currentLandmarkSnapshot ||
initialStoryNpcSnapshot !== currentStoryNpcSnapshot ||
initialSceneChapterSnapshot !== currentSceneChapterSnapshot;
const handleRequestClose = () => {
if (!hasUnsavedChanges) {
onClose();
return;
}
setIsCloseConfirmOpen(true);
};
const updateSceneActDraft = (
updater: (chapter: SceneChapterBlueprint) => SceneChapterBlueprint,
) => {
setSceneChapterDraft((current) =>
sanitizeSceneChapterBlueprint({
chapter: updater(
sanitizeSceneChapterBlueprint({
chapter: current,
landmark: {
...draft,
sceneNpcIds: dedupeTextValues(draft.sceneNpcIds),
},
fallbackImageSrc: resolvedDraftImageSrc,
}),
),
landmark: {
...draft,
sceneNpcIds: dedupeTextValues(draft.sceneNpcIds),
},
fallbackImageSrc: resolvedDraftImageSrc,
}),
);
};
const addSceneAct = () => {
if (renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT) {
window.alert(`每个场景最多只能配置 ${MAX_SCENE_ACT_COUNT} 幕。`);
return;
}
updateSceneActDraft((current) => {
const nextActCount = current.acts.length + 1;
return {
...current,
acts: [
...current.acts,
buildDefaultSceneActBlueprint({
sceneId: draft.id,
sceneName: draft.name,
sceneSummary: draft.description,
encounterNpcIds: derivedSceneNpcIds,
backgroundImageSrc: compatibilityImageSrc || resolvedDraftImageSrc,
linkedThreadIds: current.linkedThreadIds,
index: current.acts.length,
actCount: nextActCount,
}),
],
};
});
};
const removeSceneAct = (index: number) => {
if (renderedSceneChapterDraft.acts.length <= MIN_SCENE_ACT_COUNT) {
window.alert(`每个场景至少需要保留 ${MIN_SCENE_ACT_COUNT} 幕。`);
return;
}
updateSceneActDraft((current) => ({
...current,
acts: current.acts.filter((_act, actIndex) => actIndex !== index),
}));
};
const moveSceneAct = (index: number, delta: number) => {
updateSceneActDraft((current) => ({
...current,
acts: moveArrayItem(current.acts, index, index + delta),
}));
};
const updateSceneActField = (
index: number,
updater: (act: SceneActBlueprint) => SceneActBlueprint,
) => {
updateSceneActDraft((current) => ({
...current,
acts: current.acts.map((act, actIndex) =>
actIndex === index ? updater(act) : act,
),
}));
};
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 saveLandmarkProfile = () => {
const sanitizedDraft = {
...draft,
imageSrc: compatibilityImageSrc,
sceneNpcIds: compatibilitySceneNpcIds,
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 (compatibilitySceneNpcIds.length < 3) {
window.alert('每个场景至少需要在多幕配置中覆盖 3 个 NPC。');
return;
}
const nextLandmarks =
isOpeningScene
? profile.landmarks
: mode === 'create'
? [...profile.landmarks, sanitizedDraft]
: profile.landmarks.map((entry) =>
entry.id === sanitizedDraft.id ? sanitizedDraft : entry,
);
const syncedLandmarks = syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs);
const syncedSavedLandmark =
syncedLandmarks.find((entry) => entry.id === sanitizedDraft.id) ?? sanitizedDraft;
const nextProfileBase = {
...profile,
camp: isOpeningScene ? sanitizedDraft : profile.camp,
storyNpcs: draftStoryNpcs,
landmarks: syncedLandmarks,
};
const nextProfileWithCompatibilityFields = isOpeningScene
? {
...nextProfileBase,
camp: syncedSavedLandmark,
}
: nextProfileBase;
const savedLandmark =
isOpeningScene
? syncedSavedLandmark
: syncedSavedLandmark;
const savedLandmarkIndex = syncedLandmarks.findIndex(
(entry) => entry.id === savedLandmark.id,
);
const nextSceneChapterBlueprint = sanitizeSceneChapterBlueprint({
chapter: renderedSceneChapterDraft,
landmark: savedLandmark,
fallbackImageSrc: isOpeningScene
? resolveCustomWorldCampSceneImage({
...nextProfileWithCompatibilityFields,
camp: savedLandmark,
})
: resolveCustomWorldLandmarkImage(
nextProfileWithCompatibilityFields,
savedLandmark,
savedLandmarkIndex >= 0 ? savedLandmarkIndex : syncedLandmarks.length,
syncedLandmarks
.filter((entry) => entry.id !== savedLandmark.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
),
});
onSaveProfile({
...nextProfileWithCompatibilityFields,
sceneChapterBlueprints: upsertSceneChapterBlueprint(
profile.sceneChapterBlueprints,
nextSceneChapterBlueprint,
),
});
onClose();
};
return (
<>
<ModalShell
title={
mode === 'create'
? '新增场景'
: `编辑场景:${landmark.name || (isOpeningScene ? '开局场景' : '未命名场景')}`
}
onClose={handleRequestClose}
>
<div className="space-y-4">
<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>
<SectionPanel
title="多幕配置"
actions={
<ActionButton
label={
renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT
? `已满 ${MAX_SCENE_ACT_COUNT}`
: '新增一幕'
}
onClick={addSceneAct}
tone="sky"
disabled={renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT}
/>
}
>
{renderedSceneChapterDraft.acts.map((act, index) => {
const actLabel = act.title.trim() || buildDefaultSceneActTitle(index);
const encounterSlotIds = buildSceneActEncounterSlotIds(
act.encounterNpcIds,
);
const encounterSlotNpcs = encounterSlotIds.map(
(npcId) => (npcId ? storyNpcById.get(npcId) ?? null : null),
);
return (
<div
key={`${act.id}-${index}`}
data-testid="scene-act-card"
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
{index + 1}
</div>
<div className="mt-1 text-sm font-semibold text-white">
{actLabel}
</div>
</div>
<div className="flex flex-wrap gap-2">
<ActionButton
label="上移"
onClick={() => moveSceneAct(index, -1)}
disabled={index === 0}
/>
<ActionButton
label="下移"
onClick={() => moveSceneAct(index, 1)}
disabled={index >= renderedSceneChapterDraft.acts.length - 1}
/>
<ActionButton
label="删除"
onClick={() => removeSceneAct(index)}
disabled={
renderedSceneChapterDraft.acts.length <= MIN_SCENE_ACT_COUNT
}
/>
</div>
</div>
<div className="mt-4 space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<SceneActStagePreview
actLabel={actLabel}
imageSrc={act.backgroundImageSrc}
fallbackImageSrc={resolvedDraftImageSrc}
previewCharacter={previewPlayableCharacter}
slotNpcs={encounterSlotNpcs}
onSlotClick={(slotIndex) => {
if (sceneNpcOptions.length === 0) {
window.alert('请先为场景分配 NPC再配置这一幕的角色槽位。');
return;
}
if (
slotIndex > 0 &&
!encounterSlotNpcs[slotIndex - 1]
) {
window.alert('请先配置前一个角色槽位。');
return;
}
setActiveSceneActSlotPickerState({
actIndex: index,
slotIndex,
});
}}
/>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-1 text-sm text-zinc-200">
{act.backgroundImageSrc === resolvedDraftImageSrc
? '当前跟随场景主图'
: '已配置独立背景'}
</div>
</div>
<div className="flex flex-wrap gap-2">
<ActionButton
label="配置背景"
onClick={() => setActiveSceneActBackgroundIndex(index)}
tone="sky"
/>
<ActionButton
label="幕预览"
onClick={() => {
if (!encounterSlotNpcs[0]) {
window.alert('请先为这一幕配置主角色,再开始预览。');
return;
}
setActiveSceneActPreviewIndex(index);
}}
/>
</div>
</div>
</div>
</div>
</div>
);
})}
</SectionPanel>
<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}
/>
{activeSceneActBackgroundDraft && activeSceneActBackgroundIndex !== null ? (
<SceneActBackgroundModal
profile={editableProfile}
landmark={draft}
actLabel={
activeSceneActBackgroundDraft.title.trim() ||
buildDefaultSceneActTitle(activeSceneActBackgroundIndex)
}
act={activeSceneActBackgroundDraft}
currentImageSrc={activeSceneActBackgroundDraft.backgroundImageSrc}
fallbackImageSrc={resolvedDraftImageSrc}
onApply={(imageSrc) =>
updateSceneActField(activeSceneActBackgroundIndex, (current) => ({
...current,
backgroundImageSrc: imageSrc || undefined,
}))
}
onClose={() => setActiveSceneActBackgroundIndex(null)}
/>
) : null}
{activeSceneActSlotDraft && activeSceneActSlotPickerState ? (
<SceneActNpcSlotPickerModal
actLabel={
activeSceneActSlotDraft.title.trim() ||
buildDefaultSceneActTitle(activeSceneActSlotPickerState.actIndex)
}
slotIndex={activeSceneActSlotPickerState.slotIndex}
currentNpcId={
buildSceneActEncounterSlotIds(
activeSceneActSlotDraft.encounterNpcIds,
)[activeSceneActSlotPickerState.slotIndex]
}
availableNpcs={sceneNpcOptions}
onApply={(npcId) =>
updateSceneActField(activeSceneActSlotPickerState.actIndex, (current) => {
const encounterNpcIds = assignSceneActEncounterSlotId(
current.encounterNpcIds,
activeSceneActSlotPickerState.slotIndex,
npcId,
);
return {
...current,
encounterNpcIds,
primaryNpcId: encounterNpcIds[0] ?? '',
};
})
}
onClose={() => setActiveSceneActSlotPickerState(null)}
/>
) : null}
{activeSceneActPreviewDraft && activeSceneActPreviewIndex !== null ? (
<SceneActPreviewModal
profile={previewProfile}
landmark={draft}
chapter={renderedSceneChapterDraft}
actIndex={activeSceneActPreviewIndex}
onClose={() => setActiveSceneActPreviewIndex(null)}
/>
) : 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={
isOpeningScene
? [draft, ...editableProfile.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,
),
);
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 RpgCreationEntityEditorModal({
profile,
target,
onClose,
onProfileChange,
}: RpgCreationEntityEditorModalProps) {
if (!target) return null;
if (target.kind === 'world') {
return (
<WorldEditor
profile={profile}
onSave={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'cover') {
return (
<WorldCoverEditor
profile={profile}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'camp') {
return (
<CampSceneEditor
profile={profile}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'playable') {
const npc = resolveEditablePlayableNpc(profile, target);
return npc ? (
<PlayableNpcEditor
profile={profile}
npc={npc}
mode={target.mode}
onSave={(nextNpc) =>
onProfileChange({
...profile,
playableNpcs:
target.mode === 'create'
? [...profile.playableNpcs, nextNpc]
: profile.playableNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
if (target.kind === 'story') {
const npc = resolveEditableStoryNpc(profile, target);
return npc ? (
<StoryNpcEditor
profile={profile}
npc={npc}
mode={target.mode}
onSave={(nextNpc) =>
onProfileChange({
...profile,
storyNpcs:
target.mode === 'create'
? [...profile.storyNpcs, nextNpc]
: profile.storyNpcs.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
})
}
onClose={onClose}
/>
) : null;
}
const landmark = resolveEditableLandmark(profile, target);
return landmark ? (
<LandmarkEditor
profile={profile}
landmark={landmark}
mode={target.mode}
onSaveProfile={onProfileChange}
onClose={onClose}
/>
) : null;
}
export { RpgCreationEntityEditorModal };
export default RpgCreationEntityEditorModal;