6411 lines
198 KiB
TypeScript
6411 lines
198 KiB
TypeScript
import { X } from 'lucide-react';
|
||
import type { ChangeEvent } from 'react';
|
||
import type { CSSProperties } from 'react';
|
||
import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels';
|
||
import {
|
||
buildCustomWorldPlayableCharacters,
|
||
ROLE_TEMPLATE_CHARACTERS,
|
||
} from '../../data/characterPresets';
|
||
import {
|
||
normalizeCustomWorldLandmarks,
|
||
} from '../../data/customWorldSceneGraph';
|
||
import {
|
||
getAllCustomWorldSceneImages,
|
||
resolveCustomWorldCampSceneImage,
|
||
resolveCustomWorldLandmarkImage,
|
||
} from '../../data/customWorldVisuals';
|
||
import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
|
||
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
|
||
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
|
||
import { fetchJson } from '../../editor/shared/jsonClient';
|
||
import { useCombatFlow } from '../../hooks/useCombatFlow';
|
||
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
|
||
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story';
|
||
import { useRpgSessionBootstrap } from '../../hooks/rpg-session';
|
||
import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts';
|
||
import type { CustomWorldSceneImageResult } from '../../services/aiTypes';
|
||
import { resolveCustomWorldCampScene } from '../../services/customWorldCamp';
|
||
import {
|
||
buildDefaultCustomWorldCoverProfile,
|
||
resolveCustomWorldCoverPresentation,
|
||
} from '../../services/customWorldCover';
|
||
import {
|
||
getCustomWorldFoundationAnchorContent,
|
||
parseFoundationTagText,
|
||
type CustomWorldFoundationEntryId,
|
||
} from '../../services/customWorldFoundationEntries';
|
||
import {
|
||
createEmptyCustomWorldCreatorIntent,
|
||
} from '../../services/customWorldCreatorIntent';
|
||
import {
|
||
type CustomWorldCoverAssetResult,
|
||
generateCustomWorldCoverImage,
|
||
uploadCustomWorldCoverImage,
|
||
} from '../../services/customWorldCoverAssetService';
|
||
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
|
||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||
import {
|
||
AnimationState,
|
||
type Character,
|
||
type CharacterAnimationConfig,
|
||
type CustomWorldCoverCropRect,
|
||
type CustomWorldCoverProfile,
|
||
CustomWorldLandmark,
|
||
CustomWorldNpc,
|
||
CustomWorldPlayableNpc,
|
||
CustomWorldProfile,
|
||
type CustomWorldRoleInitialItem,
|
||
type CustomWorldRoleRelation,
|
||
type CustomWorldRoleSkill,
|
||
CustomWorldSceneConnection,
|
||
type ItemRarity,
|
||
type SceneActAdvanceRule,
|
||
type SceneActBlueprint,
|
||
type SceneActStage,
|
||
type SceneChapterBlueprint,
|
||
type SceneNpc,
|
||
type ScenePresetInfo,
|
||
WorldType,
|
||
} from '../../types';
|
||
import {
|
||
type CharacterAnimationGenerationPayload,
|
||
generateCharacterAnimationDraft,
|
||
publishCharacterAnimationAssets,
|
||
} from '../asset-studio/characterAssetWorkflowPersistence';
|
||
import { useAuthUi } from '../auth/AuthUiContext';
|
||
import { CharacterAnimator } from '../CharacterAnimator';
|
||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||
import { buildDefaultCustomWorldNpcVisual } from '../customWorldNpcVisualDefaults';
|
||
import {
|
||
CustomWorldNpcPortrait,
|
||
CustomWorldNpcVisualEditor,
|
||
} from '../CustomWorldNpcVisualEditor';
|
||
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
|
||
import {
|
||
RoleCharacterSprite,
|
||
SceneEncounterNpcSprite,
|
||
} from '../game-canvas/GameCanvasShared';
|
||
import { PixelIcon } from '../PixelIcon';
|
||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||
import { RpgRuntimeShell } from '../rpg-runtime-shell';
|
||
import {
|
||
createLandmarkDraft,
|
||
createPlayableNpcDraft,
|
||
createStoryNpcDraft,
|
||
resolveEditableLandmark,
|
||
resolveEditablePlayableNpc,
|
||
resolveEditableStoryNpc,
|
||
} from './rpgCreationResultFormMapper';
|
||
|
||
export type RpgCreationEditorTarget =
|
||
| { kind: 'world' }
|
||
| { kind: '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_EASED,
|
||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||
|
||
const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [
|
||
{ value: 'common', label: '普通' },
|
||
{ value: 'uncommon', label: '优秀' },
|
||
{ value: 'rare', label: '稀有' },
|
||
{ value: 'epic', label: '史诗' },
|
||
{ value: 'legendary', label: '传说' },
|
||
];
|
||
|
||
const ITEM_RARITY_LABELS: Record<ItemRarity, string> = {
|
||
common: '普通',
|
||
uncommon: '优秀',
|
||
rare: '稀有',
|
||
epic: '史诗',
|
||
legendary: '传说',
|
||
};
|
||
|
||
const MIN_SCENE_ACT_COUNT = 2;
|
||
const MAX_SCENE_ACT_COUNT = 5;
|
||
const DEFAULT_SCENE_ACT_COUNT = 3;
|
||
const SCENE_ACT_ROLE_SLOT_COUNT = 3;
|
||
|
||
const CARDINAL_CONNECTION_DIRECTIONS = [
|
||
'north',
|
||
'east',
|
||
'south',
|
||
'west',
|
||
] as const;
|
||
|
||
type CardinalConnectionDirection =
|
||
(typeof CARDINAL_CONNECTION_DIRECTIONS)[number];
|
||
|
||
const CARDINAL_CONNECTION_LABELS: Record<CardinalConnectionDirection, string> = {
|
||
north: '北',
|
||
east: '东',
|
||
south: '南',
|
||
west: '西',
|
||
};
|
||
|
||
function slugify(value: string) {
|
||
const normalized = value
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
|
||
return normalized || 'entry';
|
||
}
|
||
|
||
function createEntryId(prefix: string, label: string, seed: number) {
|
||
return `${prefix}-${slugify(label || `${prefix}-${seed}`)}-${seed.toString(36)}`;
|
||
}
|
||
|
||
function parseCommaText(value: string) {
|
||
return [
|
||
...new Set(
|
||
value
|
||
.split(/[\n,,]/u)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
}
|
||
|
||
function dedupeTextValues(values: Array<string | null | undefined>) {
|
||
return [
|
||
...new Set(
|
||
values
|
||
.map((value) => value?.trim() ?? '')
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
}
|
||
|
||
function compactTextList(values: Array<string | null | undefined>) {
|
||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
||
}
|
||
|
||
function moveArrayItem<T>(values: T[], fromIndex: number, toIndex: number) {
|
||
if (
|
||
fromIndex < 0 ||
|
||
toIndex < 0 ||
|
||
fromIndex >= values.length ||
|
||
toIndex >= values.length ||
|
||
fromIndex === toIndex
|
||
) {
|
||
return values;
|
||
}
|
||
|
||
const nextValues = [...values];
|
||
const [movedItem] = nextValues.splice(fromIndex, 1);
|
||
if (typeof movedItem === 'undefined') {
|
||
return values;
|
||
}
|
||
|
||
nextValues.splice(toIndex, 0, movedItem);
|
||
return nextValues;
|
||
}
|
||
|
||
function buildSceneActEncounterSlotIds(encounterNpcIds: string[]) {
|
||
const compactIds = dedupeTextValues(encounterNpcIds).slice(
|
||
0,
|
||
SCENE_ACT_ROLE_SLOT_COUNT,
|
||
);
|
||
|
||
return Array.from({ length: SCENE_ACT_ROLE_SLOT_COUNT }, (_unused, index) =>
|
||
compactIds[index] ?? null,
|
||
) as Array<string | null>;
|
||
}
|
||
|
||
function compactSceneActEncounterSlotIds(slotIds: Array<string | null | undefined>) {
|
||
return dedupeTextValues(slotIds).slice(0, SCENE_ACT_ROLE_SLOT_COUNT);
|
||
}
|
||
|
||
function assignSceneActEncounterSlotId(
|
||
encounterNpcIds: string[],
|
||
slotIndex: number,
|
||
nextNpcId: string | null,
|
||
) {
|
||
const nextSlots = buildSceneActEncounterSlotIds(encounterNpcIds);
|
||
const normalizedNpcId = nextNpcId?.trim() || null;
|
||
|
||
if (slotIndex < 0 || slotIndex >= nextSlots.length) {
|
||
return compactSceneActEncounterSlotIds(nextSlots);
|
||
}
|
||
|
||
if (!normalizedNpcId) {
|
||
nextSlots.splice(slotIndex, 1);
|
||
nextSlots.push(null);
|
||
return compactSceneActEncounterSlotIds(nextSlots);
|
||
}
|
||
|
||
return compactSceneActEncounterSlotIds(
|
||
nextSlots.map((slotNpcId, index) => {
|
||
if (index === slotIndex) {
|
||
return normalizedNpcId;
|
||
}
|
||
|
||
return slotNpcId === normalizedNpcId ? null : slotNpcId;
|
||
}),
|
||
);
|
||
}
|
||
|
||
function buildSceneActStageCoverage(index: number, actCount: number): SceneActStage[] {
|
||
if (actCount <= 2) {
|
||
return index === 0
|
||
? ['opening', 'expansion']
|
||
: ['turning_point', 'climax', 'aftermath'];
|
||
}
|
||
|
||
if (actCount === 3) {
|
||
if (index === 0) return ['opening'];
|
||
if (index === 1) return ['expansion', 'turning_point'];
|
||
return ['climax', 'aftermath'];
|
||
}
|
||
|
||
if (actCount === 4) {
|
||
if (index === 0) return ['opening'];
|
||
if (index === 1) return ['expansion'];
|
||
if (index === 2) return ['turning_point'];
|
||
return ['climax', 'aftermath'];
|
||
}
|
||
|
||
if (index === 0) return ['opening'];
|
||
if (index === 1) return ['expansion'];
|
||
if (index === 2) return ['turning_point'];
|
||
if (index === 3) return ['climax'];
|
||
return ['aftermath'];
|
||
}
|
||
|
||
function buildSceneActAdvanceRule(
|
||
index: number,
|
||
actCount: number,
|
||
): SceneActAdvanceRule {
|
||
if (index === 0) {
|
||
return 'after_primary_contact';
|
||
}
|
||
|
||
if (index >= actCount - 1) {
|
||
return 'after_chapter_resolution';
|
||
}
|
||
|
||
return 'after_active_step_complete';
|
||
}
|
||
|
||
function buildDefaultSceneActTitle(index: number) {
|
||
return `第${index + 1}幕`;
|
||
}
|
||
|
||
function buildDefaultSceneActBlueprint(params: {
|
||
sceneId: string;
|
||
sceneName: string;
|
||
sceneSummary: string;
|
||
encounterNpcIds: string[];
|
||
backgroundImageSrc?: string | null;
|
||
linkedThreadIds?: string[];
|
||
index: number;
|
||
actCount: number;
|
||
}): SceneActBlueprint {
|
||
const encounterNpcIds = dedupeTextValues(params.encounterNpcIds).slice(0, 1);
|
||
const actTitle = buildDefaultSceneActTitle(params.index);
|
||
const sceneLabel = params.sceneName.trim() || '当前场景';
|
||
const sceneSummary = params.sceneSummary.trim();
|
||
const 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,
|
||
} 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)] ${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 ImageField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
fallbackLabel,
|
||
tone = 'square',
|
||
showInput = true,
|
||
previewOverlay,
|
||
footer,
|
||
}: {
|
||
label: string;
|
||
value?: string;
|
||
onChange: (value: string) => void;
|
||
fallbackLabel: string;
|
||
tone?: 'square' | 'landscape';
|
||
showInput?: boolean;
|
||
previewOverlay?: ReactNode;
|
||
footer?: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||
{label}
|
||
</div>
|
||
<ImagePreview
|
||
src={value}
|
||
alt={label}
|
||
fallbackLabel={fallbackLabel}
|
||
tone={tone}
|
||
>
|
||
{previewOverlay}
|
||
</ImagePreview>
|
||
{showInput ? (
|
||
<TextInput
|
||
value={value ?? ''}
|
||
onChange={onChange}
|
||
placeholder="支持填写项目内图片路径或外链地址"
|
||
/>
|
||
) : null}
|
||
{footer}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActionButton({
|
||
label,
|
||
onClick,
|
||
tone = 'default',
|
||
disabled = false,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
tone?: 'default' | 'sky' | 'rose';
|
||
disabled?: boolean;
|
||
}) {
|
||
const toneClassName =
|
||
tone === 'sky'
|
||
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
|
||
: tone === 'rose'
|
||
? 'border-rose-300/22 bg-rose-500/12 text-rose-50 hover:border-rose-200/40 hover:text-white'
|
||
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onPointerDown={(event) => {
|
||
event.stopPropagation();
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.stopPropagation();
|
||
}}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onClick();
|
||
}}
|
||
disabled={disabled}
|
||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const SCENE_ACT_SLOT_LAYOUTS = [
|
||
{
|
||
left: '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"
|
||
>
|
||
<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 flex shrink-0 flex-col gap-3 border-t border-white/8 bg-zinc-950/95 pt-4 sm:flex-row sm:justify-end">
|
||
{selectedNpc ? (
|
||
<ActionButton
|
||
label="移除角色"
|
||
onClick={() => {
|
||
onApply(null);
|
||
onClose();
|
||
}}
|
||
tone="rose"
|
||
/>
|
||
) : null}
|
||
<ActionButton
|
||
label="保存角色"
|
||
onClick={() => {
|
||
if (!draftNpcId) {
|
||
window.alert('请先选择角色。');
|
||
return;
|
||
}
|
||
onApply(draftNpcId);
|
||
onClose();
|
||
}}
|
||
tone="sky"
|
||
disabled={!draftNpcId}
|
||
/>
|
||
</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,
|
||
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,
|
||
onApply,
|
||
onClose,
|
||
}: {
|
||
profile: CustomWorldProfile;
|
||
landmark: CustomWorldLandmark;
|
||
initialPromptText?: string;
|
||
onApply: (result: CustomWorldSceneImageResult) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [userPrompt, setUserPrompt] = useDraft(
|
||
initialPromptText?.trim() ||
|
||
landmark.visualDescription?.trim() ||
|
||
landmark.description.trim() ||
|
||
landmark.name.trim(),
|
||
);
|
||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [latestResult, setLatestResult] =
|
||
useState<CustomWorldSceneImageResult | null>(null);
|
||
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
|
||
|
||
const originalImageSrc = useMemo(() => {
|
||
const landmarkIndex = profile.landmarks.findIndex(
|
||
(entry) => entry.id === landmark.id,
|
||
);
|
||
|
||
return resolveCustomWorldLandmarkImage(
|
||
profile,
|
||
landmark,
|
||
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
|
||
profile.landmarks
|
||
.filter((entry) => entry.id !== landmark.id)
|
||
.map((entry) => entry.imageSrc)
|
||
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
|
||
);
|
||
}, [landmark, profile]);
|
||
|
||
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
|
||
|
||
const handleReferenceImageChange = async (
|
||
event: ChangeEvent<HTMLInputElement>,
|
||
) => {
|
||
const file = event.target.files?.[0];
|
||
event.currentTarget.value = '';
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const dataUrl = await readImageFileAsDataUrl(file);
|
||
setReferenceImageSrc(dataUrl);
|
||
setError(null);
|
||
} catch (uploadError) {
|
||
setError(
|
||
uploadError instanceof Error
|
||
? uploadError.message
|
||
: '参考图读取失败,请重试。',
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleRequestClose = () => {
|
||
if (isGenerating) {
|
||
return;
|
||
}
|
||
if (latestResult) {
|
||
setIsExitConfirmOpen(true);
|
||
return;
|
||
}
|
||
onClose();
|
||
};
|
||
|
||
const handleGenerate = async () => {
|
||
if (!userPrompt.trim()) {
|
||
setError('请先描述想要生成的画面内容。');
|
||
return;
|
||
}
|
||
|
||
setIsGenerating(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const result = await rpgCreationAssetClient.generateSceneImage({
|
||
profile,
|
||
landmark,
|
||
userPrompt,
|
||
size: FIXED_SCENE_IMAGE_SIZE,
|
||
...(referenceImageSrc ? { referenceImageSrc } : {}),
|
||
});
|
||
setLatestResult(result);
|
||
} catch (generationError) {
|
||
setError(
|
||
generationError instanceof Error
|
||
? generationError.message
|
||
: '场景图片生成失败,请稍后重试。',
|
||
);
|
||
} finally {
|
||
setIsGenerating(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = () => {
|
||
if (!latestResult || isGenerating) {
|
||
return;
|
||
}
|
||
onApply(latestResult);
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<ModalShell
|
||
title={`智能生成:${landmark.name || '当前场景'}`}
|
||
onClose={handleRequestClose}
|
||
panelClassName="sm:max-w-4xl"
|
||
overlayClassName="z-[99]"
|
||
disableClose={isGenerating}
|
||
usePixelFont
|
||
>
|
||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.15fr)_minmax(17rem,0.85fr)]">
|
||
<div className="space-y-4">
|
||
<Field label="画面内容描述">
|
||
<TextArea
|
||
value={userPrompt}
|
||
onChange={(value) => setUserPrompt(value)}
|
||
rows={8}
|
||
placeholder="例如:雨夜的悬桥横跨黑色峡谷,桥下翻涌蓝绿色雾潮,远处有半坍塌塔楼与零星灯火。"
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="自定义参考图(可选)">
|
||
<div className="space-y-3">
|
||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||
<input
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={(event) => {
|
||
void handleReferenceImageChange(event);
|
||
}}
|
||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||
/>
|
||
</label>
|
||
{referenceImageSrc ? (
|
||
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
|
||
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
|
||
<img
|
||
src={referenceImageSrc}
|
||
alt="自定义参考图"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
|
||
已载入自定义参考图
|
||
</div>
|
||
<ActionButton
|
||
label="移除"
|
||
onClick={() => setReferenceImageSrc('')}
|
||
disabled={isGenerating}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Field>
|
||
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||
<ImagePreview
|
||
src={previewImageSrc}
|
||
alt={landmark.name || '场景预览'}
|
||
fallbackLabel={
|
||
landmark.name ? landmark.name.slice(0, 4) : '场景'
|
||
}
|
||
tone="landscape"
|
||
/>
|
||
</div>
|
||
|
||
{latestResult ? (
|
||
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
|
||
已生成完毕,请保存后再退出页面
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||
<ActionButton
|
||
label="保存"
|
||
onClick={handleSave}
|
||
disabled={!latestResult || isGenerating}
|
||
/>
|
||
<ActionButton
|
||
label={
|
||
isGenerating
|
||
? '正在生成...'
|
||
: latestResult
|
||
? '重新生成'
|
||
: '开始生成'
|
||
}
|
||
onClick={() => {
|
||
void handleGenerate();
|
||
}}
|
||
tone="sky"
|
||
disabled={isGenerating}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ModalShell>
|
||
|
||
{isExitConfirmOpen ? (
|
||
<PortalCompactDialogShell
|
||
title="确认退出"
|
||
onClose={() => setIsExitConfirmOpen(false)}
|
||
overlayClassName="z-[140]"
|
||
usePixelFont
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
|
||
当前生成画面还未保存,退出后将丢失这次生成结果,仍然退出吗?
|
||
</div>
|
||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||
<ActionButton
|
||
label="继续编辑"
|
||
onClick={() => setIsExitConfirmOpen(false)}
|
||
/>
|
||
<ActionButton
|
||
label="仍然退出"
|
||
onClick={() => {
|
||
setIsExitConfirmOpen(false);
|
||
onClose();
|
||
}}
|
||
tone="sky"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</PortalCompactDialogShell>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SceneActBackgroundModal({
|
||
profile,
|
||
landmark,
|
||
act,
|
||
actLabel,
|
||
currentImageSrc,
|
||
fallbackImageSrc,
|
||
onApply,
|
||
onClose,
|
||
}: {
|
||
profile: CustomWorldProfile;
|
||
landmark: CustomWorldLandmark;
|
||
act: SceneActBlueprint;
|
||
actLabel: string;
|
||
currentImageSrc?: string | null;
|
||
fallbackImageSrc?: string | null;
|
||
onApply: (imageSrc?: string | null) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
|
||
const [draftImageSrc, setDraftImageSrc] = useDraft(currentImageSrc?.trim() || '');
|
||
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
|
||
const previewImageSrc = draftImageSrc || fallbackImageSrc || '';
|
||
|
||
return (
|
||
<>
|
||
<ModalShell
|
||
title={`配置幕背景:${actLabel}`}
|
||
onClose={onClose}
|
||
panelClassName="sm:max-w-5xl"
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||
<ImagePreview
|
||
src={previewImageSrc || undefined}
|
||
alt={`${actLabel}背景预览`}
|
||
fallbackLabel="暂无背景图"
|
||
tone="landscape"
|
||
/>
|
||
<div className="mt-3 flex flex-wrap gap-3">
|
||
<ActionButton
|
||
label="跟随场景主图"
|
||
onClick={() => setDraftImageSrc('')}
|
||
tone="sky"
|
||
/>
|
||
<ActionButton label="AI生成" onClick={() => setIsAiGenerateOpen(true)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
|
||
预设背景
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||
{presetImages.map((src, index) => {
|
||
const isSelected = src === draftImageSrc;
|
||
|
||
return (
|
||
<button
|
||
key={`${actLabel}-preset-${index}-${src || 'empty'}`}
|
||
type="button"
|
||
onClick={() => setDraftImageSrc(src)}
|
||
className={`overflow-hidden rounded-2xl border text-left transition-colors ${
|
||
isSelected
|
||
? 'border-sky-300/55 bg-sky-500/10'
|
||
: 'border-white/10 bg-black/20 hover:border-white/25'
|
||
}`}
|
||
>
|
||
<div className="relative aspect-[16/9] overflow-hidden">
|
||
<img
|
||
src={src}
|
||
alt={`幕背景预设 ${index + 1}`}
|
||
loading="lazy"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.82)_100%)] px-3 py-2 text-[11px] text-zinc-100">
|
||
预设 #{(index + 1).toString().padStart(3, '0')}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||
<ActionButton label="取消" onClick={onClose} />
|
||
<ActionButton
|
||
label="保存背景"
|
||
onClick={() => {
|
||
onApply(draftImageSrc || fallbackImageSrc || undefined);
|
||
onClose();
|
||
}}
|
||
tone="sky"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</ModalShell>
|
||
|
||
{isAiGenerateOpen ? (
|
||
<SceneImageGenerationModal
|
||
profile={profile}
|
||
landmark={landmark}
|
||
initialPromptText={
|
||
act.backgroundPromptText?.trim() ||
|
||
compactTextList([act.title, act.summary, act.actGoal]).join(';')
|
||
}
|
||
onApply={(result) => {
|
||
setDraftImageSrc(result.imageSrc);
|
||
}}
|
||
onClose={() => setIsAiGenerateOpen(false)}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const FIXED_COVER_IMAGE_SIZE = '1600*900';
|
||
const COVER_IMAGE_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||
|
||
function buildGeneratedCoverProfile(
|
||
result: CustomWorldCoverAssetResult,
|
||
): CustomWorldCoverProfile {
|
||
return {
|
||
sourceType: result.sourceType,
|
||
imageSrc: result.imageSrc,
|
||
characterRoleIds: [],
|
||
};
|
||
}
|
||
|
||
function CoverUploadCropModal({
|
||
imageDataUrl,
|
||
imageSize,
|
||
worldName,
|
||
isSubmitting,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
imageDataUrl: string;
|
||
imageSize: { width: number; height: number };
|
||
worldName: string;
|
||
isSubmitting: boolean;
|
||
onCancel: () => void;
|
||
onConfirm: (cropRect: CustomWorldCoverCropRect) => void;
|
||
}) {
|
||
const [zoomPercent, setZoomPercent] = useState(100);
|
||
const baseCropRect = useMemo(
|
||
() => buildCenteredCoverCropRect(imageSize.width, imageSize.height),
|
||
[imageSize],
|
||
);
|
||
const [offsetX, setOffsetX] = useState(0);
|
||
const [offsetY, setOffsetY] = useState(0);
|
||
|
||
useEffect(() => {
|
||
setZoomPercent(100);
|
||
setOffsetX(0);
|
||
setOffsetY(0);
|
||
}, [imageDataUrl]);
|
||
|
||
const cropRect = useMemo(() => {
|
||
const scale = Math.max(1, zoomPercent / 100);
|
||
const nextCropRect = {
|
||
width: baseCropRect.width / scale,
|
||
height: baseCropRect.height / scale,
|
||
x: baseCropRect.x + offsetX,
|
||
y: baseCropRect.y + offsetY,
|
||
};
|
||
|
||
return clampCoverCropRect(nextCropRect, imageSize);
|
||
}, [baseCropRect, imageSize, offsetX, offsetY, zoomPercent]);
|
||
|
||
const previewStyle = useMemo(
|
||
() => buildCoverCropPreviewStyle(cropRect, imageSize),
|
||
[cropRect, imageSize],
|
||
);
|
||
|
||
const maxOffsetX = Math.max(0, imageSize.width - cropRect.width);
|
||
const maxOffsetY = Math.max(0, imageSize.height - cropRect.height);
|
||
|
||
return (
|
||
<ModalShell
|
||
title="裁剪上传封面"
|
||
onClose={onCancel}
|
||
panelClassName="sm:max-w-5xl"
|
||
overlayClassName="z-[120]"
|
||
disableClose={isSubmitting}
|
||
>
|
||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_20rem]">
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||
<ImagePreview
|
||
src={imageDataUrl}
|
||
alt="上传封面裁剪预览"
|
||
fallbackLabel={worldName.slice(0, 4) || '封面'}
|
||
tone="landscape"
|
||
overlayInteractive
|
||
previewOverlay={
|
||
<>
|
||
<div className="absolute inset-0 bg-black/45" />
|
||
<div
|
||
className="absolute border border-sky-300/90 bg-white/8 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
|
||
style={previewStyle}
|
||
/>
|
||
</>
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<Field label="缩放">
|
||
<input
|
||
type="range"
|
||
min={100}
|
||
max={220}
|
||
step={1}
|
||
value={zoomPercent}
|
||
onChange={(event) => setZoomPercent(Number(event.target.value))}
|
||
disabled={isSubmitting}
|
||
className="w-full accent-sky-400"
|
||
/>
|
||
</Field>
|
||
<Field label="左右位置">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={Math.max(0, Math.floor(maxOffsetX))}
|
||
step={1}
|
||
value={Math.max(0, Math.floor(offsetX + baseCropRect.x))}
|
||
onChange={(event) =>
|
||
setOffsetX(Number(event.target.value) - baseCropRect.x)
|
||
}
|
||
disabled={isSubmitting}
|
||
className="w-full accent-sky-400"
|
||
/>
|
||
</Field>
|
||
<Field label="上下位置">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={Math.max(0, Math.floor(maxOffsetY))}
|
||
step={1}
|
||
value={Math.max(0, Math.floor(offsetY + baseCropRect.y))}
|
||
onChange={(event) =>
|
||
setOffsetY(Number(event.target.value) - baseCropRect.y)
|
||
}
|
||
disabled={isSubmitting}
|
||
className="w-full accent-sky-400"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-6 text-zinc-200">
|
||
成品会固定保存为 16:9,并由后端统一压缩到 1600 × 900。
|
||
</div>
|
||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-xs leading-6 text-zinc-400">
|
||
当前裁剪区域:
|
||
<br />
|
||
{`x ${Math.round(cropRect.x)} / y ${Math.round(cropRect.y)} / w ${Math.round(cropRect.width)} / h ${Math.round(cropRect.height)}`}
|
||
</div>
|
||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||
<ActionButton
|
||
label="取消"
|
||
onClick={onCancel}
|
||
disabled={isSubmitting}
|
||
/>
|
||
<ActionButton
|
||
label={isSubmitting ? '正在保存...' : '确认裁剪并上传'}
|
||
onClick={() => onConfirm(cropRect)}
|
||
tone="sky"
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ModalShell>
|
||
);
|
||
}
|
||
|
||
function CoverImageGenerationModal({
|
||
profile,
|
||
onApply,
|
||
onClose,
|
||
}: {
|
||
profile: CustomWorldProfile;
|
||
onApply: (result: CustomWorldCoverAssetResult) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const initialPresentation = useMemo(
|
||
() => resolveCustomWorldCoverPresentation(profile),
|
||
[profile],
|
||
);
|
||
const [userPrompt, setUserPrompt] = useDraft(profile.summary || profile.name);
|
||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [latestResult, setLatestResult] =
|
||
useState<CustomWorldCoverAssetResult | null>(null);
|
||
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
|
||
|
||
const previewImageSrc = latestResult?.imageSrc || initialPresentation.imageSrc;
|
||
const openingAct = profile.sceneChapterBlueprints?.[0]?.acts?.[0] ?? null;
|
||
const selectedCharacterRoleIds =
|
||
profile.cover?.sourceType === 'default'
|
||
? profile.cover.characterRoleIds
|
||
: buildDefaultCustomWorldCoverProfile(profile).characterRoleIds;
|
||
const selectedRoleLabels = profile.playableNpcs
|
||
.filter((role) => selectedCharacterRoleIds?.includes(role.id))
|
||
.map((role) => role.name)
|
||
.filter(Boolean);
|
||
|
||
const handleReferenceImageChange = async (
|
||
event: ChangeEvent<HTMLInputElement>,
|
||
) => {
|
||
const file = event.target.files?.[0];
|
||
event.currentTarget.value = '';
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const dataUrl = await readImageFileAsDataUrl(file);
|
||
setReferenceImageSrc(dataUrl);
|
||
setError(null);
|
||
} catch (uploadError) {
|
||
setError(
|
||
uploadError instanceof Error
|
||
? uploadError.message
|
||
: '参考图读取失败,请重试。',
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleRequestClose = () => {
|
||
if (isGenerating) {
|
||
return;
|
||
}
|
||
if (latestResult) {
|
||
setIsExitConfirmOpen(true);
|
||
return;
|
||
}
|
||
onClose();
|
||
};
|
||
|
||
const handleGenerate = async () => {
|
||
if (!userPrompt.trim()) {
|
||
setError('请先补一句你想要的封面氛围。');
|
||
return;
|
||
}
|
||
|
||
setIsGenerating(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const result = await generateCustomWorldCoverImage({
|
||
profile,
|
||
userPrompt,
|
||
referenceImageSrc,
|
||
characterRoleIds: selectedCharacterRoleIds,
|
||
size: FIXED_COVER_IMAGE_SIZE,
|
||
});
|
||
setLatestResult(result);
|
||
} catch (generationError) {
|
||
setError(
|
||
generationError instanceof Error
|
||
? generationError.message
|
||
: '作品封面生成失败,请稍后重试。',
|
||
);
|
||
} finally {
|
||
setIsGenerating(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = () => {
|
||
if (!latestResult || isGenerating) {
|
||
return;
|
||
}
|
||
onApply(latestResult);
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<ModalShell
|
||
title="AI 生成作品封面"
|
||
onClose={handleRequestClose}
|
||
panelClassName="sm:max-w-5xl"
|
||
overlayClassName="z-[99]"
|
||
disableClose={isGenerating}
|
||
>
|
||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
|
||
<div className="space-y-4">
|
||
<Field label="封面氛围">
|
||
<TextArea
|
||
value={userPrompt}
|
||
onChange={(value) => setUserPrompt(value)}
|
||
rows={5}
|
||
placeholder="例如:海雾压进旧码头,主角站在残灯与潮水之间,整体像一张正式 RPG 作品封面。"
|
||
/>
|
||
</Field>
|
||
|
||
<div className="rounded-2xl border border-white/8 bg-black/18 px-4 py-4 text-xs leading-6 text-zinc-300">
|
||
{openingAct?.title
|
||
? `系统会自动带入开局第一幕「${openingAct.title}」的场景素材。`
|
||
: '系统会自动带入当前世界的开局场景素材。'}
|
||
{selectedRoleLabels.length > 0
|
||
? ` 当前默认出镜角色:${selectedRoleLabels.join('、')}。`
|
||
: ''}
|
||
</div>
|
||
|
||
<Field label="参考图(可选)">
|
||
<div className="space-y-3">
|
||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||
<input
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={(event) => {
|
||
void handleReferenceImageChange(event);
|
||
}}
|
||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||
/>
|
||
</label>
|
||
{referenceImageSrc ? (
|
||
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
|
||
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
|
||
<img
|
||
src={referenceImageSrc}
|
||
alt="封面参考图"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
|
||
已载入封面参考图
|
||
</div>
|
||
<ActionButton
|
||
label="移除"
|
||
onClick={() => setReferenceImageSrc('')}
|
||
disabled={isGenerating}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
|
||
<CustomWorldCoverArtwork
|
||
imageSrc={previewImageSrc}
|
||
title={profile.name}
|
||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||
renderMode={
|
||
latestResult ? 'image' : initialPresentation.renderMode
|
||
}
|
||
characterImageSrcs={
|
||
latestResult ? [] : initialPresentation.characterImageSrcs
|
||
}
|
||
className="aspect-[16/9] max-h-[14rem] rounded-2xl"
|
||
/>
|
||
</div>
|
||
|
||
{latestResult ? (
|
||
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
|
||
已生成完毕,保存后将替换当前作品封面。
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||
<ActionButton
|
||
label="保存"
|
||
onClick={handleSave}
|
||
disabled={!latestResult || isGenerating}
|
||
/>
|
||
<ActionButton
|
||
label={
|
||
isGenerating
|
||
? '正在生成...'
|
||
: latestResult
|
||
? '重新生成'
|
||
: '开始生成'
|
||
}
|
||
onClick={() => {
|
||
void handleGenerate();
|
||
}}
|
||
tone="sky"
|
||
disabled={isGenerating}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ModalShell>
|
||
|
||
{isExitConfirmOpen ? (
|
||
<PortalCompactDialogShell
|
||
title="确认退出"
|
||
onClose={() => setIsExitConfirmOpen(false)}
|
||
overlayClassName="z-[140]"
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
|
||
当前生成结果还没有保存,确认退出吗?
|
||
</div>
|
||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||
<ActionButton
|
||
label="继续编辑"
|
||
onClick={() => setIsExitConfirmOpen(false)}
|
||
/>
|
||
<ActionButton
|
||
label="确认退出"
|
||
onClick={() => {
|
||
setIsExitConfirmOpen(false);
|
||
onClose();
|
||
}}
|
||
tone="sky"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</PortalCompactDialogShell>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function WorldCoverEditor({
|
||
profile,
|
||
onSaveProfile,
|
||
onClose,
|
||
}: {
|
||
profile: CustomWorldProfile;
|
||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [draftCover, setDraftCover] = useDraft(
|
||
profile.cover ?? buildDefaultCustomWorldCoverProfile(profile),
|
||
);
|
||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
const [pendingUploadImageDataUrl, setPendingUploadImageDataUrl] = useState('');
|
||
const [pendingUploadImageSize, setPendingUploadImageSize] = useState<{
|
||
width: number;
|
||
height: number;
|
||
} | null>(null);
|
||
const previewProfile = useMemo(
|
||
() => ({
|
||
...profile,
|
||
cover: draftCover,
|
||
}),
|
||
[draftCover, profile],
|
||
);
|
||
const previewPresentation = useMemo(
|
||
() => resolveCustomWorldCoverPresentation(previewProfile),
|
||
[previewProfile],
|
||
);
|
||
|
||
const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.currentTarget.value = '';
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (file.size > COVER_IMAGE_MAX_UPLOAD_BYTES) {
|
||
setUploadError('上传封面原图不能超过 10 MB。');
|
||
return;
|
||
}
|
||
|
||
const imageDataUrl = await readImageFileAsDataUrl(file);
|
||
const imageSize = await loadImageDimensionsFromDataUrl(imageDataUrl);
|
||
setPendingUploadImageDataUrl(imageDataUrl);
|
||
setPendingUploadImageSize(imageSize);
|
||
setUploadError(null);
|
||
} catch (uploadErrorValue) {
|
||
setUploadError(
|
||
uploadErrorValue instanceof Error
|
||
? uploadErrorValue.message
|
||
: '上传作品封面失败,请稍后重试。',
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleConfirmUploadCrop = async (cropRect: CustomWorldCoverCropRect) => {
|
||
if (!pendingUploadImageDataUrl) {
|
||
return;
|
||
}
|
||
|
||
setIsUploading(true);
|
||
setUploadError(null);
|
||
try {
|
||
const result = await uploadCustomWorldCoverImage({
|
||
profileId: profile.id,
|
||
worldName: profile.name,
|
||
imageDataUrl: pendingUploadImageDataUrl,
|
||
cropRect,
|
||
});
|
||
setDraftCover(buildGeneratedCoverProfile(result));
|
||
setPendingUploadImageDataUrl('');
|
||
setPendingUploadImageSize(null);
|
||
} catch (uploadErrorValue) {
|
||
setUploadError(
|
||
uploadErrorValue instanceof Error
|
||
? uploadErrorValue.message
|
||
: '上传作品封面失败,请稍后重试。',
|
||
);
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<ModalShell
|
||
title="编辑作品封面"
|
||
onClose={onClose}
|
||
panelClassName="sm:max-w-3xl"
|
||
>
|
||
<div className="grid gap-4 md:grid-cols-[minmax(0,0.95fr)_minmax(17rem,1.05fr)]">
|
||
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
|
||
<CustomWorldCoverArtwork
|
||
imageSrc={previewPresentation.imageSrc}
|
||
title={profile.name}
|
||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||
renderMode={previewPresentation.renderMode}
|
||
characterImageSrcs={previewPresentation.characterImageSrcs}
|
||
className="aspect-[16/9] max-h-[13rem] rounded-2xl"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap gap-3">
|
||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
|
||
{draftCover.sourceType === 'uploaded'
|
||
? '当前为上传封面'
|
||
: draftCover.sourceType === 'generated'
|
||
? '当前为 AI 封面'
|
||
: '当前为默认封面'}
|
||
</span>
|
||
</div>
|
||
|
||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||
上传封面
|
||
</div>
|
||
<div className="mb-3 text-xs leading-5 text-zinc-400">
|
||
支持 png、jpg、webp。上传后会先裁剪成 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>
|
||
);
|
||
}
|
||
|
||
function StoryNpcVisualEditorModal({
|
||
npc,
|
||
visual,
|
||
onChange,
|
||
onOpenAiStudio,
|
||
onClose,
|
||
}: {
|
||
npc: CustomWorldNpc;
|
||
visual: NonNullable<CustomWorldNpc['visual']>;
|
||
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
|
||
onOpenAiStudio?: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<ModalShell
|
||
title={`修改形象:${npc.name}`}
|
||
subtitle="在独立面板中组合中世纪奇幻角色形象,左侧预览会保持吸顶。"
|
||
onClose={onClose}
|
||
panelClassName="sm:max-w-6xl"
|
||
overlayClassName="z-[99]"
|
||
>
|
||
<CustomWorldNpcVisualEditor
|
||
npc={{
|
||
id: npc.id,
|
||
name: npc.name,
|
||
role: npc.role,
|
||
description: npc.description,
|
||
}}
|
||
value={visual}
|
||
onChange={onChange}
|
||
onAiGenerate={() => {
|
||
onClose();
|
||
onOpenAiStudio?.();
|
||
}}
|
||
/>
|
||
</ModalShell>
|
||
);
|
||
}
|
||
|
||
export function WorldEditor({
|
||
profile,
|
||
onSave,
|
||
onClose,
|
||
}: {
|
||
profile: CustomWorldProfile;
|
||
onSave: (profile: CustomWorldProfile) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [draft, setDraft] = useDraft(profile);
|
||
|
||
return (
|
||
<ModalShell
|
||
title="编辑世界信息"
|
||
subtitle="修改后的内容会直接反映在结果页,并会作为进入世界前的最终档案。"
|
||
onClose={onClose}
|
||
>
|
||
<div className="space-y-4">
|
||
<Field label="世界名称">
|
||
<TextInput
|
||
value={draft.name}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, name: value }))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="副标题">
|
||
<TextInput
|
||
value={draft.subtitle}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, subtitle: value }))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="世界概述">
|
||
<TextArea
|
||
value={draft.summary}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, summary: value }))
|
||
}
|
||
rows={4}
|
||
/>
|
||
</Field>
|
||
<Field label="世界基调">
|
||
<TextArea
|
||
value={draft.tone}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, tone: value }))
|
||
}
|
||
rows={3}
|
||
/>
|
||
</Field>
|
||
<Field label="主线目标">
|
||
<TextArea
|
||
value={draft.playerGoal}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, playerGoal: value }))
|
||
}
|
||
rows={3}
|
||
/>
|
||
</Field>
|
||
<Field label="玩家原始设定">
|
||
<TextArea
|
||
value={draft.settingText}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
settingText: value,
|
||
creatorIntent: current.creatorIntent
|
||
? {
|
||
...current.creatorIntent,
|
||
rawSettingText: value,
|
||
}
|
||
: current.creatorIntent,
|
||
}))
|
||
}
|
||
rows={4}
|
||
/>
|
||
</Field>
|
||
<SaveBar
|
||
onClose={onClose}
|
||
onSave={() => {
|
||
onSave(draft);
|
||
onClose();
|
||
}}
|
||
/>
|
||
</div>
|
||
</ModalShell>
|
||
);
|
||
}
|
||
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
|
||
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);
|
||
|
||
return (
|
||
<ModalShell
|
||
title="编辑基本设定"
|
||
onClose={onClose}
|
||
panelClassName="sm:max-w-4xl"
|
||
>
|
||
<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>
|
||
))}
|
||
<SaveBar
|
||
onClose={onClose}
|
||
onSave={() => {
|
||
onSave(applyFoundationDraftToProfile(profile, draft));
|
||
onClose();
|
||
}}
|
||
/>
|
||
</div>
|
||
</ModalShell>
|
||
);
|
||
}
|
||
|
||
export function CampSceneEditor({
|
||
profile,
|
||
onSaveProfile,
|
||
onClose,
|
||
}: {
|
||
profile: CustomWorldProfile;
|
||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<LandmarkEditor
|
||
profile={profile}
|
||
landmark={resolveCustomWorldCampScene(profile)}
|
||
mode="edit"
|
||
sceneKind="camp"
|
||
onSaveProfile={onSaveProfile}
|
||
onClose={onClose}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function PlayableNpcEditor({
|
||
profile,
|
||
npc,
|
||
mode,
|
||
onSave,
|
||
onClose,
|
||
}: {
|
||
profile: CustomWorldProfile;
|
||
npc: CustomWorldPlayableNpc;
|
||
mode: 'create' | 'edit';
|
||
onSave: (npc: CustomWorldPlayableNpc) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [draft, setDraft] = useDraft(npc);
|
||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
|
||
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
|
||
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||
const previewImageSrc = draft.imageSrc?.trim() ?? '';
|
||
const roleOptions = useMemo(
|
||
() =>
|
||
[...profile.playableNpcs, ...profile.storyNpcs]
|
||
.filter((role) => role.id !== draft.id)
|
||
.map((role) => ({
|
||
value: role.id,
|
||
label: [role.name, role.title || role.role].filter(Boolean).join(' / '),
|
||
})),
|
||
[draft.id, profile.playableNpcs, profile.storyNpcs],
|
||
);
|
||
const roleRelations =
|
||
draft.relations ??
|
||
draft.relationshipHooks.map((summary, index) => ({
|
||
id: createEntryId('relation', draft.id, index),
|
||
targetRoleId: '',
|
||
summary,
|
||
}));
|
||
|
||
const handleRequestClose = () => {
|
||
if (!hasUnsavedChanges) {
|
||
onClose();
|
||
return;
|
||
}
|
||
setIsCloseConfirmOpen(true);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<ModalShell
|
||
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
|
||
onClose={handleRequestClose}
|
||
disableClose={isAiAssetStudioOpen || isCloseConfirmOpen}
|
||
>
|
||
<div className="space-y-4">
|
||
{previewImageSrc ? (
|
||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
|
||
形象预览
|
||
</div>
|
||
<div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||
<div className="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>
|
||
) : null}
|
||
<Field label="名称">
|
||
<TextInput
|
||
value={draft.name}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, name: value }))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="头衔 / 世界身份">
|
||
<TextInput
|
||
value={draft.title || draft.role}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
title: value,
|
||
role: value,
|
||
}))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="简介">
|
||
<TextArea
|
||
value={draft.description}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, description: value }))
|
||
}
|
||
rows={3}
|
||
/>
|
||
</Field>
|
||
<Field label="背景">
|
||
<TextArea
|
||
value={draft.backstory}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, backstory: value }))
|
||
}
|
||
rows={5}
|
||
/>
|
||
</Field>
|
||
<Field label="性格">
|
||
<TextArea
|
||
value={draft.personality}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, personality: value }))
|
||
}
|
||
rows={3}
|
||
/>
|
||
</Field>
|
||
<Field label="当前动机">
|
||
<TextArea
|
||
value={draft.motivation}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, motivation: value }))
|
||
}
|
||
rows={3}
|
||
/>
|
||
</Field>
|
||
<Field label="初始好感">
|
||
<TextInput
|
||
type="number"
|
||
value={draft.initialAffinity}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
initialAffinity: clampInitialAffinity(
|
||
value,
|
||
current.initialAffinity,
|
||
),
|
||
}))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="标签">
|
||
<TextArea
|
||
value={commaText(draft.tags)}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
tags: parseCommaText(value),
|
||
}))
|
||
}
|
||
rows={2}
|
||
/>
|
||
</Field>
|
||
<BackstoryRevealEditor
|
||
value={draft.backstoryReveal}
|
||
onChange={(backstoryReveal) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
backstoryReveal,
|
||
}))
|
||
}
|
||
/>
|
||
<RoleRelationsEditor
|
||
value={roleRelations}
|
||
onChange={(relations) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
relations,
|
||
relationshipHooks: deriveRelationshipHooksFromRelations(relations),
|
||
}))
|
||
}
|
||
roleOptions={roleOptions}
|
||
labelSeed={draft.name || draft.id}
|
||
/>
|
||
<SkillListEditor
|
||
role={draft}
|
||
value={draft.skills}
|
||
onChange={(skills) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
skills,
|
||
}))
|
||
}
|
||
labelSeed={draft.name || draft.id}
|
||
/>
|
||
<InitialItemsEditor
|
||
value={draft.initialItems}
|
||
onChange={(initialItems) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
initialItems,
|
||
}))
|
||
}
|
||
labelSeed={draft.name || draft.id}
|
||
/>
|
||
<SaveBar
|
||
onClose={handleRequestClose}
|
||
onSave={() => {
|
||
onSave(draft);
|
||
onClose();
|
||
}}
|
||
showClose={false}
|
||
/>
|
||
{isAiAssetStudioOpen ? (
|
||
<RpgCreationRoleAssetStudioModal
|
||
role={draft}
|
||
roleKind="playable"
|
||
cacheScopeId={profile.id}
|
||
onApply={(nextRole) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
...nextRole,
|
||
}))
|
||
}
|
||
onClose={() => setIsAiAssetStudioOpen(false)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</ModalShell>
|
||
{isCloseConfirmOpen ? (
|
||
<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 [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
|
||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
|
||
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
|
||
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||
const roleOptions = useMemo(
|
||
() =>
|
||
[...profile.playableNpcs, ...profile.storyNpcs]
|
||
.filter((role) => role.id !== draft.id)
|
||
.map((role) => ({
|
||
value: role.id,
|
||
label: [role.name, role.title || role.role].filter(Boolean).join(' / '),
|
||
})),
|
||
[draft.id, profile.playableNpcs, profile.storyNpcs],
|
||
);
|
||
const roleRelations =
|
||
draft.relations ??
|
||
draft.relationshipHooks.map((summary, index) => ({
|
||
id: createEntryId('relation', draft.id, index),
|
||
targetRoleId: '',
|
||
summary,
|
||
}));
|
||
|
||
const handleRequestClose = () => {
|
||
if (!hasUnsavedChanges) {
|
||
onClose();
|
||
return;
|
||
}
|
||
setIsCloseConfirmOpen(true);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<ModalShell
|
||
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
|
||
onClose={handleRequestClose}
|
||
disableClose={
|
||
isVisualEditorOpen || isAiAssetStudioOpen || isCloseConfirmOpen
|
||
}
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
|
||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
|
||
形象预览
|
||
</div>
|
||
<div className="mt-3 grid gap-4 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||
<div className="flex justify-center">
|
||
<CustomWorldNpcPortrait
|
||
npc={draft}
|
||
visual={draft.visual}
|
||
className="aspect-square w-full max-w-[9.5rem]"
|
||
scale={2.05}
|
||
preferImageSrc
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 space-y-3">
|
||
<div className="flex flex-wrap gap-3">
|
||
<ActionButton
|
||
label="基于预设素材修改"
|
||
onClick={() => setIsVisualEditorOpen(true)}
|
||
tone="sky"
|
||
/>
|
||
<ActionButton
|
||
label="AI生成"
|
||
onClick={() => setIsAiAssetStudioOpen(true)}
|
||
tone="sky"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{draft.generatedVisualAssetId ? (
|
||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
|
||
已应用主图
|
||
</span>
|
||
) : null}
|
||
{draft.generatedAnimationSetId ? (
|
||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||
已应用动作
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Field label="名称">
|
||
<TextInput
|
||
value={draft.name}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, name: value }))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="头衔 / 世界身份">
|
||
<TextInput
|
||
value={draft.title || draft.role}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
title: value,
|
||
role: value,
|
||
}))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="描述">
|
||
<TextArea
|
||
value={draft.description}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, description: value }))
|
||
}
|
||
rows={4}
|
||
/>
|
||
</Field>
|
||
<Field label="背景">
|
||
<TextArea
|
||
value={draft.backstory}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, backstory: value }))
|
||
}
|
||
rows={4}
|
||
/>
|
||
</Field>
|
||
<Field label="性格">
|
||
<TextArea
|
||
value={draft.personality}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, personality: value }))
|
||
}
|
||
rows={3}
|
||
/>
|
||
</Field>
|
||
<Field label="动机">
|
||
<TextArea
|
||
value={draft.motivation}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({ ...current, motivation: value }))
|
||
}
|
||
rows={4}
|
||
/>
|
||
</Field>
|
||
<Field label="初始好感">
|
||
<TextInput
|
||
type="number"
|
||
value={draft.initialAffinity}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
initialAffinity: clampInitialAffinity(
|
||
value,
|
||
current.initialAffinity,
|
||
),
|
||
}))
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="标签">
|
||
<TextArea
|
||
value={commaText(draft.tags)}
|
||
onChange={(value) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
tags: parseCommaText(value),
|
||
}))
|
||
}
|
||
rows={2}
|
||
/>
|
||
</Field>
|
||
<BackstoryRevealEditor
|
||
value={draft.backstoryReveal}
|
||
onChange={(backstoryReveal) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
backstoryReveal,
|
||
}))
|
||
}
|
||
/>
|
||
<RoleRelationsEditor
|
||
value={roleRelations}
|
||
onChange={(relations) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
relations,
|
||
relationshipHooks: deriveRelationshipHooksFromRelations(relations),
|
||
}))
|
||
}
|
||
roleOptions={roleOptions}
|
||
labelSeed={draft.name || draft.id}
|
||
/>
|
||
<SkillListEditor
|
||
role={draft}
|
||
value={draft.skills}
|
||
onChange={(skills) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
skills,
|
||
}))
|
||
}
|
||
labelSeed={draft.name || draft.id}
|
||
/>
|
||
<InitialItemsEditor
|
||
value={draft.initialItems}
|
||
onChange={(initialItems) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
initialItems,
|
||
}))
|
||
}
|
||
labelSeed={draft.name || draft.id}
|
||
/>
|
||
<SaveBar
|
||
onClose={handleRequestClose}
|
||
onSave={() => {
|
||
onSave(draft);
|
||
onClose();
|
||
}}
|
||
showClose={false}
|
||
/>
|
||
{isVisualEditorOpen ? (
|
||
<StoryNpcVisualEditorModal
|
||
npc={draft}
|
||
visual={
|
||
draft.visual ??
|
||
buildDefaultCustomWorldNpcVisual({
|
||
id: draft.id,
|
||
name: draft.name,
|
||
role: draft.role,
|
||
description: draft.description,
|
||
})
|
||
}
|
||
onChange={(visual) =>
|
||
setDraft((current) => ({ ...current, visual }))
|
||
}
|
||
onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
|
||
onClose={() => setIsVisualEditorOpen(false)}
|
||
/>
|
||
) : null}
|
||
{isAiAssetStudioOpen ? (
|
||
<RpgCreationRoleAssetStudioModal
|
||
role={draft}
|
||
roleKind="story"
|
||
cacheScopeId={profile.id}
|
||
onApply={(nextRole) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
...nextRole,
|
||
}))
|
||
}
|
||
onClose={() => setIsAiAssetStudioOpen(false)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</ModalShell>
|
||
{isCloseConfirmOpen ? (
|
||
<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 updateSceneActSharedBackground = (imageSrc?: string | null) => {
|
||
const resolvedImageSrc = imageSrc?.trim() || compatibilityImageSrc || '';
|
||
updateSceneChapterDraft((current) => ({
|
||
...current,
|
||
acts: current.acts.map((act) => ({
|
||
...act,
|
||
backgroundImageSrc: resolvedImageSrc || undefined,
|
||
})),
|
||
}));
|
||
};
|
||
|
||
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}
|
||
>
|
||
<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={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={compatibilityImageSrc}
|
||
fallbackImageSrc={compatibilityImageSrc || resolvedDraftImageSrc}
|
||
onApply={updateSceneActSharedBackground}
|
||
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;
|