游戏设置-怪物等级 对应的是 monlvl.txt 这张全局怪物等级曲线表。它和 Levels.txt 里按地图配置怪物等级的思路不同,也和 monstats.txt 里逐个怪物定义生命、抗性、伤害的思路不同。这个模块处理的是"等级区间对应的整套成长倍率",也就是怪物在不同等级下的防御、命中、生命、伤害、经验等整列数据如何延展。对于暗黑破坏神2 MOD 工具来说,这类数据一旦改动,影响范围往往不是单个地图或单个怪物,而是整条怪物强度曲线。

也正因为影响面很大,这个功能值得被拆成独立编辑页。当前源码没有把 monlvl.txt 的三十多个列字段直接铺成大表单,而是把界面压缩成"当前等级显示 + 目标等级输入 + 刷新/应用"这一类工具型交互。页面层负责表达修改意图,真正的行扩展、行裁剪、编码读取和文件回写交给 Tauri 命令与 Rust 处理层完成。这种拆法很适合全局型规则表:一方面能减少误操作,另一方面也能把复杂算法从前端表单中剥离出来。
文章目录
文件说明
从模块组织方式来看,游戏设置-怪物等级 不是一个孤立的 Vue 页面,而是由页面壳层、设置页签路由、工具页组件、状态控制器、通用读取缓存、字段元数据、分组配置、命令桥接和 Rust 写回算法共同组成。和 整体难度、佣兵雇佣 这类整表编辑页相比,怪物等级模块更像一个"参数化生成器":界面只输入目标最大等级,后端负责把 monlvl.txt 扩展到目标上限或裁剪掉超出的等级行。

