游戏设置-整体难度 是这套暗黑破坏神2 MOD 修改工具中非常典型的全局规则模块,对应的核心数据文件是 difficultylevels.txt。这张表并不负责单个怪物、单张地图或者单件装备的局部参数,而是统一定义普通、噩梦、地狱等难度下的一组全局平衡规则,包括抗性惩罚、死亡经验惩罚、吸血吸蓝换算、怪物强化系数、PVP 伤害倍率以及赌博系统概率参数。只要这张表发生修改,影响范围就会同时落到角色成长体验、战斗压力、掉落预期和玩法节奏上。

也正因为影响范围广,difficultylevels.txt 很适合被拆成独立编辑页,而不是混在通用 TXT 表格编辑器里直接逐列修改。当前项目源码采用的是"按难度行切换 + 按字段组展示"的界面结构,左侧负责切换 Normal、Nightmare、Hell 之类的难度行,右侧负责按"基础惩罚""控制与吸取""怪物强化""PVP倍率""赌博参数"等主题组织字段。这样的页面设计既符合工具使用场景,也更符合模块开发上的长期维护需求。
文章目录
文件说明
从源码结构看,游戏设置-整体难度 并不是单个 Vue 文件堆出的一页表单,而是由页面入口、子页签挂载、字段分组配置、列元数据映射、状态控制器、通用读取缓存、保存 helper 与 Tauri 文件命令共同拼装出来的。页面层只负责把难度行和分组字段展示出来,字段中文说明来自 columns.json,字段归组来自 grouping.json 和 layout.json,实际读取与保存则落到 settingsHelpers.js 和 Rust 的 files.rs 上。

