Files
Genarrative/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

7205 lines
218 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ChangeEvent, CSSProperties, PointerEvent } from 'react';
import {
Children,
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
buildCustomWorldRuntimeCharacters,
createCharacterSkillCooldowns,
getCharacterMaxHp,
getCharacterMaxMana,
ROLE_TEMPLATE_CHARACTERS,
setRuntimeCharacterOverrides,
} from '../../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../../data/customWorldVisuals';
import { buildInitialEquipmentLoadout } from '../../data/equipmentEffects';
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
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 { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story/useRpgRuntimeStory';
import { useRpgSessionBootstrap } from '../../hooks/rpg-session/useRpgSessionBootstrap';
import { useCombatFlow } from '../../hooks/useCombatFlow';
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
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 { createEmptyCustomWorldCreatorIntent } from '../../services/customWorldCreatorIntent';
import {
type CustomWorldFoundationEntryId,
getCustomWorldFoundationAnchorContent,
parseFoundationTagText,
} from '../../services/customWorldFoundationEntries';
import {
rpgCreationAssetClient,
type RpgCreationHistoryAsset,
type RpgCreationHistoryAssetKind,
} 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,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import {
type CharacterAnimationGenerationPayload,
generateCharacterAnimationDraft,
publishCharacterAnimationAssets,
} from '../asset-studio/characterAssetWorkflowPersistence';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformOverlayBadge } from '../common/PlatformOverlayBadge';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSlotBadge } from '../common/PlatformSlotBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import { PlatformUploadTile } from '../common/PlatformUploadTile';
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { CustomWorldNpcPortrait } from '../CustomWorldNpcVisualEditor';
import {
RoleCharacterSprite,
SceneEncounterNpcSprite,
} from '../game-canvas/GameCanvasShared';
import { PixelIcon } from '../PixelIcon';
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
import { RpgRuntimeShell } from '../rpg-runtime-shell';
import {
resolveEditableLandmark,
resolveEditablePlayableNpc,
resolveEditableStoryNpc,
} from './rpgCreationResultFormMapper';
export type RpgCreationEditorTarget =
| { kind: 'world' }
| { kind: 'foundation' }
| { 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_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 RPG_EDITOR_STATUS_SUCCESS_CLASS_NAME =
'rounded-2xl border-emerald-300/18 bg-emerald-500/10 text-emerald-50';
const RPG_EDITOR_STATUS_ERROR_CLASS_NAME =
'rounded-2xl border-rose-400/18 bg-rose-500/10 text-rose-100';
function EditorEmptyState({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className={['py-4 leading-normal', className].filter(Boolean).join(' ')}
>
{children}
</PlatformEmptyState>
);
}
function EditorInfoPanel({
title,
children,
actions,
className,
headerClassName,
actionsClassName,
bodyClassName = 'mt-3',
}: {
title: ReactNode;
children: ReactNode;
actions?: ReactNode;
className?: string;
headerClassName?: string;
actionsClassName?: string;
bodyClassName?: string;
}) {
return (
<PlatformSubpanel
surface="dark"
radius="md"
padding="md"
title={title}
titleVariant="strong"
actions={actions}
headerClassName={headerClassName}
titleClassName="text-[11px] font-bold tracking-[0.16em] text-zinc-300"
actionsClassName={actionsClassName}
bodyClassName={bodyClassName}
className={className}
>
{children}
</PlatformSubpanel>
);
}
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 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 oppositeNpcId = encounterNpcIds[0] ?? '';
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: oppositeNpcId,
linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []),
advanceRule: buildSceneActAdvanceRule(params.index, params.actCount),
oppositeNpcId,
actGoal:
params.index === 0
? `先在${sceneLabel}接住当前局面`
: params.index >= params.actCount - 1
? `${sceneLabel}这一章收束并抛出下一步`
: `继续推进${sceneLabel}的核心矛盾`,
eventDescription: buildDefaultSceneActEventDescription({
sceneSummary,
oppositeNpcId,
index: params.index,
}),
transitionHook:
params.index === 0
? '和主角色完成首次有效接触后,局势会继续加压。'
: params.index >= params.actCount - 1
? '这一幕结束后,需要把后续方向明确抛给玩家。'
: '完成当前主动推进后,这一幕会转向下一层压力。',
};
}
function buildDefaultSceneActEventDescription(params: {
sceneSummary: string;
oppositeNpcId: string;
index: number;
}) {
const roleText = params.oppositeNpcId.trim() || '当前场景关键角色';
const sceneText = params.sceneSummary.trim() || '场景内的主线压力';
if (params.index === 0) {
return `第1幕中${roleText}先露出与${sceneText}有关的异常线索,玩家必须确认局势入口。`;
}
if (params.index === 1) {
return `第2幕中${roleText}的立场或阻碍让${sceneText}升级,玩家必须在压力下作出判断。`;
}
return `第3幕中${roleText}${sceneText}推向高潮,玩家必须面对关键抉择或直接后果。`;
}
function buildDefaultSceneActBackgroundPrompt(params: {
sceneSummary: string;
oppositeNpcId: string;
eventDescription: string;
index: number;
}) {
const roleText = params.oppositeNpcId.trim() || '当前场景关键角色';
const sceneText = params.sceneSummary.trim() || '场景内的主线压力';
const phaseText =
params.index === 0
? '铺垫阶段'
: params.index === 1
? '冲突升级阶段'
: '高潮阶段';
return `${sceneText}${phaseText}画面,${roleText}与玩家隔着可站立空间形成对峙,环境里保留“${params.eventDescription}”的冲突痕迹与清晰氛围。`;
}
function buildDefaultSceneTaskDescription(landmark: CustomWorldLandmark) {
const sceneName = landmark.name.trim() || '当前场景';
const sceneDescription = landmark.description.trim();
if (!sceneDescription) {
return `首次进入${sceneName}时,确认当前场景的核心异常、关键角色与下一步行动方向。`;
}
return `首次进入${sceneName}时,围绕${sceneDescription}确认核心任务、关键角色与下一步行动。`;
}
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(),
sceneTaskDescription: buildDefaultSceneTaskDescription(params.landmark),
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
: currentAct
? []
: fallbackAct.encounterNpcIds;
const primaryNpcId = resolvedEncounterNpcIds[0] ?? '';
const oppositeNpcId = currentAct?.oppositeNpcId?.trim() || primaryNpcId;
const eventDescription =
currentAct?.eventDescription?.trim() ||
buildDefaultSceneActEventDescription({
sceneSummary: params.landmark.description,
oppositeNpcId,
index,
});
const backgroundPromptText =
currentAct?.backgroundPromptText?.trim() ||
buildDefaultSceneActBackgroundPrompt({
sceneSummary: params.landmark.description,
oppositeNpcId,
eventDescription,
index,
});
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,
backgroundImageSrc:
currentAct?.backgroundImageSrc?.trim() ||
params.fallbackImageSrc ||
fallbackAct.backgroundImageSrc,
encounterNpcIds: resolvedEncounterNpcIds,
primaryNpcId,
oppositeNpcId,
eventDescription,
linkedThreadIds: dedupeTextValues(currentAct?.linkedThreadIds ?? []),
advanceRule: buildSceneActAdvanceRule(index, targetActCount),
actGoal: currentAct?.actGoal?.trim() || fallbackAct.actGoal,
transitionHook:
currentAct?.transitionHook?.trim() || fallbackAct.transitionHook,
backgroundAssetId:
currentAct?.backgroundAssetId?.trim() || fallbackAct.backgroundAssetId,
} 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,
sceneTaskDescription: (() => {
const currentTask = params.chapter?.sceneTaskDescription?.trim() ?? '';
const sceneDescription = params.landmark.description.trim();
return currentTask && currentTask !== sceneDescription
? currentTask
: fallbackChapter.sceneTaskDescription;
})(),
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() || '';
// 中文注释:场景卡片只读取当前幕已保存图片;场景主图只给没有幕图的旧草稿兜底,不能反向覆盖每一幕。
return firstActImageSrc || currentImageSrc || resolvedImageSrc || 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,
});
}
type SceneActSelectableNpc = CustomWorldPlayableNpc | CustomWorldNpc;
function getSceneActSelectableNpcVisual(npc: SceneActSelectableNpc) {
return 'visual' in npc ? npc.visual : undefined;
}
function buildSceneActSelectableNpcs(params: {
profile: CustomWorldProfile;
storyNpcs: CustomWorldProfile['storyNpcs'];
preferredNpcIds: string[];
}) {
const preferredNpcIdSet = new Set(params.preferredNpcIds);
const dedupedNpcs = new Map<string, SceneActSelectableNpc>();
[...params.profile.playableNpcs, ...params.storyNpcs].forEach((npc) => {
const npcId = npc.id.trim();
if (!npcId || dedupedNpcs.has(npcId)) {
return;
}
dedupedNpcs.set(npcId, npc);
});
return [...dedupedNpcs.values()].sort((left, right) => {
const leftPreferred = preferredNpcIdSet.has(left.id) ? 0 : 1;
const rightPreferred = preferredNpcIdSet.has(right.id) ? 0 : 1;
if (leftPreferred !== rightPreferred) {
return leftPreferred - rightPreferred;
}
return left.name.localeCompare(right.name, 'zh-Hans-CN');
});
}
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;
});
}
const COVER_CROP_RATIO = 16 / 9;
type CoverCropDragHandle =
| 'move'
| 'north'
| 'northEast'
| 'east'
| 'southEast'
| 'south'
| 'southWest'
| 'west'
| 'northWest';
type CoverCropDragSnapshot = {
pointerId: number;
handle: CoverCropDragHandle;
clientX: number;
clientY: number;
cropRect: CustomWorldCoverCropRect;
previewWidth: number;
previewHeight: number;
};
const COVER_CROP_RESIZE_HANDLES: Array<{
handle: Exclude<CoverCropDragHandle, 'move'>;
label: string;
className: string;
dotClassName: string;
}> = [
{
handle: 'northWest',
label: '拖拽左上角裁剪边界',
className:
'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'north',
label: '拖拽上边裁剪边界',
className:
'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'northEast',
label: '拖拽右上角裁剪边界',
className:
'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'east',
label: '拖拽右边裁剪边界',
className:
'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'southEast',
label: '拖拽右下角裁剪边界',
className:
'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'south',
label: '拖拽下边裁剪边界',
className:
'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'southWest',
label: '拖拽左下角裁剪边界',
className:
'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize',
dotClassName: 'left-1/2 top-1/2',
},
{
handle: 'west',
label: '拖拽左边裁剪边界',
className:
'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize',
dotClassName: 'left-1/2 top-1/2',
},
];
function clampNumber(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function getCoverCropSizeBounds(imageSize: { width: number; height: number }) {
const maxWidth = Math.max(
1,
Math.min(imageSize.width, imageSize.height * COVER_CROP_RATIO),
);
const minWidth = Math.min(maxWidth, Math.max(48, maxWidth * 0.16));
return { minWidth, maxWidth };
}
function normalizeCoverCropRect(
cropRect: CustomWorldCoverCropRect,
imageSize: { width: number; height: number },
): CustomWorldCoverCropRect {
const { minWidth, maxWidth } = getCoverCropSizeBounds(imageSize);
const width = clampNumber(cropRect.width, minWidth, maxWidth);
const height = width / COVER_CROP_RATIO;
const x = clampNumber(cropRect.x, 0, Math.max(0, imageSize.width - width));
const y = clampNumber(cropRect.y, 0, Math.max(0, imageSize.height - height));
return { x, y, width, height };
}
function buildCenteredCoverCropRect(
width: number,
height: number,
): CustomWorldCoverCropRect {
if (width <= 0 || height <= 0) {
return { x: 0, y: 0, width: 1, height: 1 };
}
if (width / height >= COVER_CROP_RATIO) {
const cropHeight = height;
const cropWidth = cropHeight * COVER_CROP_RATIO;
return {
x: (width - cropWidth) / 2,
y: 0,
width: cropWidth,
height: cropHeight,
};
}
const cropWidth = width;
const cropHeight = cropWidth / COVER_CROP_RATIO;
return {
x: 0,
y: (height - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
};
}
function resizeCoverCropRectFromHandle(
snapshot: CoverCropDragSnapshot,
deltaX: number,
deltaY: number,
imageSize: { width: number; height: number },
): CustomWorldCoverCropRect {
const start = snapshot.cropRect;
const startRight = start.x + start.width;
const startBottom = start.y + start.height;
const startCenterX = start.x + start.width / 2;
const startCenterY = start.y + start.height / 2;
const { minWidth, maxWidth } = getCoverCropSizeBounds(imageSize);
const chooseWidth = (widthFromX: number, widthFromY: number) => {
const xDistance = Math.abs(widthFromX - start.width);
const yDistance = Math.abs(widthFromY - start.width);
return xDistance >= yDistance ? widthFromX : widthFromY;
};
const clampWidth = (width: number, maxByAnchor = maxWidth) =>
clampNumber(
width,
minWidth,
Math.max(minWidth, Math.min(maxWidth, maxByAnchor)),
);
if (snapshot.handle === 'move') {
return normalizeCoverCropRect(
{
...start,
x: start.x + deltaX,
y: start.y + deltaY,
},
imageSize,
);
}
if (snapshot.handle === 'east' || snapshot.handle === 'west') {
const isEast = snapshot.handle === 'east';
const anchorX = isEast ? start.x : startRight;
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
const maxByCenterY =
2 *
Math.min(startCenterY, imageSize.height - startCenterY) *
COVER_CROP_RATIO;
const width = clampWidth(
start.width + (isEast ? deltaX : -deltaX),
Math.min(maxByAnchorX, maxByCenterY),
);
const height = width / COVER_CROP_RATIO;
return normalizeCoverCropRect(
{
x: isEast ? anchorX : anchorX - width,
y: startCenterY - height / 2,
width,
height,
},
imageSize,
);
}
if (snapshot.handle === 'north' || snapshot.handle === 'south') {
const isSouth = snapshot.handle === 'south';
const anchorY = isSouth ? start.y : startBottom;
const maxByAnchorY =
(isSouth ? imageSize.height - anchorY : anchorY) * COVER_CROP_RATIO;
const maxByCenterX =
2 * Math.min(startCenterX, imageSize.width - startCenterX);
const width = clampWidth(
(start.height + (isSouth ? deltaY : -deltaY)) * COVER_CROP_RATIO,
Math.min(maxByAnchorY, maxByCenterX),
);
const height = width / COVER_CROP_RATIO;
return normalizeCoverCropRect(
{
x: startCenterX - width / 2,
y: isSouth ? anchorY : anchorY - height,
width,
height,
},
imageSize,
);
}
const isEast =
snapshot.handle === 'northEast' || snapshot.handle === 'southEast';
const isSouth =
snapshot.handle === 'southEast' || snapshot.handle === 'southWest';
const anchorX = isEast ? start.x : startRight;
const anchorY = isSouth ? start.y : startBottom;
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
const maxByAnchorY =
(isSouth ? imageSize.height - anchorY : anchorY) * COVER_CROP_RATIO;
const widthFromX = start.width + (isEast ? deltaX : -deltaX);
const widthFromY =
(start.height + (isSouth ? deltaY : -deltaY)) * COVER_CROP_RATIO;
const width = clampWidth(
chooseWidth(widthFromX, widthFromY),
Math.min(maxByAnchorX, maxByAnchorY),
);
const height = width / COVER_CROP_RATIO;
return normalizeCoverCropRect(
{
x: isEast ? anchorX : anchorX - width,
y: isSouth ? anchorY : anchorY - height,
width,
height,
},
imageSize,
);
}
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)] xl:max-h-[min(94vh,64rem)] ${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>
<PlatformModalCloseButton
label="关闭"
variant="platformIcon"
onClick={onClose}
disabled={disableClose}
className={disableClose ? 'cursor-not-allowed opacity-45' : ''}
/>
</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,
panelClassName = '',
overlayClassName = 'z-[140]',
disableClose = false,
usePixelFont = false,
}: {
title: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
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}`} ${panelClassName}`}
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>
<PlatformModalCloseButton
label="关闭"
variant="platformIcon"
onClick={onClose}
disabled={disableClose}
className={disableClose ? 'cursor-not-allowed opacity-45' : ''}
/>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
function PortalCompactDialogShell(props: {
title: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<CompactDialogShell {...props} />, document.body);
}
function CloseConfirmDialog({
message,
onCancel,
onConfirm,
confirmLabel = '确认关闭',
}: {
message: string;
onCancel: () => void;
onConfirm: () => void;
confirmLabel?: string;
}) {
return (
<UnifiedConfirmDialog
open
title="确认关闭"
onClose={onCancel}
confirmLabel={confirmLabel}
cancelLabel="继续编辑"
showCancel
onConfirm={onConfirm}
overlayClassName="z-[140] !items-center"
panelClassName="platform-remap-surface rounded-[1.5rem]"
>
{message}
</UnifiedConfirmDialog>
);
}
function EditorNoticeDialog({
message,
onClose,
}: {
message: string;
onClose: () => void;
}) {
return (
<UnifiedConfirmDialog
open
title="提示"
onClose={onClose}
confirmLabel="知道了"
overlayClassName="z-[140] !items-center"
panelClassName="platform-remap-surface rounded-[1.5rem]"
>
{message}
</UnifiedConfirmDialog>
);
}
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>
<PlatformIconButton
label={`${label}说明`}
variant="darkMini"
icon={<span aria-hidden="true">?</span>}
onClick={(event) => {
event.preventDefault();
setOpen((current) => !current);
}}
className="h-4 w-4 text-[10px]"
/>
{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 (
<PlatformMediaFrame
src={src}
alt={alt}
fallbackLabel={fallbackLabel}
aspect={tone === 'landscape' ? 'landscape' : 'square'}
surface="editorDark"
loading="lazy"
overlayInteractive={overlayInteractive}
previewOverlay={previewOverlay}
>
{children}
</PlatformMediaFrame>
);
}
function ActionButton({
label,
onClick,
tone = 'default',
disabled = false,
className = '',
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'sky' | 'rose';
disabled?: boolean;
className?: string;
}) {
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' : ''} ${className}`}
>
{label}
</button>
);
}
function formatHistoryAssetDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value || '';
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function HistoryAssetPickerModal({
title,
kind,
tone,
onSelect,
onClose,
}: {
title: string;
kind: RpgCreationHistoryAssetKind;
tone: 'square' | 'landscape';
onSelect: (asset: RpgCreationHistoryAsset) => void;
onClose: () => void;
}) {
const [assets, setAssets] = useState<RpgCreationHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isCancelled = false;
setIsLoading(true);
setError(null);
rpgCreationAssetClient
.listHistoryAssets({ kind, limit: 120 })
.then((nextAssets) => {
if (!isCancelled) {
setAssets(nextAssets);
}
})
.catch((loadError) => {
if (!isCancelled) {
setError(
loadError instanceof Error
? loadError.message
: '历史素材读取失败。',
);
}
})
.finally(() => {
if (!isCancelled) {
setIsLoading(false);
}
});
return () => {
isCancelled = true;
};
}, [kind]);
return (
<ModalShell
title={title}
onClose={onClose}
overlayClassName="z-[99]"
panelClassName="sm:max-w-5xl"
>
<div className="space-y-4">
<PlatformAssetPickerGrid
items={assets}
isLoading={isLoading}
error={error}
loadingLabel="读取中..."
emptyLabel="暂无历史素材"
surface="editorDark"
selectLabel="使用"
getKey={(asset) => asset.assetObjectId}
getImageSrc={(asset) => asset.imageSrc}
getImageAlt={(asset) => asset.ownerLabel || '素材'}
getTitle={(asset) => asset.ownerLabel || '未记录账号'}
getSubtitle={(asset) => formatHistoryAssetDate(asset.createdAt)}
onSelect={onSelect}
gridClassName={`grid gap-3 ${
tone === 'landscape'
? 'sm:grid-cols-2 xl:grid-cols-3'
: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-4'
}`}
imageShellClassName={
tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'
}
bodyClassName="space-y-2 px-3 py-3"
/>
</div>
</ModalShell>
);
}
const SCENE_ACT_SLOT_LAYOUTS = [
{
left: '77%',
bottom: '16%',
scale: 1,
zIndex: 4,
},
{
left: '91%',
bottom: '36%',
scale: 0.82,
zIndex: 3,
},
{
left: '91%',
bottom: '4%',
scale: 0.82,
zIndex: 2,
},
] as const;
const SCENE_ACT_PLAYER_LAYOUT = {
left: '24%',
bottom: '11%',
scale: 1,
zIndex: 4,
} as const;
function SceneActStageNpcSprite({
npc,
slotIndex,
onClick,
}: {
npc: CustomWorldNpc | null;
slotIndex: number;
onClick: () => void;
}) {
const slotBadgeLabel = slotIndex === 0 ? '主' : String(slotIndex + 1);
const slotName = npc?.name ?? '添加角色';
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 relative flex flex-col items-center text-center transition-transform hover:scale-[1.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80"
style={{ transformOrigin: 'center bottom' }}
>
<div
className={`mb-1 flex max-w-[6.5rem] items-center gap-1 rounded-full border px-2 py-1 shadow-[0_8px_18px_rgba(0,0,0,0.32)] backdrop-blur-sm ${
npc
? 'border-sky-100/70 bg-slate-950/78 text-white'
: 'border-white/38 bg-white/86 text-zinc-800'
}`}
>
<PlatformSlotBadge tone={npc ? 'active' : 'inactive'}>
{slotBadgeLabel}
</PlatformSlotBadge>
<span className="truncate text-[11px] font-bold leading-none">
{slotName}
</span>
</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-95 drop-shadow-[0_8px_16px_rgba(0,0,0,0.24)]'
}`}
>
{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/65 bg-slate-950/42 text-lg font-black text-white shadow-[inset_0_0_18px_rgba(255,255,255,0.08)] 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%)]" />
<PlatformOverlayBadge>{actLabel}</PlatformOverlayBadge>
<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,
onNotice,
onApply,
onClose,
}: {
actLabel: string;
slotIndex: number;
currentNpcId?: string | null;
availableNpcs: SceneActSelectableNpc[];
onNotice: (message: string) => void;
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="flex max-h-[88vh] flex-col sm:max-w-4xl"
bodyClassName="flex flex-col overflow-hidden"
>
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<EditorInfoPanel title="当前角色">
{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={getSceneActSelectableNpcVisual(selectedNpc)}
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>
) : (
<EditorEmptyState></EditorEmptyState>
)}
</EditorInfoPanel>
<EditorInfoPanel
title="可选角色"
bodyClassName="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={getSceneActSelectableNpcVisual(npc)}
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>
<PlatformPillBadge
tone={isSelected ? 'darkSky' : 'darkNeutral'}
size="xxs"
className={`px-2.5 ${isSelected ? 'text-sky-50' : 'text-zinc-400'}`}
>
{isSelected ? '已选中' : '选择'}
</PlatformPillBadge>
</div>
</button>
);
})
) : (
<EditorEmptyState className="sm:col-span-2">
</EditorEmptyState>
)}
</EditorInfoPanel>
</div>
<div className="mt-4 grid shrink-0 grid-cols-4 gap-3 border-t border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] px-4 py-4">
{selectedNpc ? (
<ActionButton
label="移除角色"
onClick={() => {
onApply(null);
onClose();
}}
tone="rose"
className="col-span-1 justify-center px-2"
/>
) : null}
<ActionButton
label="保存角色"
onClick={() => {
if (!draftNpcId) {
onNotice('请先选择角色。');
return;
}
onApply(draftNpcId);
onClose();
}}
tone="sky"
disabled={!draftNpcId}
className={`justify-center ${
selectedNpc ? 'col-span-3' : 'col-span-4'
}`}
/>
</div>
</div>
</ModalShell>
);
}
function buildSceneActPreviewSceneNpc(npc: SceneActSelectableNpc): 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: getSceneActSelectableNpcVisual(npc),
narrativeProfile: npc.narrativeProfile,
attributeProfile: npc.attributeProfile,
};
}
function buildSceneActPreviewScenePreset(params: {
landmark: CustomWorldLandmark;
act: SceneActBlueprint;
encounterNpcs: SceneActSelectableNpc[];
}): 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),
};
}
const SCENE_ACT_PREVIEW_SESSION_ID = 'runtime-scene-act-preview';
function buildSceneActPreviewNpcOption(params: {
functionId: string;
actionText: string;
npcId: string;
action: 'chat' | 'fight' | 'leave';
}): StoryOption {
return {
functionId: params.functionId,
actionText: params.actionText,
text: params.actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: params.npcId,
action: params.action,
},
};
}
function buildSceneActPreviewOpeningStory(params: {
sceneName: string;
encounter: NonNullable<ReturnType<typeof buildEncounterFromSceneNpc>>;
}): StoryMoment {
const npcId = params.encounter.id ?? params.encounter.npcName;
const openingText = `${params.encounter.npcName}已经在${params.sceneName || '当前场景'}等你。`;
return {
text: openingText,
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: params.encounter.npcName,
text: openingText,
},
],
streaming: false,
npcChatState: {
npcId,
npcName: params.encounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: 'npc_initiated',
sceneActId: null,
turnLimit: null,
remainingTurns: null,
limitReason: null,
forceExitAfterTurn: false,
terminationMode: null,
terminationReason: null,
isHostileChat: false,
pendingQuestOffer: null,
combatContext: null,
},
options: [
buildSceneActPreviewNpcOption({
functionId: 'npc_chat',
actionText: '先听他说完',
npcId,
action: 'chat',
}),
buildSceneActPreviewNpcOption({
functionId: 'npc_fight',
actionText: '与他对战',
npcId,
action: 'fight',
}),
buildSceneActPreviewNpcOption({
functionId: 'npc_leave',
actionText: '暂时离开',
npcId,
action: 'leave',
}),
],
};
}
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.playableNpcs.find((entry) => entry.id === npcId) ??
profile.storyNpcs.find((entry) => entry.id === npcId) ??
null,
)
.filter((npc): npc is SceneActSelectableNpc => Boolean(npc)),
[act?.encounterNpcIds, profile.playableNpcs, 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' &&
gameState.runtimeSessionId === SCENE_ACT_PREVIEW_SESSION_ID &&
gameState.playerCharacter?.id === previewCharacter?.id &&
gameState.currentScenePreset?.id === previewScenePreset?.id &&
Boolean(storyFlow.currentStory);
useEffect(() => {
if (
hasBootstrappedRef.current ||
!act ||
!previewCharacter ||
!previewScenePreset ||
!previewEncounter ||
!previewActRuntimeState
) {
return;
}
hasBootstrappedRef.current = true;
storyFlow.resetStoryState();
setBottomTab('adventure');
setIsMapOpen(false);
// 中文注释:幕预览只需要同步静态资料层,不能调用正式选世界入口;
// 后者会排队写入“已选世界但未选角”的中间态,把本地预览 GameState 覆盖回加载中。
setRuntimeCustomWorldProfile(profile);
setRuntimeCharacterOverrides(buildCustomWorldRuntimeCharacters(profile));
const previewCharacterMaxHp = getCharacterMaxHp(
previewCharacter,
WorldType.CUSTOM,
profile,
);
const previewCharacterMaxMana = getCharacterMaxMana(previewCharacter);
const previewEquipment = buildInitialEquipmentLoadout(
previewCharacter,
profile,
);
setGameState((current) => ({
...current,
worldType: WorldType.CUSTOM,
customWorldProfile: profile,
playerCharacter: previewCharacter,
runtimeSessionId: SCENE_ACT_PREVIEW_SESSION_ID,
runtimeActionVersion: 1,
// 中文注释:幕预览也统一复用正式 play 运行链,
// 只通过禁持久化控制“不写正式存档”。
runtimeMode: 'play',
runtimePersistenceDisabled: true,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
currentScenePreset: previewScenePreset,
currentEncounter: previewEncounter,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
playerHp: previewCharacterMaxHp,
playerMaxHp: previewCharacterMaxHp,
playerMana: previewCharacterMaxMana,
playerMaxMana: previewCharacterMaxMana,
playerSkillCooldowns: createCharacterSkillCooldowns(previewCharacter),
playerCurrency: 0,
playerInventory: [],
playerEquipment: previewEquipment,
npcStates: {},
quests: [],
roster: [],
companions: [],
storyHistory: [],
chapterState: null,
campaignState: null,
activeScenarioPackId: profile.scenarioPackId ?? null,
activeCampaignPackId: profile.campaignPackId ?? null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
characterChats: {},
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,
},
}));
storyFlow.hydrateStoryState(
buildSceneActPreviewOpeningStory({
sceneName: previewScenePreset.name,
encounter: previewEncounter,
}),
);
}, [
act,
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,
refreshNpcChatOptions: storyFlow.refreshNpcChatOptions,
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 ?? (() => {}),
}}
chrome={{
hidePlayerLevelBadge: true,
}}
/>
);
}
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="pointer-events-none absolute inset-x-0 top-0 z-[2] bg-[linear-gradient(180deg,rgba(8,10,17,0.84)_0%,rgba(8,10,17,0)_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>
</div>
<SceneActPreviewRuntime
profile={profile}
landmark={landmark}
chapter={chapter}
actIndex={actIndex}
onClose={onClose}
/>
<div
className="fixed inset-x-0 z-[170] flex justify-center px-4"
style={{
top: 'calc(36vh - 3.25rem)',
}}
>
<ActionButton label="结束预览" onClick={onClose} />
</div>
</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 ? (
<PlatformPillBadge
tone="darkSky"
size="xxs"
className="px-2.5 text-sky-50"
>
</PlatformPillBadge>
) : 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;
isCurrent: boolean;
left: number;
top: number;
centerX: number;
centerY: number;
};
type WorldMapEdgeLayout = {
fromId: string;
toId: string;
label: string;
labelX: number;
labelY: number;
};
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[],
currentSceneId?: string | null,
) {
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,
isCurrent: landmark.id === currentSceneId,
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)) {
const fromNode = nodes.find((node) => node.id === sourceId);
const toNode = nodes.find(
(node) => node.id === connection.targetLandmarkId,
);
edgeMap.set(pairKey, {
fromId: sourceId,
toId: connection.targetLandmarkId,
label:
CARDINAL_CONNECTION_LABELS[
connection.relativePosition as CardinalConnectionDirection
],
labelX:
fromNode && toNode ? (fromNode.centerX + toNode.centerX) / 2 : 0,
labelY:
fromNode && toNode ? (fromNode.centerY + toNode.centerY) / 2 : 0,
});
}
});
});
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,
currentSceneId,
onClose,
}: {
landmarks: CustomWorldLandmark[];
currentSceneId?: string | null;
onClose: () => void;
}) {
const { nodes, edges, width, height } = useMemo(
() => buildWorldMapLayout(landmarks, currentSceneId),
[currentSceneId, 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-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-page-fill)] p-3 sm:p-4">
<div
className="relative"
style={{
width: `${width}px`,
height: `${height}px`,
minWidth: '100%',
}}
>
{nodes.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
) : null}
<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="var(--platform-line-soft)"
strokeWidth="2"
strokeLinecap="round"
/>
);
})}
{edges.map((edge) => (
<g key={`${edge.fromId}-${edge.toId}-label`}>
<circle
cx={edge.labelX}
cy={edge.labelY}
r="12"
fill="var(--platform-input-fill)"
stroke="var(--platform-subpanel-border)"
strokeWidth="1"
/>
<text
x={edge.labelX}
y={edge.labelY + 4}
textAnchor="middle"
className="fill-[var(--platform-text-soft)] text-[10px] font-bold"
>
{edge.label}
</text>
</g>
))}
</svg>
{nodes.map((node) => (
<div
key={node.id}
className={`platform-subpanel absolute rounded-[1.25rem] px-4 py-3 shadow-[0_14px_34px_rgba(20,12,24,0.12)] ${
node.isCurrent
? 'border-[var(--platform-button-primary-border)] ring-2 ring-[var(--platform-input-focus-ring)]'
: ''
}`}
style={{
left: `${node.left}px`,
top: `${node.top}px`,
width: `${WORLD_MAP_NODE_WIDTH}px`,
minHeight: `${WORLD_MAP_NODE_HEIGHT}px`,
}}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 text-sm font-semibold text-[var(--platform-text-strong)]">
{node.name}
</div>
{node.isCurrent ? (
<PlatformPillBadge
tone="muted"
size="xxs"
className="shrink-0 px-2 py-0.5 font-semibold"
>
</PlatformPillBadge>
) : null}
</div>
{node.description ? (
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{node.description}
</div>
) : null}
</div>
))}
</div>
</div>
</ModalShell>
);
}
const FIXED_SCENE_IMAGE_SIZE = '1280*720';
function SceneImageGenerationModal({
profile,
landmark,
initialPromptText,
initialPreviewImageSrc,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
initialPromptText?: string;
initialPreviewImageSrc?: string | null;
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 referenceImageInputId = useId();
const originalImageSrc = useMemo(() => {
const initialPreview = initialPreviewImageSrc?.trim() || '';
if (initialPreview) {
return initialPreview;
}
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)),
);
}, [initialPreviewImageSrc, 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">
<PlatformUploadTile
asChild="label"
htmlFor={referenceImageInputId}
label="上传自定义参考图"
hint="png / jpg / webp"
size="compact"
surface="editorDark"
disabled={isGenerating}
/>
<input
id={referenceImageInputId}
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isGenerating}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="sr-only"
/>
{referenceImageSrc ? (
<PlatformUploadPreviewCard
layout="inline"
surface="editorDark"
imageSrc={referenceImageSrc}
imageAlt="自定义参考图"
caption="已载入自定义参考图"
removeLabel="移除自定义参考图"
onRemove={() => setReferenceImageSrc('')}
disabled={isGenerating}
imageShellClassName="!h-16 !w-24 rounded-xl"
/>
) : 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 ? (
<PlatformStatusMessage
tone="success"
surface="tinted"
size="md"
className={RPG_EDITOR_STATUS_SUCCESS_CLASS_NAME}
>
退
</PlatformStatusMessage>
) : null}
{error ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className={RPG_EDITOR_STATUS_ERROR_CLASS_NAME}
>
{error}
</PlatformStatusMessage>
) : 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 ? (
<UnifiedConfirmDialog
open
title="确认退出"
onClose={() => setIsExitConfirmOpen(false)}
confirmLabel="仍然退出"
cancelLabel="继续编辑"
showCancel
onConfirm={() => {
setIsExitConfirmOpen(false);
onClose();
}}
variant="pixel"
overlayClassName="z-[140]"
>
退退
</UnifiedConfirmDialog>
) : 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, assetId?: string | null) => void;
onClose: () => void;
}) {
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const [draftImageSrc, setDraftImageSrc] = useDraft(
currentImageSrc?.trim() || '',
);
const [draftAssetId, setDraftAssetId] = useDraft(
act.backgroundAssetId?.trim() || '',
);
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const previewImageSrc = draftImageSrc || fallbackImageSrc || '';
return (
<>
<ModalShell
title={`配置幕背景:${actLabel}`}
onClose={onClose}
panelClassName="sm:max-w-5xl"
>
<div className="space-y-4">
<PlatformSubpanel surface="dark" radius="md" padding="md">
<ImagePreview
src={previewImageSrc || undefined}
alt={`${actLabel}背景预览`}
fallbackLabel="暂无背景图"
tone="landscape"
/>
<div className="mt-3 flex flex-wrap gap-3">
<ActionButton
label="跟随场景主图"
onClick={() => {
setDraftImageSrc('');
setDraftAssetId('');
}}
tone="sky"
/>
<ActionButton
label="AI生成"
onClick={() => setIsAiGenerateOpen(true)}
/>
<ActionButton
label="使用历史素材"
onClick={() => setIsHistoryPickerOpen(true)}
/>
</div>
</PlatformSubpanel>
<EditorInfoPanel
title="预设背景"
bodyClassName="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'
}`}
>
<PlatformMediaFrame
src={src}
alt={`幕背景预设 ${index + 1}`}
fallbackLabel={`预设 ${index + 1}`}
aspect="landscape"
surface="none"
loading="lazy"
className="rounded-none"
imageClassName="h-full w-full object-cover"
previewOverlay={
<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>
}
/>
</button>
);
})}
</EditorInfoPanel>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label="保存背景"
onClick={() => {
onApply(draftImageSrc || undefined, draftAssetId);
onClose();
}}
tone="sky"
/>
</div>
</div>
</ModalShell>
{isAiGenerateOpen ? (
<SceneImageGenerationModal
profile={profile}
landmark={landmark}
initialPromptText={
act.backgroundPromptText?.trim() ||
landmark.visualDescription?.trim() ||
landmark.description.trim() ||
landmark.name.trim()
}
initialPreviewImageSrc={previewImageSrc}
onApply={(result) => {
setDraftImageSrc(result.imageSrc);
setDraftAssetId(result.assetId);
}}
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
{isHistoryPickerOpen ? (
<HistoryAssetPickerModal
title="使用历史素材"
kind="scene_image"
tone="landscape"
onSelect={(asset) => {
setDraftImageSrc(asset.imageSrc);
setDraftAssetId(asset.assetObjectId);
setIsHistoryPickerOpen(false);
}}
onClose={() => setIsHistoryPickerOpen(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,
isSubmitting,
onCancel,
onConfirm,
}: {
imageDataUrl: string;
imageSize: { width: number; height: number };
isSubmitting: boolean;
onCancel: () => void;
onConfirm: (cropRect: CustomWorldCoverCropRect) => void;
}) {
const previewRef = useRef<HTMLDivElement | null>(null);
const dragSnapshotRef = useRef<CoverCropDragSnapshot | null>(null);
const [activeDragHandle, setActiveDragHandle] =
useState<CoverCropDragHandle | null>(null);
const [cropRect, setCropRect] = useState(() =>
normalizeCoverCropRect(
buildCenteredCoverCropRect(imageSize.width, imageSize.height),
imageSize,
),
);
useEffect(() => {
setActiveDragHandle(null);
dragSnapshotRef.current = null;
setCropRect(
normalizeCoverCropRect(
buildCenteredCoverCropRect(imageSize.width, imageSize.height),
imageSize,
),
);
}, [imageDataUrl, imageSize]);
const previewStyle = useMemo(
() => buildCoverCropPreviewStyle(cropRect, imageSize),
[cropRect, imageSize],
);
const editorPreviewStyle = useMemo(
() =>
({
aspectRatio: `${imageSize.width} / ${imageSize.height}`,
width: `min(100%, calc(min(58vh, 34rem) * ${
imageSize.width / Math.max(1, imageSize.height)
}))`,
}) satisfies CSSProperties,
[imageSize],
);
const outputPreviewStyle = useMemo(
() =>
({
left: `${-(cropRect.x / cropRect.width) * 100}%`,
top: `${-(cropRect.y / cropRect.height) * 100}%`,
width: `${(imageSize.width / cropRect.width) * 100}%`,
height: `${(imageSize.height / cropRect.height) * 100}%`,
}) satisfies CSSProperties,
[cropRect, imageSize],
);
const beginCropDrag = (
handle: CoverCropDragHandle,
event: PointerEvent<HTMLElement>,
) => {
if (isSubmitting) {
return;
}
const preview = previewRef.current;
if (!preview) {
return;
}
const rect = preview.getBoundingClientRect();
dragSnapshotRef.current = {
pointerId: event.pointerId,
handle,
clientX: event.clientX,
clientY: event.clientY,
cropRect,
previewWidth: rect.width,
previewHeight: rect.height,
};
setActiveDragHandle(handle);
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
};
const updateCropDrag = (event: PointerEvent<HTMLElement>) => {
const snapshot = dragSnapshotRef.current;
if (!snapshot || snapshot.pointerId !== event.pointerId) {
return;
}
const deltaX =
((event.clientX - snapshot.clientX) * imageSize.width) /
Math.max(1, snapshot.previewWidth);
const deltaY =
((event.clientY - snapshot.clientY) * imageSize.height) /
Math.max(1, snapshot.previewHeight);
setCropRect(
resizeCoverCropRectFromHandle(snapshot, deltaX, deltaY, imageSize),
);
};
const stopCropDrag = (event: PointerEvent<HTMLElement>) => {
if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
return;
}
dragSnapshotRef.current = null;
setActiveDragHandle(null);
event.currentTarget.releasePointerCapture(event.pointerId);
};
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-3">
<div className="rounded-2xl border border-white/8 bg-black/18 p-2 sm:p-3">
<PlatformMediaFrame
ref={previewRef}
src={imageDataUrl}
alt="上传封面裁剪预览"
fallbackLabel="封面"
aspect="auto"
surface="none"
imageClassName="h-full w-full object-fill"
imageProps={{ draggable: false }}
overlayInteractive
aria-label="封面裁剪操作区"
className="relative mx-auto overflow-hidden rounded-xl border border-white/10 bg-black/40 select-none touch-none"
style={editorPreviewStyle}
>
<div
className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
activeDragHandle === 'move'
? 'cursor-grabbing'
: 'cursor-grab'
}`}
style={previewStyle}
onPointerDown={(event) => beginCropDrag('move', event)}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
/>
<div
className="pointer-events-none absolute border border-white/70"
style={previewStyle}
>
<div className="absolute inset-x-0 top-1/3 border-t border-white/35" />
<div className="absolute inset-x-0 top-2/3 border-t border-white/35" />
<div className="absolute inset-y-0 left-1/3 border-l border-white/35" />
<div className="absolute inset-y-0 left-2/3 border-l border-white/35" />
</div>
<div
className="pointer-events-none absolute"
style={previewStyle}
>
{COVER_CROP_RESIZE_HANDLES.map((handleConfig) => (
<button
key={handleConfig.handle}
type="button"
aria-label={handleConfig.label}
disabled={isSubmitting}
className={`pointer-events-auto absolute z-10 h-10 w-10 rounded-full border border-white/15 bg-black/5 p-0 disabled:cursor-not-allowed disabled:opacity-45 sm:h-9 sm:w-9 ${handleConfig.className}`}
onPointerDown={(event) =>
beginCropDrag(handleConfig.handle, event)
}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
>
<span
className={`absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)] ${handleConfig.dotClassName}`}
/>
</button>
))}
</div>
</PlatformMediaFrame>
</div>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border border-white/8 bg-black/20 p-2">
<PlatformMediaFrame
src={imageDataUrl}
alt="上传封面裁剪结果预览"
fallbackLabel="封面"
aspect="landscape"
surface="none"
className="rounded-xl bg-black/30"
imageClassName="absolute max-w-none object-fill"
imageProps={{
draggable: false,
style: outputPreviewStyle,
}}
/>
</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 referenceImageInputId = useId();
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">
<PlatformUploadTile
asChild="label"
htmlFor={referenceImageInputId}
label="上传封面参考图"
hint="png / jpg / webp"
size="compact"
surface="editorDark"
disabled={isGenerating}
/>
<input
id={referenceImageInputId}
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isGenerating}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="sr-only"
/>
{referenceImageSrc ? (
<PlatformUploadPreviewCard
layout="inline"
surface="editorDark"
imageSrc={referenceImageSrc}
imageAlt="封面参考图"
caption="已载入封面参考图"
removeLabel="移除封面参考图"
onRemove={() => setReferenceImageSrc('')}
disabled={isGenerating}
imageShellClassName="!h-16 !w-24 rounded-xl"
/>
) : 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 ? (
<PlatformStatusMessage
tone="success"
surface="tinted"
size="md"
className={RPG_EDITOR_STATUS_SUCCESS_CLASS_NAME}
>
</PlatformStatusMessage>
) : null}
{error ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className={RPG_EDITOR_STATUS_ERROR_CLASS_NAME}
>
{error}
</PlatformStatusMessage>
) : 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 ? (
<UnifiedConfirmDialog
open
title="确认退出"
onClose={() => setIsExitConfirmOpen(false)}
overlayClassName="z-[140]"
panelClassName="platform-remap-surface rounded-[1.5rem]"
confirmLabel="确认退出"
cancelLabel="继续编辑"
showCancel
onConfirm={() => {
setIsExitConfirmOpen(false);
onClose();
}}
>
退
</UnifiedConfirmDialog>
) : 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 coverUploadInputId = useId();
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">
<PlatformPillBadge tone="darkNeutral" size="xs">
{draftCover.sourceType === 'uploaded'
? '当前为上传封面'
: draftCover.sourceType === 'generated'
? '当前为 AI 封面'
: '当前为默认封面'}
</PlatformPillBadge>
</div>
<PlatformUploadTile
asChild="label"
htmlFor={coverUploadInputId}
label="上传封面"
hint="支持 png、jpg、webp。上传后会先裁剪成 16:9再保存成封面。"
icon={null}
size="panel"
surface="editorDark"
disabled={isUploading}
/>
<input
id={coverUploadInputId}
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isUploading}
onChange={(event) => {
void handleUploadCover(event);
}}
className="sr-only"
/>
<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 ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className={RPG_EDITOR_STATUS_ERROR_CLASS_NAME}
>
{uploadError}
</PlatformStatusMessage>
) : 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}
isSubmitting={isUploading}
onCancel={() => {
if (isUploading) {
return;
}
setPendingUploadImageDataUrl('');
setPendingUploadImageSize(null);
}}
onConfirm={(cropRect) => {
void handleConfirmUploadCrop(cropRect);
}}
/>
) : null}
{isUploading ? (
<PortalCompactDialogShell
title="上传封面中"
onClose={() => {}}
disableClose
>
<PlatformSubpanel
as="div"
surface="darkSky"
radius="sm"
padding="md"
className="text-sm leading-6"
>
</PlatformSubpanel>
</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-[var(--platform-surface-border)] bg-[color:var(--platform-surface)]/95 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.2rem)] pt-2 shadow-[0_-16px_28px_rgba(15,23,42,0.08)] backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:shadow-none 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}
<ActionButton label="保存修改" onClick={onSave} tone="sky" />
</div>
</div>
</div>
);
}
export function SectionPanel({
title,
subtitle,
actions,
children,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<PlatformSubpanel
surface="dark"
radius="md"
padding="md"
title={
<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>
}
titleVariant="strong"
actions={actions}
headerClassName="flex-wrap items-start"
titleClassName="text-[11px] font-bold tracking-[0.16em] text-zinc-300"
bodyClassName="mt-4 space-y-3"
>
{children}
</PlatformSubpanel>
);
}
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,
onNotice,
onChange,
}: {
value: CustomWorldPlayableNpc['backstoryReveal'];
onNotice: (message: string) => void;
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) {
onNotice('至少保留一个背景章节。');
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>
))
) : (
<EditorEmptyState></EditorEmptyState>
)}
</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 ? (
<PlatformMediaFrame
src={role.imageSrc}
alt={role.name}
fallbackLabel="技能动作预览"
aspect="auto"
surface="none"
className="flex h-full w-full items-center justify-center rounded-none"
imageClassName="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 ? (
<PlatformMediaFrame
src={role.imageSrc}
alt={skill.name}
fallbackLabel="技能预览"
aspect="auto"
surface="none"
className="flex h-full w-full items-center justify-center rounded-none"
imageClassName="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">
<PlatformPillBadge
tone={
skill.actionPreviewConfig ? 'darkEmerald' : 'darkNeutral'
}
size="xxs"
className={`px-2.5 ${skill.actionPreviewConfig ? '' : 'text-zinc-400'}`}
>
{skill.actionPreviewConfig ? '动作已生成' : '待生成动作'}
</PlatformPillBadge>
</div>
</div>
</button>
);
})
) : (
<EditorEmptyState></EditorEmptyState>
)}
{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 RoleAssetAppliedBadges({
generatedVisualAssetId,
generatedAnimationSetId,
className,
}: {
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
className?: string;
}) {
if (!generatedVisualAssetId && !generatedAnimationSetId) {
return null;
}
return (
<div
className={['flex flex-wrap gap-2', className].filter(Boolean).join(' ')}
>
{generatedVisualAssetId ? (
<PlatformPillBadge tone="darkEmerald" size="xxs" className="px-2.5">
</PlatformPillBadge>
) : null}
{generatedAnimationSetId ? (
<PlatformPillBadge tone="darkAmber" size="xxs" className="px-2.5">
</PlatformPillBadge>
) : null}
</div>
);
}
function RoleInitialItemTagBadges({
itemId,
tags,
}: {
itemId: string;
tags: string[];
}) {
if (tags.length === 0) {
return null;
}
return (
<div className="mt-2 flex flex-wrap gap-2">
{tags.map((tag) => (
<PlatformPillBadge
key={`${itemId}-${tag}`}
tone="darkNeutral"
size="xxs"
className="px-2 py-1 text-zinc-300"
>
{tag}
</PlatformPillBadge>
))}
</div>
);
}
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>
<RoleInitialItemTagBadges itemId={draft.id} tags={draft.tags} />
</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>
<RoleInitialItemTagBadges itemId={item.id} tags={item.tags} />
</div>
<div className="text-xs text-zinc-200">x{item.quantity}</div>
</div>
</button>
))
) : (
<EditorEmptyState></EditorEmptyState>
)}
{editingItemIndex !== null && value[editingItemIndex] ? (
<RoleInitialItemEditorModal
item={value[editingItemIndex]!}
onSave={(nextItem) =>
onChange(
value.map((item, itemIndex) =>
itemIndex === editingItemIndex ? nextItem : item,
),
)
}
onClose={() => setEditingItemIndex(null)}
/>
) : null}
</SectionPanel>
);
}
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}
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
>
<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>
<SaveBar
onClose={onClose}
onSave={() => {
onSave(draft);
onClose();
}}
/>
</div>
</ModalShell>
);
}
type FoundationDraft = Record<CustomWorldFoundationEntryId, string>;
const FOUNDATION_EDITOR_FIELDS: Array<{
id: CustomWorldFoundationEntryId;
label: string;
rows: number;
}> = [
{ id: 'world-promise', label: '世界承诺', rows: 4 },
{ id: 'player-fantasy', label: '玩家幻想', rows: 4 },
{ id: 'theme-boundary', label: '主题边界', rows: 4 },
{ id: 'player-entry-point', label: '玩家切入口', rows: 4 },
{ id: 'core-conflict', label: '核心冲突', rows: 4 },
{ id: 'key-relationships', label: '关键关系', rows: 4 },
{ id: 'hidden-lines', label: '暗线与揭示', rows: 4 },
{ id: 'iconic-elements', label: '标志元素', rows: 4 },
];
function buildFoundationDraft(profile: CustomWorldProfile): FoundationDraft {
const anchorContent = getCustomWorldFoundationAnchorContent(profile);
return {
'world-promise': anchorContent.worldPromise || '',
'player-fantasy': anchorContent.playerFantasy || '',
'theme-boundary': anchorContent.themeBoundary || '',
'player-entry-point': anchorContent.playerEntryPoint || '',
'core-conflict': anchorContent.coreConflict || '',
'key-relationships': anchorContent.keyRelationships || '',
'hidden-lines': anchorContent.hiddenLines || '',
'iconic-elements': anchorContent.iconicElements || '',
};
}
function splitCommaTags(value: string) {
return value
.split(/[,]/u)
.map((item) => item.trim())
.filter(Boolean);
}
function applyFoundationDraftToProfile(
profile: CustomWorldProfile,
draft: FoundationDraft,
): CustomWorldProfile {
const worldPromiseTags = parseFoundationTagText(draft['world-promise']);
const playerFantasyTags = parseFoundationTagText(draft['player-fantasy']);
const themeBoundaryTags = parseFoundationTagText(draft['theme-boundary']);
const playerEntryTags = parseFoundationTagText(draft['player-entry-point']);
const coreConflictTags = parseFoundationTagText(draft['core-conflict']);
const creatorIntent =
profile.creatorIntent ?? createEmptyCustomWorldCreatorIntent('freeform');
return {
...profile,
summary: worldPromiseTags[0] || profile.summary,
subtitle: worldPromiseTags[1] || profile.subtitle,
tone: themeBoundaryTags[0] || profile.tone,
playerGoal: playerFantasyTags[1] || profile.playerGoal,
worldHook: worldPromiseTags[0] || profile.worldHook || null,
playerPremise: playerFantasyTags[0] || profile.playerPremise || null,
coreConflicts: coreConflictTags[0]
? splitCommaTags(coreConflictTags[0])
: profile.coreConflicts,
creatorIntent: {
...creatorIntent,
worldHook: worldPromiseTags[0] || creatorIntent.worldHook,
playerPremise: playerFantasyTags[0] || creatorIntent.playerPremise,
openingSituation: playerEntryTags[1] || creatorIntent.openingSituation,
themeKeywords: themeBoundaryTags[0]
? splitCommaTags(themeBoundaryTags[0])
: creatorIntent.themeKeywords,
toneDirectives: themeBoundaryTags[1]
? splitCommaTags(themeBoundaryTags[1])
: creatorIntent.toneDirectives,
coreConflicts: coreConflictTags[0]
? splitCommaTags(coreConflictTags[0])
: creatorIntent.coreConflicts,
iconicElements: draft['iconic-elements'].trim()
? splitCommaTags(draft['iconic-elements'])
: creatorIntent.iconicElements,
forbiddenDirectives: themeBoundaryTags[2]
? splitCommaTags(
themeBoundaryTags[2].replace(/^[:]\s*/u, '').trim(),
)
: creatorIntent.forbiddenDirectives,
},
anchorContent: {
worldPromise: draft['world-promise'].trim() || null,
playerFantasy: draft['player-fantasy'].trim() || null,
themeBoundary: draft['theme-boundary'].trim() || null,
playerEntryPoint: draft['player-entry-point'].trim() || null,
coreConflict: draft['core-conflict'].trim() || null,
keyRelationships: draft['key-relationships'].trim() || null,
hiddenLines: draft['hidden-lines'].trim() || null,
iconicElements: draft['iconic-elements'].trim() || null,
},
};
}
function WorldAttributeSchemaEditor({
value,
onChange,
}: {
value: CustomWorldProfile['attributeSchema'];
onChange: (value: CustomWorldProfile['attributeSchema']) => void;
}) {
const updateSlot = (
slotId: string,
patch: Partial<CustomWorldProfile['attributeSchema']['slots'][number]>,
) => {
onChange({
...value,
slots: value.slots.map((slot) =>
slot.slotId === slotId ? { ...slot, ...patch } : slot,
),
});
};
return (
<SectionPanel title="角色维度">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{value.slots.map((slot) => (
<div
key={slot.slotId}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<Field label="维度名称">
<TextInput
value={slot.name}
onChange={(name) => updateSlot(slot.slotId, { name })}
/>
</Field>
</div>
))}
</div>
</SectionPanel>
);
}
export function WorldFoundationEditor({
profile,
onSave,
onClose,
}: {
profile: CustomWorldProfile;
onSave: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const initialDraft = useMemo(() => buildFoundationDraft(profile), [profile]);
const [draft, setDraft] = useDraft(initialDraft);
const [attributeSchemaDraft, setAttributeSchemaDraft] = useDraft(
profile.attributeSchema,
);
return (
<ModalShell
title="编辑基本设定"
onClose={onClose}
panelClassName="sm:max-w-5xl xl:max-w-7xl 2xl:max-w-[92rem]"
>
<div className="space-y-4">
{FOUNDATION_EDITOR_FIELDS.map((field) => (
<Field key={field.id} label={field.label}>
<div className="space-y-3">
<TextArea
value={draft[field.id]}
onChange={(value) =>
setDraft((current) => ({
...current,
[field.id]: value,
}))
}
rows={field.rows}
/>
{draft[field.id].trim() ? (
<div className="flex flex-wrap gap-2">
{parseFoundationTagText(draft[field.id]).map((tag, index) => (
<PlatformPillBadge
key={`${field.id}-${index}-${tag}`}
tone="darkSoft"
size="sm"
className="leading-5"
>
{tag}
</PlatformPillBadge>
))}
</div>
) : null}
</div>
</Field>
))}
<WorldAttributeSchemaEditor
value={attributeSchemaDraft}
onChange={setAttributeSchemaDraft}
/>
<SaveBar
onClose={onClose}
onSave={() => {
const nextProfile = applyFoundationDraftToProfile(profile, draft);
onSave({
...nextProfile,
attributeSchema: attributeSchemaDraft,
ownedSettingLayers: nextProfile.ownedSettingLayers
? {
...nextProfile.ownedSettingLayers,
ruleProfile: {
...nextProfile.ownedSettingLayers.ruleProfile,
attributeSchema: attributeSchemaDraft,
},
}
: nextProfile.ownedSettingLayers,
});
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 [editorNotice, setEditorNotice] = useState<string | null>(null);
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}
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
disableClose={
isAiAssetStudioOpen || isCloseConfirmOpen || editorNotice !== null
}
>
<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)]">
<PlatformMediaFrame
src={previewImageSrc}
alt={draft.name || '角色形象'}
fallbackLabel="角色形象"
aspect="square"
surface="editorDark"
className="w-full rounded-2xl"
imageClassName="h-full w-full object-contain object-top"
/>
<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>
<RoleAssetAppliedBadges
generatedVisualAssetId={draft.generatedVisualAssetId}
generatedAnimationSetId={draft.generatedAnimationSetId}
className="mt-3"
/>
<div className="mt-3">
<ActionButton
label="AI生成"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
</div>
<ActionButton
label="AI生成"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</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={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}
onNotice={setEditorNotice}
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>
{editorNotice ? (
<EditorNoticeDialog
message={editorNotice}
onClose={() => setEditorNotice(null)}
/>
) : null}
{isCloseConfirmOpen ? (
<CloseConfirmDialog
message="当前修改尚未保存,确认关闭吗?"
onCancel={() => setIsCloseConfirmOpen(false)}
onConfirm={() => {
setIsCloseConfirmOpen(false);
onClose();
}}
/>
) : 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 [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const [editorNotice, setEditorNotice] = useState<string | null>(null);
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}
panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
disableClose={
isHistoryPickerOpen ||
isAiAssetStudioOpen ||
isCloseConfirmOpen ||
editorNotice !== null
}
>
<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={() => setIsHistoryPickerOpen(true)}
tone="sky"
/>
<ActionButton
label="AI生成"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
<RoleAssetAppliedBadges
generatedVisualAssetId={draft.generatedVisualAssetId}
generatedAnimationSetId={draft.generatedAnimationSetId}
/>
</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}
onNotice={setEditorNotice}
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}
/>
{isHistoryPickerOpen ? (
<HistoryAssetPickerModal
title="使用历史素材"
kind="character_visual"
tone="square"
onSelect={(asset) => {
setDraft((current) => ({
...current,
imageSrc: asset.imageSrc,
generatedVisualAssetId: asset.assetObjectId,
generatedAnimationSetId: undefined,
animationMap: undefined,
}));
setIsHistoryPickerOpen(false);
}}
onClose={() => setIsHistoryPickerOpen(false)}
/>
) : null}
{isAiAssetStudioOpen ? (
<RpgCreationRoleAssetStudioModal
role={draft}
roleKind="story"
cacheScopeId={profile.id}
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
{editorNotice ? (
<EditorNoticeDialog
message={editorNotice}
onClose={() => setEditorNotice(null)}
/>
) : null}
{isCloseConfirmOpen ? (
<CloseConfirmDialog
message="当前修改尚未保存,确认关闭吗?"
onCancel={() => setIsCloseConfirmOpen(false)}
onConfirm={() => {
setIsCloseConfirmOpen(false);
onClose();
}}
/>
) : 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 [validationNotice, setValidationNotice] = useState<string | null>(null);
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 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(
() =>
buildSceneActSelectableNpcs({
profile,
storyNpcs: draftStoryNpcs,
preferredNpcIds: compatibilitySceneNpcIds,
}),
[compatibilitySceneNpcIds, draftStoryNpcs, profile],
);
const selectableNpcById = useMemo(
() => new Map(sceneNpcOptions.map((npc) => [npc.id, npc])),
[sceneNpcOptions],
);
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 worldMapLandmarks = useMemo(() => {
const campScene = resolveCustomWorldCampScene({
...editableProfile,
camp: isOpeningScene ? draft : editableProfile.camp,
});
const normalizedCampScene: CustomWorldLandmark = {
...campScene,
connections: buildDirectionalConnections(
campScene.connections,
editableProfile.landmarks,
),
};
const normalizedLandmarks = editableProfile.landmarks.map((entry) => ({
...entry,
connections: buildDirectionalConnections(entry.connections, [
normalizedCampScene,
...editableProfile.landmarks.filter(
(candidate) => candidate.id !== entry.id,
),
]),
}));
// 中文注释:地图预览必须使用当前草稿对象,避免未保存的连接关系在弹窗里缺席。
return [normalizedCampScene, ...normalizedLandmarks];
}, [draft, editableProfile, isOpeningScene]);
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 showValidationNotice = (message: string) => {
setValidationNotice(message);
};
const updateSceneChapterDraft = (
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 updateSceneActField = (
index: number,
updater: (act: SceneActBlueprint) => SceneActBlueprint,
) => {
updateSceneChapterDraft((current) => ({
...current,
acts: current.acts.map((act, actIndex) =>
actIndex === index ? updater(act) : act,
),
}));
};
const addSceneAct = () => {
if (renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT) {
showValidationNotice(`每个场景最多只能配置 ${MAX_SCENE_ACT_COUNT} 幕。`);
return;
}
updateSceneChapterDraft((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) {
showValidationNotice(`每个场景至少需要保留 ${MIN_SCENE_ACT_COUNT} 幕。`);
return;
}
updateSceneChapterDraft((current) => ({
...current,
acts: current.acts.filter((_act, actIndex) => actIndex !== index),
}));
};
const moveSceneAct = (index: number, delta: number) => {
updateSceneChapterDraft((current) => ({
...current,
acts: moveArrayItem(current.acts, index, index + delta),
}));
};
const updateSceneActBackground = (
actIndex: number,
imageSrc?: string | null,
assetId?: string | null,
) => {
const resolvedImageSrc = imageSrc?.trim() || '';
const normalizedAssetId = assetId?.trim();
updateSceneChapterDraft((current) => ({
...current,
acts: current.acts.map((act, currentActIndex) =>
currentActIndex === actIndex
? {
...act,
backgroundImageSrc: resolvedImageSrc || undefined,
backgroundAssetId:
normalizedAssetId !== undefined
? normalizedAssetId || undefined
: resolvedImageSrc
? act.backgroundAssetId
: undefined,
}
: 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) {
showValidationNotice('请先保留至少一个其他场景,才能配置连接关系。');
return;
}
setActiveConnectionDirection(direction);
};
const saveLandmarkProfile = () => {
const sanitizedDraft = {
...draft,
imageSrc: compatibilityImageSrc,
sceneNpcIds: derivedSceneNpcIds,
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 (derivedSceneNpcIds.length < 1) {
showValidationNotice('请至少为一幕配置主角色。');
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}
panelClassName="sm:max-w-5xl xl:max-w-7xl 2xl:max-w-[96rem]"
>
<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>
<Field label="场景任务">
<TextArea
value={renderedSceneChapterDraft.sceneTaskDescription}
onChange={(value) =>
updateSceneChapterDraft((current) => ({
...current,
sceneTaskDescription: value,
}))
}
rows={3}
/>
</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 ? (selectableNpcById.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?.trim() ||
compatibilityImageSrc
}
fallbackImageSrc={resolvedDraftImageSrc}
previewCharacter={previewPlayableCharacter}
slotNpcs={encounterSlotNpcs}
onSlotClick={(slotIndex) => {
if (sceneNpcOptions.length === 0) {
showValidationNotice(
'请先在世界档案里创建角色,再配置这一幕的角色槽位。',
);
return;
}
if (
slotIndex > 0 &&
!encounterSlotNpcs[slotIndex - 1]
) {
showValidationNotice('请先配置前一个角色槽位。');
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">
</div>
</div>
<div className="flex flex-wrap gap-2">
<ActionButton
label="配置背景"
onClick={() =>
setActiveSceneActBackgroundIndex(index)
}
tone="sky"
/>
<ActionButton
label="幕预览"
onClick={() => {
if (!encounterSlotNpcs[0]) {
showValidationNotice(
'请先为这一幕配置主角色,再开始预览。',
);
return;
}
setActiveSceneActPreviewIndex(index);
}}
/>
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-white/6 px-3 py-2 text-sm leading-6 text-zinc-100">
{act.eventDescription?.trim() ||
buildDefaultSceneActEventDescription({
sceneSummary: draft.description,
oppositeNpcId:
act.oppositeNpcId || act.primaryNpcId,
index,
})}
</div>
</div>
</div>
</div>
);
})}
</SectionPanel>
<EditorInfoPanel
title="场景连接关系"
actions={
<ActionButton
label="查看世界地图"
onClick={() => setIsWorldMapOpen(true)}
tone="sky"
/>
}
headerClassName="flex-wrap"
bodyClassName="mt-4"
>
<DirectionalSceneConnectionCompass
centerName={draft.name || '当前场景'}
directionTargets={directionTargetLabels}
onDirectionClick={handleOpenDirectionPicker}
/>
</EditorInfoPanel>
<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?.trim() || ''
}
fallbackImageSrc={compatibilityImageSrc || resolvedDraftImageSrc}
onApply={(imageSrc, assetId) =>
updateSceneActBackground(
activeSceneActBackgroundIndex,
imageSrc,
assetId,
)
}
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}
onNotice={showValidationNotice}
onApply={(npcId) =>
updateSceneActField(
activeSceneActSlotPickerState.actIndex,
(current) => {
const encounterNpcIds = assignSceneActEncounterSlotId(
current.encounterNpcIds,
activeSceneActSlotPickerState.slotIndex,
npcId,
);
return {
...current,
encounterNpcIds,
primaryNpcId: encounterNpcIds[0] ?? '',
oppositeNpcId: 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={worldMapLandmarks}
currentSceneId={draft.id}
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 ? (
<CloseConfirmDialog
message="当前场景修改尚未保存,确认关闭吗?"
onCancel={() => setIsCloseConfirmOpen(false)}
onConfirm={() => {
setIsCloseConfirmOpen(false);
onClose();
}}
/>
) : null}
{validationNotice ? (
<EditorNoticeDialog
message={validationNotice}
onClose={() => setValidationNotice(null)}
/>
) : 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 === 'foundation') {
return (
<WorldFoundationEditor
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;