Files
Genarrative/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
2026-04-28 20:25:37 +08:00

6677 lines
206 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { X } from 'lucide-react';
import type { ChangeEvent } from 'react';
import type { CSSProperties } from 'react';
import {
Children,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
ROLE_TEMPLATE_CHARACTERS,
} from '../../data/characterPresets';
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../../data/customWorldVisuals';
import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
import { 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,
WorldType,
} from '../../types';
import {
type CharacterAnimationGenerationPayload,
generateCharacterAnimationDraft,
publishCharacterAnimationAssets,
} from '../asset-studio/characterAssetWorkflowPersistence';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { CustomWorldNpcPortrait } from '../CustomWorldNpcVisualEditor';
import {
RoleCharacterSprite,
SceneEncounterNpcSprite,
} from '../game-canvas/GameCanvasShared';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
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 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;
});
}
function buildCenteredCoverCropRect(
width: number,
height: number,
): CustomWorldCoverCropRect {
const targetRatio = 16 / 9;
if (width <= 0 || height <= 0) {
return { x: 0, y: 0, width: 1, height: 1 };
}
if (width / height >= targetRatio) {
const cropHeight = height;
const cropWidth = cropHeight * targetRatio;
return {
x: (width - cropWidth) / 2,
y: 0,
width: cropWidth,
height: cropHeight,
};
}
const cropWidth = width;
const cropHeight = cropWidth / targetRatio;
return {
x: 0,
y: (height - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
};
}
function clampCoverCropRect(
cropRect: CustomWorldCoverCropRect,
imageSize: { width: number; height: number },
) {
const width = Math.max(1, Math.min(imageSize.width, cropRect.width));
const height = Math.max(1, Math.min(imageSize.height, cropRect.height));
const x = Math.max(0, Math.min(imageSize.width - width, cropRect.x));
const y = Math.max(0, Math.min(imageSize.height - height, cropRect.y));
return { x, y, width, height };
}
function buildCoverCropPreviewStyle(
cropRect: CustomWorldCoverCropRect,
imageSize: { width: number; height: number },
) {
if (imageSize.width <= 0 || imageSize.height <= 0) {
return {};
}
return {
left: `${(cropRect.x / imageSize.width) * 100}%`,
top: `${(cropRect.y / imageSize.height) * 100}%`,
width: `${(cropRect.width / imageSize.width) * 100}%`,
height: `${(cropRect.height / imageSize.height) * 100}%`,
} satisfies CSSProperties;
}
function ModalShell({
title,
subtitle,
onClose,
children,
panelClassName = 'sm:max-w-2xl',
overlayClassName = 'z-[98]',
bodyClassName = '',
disableClose = false,
usePixelFont = false,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
const authUi = useAuthUi();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
return (
<div
className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={
disableClose
? undefined
: (event) => {
if (event.target === event.currentTarget) {
onClose();
}
}
}
>
<div
className={`platform-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] 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>
<button
type="button"
onClick={onClose}
disabled={disableClose}
aria-label="关闭"
className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div
className={`min-h-0 flex-1 overflow-y-auto p-4 sm:p-5 ${bodyClassName}`}
>
{children}
</div>
</div>
</div>
);
}
function _PortalModalShell(props: {
title: string;
subtitle?: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<ModalShell {...props} />, document.body);
}
function CompactDialogShell({
title,
onClose,
children,
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>
<button
type="button"
onClick={onClose}
disabled={disableClose}
aria-label="关闭"
className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
function PortalCompactDialogShell(props: {
title: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<CompactDialogShell {...props} />, document.body);
}
// 中文注释:关闭确认弹窗使用语义类承接亮暗主题,避免在浅色面板中沿用深色 Tailwind 色值。
function CloseConfirmDialog({
message,
onCancel,
onConfirm,
confirmLabel = '确认关闭',
}: {
message: string;
onCancel: () => void;
onConfirm: () => void;
confirmLabel?: string;
}) {
return (
<PortalCompactDialogShell
title="确认关闭"
onClose={onCancel}
overlayClassName="z-[140]"
panelClassName="platform-close-confirm-dialog"
>
<div className="platform-close-confirm-dialog__content">
<div className="platform-close-confirm-dialog__message">{message}</div>
<div className="platform-close-confirm-dialog__actions">
<button
type="button"
className="platform-close-confirm-dialog__button platform-close-confirm-dialog__button--primary"
onClick={onConfirm}
>
{confirmLabel}
</button>
<button
type="button"
className="platform-close-confirm-dialog__button platform-close-confirm-dialog__button--secondary"
onClick={onCancel}
>
</button>
</div>
</div>
</PortalCompactDialogShell>
);
}
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
const hasVisibleChildren = Children.toArray(children).some(
(child) => !(typeof child === 'string' && child.trim().length === 0),
);
if (!hasVisibleChildren) return null;
return (
<label className="block">
<div className="mb-2 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
{label}
</div>
{children}
</label>
);
}
function LabelWithInfo({ label, info }: { label: string; info: string }) {
const [open, setOpen] = useState(false);
return (
<span className="flex flex-wrap items-center gap-2">
<span>{label}</span>
<button
type="button"
onClick={(event) => {
event.preventDefault();
setOpen((current) => !current);
}}
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/16 bg-black/20 text-[10px] text-zinc-200 transition-colors hover:text-white"
aria-label={`${label}说明`}
>
?
</button>
{open ? (
<span className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-[11px] font-normal tracking-normal text-zinc-300">
{info}
</span>
) : null}
</span>
);
}
function TextInput({
value,
onChange,
type = 'text',
placeholder,
}: {
value: string | number;
onChange: (value: string) => void;
type?: 'text' | 'number';
placeholder?: string;
}) {
return (
<input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
);
}
function TextArea({
value,
onChange,
rows = 4,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
<textarea
rows={rows}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
);
}
function SelectField({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors focus:border-sky-300/35"
>
{options.map((option) => (
<option key={`${option.value}-${option.label}`} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
function ImagePreview({
src,
alt,
fallbackLabel,
tone = 'square',
children,
previewOverlay,
overlayInteractive = false,
}: {
src?: string;
alt: string;
fallbackLabel: string;
tone?: 'square' | 'landscape';
children?: ReactNode;
previewOverlay?: ReactNode;
overlayInteractive?: boolean;
}) {
return (
<div
className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
>
{src ? (
<ResolvedAssetImage
src={src}
alt={alt}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
{fallbackLabel}
</div>
)}
{children || previewOverlay ? (
<div
className={`${overlayInteractive ? 'pointer-events-auto' : 'pointer-events-none'} absolute inset-0`}
>
{previewOverlay}
{children}
</div>
) : null}
</div>
);
}
function 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">
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
{isLoading ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-8 text-center text-sm text-zinc-300">
...
</div>
) : assets.length === 0 && !error ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-8 text-center text-sm text-zinc-300">
</div>
) : (
<div
className={`grid gap-3 ${
tone === 'landscape'
? 'sm:grid-cols-2 xl:grid-cols-3'
: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-4'
}`}
>
{assets.map((asset) => (
<div
key={asset.assetObjectId}
className="overflow-hidden rounded-2xl border border-white/10 bg-black/20"
>
<ImagePreview
src={asset.imageSrc}
alt={asset.ownerLabel}
fallbackLabel="素材"
tone={tone}
/>
<div className="space-y-2 px-3 py-3">
<div className="truncate text-xs font-semibold text-zinc-100">
{asset.ownerLabel || '未记录账号'}
</div>
<div className="text-[11px] leading-5 text-zinc-400">
{formatHistoryAssetDate(asset.createdAt)}
</div>
<ActionButton
label="使用"
onClick={() => onSelect(asset)}
tone="sky"
className="w-full"
/>
</div>
</div>
))}
</div>
)}
</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'
}`}
>
<span
className={`flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-black leading-none ${
npc
? 'border-sky-100/70 bg-sky-300 text-slate-950'
: 'border-zinc-400/70 bg-zinc-900 text-white'
}`}
>
{slotBadgeLabel}
</span>
<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%)]" />
<div className="absolute left-3 top-3 rounded-full border border-white/40 bg-white/88 px-3 py-1 text-[10px] font-bold tracking-[0.18em] text-zinc-900 shadow-[0_8px_20px_rgba(0,0,0,0.22)]">
{actLabel}
</div>
<div className="absolute inset-x-0 bottom-0 h-24 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.74)_100%)]" />
<div className="absolute bottom-[12%] left-[50%] h-16 w-16 -translate-x-1/2 rounded-full bg-amber-300/18 blur-2xl sm:h-20 sm:w-20" />
<div
className="absolute"
style={{
left: SCENE_ACT_PLAYER_LAYOUT.left,
bottom: SCENE_ACT_PLAYER_LAYOUT.bottom,
zIndex: SCENE_ACT_PLAYER_LAYOUT.zIndex,
transform: `translateX(-50%) scale(${SCENE_ACT_PLAYER_LAYOUT.scale})`,
transformOrigin: 'center bottom',
}}
>
<SceneActStagePlayerSprite character={previewCharacter} />
</div>
{slotNpcs.map((npc, slotIndex) => {
const layout = SCENE_ACT_SLOT_LAYOUTS[slotIndex];
if (!layout) {
return null;
}
return (
<div
key={`${actLabel}-slot-${slotIndex + 1}`}
className="absolute"
style={{
left: layout.left,
bottom: layout.bottom,
zIndex: layout.zIndex,
transform: `translateX(-50%) scale(${layout.scale})`,
transformOrigin: 'center bottom',
}}
>
<SceneActStageNpcSprite
slotIndex={slotIndex}
npc={npc}
onClick={() => onSlotClick(slotIndex)}
/>
</div>
);
})}
</ImagePreview>
);
}
function SceneActNpcSlotPickerModal({
actLabel,
slotIndex,
currentNpcId,
availableNpcs,
onApply,
onClose,
}: {
actLabel: string;
slotIndex: number;
currentNpcId?: string | null;
availableNpcs: SceneActSelectableNpc[];
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">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<div className="mt-3">
{selectedNpc ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="flex items-center gap-3">
<CustomWorldNpcPortrait
npc={selectedNpc}
visual={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>
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{availableNpcs.length > 0 ? (
availableNpcs.map((npc) => {
const isSelected = npc.id === draftNpcId;
return (
<button
key={`${slotLabel}-${npc.id}`}
type="button"
onClick={() => setDraftNpcId(npc.id)}
className={`rounded-2xl border px-4 py-4 text-left transition-colors ${
isSelected
? 'border-sky-300/35 bg-sky-500/10'
: 'border-white/8 bg-black/20 hover:border-white/20'
}`}
>
<div className="flex items-start gap-3">
<CustomWorldNpcPortrait
npc={npc}
visual={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>
<div
className={`rounded-full border px-2.5 py-1 text-[10px] ${
isSelected
? 'border-sky-300/30 bg-sky-500/12 text-sky-50'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{isSelected ? '已选中' : '选择'}
</div>
</div>
</button>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500 sm:col-span-2">
</div>
)}
</div>
</div>
</div>
<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="platform-button platform-button--danger col-span-1 justify-center px-2"
/>
) : null}
<ActionButton
label="保存角色"
onClick={() => {
if (!draftNpcId) {
window.alert('请先选择角色。');
return;
}
onApply(draftNpcId);
onClose();
}}
tone="sky"
disabled={!draftNpcId}
className={`platform-button platform-button--primary 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),
};
}
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' &&
Boolean(gameState.playerCharacter) &&
gameState.currentScenePreset?.id === landmark.id;
useEffect(() => {
if (
hasBootstrappedRef.current ||
!act ||
!previewCharacter ||
!previewScenePreset ||
!previewEncounter ||
!previewActRuntimeState
) {
return;
}
hasBootstrappedRef.current = true;
storyFlow.resetStoryState();
setBottomTab('adventure');
setIsMapOpen(false);
handleCustomWorldSelect(profile);
handleCharacterSelect(previewCharacter);
setGameState((current) => ({
...current,
worldType: WorldType.CUSTOM,
customWorldProfile: profile,
// 中文注释:幕预览也统一复用正式 play 运行链,
// 只通过禁持久化控制“不写正式存档”。
runtimeMode: 'play',
runtimePersistenceDisabled: true,
currentScene: 'Story',
currentScenePreset: previewScenePreset,
currentEncounter: previewEncounter,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
storyHistory: [],
chapterState: null,
campaignState: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
animationState: AnimationState.IDLE,
activeCombatEffects: [],
activeBuildBuffs: [],
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
storyEngineMemory: {
...(current.storyEngineMemory ?? createEmptyStoryEngineMemoryState()),
currentChapter: null,
currentSceneActState: previewActRuntimeState,
},
}));
}, [
act,
handleCharacterSelect,
handleCustomWorldSelect,
landmark.id,
previewActRuntimeState,
previewCharacter,
previewEncounter,
previewScenePreset,
profile,
setBottomTab,
setGameState,
setIsMapOpen,
storyFlow,
]);
if (!act || !previewCharacter || !previewScenePreset || !previewEncounter) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-300">
</div>
);
}
if (!isPreviewReady) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-300">
...
</div>
);
}
return (
<RpgRuntimeShell
session={{
gameState,
currentStory: storyFlow.currentStory,
isLoading: storyFlow.isLoading,
aiError: storyFlow.aiError,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
}}
story={{
displayedOptions: storyFlow.displayedOptions,
canRefreshOptions: storyFlow.canRefreshOptions,
handleRefreshOptions: storyFlow.handleRefreshOptions,
handleChoice: storyFlow.handleChoice,
handleNpcChatInput: storyFlow.handleNpcChatInput,
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 ? (
<div className="rounded-full border border-sky-300/30 bg-sky-500/12 px-2.5 py-1 text-[10px] text-sky-50">
</div>
) : null}
</div>
</button>
);
})}
<div className="flex flex-col-reverse gap-3 pt-1 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
{currentTargetId ? (
<ActionButton
label="清空连接"
onClick={() => {
onRemove();
onClose();
}}
/>
) : null}
</div>
</div>
</ModalShell>
);
}
type WorldMapNodeLayout = {
id: string;
name: string;
description: string;
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 ? (
<div className="shrink-0 rounded-full border border-[var(--platform-button-primary-border)] bg-[var(--platform-button-ghost-fill)] px-2 py-0.5 text-[10px] font-semibold text-[var(--platform-text-soft)]">
</div>
) : 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 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">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
{referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
<img
src={referenceImageSrc}
alt="自定义参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
</div>
<ActionButton
label="移除"
onClick={() => setReferenceImageSrc('')}
disabled={isGenerating}
/>
</div>
) : null}
</div>
</Field>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={previewImageSrc}
alt={landmark.name || '场景预览'}
fallbackLabel={
landmark.name ? landmark.name.slice(0, 4) : '场景'
}
tone="landscape"
/>
</div>
{latestResult ? (
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
退
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="保存"
onClick={handleSave}
disabled={!latestResult || isGenerating}
/>
<ActionButton
label={
isGenerating
? '正在生成...'
: latestResult
? '重新生成'
: '开始生成'
}
onClick={() => {
void handleGenerate();
}}
tone="sky"
disabled={isGenerating}
/>
</div>
</div>
</div>
</ModalShell>
{isExitConfirmOpen ? (
<PortalCompactDialogShell
title="确认退出"
onClose={() => setIsExitConfirmOpen(false)}
overlayClassName="z-[140]"
usePixelFont
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
退退
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsExitConfirmOpen(false)}
/>
<ActionButton
label="仍然退出"
onClick={() => {
setIsExitConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
function SceneActBackgroundModal({
profile,
landmark,
act,
actLabel,
currentImageSrc,
fallbackImageSrc,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
act: SceneActBlueprint;
actLabel: string;
currentImageSrc?: string | null;
fallbackImageSrc?: string | null;
onApply: (imageSrc?: string | null, 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">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<ImagePreview
src={previewImageSrc || undefined}
alt={`${actLabel}背景预览`}
fallbackLabel="暂无背景图"
tone="landscape"
/>
<div className="mt-3 flex flex-wrap gap-3">
<ActionButton
label="跟随场景主图"
onClick={() => {
setDraftImageSrc('');
setDraftAssetId('');
}}
tone="sky"
/>
<ActionButton
label="AI生成"
onClick={() => setIsAiGenerateOpen(true)}
/>
<ActionButton
label="使用历史素材"
onClick={() => setIsHistoryPickerOpen(true)}
/>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
{presetImages.map((src, index) => {
const isSelected = src === draftImageSrc;
return (
<button
key={`${actLabel}-preset-${index}-${src || 'empty'}`}
type="button"
onClick={() => setDraftImageSrc(src)}
className={`overflow-hidden rounded-2xl border text-left transition-colors ${
isSelected
? 'border-sky-300/55 bg-sky-500/10'
: 'border-white/10 bg-black/20 hover:border-white/25'
}`}
>
<div className="relative aspect-[16/9] overflow-hidden">
<img
src={src}
alt={`幕背景预设 ${index + 1}`}
loading="lazy"
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.82)_100%)] px-3 py-2 text-[11px] text-zinc-100">
#{(index + 1).toString().padStart(3, '0')}
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label="保存背景"
onClick={() => {
onApply(draftImageSrc || 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,
worldName,
isSubmitting,
onCancel,
onConfirm,
}: {
imageDataUrl: string;
imageSize: { width: number; height: number };
worldName: string;
isSubmitting: boolean;
onCancel: () => void;
onConfirm: (cropRect: CustomWorldCoverCropRect) => void;
}) {
const [zoomPercent, setZoomPercent] = useState(100);
const baseCropRect = useMemo(
() => buildCenteredCoverCropRect(imageSize.width, imageSize.height),
[imageSize],
);
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
useEffect(() => {
setZoomPercent(100);
setOffsetX(0);
setOffsetY(0);
}, [imageDataUrl]);
const cropRect = useMemo(() => {
const scale = Math.max(1, zoomPercent / 100);
const nextCropRect = {
width: baseCropRect.width / scale,
height: baseCropRect.height / scale,
x: baseCropRect.x + offsetX,
y: baseCropRect.y + offsetY,
};
return clampCoverCropRect(nextCropRect, imageSize);
}, [baseCropRect, imageSize, offsetX, offsetY, zoomPercent]);
const previewStyle = useMemo(
() => buildCoverCropPreviewStyle(cropRect, imageSize),
[cropRect, imageSize],
);
const maxOffsetX = Math.max(0, imageSize.width - cropRect.width);
const maxOffsetY = Math.max(0, imageSize.height - cropRect.height);
return (
<ModalShell
title="裁剪上传封面"
onClose={onCancel}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[120]"
disableClose={isSubmitting}
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_20rem]">
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={imageDataUrl}
alt="上传封面裁剪预览"
fallbackLabel={worldName.slice(0, 4) || '封面'}
tone="landscape"
overlayInteractive
previewOverlay={
<>
<div className="absolute inset-0 bg-black/45" />
<div
className="absolute border border-sky-300/90 bg-white/8 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
style={previewStyle}
/>
</>
}
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="缩放">
<input
type="range"
min={100}
max={220}
step={1}
value={zoomPercent}
onChange={(event) => setZoomPercent(Number(event.target.value))}
disabled={isSubmitting}
className="w-full accent-sky-400"
/>
</Field>
<Field label="左右位置">
<input
type="range"
min={0}
max={Math.max(0, Math.floor(maxOffsetX))}
step={1}
value={Math.max(0, Math.floor(offsetX + baseCropRect.x))}
onChange={(event) =>
setOffsetX(Number(event.target.value) - baseCropRect.x)
}
disabled={isSubmitting}
className="w-full accent-sky-400"
/>
</Field>
<Field label="上下位置">
<input
type="range"
min={0}
max={Math.max(0, Math.floor(maxOffsetY))}
step={1}
value={Math.max(0, Math.floor(offsetY + baseCropRect.y))}
onChange={(event) =>
setOffsetY(Number(event.target.value) - baseCropRect.y)
}
disabled={isSubmitting}
className="w-full accent-sky-400"
/>
</Field>
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-6 text-zinc-200">
16:9 1600 × 900
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-xs leading-6 text-zinc-400">
<br />
{`x ${Math.round(cropRect.x)} / y ${Math.round(cropRect.y)} / w ${Math.round(cropRect.width)} / h ${Math.round(cropRect.height)}`}
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="取消"
onClick={onCancel}
disabled={isSubmitting}
/>
<ActionButton
label={isSubmitting ? '正在保存...' : '确认裁剪并上传'}
onClick={() => onConfirm(cropRect)}
tone="sky"
disabled={isSubmitting}
/>
</div>
</div>
</div>
</ModalShell>
);
}
function CoverImageGenerationModal({
profile,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
onApply: (result: CustomWorldCoverAssetResult) => void;
onClose: () => void;
}) {
const initialPresentation = useMemo(
() => resolveCustomWorldCoverPresentation(profile),
[profile],
);
const [userPrompt, setUserPrompt] = useDraft(profile.summary || profile.name);
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestResult, setLatestResult] =
useState<CustomWorldCoverAssetResult | null>(null);
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
const previewImageSrc =
latestResult?.imageSrc || initialPresentation.imageSrc;
const openingAct = profile.sceneChapterBlueprints?.[0]?.acts?.[0] ?? null;
const selectedCharacterRoleIds =
profile.cover?.sourceType === 'default'
? profile.cover.characterRoleIds
: buildDefaultCustomWorldCoverProfile(profile).characterRoleIds;
const selectedRoleLabels = profile.playableNpcs
.filter((role) => selectedCharacterRoleIds?.includes(role.id))
.map((role) => role.name)
.filter(Boolean);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageFileAsDataUrl(file);
setReferenceImageSrc(dataUrl);
setError(null);
} catch (uploadError) {
setError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const handleRequestClose = () => {
if (isGenerating) {
return;
}
if (latestResult) {
setIsExitConfirmOpen(true);
return;
}
onClose();
};
const handleGenerate = async () => {
if (!userPrompt.trim()) {
setError('请先补一句你想要的封面氛围。');
return;
}
setIsGenerating(true);
setError(null);
try {
const result = await generateCustomWorldCoverImage({
profile,
userPrompt,
referenceImageSrc,
characterRoleIds: selectedCharacterRoleIds,
size: FIXED_COVER_IMAGE_SIZE,
});
setLatestResult(result);
} catch (generationError) {
setError(
generationError instanceof Error
? generationError.message
: '作品封面生成失败,请稍后重试。',
);
} finally {
setIsGenerating(false);
}
};
const handleSave = () => {
if (!latestResult || isGenerating) {
return;
}
onApply(latestResult);
onClose();
};
return (
<>
<ModalShell
title="AI 生成作品封面"
onClose={handleRequestClose}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
<div className="space-y-4">
<Field label="封面氛围">
<TextArea
value={userPrompt}
onChange={(value) => setUserPrompt(value)}
rows={5}
placeholder="例如:海雾压进旧码头,主角站在残灯与潮水之间,整体像一张正式 RPG 作品封面。"
/>
</Field>
<div className="rounded-2xl border border-white/8 bg-black/18 px-4 py-4 text-xs leading-6 text-zinc-300">
{openingAct?.title
? `系统会自动带入开局第一幕「${openingAct.title}」的场景素材。`
: '系统会自动带入当前世界的开局场景素材。'}
{selectedRoleLabels.length > 0
? ` 当前默认出镜角色:${selectedRoleLabels.join('、')}`
: ''}
</div>
<Field label="参考图(可选)">
<div className="space-y-3">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
{referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
<img
src={referenceImageSrc}
alt="封面参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
</div>
<ActionButton
label="移除"
onClick={() => setReferenceImageSrc('')}
disabled={isGenerating}
/>
</div>
) : null}
</div>
</Field>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
<CustomWorldCoverArtwork
imageSrc={previewImageSrc}
title={profile.name}
fallbackLabel={profile.name.slice(0, 4) || '封面'}
renderMode={
latestResult ? 'image' : initialPresentation.renderMode
}
characterImageSrcs={
latestResult ? [] : initialPresentation.characterImageSrcs
}
className="aspect-[16/9] max-h-[14rem] rounded-2xl"
/>
</div>
{latestResult ? (
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="保存"
onClick={handleSave}
disabled={!latestResult || isGenerating}
/>
<ActionButton
label={
isGenerating
? '正在生成...'
: latestResult
? '重新生成'
: '开始生成'
}
onClick={() => {
void handleGenerate();
}}
tone="sky"
disabled={isGenerating}
/>
</div>
</div>
</div>
</ModalShell>
{isExitConfirmOpen ? (
<PortalCompactDialogShell
title="确认退出"
onClose={() => setIsExitConfirmOpen(false)}
overlayClassName="z-[140]"
>
<div className="space-y-4">
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
退
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="继续编辑"
onClick={() => setIsExitConfirmOpen(false)}
/>
<ActionButton
label="确认退出"
onClick={() => {
setIsExitConfirmOpen(false);
onClose();
}}
tone="sky"
/>
</div>
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
export function WorldCoverEditor({
profile,
onSaveProfile,
onClose,
}: {
profile: CustomWorldProfile;
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draftCover, setDraftCover] = useDraft(
profile.cover ?? buildDefaultCustomWorldCoverProfile(profile),
);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [pendingUploadImageDataUrl, setPendingUploadImageDataUrl] =
useState('');
const [pendingUploadImageSize, setPendingUploadImageSize] = useState<{
width: number;
height: number;
} | null>(null);
const previewProfile = useMemo(
() => ({
...profile,
cover: draftCover,
}),
[draftCover, profile],
);
const previewPresentation = useMemo(
() => resolveCustomWorldCoverPresentation(previewProfile),
[previewProfile],
);
const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
if (file.size > COVER_IMAGE_MAX_UPLOAD_BYTES) {
setUploadError('上传封面原图不能超过 10 MB。');
return;
}
const imageDataUrl = await readImageFileAsDataUrl(file);
const imageSize = await loadImageDimensionsFromDataUrl(imageDataUrl);
setPendingUploadImageDataUrl(imageDataUrl);
setPendingUploadImageSize(imageSize);
setUploadError(null);
} catch (uploadErrorValue) {
setUploadError(
uploadErrorValue instanceof Error
? uploadErrorValue.message
: '上传作品封面失败,请稍后重试。',
);
}
};
const handleConfirmUploadCrop = async (
cropRect: CustomWorldCoverCropRect,
) => {
if (!pendingUploadImageDataUrl) {
return;
}
setIsUploading(true);
setUploadError(null);
try {
const result = await uploadCustomWorldCoverImage({
profileId: profile.id,
worldName: profile.name,
imageDataUrl: pendingUploadImageDataUrl,
cropRect,
});
setDraftCover(buildGeneratedCoverProfile(result));
setPendingUploadImageDataUrl('');
setPendingUploadImageSize(null);
} catch (uploadErrorValue) {
setUploadError(
uploadErrorValue instanceof Error
? uploadErrorValue.message
: '上传作品封面失败,请稍后重试。',
);
} finally {
setIsUploading(false);
}
};
return (
<>
<ModalShell
title="编辑作品封面"
onClose={onClose}
panelClassName="sm:max-w-3xl"
>
<div className="grid gap-4 md:grid-cols-[minmax(0,0.95fr)_minmax(17rem,1.05fr)]">
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
<CustomWorldCoverArtwork
imageSrc={previewPresentation.imageSrc}
title={profile.name}
fallbackLabel={profile.name.slice(0, 4) || '封面'}
renderMode={previewPresentation.renderMode}
characterImageSrcs={previewPresentation.characterImageSrcs}
className="aspect-[16/9] max-h-[13rem] rounded-2xl"
/>
</div>
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
{draftCover.sourceType === 'uploaded'
? '当前为上传封面'
: draftCover.sourceType === 'generated'
? '当前为 AI 封面'
: '当前为默认封面'}
</span>
</div>
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
</div>
<div className="mb-3 text-xs leading-5 text-zinc-400">
pngjpgwebp 16:9
</div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleUploadCover(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
<div className="flex flex-wrap gap-3">
<ActionButton
label="AI 生成"
onClick={() => setIsGenerating(true)}
tone="sky"
/>
<ActionButton
label="重置为默认"
onClick={() =>
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
}
disabled={draftCover.sourceType === 'default'}
/>
</div>
{uploadError ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{uploadError}
</div>
) : null}
<SaveBar
onClose={onClose}
onSave={() => {
onSaveProfile({
...profile,
cover: draftCover,
});
onClose();
}}
/>
</div>
</div>
</ModalShell>
{isGenerating ? (
<CoverImageGenerationModal
profile={previewProfile}
onApply={(result) => {
setDraftCover(buildGeneratedCoverProfile(result));
setUploadError(null);
setIsGenerating(false);
}}
onClose={() => setIsGenerating(false)}
/>
) : null}
{pendingUploadImageDataUrl && pendingUploadImageSize ? (
<CoverUploadCropModal
imageDataUrl={pendingUploadImageDataUrl}
imageSize={pendingUploadImageSize}
worldName={profile.name}
isSubmitting={isUploading}
onCancel={() => {
if (isUploading) {
return;
}
setPendingUploadImageDataUrl('');
setPendingUploadImageSize(null);
}}
onConfirm={(cropRect) => {
void handleConfirmUploadCrop(cropRect);
}}
/>
) : null}
{isUploading ? (
<PortalCompactDialogShell
title="上传封面中"
onClose={() => {}}
disableClose
>
<div className="rounded-2xl border border-sky-300/18 bg-sky-500/10 px-4 py-4 text-sm leading-6 text-sky-50">
</div>
</PortalCompactDialogShell>
) : null}
</>
);
}
export function SaveBar({
onClose,
onSave,
extraAction,
showClose = true,
}: {
onClose: () => void;
onSave: () => void;
extraAction?: ReactNode;
showClose?: boolean;
}) {
return (
<div className="sticky bottom-0 z-10 -mx-4 border-t border-[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}
<button
type="button"
onClick={onSave}
className="platform-button platform-button--primary text-left"
>
</button>
</div>
</div>
</div>
);
}
export function SectionPanel({
title,
subtitle,
actions,
children,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
{title}
</div>
{subtitle ? (
<div className="mt-2 text-sm leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
{actions}
</div>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function buildRolePreviewCharacter(
role: CustomWorldPlayableNpc | CustomWorldNpc,
): Character | null {
const portrait = role.imageSrc;
if (!portrait) {
return null;
}
return {
id: role.id,
name: role.name,
title: role.title,
description: role.description,
backstory: role.backstory,
avatar: portrait,
portrait,
assetFolder: 'custom-world',
assetVariant: 'generated',
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap,
attributes: { strength: 0, agility: 0, intelligence: 0, spirit: 0 },
personality: role.personality,
skills: [],
adventureOpenings: {},
} as Character;
}
function BackstoryRevealEditor({
value,
onChange,
}: {
value: CustomWorldPlayableNpc['backstoryReveal'];
onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void;
}) {
const updateChapter = (
index: number,
updater: (
chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => {
onChange({
...value,
chapters: value.chapters.map((chapter, chapterIndex) =>
chapterIndex === index ? updater(chapter) : chapter,
),
});
};
const addChapter = () => {
onChange({
...value,
chapters: [
...value.chapters,
createBackstoryChapterDraft('custom-role', value.chapters.length),
],
});
};
const removeChapter = (index: number) => {
if (value.chapters.length <= 1) {
window.alert('至少保留一个背景章节。');
return;
}
onChange({
...value,
chapters: value.chapters.filter(
(_chapter, chapterIndex) => chapterIndex !== index,
),
});
};
return (
<SectionPanel
title="背景故事"
actions={
<ActionButton label="新增章节" onClick={addChapter} tone="sky" />
}
>
<Field label="对外摘要">
<TextArea
value={value.publicSummary}
onChange={(nextValue) =>
onChange({
...value,
publicSummary: nextValue,
})
}
rows={3}
/>
</Field>
{value.chapters.map((chapter, index) => (
<div
key={`${chapter.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除章节"
onClick={() => removeChapter(index)}
/>
</div>
<Field label="章节标题">
<TextInput
value={chapter.title}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
title: nextValue,
}))
}
/>
</Field>
<Field label="解锁好感">
<TextInput
type="number"
value={chapter.affinityRequired}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
affinityRequired: clampInitialAffinity(
nextValue,
current.affinityRequired,
),
}))
}
/>
</Field>
<Field
label={
<LabelWithInfo
label="章节提示"
info="作用:作为该段背景尚未完全公开时的提示线索,帮助系统在关系推进或试探阶段提前埋钩子。是否展示给用户:会。展示位置:角色相关结果页、关系推进展示,以及后续部分剧情中的悬念化提示。"
/>
}
>
<TextArea
value={chapter.teaser}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
teaser: nextValue,
}))
}
rows={2}
/>
</Field>
<Field
label={
<LabelWithInfo
label="章节内容"
info="作用:作为该段背景真正解锁后的完整内容,提供系统后续剧情、关系推进和角色理解所需的核心信息。是否展示给用户:会。展示位置:对应背景片段被解锁后的角色内容面板,以及相关剧情正式揭露时。"
/>
}
>
<TextArea
value={chapter.content}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
content: nextValue,
}))
}
rows={3}
/>
</Field>
<Field
label={
<LabelWithInfo
label="剧情引用摘要"
info="作用:给叙事系统提供一段可被剧情直接抽取引用的压缩摘要,用来在剧情里自然提到这段背景,而不是整段照搬。是否展示给用户:通常不直接完整展示。展示位置:主要用于后续剧情文案、角色对话、事件摘要中的引用表达。"
/>
}
>
<TextArea
value={chapter.contextSnippet}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
contextSnippet: nextValue,
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function RoleRelationsEditor({
value,
onChange,
roleOptions,
labelSeed,
}: {
value: CustomWorldRoleRelation[];
onChange: (value: CustomWorldRoleRelation[]) => void;
roleOptions: Array<{ value: string; label: string }>;
labelSeed: string;
}) {
const updateRelation = (
index: number,
updater: (relation: CustomWorldRoleRelation) => CustomWorldRoleRelation,
) => {
const nextRelations = value.map((relation, relationIndex) =>
relationIndex === index ? updater(relation) : relation,
);
onChange(nextRelations);
};
return (
<SectionPanel
title="与其他角色的关系"
actions={
<ActionButton
label="新增关系"
onClick={() =>
onChange([
...value,
createRoleRelationDraft(labelSeed, value.length),
])
}
tone="sky"
/>
}
>
{value.length > 0 ? (
value.map((relation, index) => (
<div
key={`${relation.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除关系"
onClick={() =>
onChange(
value.filter(
(_relation, relationIndex) => relationIndex !== index,
),
)
}
/>
</div>
<Field label="关联角色">
<SelectField
value={relation.targetRoleId}
onChange={(nextValue) =>
updateRelation(index, (current) => ({
...current,
targetRoleId: nextValue,
}))
}
options={[{ value: '', label: '未指定' }, ...roleOptions]}
/>
</Field>
<Field label="关系文本">
<TextArea
value={relation.summary}
onChange={(nextValue) =>
updateRelation(index, (current) => ({
...current,
summary: nextValue,
}))
}
rows={2}
placeholder="例如:她与沈砺曾共同守过旧灯塔,但在沉船事件后分道扬镳。"
/>
</Field>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
</SectionPanel>
);
}
function RoleSkillEditorModal({
role,
skill,
onSave,
onClose,
}: {
role: CustomWorldPlayableNpc | CustomWorldNpc;
skill: CustomWorldRoleSkill;
onSave: (skill: CustomWorldRoleSkill) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(skill);
const [status, setStatus] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const previewCharacter = useMemo(() => {
const base = buildRolePreviewCharacter(role);
if (!base || !draft.actionPreviewConfig) {
return base;
}
return {
...base,
animationMap: {
...(base.animationMap ?? {}),
[AnimationState.ATTACK]: draft.actionPreviewConfig,
},
} satisfies Character;
}, [draft.actionPreviewConfig, role]);
const actionPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(draft.actionPreviewConfig, 320),
[draft.actionPreviewConfig],
);
const handleGenerateAction = async () => {
if (!role.imageSrc || !role.generatedVisualAssetId) {
setStatus('请先为角色生成并保存主图后,再生成技能动作。');
return;
}
setIsGenerating(true);
setStatus(null);
try {
const promptText =
draft.actionPromptText?.trim() ||
buildSkillActionPrompt({
role,
skill: draft,
});
const actionKey = `skill-${draft.id}`;
const templateId = inferSkillActionTemplateId(draft);
const generationResult = await generateCharacterAnimationDraft({
characterId: role.id,
strategy: 'image-to-video',
animation: actionKey,
promptText,
characterBriefText: [
role.name,
role.title,
role.role,
role.description,
role.backstory,
role.personality,
role.motivation,
]
.filter(Boolean)
.join(' / '),
actionTemplateId: templateId,
visualSource: role.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: role.imageSrc,
frameCount: 8,
fps: 10,
durationSeconds: 4,
loop: false,
useChromaKey: true,
resolution: '480p',
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'doubao-seedance-2-0-fast-260128',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
if (generationResult.strategy !== 'image-to-video') {
throw new Error('当前技能动作预览仅支持图生视频生成。');
}
const publishResult = await publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
animations: {
[actionKey]: {
framesDataUrls: [],
fps: 10,
loop: false,
frameWidth: 192,
frameHeight: 256,
frameCount: 8,
applyChromaKey: true,
previewVideoPath: generationResult.previewVideoPath,
},
},
updateCharacterOverride: false,
});
setDraft((current) => ({
...current,
actionPromptText: promptText,
actionPreviewConfig: publishResult.animationMap[
actionKey
] as CharacterAnimationConfig,
}));
setStatus('技能动作预览已更新。');
} catch (error) {
setStatus(error instanceof Error ? error.message : '技能动作生成失败。');
} finally {
setIsGenerating(false);
}
};
return (
<ModalShell
title={`编辑技能:${skill.name || '未命名技能'}`}
onClose={onClose}
panelClassName="sm:max-w-3xl"
overlayClassName="z-[130]"
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="flex min-h-[20rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
{previewCharacter && draft.actionPreviewConfig ? (
<div style={actionPreviewFrameStyle}>
<CharacterAnimator
state={AnimationState.ATTACK}
character={previewCharacter}
className="h-full w-full"
/>
</div>
) : role.imageSrc ? (
<img
src={role.imageSrc}
alt={role.name}
className="max-h-40 w-full object-contain"
/>
) : (
<div className="text-sm text-zinc-500"></div>
)}
</div>
</div>
<Field label="技能名称">
<TextInput
value={draft.name}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, name: nextValue }))
}
/>
</Field>
<Field label="技能摘要">
<TextArea
value={draft.summary}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, summary: nextValue }))
}
rows={3}
/>
</Field>
<Field label="技能动作提示词">
<TextArea
value={
draft.actionPromptText ||
buildSkillActionPrompt({
role,
skill: draft,
})
}
onChange={(nextValue) =>
setDraft((current) => ({
...current,
actionPromptText: nextValue,
}))
}
rows={4}
/>
</Field>
{status ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{status}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label={isGenerating ? '生成中...' : '重新生成技能动作'}
onClick={() => {
void handleGenerateAction();
}}
disabled={isGenerating}
tone="sky"
/>
<ActionButton
label="保存"
onClick={() => {
onSave(draft);
onClose();
}}
tone="sky"
/>
</div>
</div>
</ModalShell>
);
}
function SkillListEditor({
role,
value,
onChange,
labelSeed,
}: {
role: CustomWorldPlayableNpc | CustomWorldNpc;
value: CustomWorldPlayableNpc['skills'];
onChange: (value: CustomWorldPlayableNpc['skills']) => void;
labelSeed: string;
}) {
const [editingSkillIndex, setEditingSkillIndex] = useState<number | null>(
null,
);
const rolePreviewCharacter = useMemo(
() => buildRolePreviewCharacter(role),
[role],
);
return (
<SectionPanel
title="技能"
actions={
<ActionButton
label="新增技能"
onClick={() =>
onChange([...value, createRoleSkillDraft(labelSeed, value.length)])
}
tone="sky"
/>
}
>
{value.length > 0 ? (
value.map((skill, index) => {
const previewCharacter =
rolePreviewCharacter && skill.actionPreviewConfig
? ({
...rolePreviewCharacter,
animationMap: {
...(rolePreviewCharacter.animationMap ?? {}),
[AnimationState.ATTACK]: skill.actionPreviewConfig,
},
} satisfies Character)
: rolePreviewCharacter;
return (
<button
key={`${skill.id}-${index}`}
type="button"
onClick={() => setEditingSkillIndex(index)}
className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition-colors hover:border-white/18"
>
<div className="grid gap-3 sm:grid-cols-[6.5rem_minmax(0,1fr)_auto] sm:items-center">
<div className="flex h-24 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/25 p-2">
{previewCharacter && skill.actionPreviewConfig ? (
<div className="h-20 w-20">
<CharacterAnimator
state={AnimationState.ATTACK}
character={previewCharacter}
className="h-full w-full"
/>
</div>
) : role.imageSrc ? (
<img
src={role.imageSrc}
alt={skill.name}
className="max-h-20 w-full object-contain"
/>
) : (
<div className="text-xs text-zinc-500"></div>
)}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{skill.name}
</div>
<div className="mt-2 line-clamp-2 text-sm leading-6 text-zinc-400">
{skill.summary || '点击补充技能摘要与技能动作。'}
</div>
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
<StatusBadge
label={
skill.actionPreviewConfig ? '动作已生成' : '待生成动作'
}
tone={skill.actionPreviewConfig ? 'ready' : 'idle'}
/>
</div>
</div>
</button>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
{editingSkillIndex !== null && value[editingSkillIndex] ? (
<RoleSkillEditorModal
role={role}
skill={value[editingSkillIndex]!}
onSave={(nextSkill) =>
onChange(
value.map((skill, skillIndex) =>
skillIndex === editingSkillIndex ? nextSkill : skill,
),
)
}
onClose={() => setEditingSkillIndex(null)}
/>
) : null}
</SectionPanel>
);
}
function StatusBadge({
label,
tone,
}: {
label: string;
tone: 'ready' | 'idle';
}) {
return (
<span
className={`rounded-full border px-2.5 py-1 text-[10px] ${
tone === 'ready'
? 'border-emerald-400/24 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{label}
</span>
);
}
function RoleInitialItemEditorModal({
item,
onSave,
onClose,
}: {
item: CustomWorldRoleInitialItem;
onSave: (item: CustomWorldRoleInitialItem) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(item);
const [assetPaths, setAssetPaths] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void fetchJson<{ assetPaths: string[] }>(
EDITOR_ITEM_CATALOG_API_PATH,
'读取物品图标目录失败',
)
.then((result) => {
if (!cancelled) {
setAssetPaths(result.assetPaths ?? []);
}
})
.catch((error) => {
if (!cancelled) {
setStatus(
error instanceof Error ? error.message : '读取物品图标目录失败。',
);
}
});
return () => {
cancelled = true;
};
}, []);
const handleRegenerateIcon = () => {
if (assetPaths.length === 0) {
setStatus('当前没有可用的物品图标素材。');
return;
}
const normalizedCategory = draft.category.trim();
const categoryKeywords: Record<string, string[]> = {
: ['weapon', 'sword', 'axe', 'bow', 'wand', 'staff', 'dagger'],
: ['armor', 'helmet', 'shield', 'robe', 'boots', 'cloak'],
: ['ring', 'amulet', 'gem', 'relic', 'necklace'],
: ['potion', 'bottle', 'food', 'mushroom', 'apple', 'bandage'],
: ['ore', 'stone', 'wood', 'leaf', 'flower', 'material'],
: ['scroll', 'book', 'crystal', 'magic', 'bag'],
: ['artifact', 'legend', 'treasure', 'relic'],
};
const pool = assetPaths.filter((assetPath) => {
const lower = assetPath.toLowerCase();
const keywords = categoryKeywords[normalizedCategory] ?? [];
return (
keywords.length === 0 ||
keywords.some((keyword) => lower.includes(keyword))
);
});
const candidates = pool.length > 0 ? pool : assetPaths;
const currentIndex = candidates.findIndex(
(entry) => `/${entry}` === (draft.iconSrc ?? ''),
);
const seed = hashText(
[
draft.name,
draft.category,
draft.description,
draft.tags.join('|'),
].join('::'),
);
const nextIndex =
currentIndex >= 0
? (currentIndex + 1) % candidates.length
: seed % candidates.length;
setDraft((current) => ({
...current,
iconSrc: `/${candidates[nextIndex]!}`,
}));
setStatus('物品图标已更新。');
};
return (
<ModalShell
title={`编辑物品:${item.name || '未命名物品'}`}
onClose={onClose}
panelClassName="sm:max-w-3xl"
overlayClassName="z-[130]"
>
<div className="space-y-4">
<div
className={`rounded-2xl border p-4 ${getItemRarityCardClass(draft.rarity)}`}
>
<div className="flex items-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
{draft.iconSrc ? (
<PixelIcon src={draft.iconSrc} className="h-12 w-12" />
) : (
<div className="text-xs text-zinc-500"></div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-white">
{draft.name}
</div>
<div className="mt-1 text-xs text-zinc-300">
{getItemRarityLabel(draft.rarity)} / {draft.quantity}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{draft.tags.map((tag) => (
<span
key={`${draft.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<ActionButton
label="重新生成图标"
onClick={handleRegenerateIcon}
tone="sky"
/>
</div>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, name: nextValue }))
}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="分类">
<TextInput
value={draft.category}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, category: nextValue }))
}
/>
</Field>
<Field label="稀有度">
<SelectField
value={draft.rarity}
onChange={(nextValue) =>
setDraft((current) => ({
...current,
rarity: nextValue as ItemRarity,
}))
}
options={ITEM_RARITY_OPTIONS}
/>
</Field>
</div>
<Field label="数量">
<TextInput
type="number"
value={draft.quantity}
onChange={(nextValue) =>
setDraft((current) => ({
...current,
quantity: Math.max(
1,
parseOptionalNumber(nextValue) ?? current.quantity,
),
}))
}
/>
</Field>
<Field label="描述">
<TextArea
value={draft.description}
onChange={(nextValue) =>
setDraft((current) => ({ ...current, description: nextValue }))
}
rows={3}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
onChange={(nextValue) =>
setDraft((current) => ({
...current,
tags: parseCommaText(nextValue),
}))
}
rows={2}
/>
</Field>
{status ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{status}
</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton label="取消" onClick={onClose} />
<ActionButton
label="保存"
onClick={() => {
onSave(draft);
onClose();
}}
tone="sky"
/>
</div>
</div>
</ModalShell>
);
}
function InitialItemsEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['initialItems'];
onChange: (value: CustomWorldPlayableNpc['initialItems']) => void;
labelSeed: string;
}) {
const [editingItemIndex, setEditingItemIndex] = useState<number | null>(null);
return (
<SectionPanel
title="物品"
actions={
<ActionButton
label="新增物品"
onClick={() =>
onChange([
...value,
createRoleInitialItemDraft(labelSeed, value.length),
])
}
tone="sky"
/>
}
>
{value.length > 0 ? (
value.map((item, index) => (
<button
key={`${item.id}-${index}`}
type="button"
onClick={() => setEditingItemIndex(index)}
className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors hover:border-white/18 ${getItemRarityCardClass(item.rarity)}`}
>
<div className="grid gap-3 sm:grid-cols-[4.5rem_minmax(0,1fr)_auto] sm:items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-2">
{item.iconSrc ? (
<PixelIcon src={item.iconSrc} className="h-10 w-10" />
) : (
<div className="text-[10px] text-zinc-500"></div>
)}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{item.name}
</div>
<div className="mt-1 text-xs text-zinc-300">
{getItemRarityLabel(item.rarity)}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{item.tags.map((tag) => (
<span
key={`${item.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
</div>
</div>
<div className="text-xs text-zinc-200">x{item.quantity}</div>
</div>
</button>
))
) : (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
</div>
)}
{editingItemIndex !== null && value[editingItemIndex] ? (
<RoleInitialItemEditorModal
item={value[editingItemIndex]!}
onSave={(nextItem) =>
onChange(
value.map((item, itemIndex) =>
itemIndex === editingItemIndex ? nextItem : item,
),
)
}
onClose={() => setEditingItemIndex(null)}
/>
) : null}
</SectionPanel>
);
}
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) => (
<span
key={`${field.id}-${index}-${tag}`}
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
>
{tag}
</span>
))}
</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 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}
>
<div className="space-y-4">
{previewImageSrc ? (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
</div>
<div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<div className="aspect-square w-full overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img
src={previewImageSrc}
alt={draft.name || '角色形象'}
className="h-full w-full object-contain object-top"
/>
</div>
<div className="min-w-0">
<div className="text-base font-semibold text-white">
{draft.name || '未命名角色'}
</div>
<div className="mt-1 text-sm text-zinc-400">
{draft.title || draft.role}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-3">
<ActionButton
label="AI生成"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
</div>
</div>
</div>
) : (
<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}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<RoleRelationsEditor
value={roleRelations}
onChange={(relations) =>
setDraft((current) => ({
...current,
relations,
relationshipHooks:
deriveRelationshipHooksFromRelations(relations),
}))
}
roleOptions={roleOptions}
labelSeed={draft.name || draft.id}
/>
<SkillListEditor
role={draft}
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={handleRequestClose}
onSave={() => {
onSave(draft);
onClose();
}}
showClose={false}
/>
{isAiAssetStudioOpen ? (
<RpgCreationRoleAssetStudioModal
role={draft}
roleKind="playable"
cacheScopeId={profile.id}
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
{isCloseConfirmOpen ? (
<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 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
}
>
<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>
<div className="flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
</div>
</div>
</div>
<Field label="名称">
<TextInput
value={draft.name}
onChange={(value) =>
setDraft((current) => ({ ...current, name: value }))
}
/>
</Field>
<Field label="头衔 / 世界身份">
<TextInput
value={draft.title || draft.role}
onChange={(value) =>
setDraft((current) => ({
...current,
title: value,
role: value,
}))
}
/>
</Field>
<Field label="描述">
<TextArea
value={draft.description}
onChange={(value) =>
setDraft((current) => ({ ...current, description: value }))
}
rows={4}
/>
</Field>
<Field label="背景">
<TextArea
value={draft.backstory}
onChange={(value) =>
setDraft((current) => ({ ...current, backstory: value }))
}
rows={4}
/>
</Field>
<Field label="性格">
<TextArea
value={draft.personality}
onChange={(value) =>
setDraft((current) => ({ ...current, personality: value }))
}
rows={3}
/>
</Field>
<Field label="动机">
<TextArea
value={draft.motivation}
onChange={(value) =>
setDraft((current) => ({ ...current, motivation: value }))
}
rows={4}
/>
</Field>
<Field label="初始好感">
<TextInput
type="number"
value={draft.initialAffinity}
onChange={(value) =>
setDraft((current) => ({
...current,
initialAffinity: clampInitialAffinity(
value,
current.initialAffinity,
),
}))
}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(draft.tags)}
onChange={(value) =>
setDraft((current) => ({
...current,
tags: parseCommaText(value),
}))
}
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<RoleRelationsEditor
value={roleRelations}
onChange={(relations) =>
setDraft((current) => ({
...current,
relations,
relationshipHooks:
deriveRelationshipHooksFromRelations(relations),
}))
}
roleOptions={roleOptions}
labelSeed={draft.name || draft.id}
/>
<SkillListEditor
role={draft}
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={handleRequestClose}
onSave={() => {
onSave(draft);
onClose();
}}
showClose={false}
/>
{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>
{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 [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 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) {
window.alert(`每个场景最多只能配置 ${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) {
window.alert(`每个场景至少需要保留 ${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) {
window.alert('请先保留至少一个其他场景,才能配置连接关系。');
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) {
window.alert('请至少为一幕配置主角色。');
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) {
window.alert(
'请先在世界档案里创建角色,再配置这一幕的角色槽位。',
);
return;
}
if (
slotIndex > 0 &&
!encounterSlotNpcs[slotIndex - 1]
) {
window.alert('请先配置前一个角色槽位。');
return;
}
setActiveSceneActSlotPickerState({
actIndex: index,
slotIndex,
});
}}
/>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-1 text-sm text-zinc-200">
</div>
</div>
<div className="flex flex-wrap gap-2">
<ActionButton
label="配置背景"
onClick={() =>
setActiveSceneActBackgroundIndex(index)
}
tone="sky"
/>
<ActionButton
label="幕预览"
onClick={() => {
if (!encounterSlotNpcs[0]) {
window.alert(
'请先为这一幕配置主角色,再开始预览。',
);
return;
}
setActiveSceneActPreviewIndex(index);
}}
/>
</div>
</div>
<div 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>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
</div>
<ActionButton
label="查看世界地图"
onClick={() => setIsWorldMapOpen(true)}
tone="sky"
/>
</div>
<div className="mt-4">
<DirectionalSceneConnectionCompass
centerName={draft.name || '当前场景'}
directionTargets={directionTargetLabels}
onDirectionClick={handleOpenDirectionPicker}
/>
</div>
</div>
<SaveBar
onClose={handleRequestClose}
onSave={saveLandmarkProfile}
showClose={false}
/>
{activeSceneActBackgroundDraft &&
activeSceneActBackgroundIndex !== null ? (
<SceneActBackgroundModal
profile={editableProfile}
landmark={draft}
actLabel={
activeSceneActBackgroundDraft.title.trim() ||
buildDefaultSceneActTitle(activeSceneActBackgroundIndex)
}
act={activeSceneActBackgroundDraft}
currentImageSrc={
activeSceneActBackgroundDraft.backgroundImageSrc?.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}
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}
</>
);
}
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;