模块文件职责表如下。这个表更适合从工程协作视角理解当前模块,因为它能直接看出哪些文件属于页面壳层,哪些属于配置层,哪些属于解析层,哪些属于数据读写层。
| 文件名 | 文件类型 | 模块职责 | 与界面或数据处理的关系 | 备注 |
|---|---|---|---|---|
src/pages/HomePage.vue |
Vue | 主页面壳层 | 当主导航切到"游戏设置"时挂载 SettingsTab,并在页头显示当前加载状态 |
页面壳层 |
src/modules/settings/SettingsTab.vue |
Vue | 游戏设置总入口 | 子页签切到 difficultyLevel 时渲染 SettingsDifficultyLevelTab |
子模块挂载层 |
src/modules/settings/difficultyLevel/SettingsDifficultyLevelTab.vue |
Vue | 整体难度编辑页 | 左侧切换难度行,右侧切换字段组并渲染编辑控件 | 当前模块核心界面 |
src/pages/home/useHomePageController.js |
JS | 状态装配与计算 | 生成 difficultySettingRows、difficultyGroupTabs、currentDifficultyActiveGroup 等页面所需数据结构 |
解析与状态层 |
src/pages/home/settingsHelpers.js |
JS | 读取与保存 helper | 负责 loadDifficultySettings、updateDifficultySettingCell、saveDifficultySettings |
数据交互层 |
src/pages/home/tableDataLoader.js |
JS | 表读取缓存 | 对 py_load_table 结果做缓存、去重和失效控制 |
读取性能层 |
src/modules/settings/difficultyLevel/difficultylevels.grouping.json |
JSON | 字段分组规则 | 通过 columns/includes/patterns 定义字段属于哪个分组 |
配置层 |
src/modules/settings/difficultyLevel/difficultylevels.layout.json |
JSON | 分组顺序配置 | 控制"基础惩罚、控制与吸取、怪物强化、PVP倍率、赌博参数"的展示顺序 | 布局层 |
src/modules/settings/difficultyLevel/difficultylevelsGrouping.js |
JS | 分组解析器 | 把 JSON 分组规则转换成运行时可调用的 resolveDifficultyGroupByField |
解析层 |
src/assets/resources/index.json |
JSON | 列元数据索引 | 为 difficultylevels.txt.columns.json 提供索引入口,供控制器动态加载 |
元数据入口 |
src/assets/resources/files/difficultylevels.txt.columns.json |
JSON | 字段中文与说明元数据 | 提供字段中文标签、类型、说明,用于标签和 tooltip 展示 | 字段说明层 |
src-tauri/src/main.rs |
Rust | Tauri 命令注册 | 暴露 py_load_table 与 py_save_table_json 给前端调用 |
前后端桥接层 |
src-tauri/src/commands/files.rs |
Rust | TXT 读写命令 | 负责把 difficultylevels.txt 解析成 header + rows,以及保存时回写并生成备份 |
数据层 |
这组文件之间的关系很明确。HomePage.vue 与 SettingsTab.vue 解决的是"模块放在哪"这个问题,SettingsDifficultyLevelTab.vue 解决的是"页面长什么样"这个问题,useHomePageController.js 与 difficultylevelsGrouping.js 解决的是"字段怎样组织给界面消费"这个问题,settingsHelpers.js 与 files.rs 解决的是"数据怎样读进来、怎样保存回去"这个问题。当前模块没有单独的后端接口服务,也没有额外的 Python 业务脚本参与运行时保存链路,能直接确认的主流程就是 Vue + JS + Tauri Rust 这一条本地桌面应用链路。
再看真正参与界面构建、字段展示、布局控制、数据更新和保存逻辑的字段、配置项与方法。这里不把所有变量机械展开,只保留当前模块主链路中真正发挥作用的部分。
| 字段/配置项/方法 | 所属文件 | 作用 | 在界面中的体现 | 修改后的影响 |
|---|---|---|---|---|
difficultySettingState.file |
src/pages/home/useHomePageController.js |
定义数据源文件 | 页面始终围绕 difficultylevels.txt 工作 |
决定读取与保存目标 |
difficultySettingRows |
src/pages/home/useHomePageController.js |
生成难度行列表 | 左侧垂直按钮区显示 Normal/Nightmare/Hell |
影响当前编辑行 |
difficultySettingState.activeName |
SettingsDifficultyLevelTab.vue / controller |
记录当前选中的难度名称 | 左侧按钮高亮与右侧字段内容联动 | 决定当前编辑的是哪一行 |
currentDifficultyRowIndex |
src/pages/home/useHomePageController.js |
计算当前难度行下标 | 页面不直接显示,但所有字段都依赖该下标取值 | 决定数据读写行位置 |
currentDifficultyRowFields |
src/pages/home/useHomePageController.js |
把 header + row + colMeta 转成字段对象数组 |
右侧字段标签、tooltip、只读状态、当前值都由它提供 | 决定界面可消费的数据结构 |
resolveDifficultyGroupByField |
src/modules/settings/difficultyLevel/difficultylevelsGrouping.js |
根据列名规则给字段归组 | 顶部分组按钮和字段归类都依赖它 | 修改规则会改变字段所属分组 |
difficultyGroupTabs |
src/pages/home/useHomePageController.js |
输出当前可见分组名称 | 右侧顶部的分组切换按钮 | 决定界面展示顺序 |
currentDifficultyActiveGroup |
src/pages/home/useHomePageController.js |
输出当前选中分组的字段集合 | 页面网格区只渲染这个分组内字段 | 决定当前画面可编辑字段 |
difficultySettingColTooltip |
src/pages/home/settingsHelpers.js |
组合中文名、中文说明和英文说明 | 悬停标签时显示字段说明 | 提升字段可读性 |
updateDifficultySettingCell |
src/pages/home/settingsHelpers.js |
把输入值写入内存中的 rows |
输入即生效到页面状态 | 决定保存时写回的值 |
loadDifficultySettings |
src/pages/home/settingsHelpers.js |
读取文件并初始化状态 | 刷新按钮与进入页面时触发 | 重建 header、rows、行索引与元数据 |
saveDifficultySettings |
src/pages/home/settingsHelpers.js |
保存当前内存数据 | 点击保存按钮触发 | 调用 py_save_table_json 落盘并备份 |
setCachedTableResult |
src/pages/home/settingsHelpers.js |
保存后更新前端缓存 | 页面无感,但避免旧数据回显 | 保持界面与文件内容一致 |
py_load_table / py_save_table_json |
src-tauri/src/main.rs |
Tauri 命令入口 | 界面无直接按钮,但所有读写通过它们完成 | 构成模块数据链路 |
整体难度模块从数据字段角度也很值得单独说明,因为它不像怪物等级工具那样只围绕一个"最大等级"参数运作,而是实打实地编辑整张全局规则表。下表挑选出真正具有代表性的字段,既能反映页面分组思路,也能反映这个模块在平衡设计中的实际价值。
| 字段名 | 中文含义 | 所属分组 | 前端展示方式 | 实际用途说明 |
|---|---|---|---|---|
Name |
难度名称 | 行标识 | 在当前行中只读展示,不参与数值输入 | 用来区分 Normal、Nightmare、Hell |
ResistPenalty |
抗性惩罚(资料片) | 基础惩罚 | 数字输入 | 影响高难度下角色抗性起点 |
ResistPenaltyNonExpansion |
抗性惩罚(经典) | 基础惩罚 | 数字输入 | 影响经典模式下抗性惩罚 |
DeathExpPenalty |
死亡经验惩罚 | 基础惩罚 | 数字输入 | 影响死亡后经验损失比例 |
StaticFieldMin |
静电下限 | 基础惩罚 | 数字输入 | 限制静电力场压低怪物生命的下限 |
LifeStealDivisor |
生命偷取除数 | 控制与吸取 | 数字输入 | 影响吸血在不同难度下的换算结果 |
ManaStealDivisor |
法力偷取除数 | 控制与吸取 | 数字输入 | 影响吸蓝在不同难度下的换算结果 |
MonsterSkillBonus |
怪物技能加成 | 控制与吸取 | 数字输入 | 影响怪物技能强度的全局加成 |
UniqueDamageBonus |
唯一怪伤害加成 | 怪物强化 | 数字输入 | 提高金怪威胁度 |
ChampionDamageBonus |
精英怪伤害加成 | 怪物强化 | 数字输入 | 提高蓝精英威胁度 |
PlayerDamagePercentVSPlayer |
玩家对玩家伤害系数 | PVP倍率 | 数字输入 | 调整 PVP 输出平衡 |
MercenaryDamagePercentVSBoss |
佣兵对 Boss 伤害系数 | PVP倍率 | 数字输入 | 调整佣兵对首领输出效率 |
GambleRare |
赌博稀有阈值 | 赌博参数 | 数字输入 | 调整赌博出稀有装备概率 |
GambleUnique |
赌博唯一阈值 | 赌博参数 | 数字输入 | 调整赌博出唯一装备概率 |
GambleUltra |
赌博精英升级阈值 | 赌博参数 | 数字输入 | 调整赌博升级到精英品质的概率参数 |
从这些字段就能看出,整体难度页不是单纯的"难度倍率页",它实际承担的是全局玩法平衡控制台的角色。抗性惩罚决定角色承压,偷取除数决定续航,怪物强化决定战斗压力,PVP 倍率决定角色与佣兵之间的伤害关系,赌博参数又把装备产出的一部分概率控制拉进了同一张表。也就是说,把这张表做成独立页面,不只是界面整理问题,更是系统分层问题。
软件开发
从开发实现角度看,游戏设置-整体难度 采用的是非常典型的配置驱动方案。页面组件不直接写死所有字段,也不在模板中硬编码每一列出现在哪个位置,而是先把 difficultylevels.txt 读成原始表格结构,再结合列元数据把每一列转成带中文名、tooltip、当前值、只读状态的字段对象,接着再通过 difficultylevels.grouping.json 和 difficultylevels.layout.json 决定分组归属和展示顺序。这样做的核心好处在于,字段增加、字段改名、分组调整都可以在配置层和控制器层完成,不需要把页面模板改成难以维护的大体量表单。
核心实现结构表如下。它能比较清楚地反映当前模块为什么适合做配置驱动,而不是手工表单。
| 实现层 | 核心文件/代码 | 作用说明 | 设计意义 |
|---|---|---|---|
| 页面壳层 | HomePage.vue / SettingsTab.vue |
主导航进入"游戏设置",子页签进入"整体难度" | 保持工具导航结构清晰 |
| 行选择层 | useHomePageController.js 中 difficultySettingRows |
从表数据中提取难度行,并按 Normal/Nightmare/Hell 排序 |
用一张表支持多行切换,而不是多页面复制 |
| 字段解析层 | currentDifficultyRowFields |
把原始 header、row、colMeta 转成字段对象 | 让 UI 只消费统一字段结构 |
| 分组配置层 | difficultylevels.grouping.json / difficultylevels.layout.json / difficultylevelsGrouping.js |
按列名规则把字段归入固定业务组 | 降低页面硬编码程度 |
| 控件复用层 | SettingsDifficultyLevelTab.vue + 通用组件 |
通过 NumberStepper、VersionSelect、EnabledSelect、ClassSelect 复用输入控件 |
让设置类模块共享同一套交互模式 |
| 读取保存层 | settingsHelpers.js |
负责读取、写值、保存、缓存同步、编辑器联动 | 页面组件不直接接触文件细节 |
| 后端数据层 | files.rs |
负责 TXT 编码读取、JSON 反序列化、写回与备份 | 把真实落盘逻辑留在 Rust 侧 |
页面入口代码
vue
const settingsTabComponents = {
charLevel: SettingsCharLevelTab,
charStats: SettingsCharStatsTab,
skillLevel: SettingsSkillLevelTab,
monLevel: SettingsMonLevelTab,
level: SettingsMonLevelTab,
difficultyLevel: SettingsDifficultyLevelTab,
hireling: SettingsHirelingTab,
}
const activeSettingsTabComponent = computed(() => settingsTabComponents[settingsTab.value] || SettingsCharLevelTab)
function switchSettingsTab(nextTab) {
settingsTab.value = nextTab
if (nextTab === 'skillLevel') {
levelToolTab.value = 'skill'
return
}
if (nextTab === 'monLevel' || nextTab === 'level') {
levelToolTab.value = 'mon'
}
}
js
watch(settingsTab, async (value) => {
if (activeTab.value !== 'settings') return
if (value === 'difficultyLevel') {
await loadDifficultySettings()
return
}
})
这一段实现说明,整体难度模块的入口并不复杂。SettingsTab.vue 负责把 difficultyLevel 映射到 SettingsDifficultyLevelTab,useHomePageController.js 负责在真正切到这个子页签时调用 loadDifficultySettings()。页面壳层只处理模块挂载,数据初始化则交给控制器监听完成,这样既避免了页面组件里混入太多初始化逻辑,也让"切页签就加载对应表"的行为保持统一。
难度行与字段分组计算代码
js
const DIFFICULTY_NAME_ZH_MAP = {
normal: '普通',
nightmare: '噩梦',
hell: '地狱',
}
function normalizeDifficultyName(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '')
}
function difficultyOrder(value) {
const key = normalizeDifficultyName(value)
if (key === 'normal') return 0
if (key === 'nightmare') return 1
if (key === 'hell') return 2
return 100
}
const difficultySettingRows = computed(() => {
const idx = difficultySettingState.nameColIndex
return difficultySettingState.rows
.map((row, rowIndex) => {
const name = String(row?.[idx] || '').trim()
return {
rowIndex,
name,
order: difficultyOrder(name),
label: difficultyLabel(name),
}
})
.filter((item) => item.name.length > 0)
.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order
return a.rowIndex - b.rowIndex
})
})
const currentDifficultyRowFields = computed(() => {
if (currentDifficultyRowIndex.value < 0) return []
const row = difficultySettingState.rows[currentDifficultyRowIndex.value] || []
const hasMap = Object.keys(difficultySettingState.colMeta || {}).length > 0
return difficultySettingState.header
.map((col, index) => {
const meta = difficultySettingColMeta(col)
return {
index,
colName: col,
en: col,
zh: (meta?.zh || '').trim(),
meta,
tooltip: difficultySettingColTooltip(col),
value: row[index] || '',
readonly: index === difficultySettingState.nameColIndex,
}
})
.filter((field) => {
if (!hasMap) return true
return field.zh.length > 0
})
})
const currentDifficultyFieldGroups = computed(() => {
const order = DIFFICULTY_GROUP_ORDER
const groups = new Map()
for (const field of currentDifficultyRowFields.value) {
const groupName = resolveDifficultyGroupByField(field)
if (!String(groupName || '').trim()) continue
if (!groups.has(groupName)) groups.set(groupName, [])
groups.get(groupName).push(field)
}
return [...groups.entries()]
.sort((a, b) => {
const ai = order.indexOf(a[0])
const bi = order.indexOf(b[0])
const av = ai >= 0 ? ai : 999
const bv = bi >= 0 ? bi : 999
return av - bv
})
.map(([name, fields]) => ({ name, fields }))
})
这段控制器代码就是当前模块真正的"解析器"。当前项目并没有单独写一个 parser.ts 去转换 difficultylevels.txt,而是在 useHomePageController.js 里通过多个 computed 完成了解析职责。difficultySettingRows 负责把整张表的行变成左侧难度按钮,currentDifficultyRowFields 负责把当前行转成字段对象,currentDifficultyFieldGroups 再把这些字段塞进配置定义的分组里。页面组件消费到的已经不是原始二维数组,而是可直接渲染的结构化字段数据。
难度编辑界面代码
vue
<template>
<section class="charstats-section">
<div class="settings-split-layout charstats-body">
<aside class="settings-split-side charstats-side">
<div class="charstats-side-list">
<button
v-for="item in difficultySettingRows"
:key="`difficulty-${item.rowIndex}`"
class="charstats-vtab"
:class="{ active: difficultySettingState.activeName === item.name }"
@click="difficultySettingState.activeName = item.name"
>
{{ item.label }}
</button>
</div>
<div class="settings-split-side-actions charstats-side-actions">
<button class="warning" :disabled="difficultySettingState.loading" @click="loadDifficultySettings({ force: true })">刷新</button>
<button class="success" :disabled="difficultySettingState.loading || !difficultySettingState.header.length" @click="saveDifficultySettings">保存</button>
</div>
</aside>
<section class="settings-split-main charstats-main">
<div v-if="difficultyGroupTabs.length" class="charstats-group-tabs">
<button
v-for="groupName in difficultyGroupTabs"
:key="`difficulty-group-${groupName}`"
class="settings-tab"
:class="{ active: difficultySettingState.activeGroup === groupName }"
@click="difficultySettingState.activeGroup = groupName"
>
{{ groupName }}
</button>
</div>
<div v-if="currentDifficultyActiveGroup?.fields?.length" class="exp-grid-wrap">
<div class="exp-grid">
<div v-for="field in currentDifficultyActiveGroup.fields" :key="`difficulty-field-${field.index}`" class="exp-field">
<label :title="field.tooltip" class="charstats-field-label">
<span class="field-zh">{{ field.zh || field.en }}</span>
</label>
<NumberStepper
v-else
:model-value="field.value"
type="number"
step="1"
len-mode="layout"
:readonly="field.readonly"
:disabled="field.readonly"
@update:model-value="updateDifficultyNumericField(field, $event)"
@step="stepDifficultyNumericField(field, $event)"
/>
</div>
</div>
</div>
</section>
</div>
</section>
</template>
页面模板本身非常克制。左侧是难度行切换和刷新保存动作,右侧是分组按钮和字段网格。源码里虽然保留了 VersionSelect、EnabledSelect、ClassSelect 这类通用分支,但从当前 difficultylevels.txt 字段集合能够确认,实际主力控件仍然是 NumberStepper。这说明当前模块并不是为某张表临时写死的一页,而是共享了一套设置页通用渲染框架,再让当前表按自身字段特点自然落到数值输入为主的表现上。
分组配置代码
json
{
"groups": {
"基础惩罚": {
"columns": [
"resistpenalty",
"resistpenaltynonexpansion",
"deathexppenalty",
"staticfieldmin"
]
},
"控制与吸取": {
"columns": [
"monsterskillbonus",
"monsterfreezedivisor",
"monstercolddivisor",
"aicursedivisor",
"lifestealdivisor",
"manastealdivisor",
"playerhitreactbuffervsmonster",
"mercenarymaxstunlength"
]
},
"怪物强化": {
"columns": [
"uniquedamagebonus",
"championdamagebonus",
"monstercedamagepercent",
"monsterfireenchantexplosiondamagepercent"
]
},
"PVP倍率": {
"columns": [
"playerdamagepercentvsplayer",
"playerdamagepercentvsmercenary",
"playerdamagepercentvsprimeevil",
"mercenarydamagepercentvsplayer",
"mercenarydamagepercentvsmercenary",
"mercenarydamagepercentvsboss",
"primeevildamagepercentvsplayer",
"primeevildamagepercentvsmercenary",
"primeevildamagepercentvspet",
"petdamagepercentvsplayer"
]
},
"赌博参数": {
"columns": [
"gamblerare",
"gambleset",
"gambleunique",
"gambleuber",
"gambleultra",
"([itemlevel]"
],
"includes": [
"gamble",
"itemlevel"
]
}
}
}
js
export function resolveDifficultyGroupByField(field) {
const colName = String(field?.colName || field?.label || field?.en || '').trim()
const key = normKey(colName)
for (const rule of RULES) {
if (rule.columns.includes(key)) return rule.group
if (rule.patterns.some((regex) => regex.test(key))) return rule.group
if (rule.includes.some((item) => item && key.includes(item))) return rule.group
}
return DIFFICULTY_GROUP_FALLBACK || ''
}
这一层配置直接体现了当前模块为什么不适合把表单写死在页面里。difficultylevels.txt 列数不少,字段语义跨度也很大,若全部硬写进模板,页面会迅速变成大体量条件分支。当前实现把归组责任交给 JSON 配置和 resolveDifficultyGroupByField,字段是依据列名规则动态落组的,展示顺序又由 difficultylevels.layout.json 统一控制。字段扩展时,只要列名符合既有规则,页面大多情况下不需要新增模板分支。
读取与保存代码
js
async function loadDifficultySettings(options = {}) {
const force = options.force === true
if (!state.excelPath) {
const message = '请先在文件配置定位 TXT 目录'
setDifficultySettingStatus(message, 'error')
appendLog(`整体难度读取失败: ${message}`)
return
}
try {
difficultySettingState.loading = true
const result = await loadTableData(state.excelPath, difficultySettingState.file, { force })
difficultySettingState.encoding = result.encoding || 'utf-8'
difficultySettingState.header = result.header || []
difficultySettingState.rows = (result.rows || []).map((row) => [...row])
const idx = difficultySettingState.header.findIndex((item, i) => i === 0 || normKey(item) === 'name')
difficultySettingState.nameColIndex = idx >= 0 ? idx : 0
await loadDifficultyColumnMapping()
const normalRow = difficultySettingRows.value.find((item) => normalizeDifficultyName(item.name) === 'normal')
const first = normalRow || difficultySettingRows.value[0]
if (!difficultySettingRows.value.some((item) => item.name === String(difficultySettingState.activeName || '').trim())) {
difficultySettingState.activeName = first ? first.name : ''
}
setDifficultySettingStatus(`已加载 ${difficultySettingState.file},共 ${difficultySettingState.rows.length} 行`, 'ok')
appendLog(`整体难度已加载: ${difficultySettingState.file}`)
} catch (error) {
setDifficultySettingStatus(`读取失败: ${String(error)}`, 'error')
appendLog(`整体难度读取失败: ${String(error)}`)
} finally {
difficultySettingState.loading = false
}
}
function updateDifficultySettingCell(rowIndex, colIndex, value) {
if (rowIndex < 0 || colIndex < 0) return
if (!difficultySettingState.rows[rowIndex]) return
difficultySettingState.rows[rowIndex][colIndex] = String(value || '')
}
async function saveDifficultySettings() {
if (!state.excelPath || !difficultySettingState.header.length) {
setDifficultySettingStatus('请先读取 difficultylevels.txt', 'error')
return
}
try {
difficultySettingState.loading = true
const result = await invoke('py_save_table_json', {
folder: state.excelPath,
file: difficultySettingState.file,
encoding: difficultySettingState.encoding,
headerJson: JSON.stringify(difficultySettingState.header),
rowsJson: JSON.stringify(difficultySettingState.rows),
backupPath: backupPath.value || buildBackupPath(state.modPath),
})
setDifficultySettingStatus('保存成功', 'ok')
setCachedTableResult(state.excelPath, difficultySettingState.file, {
encoding: difficultySettingState.encoding,
header: [...difficultySettingState.header],
rows: cloneRows(difficultySettingState.rows),
row_count: difficultySettingState.rows.length,
path: joinExcelFilePath(state.excelPath, difficultySettingState.file),
})
if (normKey(editorState.fileName) === normKey(difficultySettingState.file)) {
await openEditorFile()
}
} catch (error) {
setDifficultySettingStatus(`保存失败: ${String(error)}`, 'error')
} finally {
difficultySettingState.loading = false
}
}
这一段 helper 代码把整体难度模块的读写链路完整串了起来。读取时,控制器会先通过 loadTableData 取回 header 和 rows,再找到 Name 列的位置,把当前难度行、当前字段、分组标签依次算出来。保存时,并不是逐个字段发请求,而是把当前整个 header 和 rows 序列化后交给 py_save_table_json。这意味着整体难度页本质上仍然是整表编辑器,只不过它在界面上做了"按行收缩 + 按组收缩"的视图组织。
Tauri 文件写回代码
rust
pub fn save_table_json(
folder: String,
file: String,
encoding: String,
header_json: String,
rows_json: String,
backup_path: Option<String>,
) -> Result<Value, String> {
let path = table_path(&folder, &file);
let header_values: Vec<Value> =
serde_json::from_str(&header_json).map_err(|error| format!("invalid header: {error}"))?;
let rows_values: Vec<Vec<Value>> =
serde_json::from_str(&rows_json).map_err(|error| format!("invalid rows: {error}"))?;
let header: Vec<String> = header_values
.into_iter()
.map(|v| v.to_string().trim_matches('\"').to_string())
.collect();
let rows: Vec<Vec<String>> = rows_values
.into_iter()
.map(|row| {
row.into_iter()
.map(|v| match v {
Value::String(s) => s,
other => other.to_string(),
})
.collect()
})
.collect();
let backup_dir = normalize_backup_dir(backup_path.as_deref())?;
let backup_path = write_table(&path, &header, &rows, &encoding, Some(&backup_dir))?;
Ok(json!({
"file": file.trim(),
"path": path.to_string_lossy(),
"encoding": encoding,
"backup_path": backup_path,
"backup_dir": backup_dir.to_string_lossy(),
"row_count": rows.len()
}))
}
从当前代码能够确认,整体难度模块的后端保存链路是非常完整的本地 Tauri 写回流程。前端把整表序列化为 JSON,Rust 侧反序列化后恢复为字符串二维表,再按原编码写回 TXT,并返回备份路径与行数信息。也就是说,当前模块的保存行为不是模拟保存,也不是仅修改前端状态,而是真正落到了文件系统。仓库中没有看到独立 HTTP 服务、远程 API 或运行时热更新模块,因此能直接确认的后端实现主要集中在本地文件读写这一层。
从当前代码能够确认的内容主要集中在前端字段组织、本地 Tauri 读写链路、缓存同步与编辑器联动机制。游戏进程是否在运行时即时读取最新 difficultylevels.txt,仓库中暂时没有直接证据,所以本文只围绕可确认的文件读取、页面展示与本地落盘展开,不补写运行时热重载逻辑。
操作演示
就软件使用路径来说,整体难度模块的交互设计很适合频繁调参。进入页面后,并不会一次性看到整张 difficultylevels.txt 的全部列,而是先选中某个难度行,再切换某个字段分组,随后只在当前分组中调整数值。这样一来,修改噩梦难度的抗性惩罚时,不会同时被赌博参数和 PVP 参数干扰;调整地狱难度的怪物强化时,也不需要在整张表里横向搜索字段位置。
操作演示表如下。表中的行为全部来自当前源码能够确认的页面逻辑与保存链路。
| 操作阶段 | 界面表现 | 操作动作 | 数据变化或目的 |
|---|---|---|---|
| 模块进入 | 主导航进入"游戏设置",子页签点击"整体难度" | 切换到 difficultyLevel |
挂载 SettingsDifficultyLevelTab,并触发 loadDifficultySettings() |
| 读取数据 | 页面初次加载或点击刷新按钮 | 触发读取 | py_load_table 读取 difficultylevels.txt,重建 header、rows、行索引和列元数据 |
| 切换难度行 | 左侧显示 Normal/Nightmare/Hell 等行按钮 |
点击某个难度名称 | 更新 activeName,右侧字段值切换到对应行 |
| 切换字段组 | 右侧顶部显示"基础惩罚、控制与吸取、怪物强化、PVP倍率、赌博参数" | 点击某个分组按钮 | 更新 activeGroup,网格区只展示该组字段 |
| 修改参数 | 字段标签下方显示数字输入控件 | 调整数值 | updateDifficultySettingCell 直接改写内存中的 rows[rowIndex][colIndex] |
| 保存数据 | 右下动作区点击保存 | 触发保存 | py_save_table_json 把当前整表写回 difficultylevels.txt 并生成备份 |
| 校验保存结果 | 保存成功后查看顶部状态或再次刷新 | 重新读取 | 前端缓存更新,必要时文件编辑器同步刷新同名文件 |
| 游戏内效果验证 | 回到游戏环境验证难度差异 | 外部验证 | 从当前代码可确认文件已写回,具体生效时机仍依赖运行环境 |
从实际使用意义看,这种操作流很适合版本平衡迭代。举例来说,若需要只压低地狱难度的吸血收益,可以直接切到 Hell 行,再切到"控制与吸取"分组,调整 LifeStealDivisor 和 ManaStealDivisor,保存后再回到游戏验证。若要控制噩梦与地狱的死亡惩罚差异,则切到"基础惩罚"分组编辑 DeathExpPenalty 即可。字段分组和难度行切换的组合,让 difficultylevels.txt 这种全局规则表变成了可读、可控、可回滚的参数面板。
总结
游戏设置-整体难度 这个模块很能代表暗黑破坏神2 MOD 修改工具处理全局规则表时的工程思路。页面层尽量轻,状态层负责把整表数据转换成可编辑字段,配置层负责字段归组和布局顺序,数据层负责编码读取、文件写回和备份。这样带来的直接收益不是"代码更优雅"这么简单,而是工具在面对复杂 TXT 数据时更稳,字段增加时更容易扩展,保存风险也更容易控制。
对于工具使用场景来说,这个模块的价值在于把 difficultylevels.txt 从原始表格改造成了"按难度行 + 按业务分组"的编辑页。对于开发实现来说,它又展示了一种非常清晰的组织方式:SettingsTab.vue 负责挂载,useHomePageController.js 负责解析与状态,difficultylevelsGrouping.js 负责归组规则,settingsHelpers.js 负责保存入口,files.rs 负责实际落盘。整条链路在当前仓库中都能够定位到,没有依赖额外服务端。