From fcd8d727b0ffb897113097b7a3453ee9638d2174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sun, 5 Apr 2026 22:20:30 +0800 Subject: [PATCH] Split custom world generation into staged lightweight batches --- .env.example | 2 +- AGENTS.md | 1 + src/components/AdventureEntityModal.tsx | 753 ++++---- src/components/AdventurePanel.tsx | 8 +- src/components/AffinityStatusCard.tsx | 86 +- src/components/BackstoryArchive.tsx | 96 ++ src/components/CharacterDetailModal.tsx | 10 +- src/components/CharacterPanel.tsx | 717 +++++--- src/components/CustomWorldEntityCatalog.tsx | 229 ++- .../CustomWorldEntityEditorModal.tsx | 522 +++++- src/components/GameShell.tsx | 3 +- src/components/MapModal.tsx | 177 +- src/components/NpcModals.tsx | 15 + src/components/SkillEffectPreview.tsx | 3 + src/components/StateFunctionEditor.tsx | 6 +- .../game-canvas/GameCanvasRuntime.tsx | 5 +- .../game-shell/GameShellCanvasStage.tsx | 3 +- .../game-shell/PreGameSelectionFlow.tsx | 2 +- src/data/affinityLevels.ts | 130 ++ src/data/attributeCombat.ts | 108 ++ src/data/attributeResolver.ts | 7 +- src/data/buildDamage.test.ts | 277 ++- src/data/buildDamage.ts | 567 ++++-- src/data/characterPresets.ts | 142 +- src/data/customWorldCharacterLoadout.ts | 118 +- src/data/customWorldLibrary.ts | 357 +++- src/data/customWorldSceneGraph.ts | 422 +++++ src/data/equipmentEffects.ts | 11 +- src/data/functionCatalog/npc/npcGift.ts | 8 + src/data/functionCatalog/npc/npcRecruit.ts | 8 + src/data/functionCatalog/npc/npcTrade.ts | 8 + src/data/hostileNpcs.ts | 11 +- src/data/npcInteractions.ts | 87 +- src/data/questFlow.ts | 10 +- src/data/sceneEncounterPreviews.ts | 7 +- src/data/scenePresets.ts | 200 ++- src/data/storyRecovery.ts | 59 + src/hooks/combat/battlePlan.test.ts | 48 + src/hooks/combat/battlePlan.ts | 121 +- src/hooks/story/choiceActions.test.ts | 322 ++++ src/hooks/story/choiceActions.ts | 87 +- src/hooks/story/npcEncounterActions.ts | 181 +- src/hooks/story/npcInteraction.ts | 241 ++- src/hooks/story/progressionActions.ts | 5 + src/hooks/story/uiTypes.ts | 3 + src/hooks/useGameFlow.ts | 16 +- src/hooks/useGamePersistence.ts | 13 +- src/hooks/useStoryGeneration.ts | 8 +- src/services/ai.test.ts | 355 +++- src/services/ai.ts | 561 +++++- src/services/customWorld.test.ts | 227 +++ src/services/customWorld.ts | 1519 +++++++++++++++-- src/services/customWorldBuilder.ts | 120 +- src/services/llmClient.ts | 15 +- src/services/runtimeItemAiDirector.ts | 2 +- src/types/customWorld.ts | 44 + src/types/scene.ts | 8 + 57 files changed, 7646 insertions(+), 1425 deletions(-) create mode 100644 src/components/BackstoryArchive.tsx create mode 100644 src/data/affinityLevels.ts create mode 100644 src/data/attributeCombat.ts create mode 100644 src/data/customWorldSceneGraph.ts create mode 100644 src/data/storyRecovery.ts create mode 100644 src/hooks/story/choiceActions.test.ts create mode 100644 src/services/customWorld.test.ts diff --git a/.env.example b/.env.example index 8f6f4755..7a3d0d2e 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,7 @@ DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS="150000" VITE_LLM_REQUEST_TIMEOUT_MS="15000" # Optional: longer timeout for custom world generation, in milliseconds. -VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS="45000" +VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS="120000" # Optional: timeout for custom-world scene image generation, in milliseconds. VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS="150000" diff --git a/AGENTS.md b/AGENTS.md index 14dc768c..4374269f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,3 +8,4 @@ - 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。 - 非必要不要整文件重写,尤其是包含中文的文件;优先做局部补丁,避免把未改动的中文内容重新编码。 - 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。 +- UI面板中不要默认写一些规则描述文案,清爽一些,按照游戏UI设计规范设计即可。 diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index e0f3f361..05c7fa47 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -3,6 +3,10 @@ import { AnimatePresence, motion } from 'motion/react'; import type { CSSProperties, ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; +import { + AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS, + DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, +} from '../data/affinityLevels'; import { buildRelationState, formatAttributeList, @@ -11,28 +15,39 @@ import { } from '../data/attributeResolver'; import { type BuildDamageBreakdown, - describeBuildContribution, + formatBuildContributionPercent, getBuildContributionAttributeRows, - getBuildSourceLabel, + getBuildContributionQualityLabel, + getBuildContributionQualityRatio, getCompanionBuildDamageBreakdown, getPlayerBuildDamageBreakdown, + resolveMonsterOutgoingDamage, } from '../data/buildDamage'; import { getCharacterById, + getCharacterMaxHp, getCharacterMaxMana, getCharacterPrivateChatUnlockAffinity, + getCharacterPublicBackstorySummary, getInventoryItems, + getLockedCharacterBackstoryChapters, + getUnlockedCharacterBackstoryChapters, } from '../data/characterPresets'; -import { getHostileNpcPresetById } from '../data/hostileNpcPresets'; +import { + getHostileNpcPresetById, + getMonsterPresetsByWorld, +} from '../data/hostileNpcPresets'; import { buildEncounterAttributeRumors, resolveEncounterAttributeProfile, } from '../data/npcAttributeInsights'; import { buildInitialNpcState, + createNpcBattleMonster, normalizeNpcPersistentState, } from '../data/npcInteractions'; import type { CharacterChatTarget } from '../hooks/useStoryGeneration'; +import { getResourceLabelsForWorld } from '../services/customWorldPresentation'; import { AnimationState, type Character, @@ -40,11 +55,16 @@ import { type GameState, type InventoryItem, type NpcPersistentState, - type WorldAttributeSchema, } from '../types'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { AffinityStatusCard } from './AffinityStatusCard'; +import { + BackstoryArchive, + type BackstoryLockedChapter, + type BackstoryUnlockedChapter, +} from './BackstoryArchive'; import { CharacterAnimator } from './CharacterAnimator'; +import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared'; import type { GameCanvasEntitySelection } from './GameCanvas'; import { HostileNpcAnimator } from './HostileNpcAnimator'; import { @@ -52,6 +72,7 @@ import { InventoryItemGrid, } from './InventoryItemViews'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; +import { SkillEffectPreview } from './SkillEffectPreview'; interface AdventureEntityModalProps { selection: GameCanvasEntitySelection | null; @@ -60,15 +81,29 @@ interface AdventureEntityModalProps { onOpenCharacterChat?: (target: CharacterChatTarget) => void; } -function estimateCharacterMaxHp(character: Character) { - return Math.max( - 120, - 90 + character.attributes.strength * 10 + character.attributes.spirit * 4, - ); +const [ + BACKSTORY_UNLOCK_AFFINITY_EASED = 15, + BACKSTORY_UNLOCK_AFFINITY_FRIENDLY = 30, + BACKSTORY_UNLOCK_AFFINITY_TRUSTED = 60, + BACKSTORY_UNLOCK_AFFINITY_CLOSE = 90, +] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS; + +function estimateCharacterMaxHp( + character: Character, + worldType: GameState['worldType'], + customWorldProfile: GameState['customWorldProfile'], +) { + return getCharacterMaxHp(character, worldType, customWorldProfile); } -function estimateNpcMaxHp(character: Character | null) { - return character ? estimateCharacterMaxHp(character) : 120; +function estimateNpcMaxHp( + character: Character | null, + worldType: GameState['worldType'], + customWorldProfile: GameState['customWorldProfile'], +) { + return character + ? estimateCharacterMaxHp(character, worldType, customWorldProfile) + : 120; } function estimateNpcMaxMana(character: Character | null) { @@ -121,10 +156,6 @@ function Section({ title, children }: { title: string; children: ReactNode }) { ); } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - const SKILL_STYLE_LABELS = { burst: '爆发', steady: '稳态', @@ -145,24 +176,25 @@ function getSkillStyleLabel(skill: Character['skills'][number]) { return SKILL_STYLE_LABELS[skill.style]; } -function getContributionHeatRatio(value: number, minValue = 0, maxValue = 1) { - const normalizedMin = Number.isFinite(minValue) ? minValue : 0; - const normalizedMax = Number.isFinite(maxValue) ? maxValue : 1; - const range = normalizedMax - normalizedMin; - - if (range <= 0.0001) { - return normalizedMax > 0 ? 1 : 0; +function resolveSkillPreviewMonsterId(gameState: GameState) { + if (!gameState.worldType) { + return null; } - return clamp((value - normalizedMin) / range, 0, 1); + const sceneMonsterId = gameState.currentScenePreset?.monsterIds?.[0] ?? null; + if (sceneMonsterId) { + return sceneMonsterId; + } + + return getMonsterPresetsByWorld(gameState.worldType)[0]?.id ?? null; } -function getContributionVisualStyle( - value: number, - minValue = 0, - maxValue = 1, -): CSSProperties { - const ratio = getContributionHeatRatio(value, minValue, maxValue); +function getContributionHeatRatio(value: number) { + return getBuildContributionQualityRatio(value); +} + +function getContributionVisualStyle(value: number): CSSProperties { + const ratio = getContributionHeatRatio(value); const hue = 210 - ratio * 178; const saturation = 62 + ratio * 16; const lightness = 56 + ratio * 6; @@ -180,28 +212,11 @@ function getContributionVisualStyle( }; } -function getContributionTrackStyle( - value: number, - minValue = 0, - maxValue = 1, -): CSSProperties { - const ratio = getContributionHeatRatio(value, minValue, maxValue); - const widthRatio = 0.18 + ratio * 0.82; - const hue = 210 - ratio * 178; - - return { - width: `${widthRatio * 100}%`, - background: `linear-gradient(90deg, hsla(${hue}, ${70 + ratio * 14}%, ${56 + ratio * 10}%, 0.94) 0%, rgba(255, 229, 214, 0.98) 100%)`, - }; -} - function MultiplierContributionList({ breakdown, - schema, onSelectContribution, }: { breakdown: BuildDamageBreakdown; - schema: WorldAttributeSchema; onSelectContribution: (row: ContributionRow) => void; }) { const sortedRows = [...breakdown.rows].sort( @@ -209,30 +224,12 @@ function MultiplierContributionList({ right.bonusDelta - left.bonusDelta || left.label.localeCompare(right.label, 'zh-CN'), ); - const contributionProducts = sortedRows.map((row) => row.bonusDelta); - const weakestProduct = - contributionProducts.length > 0 ? Math.min(...contributionProducts) : 0; - const strongestProduct = - contributionProducts.length > 0 ? Math.max(...contributionProducts) : 1; return (
状态标签 - 点击标签查看收益来自哪些属性 -
-
-
-
- 属性适配倍率 -
-
- x{breakdown.buildDamageMultiplier.toFixed(2)} -
-
- 总加成 +{breakdown.buildDamageBonus.toFixed(2)} -
-
+ 点击标签查看具体属性加成
{sortedRows.length > 0 ? (
@@ -242,32 +239,17 @@ function MultiplierContributionList({ type="button" onClick={() => onSelectContribution(row)} className="min-w-[6.25rem] rounded-xl border px-3 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5" - style={getContributionVisualStyle( - row.bonusDelta, - weakestProduct, - strongestProduct, - )} - title={`查看 ${row.label} 的属性适配明细`} + style={getContributionVisualStyle(row.bonusDelta)} + title={`查看 ${row.label} 的标签效果`} >
{row.label} - - +{row.bonusDelta.toFixed(2)} + + {getBuildContributionQualityLabel(row.bonusDelta)}
- {getBuildSourceLabel(row.source)} ·{' '} - {describeBuildContribution(row, schema)} -
-
-
+ 总加成 {formatBuildContributionPercent(row.bonusDelta)}
))} @@ -282,19 +264,19 @@ function MultiplierContributionList({ } function CharacterSkills({ - character, + skills, onSelectSkill, }: { - character: Character; + skills: Character['skills']; onSelectSkill: (skillId: string) => void; }) { - if (character.skills.length === 0) { + if (skills.length === 0) { return
暂无技能信息
; } return (
- {character.skills.map((skill) => ( + {skills.map((skill) => (
@@ -866,55 +969,33 @@ export function AdventureEntityModal({
- {selection.kind === 'player' && playerCharacter ? ( + {archiveCharacter && archiveNpcState ? (
-
- 你当前正以这名角色的身份推进故事与冒险,这里显示的是主视角角色在队伍中的关系位置与当前状态。 -
-
-
-
- 当前身份 -
-
- 主角 -
-
-
-
- 队伍定位 -
-
- 队伍领队 -
-
-
-
- 当前阶段 -
-
- {gameState.inBattle ? '战斗中' : '探索中'} -
-
-
-
- 同行人数 -
-
- {gameState.companions.length} -
-
-
+ +
- ) : archiveCharacter && archiveNpcState ? ( -
- -
) : selection.kind === 'npc' && npcState ? (
- +
+ + {genericNpcArchive ? ( + + ) : null} +
) : null} @@ -929,7 +1010,7 @@ export function AdventureEntityModal({
{privateChatUnlocked ? '已解锁,可直接与该同伴单独交谈。' - : `好感达到 ${privateChatUnlockAffinity ?? 70} 后解锁,当前 ${companionNpcState?.affinity ?? 0}。`} + : `好感达到 ${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 后解锁,当前 ${companionNpcState?.affinity ?? 0}。`}
@@ -966,14 +1047,14 @@ export function AdventureEntityModal({
{maxMana > 0 ? ( setSelectedContributionLabel(row.label) } @@ -996,10 +1076,10 @@ export function AdventureEntityModal({ key={slot.slotId} className="rounded-xl border border-white/8 bg-black/25 px-3 py-2" > -
+
{slot.name}
-
+
{value}
@@ -1019,32 +1099,16 @@ export function AdventureEntityModal({ {detailCharacter ? (
- ) : genericNpcTechniqueCards.length > 0 ? ( + ) : displayedSkills.length > 0 ? (
-
- {genericNpcPersonality ? ( -
- {genericNpcPersonality} -
- ) : null} - {genericNpcTechniqueCards.map((card) => ( -
-
- {card.title} -
-
- {card.detail} -
-
- ))} -
+
) : null} @@ -1060,31 +1124,6 @@ export function AdventureEntityModal({
暂无物品
)} - - {selection.kind === 'npc' && npcEncounter ? ( -
-
-
名称: {npcEncounter.npcName}
-
背景: {npcEncounter.context}
-
- 类型:{' '} - {getNpcBadge( - npcEncounter, - npcState?.affinity ?? 0, - Boolean(npcBattleState), - )} -
- {npcBattleState ? ( -
- 战斗模式:{' '} - {npcBattleState.combatMode === 'melee' - ? '近战' - : '远程'} -
- ) : null} -
-
- ) : null}
@@ -1112,7 +1151,7 @@ export function AdventureEntityModal({
- 属性适配解析 + 标签效果
{selectedContributionRow.label} @@ -1130,111 +1169,81 @@ export function AdventureEntityModal({
-
-
-
-
-
- {selectedContributionRow.label} -
-
- {getBuildSourceLabel(selectedContributionRow.source)} ·{' '} - {describeBuildContribution( - selectedContributionRow, - attributeSchema, - )} -
-
-
-
- 加成 +{selectedContributionRow.bonusDelta.toFixed(2)} -
-
- 适配度{' '} - {Math.round(selectedContributionRow.fitScore * 100)}% -
-
-
-
+
+
+
-
-
- -
-
- bonusDelta = 各属性加成之和 -
-
- 每个标签会分别匹配当前世界的属性轴,再和角色自身的属性权重逐项相乘。每条属性先生成单独的加成,最后汇总成这个标签的收益。 -
-
- {selectedContributionRow.label} = 0.12 x 适配度{' '} - {selectedContributionRow.fitScore.toFixed(2)} x 来源系数{' '} - {selectedContributionRow.sourceCoefficient.toFixed(2)} ={' '} - {selectedContributionRow.bonusDelta.toFixed(2)} -
-
- - {selectedContributionAttributes.length > 0 ? ( -
- {selectedContributionAttributes.map((attribute) => ( -
-
- {attribute.label} - {Math.round(attribute.percent * 100)}% -
-
- {attribute.definition} -
-
-
- 标签亲和 {Math.round(attribute.similarity * 100)}% + > +
+
+
+ 标签概览
-
- 角色权重 {Math.round(attribute.weight * 100)}% -
-
适配贡献 {attribute.value.toFixed(4)}
-
- 属性加成 +{attribute.modifierDelta.toFixed(4)} +
+ {selectedContributionRow.label}
-
-
+
+
+ {getBuildContributionQualityLabel( + selectedContributionRow.bonusDelta, + )} +
+
+ 总加成{' '} + {formatBuildContributionPercent( + selectedContributionRow.bonusDelta, + )} +
- ))} +
+
- ) : ( -
- 当前标签还没有可展示的属性适配明细。 + +
+
+ 属性加成 +
+ + {selectedContributionAttributes.length > 0 ? ( +
+ {selectedContributionAttributes.map((attribute) => ( +
+
+ {attribute.label} + + {formatBuildContributionPercent( + attribute.modifierDelta, + )} + +
+
+ {attribute.definition} +
+
+ ))} +
+ ) : ( +
+ 当前标签还没有可展示的属性适配明细。 +
+ )}
- )} +
)} - {selectedSkill && detailCharacter ? ( + {selectedSkill ? ( event.stopPropagation()} > @@ -1260,7 +1269,7 @@ export function AdventureEntityModal({ {selectedSkill.name}
- {detailCharacter.name} + {selectedSkillOwnerName}
))}
@@ -254,21 +255,44 @@ function MultiplierContributionList({ ); } -function buildLeaderEquipmentRows(playerCharacter: Character, playerEquipment: EquipmentLoadout): EquipmentRow[] { +function formatAttributeMetricValue(value: number) { + const rounded = Math.round(value * 10) / 10; + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); +} + +function getAttributeBonusPillClassName(bonus: number) { + if (bonus >= 0.05) { + return 'border-amber-400/25 bg-amber-500/12 text-amber-100'; + } + if (bonus > 0) { + return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'; + } + return 'border-white/10 bg-black/20 text-zinc-500'; +} + +function buildLeaderEquipmentRows( + playerCharacter: Character, + playerEquipment: EquipmentLoadout, +): EquipmentRow[] { const starterLoadout = buildInitialEquipmentLoadout(playerCharacter); - return EQUIPMENT_SLOTS.map(slot => { + return EQUIPMENT_SLOTS.map((slot) => { const equippedItem = playerEquipment[slot] ?? starterLoadout[slot]; return { key: `leader-${slot}`, slotLabel: getEquipmentSlotLabel(slot), itemLabel: equippedItem?.name ?? '绌轰綅', - rarityLabel: equippedItem ? getEquipmentRarityLabel(equippedItem.rarity) : '绌轰綅', + rarityLabel: equippedItem + ? getEquipmentRarityLabel(equippedItem.rarity) + : '绌轰綅', }; }); } -function buildCompanionEquipmentRows(character: Character, keyPrefix: string): EquipmentRow[] { - return getCharacterEquipment(character).map(item => ({ +function buildCompanionEquipmentRows( + character: Character, + keyPrefix: string, +): EquipmentRow[] { + return getCharacterEquipment(character).map((item) => ({ key: `${keyPrefix}-${item.slot}-${item.item}`, slotLabel: item.slot, itemLabel: item.item, @@ -302,7 +326,9 @@ export function CharacterPanel({ onInspectMember, }: CharacterPanelProps) { const [selectedMemberId, setSelectedMemberId] = useState(null); - const [selectedContributionLabel, setSelectedContributionLabel] = useState(null); + const [selectedContributionLabel, setSelectedContributionLabel] = useState< + string | null + >(null); const partyMembers = useMemo( () => [ @@ -318,7 +344,7 @@ export function CharacterPanel({ maxMana: playerMaxMana, isLeader: true, }, - ...companionRenderStates.map(companion => ({ + ...companionRenderStates.map((companion) => ({ id: companion.npcId, npcId: companion.npcId, renderState: companion, @@ -331,61 +357,164 @@ export function CharacterPanel({ isLeader: false, })), ], - [companionRenderStates, playerCharacter, playerHp, playerMaxHp, playerMana, playerMaxMana], + [ + companionRenderStates, + playerCharacter, + playerHp, + playerMaxHp, + playerMana, + playerMaxMana, + ], ); const selectedMember = useMemo( - () => partyMembers.find(member => member.id === selectedMemberId) ?? null, + () => partyMembers.find((member) => member.id === selectedMemberId) ?? null, [partyMembers, selectedMemberId], ); const activeQuests = useMemo( - () => quests.filter(quest => quest.status !== 'turned_in'), + () => quests.filter((quest) => quest.status !== 'turned_in'), [quests], ); const buildBreakdownByMemberId = useMemo( - () => Object.fromEntries( - partyMembers.map(member => [ - member.id, - member.isLeader - ? getPlayerBuildDamageBreakdown({ - worldType, - customWorldProfile, - playerEquipment, - activeBuildBuffs, - } as GameState, playerCharacter) - : getCompanionBuildDamageBreakdown(member.character, worldType, customWorldProfile), - ]), - ) as Record, - [activeBuildBuffs, customWorldProfile, partyMembers, playerCharacter, playerEquipment, worldType], + () => + Object.fromEntries( + partyMembers.map((member) => [ + member.id, + member.isLeader + ? getPlayerBuildDamageBreakdown( + { + worldType, + customWorldProfile, + playerEquipment, + activeBuildBuffs, + } as GameState, + playerCharacter, + ) + : getCompanionBuildDamageBreakdown( + member.character, + worldType, + customWorldProfile, + ), + ]), + ) as Record, + [ + activeBuildBuffs, + customWorldProfile, + partyMembers, + playerCharacter, + playerEquipment, + worldType, + ], ); - const selectedBuildBreakdown = selectedMember ? buildBreakdownByMemberId[selectedMember.id] ?? null : null; - const selectedContributionRow = selectedBuildBreakdown?.rows.find(row => row.label === selectedContributionLabel) ?? null; - const selectedContributionProducts = selectedBuildBreakdown?.rows.map(row => row.bonusDelta) ?? []; - const selectedContributionMinProduct = selectedContributionProducts.length > 0 ? Math.min(...selectedContributionProducts) : 0; - const selectedContributionMaxProduct = selectedContributionProducts.length > 0 ? Math.max(...selectedContributionProducts) : 1; - const selectedAttributeSchema = resolveAttributeSchema(worldType, customWorldProfile); - const selectedMemberAffinity = selectedMember?.npcId - ? npcStates[selectedMember.npcId]?.affinity ?? 0 + const selectedBuildBreakdown = selectedMember + ? (buildBreakdownByMemberId[selectedMember.id] ?? null) : null; + const selectedContributionRow = + selectedBuildBreakdown?.rows.find( + (row) => row.label === selectedContributionLabel, + ) ?? null; + const selectedAttributeSchema = resolveAttributeSchema( + worldType, + customWorldProfile, + ); + const resourceLabels = getResourceLabelsForWorld( + worldType, + customWorldProfile, + ); + const selectedMemberAffinity = selectedMember?.npcId + ? (npcStates[selectedMember.npcId]?.affinity ?? 0) + : null; + const selectedMemberPublicBackstory = + selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null + ? getCharacterPublicBackstorySummary(selectedMember.character, worldType) + : null; + const selectedMemberUnlockedBackstoryChapters = + selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null + ? getUnlockedCharacterBackstoryChapters( + selectedMember.character, + selectedMemberAffinity, + worldType, + ).map((chapter) => ({ + id: chapter.id, + title: chapter.title, + content: chapter.content, + })) + : []; + const selectedMemberLockedBackstoryChapters = + selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null + ? getLockedCharacterBackstoryChapters( + selectedMember.character, + selectedMemberAffinity, + worldType, + ).map((chapter) => ({ + id: chapter.id, + title: chapter.title, + teaser: chapter.teaser, + affinityRequired: chapter.affinityRequired, + })) + : []; const selectedEquipmentRows = selectedMember ? selectedMember.isLeader ? buildLeaderEquipmentRows(playerCharacter, playerEquipment) : buildCompanionEquipmentRows(selectedMember.character, selectedMember.id) : []; - const selectedAttributeRows = selectedMember - ? formatAttributeList( - resolveCharacterAttributeProfile(selectedMember.character, worldType, customWorldProfile), + const selectedAttributeRows = useMemo( + () => + selectedMember + ? formatAttributeList( + resolveCharacterAttributeProfile( + selectedMember.character, + worldType, + customWorldProfile, + ), + selectedAttributeSchema, + ) + : [], + [customWorldProfile, selectedAttributeSchema, selectedMember, worldType], + ); + const selectedAttributeBonusBySlot = useMemo( + () => + Object.fromEntries( + selectedAttributeSchema.slots.map((slot) => [ + slot.slotId, + Number( + ( + selectedBuildBreakdown?.rows.reduce( + (sum, row) => + sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0), + 0, + ) ?? 0 + ).toFixed(4), + ), + ]), + ) as Record, + [selectedAttributeSchema, selectedBuildBreakdown], + ); + const selectedDisplayAttributeRows = useMemo( + () => + selectedAttributeRows.map(({ slot, value }) => { + const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0; + const boostedValue = value * (1 + totalBonus); + + return { + slot, + baseValue: value, + boostedValue, + totalBonus, + }; + }), + [selectedAttributeBonusBySlot, selectedAttributeRows], + ); + const selectedContributionAttributes = selectedContributionRow + ? getBuildContributionAttributeRows( + selectedContributionRow, selectedAttributeSchema, + { resourceLabels }, ) : []; - const selectedContributionAttributes = selectedContributionRow - ? getBuildContributionAttributeRows(selectedContributionRow, selectedAttributeSchema) - : []; - - const resourceLabels = getResourceLabelsForWorld(worldType); useEffect(() => { if (!selectedContributionLabel) return; @@ -418,15 +547,30 @@ export function CharacterPanel({ return ( <>
-
+
{activeQuests.length > 0 && (
-
褰撳墠濮旀墭
+
+ 褰撳墠濮旀墭 +
- {activeQuests.map(quest => ( -
-
{quest.title}
-
{quest.summary}
+ {activeQuests.map((quest) => ( +
+
+ {quest.title} +
+
+ {quest.summary} +
))}
@@ -435,7 +579,7 @@ export function CharacterPanel({
队伍成员
- {partyMembers.map(member => ( + {partyMembers.map((member) => (
@@ -498,13 +662,19 @@ export function CharacterPanel({ transition={{ duration: 0.18, ease: 'easeOut' }} className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,40rem)] w-full max-w-xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]" style={getNineSliceStyle(UI_CHROME.modalPanel)} - onClick={event => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} >
-
{'\u5c5e\u6027\u9002\u914d\u89e3\u6790'}
-
{selectedContributionRow.label}
-
{selectedMember.character.name}
+
+ {'\u6807\u7b7e\u6548\u679c'} +
+
+ {selectedContributionRow.label} +
+
+ {selectedMember.character.name} +
-
-
-
-
-
{selectedContributionRow.label}
-
- {getBuildSourceLabel(selectedContributionRow.source)} · {describeBuildContribution(selectedContributionRow, selectedAttributeSchema)} -
-
-
-
{'\u52a0\u6210'} +{selectedContributionRow.bonusDelta.toFixed(2)}
-
{'\u9002\u914d\u5ea6'} {Math.round(selectedContributionRow.fitScore * 100)}%
-
-
-
-
-
-
- -
-
bonusDelta = {'\u5404\u5c5e\u6027\u52a0\u6210\u4e4b\u548c'}
-
- {'\u6bcf\u4e2a\u6807\u7b7e\u90fd\u4f1a\u5206\u522b\u5339\u914d\u5f53\u524d\u4e16\u754c\u7684\u5c5e\u6027\u8f74\uff0c\u518d\u548c\u89d2\u8272\u81ea\u5df1\u7684\u5c5e\u6027\u6743\u91cd\u9010\u9879\u76f8\u4e58\u3002\u6bcf\u6761\u5c5e\u6027\u5148\u751f\u6210\u5355\u72ec\u7684\u52a0\u6210\uff0c\u6700\u540e\u6c47\u603b\u6210\u8fd9\u4e2a\u6807\u7b7e\u7684\u6536\u76ca\u3002'} -
-
- {selectedContributionRow.label} = 0.12 x {'\u9002\u914d\u5ea6'} {selectedContributionRow.fitScore.toFixed(2)} x {'\u6765\u6e90\u7cfb\u6570'} {selectedContributionRow.sourceCoefficient.toFixed(2)} = {selectedContributionRow.bonusDelta.toFixed(2)} -
-
- - {selectedContributionAttributes.length > 0 ? ( -
- {selectedContributionAttributes.map(attribute => ( -
-
- {attribute.label} - {Math.round(attribute.percent * 100)}% +
+
+
+
+
+
+
+ 标签概览
-
- {attribute.definition} -
-
-
{'\u6807\u7b7e\u4eb2\u548c'} {Math.round(attribute.similarity * 100)}%
-
{'\u89d2\u8272\u6743\u91cd'} {Math.round(attribute.weight * 100)}%
-
{'\u9002\u914d\u8d21\u732e'} {attribute.value.toFixed(4)}
-
{'\u5c5e\u6027\u52a0\u6210'} +{attribute.modifierDelta.toFixed(4)}
-
-
-
+
+ {selectedContributionRow.label}
- ))} +
+
+ {getBuildContributionQualityLabel( + selectedContributionRow.bonusDelta, + )} +
+
+ {'\u603b\u52a0\u6210'}{' '} + {formatBuildContributionPercent( + selectedContributionRow.bonusDelta, + )} +
+
+
+
+
- ) : ( -
- {'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'} + +
+
+ {'\u5c5e\u6027\u52a0\u6210'} +
+ + {selectedContributionAttributes.length > 0 ? ( +
+ {selectedContributionAttributes.map((attribute) => ( +
+
+ {attribute.label} + + {formatBuildContributionPercent( + attribute.modifierDelta, + )} + +
+
+ {attribute.definition} +
+
+ ))} +
+ ) : ( +
+ { + '\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002' + } +
+ )}
- )} +
@@ -594,12 +778,16 @@ export function CharacterPanel({ transition={{ duration: 0.18, ease: 'easeOut' }} className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]" style={getNineSliceStyle(UI_CHROME.modalPanel)} - onClick={event => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} >
-
角色详情
-
{selectedMember.character.name}
+
+ 角色详情 +
+
+ {selectedMember.character.name} +
{selectedMember.character.title} @@ -618,7 +806,10 @@ export function CharacterPanel({
-
+
-
{selectedMember.character.name}
-
{selectedMember.character.title}
-

{selectedMember.character.description}

+
+ {selectedMember.character.name} +
+
+ {selectedMember.character.title} +
+

+ {selectedMember.character.description} +

-
-
状态
+
+
+ 状态 +
- - + + {selectedMemberAffinity != null && ( )} + {selectedMemberAffinity != null && ( + + )} {selectedBuildBreakdown && ( setSelectedContributionLabel(row.label)} + onSelectContribution={(row) => + setSelectedContributionLabel(row.label) + } /> )}
- {selectedAttributeRows.map(({ slot, value }) => ( -
-
{slot.name}: {value}
-
{slot.definition}
-
- ))} + {selectedDisplayAttributeRows.map( + ({ slot, baseValue, boostedValue, totalBonus }) => ( +
+
+ {slot.name} +
+
+
+
+ {formatAttributeMetricValue(boostedValue)} +
+
+ 原始 {formatAttributeMetricValue(baseValue)} +
+
+ + {formatBuildContributionPercent(totalBonus)} + +
+
+ {slot.definition} +
+
+ ), + )}
-
-
背景故事
-
- {selectedMember.character.backstory} + {selectedMemberAffinity == null && ( +
+
+ 背景故事 +
+
+ {selectedMember.character.backstory} +
-
+ )} -
-
性格
+
+
+ 性格 +
{selectedMember.character.personality}
-
-
{'\u6280\u80fd'}
+
+
+ {'\u6280\u80fd'} +
-
-
装备
+
+
+ 装备 +
- {selectedEquipmentRows.map(item => ( + {selectedEquipmentRows.map((item) => (
- +
-
{item.slotLabel}
+
+ {item.slotLabel} +
{item.itemLabel}
@@ -713,5 +988,3 @@ export function CharacterPanel({ ); } - - diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 05889850..08b4d031 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -1,5 +1,9 @@ -import { type ReactNode,useDeferredValue, useMemo, useState } from 'react'; +import { type ReactNode, useDeferredValue, useMemo, useState } from 'react'; +import { + getCustomWorldSceneRelativePositionLabel, + normalizeCustomWorldLandmarks, +} from '../data/customWorldSceneGraph'; import { AnimationState, Character, CustomWorldProfile } from '../types'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { CharacterAnimator } from './CharacterAnimator'; @@ -137,10 +141,61 @@ function matchText(text: string, query: string) { function getSearchPlaceholder(tab: ResultTab) { if (tab === 'playable') return '搜索角色名称、称号、标签'; if (tab === 'story') return '搜索场景角色名称、身份、动机'; - if (tab === 'landmarks') return '搜索场景名称、描述'; + if (tab === 'landmarks') return '搜索场景名称、描述、NPC、连接'; return '搜索'; } +type CatalogRole = + | CustomWorldProfile['playableNpcs'][number] + | CustomWorldProfile['storyNpcs'][number]; + +function buildRoleSearchText(role: CatalogRole) { + return [ + role.name, + role.title, + role.role, + role.description, + role.backstory, + role.backstoryReveal.publicSummary, + role.personality, + role.motivation, + role.combatStyle, + ...role.backstoryReveal.chapters.flatMap((chapter) => [ + chapter.title, + chapter.teaser, + chapter.content, + chapter.contextSnippet, + ]), + ...role.skills.flatMap((skill) => [skill.name, skill.summary, skill.style]), + ...role.initialItems.flatMap((item) => [ + item.name, + item.category, + item.description, + ...item.tags, + ]), + ...role.relationshipHooks, + ...role.tags, + ].join(' '); +} + +function buildLandmarkSearchText( + landmark: CustomWorldProfile['landmarks'][number], + storyNpcById: Map, + landmarkById: Map, +) { + return [ + landmark.name, + landmark.description, + landmark.dangerLevel, + ...landmark.sceneNpcIds.map((npcId) => storyNpcById.get(npcId)?.name ?? ''), + ...landmark.connections.flatMap((connection) => [ + landmarkById.get(connection.targetLandmarkId)?.name ?? '', + getCustomWorldSceneRelativePositionLabel(connection.relativePosition), + connection.summary, + ]), + ].join(' '); +} + export function CustomWorldEntityCatalog({ profile, previewCharacters, @@ -154,6 +209,14 @@ export function CustomWorldEntityCatalog({ const [searchDraft, setSearchDraft] = useState(''); const deferredSearch = useDeferredValue(searchDraft.trim()); + const storyNpcById = useMemo( + () => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])), + [profile.storyNpcs], + ); + const landmarkById = useMemo( + () => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])), + [profile.landmarks], + ); const previewCharacterById = useMemo( () => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])), [previewCharacters, profile.playableNpcs], @@ -162,21 +225,7 @@ export function CustomWorldEntityCatalog({ const filteredPlayable = useMemo( () => profile.playableNpcs.filter(role => !deferredSearch - || matchText( - [ - role.name, - role.title, - role.role, - role.description, - role.backstory, - role.personality, - role.motivation, - role.combatStyle, - ...role.relationshipHooks, - ...role.tags, - ].join(' '), - deferredSearch, - ), + || matchText(buildRoleSearchText(role), deferredSearch), ), [deferredSearch, profile.playableNpcs], ); @@ -184,21 +233,7 @@ export function CustomWorldEntityCatalog({ const filteredStory = useMemo( () => profile.storyNpcs.filter(npc => !deferredSearch - || matchText( - [ - npc.name, - npc.title, - npc.role, - npc.description, - npc.backstory, - npc.personality, - npc.motivation, - npc.combatStyle, - ...npc.relationshipHooks, - ...npc.tags, - ].join(' '), - deferredSearch, - ), + || matchText(buildRoleSearchText(npc), deferredSearch), ), [deferredSearch, profile.storyNpcs], ); @@ -206,9 +241,12 @@ export function CustomWorldEntityCatalog({ const filteredLandmarks = useMemo( () => profile.landmarks.filter(landmark => !deferredSearch - || matchText([landmark.name, landmark.description].join(' '), deferredSearch), + || matchText( + buildLandmarkSearchText(landmark, storyNpcById, landmarkById), + deferredSearch, + ), ), - [deferredSearch, profile.landmarks], + [deferredSearch, landmarkById, profile.landmarks, storyNpcById], ); const counts = { @@ -232,17 +270,34 @@ export function CustomWorldEntityCatalog({ const removeStoryNpc = (id: string, name: string) => { if (!window.confirm(`确认删除场景角色「${name}」吗?`)) return; + const nextStoryNpcs = profile.storyNpcs.filter(npc => npc.id !== id); onProfileChange({ ...profile, - storyNpcs: profile.storyNpcs.filter(npc => npc.id !== id), + storyNpcs: nextStoryNpcs, + landmarks: normalizeCustomWorldLandmarks({ + landmarks: profile.landmarks.map((landmark) => ({ + ...landmark, + sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => npcId !== id), + })), + storyNpcs: nextStoryNpcs, + }), }); }; const removeLandmark = (id: string, name: string) => { if (!window.confirm(`确认删除场景「${name}」吗?`)) return; + const nextLandmarks = profile.landmarks.filter(landmark => landmark.id !== id); onProfileChange({ ...profile, - landmarks: profile.landmarks.filter(landmark => landmark.id !== id), + landmarks: normalizeCustomWorldLandmarks({ + landmarks: nextLandmarks.map((landmark) => ({ + ...landmark, + connections: landmark.connections.filter( + (connection) => connection.targetLandmarkId !== id, + ), + })), + storyNpcs: profile.storyNpcs, + }), }); }; @@ -293,7 +348,7 @@ export function CustomWorldEntityCatalog({
-
+
{profile.playableNpcs.length}
@@ -347,6 +402,9 @@ export function CustomWorldEntityCatalog({
{role.description}
{role.backstory}
+
+ 公开背景:{role.backstoryReveal.publicSummary || '未填写'} +
身份:{role.role}
初始好感:{role.initialAffinity}
@@ -354,6 +412,36 @@ export function CustomWorldEntityCatalog({
战斗:{role.combatStyle}
动机:{role.motivation}
+
+
好感背景章节
+
+ {role.backstoryReveal.chapters.map(chapter => ( +
+ {chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser} +
+ ))} +
+
+
+
技能
+
+ {role.skills.map(skill => ( +
+ {skill.name} · {skill.style}:{skill.summary} +
+ ))} +
+
+
+
初始物品
+
+ {role.initialItems.map(item => ( +
+ {item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description} +
+ ))} +
+
{role.tags.map(tag => ( @@ -400,6 +488,9 @@ export function CustomWorldEntityCatalog({ />
{npc.description}
+
+ 公开背景:{npc.backstoryReveal.publicSummary || '未填写'} +
头衔:{npc.title}
初始好感:{npc.initialAffinity}
@@ -410,6 +501,36 @@ export function CustomWorldEntityCatalog({
背景:{npc.backstory}
) : null}
动机:{npc.motivation}
+
+
好感背景章节
+
+ {npc.backstoryReveal.chapters.map(chapter => ( +
+ {chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser} +
+ ))} +
+
+
+
技能
+
+ {npc.skills.map(skill => ( +
+ {skill.name} · {skill.style}:{skill.summary} +
+ ))} +
+
+
+
初始物品
+
+ {npc.initialItems.map(item => ( +
+ {item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description} +
+ ))} +
+
{npc.relationshipHooks.map(hook => ( @@ -434,7 +555,7 @@ export function CustomWorldEntityCatalog({ {activeTab === 'landmarks' ? (
- 场景图会同步用于结果页和正式世界中的背景展示。 + 场景图会同步用于结果页和正式世界中的背景展示;这里还能看到每个场景承载的 NPC 和连接关系。
{filteredLandmarks.length === 0 ? ( @@ -453,6 +574,38 @@ export function CustomWorldEntityCatalog({
{landmark.description}
+
+ 危险度:{landmark.dangerLevel || '未填写'} +
+
+
场景内 NPC
+
+ {landmark.sceneNpcIds.length > 0 ? ( + landmark.sceneNpcIds.map((npcId) => ( + + {storyNpcById.get(npcId)?.name ?? '未匹配角色'} + + )) + ) : ( + 尚未分配场景角色 + )} +
+
+
+
连接关系
+
+ {landmark.connections.length > 0 ? ( + landmark.connections.map((connection) => ( +
+ {getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} · {landmarkById.get(connection.targetLandmarkId)?.name ?? '未匹配场景'} + {connection.summary ? `:${connection.summary}` : ''} +
+ )) + ) : ( +
尚未配置连接关系
+ )} +
+
diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 63b980e0..4943c5cf 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -1,9 +1,17 @@ import { Children, type ReactNode, useEffect, useMemo, useState } from 'react'; +import { + AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS, +} from '../data/affinityLevels'; import { buildCustomWorldPlayableCharacters, PRESET_CHARACTERS, } from '../data/characterPresets'; +import { + CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS, + getCustomWorldSceneRelativePositionLabel, + normalizeCustomWorldLandmarks, +} from '../data/customWorldSceneGraph'; import { getAllCustomWorldSceneImages, getDefaultCustomWorldSceneImage, @@ -22,6 +30,7 @@ import { CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, + CustomWorldSceneConnection, } from '../types'; import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { CharacterAnimator } from './CharacterAnimator'; @@ -48,6 +57,13 @@ interface CustomWorldEntityEditorModalProps { onProfileChange: (profile: CustomWorldProfile) => void; } +const [ + BACKSTORY_UNLOCK_AFFINITY_EASED, + BACKSTORY_UNLOCK_AFFINITY_FRIENDLY, + BACKSTORY_UNLOCK_AFFINITY_TRUSTED, + BACKSTORY_UNLOCK_AFFINITY_CLOSE, +] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS; + function slugify(value: string) { const normalized = value .trim() @@ -85,6 +101,16 @@ function clampInitialAffinity(value: string, fallback: number) { return Math.max(-40, Math.min(90, Math.round(parsed))); } +function syncLandmarksWithStoryNpcs( + landmarks: CustomWorldLandmark[], + storyNpcs: CustomWorldProfile['storyNpcs'], +) { + return normalizeCustomWorldLandmarks({ + landmarks, + storyNpcs, + }); +} + function useDraft(value: T) { const [draft, setDraft] = useState(value); useEffect(() => setDraft(value), [value]); @@ -1208,24 +1234,97 @@ function LandmarkEditor({ profile, landmark, mode, - onSave, + onSaveProfile, onClose, }: { profile: CustomWorldProfile; landmark: CustomWorldLandmark; mode: 'create' | 'edit'; - onSave: (landmark: CustomWorldLandmark) => void; + onSaveProfile: (profile: CustomWorldProfile) => void; onClose: () => void; }) { const [draft, setDraft] = useDraft(landmark); + const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs); const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false); const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false); + const [npcEditorState, setNpcEditorState] = useState<{ + mode: 'create' | 'edit'; + npc: CustomWorldNpc; + } | null>(null); const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []); + const storyNpcById = useMemo( + () => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])), + [draftStoryNpcs], + ); + const availableTargetLandmarks = useMemo( + () => profile.landmarks.filter((entry) => entry.id !== draft.id), + [draft.id, profile.landmarks], + ); + + const toggleSceneNpc = (npcId: string) => { + setDraft((current) => ({ + ...current, + sceneNpcIds: current.sceneNpcIds.includes(npcId) + ? current.sceneNpcIds.filter((entry) => entry !== npcId) + : [...current.sceneNpcIds, npcId], + })); + }; + + const updateConnection = ( + index: number, + updater: (connection: CustomWorldSceneConnection) => CustomWorldSceneConnection, + ) => { + setDraft((current) => ({ + ...current, + connections: current.connections.map((connection, connectionIndex) => + connectionIndex === index ? updater(connection) : connection, + ), + })); + }; + + const addConnection = () => { + const fallbackTarget = availableTargetLandmarks[0]; + if (!fallbackTarget) { + window.alert('请先保留至少一个其他场景,才能配置连接关系。'); + return; + } + + setDraft((current) => ({ + ...current, + connections: [ + ...current.connections, + { + targetLandmarkId: fallbackTarget.id, + relativePosition: 'forward', + summary: `可通往${fallbackTarget.name}`, + }, + ], + })); + }; + + const saveLandmarkProfile = () => { + if (draft.sceneNpcIds.length < 3) { + window.alert('每个场景至少需要分配 3 个 NPC。'); + return; + } + + const nextLandmarks = + mode === 'create' + ? [...profile.landmarks, draft] + : profile.landmarks.map((entry) => (entry.id === draft.id ? draft : entry)); + + onSaveProfile({ + ...profile, + storyNpcs: draftStoryNpcs, + landmarks: syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs), + }); + onClose(); + }; return (
@@ -1286,12 +1385,213 @@ function LandmarkEditor({ } /> +
+
+
+
+ 场景内 NPC +
+
+ 每个场景至少保留 3 个 NPC。可以在这里直接继续新增 NPC,并立即加入当前场景。 +
+
+ + setNpcEditorState({ + mode: 'create', + npc: createStoryNpc({ storyNpcs: draftStoryNpcs }), + }) + } + tone="sky" + /> +
+
+ {draft.sceneNpcIds.length > 0 ? ( + draft.sceneNpcIds.map((npcId) => { + const npc = storyNpcById.get(npcId); + return ( +
+
+
+
+ {npc?.name ?? '未匹配场景角色'} +
+
+ {npc?.role || npc?.title || '未填写身份'} +
+
+
+ {npc ? ( + + setNpcEditorState({ + mode: 'edit', + npc, + }) + } + tone="sky" + /> + ) : null} + toggleSceneNpc(npcId)} + /> +
+
+
+ ); + }) + ) : ( +
+ 还没有为这个场景分配 NPC。 +
+ )} +
+
+ {draftStoryNpcs.map((npc) => { + const selected = draft.sceneNpcIds.includes(npc.id); + return ( + + ); + })} +
+
+
+
+
+
+ 场景连接关系 +
+
+ 编辑当前场景与其他场景之间的相对位置关系。保存时会自动同步反向连线,避免地图断开。 +
+
+ +
+
+ {draft.connections.length > 0 ? ( + draft.connections.map((connection, index) => ( +
+
+ + + updateConnection(index, (current) => ({ + ...current, + targetLandmarkId: value, + })) + } + options={availableTargetLandmarks.map((entry) => ({ + value: entry.id, + label: entry.name, + }))} + /> + + + + updateConnection(index, (current) => ({ + ...current, + relativePosition: value as CustomWorldSceneConnection['relativePosition'], + })) + } + options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map( + (option) => ({ + value: option.value, + label: option.label, + }), + )} + /> + +
+ +