模块文件职责表如下。表中既列出运行时会直接参与当前功能的文件,也保留与该模块规范相关的 grouping、layout、columns 等配置文件。需要注意的是,当前怪物等级页并没有直接渲染完整字段网格,因此部分配置文件处于"已定义、当前页面未直接消费"的状态,这一点在源码中可以明确区分出来。
| 文件名 | 文件类型 | 模块职责 | 与界面或数据处理的关系 | 备注 |
|---|---|---|---|---|
src/pages/HomePage.vue |
Vue | 主页面壳层 | 侧栏切到"游戏设置"时挂载 SettingsTab,顶部显示 settingsLoadedMeta |
当前模块的上层容器 |
src/modules/settings/SettingsTab.vue |
Vue | 游戏设置总入口 | 子页签切到 monLevel 时渲染 SettingsMonLevelTab,并同步 levelToolTab='mon' |
模块路由层 |
src/modules/settings/monLevel/SettingsMonLevelTab.vue |
Vue | 怪物等级工具页 | 展示当前等级、目标等级输入框、刷新按钮、应用按钮 | 当前模块的直接界面 |
src/modules/commonControls/NumberStepper.vue |
Vue | 通用数字步进组件 | 为目标等级输入提供 +/-、回车提交和数值输入事件 |
公共组件复用 |
src/pages/home/useHomePageController.js |
JS | 首页控制器与状态装配 | 定义 levelTools.mon、currentLevelTool、settingsLoadedMeta、页签切换后的自动刷新逻辑 |
状态层核心 |
src/pages/home/settingsHelpers.js |
JS | 设置模块通用 helper | 读取 monlvl.txt 当前最大等级,调用 py_apply_level_tools 修改文件 |
读写调用层 |
src/pages/home/tableDataLoader.js |
JS | 表数据读取缓存 | 对 py_load_table 做缓存、去重和失效控制 |
读取性能层 |
src/assets/resources/index.json |
JSON | 字段元数据索引 | 把 MonLvl.txt.columns.json 纳入统一列描述系统 |
元数据入口 |
src/assets/resources/files/MonLvl.txt.columns.json |
JSON | monlvl.txt 字段说明 |
定义 Level、AC、HP、XP 等列的中文含义和描述 |
数据说明层 |
src/modules/settings/monLevel/monLevel.grouping.json |
JSON | 分组配置 | 定义字段默认归入"基础信息"与"其他" | 当前页未直接渲染整表 |
src/modules/settings/monLevel/monLevel.layout.json |
JSON | 布局顺序配置 | 定义分组顺序和回退组 | 当前页未直接渲染整表 |
src/modules/settings/monLevel/monLevelGrouping.js |
JS | 分组解析封装 | 通过 groupingFactory 生成字段分组解析器 |
当前页未直接调用,但属于模块规范 |
src/modules/commonControls/groupingFactory.js |
JS | 通用分组工厂 | 把 grouping/layout 配置转换成 resolveInfoByField 等方法 |
配置解析层 |
src-tauri/src/main.rs |
Rust | Tauri 命令注册 | 暴露 py_load_table、py_apply_level_tools 给前端 invoke |
前后端桥接层 |
src-tauri/src/commands/files.rs |
Rust | 文件读取命令 | 读取 TXT、保留编码、解析为 header + rows + row_count 返回前端 |
数据解析层 |
src-tauri/src/commands/level_tools.rs |
Rust | 等级工具算法 | apply_max_mon_level 负责按目标等级扩行、裁行并写回 monlvl.txt |
当前模块最核心的写入层 |
把这些文件按层次拆开后,结构会更清楚。HomePage.vue 和 SettingsTab.vue 属于页面壳层,负责把"游戏设置"和"怪物等级"放到正确的位置。SettingsMonLevelTab.vue 和 NumberStepper.vue 属于界面层,负责表达当前值、输入值与操作按钮。useHomePageController.js、settingsHelpers.js、tableDataLoader.js 属于配置与状态层,负责上下文注入、缓存、刷新和命令调用。index.json、MonLvl.txt.columns.json、monLevel.grouping.json、monLevel.layout.json、monLevelGrouping.js 属于配置层和解析层。main.rs、files.rs、level_tools.rs 则是数据层和命令层,承担真正的读取、解析、回写、备份工作。
接下来再看真正参与 UI 构建、字段展示、数据读取与保存链路的方法和状态。这个模块不是整表表单页,因此不需要把所有列变量机械罗列出来,重点应该落在"界面看见了什么"和"点击之后会发生什么"。
| 字段/配置项/方法 | 所属文件 | 作用 | 在界面中的体现 | 修改后的影响 |
|---|---|---|---|---|
settingsTab |
src/modules/settings/SettingsTab.vue |
标识当前游戏设置子页签 | 点击"怪物等级"按钮后切换到当前模块 | 决定渲染哪个设置页组件 |
levelToolTab |
src/modules/settings/SettingsTab.vue / useHomePageController.js |
标识当前等级工具类型 | 切到 monLevel 时会被同步成 mon |
决定刷新和应用时操作哪张文件 |
levelTools.mon.file |
src/pages/home/useHomePageController.js |
定义怪物等级工具对应文件 | 页面不直接显示字段名,但所有读取和写回都指向 monlvl.txt |
直接决定数据源 |
levelTools.mon.loadedRows |
src/pages/home/useHomePageController.js |
记录当前读取到的行数 | 顶部 settingsLoadedMeta 会显示"已加载 monlvl.txt,共 X 行" |
用于加载状态展示 |
currentLevelTool.currentText |
src/pages/home/useHomePageController.js |
输出当前最大等级文本 | "当前等级"区域直接显示 | 用于确认当前曲线长度 |
currentLevelTool.input |
src/pages/home/useHomePageController.js |
保存待应用的目标等级 | NumberStepper 输入框显示和回填该值 | 决定应用时的目标上限 |
updateLevelInputByKey |
src/pages/home/useHomePageController.js |
处理输入框实时改值 | 手动输入整数时触发 | 改变待提交目标 |
stepLevelInputByKey |
src/pages/home/useHomePageController.js |
处理步进器加减 | 点击 +/- 时触发 |
快速调整目标等级 |
refreshCurrentLevel |
src/pages/home/settingsHelpers.js |
重新读取当前最大等级 | 点击"刷新"按钮触发 | 重新加载 monlvl.txt 并更新状态 |
applyCurrentLevelTool |
src/pages/home/settingsHelpers.js |
应用当前目标等级 | 点击"应用"按钮或回车触发 | 调用后端改写 monlvl.txt |
settingsLoadedMeta |
src/pages/home/useHomePageController.js |
生成顶部状态文案 | 页头显示当前模块已加载文件和行数 | 反馈读取结果 |
loadTableData |
src/pages/home/tableDataLoader.js |
带缓存读取 TXT 表 | 页面切换与刷新时不会无意义重复读取 | 提升读取性能并减少并发请求 |
invoke('py_load_table') |
src/pages/home/tableDataLoader.js 间接调用 |
读取表格数据 | 界面无感,结果体现在当前等级和加载状态 | 提供读取链路 |
invoke('py_apply_level_tools') |
src/pages/home/settingsHelpers.js |
调用 Rust 等级工具 | 界面无感,结果体现在应用成功与失败提示 | 提供写回链路 |
apply_max_mon_level |
src-tauri/src/commands/level_tools.rs |
对 monlvl.txt 执行扩行或裁剪 |
不直接出现在界面 | 改变曲线长度和新增行数值 |
从界面设计角度看,这些状态和方法之所以会出现在当前模块里,是因为页面目标并不是"编辑某一行某一列",而是"把一条曲线整体拉长或缩短"。也就是说,界面需要展示的是当前曲线长度和目标曲线长度,而不是 AC(H)、L-XP(N) 之类的逐列细节。细节列仍然存在于 monlvl.txt 内部,只是被收敛到了算法层处理。
由于这个模块更适合从数据字段角度理解,再补一张字段说明表会更直观。这里的字段并非来自想象,而是可以同时从 MonLvl.txt.columns.json 和仓库中的 src-tauri/bak/monlvl.txt_* 备份样本中确认到。元数据文件用合并描述解释了字段含义,备份文件则展示了真实表头是如何展开成多列的。
| 字段名 | 中文含义 | 所属分组 | 前端展示方式 | 实际用途说明 |
|---|---|---|---|---|
Level |
等级行标识 | 基础标识 | 当前页不逐列展示 | 后端以首列最大数值判断当前等级上限 |
AC / AC(N) / AC(H) |
普通、噩梦、地狱防御倍率 | 怪物成长 | 当前页不逐列展示 | 扩等级时按末两行差值持续推导 |
TH / TH(N) / TH(H) |
普通、噩梦、地狱命中倍率 | 怪物成长 | 当前页不逐列展示 | 决定更高等级怪物命中成长 |
HP / HP(N) / HP(H) |
普通、噩梦、地狱生命倍率 | 怪物成长 | 当前页不逐列展示 | 决定更高等级怪物生命成长 |
DM / DM(N) / DM(H) |
普通、噩梦、地狱伤害倍率 | 怪物成长 | 当前页不逐列展示 | 决定更高等级怪物伤害成长 |
XP / XP(N) / XP(H) |
普通、噩梦、地狱经验倍率 | 奖励成长 | 当前页不逐列展示 | 决定击杀怪物经验收益 |
L-AC / L-AC(N) / L-AC(H) |
天梯防御倍率 | 天梯成长 | 当前页不逐列展示 | 天梯模式下的防御成长曲线 |
L-TH / L-TH(N) / L-TH(H) |
天梯命中倍率 | 天梯成长 | 当前页不逐列展示 | 天梯模式下的命中成长曲线 |
L-HP / L-HP(N) / L-HP(H) |
天梯生命倍率 | 天梯成长 | 当前页不逐列展示 | 天梯模式下的生命成长曲线 |
L-DM / L-DM(N) / L-DM(H) |
天梯伤害倍率 | 天梯成长 | 当前页不逐列展示 | 天梯模式下的伤害成长曲线 |
L-XP / L-XP(N) / L-XP(H) |
天梯经验倍率 | 天梯成长 | 当前页不逐列展示 | 天梯模式下的经验收益曲线 |
从当前代码能够确认的内容是,怪物等级页虽然只暴露了一个"新的最大等级"输入框,但背后对应的是一整张多列曲线表。这正是当前模块存在价值的来源:工具使用者不需要手工维护三十多列等级行,工具作者也不需要把整个 monlvl.txt 硬铺到页面上,只要保证"目标上限 -> 曲线扩展/裁剪"的规则稳定即可。
软件开发
从开发视角看,怪物等级模块非常有代表性。它没有走传统后台管理那种"大表单 + 保存整行"的路线,而是把前端压缩成一个极简工具,把算法沉到 Rust 层。这个选择并不是为了省页面代码,而是因为 monlvl.txt 的修改本质不是普通字段编辑,而是"根据目标最大等级重新整理整张曲线表"。一旦把这类逻辑写死在页面里,界面状态、校验、备份、编码写回、缓存失效都会迅速膨胀。
这类设计也能看出整个工具的工程取向。页面壳层负责挂载模块,状态层负责输入和结果,配置层负责统一元数据与分组能力,命令层负责文件桥接,算法层负责业务改写。和整表模块共用的不是表单内容,而是 settingsCtx、loadTableData、NumberStepper、Tauri invoke、TXT 解析函数这套基础设施。也就是说,模块级差异体现在写入策略,而不是体现在基础框架上。
核心实现结构表如下。
| 实现层 | 核心文件/代码 | 作用说明 | 设计意义 |
|---|---|---|---|
| 页面壳层 | HomePage.vue / SettingsTab.vue |
让"游戏设置 -> 怪物等级"成为明确可切换的独立页签 | 把导航和具体功能解耦,降低设置页膨胀风险 |
| 交互层 | SettingsMonLevelTab.vue |
只显示当前值、目标值和刷新/应用按钮 | 把复杂表格操作收束为单一工具操作 |
| 公共组件层 | NumberStepper.vue |
提供统一的数值输入、步进和回车提交事件 | 保持输入交互一致,避免每个模块重复造轮子 |
| 状态层 | useHomePageController.js |
管理 levelTools.mon、currentLevelTool、顶部加载提示和页签切换联动 |
把界面状态从组件模板中抽离 |
| 读取层 | tableDataLoader.js / files.rs |
读取 monlvl.txt,保留编码并解析为 header + rows |
统一 TXT 读取方式,兼顾性能和编码安全 |
| 命令桥接层 | main.rs |
注册 py_load_table 和 py_apply_level_tools |
让前端只关心调用意图,不关心本地文件细节 |
| 应用逻辑层 | settingsHelpers.js |
根据当前工具类型决定读取、校验、调用哪个后端命令 | 共用一套 helper 覆盖人物、怪物、技能等级工具 |
| 后端算法层 | level_tools.rs |
按目标等级扩展或裁剪 monlvl.txt 并写回 |
把曲线推导和落盘细节集中到 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'
}
}
这段代码说明当前项目并没有把所有设置页堆在一个大组件里,而是通过 settingsTabComponents 做子模块映射。怪物等级页对应 monLevel,同时兼容旧键值 level。切换到该页签时,levelToolTab 会同步成 mon,后续 refreshCurrentLevel 和 applyCurrentLevelTool 都会以"怪物等级工具"身份运行。这个设计很关键,因为人物等级、怪物等级、技能等级三套工具虽然共享 helper,但目标文件和应用策略并不相同。
模块交互代码
vue
<template>
<section class="level-card">
<div class="settings-split-layout">
<aside class="settings-split-side charstats-side">
<div class="charstats-side-list"></div>
<div class="settings-split-side-actions charstats-side-actions">
<button class="warning" @click="refreshCurrentLevel">刷新</button>
<button class="success" @click="applyCurrentLevelTool">应用</button>
</div>
</aside>
<section class="settings-split-main charstats-main char-exp-main-panel">
<div class="row level-char-row">
<label>当前等级</label>
<div class="current-level-text">{{ currentLevelTool.currentText || '未知' }}</div>
<label>新的最大等级</label>
<NumberStepper
wrapper-class="level-max-step"
:model-value="currentLevelTool.input"
type="number"
min="1"
step="1"
placeholder="请输入大于 0 的整数"
@update:model-value="updateLevelInputByKey(levelToolTab, $event)"
@step="stepLevelInputByKey(levelToolTab, $event)"
@enter="applyCurrentLevelTool"
/>
</div>
<div v-if="currentLevelTool.statusError" class="level-status error">
{{ currentLevelTool.statusText || ' ' }}
</div>
</section>
</div>
</section>
</template>
<script>
import { inject } from 'vue'
import NumberStepper from '../../commonControls/NumberStepper.vue'
export default {
components: { NumberStepper },
setup() {
return inject('settingsTabCtx') || {}
},
}
</script>
这段模板把模块定位表达得很清楚。页面没有逐列渲染 monlvl.txt,而是只暴露当前等级和目标等级输入。NumberStepper 作为公共组件被直接复用,输入、步进、回车三类事件都接回 settingsTabCtx。界面层的职责只剩下表达当前状态和收集目标值,不承担任何曲线推导逻辑。这种做法让模块非常适合做"全局规则型操作页"。
状态模型与页签联动代码
js
const levelTools = reactive({
char: {
title: '人物最大等级(experience.txt)',
file: 'experience.txt',
current: '',
input: '',
statusText: '',
statusOk: false,
statusError: false,
loadedRows: 0,
},
mon: {
title: '怪物等级上限(monlvl.txt)',
file: 'monlvl.txt',
current: '',
input: '',
statusText: '',
statusOk: false,
statusError: false,
loadedRows: 0,
},
skill: {
title: '技能等级上限(skills.txt)',
file: 'skills.txt',
current: '',
input: '',
statusText: '',
statusOk: false,
statusError: false,
loadedRows: 0,
},
})
const currentLevelTool = computed(() => {
const tool = levelTools[levelToolTab.value] || levelTools.mon
return {
...tool,
currentText: tool.current === '' ? '未检测' : String(tool.current),
}
})
watch(settingsTab, async (value) => {
if (activeTab.value !== 'settings') return
if (value === 'monLevel') {
const changed = levelToolTab.value !== 'mon'
levelToolTab.value = 'mon'
if (!changed) {
await refreshCurrentLevel(false)
}
}
})
这段代码把三个等级工具收进同一个 levelTools 状态对象里,怪物等级只是其中的 mon 分支。好处在于上下文结构统一,坏处则被控制在很小范围内,因为真正的差异化逻辑放在 settingsHelpers.js。当设置页签切到 monLevel 时,控制器并不会临时创建一套新状态,而是把 levelToolTab 切换到 mon,让后续所有读取和应用逻辑自然落到 monlvl.txt 上。这比为每个等级工具维护独立页面状态更稳定,也更便于扩展。
分组配置与布局代码
js
import groupingConfig from './monLevel.grouping.json'
import layoutConfig from './monLevel.layout.json'
import { createGroupingResolver } from '../../commonControls/groupingFactory'
const resolver = createGroupingResolver(groupingConfig, layoutConfig)
export const MONLEVEL_GROUP_ORDER = resolver.GROUP_ORDER
export const MONLEVEL_GROUP_FALLBACK = resolver.GROUP_FALLBACK
export function resolveMonLevelGroupingInfoByField(field) {
return resolver.resolveInfoByField(field)
}
export function resolveMonLevelGroupByField(field) {
return resolver.resolveGroupByField(field)
}
export function resolveMonLevelControlByField(field) {
return resolver.resolveControlByField(field)
}
json
{
"groups": {
"基础信息": {
"patterns": [
".*"
],
"subgroup_order": [],
"subgroups": {}
},
"其他": {}
},
"field_controls": [
{}
]
}
json
{
"order": [
"基础信息",
"其他"
],
"fallback": "其他"
}
从当前代码能够确认,这个模块已经具备完整的 grouping/layout 规范文件和解析封装,但当前 SettingsMonLevelTab.vue 并没有直接消费这些分组结果。也就是说,模块规范层已经就位,运行时页面仍然采用工具型极简界面。monLevel.grouping.json 的配置也很克制,几乎等于"所有字段默认归入基础信息"。这恰好反映了模块定位:当前页不是完整表格编辑器,分组配置更像是为后续统一规范预留的接口,而不是当前交互主轴。
读取当前值代码
js
async function readLevelCurrent(key, options = {}) {
const force = options.force !== false
if (!state.excelPath) {
const message = '请先在文件配置定位 TXT 目录'
setLevelToolStatus(key, message, 'error')
levelToolByKey(key).loadedRows = 0
appendLog(`等级读取失败: ${message}`)
return
}
const tool = levelToolByKey(key)
try {
const result = await loadTableData(state.excelPath, tool.file, { force })
const rows = result.rows || []
tool.loadedRows = rows.length
let current = ''
if (key === 'mon') {
const levels = rows
.map((row) => Number(row[0]))
.filter((num) => Number.isFinite(num))
current = levels.length ? String(Math.max(...levels)) : ''
}
tool.current = current
if (!tool.input) {
tool.input = current || ''
}
setLevelToolStatus(key, `当前: ${current || '未知'}`, current ? 'ok' : 'info')
appendLog(`等级已加载: ${tool.file}`)
appendLog(`已读取 ${tool.file} 当前等级: ${current || '未知'}`)
} catch (error) {
tool.current = ''
tool.loadedRows = 0
const message = `读取失败: ${String(error)}`
setLevelToolStatus(key, message, 'error')
appendLog(`等级读取失败(${tool.file}): ${String(error)}`)
}
}
怪物等级的"当前值"读取逻辑非常直接。当前模块并不依赖 MonLvl.txt.columns.json 去算当前上限,而是把 monlvl.txt 读成二维数组后,直接扫描首列 Level 的数值,取最大值作为当前等级上限。这个策略很符合业务本质:对于怪物等级工具来说,界面真正关心的不是某个倍率列的值,而是这张曲线表已经延伸到了哪一级。loadedRows 也在这里被同步更新,页面顶部状态就能显示"已加载 monlvl.txt,共 X 行"。
应用命令调用代码
js
async function applyLevelToolByKey(key) {
if (!state.excelPath) {
setLevelToolStatus(key, '请先在文件配置定位 TXT 目录', 'error')
return
}
const tool = levelToolByKey(key)
const target = Number((tool.input || '').trim())
if (!Number.isInteger(target) || target <= 0) {
setLevelToolStatus(key, '请输入大于 0 的整数', 'error')
return
}
const charMax = key === 'char' ? String(target) : '0'
const monMax = key === 'mon' ? String(target) : '0'
const skillMax = key === 'skill' ? String(target) : '0'
try {
const result = await invoke('py_apply_level_tools', {
folder: state.excelPath,
charMax,
monMax,
skillMax,
})
const changes = Array.isArray(result?.changes) ? result.changes : []
const changedFiles = changes.map((item) => item?.file).filter(Boolean).join(', ')
for (const item of changes) {
if (item?.file) invalidateTableCache(item.file)
}
setLevelToolStatus(key, `已更新到 ${target}${changedFiles ? `(${changedFiles})` : ''}`, 'ok')
appendLog(`等级修改成功: ${tool.file} -> ${target}`)
await readLevelCurrent(key)
if (editorState.fileName && normKey(editorState.fileName) === normKey(tool.file)) {
await openEditorFile()
}
} catch (error) {
setLevelToolStatus(key, `应用失败: ${String(error)}`, 'error')
appendLog(`等级修改失败: ${String(error)}`)
}
}
这段逻辑明确区分了三个等级工具的共同入口和不同目标。当前模块场景下,只有 monMax 会被赋值成目标等级,charMax 和 skillMax 都保持为 0。这意味着前端不会直接改写 monlvl.txt,只会把"目标等级是多少"这个意图交给后端。命令成功后,前端会失效对应文件缓存,再次读取最新值,并在必要时刷新文件编辑器。这种结构比直接在前端拼装整张新表稳健得多。
命令桥接与文件解析代码
rust
#[tauri::command]
fn py_load_table(folder: String, file: String) -> Result<Value, String> {
commands::files::load_table(folder, file)
}
#[tauri::command]
fn py_apply_level_tools(
folder: String,
char_max: String,
mon_max: String,
skill_max: String,
) -> Result<Value, String> {
commands::level_tools::apply_level_tools(folder, char_max, mon_max, skill_max)
}
rust
pub fn load_table(folder: String, file: String) -> Result<Value, String> {
let path = table_path(&folder, &file);
let (text, encoding) = read_text_with_encoding(&path)?;
let (header, rows) = parse_tab_text(&text);
if header.is_empty() {
return Err(format!("header empty: {}", file.trim()));
}
let backup_dir = backup_dir()?;
Ok(json!({
"folder": PathBuf::from(folder.trim()).to_string_lossy(),
"file": file.trim(),
"path": path.to_string_lossy(),
"encoding": encoding,
"backup_dir": backup_dir.to_string_lossy(),
"header": header,
"rows": rows,
"row_count": rows.len()
}))
}
这段代码把前后端的边界画得很清晰。前端只能看到 py_load_table 和 py_apply_level_tools 这两个命令名,具体读取和写回细节在 Rust 层完成。load_table 先保留原文件编码,再通过 parse_tab_text 把 TAB 分隔文本拆成 header + rows 的结构返回前端。也就是说,当前模块的"解析器"并不是单独的一段表单转换代码,而是 read_text_with_encoding + parse_tab_text + JSON 返回结构 这条通用链路。怪物等级页是在这条链路的结果之上,再额外计算出"当前最大等级"。
后端等级扩展代码
rust
fn apply_max_mon_level(folder: &Path, new_max: i64) -> Result<Value, String> {
let file = "monlvl.txt";
let path = folder.join(file);
let (text, encoding) = read_text_with_encoding(&path)?;
let (header, mut data) = parse_tab_text(&text);
let level_idx = 0usize;
let mut numeric_rows: Vec<(usize, i64)> = data
.iter()
.enumerate()
.filter_map(|(idx, row)| {
row.get(level_idx)?
.trim()
.parse::<i64>()
.ok()
.map(|v| (idx, v))
})
.collect();
if numeric_rows.is_empty() {
return Err("monlvl.txt 閹靛彞绗夐崚鎵搼缁狙嗩攽".to_string());
}
numeric_rows.sort_by_key(|(_, level)| *level);
let max_existing = numeric_rows.last().map(|(_, lvl)| *lvl).unwrap_or(0);
if new_max < max_existing {
data = data
.into_iter()
.filter(|row| {
let v = row.get(level_idx).map(|v| v.trim()).unwrap_or("");
if let Ok(level) = v.parse::<i64>() {
level <= new_max
} else {
true
}
})
.collect();
} else if new_max > max_existing {
let (last_idx, _) = *numeric_rows
.last()
.ok_or_else(|| "monlvl.txt missing level rows".to_string())?;
let prev_idx = if numeric_rows.len() >= 2 {
numeric_rows[numeric_rows.len() - 2].0
} else {
last_idx
};
let last = data[last_idx].clone();
let prev = data[prev_idx].clone();
let mut deltas: HashMap<usize, i64> = HashMap::new();
let mut last_vals: HashMap<usize, i64> = HashMap::new();
for idx in 1..header.len() {
deltas.insert(idx, to_i64(&last[idx]) - to_i64(&prev[idx]));
last_vals.insert(idx, to_i64(&last[idx]));
}
for level in (max_existing + 1)..=new_max {
let mut row = vec![String::new(); header.len()];
row[level_idx] = level.to_string();
for idx in 1..header.len() {
let next = last_vals.get(&idx).copied().unwrap_or(0)
+ deltas.get(&idx).copied().unwrap_or(0);
last_vals.insert(idx, next);
row[idx] = next.to_string();
}
data.push(row);
}
}
let backup = write_table(&path, &header, &data, &encoding, None)?;
Ok(json!({
"file": file,
"backup_path": backup
}))
}
这就是当前模块的核心算法。目标等级小于当前上限时,后端会把 Level 大于目标值的所有行过滤掉;目标等级大于当前上限时,后端会取现有最后两行,按每一列的差值持续向上推导,直到补齐到目标等级。这里真正重要的设计点有两个。其一,新增行不是固定模板复制,而是"末两行差值递推",这能保证曲线延伸符合原有增长趋势。其二,写回时调用的是 write_table(..., None),从当前代码能够确认,这条专用链路并没有走 py_save_table_json 的自定义备份路径参数,而是使用默认备份目录策略。源码里个别错误字符串还存在编码异常,这也是当前实现中可以直接观察到的细节。
综合这几段代码就能理解,当前模块为什么没有把表单写死在页面里。因为真正复杂的不是输入框,而是"如何稳定地改写整张等级曲线表"。如果把所有逻辑都放在 SettingsMonLevelTab.vue,页面不仅要负责校验,还要负责行扩展、编码兼容、备份和缓存刷新,维护成本会非常高。当前实现把这些问题拆散到各自最合适的层级里,前端保持轻量,后端保持确定性,整体结构比直接硬写表单更适合 D2R 这类 TXT 驱动的工具。
从当前代码能够确认的内容主要集中在前端状态层、Tauri 命令桥接、TXT 解析链路和 Rust 文件改写层。monlvl.txt 的读取、扩展、裁剪、回写和备份路径返回都能在源码中定位到。游戏进程是否热重载该文件、改动是否在运行中的游戏里即时生效,这部分仓库中没有完整证据,因此不能当作源码已确认事实来写。
操作演示
就软件使用路径而言,当前模块的操作流程非常清晰。进入"游戏设置"后点击"怪物等级",页面会先围绕 levelToolTab='mon' 建立上下文。点击"刷新"时,前端读取 monlvl.txt 并计算首列最大等级。输入目标值并点击"应用"后,前端只把 monMax 传给后端命令,后端完成整张曲线表的调整。也就是说,界面上虽然只有一个数字输入,但背后改动的是整条等级曲线。
操作演示表如下。表中只写能够从当前代码确认的界面行为和数据变化,不补写仓库中不存在的提示框、接口返回页或游戏内即时表现。
| 演示环节 | 界面与动作 | 源码依据 | 可确认结果 |
|---|---|---|---|
| 模块进入 | 主侧栏点击"游戏设置",子页签点击"怪物等级" | HomePage.vue 挂载 SettingsTab,SettingsTab.vue 渲染 SettingsMonLevelTab |
当前模块页面被激活 |
| 初始加载 | 页面切入后触发状态同步 | settingsTab 切到 monLevel 时 levelToolTab 会被设置为 mon |
后续刷新和应用都指向 monlvl.txt |
| 读取当前值 | 点击"刷新"按钮 | refreshCurrentLevel -> readLevelCurrent('mon') |
读取 monlvl.txt 首列最大值并显示在"当前等级"区域 |
| 查看加载状态 | 观察页头状态文案 | settingsLoadedMeta 使用 loadedRows 生成文案 |
可以确认当前已加载文件名和行数 |
| 输入目标值 | 在 NumberStepper 中输入整数或点击 +/- |
updateLevelInputByKey、stepLevelInputByKey |
目标等级写入 currentLevelTool.input |
| 应用更高等级 | 输入一个大于当前值的整数后点击"应用" | applyLevelToolByKey 调用 py_apply_level_tools,Rust 进入扩行分支 |
monlvl.txt 会按末两行差值继续补齐到目标等级 |
| 应用更低等级 | 输入一个小于当前值的整数后点击"应用" | apply_max_mon_level 进入裁剪分支 |
Level 超出目标值的行会被删除 |
| 刷新校验 | 应用完成后再次点击"刷新" | 再次走 readLevelCurrent('mon') |
界面上的"当前等级"会回到最新文件状态 |
| 编辑器联动 | 若文件编辑器正打开 monlvl.txt |
applyLevelToolByKey 内有 openEditorFile() 判断 |
文件编辑区会同步刷新当前文件内容 |
| 缓存失效 | 应用命令完成后 | invalidateTableCache(item.file) |
避免后续读取命中旧缓存 |
再从数据结果的角度看,这个模块并不是只写一个"最大等级数字"。仓库里的 src-tauri/bak/monlvl.txt_2026_02_16_01_45_54 备份样本可以确认真实表头是 Level、AC、AC(N)、AC(H)、L-AC...L-XP(H) 这一整套列,前几行内容也能看出它是一张按等级递增的成长曲线表。这意味着"应用"按钮的语义不是更新单一字段,而是对整张曲线表进行结构性改写。对于 MOD 工具来说,这种工具型封装的价值非常大,因为手工维护三十多列等级行既费时,也容易在高等级扩展场景下出错。
还需要补充一点保存行为边界。当前代码能够确认,怪物等级模块走的是专用命令 py_apply_level_tools,不是整表模块常见的 py_save_table_json。前者在 Rust 层直接调用 write_table 回写并返回备份路径,仓库中的 src-tauri/bak 目录也能看到现成的 monlvl.txt_* 备份文件样本。至于游戏是否在不重启的情况下立即读取了最新 monlvl.txt,当前仓库没有运行时监听或热更新证据,文章只能把结论限定在"文件已写回"和"界面可重新读取验证"这个范围内。
总结
游戏设置-怪物等级 是这个暗黑破坏神2 MOD 修改工具里很典型的一个全局规则型模块。它背后对应的是 monlvl.txt 整张怪物等级成长曲线表,但前端并没有把问题做成巨型表格,而是把操作收敛成"看当前值、设目标值、应用改写"这套工具流。这样的界面取舍并不保守,恰恰体现了工具作者对数据结构的理解:面对一张需要整表推导的曲线表,最合适的交互不是逐格编辑,而是把参数输入与算法写回分层处理。
从开发实现来看,这个模块也能代表整个项目的工程方式。页面壳层负责挂载,控制器负责状态,helper 负责读写调用,通用组件负责输入体验,JSON 配置负责模块规范,Tauri 命令负责桥接,Rust 负责最终算法和文件回写。这样组织带来的好处很直接,新增功能时不需要重新搭建整套基础设施,修改算法时也不必侵入 Vue 页面。对于长期维护 D2R TXT 工具的开发者来说,这种层次分工比把表单写死在页面里更稳,也更容易扩展。
为了避免结论超出材料边界,再用一张表收束本文能够确认和不能确认的部分。
| 边界类别 | 内容 | 依据 |
|---|---|---|
| 源码直接确认 | 页签挂载关系、当前等级读取方式、py_apply_level_tools 调用链、apply_max_mon_level 的扩行与裁剪逻辑 |
Vue、JS、Rust 源码可直接定位 |
| 源码直接确认 | monlvl.txt 真实表头包含 Level、AC、HP、DM、XP 及其难度和天梯列 |
MonLvl.txt.columns.json 与仓库备份样本可相互印证 |
| 源码直接确认 | 应用命令会回写文件并生成备份 | write_table 与 src-tauri/bak/monlvl.txt_* 样本可确认 |
| 从当前代码能够确认的内容 | 当前页面是工具型界面,不是整表字段编辑页;分组与布局配置已存在,但当前模板未直接消费 | SettingsMonLevelTab.vue 与 monLevelGrouping.js、JSON 配置对照可确认 |
| 当前材料无法直接确认 | 游戏运行时是否即时热重载 monlvl.txt,是否存在独立后端服务做运行时同步 |
仓库内未看到对应监听、服务接口或热刷新实现 |
这也正是当前模块值得单独成文的原因。对于工具使用者来说,它降低了直接编辑曲线表的门槛。对于 MOD 制作者来说,它把全局怪物强度曲线的调整收敛成了可重复、可验证的操作。对于二次开发者来说,它展示了一个很完整的实现范式:Vue 负责表达意图,JS 负责状态和调用,Rust 负责真正的数据规则。这样的模块,不只是"能用",也具备继续扩展和长期维护的开发价值。