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 === 'companion' && companionCharacter ? (
+ ) : selection.kind === 'companion' &&
+ companionCharacter ? (
) : npcEncounter ? (
-
+
) : null}
{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.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 => (
-
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 }))
+ }
+ />
+
+
+
+
+
+ setDraft((current) => ({
+ ...current,
+ initialAffinity: clampInitialAffinity(
+ value,
+ current.initialAffinity,
+ ),
+ }))
+ }
+ />
+
+
+