import { X } from 'lucide-react'; import type { ChangeEvent } from 'react'; import type { CSSProperties } from 'react'; import { Children, type ReactNode, useEffect, useMemo, useRef, useState, } from 'react'; import { createPortal } from 'react-dom'; import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels'; import { buildCustomWorldPlayableCharacters, ROLE_TEMPLATE_CHARACTERS, } from '../../data/characterPresets'; import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph'; import { getAllCustomWorldSceneImages, resolveCustomWorldCampSceneImage, resolveCustomWorldLandmarkImage, } from '../../data/customWorldVisuals'; import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews'; import { buildEncounterFromSceneNpc } from '../../data/scenePresets'; import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient'; import { fetchJson } from '../../editor/shared/jsonClient'; import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story/useRpgRuntimeStory'; import { useRpgSessionBootstrap } from '../../hooks/rpg-session/useRpgSessionBootstrap'; import { useCombatFlow } from '../../hooks/useCombatFlow'; import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow'; import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts'; import type { CustomWorldSceneImageResult } from '../../services/aiTypes'; import { resolveCustomWorldCampScene } from '../../services/customWorldCamp'; import { buildDefaultCustomWorldCoverProfile, resolveCustomWorldCoverPresentation, } from '../../services/customWorldCover'; import { type CustomWorldCoverAssetResult, generateCustomWorldCoverImage, uploadCustomWorldCoverImage, } from '../../services/customWorldCoverAssetService'; import { createEmptyCustomWorldCreatorIntent } from '../../services/customWorldCreatorIntent'; import { type CustomWorldFoundationEntryId, getCustomWorldFoundationAnchorContent, parseFoundationTagText, } from '../../services/customWorldFoundationEntries'; import { rpgCreationAssetClient, type RpgCreationHistoryAsset, type RpgCreationHistoryAssetKind, } from '../../services/rpg-creation/rpgCreationAssetClient'; import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine'; import { AnimationState, type Character, type CharacterAnimationConfig, type CustomWorldCoverCropRect, type CustomWorldCoverProfile, CustomWorldLandmark, CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, type CustomWorldRoleInitialItem, type CustomWorldRoleRelation, type CustomWorldRoleSkill, CustomWorldSceneConnection, type ItemRarity, type SceneActAdvanceRule, type SceneActBlueprint, type SceneActStage, type SceneChapterBlueprint, type SceneNpc, type ScenePresetInfo, WorldType, } from '../../types'; import { type CharacterAnimationGenerationPayload, generateCharacterAnimationDraft, publishCharacterAnimationAssets, } from '../asset-studio/characterAssetWorkflowPersistence'; import { useAuthUi } from '../auth/AuthUiContext'; import { CharacterAnimator } from '../CharacterAnimator'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { CustomWorldNpcPortrait } from '../CustomWorldNpcVisualEditor'; import { RoleCharacterSprite, SceneEncounterNpcSprite, } from '../game-canvas/GameCanvasShared'; import { PixelIcon } from '../PixelIcon'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal'; import { RpgRuntimeShell } from '../rpg-runtime-shell'; import { resolveEditableLandmark, resolveEditablePlayableNpc, resolveEditableStoryNpc, } from './rpgCreationResultFormMapper'; export type RpgCreationEditorTarget = | { kind: 'world' } | { kind: 'foundation' } | { kind: 'cover' } | { kind: 'camp' } | { kind: 'playable'; mode: 'create' } | { kind: 'playable'; mode: 'edit'; id: string } | { kind: 'story'; mode: 'create' } | { kind: 'story'; mode: 'edit'; id: string } | { kind: 'landmark'; mode: 'create' } | { kind: 'landmark'; mode: 'edit'; id: string }; export interface RpgCreationEntityEditorModalProps { profile: CustomWorldProfile; target: RpgCreationEditorTarget | null; onClose: () => void; onProfileChange: (profile: CustomWorldProfile) => void; } function getAnimationPreviewFrameStyle( _config: CharacterAnimationConfig | null | undefined, targetSize: number, ) { return { width: `${targetSize}px`, height: `${targetSize}px`, maxWidth: '100%', maxHeight: '100%', } satisfies CSSProperties; } const [ , , , BACKSTORY_UNLOCK_AFFINITY_CLOSE, ] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS; const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [ { value: 'common', label: '普通' }, { value: 'uncommon', label: '优秀' }, { value: 'rare', label: '稀有' }, { value: 'epic', label: '史诗' }, { value: 'legendary', label: '传说' }, ]; const ITEM_RARITY_LABELS: Record = { 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 = { 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) { return [ ...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)), ]; } function moveArrayItem(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; } function compactSceneActEncounterSlotIds( slotIds: Array, ) { return dedupeTextValues(slotIds).slice(0, SCENE_ACT_ROLE_SLOT_COUNT); } function assignSceneActEncounterSlotId( encounterNpcIds: string[], slotIndex: number, nextNpcId: string | null, ) { const nextSlots = buildSceneActEncounterSlotIds(encounterNpcIds); const normalizedNpcId = nextNpcId?.trim() || null; if (slotIndex < 0 || slotIndex >= nextSlots.length) { return compactSceneActEncounterSlotIds(nextSlots); } if (!normalizedNpcId) { nextSlots.splice(slotIndex, 1); nextSlots.push(null); return compactSceneActEncounterSlotIds(nextSlots); } return compactSceneActEncounterSlotIds( nextSlots.map((slotNpcId, index) => { if (index === slotIndex) { return normalizedNpcId; } return slotNpcId === normalizedNpcId ? null : slotNpcId; }), ); } function buildSceneActStageCoverage( index: number, actCount: number, ): SceneActStage[] { if (actCount <= 2) { return index === 0 ? ['opening', 'expansion'] : ['turning_point', 'climax', 'aftermath']; } if (actCount === 3) { if (index === 0) return ['opening']; if (index === 1) return ['expansion', 'turning_point']; return ['climax', 'aftermath']; } if (actCount === 4) { if (index === 0) return ['opening']; if (index === 1) return ['expansion']; if (index === 2) return ['turning_point']; return ['climax', 'aftermath']; } if (index === 0) return ['opening']; if (index === 1) return ['expansion']; if (index === 2) return ['turning_point']; if (index === 3) return ['climax']; return ['aftermath']; } function buildSceneActAdvanceRule( index: number, actCount: number, ): SceneActAdvanceRule { if (index === 0) { return 'after_primary_contact'; } if (index >= actCount - 1) { return 'after_chapter_resolution'; } return 'after_active_step_complete'; } function buildDefaultSceneActTitle(index: number) { return `第${index + 1}幕`; } function buildDefaultSceneActBlueprint(params: { sceneId: string; sceneName: string; sceneSummary: string; encounterNpcIds: string[]; backgroundImageSrc?: string | null; linkedThreadIds?: string[]; index: number; actCount: number; }): SceneActBlueprint { const encounterNpcIds = dedupeTextValues(params.encounterNpcIds).slice(0, 1); const actTitle = buildDefaultSceneActTitle(params.index); const sceneLabel = params.sceneName.trim() || '当前场景'; const sceneSummary = params.sceneSummary.trim(); const oppositeNpcId = encounterNpcIds[0] ?? ''; const stageCoverage = buildSceneActStageCoverage( params.index, params.actCount, ); const actSummary = params.index === 0 ? `玩家会在${sceneLabel}接住这一章的开场入口。` : params.index >= params.actCount - 1 ? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。` : `${sceneLabel}的主要压力会在这一幕继续加深。`; return { id: `${params.sceneId}-act-${params.index + 1}`, sceneId: params.sceneId, title: actTitle, summary: actSummary, stageCoverage, // 幕背景画面描述应来自草稿生成阶段的大模型输出,前端缺失时只留空,避免展示规则拼接文本。 backgroundPromptText: '', backgroundImageSrc: params.backgroundImageSrc || undefined, encounterNpcIds, primaryNpcId: oppositeNpcId, linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []), advanceRule: buildSceneActAdvanceRule(params.index, params.actCount), oppositeNpcId, actGoal: params.index === 0 ? `先在${sceneLabel}接住当前局面` : params.index >= params.actCount - 1 ? `把${sceneLabel}这一章收束并抛出下一步` : `继续推进${sceneLabel}的核心矛盾`, eventDescription: buildDefaultSceneActEventDescription({ sceneSummary, oppositeNpcId, index: params.index, }), transitionHook: params.index === 0 ? '和主角色完成首次有效接触后,局势会继续加压。' : params.index >= params.actCount - 1 ? '这一幕结束后,需要把后续方向明确抛给玩家。' : '完成当前主动推进后,这一幕会转向下一层压力。', }; } function buildDefaultSceneActEventDescription(params: { sceneSummary: string; oppositeNpcId: string; index: number; }) { const roleText = params.oppositeNpcId.trim() || '当前场景关键角色'; const sceneText = params.sceneSummary.trim() || '场景内的主线压力'; if (params.index === 0) { return `第1幕中,${roleText}先露出与${sceneText}有关的异常线索,玩家必须确认局势入口。`; } if (params.index === 1) { return `第2幕中,${roleText}的立场或阻碍让${sceneText}升级,玩家必须在压力下作出判断。`; } return `第3幕中,${roleText}把${sceneText}推向高潮,玩家必须面对关键抉择或直接后果。`; } function buildDefaultSceneActBackgroundPrompt(params: { sceneSummary: string; oppositeNpcId: string; eventDescription: string; index: number; }) { const roleText = params.oppositeNpcId.trim() || '当前场景关键角色'; const sceneText = params.sceneSummary.trim() || '场景内的主线压力'; const phaseText = params.index === 0 ? '铺垫阶段' : params.index === 1 ? '冲突升级阶段' : '高潮阶段'; return `${sceneText}的${phaseText}画面,${roleText}与玩家隔着可站立空间形成对峙,环境里保留“${params.eventDescription}”的冲突痕迹与清晰氛围。`; } function buildDefaultSceneTaskDescription(landmark: CustomWorldLandmark) { const sceneName = landmark.name.trim() || '当前场景'; const sceneDescription = landmark.description.trim(); if (!sceneDescription) { return `首次进入${sceneName}时,确认当前场景的核心异常、关键角色与下一步行动方向。`; } return `首次进入${sceneName}时,围绕${sceneDescription}确认核心任务、关键角色与下一步行动。`; } function buildDefaultSceneChapterBlueprint(params: { landmark: CustomWorldLandmark; fallbackImageSrc?: string | null; chapterId?: string; chapterTitle?: string; chapterSummary?: string; linkedThreadIds?: string[]; linkedLandmarkIds?: string[]; actCount?: number; }) { const actCount = Math.min( MAX_SCENE_ACT_COUNT, Math.max(MIN_SCENE_ACT_COUNT, params.actCount ?? DEFAULT_SCENE_ACT_COUNT), ); return { id: params.chapterId?.trim() || `scene-chapter-${params.landmark.id}`, sceneId: params.landmark.id, title: params.chapterTitle?.trim() || params.landmark.name.trim() || '场景章节', summary: params.chapterSummary?.trim() || params.landmark.description.trim(), sceneTaskDescription: buildDefaultSceneTaskDescription(params.landmark), linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []), linkedLandmarkIds: dedupeTextValues([ params.landmark.id, ...(params.linkedLandmarkIds ?? []), ]), acts: Array.from({ length: actCount }, (_unused, index) => buildDefaultSceneActBlueprint({ sceneId: params.landmark.id, sceneName: params.landmark.name, sceneSummary: params.landmark.description, encounterNpcIds: params.landmark.sceneNpcIds, backgroundImageSrc: params.fallbackImageSrc, linkedThreadIds: params.linkedThreadIds, index, actCount, }), ), } satisfies SceneChapterBlueprint; } function sanitizeSceneChapterBlueprint(params: { chapter: SceneChapterBlueprint | null | undefined; landmark: CustomWorldLandmark; fallbackImageSrc?: string | null; }) { const fallbackChapter = buildDefaultSceneChapterBlueprint({ landmark: params.landmark, fallbackImageSrc: params.fallbackImageSrc, chapterId: params.chapter?.id, chapterTitle: params.chapter?.title, chapterSummary: params.chapter?.summary, linkedThreadIds: params.chapter?.linkedThreadIds, linkedLandmarkIds: params.chapter?.linkedLandmarkIds, actCount: params.chapter?.acts.length, }); const rawActs = params.chapter?.acts ?? []; const chapterEncounterNpcIds = dedupeTextValues( rawActs.flatMap((act) => act.encounterNpcIds), ); const availableSceneNpcIds = dedupeTextValues([ ...chapterEncounterNpcIds, ...params.landmark.sceneNpcIds, ]); const availableSceneNpcIdSet = new Set(availableSceneNpcIds); const targetActCount = Math.min( MAX_SCENE_ACT_COUNT, Math.max( MIN_SCENE_ACT_COUNT, rawActs.length > 0 ? rawActs.length : fallbackChapter.acts.length, ), ); const acts = Array.from({ length: targetActCount }, (_unused, index) => { const fallbackAct = buildDefaultSceneActBlueprint({ sceneId: params.landmark.id, sceneName: params.landmark.name, sceneSummary: params.landmark.description, encounterNpcIds: availableSceneNpcIds, backgroundImageSrc: params.fallbackImageSrc, linkedThreadIds: rawActs[index]?.linkedThreadIds ?? fallbackChapter.linkedThreadIds, index, actCount: targetActCount, }); const currentAct = rawActs[index]; const candidateNpcIds = dedupeTextValues(currentAct?.encounterNpcIds ?? []); const encounterNpcIds = availableSceneNpcIdSet.size > 0 ? candidateNpcIds.filter((npcId) => availableSceneNpcIdSet.has(npcId)) : candidateNpcIds; // 中文注释:已有幕只信任本幕保存的槽位;只有缺少整份幕蓝图的旧草稿才从场景角色里兜底,避免配置第一幕时把角色串到其他幕。 const resolvedEncounterNpcIds = encounterNpcIds.length > 0 ? encounterNpcIds : currentAct ? [] : fallbackAct.encounterNpcIds; const primaryNpcId = resolvedEncounterNpcIds[0] ?? ''; const oppositeNpcId = currentAct?.oppositeNpcId?.trim() || primaryNpcId; const eventDescription = currentAct?.eventDescription?.trim() || buildDefaultSceneActEventDescription({ sceneSummary: params.landmark.description, oppositeNpcId, index, }); const backgroundPromptText = currentAct?.backgroundPromptText?.trim() || buildDefaultSceneActBackgroundPrompt({ sceneSummary: params.landmark.description, oppositeNpcId, eventDescription, index, }); return { ...fallbackAct, id: currentAct?.id?.trim() || fallbackAct.id, title: currentAct?.title?.trim() || fallbackAct.title, summary: currentAct?.summary?.trim() || fallbackAct.summary, stageCoverage: buildSceneActStageCoverage(index, targetActCount), backgroundPromptText, backgroundImageSrc: currentAct?.backgroundImageSrc?.trim() || params.fallbackImageSrc || fallbackAct.backgroundImageSrc, encounterNpcIds: resolvedEncounterNpcIds, primaryNpcId, oppositeNpcId, eventDescription, linkedThreadIds: dedupeTextValues(currentAct?.linkedThreadIds ?? []), advanceRule: buildSceneActAdvanceRule(index, targetActCount), actGoal: currentAct?.actGoal?.trim() || fallbackAct.actGoal, transitionHook: currentAct?.transitionHook?.trim() || fallbackAct.transitionHook, backgroundAssetId: currentAct?.backgroundAssetId?.trim() || fallbackAct.backgroundAssetId, } satisfies SceneActBlueprint; }); return { ...fallbackChapter, id: params.chapter?.id?.trim() || fallbackChapter.id, title: params.chapter?.title?.trim() || fallbackChapter.title, summary: params.chapter?.summary?.trim() || fallbackChapter.summary, sceneTaskDescription: (() => { const currentTask = params.chapter?.sceneTaskDescription?.trim() ?? ''; const sceneDescription = params.landmark.description.trim(); return currentTask && currentTask !== sceneDescription ? currentTask : fallbackChapter.sceneTaskDescription; })(), linkedThreadIds: dedupeTextValues(params.chapter?.linkedThreadIds ?? []), linkedLandmarkIds: dedupeTextValues([ params.landmark.id, ...(params.chapter?.linkedLandmarkIds ?? []), ]), acts, } satisfies SceneChapterBlueprint; } function collectSceneChapterEncounterNpcIds(chapter: SceneChapterBlueprint) { return dedupeTextValues(chapter.acts.flatMap((act) => act.encounterNpcIds)); } function resolveSceneCompatibilityNpcIds(params: { chapter: SceneChapterBlueprint; currentNpcIds: string[]; }) { const chapterNpcIds = collectSceneChapterEncounterNpcIds(params.chapter); return dedupeTextValues([...chapterNpcIds, ...params.currentNpcIds]); } function resolveSceneCompatibilityImageSrc(params: { chapter: SceneChapterBlueprint; currentImageSrc?: string | null; resolvedImageSrc?: string | null; }) { const currentImageSrc = params.currentImageSrc?.trim() || ''; const resolvedImageSrc = params.resolvedImageSrc?.trim() || ''; const firstActImageSrc = params.chapter.acts[0]?.backgroundImageSrc?.trim() || ''; // 中文注释:场景卡片只读取当前幕已保存图片;场景主图只给没有幕图的旧草稿兜底,不能反向覆盖每一幕。 return firstActImageSrc || currentImageSrc || resolvedImageSrc || undefined; } function resolveSceneChapterBlueprintDraft(params: { profile: CustomWorldProfile; landmark: CustomWorldLandmark; fallbackImageSrc?: string | null; }) { const matchedChapter = params.profile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === params.landmark.id || entry.linkedLandmarkIds.includes(params.landmark.id), ) ?? null; return sanitizeSceneChapterBlueprint({ chapter: matchedChapter, landmark: params.landmark, fallbackImageSrc: params.fallbackImageSrc, }); } function upsertSceneChapterBlueprint( chapters: CustomWorldProfile['sceneChapterBlueprints'], nextChapter: SceneChapterBlueprint, ) { const nextChapters: SceneChapterBlueprint[] = []; let hasReplaced = false; (chapters ?? []).forEach((chapter) => { const isSameScene = chapter.id === nextChapter.id || chapter.sceneId === nextChapter.sceneId || chapter.linkedLandmarkIds.includes(nextChapter.sceneId); if (isSameScene) { if (!hasReplaced) { nextChapters.push(nextChapter); hasReplaced = true; } return; } nextChapters.push(chapter); }); if (!hasReplaced) { nextChapters.push(nextChapter); } return nextChapters; } function normalizeConnectionDirection( value: CustomWorldSceneConnection['relativePosition'], ): CardinalConnectionDirection | null { switch (value) { case 'north': case 'forward': return 'north'; case 'east': case 'right': return 'east'; case 'south': case 'back': return 'south'; case 'west': case 'left': return 'west'; default: return null; } } function buildConnectionSummary( direction: CardinalConnectionDirection, targetName?: string, ) { if (!targetName) { return ''; } return `${CARDINAL_CONNECTION_LABELS[direction]}侧可前往${targetName}`; } function buildDirectionalConnections( connections: CustomWorldSceneConnection[], landmarks: Array>, ) { 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, ) { 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(); [...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(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((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 (
{ if (event.target === event.currentTarget) { onClose(); } } } >
event.stopPropagation()} >
{title}
{subtitle ? (
{subtitle}
) : null}
{children}
); } 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(, 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 (
{ if (event.target === event.currentTarget) { onClose(); } } } >
event.stopPropagation()} >
{title}
{children}
); } 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(, document.body); } // 中文注释:关闭确认弹窗使用语义类承接亮暗主题,避免在浅色面板中沿用深色 Tailwind 色值。 function CloseConfirmDialog({ message, onCancel, onConfirm, confirmLabel = '确认关闭', }: { message: string; onCancel: () => void; onConfirm: () => void; confirmLabel?: string; }) { return (
{message}
); } 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 ( ); } function LabelWithInfo({ label, info }: { label: string; info: string }) { const [open, setOpen] = useState(false); return ( {label} {open ? ( {info} ) : null} ); } function TextInput({ value, onChange, type = 'text', placeholder, }: { value: string | number; onChange: (value: string) => void; type?: 'text' | 'number'; placeholder?: string; }) { return ( 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 (