From 89cecda7da7ec55f65c4258b00f6c2cf55b40b9f 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 17:13:07 +0800 Subject: [PATCH] Refine NPC interactions and runtime item generation --- docs/ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md | 133 ++ src/components/AdventureEntityModal.tsx | 1508 ++++++++++++----- src/components/AdventurePanel.tsx | 8 +- src/components/AffinityStatusCard.tsx | 273 ++- src/components/CharacterDetailModal.tsx | 2 +- src/components/CharacterPanel.tsx | 22 +- src/components/CompanionCampModal.tsx | 54 +- src/components/CustomWorldEntityCatalog.tsx | 58 +- .../CustomWorldEntityEditorModal.tsx | 171 +- src/components/CustomWorldNpcVisualEditor.tsx | 42 +- src/components/DeveloperTeamModal.tsx | 2 +- src/components/InventoryItemViews.tsx | 309 ++++ src/components/InventoryPanel.tsx | 525 +++--- src/components/ItemCatalogEditor.tsx | 12 +- src/components/NpcModals.tsx | 2 +- src/components/StateFunctionEditor.tsx | 35 +- .../AdventurePanelOverlays.tsx | 2 +- .../game-canvas/GameCanvasEntityLayer.tsx | 8 +- .../game-shell/GameShellStoryPanels.tsx | 2 +- .../preset-editor/CharacterAssetPanel.tsx | 12 +- .../preset-editor/LazyEditorFallback.tsx | 2 +- src/data/attributeProfileGenerator.ts | 26 +- src/data/buildTags.ts | 94 +- src/data/characterPresets.ts | 4 + src/data/customWorldLibrary.ts | 37 +- src/data/customWorldNpcMonsters.ts | 159 ++ src/data/functionCatalog/npc/npcGift.ts | 5 +- src/data/hostileNpcPresets.test.ts | 91 + src/data/hostileNpcPresets.ts | 283 ++-- src/data/npcInteractions.test.ts | 247 +++ src/data/npcInteractions.ts | 323 +++- src/data/questFlow.test.ts | 1 + src/data/questFlow.ts | 149 +- src/data/runtimeItemDirector.ts | 52 +- src/data/sceneEncounterPreviews.ts | 2 +- src/data/scenePresets.ts | 89 +- src/hooks/combatStoryUtils.ts | 18 +- src/hooks/story/characterChat.ts | 2 +- src/hooks/story/choiceActions.ts | 4 +- src/hooks/story/npcEncounterActions.ts | 272 ++- src/hooks/story/npcInteraction.ts | 51 +- src/hooks/story/openingAdventure.ts | 4 +- src/hooks/story/progressionActions.ts | 4 +- src/hooks/story/storyGenerationState.test.ts | 52 +- src/hooks/story/storyGenerationState.ts | 28 +- src/hooks/useGameFlow.ts | 2 +- src/hooks/useStoryGeneration.ts | 35 +- src/services/ai.test.ts | 10 + src/services/customWorld.ts | 78 +- src/services/customWorldPresentation.stub.ts | 6 +- src/services/customWorldPresentation.ts | 234 +-- src/services/llmClient.ts | 12 +- src/services/prompt.ts | 4 +- src/services/runtimeItemAiDirector.ts | 94 + src/services/runtimeItemAiPrompt.ts | 85 + src/types/customWorld.ts | 20 +- src/types/scene.ts | 1 + src/types/story.ts | 1 + 58 files changed, 4199 insertions(+), 1562 deletions(-) create mode 100644 docs/ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md create mode 100644 src/components/InventoryItemViews.tsx create mode 100644 src/data/customWorldNpcMonsters.ts create mode 100644 src/data/hostileNpcPresets.test.ts create mode 100644 src/data/npcInteractions.test.ts create mode 100644 src/services/runtimeItemAiDirector.ts create mode 100644 src/services/runtimeItemAiPrompt.ts diff --git a/docs/ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md b/docs/ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md new file mode 100644 index 00000000..1482623c --- /dev/null +++ b/docs/ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md @@ -0,0 +1,133 @@ +# 物品生成系统与 Build 标签系统 PRD 落地审计 + +审计时间:2026-04-05 + +审计范围: + +- `docs/prd/AI_NATIVE_RUNTIME_ITEM_GENERATION_DESIGN.md` +- `docs/prd/RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md` +- `docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md` +- `docs/EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md` + +## 结论速览 + +### 1. 物品生成系统 + +当前状态可以判定为:**主链已补齐落地**。 + +已经落地的是: + +- 有独立的运行时上下文层、导演层、本地编译层、叙事回写层。 +- 宝藏、怪物掉落、通用 NPC 商店已经能走统一的 runtime item director。 +- 永久 build 标签物品、限时 build buff 物品、少量数值物品三种骨架都已经能编译出来,并接进现有背包 / 装备 / build 结算。 + +本轮已补齐的是: + +- 新增了 runtime item AI 意图导演与 prompt,并接入 NPC 帮助奖励主链,失败时自动回退到本地导演。 +- NPC 交易库存改成按玩家当前 build 生成,并通过 `tradeStockSignature` 只在 build 变化时刷新。 +- NPC 帮助奖励、委托奖励已经统一接入 runtime item director。 +- 怪物掉落已经改成“基础掉落 + 语义掉落”双层叠加。 + +### 2. Build 标签系统 + +当前状态可以判定为:**核心已按 PRD 落地,且实现范围比 PRD 更大**。 + +已经落地的是: + +- `BuildTagDefinition.attributeAffinity` 已扩展。 +- `buildDamage.ts` 已改成“标签分别匹配角色属性画像”的加法模型,不再做标签两两网络效应。 +- Buff / 角色固有 / 武器 / 护甲 / 饰品 / 套装标签都能进入最终倍率。 +- Character 面板已经切到“属性适配度”展示,并能拆出单标签的属性贡献明细。 +- 有针对新公式的测试覆盖。 + +还存在的尾项主要是: + +- 实现用的是“世界属性 schema 六轴模型”,不是 PRD 文字里的固定四维属性。 +- 旧的标签相似度辅助能力没有完全清干净,重铸仍在用 `getSimilarBuildTags` 做候选标签替换。 + +## 物品生成系统审计 + +| PRD 项 | 当前实现 | 判定 | 代码证据 | +| --- | --- | --- | --- | +| 上下文采样层 | 已有 `buildRuntimeItemGenerationContext` / `buildQuestRuntimeItemGenerationContext`,会收集场景、遭遇、关联 NPC、最近剧情、玩家 build 标签与 build gap。 | 已落地 | `src/data/runtimeItemContext.ts:157-188`、`src/data/runtimeItemContext.ts:191-252` | +| AI 意图层 | 已新增 `runtimeItemAiDirector` / `runtimeItemAiPrompt`,`buildRuntimeItemAiPromptInput` 已进入真实 prompt 组装;NPC 帮助奖励主链会先请求 AI 物品意图,再回落到本地意图导演。 | 已补齐 | `src/services/runtimeItemAiPrompt.ts`、`src/services/runtimeItemAiDirector.ts`、`src/data/runtimeItemDirector.ts`、`src/hooks/story/npcEncounterActions.ts` | +| 本地编译层 | 已按 channel / slot / permanence 做 rarity 与预算编译,并产出 `statProfile`、`useProfile.buildBuffs`、`buildProfile`、`runtimeMetadata`。 | 已落地 | `src/data/runtimeItemCompiler.ts:77-101`、`src/data/runtimeItemCompiler.ts:122-206`、`src/data/runtimeItemCompiler.ts:245-276` | +| 叙事回写层 | 会把锚点、来源理由、build 倾向回写进物品名和描述。 | 已落地 | `src/data/runtimeItemNarrative.ts:171-189` | +| 永久标签 / 限时标签 / 少量数值三类物品 | 永久物品走 `buildProfile`,限时物品走 `useProfile.buildBuffs`,并可附带少量数值。 | 已落地 | `src/data/runtimeItemCompiler.ts:104-155`、`src/data/runtimeItemCompiler.ts:157-206` | +| 宝藏入口 | 宝藏奖励已经走 `buildRuntimeItemGenerationContext + buildDirectedRuntimeReward`,并把结果写回 story hint。 | 已落地 | `src/data/treasureInteractions.ts:52-89`、`src/hooks/useTreasureFlow.ts:45-75` | +| NPC 交易入口 | 通用 NPC 商店已改成按玩家当前 build 生成;初始 NPC 状态、交易模态打开时刷新、场景预览读取都会带上完整 `GameState`,并用 `tradeStockSignature` 避免无意义重刷。角色型 NPC 仍保留既有角色装备/背包模板。 | 已补齐 | `src/data/npcInteractions.ts`、`src/hooks/story/npcInteraction.ts`、`src/hooks/useStoryGeneration.ts`、`src/components/NpcModals.tsx` | +| NPC 帮助 / 关系奖励 | `npc_reward` 已真正接入主链。帮助奖励现在会生成带 `runtimeMetadata` 的 runtime item,并保留数值恢复、冷却缩减与 story hint。 | 已补齐 | `src/data/npcInteractions.ts`、`src/hooks/story/npcEncounterActions.ts` | +| 委托奖励入口 | Quest reward 已改为走 runtime director,支持 `buildQuestRuntimeItemGenerationContext`,领取时发放的是 runtime item。主 NPC 接任务流程也已切到 `generateQuestForNpcEncounter`。 | 已补齐 | `src/data/questFlow.ts`、`src/hooks/story/npcEncounterActions.ts`、`src/services/questDirector.ts` | +| 怪物掉落双层设计 | 普通世界掉落已改成预设 `lootTable` 基础掉落与 `monster_drop` runtime 语义掉落并存,不再互相覆盖。 | 已补齐 | `src/data/hostileNpcPresets.ts`、`src/data/hostileNpcPresets.test.ts` | + +### 物品系统的具体判断 + +#### 已经明显符合 PRD 的部分 + +1. 系统分层已经形成。 + `runtimeItemContext -> runtimeItemDirector -> runtimeItemCompiler -> runtimeItemNarrative` 这条链已经非常接近 PRD 里“上下文采样 / AI 意图 / 本地编译 / 叙事回写”的结构。 + +2. build 导向优先于纯数值。 + `buildRuntimeItemContext` 会先算 `playerBuildTags` 和 `playerBuildGaps`,导演层再优先把 gap tag 与现有 build tag 拼进 `targetBuildDirection`,编译层才决定数值预算。 + +3. 奖励已经进入真实玩法结算。 + runtime item 生成出的 `buildProfile` 会进入装备 build 结算,`useProfile.buildBuffs` 会在使用物品时写入 `activeBuildBuffs`。 + +#### 本轮补齐后的说明 + +1. 运行时物品意图已经进入真实主链。 + 当前至少在 NPC 帮助奖励链路中,已经先走 AI 意图导演,再走本地编译与叙事回写;如果模型不可用,会回退到既有启发式导演,保证玩法不断。 + +2. 交易库存已经真正读取玩家当前构筑。 + 通用交易 NPC 的库存会基于玩家当前 build、装备标签和 build gap 生成,而不是继续依赖 NPC 自身偏好标签。 + +3. 帮助奖励、委托奖励、怪物掉落都已并入统一 runtime director。 + 现在三条链路都能产出带 relation anchor / source reason / runtime metadata 的 runtime item。 + +## Build 标签系统审计 + +| PRD 项 | 当前实现 | 判定 | 代码证据 | +| --- | --- | --- | --- | +| `BuildTagDefinition.attributeAffinity` 扩展 | 类型已扩展,标签注册表也会为每个标签注入 affinity。 | 已落地 | `src/types/build.ts:6-13`、`src/data/buildTags.ts:66-69` | +| 静态标签亲和度表 | 已有 `buildTagAttributeAffinity.ts`,提供标签到属性轴的静态 affinity 表。 | 已落地 | `src/data/buildTagAttributeAffinity.ts:127-183` | +| 从“标签互相影响”改为“标签分别匹配角色属性” | `buildDamage.ts` 已按单标签计算 `fitScore`、`bonusDelta` 和属性贡献,最后做加法累积,不再做 pair/cluster 乘法网络。 | 已落地 | `src/data/buildDamage.ts:236-318` | +| 来源系数 | Buff / 角色 / 武器 / 护甲 / 饰品 / 套装都有独立 source coefficient,和 PRD 基本一致。 | 已落地 | `src/data/buildDamage.ts:95-114` | +| 最终伤害接入 | `resolvePlayerOutgoingDamage` / `resolveCompanionOutgoingDamage` / `resolveMonsterOutgoingDamage` 都已接 `buildDamageMultiplier`。 | 已落地 | `src/data/buildDamage.ts:498-542` | +| 展示层从“标签协同”改成“属性适配度” | Character 面板已经按标签展示 bonus、来源、主导属性,并能打开明细弹窗。 | 已落地 | `src/components/CharacterPanel.tsx:205-247`、`src/components/CharacterPanel.tsx:497-539` | +| 验收测试 | 已覆盖单标签可拆分、删一个标签不重算其余标签、不同属性角色用同一套装倍率不同、Buff/套装来源正常进入等场景。 | 已落地 | `src/data/buildDamage.test.ts:125-313` | +| 四维属性口径 | PRD 写的是四维固定属性;实现已经提升为 world schema 六轴模型。目标一致,但口径不再一一对应。 | 已落地,但实现已外延扩展 | `src/types/attributes.ts:3-15`、`src/data/worldAttributeSchemas.ts:4-155` | +| 旧标签相似度清理 | 核心伤害结算已不再依赖旧矩阵,但 `getSimilarBuildTags` 仍存在,重铸继续用它挑候选标签,`generate:build-tags` 脚本也还保留。 | 收尾未完成 | `src/data/buildTags.ts:183-215`、`src/data/forgeSystem.ts:371-399`、`package.json:21-24` | + +### Build 系统的具体判断 + +#### 已经符合 PRD 核心目标的部分 + +1. 可解释性已经建立。 + 现在每个标签都有自己的 `fitScore`、`attributeContributions` 和 `attributeModifierDeltas`,玩家可以看到“这个标签为什么强、强在哪条属性轴上”。 + +2. 新增标签不会反向扰动旧标签贡献。 + `buildDamage.test.ts` 已专门验证“删掉一个标签,只会移除它自己的 row,不会重算其他 row”。 + +3. 套装标签、Buff 标签、装备标签都能统一进入同一公式。 + 这点和 PRD 的来源规则一致,且测试已经覆盖。 + +#### 需要注意但不构成核心未落地的问题 + +1. 实现已经超出 PRD 的四维设计。 + 现在不是固定 `strength / agility / intelligence / spirit` 四维,而是通过 `WorldAttributeSchema` 映射成武侠/仙侠/自定义世界都能共用的语义属性轴。这更适合当前仓库的世界化属性系统,但也意味着 PRD 文案需要同步。 + +2. 旧相似度能力没有完全退场。 + 核心伤害结算已经不用标签两两矩阵,但锻造重铸仍然会根据标签相似度挑换洗标签,所以“旧体系相关命名/脚本”还没彻底收尾。 + +## 综合判断 + +如果按“是否已经把 PRD 的主战场落到代码里”来判断: + +- **Build 标签系统:可以认为已经落地。** +- **物品生成系统:可以认为主链已经落地,之前审计出的缺口已在本轮补齐。** + +如果接下来继续做收尾,更建议做的是: + +1. 把 runtime item AI 意图层继续扩到宝藏、怪物掉落、委托奖励以外的更多同步入口,减少启发式 fallback 的覆盖面。 +2. 给 Build 系统补一版文档同步,明确当前实现已经从 PRD 四维模型升级为世界属性 schema 模型,并清理剩余旧相似度脚本/命名。 +3. 评估 `origin: 'ai_compiled'` 的语义是否还要细分成“AI 意图 + 本地编译”与“纯本地 fallback”两档,方便后续观测与埋点。 diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index 53dcde62..e0f3f361 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -1,29 +1,57 @@ -import {X} from 'lucide-react'; -import {AnimatePresence, motion} from 'motion/react'; -import type {ReactNode} from 'react'; +import { X } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import {buildRelationState, formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile} from '../data/attributeResolver'; +import { + buildRelationState, + formatAttributeList, + resolveAttributeSchema, + resolveCharacterAttributeProfile, +} from '../data/attributeResolver'; +import { + type BuildDamageBreakdown, + describeBuildContribution, + getBuildContributionAttributeRows, + getBuildSourceLabel, + getCompanionBuildDamageBreakdown, + getPlayerBuildDamageBreakdown, +} from '../data/buildDamage'; import { getCharacterById, - getCharacterEquipment, getCharacterMaxMana, getCharacterPrivateChatUnlockAffinity, - getCharacterPublicBackstorySummary, - getLockedCharacterBackstoryChapters, - getUnlockedCharacterBackstoryChapters, + getInventoryItems, } from '../data/characterPresets'; -import {getEquipmentSlotFromItem, getEquipmentSlotLabel} from '../data/equipmentEffects'; -import {getHostileNpcPresetById} from '../data/hostileNpcPresets'; -import {buildEncounterAttributeRumors, resolveEncounterAttributeProfile} from '../data/npcAttributeInsights'; -import {buildInitialNpcState, getRarityLabel, normalizeNpcPersistentState} from '../data/npcInteractions'; -import type {CharacterChatTarget} from '../hooks/useStoryGeneration'; -import {AnimationState, type Character, type Encounter, type GameState, type InventoryItem, type NpcPersistentState} from '../types'; -import {getNineSliceStyle, UI_CHROME} from '../uiAssets'; -import {AffinityStatusCard} from './AffinityStatusCard'; -import {CharacterAnimator} from './CharacterAnimator'; -import type {GameCanvasEntitySelection} from './GameCanvas'; -import {HostileNpcAnimator} from './HostileNpcAnimator'; -import {MedievalNpcAnimator} from './MedievalNpcAnimator'; +import { getHostileNpcPresetById } from '../data/hostileNpcPresets'; +import { + buildEncounterAttributeRumors, + resolveEncounterAttributeProfile, +} from '../data/npcAttributeInsights'; +import { + buildInitialNpcState, + normalizeNpcPersistentState, +} from '../data/npcInteractions'; +import type { CharacterChatTarget } from '../hooks/useStoryGeneration'; +import { + AnimationState, + type Character, + type Encounter, + type GameState, + type InventoryItem, + type NpcPersistentState, + type WorldAttributeSchema, +} from '../types'; +import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { AffinityStatusCard } from './AffinityStatusCard'; +import { CharacterAnimator } from './CharacterAnimator'; +import type { GameCanvasEntitySelection } from './GameCanvas'; +import { HostileNpcAnimator } from './HostileNpcAnimator'; +import { + InventoryItemDetailModal, + InventoryItemGrid, +} from './InventoryItemViews'; +import { MedievalNpcAnimator } from './MedievalNpcAnimator'; interface AdventureEntityModalProps { selection: GameCanvasEntitySelection | null; @@ -33,7 +61,10 @@ interface AdventureEntityModalProps { } function estimateCharacterMaxHp(character: Character) { - return Math.max(120, 90 + character.attributes.strength * 10 + character.attributes.spirit * 4); + return Math.max( + 120, + 90 + character.attributes.strength * 10 + character.attributes.spirit * 4, + ); } function estimateNpcMaxHp(character: Character | null) { @@ -56,66 +87,42 @@ function StatBar({ tone: 'hp' | 'mp'; }) { const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0)); - const fillClass = tone === 'hp' - ? 'from-emerald-400 via-lime-300 to-emerald-200' - : 'from-sky-500 via-cyan-300 to-sky-100'; + const fillClass = + tone === 'hp' + ? 'from-emerald-400 via-lime-300 to-emerald-200' + : 'from-sky-500 via-cyan-300 to-sky-100'; return (
{label} - {current} / {max} + + {current} / {max} +
-
+
); } -function Section({ - title, - children, -}: { - title: string; - children: ReactNode; -}) { +function Section({ title, children }: { title: string; children: ReactNode }) { return (
-
{title}
+
+ {title} +
{children}
); } -function ItemList({items}: {items: InventoryItem[]}) { - if (items.length === 0) { - return
暂无物品
; - } - - return ( -
- {items.map(item => { - const slot = getEquipmentSlotFromItem(item); - return ( -
-
-
- {item.name} - {item.quantity > 1 ? ` x${item.quantity}` : ''} -
-
- {item.category} / {getRarityLabel(item.rarity)}{slot ? ` / ${getEquipmentSlotLabel(slot)}` : ''} -
-
-
- ); - })} -
- ); +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); } const SKILL_STYLE_LABELS = { @@ -126,25 +133,173 @@ const SKILL_STYLE_LABELS = { projectile: '投射', } satisfies Record; +type ContributionRow = BuildDamageBreakdown['rows'][number]; + function getSkillDeliveryLabel(skill: Character['skills'][number]) { - return skill.delivery === 'ranged' || skill.style === 'projectile' ? '远程' : '近战'; + return skill.delivery === 'ranged' || skill.style === 'projectile' + ? '远程' + : '近战'; } function getSkillStyleLabel(skill: Character['skills'][number]) { return SKILL_STYLE_LABELS[skill.style]; } -function CharacterSkills({character}: {character: Character}) { +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; + } + + return clamp((value - normalizedMin) / range, 0, 1); +} + +function getContributionVisualStyle( + value: number, + minValue = 0, + maxValue = 1, +): CSSProperties { + const ratio = getContributionHeatRatio(value, minValue, maxValue); + const hue = 210 - ratio * 178; + const saturation = 62 + ratio * 16; + const lightness = 56 + ratio * 6; + + return { + borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`, + background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`, + boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`, + color: + ratio > 0.76 + ? 'rgb(255 244 235)' + : ratio > 0.32 + ? 'rgb(236 242 248)' + : 'rgb(203 213 225)', + }; +} + +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( + (left, right) => + 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 ? ( +
+ {sortedRows.map((row) => ( + + ))} +
+ ) : ( + + 当前还没有形成有效标签 + + )} +
+ ); +} + +function CharacterSkills({ + character, + onSelectSkill, +}: { + character: Character; + onSelectSkill: (skillId: string) => void; +}) { if (character.skills.length === 0) { return
暂无技能信息
; } return (
- {character.skills.map(skill => ( -
( + ))}
); } -function CharacterEquipment({character}: {character: Character}) { - const equipment = getCharacterEquipment(character); +function buildPreviewInventoryDescription( + characterName: string, + item: { category: string; name: string; quantity: number }, +) { + const quantityText = item.quantity > 1 ? `,当前数量 x${item.quantity}` : ''; - if (equipment.length === 0) { - return
暂无装备信息
; + switch (item.category) { + case '消耗品': + return `${characterName} 随身准备的消耗品,适合在关键时刻快速补给${quantityText}。`; + case '稀有品': + return `${characterName} 妥善保管的稀有物件,通常和经历、身份或交易筹码有关${quantityText}。`; + case '专属品': + return `${characterName} 不轻易示人的专属信物,往往带着明显的个人痕迹${quantityText}。`; + case '材料': + return `${characterName} 随身收着的制作材料,可用于后续锻造或交换${quantityText}。`; + default: + return `${characterName} 携带的${item.category}${quantityText}。`; } +} - return ( -
- {equipment.map(item => ( -
-
-
{item.slot}
-
{item.item}
-
- - {item.rarity} - -
- ))} -
+function getPreviewInventoryRarity(category: string): InventoryItem['rarity'] { + switch (category) { + case '专属品': + return 'epic'; + case '稀有品': + return 'rare'; + case '材料': + return 'uncommon'; + default: + return 'common'; + } +} + +function buildCharacterInventoryPreviewItems( + character: Character, + worldType: GameState['worldType'], +) { + return getInventoryItems(character, worldType).map( + (item, index) => + ({ + id: `preview:${character.id}:${index}:${item.category}:${item.name}`, + category: item.category, + name: item.name, + quantity: item.quantity, + rarity: getPreviewInventoryRarity(item.category), + tags: [], + description: buildPreviewInventoryDescription(character.name, item), + }) satisfies InventoryItem, ); } -function getNpcBadge(encounter: Encounter, affinity: number, battleStatePresent: boolean) { +function getNpcBadge( + encounter: Encounter, + affinity: number, + battleStatePresent: boolean, +) { if (encounter.hostile || battleStatePresent || affinity < 0) { return '敌对角色'; } @@ -218,36 +401,19 @@ function describeRelationStance(affinity: number) { } } -function buildGenericNpcArchiveSummary( - encounter: Encounter, - npcState: NpcPersistentState, - rumors: string[], -) { - const contactSummary = npcState.firstMeaningfulContactResolved - ? '你们已经越过最初的表面试探,对方开始显露更稳定的行事轮廓。' - : '目前仍停留在初见观察阶段,对方真正的来历和立场还没有完全摊开。'; - const rumorSummary = rumors.length > 0 - ? `从细节里能确认的线索有:${rumors.join(';')}` - : `${encounter.npcName}当前显露出来的大多只是“${encounter.context}”这一层身份。`; - - return { - publicSummary: `${encounter.npcName}以“${encounter.context}”的身份出现在你面前。${encounter.npcDescription}`, - clueSummary: `${contactSummary}${rumorSummary}`, - }; -} - function buildGenericNpcPersonalitySummary( encounter: Encounter, affinity: number, rumors: string[], ) { - const stanceSummary = affinity >= 60 - ? '说话会更直接,也更愿意表明自己的立场。' - : affinity >= 30 - ? '已经不再把你完全当外人,但依旧保留自己的边界。' - : affinity >= 15 - ? '愿意正常交流,不过还在观察你的来意。' - : '习惯先判断风险,再决定透露多少。'; + const stanceSummary = + affinity >= 60 + ? '说话会更直接,也更愿意表明自己的立场。' + : affinity >= 30 + ? '已经不再把你完全当外人,但依旧保留自己的边界。' + : affinity >= 15 + ? '愿意正常交流,不过还在观察你的来意。' + : '习惯先判断风险,再决定透露多少。'; const rumorSummary = rumors[0] ? `从表现上看,最鲜明的一面是:${rumors[0]}` : `${encounter.context}这层身份几乎决定了对方当前的处事方式。`; @@ -262,8 +428,9 @@ function buildGenericNpcTechniqueCards(params: { inventoryCount: number; rumors: string[]; }) { - const {encounter, npcState, hasBattleState, inventoryCount, rumors} = params; - const cards: Array<{title: string; detail: string}> = [ + const { encounter, npcState, hasBattleState, inventoryCount, rumors } = + params; + const cards: Array<{ title: string; detail: string }> = [ { title: '身份手段', detail: `${encounter.npcName}主要以“${encounter.context}”这一身份处理眼前局面,通常会先观察、试探,再决定是否继续合作或对抗。`, @@ -322,193 +489,312 @@ export function AdventureEntityModal({ onClose, onOpenCharacterChat, }: AdventureEntityModalProps) { - const playerCharacter = selection?.kind === 'player' ? gameState.playerCharacter : null; - const companion = selection?.kind === 'companion' ? selection.companion : null; + const [selectedSkillId, setSelectedSkillId] = useState(null); + const [selectedContributionLabel, setSelectedContributionLabel] = useState< + string | null + >(null); + const [selectedItemId, setSelectedItemId] = useState(null); + const playerCharacter = + selection?.kind === 'player' ? gameState.playerCharacter : null; + const companion = + selection?.kind === 'companion' ? selection.companion : null; const companionCharacter = companion?.character ?? null; const companionRosterState = companion - ? gameState.companions.find(item => item.npcId === companion.npcId) - ?? gameState.roster.find(item => item.npcId === companion.npcId) - ?? null + ? (gameState.companions.find((item) => item.npcId === companion.npcId) ?? + gameState.roster.find((item) => item.npcId === companion.npcId) ?? + null) : null; const companionNpcState = companion ? normalizeNpcPersistentState( - gameState.npcStates[companion.npcId] - ?? buildFallbackCompanionNpcState(companionRosterState?.joinedAtAffinity ?? 0), + gameState.npcStates[companion.npcId] ?? + buildFallbackCompanionNpcState( + companionRosterState?.joinedAtAffinity ?? 0, + ), ) : null; const npcEncounter = selection?.kind === 'npc' ? selection.encounter : null; - const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null; + const npcCharacter = npcEncounter?.characterId + ? getCharacterById(npcEncounter.characterId) + : null; const npcId = npcEncounter?.id ?? npcEncounter?.npcName ?? null; const npcState = npcEncounter ? normalizeNpcPersistentState( - gameState.npcStates[npcId ?? ''] ?? buildInitialNpcState(npcEncounter, gameState.worldType), + gameState.npcStates[npcId ?? ''] ?? + buildInitialNpcState(npcEncounter, gameState.worldType, gameState), ) : null; - const hostileNpcPresetId = npcEncounter?.hostileNpcPresetId ?? npcEncounter?.hostileNpcPresetId; - const hostileNpcPreset = hostileNpcPresetId && gameState.worldType - ? getHostileNpcPresetById(gameState.worldType, hostileNpcPresetId) - : null; - const npcBattleState = selection?.kind === 'npc' ? selection.battleState ?? null : null; - const archiveCharacter = selection?.kind === 'companion' - ? companionCharacter - : selection?.kind === 'npc' - ? npcCharacter + const hostileNpcPresetId = + npcEncounter?.hostileNpcPresetId ?? npcEncounter?.hostileNpcPresetId; + const hostileNpcPreset = + hostileNpcPresetId && gameState.worldType + ? getHostileNpcPresetById(gameState.worldType, hostileNpcPresetId) : null; - const archiveNpcState = selection?.kind === 'companion' - ? companionNpcState - : selection?.kind === 'npc' - ? npcState - : null; - const archiveAffinity = archiveNpcState?.affinity ?? 0; - const archivePublicSummary = archiveCharacter - ? getCharacterPublicBackstorySummary(archiveCharacter, gameState.worldType) - : null; - const unlockedBackstoryChapters = archiveCharacter - ? getUnlockedCharacterBackstoryChapters( - archiveCharacter, - archiveAffinity, - gameState.worldType, - ) - : []; - const lockedBackstoryChapters = archiveCharacter - ? getLockedCharacterBackstoryChapters( - archiveCharacter, - archiveAffinity, - gameState.worldType, - ) - : []; + const npcBattleState = + selection?.kind === 'npc' ? (selection.battleState ?? null) : null; + const archiveCharacter = + selection?.kind === 'companion' + ? companionCharacter + : selection?.kind === 'npc' + ? npcCharacter + : null; + const archiveNpcState = + selection?.kind === 'companion' + ? companionNpcState + : selection?.kind === 'npc' + ? npcState + : null; + const detailCharacter = + selection?.kind === 'player' + ? playerCharacter + : selection?.kind === 'companion' + ? companionCharacter + : npcCharacter; const privateChatUnlockAffinity = companionCharacter - ? getCharacterPrivateChatUnlockAffinity(companionCharacter, gameState.worldType) + ? getCharacterPrivateChatUnlockAffinity( + companionCharacter, + gameState.worldType, + ) : null; const privateChatUnlocked = Boolean( - selection?.kind === 'companion' - && companionCharacter - && companionNpcState?.recruited - && privateChatUnlockAffinity != null - && companionNpcState.affinity >= privateChatUnlockAffinity, + selection?.kind === 'companion' && + companionCharacter && + companionNpcState?.recruited && + privateChatUnlockAffinity != null && + companionNpcState.affinity >= privateChatUnlockAffinity, ); - const title = selection?.kind === 'player' - ? playerCharacter?.name ?? '主角' - : selection?.kind === 'companion' - ? companionCharacter?.name ?? '同行角色' - : npcEncounter?.npcName ?? '相遇角色'; + const title = + selection?.kind === 'player' + ? (playerCharacter?.name ?? '主角') + : selection?.kind === 'companion' + ? (companionCharacter?.name ?? '同行角色') + : (npcEncounter?.npcName ?? '相遇角色'); - const subtitle = selection?.kind === 'player' - ? playerCharacter?.title ?? '主角' - : selection?.kind === 'companion' - ? companionCharacter?.title ?? '同行角色' - : npcEncounter?.context ?? '相遇角色'; + const subtitle = + selection?.kind === 'player' + ? (playerCharacter?.title ?? '主角') + : selection?.kind === 'companion' + ? (companionCharacter?.title ?? '同行角色') + : (npcEncounter?.context ?? '相遇角色'); - const description = selection?.kind === 'player' - ? playerCharacter?.description ?? '' - : selection?.kind === 'companion' - ? companionCharacter?.description ?? '' - : npcEncounter?.npcDescription ?? ''; + const description = + selection?.kind === 'player' + ? (playerCharacter?.description ?? '') + : selection?.kind === 'companion' + ? (companionCharacter?.description ?? '') + : (npcEncounter?.npcDescription ?? ''); - const hp = selection?.kind === 'player' - ? gameState.playerHp - : selection?.kind === 'companion' - ? companion?.hp ?? (companionCharacter ? estimateCharacterMaxHp(companionCharacter) : 0) - : npcBattleState?.hp ?? estimateNpcMaxHp(npcCharacter); + const hp = + selection?.kind === 'player' + ? gameState.playerHp + : selection?.kind === 'companion' + ? (companion?.hp ?? + (companionCharacter ? estimateCharacterMaxHp(companionCharacter) : 0)) + : (npcBattleState?.hp ?? estimateNpcMaxHp(npcCharacter)); - const maxHp = selection?.kind === 'player' - ? gameState.playerMaxHp - : selection?.kind === 'companion' - ? companion?.maxHp ?? (companionCharacter ? estimateCharacterMaxHp(companionCharacter) : 0) - : npcBattleState?.maxHp ?? estimateNpcMaxHp(npcCharacter); + const maxHp = + selection?.kind === 'player' + ? gameState.playerMaxHp + : selection?.kind === 'companion' + ? (companion?.maxHp ?? + (companionCharacter ? estimateCharacterMaxHp(companionCharacter) : 0)) + : (npcBattleState?.maxHp ?? estimateNpcMaxHp(npcCharacter)); - const mana = selection?.kind === 'player' - ? gameState.playerMana - : selection?.kind === 'companion' - ? companion?.mana ?? (companionCharacter ? getCharacterMaxMana(companionCharacter) : 0) - : estimateNpcMaxMana(npcCharacter); + const mana = + selection?.kind === 'player' + ? gameState.playerMana + : selection?.kind === 'companion' + ? (companion?.mana ?? + (companionCharacter ? getCharacterMaxMana(companionCharacter) : 0)) + : estimateNpcMaxMana(npcCharacter); - const maxMana = selection?.kind === 'player' - ? gameState.playerMaxMana - : selection?.kind === 'companion' - ? companion?.maxMana ?? (companionCharacter ? getCharacterMaxMana(companionCharacter) : 0) - : estimateNpcMaxMana(npcCharacter); - const companionChatTarget = selection?.kind === 'companion' && companionCharacter - ? { - character: companionCharacter, - npcId: companion?.npcId ?? null, - roleLabel: '同行角色', - hp, - maxHp, - mana, - maxMana, - affinity: companionNpcState?.affinity ?? null, - } satisfies CharacterChatTarget - : null; - - const inventory = selection?.kind === 'player' - ? gameState.playerInventory - : selection?.kind === 'companion' - ? [] - : npcState?.inventory ?? []; - const attributeSchema = resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile); - const selectedAttributeProfile = selection?.kind === 'player' - ? (playerCharacter ? resolveCharacterAttributeProfile(playerCharacter, gameState.worldType, gameState.customWorldProfile) : null) - : selection?.kind === 'companion' - ? (companionCharacter ? resolveCharacterAttributeProfile(companionCharacter, gameState.worldType, gameState.customWorldProfile) : null) - : npcCharacter - ? resolveCharacterAttributeProfile(npcCharacter, gameState.worldType, gameState.customWorldProfile) - : npcEncounter - ? resolveEncounterAttributeProfile(npcEncounter, { - worldType: gameState.worldType, - customWorldProfile: gameState.customWorldProfile, - }) - : null; + const maxMana = + selection?.kind === 'player' + ? gameState.playerMaxMana + : selection?.kind === 'companion' + ? (companion?.maxMana ?? + (companionCharacter ? getCharacterMaxMana(companionCharacter) : 0)) + : estimateNpcMaxMana(npcCharacter); + const companionChatTarget = + selection?.kind === 'companion' && companionCharacter + ? ({ + character: companionCharacter, + npcId: companion?.npcId ?? null, + roleLabel: '同行角色', + hp, + maxHp, + mana, + maxMana, + affinity: companionNpcState?.affinity ?? null, + } satisfies CharacterChatTarget) + : null; + const inventory = useMemo( + () => + selection?.kind === 'player' + ? gameState.playerInventory + : selection?.kind === 'companion' && companionCharacter + ? buildCharacterInventoryPreviewItems( + companionCharacter, + gameState.worldType, + ) + : (npcState?.inventory ?? []), + [ + companionCharacter, + gameState.playerInventory, + gameState.worldType, + npcState?.inventory, + selection?.kind, + ], + ); + const attributeSchema = resolveAttributeSchema( + gameState.worldType, + gameState.customWorldProfile, + ); + const selectedAttributeProfile = + selection?.kind === 'player' + ? playerCharacter + ? resolveCharacterAttributeProfile( + playerCharacter, + gameState.worldType, + gameState.customWorldProfile, + ) + : null + : selection?.kind === 'companion' + ? companionCharacter + ? resolveCharacterAttributeProfile( + companionCharacter, + gameState.worldType, + gameState.customWorldProfile, + ) + : null + : npcCharacter + ? resolveCharacterAttributeProfile( + npcCharacter, + gameState.worldType, + gameState.customWorldProfile, + ) + : npcEncounter + ? resolveEncounterAttributeProfile(npcEncounter, { + worldType: gameState.worldType, + customWorldProfile: gameState.customWorldProfile, + }) + : null; const attributeRows = selectedAttributeProfile ? formatAttributeList(selectedAttributeProfile, attributeSchema) : []; - const genericNpcRumors = npcEncounter && !npcCharacter - ? buildEncounterAttributeRumors(npcEncounter, { - worldType: gameState.worldType, - customWorldProfile: gameState.customWorldProfile, - limit: 3, - }) - : []; - const genericNpcArchive = npcEncounter && npcState && !npcCharacter - ? buildGenericNpcArchiveSummary(npcEncounter, npcState, genericNpcRumors) - : null; - const genericNpcPersonality = npcEncounter && npcState && !npcCharacter - ? buildGenericNpcPersonalitySummary(npcEncounter, npcState.affinity, genericNpcRumors) - : null; - const genericNpcTechniqueCards = npcEncounter && npcState && !npcCharacter - ? buildGenericNpcTechniqueCards({ - encounter: npcEncounter, - npcState, - hasBattleState: Boolean(npcBattleState), - inventoryCount: npcState.inventory.length, - rumors: genericNpcRumors, - }) + const genericNpcRumors = + npcEncounter && !npcCharacter + ? buildEncounterAttributeRumors(npcEncounter, { + worldType: gameState.worldType, + customWorldProfile: gameState.customWorldProfile, + limit: 3, + }) + : []; + const genericNpcPersonality = + npcEncounter && npcState && !npcCharacter + ? buildGenericNpcPersonalitySummary( + npcEncounter, + npcState.affinity, + genericNpcRumors, + ) + : null; + const genericNpcTechniqueCards = + npcEncounter && npcState && !npcCharacter + ? buildGenericNpcTechniqueCards({ + encounter: npcEncounter, + npcState, + hasBattleState: Boolean(npcBattleState), + inventoryCount: npcState.inventory.length, + rumors: genericNpcRumors, + }) + : []; + const buildBreakdown = useMemo( + () => + selection?.kind === 'player' && playerCharacter + ? getPlayerBuildDamageBreakdown(gameState, playerCharacter) + : detailCharacter + ? getCompanionBuildDamageBreakdown( + detailCharacter, + gameState.worldType, + gameState.customWorldProfile, + ) + : null, + [detailCharacter, gameState, playerCharacter, selection?.kind], + ); + const selectedContributionRow = + buildBreakdown?.rows.find( + (row) => row.label === selectedContributionLabel, + ) ?? null; + const selectedContributionProducts = + buildBreakdown?.rows.map((row) => row.bonusDelta) ?? []; + const selectedContributionMinProduct = + selectedContributionProducts.length > 0 + ? Math.min(...selectedContributionProducts) + : 0; + const selectedContributionMaxProduct = + selectedContributionProducts.length > 0 + ? Math.max(...selectedContributionProducts) + : 1; + const selectedContributionAttributes = selectedContributionRow + ? getBuildContributionAttributeRows( + selectedContributionRow, + attributeSchema, + ) : []; + const selectedSkill = + detailCharacter?.skills.find((skill) => skill.id === selectedSkillId) ?? + null; + const selectedInventoryItem = + inventory.find((item) => item.id === selectedItemId) ?? null; + + useEffect(() => { + setSelectedSkillId(null); + setSelectedContributionLabel(null); + setSelectedItemId(null); + }, [selection?.kind, title]); + + useEffect(() => { + if (!selectedContributionLabel || selectedContributionRow) return; + setSelectedContributionLabel(null); + }, [selectedContributionLabel, selectedContributionRow]); + + useEffect(() => { + if (!selectedSkillId || selectedSkill) return; + setSelectedSkillId(null); + }, [selectedSkill, selectedSkillId]); + + useEffect(() => { + if (!selectedItemId || selectedInventoryItem) return; + setSelectedItemId(null); + }, [selectedInventoryItem, selectedItemId]); return ( {selection && ( event.stopPropagation()} + onClick={(event) => event.stopPropagation()} >
-
详情
-
{title}
+
+ 详情 +
+
+ {title} +
{subtitle}
{selection.kind === 'npc' && npcEncounter && npcState && (
- {getNpcBadge(npcEncounter, npcState.affinity, Boolean(npcBattleState))} + {getNpcBadge( + npcEncounter, + npcState.affinity, + Boolean(npcBattleState), + )}
)} -

{description}

+

+ {description} +

- -
-
- - {maxMana > 0 ? : null} -
-
- - {archiveCharacter && archiveNpcState ? ( -
-
- - -
-
-
好感度
-
{archiveNpcState.affinity}
-
-
-
关系阶段
-
- {describeRelationStance(archiveNpcState.affinity)} -
-
-
-
首遇状态
-
- {archiveNpcState.firstMeaningfulContactResolved - ? '已完成第一次正式对接' - : '初次接触未完成'} -
-
-
-
背景进度
-
- 已解锁 {unlockedBackstoryChapters.length} / {unlockedBackstoryChapters.length + lockedBackstoryChapters.length} -
-
-
- - {selection.kind === 'companion' && companionChatTarget ? ( -
-
-
-
私聊
-
- {privateChatUnlocked - ? '已解锁,可直接与该同伴单独交谈。' - : `好感达到 ${privateChatUnlockAffinity ?? 70} 后解锁,当前 ${companionNpcState?.affinity ?? 0}。`} -
-
- -
-
- ) : null} -
-
- ) : selection.kind === 'npc' && npcState ? ( -
-
- -
-
好感度: {npcState.affinity}
-
已招募: {npcState.recruited ? '是' : '否'}
-
-
-
- ) : null}
{selection.kind === 'player' && playerCharacter ? ( - <> -
-
- {playerCharacter.backstory} +
+
+
+ 你当前正以这名角色的身份推进故事与冒险,这里显示的是主视角角色在队伍中的关系位置与当前状态。
-
- -
-
- {playerCharacter.personality} +
+
+
+ 当前身份 +
+
+ 主角 +
+
+
+
+ 队伍定位 +
+
+ 队伍领队 +
+
+
+
+ 当前阶段 +
+
+ {gameState.inBattle ? '战斗中' : '探索中'} +
+
+
+
+ 同行人数 +
+
+ {gameState.companions.length} +
+
-
- -
- -
- -
- -
- +
+
) : archiveCharacter && archiveNpcState ? ( - <> -
-
-
-
公开印象
-
{archivePublicSummary}
-
- {unlockedBackstoryChapters.map(chapter => ( -
-
-
{chapter.title}
- - 已解锁 - -
-
{chapter.content}
-
- ))} - {lockedBackstoryChapters.map(chapter => ( -
-
-
{chapter.title}
- - 需好感 {chapter.affinityRequired} - -
-
{chapter.teaser}
-
- ))} -
-
- -
-
- {archiveCharacter.personality} -
-
- -
- -
- -
- -
- - ) : genericNpcArchive && genericNpcPersonality ? ( - <> -
-
-
-
公开印象
-
{genericNpcArchive.publicSummary}
-
-
-
已知线索
-
{genericNpcArchive.clueSummary}
-
-
-
- -
-
- {genericNpcPersonality} -
-
- -
-
- {genericNpcTechniqueCards.map(card => ( -
-
{card.title}
-
{card.detail}
-
- ))} -
-
- +
+ +
+ ) : selection.kind === 'npc' && npcState ? ( +
+ +
) : null} - {attributeRows.length > 0 ? ( -
-
- {attributeRows.map(({slot, value}) => ( -
-
{slot.name}
-
{value}
-
{slot.definition}
+ {selection.kind === 'companion' && companionChatTarget ? ( +
+
+
+
+
+ 私聊 +
+
+ {privateChatUnlocked + ? '已解锁,可直接与该同伴单独交谈。' + : `好感达到 ${privateChatUnlockAffinity ?? 70} 后解锁,当前 ${companionNpcState?.affinity ?? 0}。`} +
+
+ +
+
+
+ ) : null} + +
+
+
+ + {maxMana > 0 ? ( + + ) : null} +
+ {buildBreakdown ? ( + + setSelectedContributionLabel(row.label) + } + /> + ) : null} + {attributeRows.length > 0 ? ( +
+ {attributeRows.map(({ slot, value }) => ( +
+
+ {slot.name} +
+
+ {value} +
+
+ {slot.definition} +
+
+ ))} +
+ ) : ( +
+ 暂无属性信息 +
+ )} +
+
+ + {detailCharacter ? ( +
+ +
+ ) : genericNpcTechniqueCards.length > 0 ? ( +
+
+ {genericNpcPersonality ? ( +
+ {genericNpcPersonality} +
+ ) : null} + {genericNpcTechniqueCards.map((card) => ( +
+
+ {card.title} +
+
+ {card.detail} +
))}
) : null} +
+ {inventory.length > 0 ? ( + setSelectedItemId(item.id)} + /> + ) : ( +
暂无物品
+ )} +
+ {selection.kind === 'npc' && npcEncounter ? (
名称: {npcEncounter.npcName}
背景: {npcEncounter.context}
-
类型: {getNpcBadge(npcEncounter, npcState?.affinity ?? 0, Boolean(npcBattleState))}
- {npcBattleState ?
战斗模式: {npcBattleState.combatMode === 'melee' ? '近战' : '远程'}
: null} +
+ 类型:{' '} + {getNpcBadge( + npcEncounter, + npcState?.affinity ?? 0, + Boolean(npcBattleState), + )} +
+ {npcBattleState ? ( +
+ 战斗模式:{' '} + {npcBattleState.combatMode === 'melee' + ? '近战' + : '远程'} +
+ ) : null}
) : null} - -
- -
)} + + {selectedContributionRow && detailCharacter && ( + setSelectedContributionLabel(null)} + > + event.stopPropagation()} + > +
+
+
+ 属性适配解析 +
+
+ {selectedContributionRow.label} +
+
+ {detailCharacter.name} +
+
+ +
+ +
+
+
+
+
+ {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)} +
+
+
+
+
+
+ ))} +
+ ) : ( +
+ 当前标签还没有可展示的属性适配明细。 +
+ )} +
+ + + )} + + {selectedSkill && detailCharacter ? ( + setSelectedSkillId(null)} + > + event.stopPropagation()} + > +
+
+
+ 技能详情 +
+
+ {selectedSkill.name} +
+
+ {detailCharacter.name} +
+
+ +
+ +
+
+ + {getSkillDeliveryLabel(selectedSkill)} + + + {getSkillStyleLabel(selectedSkill)} + + {selectedSkill.buildBuffs?.length ? ( + + 附带 {selectedSkill.buildBuffs.length} 个状态标签 + + ) : null} +
+ +
+
+
+ 伤害 +
+
+ {selectedSkill.damage} +
+
+
+
+ 法力 +
+
+ {selectedSkill.manaCost} +
+
+
+
+ 冷却 +
+
+ {selectedSkill.cooldownTurns} +
+
+
+
+ 距离 +
+
+ {selectedSkill.range} +
+
+
+ +
+ {selectedSkill.name} 属于{getSkillStyleLabel(selectedSkill)} + 路线,通常以{getSkillDeliveryLabel(selectedSkill)}方式出手, + 造成 {selectedSkill.damage} 点伤害,消耗{' '} + {selectedSkill.manaCost} 点灵力,冷却{' '} + {selectedSkill.cooldownTurns} 回合。 + {selectedSkill.effects?.length + ? ` 该技能还会触发 ${selectedSkill.effects.length} 段战斗特效。` + : ''} +
+ + {selectedSkill.buildBuffs?.length ? ( +
+
+ 附带状态标签 +
+
+ {selectedSkill.buildBuffs.map((buff) => ( + + {buff.name} / {buff.tags.join('、')} /{' '} + {buff.durationTurns} 回合 + + ))} +
+
+ ) : null} +
+
+
+ ) : null} + + {(gameState.playerCharacter ?? detailCharacter) ? ( + setSelectedItemId(null)} + /> + ) : null} ); } diff --git a/src/components/AdventurePanel.tsx b/src/components/AdventurePanel.tsx index fb295b32..312cef2d 100644 --- a/src/components/AdventurePanel.tsx +++ b/src/components/AdventurePanel.tsx @@ -105,7 +105,7 @@ function AdventurePanelOverlayLoadingFallback() { className="pixel-nine-slice pixel-modal-shell flex min-h-32 w-full max-w-sm items-center justify-center px-5 py-6 text-center text-[11px] uppercase tracking-[0.24em] text-zinc-400 shadow-[0_24px_80px_rgba(0,0,0,0.55)]" style={getNineSliceStyle(UI_CHROME.modalPanel)} > - Loading adventure panels + 正在载入冒险面板
); @@ -755,7 +755,7 @@ export function AdventurePanel({ key: 'quests-completed', label: '完成任务', value: `${statistics.questsCompleted}`, - detail: `已接 ${statistics.questsAccepted} / 已交�?${statistics.questsTurnedIn}`, + detail: `已接 ${statistics.questsAccepted} / 已交 ${statistics.questsTurnedIn}`, icon: ScrollText, }, { @@ -776,7 +776,7 @@ export function AdventurePanel({ key: 'inventory', label: '背包物品', value: `${statistics.inventoryItemCount}`, - detail: `${statistics.inventoryStackCount} 组物�?/ 使用 ${statistics.itemsUsed} 次`, + detail: `${statistics.inventoryStackCount} 组物品 / 使用 ${statistics.itemsUsed} 次`, icon: Backpack, }, { @@ -790,7 +790,7 @@ export function AdventurePanel({ key: 'scene', label: '当前区域', value: statistics.currentSceneName, - detail: '本次冒险所在地�?', + detail: '本次冒险所在地', icon: MapPinned, }, ], diff --git a/src/components/AffinityStatusCard.tsx b/src/components/AffinityStatusCard.tsx index 58e64248..bf1f7423 100644 --- a/src/components/AffinityStatusCard.tsx +++ b/src/components/AffinityStatusCard.tsx @@ -1,72 +1,164 @@ type AffinityLevelMeta = { - value: number; label: string; + minAffinity: number; + nextAffinity: number | null; description: string; accentClassName: string; }; -const DEFAULT_AFFINITY_LEVEL: AffinityLevelMeta = { - value: 0, - label: '戒备', - description: '对方仍保持明显距离,只会给出谨慎而有限的回应。', - accentClassName: 'border-white/12 bg-white/8 text-zinc-100', +type AffinityProgressMarker = { + value: number; + label: string; }; +const AFFINITY_PROGRESS_MIN = -40; +const AFFINITY_PROGRESS_MAX = 90; + const AFFINITY_LEVELS: AffinityLevelMeta[] = [ - DEFAULT_AFFINITY_LEVEL, { - value: 15, + label: '敌对', + minAffinity: Number.NEGATIVE_INFINITY, + nextAffinity: 0, + description: + '好感落入负值区间后,会按敌对关系处理,靠近时通常直接进入对战。', + accentClassName: 'border-rose-300/28 bg-rose-500/14 text-rose-100', + }, + { + label: '戒备', + minAffinity: 0, + nextAffinity: 15, + description: '对方仍保持明显距离,只会给出谨慎而有限的回应。', + accentClassName: 'border-white/12 bg-white/8 text-zinc-100', + }, + { label: '缓和', + minAffinity: 15, + nextAffinity: 30, description: '戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。', accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100', }, { - value: 30, label: '友善', + minAffinity: 30, + nextAffinity: 60, description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。', accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100', }, { - value: 60, label: '信任', + minAffinity: 60, + nextAffinity: 90, description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。', accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100', }, { - value: 90, label: '深交', + minAffinity: 90, + nextAffinity: null, description: '关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。', accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100', }, ]; +const DEFAULT_AFFINITY_LEVEL = AFFINITY_LEVELS[0]!; + +const AFFINITY_PROGRESS_MARKERS: AffinityProgressMarker[] = [ + { value: -40, label: '敌对' }, + { value: 0, label: '戒备' }, + { value: 15, label: '缓和' }, + { value: 30, label: '友善' }, + { value: 60, label: '信任' }, + { value: 90, label: '深交' }, +]; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + function getAffinityLevelMeta(affinity: number) { - return [...AFFINITY_LEVELS].reverse().find(level => affinity >= level.value) ?? DEFAULT_AFFINITY_LEVEL; + return ( + [...AFFINITY_LEVELS] + .reverse() + .find((level) => affinity >= level.minAffinity) ?? DEFAULT_AFFINITY_LEVEL + ); } -function getNextAffinityLevelMeta(affinity: number) { - return AFFINITY_LEVELS.find(level => affinity < level.value) ?? null; -} - -export function AffinityStatusCard({affinity}: {affinity: number}) { +function getNextAffinityMarker(affinity: number) { const currentLevel = getAffinityLevelMeta(affinity); - const nextLevel = getNextAffinityLevelMeta(affinity); - const maxVisibleAffinity = AFFINITY_LEVELS[AFFINITY_LEVELS.length - 1]?.value ?? 1; - const progress = Math.max(0, Math.min(1, affinity / maxVisibleAffinity)); + if (currentLevel.nextAffinity == null) return null; + + return ( + AFFINITY_PROGRESS_MARKERS.find( + (marker) => marker.value === currentLevel.nextAffinity, + ) ?? null + ); +} + +function getAffinityProgressRatio(value: number) { + return clamp( + (value - AFFINITY_PROGRESS_MIN) / + (AFFINITY_PROGRESS_MAX - AFFINITY_PROGRESS_MIN), + 0, + 1, + ); +} + +function getAnchorTransform(ratio: number) { + if (ratio <= 0.02) return 'translateX(0)'; + if (ratio >= 0.98) return 'translateX(-100%)'; + return 'translateX(-50%)'; +} + +function isMarkerReached(marker: AffinityProgressMarker, affinity: number) { + if (marker.value < 0) { + return affinity < 0; + } + + return affinity >= marker.value; +} + +export function AffinityStatusCard({ affinity }: { affinity: number }) { + const currentLevel = getAffinityLevelMeta(affinity); + const nextLevel = getNextAffinityMarker(affinity); + const currentRatio = getAffinityProgressRatio(affinity); + const zeroRatio = getAffinityProgressRatio(0); + const activeMarkerValue = + currentLevel.minAffinity <= AFFINITY_PROGRESS_MIN + ? AFFINITY_PROGRESS_MIN + : currentLevel.minAffinity; + const fillLeftRatio = Math.min(currentRatio, zeroRatio); + const fillWidthRatio = Math.abs(currentRatio - zeroRatio); + const fillWidthPercent = + fillWidthRatio > 0 ? `${Math.max(fillWidthRatio * 100, 1)}%` : '0%'; + const currentPointerTone = + affinity < 0 + ? 'border-rose-100/90 bg-rose-300 shadow-[0_0_16px_rgba(251,113,133,0.45)]' + : 'border-sky-50/90 bg-sky-300 shadow-[0_0_18px_rgba(125,211,252,0.35)]'; + const fillGradient = + affinity < 0 + ? 'linear-gradient(90deg, rgba(251,113,133,0.92) 0%, rgba(253,164,175,0.98) 100%)' + : 'linear-gradient(90deg, rgba(125,211,252,0.92) 0%, rgba(251,191,36,0.94) 60%, rgba(251,113,133,0.96) 100%)'; return (
-
好感等级
+
+ 好感等级 +
- + {currentLevel.label} - 当前好感 {affinity} + + 当前好感 {affinity} +
+
{nextLevel ? ( <> @@ -83,76 +175,117 @@ export function AffinityStatusCard({affinity}: {affinity: number}) { )}
-

{currentLevel.description}

+ +

+ {currentLevel.description} +

-
好感进度
-
节点数值表示进入对应等级所需的好感度。
+
+ 好感进度 +
+
+ 0 是战斗分界线,低于 0 + 会直接进入对战;其余节点表示进入对应阶段所需的最低好感。 +
-
-
+
+
+
+
- {AFFINITY_LEVELS.map((level, index) => { - const ratio = maxVisibleAffinity > 0 ? level.value / maxVisibleAffinity : 0; - const isReached = affinity >= level.value; - const isCurrent = currentLevel.value === level.value; - const isFirst = index === 0; - const isLast = index === AFFINITY_LEVELS.length - 1; +
+
+
+ + {AFFINITY_PROGRESS_MARKERS.map((marker) => { + const markerRatio = getAffinityProgressRatio(marker.value); + const isReached = isMarkerReached(marker, affinity); + const isActive = marker.value === activeMarkerValue; return (
-
- {isCurrent ? ( -
- ) : null} - {isReached && !isCurrent ? ( -
- ) : null} +
+
+ {isActive ? ( +
+ ) : null} +
+
+
+
+
-
+ {marker.label} +
+
+ {marker.value}
); })} - -
- {AFFINITY_LEVELS.map(level => { - const isReached = affinity >= level.value; - - return ( -
-
- {level.label} -
-
- {level.value} -
-
- ); - })} -
diff --git a/src/components/CharacterDetailModal.tsx b/src/components/CharacterDetailModal.tsx index f096bdc0..3b8d8b03 100644 --- a/src/components/CharacterDetailModal.tsx +++ b/src/components/CharacterDetailModal.tsx @@ -260,7 +260,7 @@ export function CharacterDetailModal({
-
+
diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index 025f5402..cf2b8395 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -111,6 +111,12 @@ function StatusRow({ ); } +function getGenderLabel(gender: Character['gender']) { + if (gender === 'female') return '女'; + if (gender === 'male') return '男'; + return '未明'; +} + const SKILL_STYLE_LABELS = { burst: '爆发', steady: '稳态', @@ -427,7 +433,7 @@ export function CharacterPanel({
)} -
闃熶紞鎴愬憳
+
队伍成员
{partyMembers.map(member => (
@@ -204,7 +204,7 @@ export function CompanionCampModal({ onClick={() => setSelectedSwapNpcId(companion.npcId)} className={`rounded-lg border px-3 py-2 text-xs ${selectedForSwap ? 'border-sky-400/30 bg-sky-500/15 text-sky-100' : 'border-white/10 bg-white/5 text-zinc-200'} ${inBattle ? 'opacity-50' : ''}`} > - Set Swap Slot + 设为替换位
); }) : (
- No active companions right now. + 当前没有已出战的同行者。
)}
@@ -228,12 +228,12 @@ export function CompanionCampModal({
-
Reserve Team
+
后备队伍
- Reserve companions stay ready in camp until you call them back. + 后备同行者会在营地待命,随时可以重新召回。
- +
@@ -254,9 +254,9 @@ export function CompanionCampModal({
{character.name}
{character.title}
- - - + + +
@@ -270,13 +270,13 @@ export function CompanionCampModal({ : 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100' }`} > - {needsSwap ? 'Swap Into Team' : 'Activate'} + {needsSwap ? '换入队伍' : '编入队伍'}
); }) : (
- No reserve companions yet. + 当前还没有后备同行者。
)}
@@ -284,7 +284,7 @@ export function CompanionCampModal({
-
Camp Mood
+
营地气氛
{campMoments.map(moment => (
diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 5a2c6d19..05889850 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -162,7 +162,21 @@ export function CustomWorldEntityCatalog({ const filteredPlayable = useMemo( () => profile.playableNpcs.filter(role => !deferredSearch - || matchText([role.name, role.title, role.description, role.backstory, role.personality, ...role.tags].join(' '), deferredSearch), + || matchText( + [ + role.name, + role.title, + role.role, + role.description, + role.backstory, + role.personality, + role.motivation, + role.combatStyle, + ...role.relationshipHooks, + ...role.tags, + ].join(' '), + deferredSearch, + ), ), [deferredSearch, profile.playableNpcs], ); @@ -170,7 +184,21 @@ export function CustomWorldEntityCatalog({ const filteredStory = useMemo( () => profile.storyNpcs.filter(npc => !deferredSearch - || matchText([npc.name, npc.role, npc.description, npc.motivation, ...npc.relationshipHooks].join(' '), deferredSearch), + || matchText( + [ + npc.name, + npc.title, + npc.role, + npc.description, + npc.backstory, + npc.personality, + npc.motivation, + npc.combatStyle, + ...npc.relationshipHooks, + ...npc.tags, + ].join(' '), + deferredSearch, + ), ), [deferredSearch, profile.storyNpcs], ); @@ -320,9 +348,12 @@ export function CustomWorldEntityCatalog({
{role.description}
{role.backstory}
+
身份:{role.role}
+
初始好感:{role.initialAffinity}
性格:{role.personality}
战斗:{role.combatStyle}
+
动机:{role.motivation}
{role.tags.map(tag => ( @@ -343,7 +374,7 @@ export function CustomWorldEntityCatalog({ {activeTab === 'story' ? (
- 每个场景角色都可以单独组合中世纪奇幻角色形象,并同步到进入世界后的展示效果。 + 场景角色默认可组合中世纪奇幻角色形象;当角色文本明显指向怪物型 NPC 且初始好感偏敌对时,预览也会自动尝试引用怪物素材。
{filteredStory.length === 0 ? ( @@ -362,18 +393,22 @@ export function CustomWorldEntityCatalog({ >
{npc.description}
+
+
头衔:{npc.title}
+
初始好感:{npc.initialAffinity}
+
性格:{npc.personality || '未填写'}
+
战斗:{npc.combatStyle || '未填写'}
+
+ {npc.backstory ? ( +
背景:{npc.backstory}
+ ) : null}
动机:{npc.motivation}
{npc.relationshipHooks.map(hook => ( @@ -381,6 +416,11 @@ export function CustomWorldEntityCatalog({ {hook} ))} + {npc.tags.map(tag => ( + + {tag} + + ))}
diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index d8500d1e..63b980e0 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -77,6 +77,14 @@ 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 useDraft(value: T) { const [draft, setDraft] = useState(value); useEffect(() => setDraft(value), [value]); @@ -726,8 +734,8 @@ function StoryNpcVisualEditorModal({ /> {isAiGenerateOpen ? ( setIsAiGenerateOpen(false)} /> ) : null} @@ -900,6 +908,14 @@ function PlayableNpcEditor({ } /> + + + setDraft((current) => ({ ...current, role: value })) + } + /> +