装备编辑-武器物品 对应的是 weapons.txt 这张基础装备数据表。在暗黑破坏神2 的 TXT 体系里,武器并不是单纯的掉落对象,而是一组会持续参与战斗、商店、需求判定、词缀生成、名称显示和描述文本联动的基础条目。像 mindam、maxdam、2handmindam、2handmaxdam 这样的字段直接影响伤害区间,reqstr、reqdex、levelreq 影响角色装备门槛,spawnable、rarity、level 又决定它在掉落体系中的出现方式。只要 weapons.txt 被改动,游戏中的武器强度曲线、可获得节奏和基础底材生态都会发生变化。

从当前项目实现来看,这个模块并没有给 weapons.txt 单独写一套专属表单,而是采用"武器页包装文件 + 通用装备编辑器 + 公共字段分组 + 公共保存链路"的方案。页面层只声明当前编辑目标是 weapons.txt,真正的行切换、字段分组、控件选择、复制条目、描述编辑、保存回写都放在共享的 ItemEquipEditor 和控制器层里完成。这样的设计很适合基础装备模块,因为武器、防具、杂项在结构上高度相似,差异主要落在字段元数据和实际文件名上,而不是页面骨架上。
文章目录
文件说明
从模块协作关系看,武器物品页的核心并不在 ItemEquipWeaponsTab.vue 这一层,而是在更上层的文件切换入口、更中间的通用编辑器、更下层的字段分组配置和数据读写链路。ItemEquipWeaponsTab.vue 只是把 weapons.txt 作为参数交给共享编辑器,控制器负责把 header + rows + columns.json 组合成当前条目和当前分组,itemEquip.grouping.json 负责决定这些字段在界面里怎样归类,itemEquipHelpers.js 负责读写 weapons.txt,Rust 则负责真正把整表写回文件系统。

模块文件职责表如下。这里既保留直接参与运行的核心文件,也保留武器目录下存在但当前主链路未直接引用的标准化 grouping/layout 包装文件,避免把模块结构理解成只有一个 Vue 页面。
| 文件名 | 文件类型 | 模块职责 | 与界面或数据处理的关系 | 备注 |
|---|---|---|---|---|
src/modules/itemEdit/ItemEditTab.vue |
Vue | 装备编辑总入口 | 顶部文件页签切换到 weapons.txt 时挂载武器子页 |
当前模块页面入口 |
src/modules/itemEdit/weapons/ItemEquipWeaponsTab.vue |
Vue | 武器页包装组件 | 仅把 file-name="weapons.txt" 传给通用编辑器 |
武器模块最薄的一层 |
src/modules/itemEdit/ItemEquipEditor.vue |
Vue | 通用装备编辑器 | 提供搜索、条目选择、字段分组、保存、复制、描述编辑等完整 UI | 当前模块真正的主界面 |
src/pages/home/useHomePageController.js |
JS | 装备编辑状态装配 | 生成 itemEquipFileTabs、currentItemEquipFields、itemEquipGroupTabs、currentItemEquipActiveGroup 等运行时数据 |
解析与状态层核心 |
src/pages/home/itemEquipHelpers.js |
JS | 装备数据读取与保存 | 负责 loadItemEquipFile、updateItemEquipCell、addItemEquipRow、saveItemEquipFile |
当前模块前端数据层 |
src/pages/home/tableDataLoader.js |
JS | 通用 TXT 读取缓存 | 对 py_load_table 结果做缓存、去重和失效控制 |
读取性能层 |
src/modules/itemEdit/common/itemEquip.grouping.json |
JSON | 通用字段分组规则 | 按列名、关键词、范围组把字段归入"基础标识、需求与价格、攻防属性"等业务组 | 当前武器页实际分组主配置 |
src/modules/itemEdit/common/itemEquipGrouping.js |
JS | 通用分组解析器 | 根据 range_group、列名、包含词和说明文本解析字段归组 |
当前武器页实际分组解析层 |
src/modules/itemEdit/weapons/weapons.grouping.json |
JSON | 武器模块标准化分组配置 | 定义"基础信息/其他"两组的包装配置 | 当前源码未看到直接引用 |
src/modules/itemEdit/weapons/weapons.layout.json |
JSON | 武器模块布局顺序 | 定义武器专属包装配置的组顺序 | 当前源码未看到直接引用 |
src/modules/itemEdit/weapons/weaponsGrouping.js |
JS | 武器模块分组解析封装 | 通过 groupingFactory 生成解析器接口 |
当前源码未看到直接引用 |
src/assets/resources/index.json |
JSON | 列元数据索引 | 把 weapons.txt.columns.json 纳入统一资源索引 |
元数据入口 |
src/assets/resources/files/weapons.txt.columns.json |
JSON | 武器字段中文与说明 | 提供列中文名、字段类型、说明文字、范围组信息 | 字段说明层 |
src-tauri/src/main.rs |
Rust | Tauri 命令注册 | 暴露 py_load_table、py_save_table_json 给前端调用 |
命令桥接层 |
src-tauri/src/commands/files.rs |
Rust | 本地 TXT 读写命令 | 负责 weapons.txt 的编码读取、表头解析、整表写回与备份生成 |
当前模块后端数据层 |
把这些文件按层次拆开后,当前模块的组织方式就会很清楚。ItemEditTab.vue 属于页面壳层,决定"武器物品"在装备编辑中的位置。ItemEquipWeaponsTab.vue 属于页面包装层,职责只是把文件名交给通用编辑器。ItemEquipEditor.vue 属于界面层,处理武器条目切换、分组展示、描述编辑和复制保存。useHomePageController.js、itemEquipHelpers.js、tableDataLoader.js 属于状态与数据层。itemEquip.grouping.json、itemEquipGrouping.js、weapons.txt.columns.json 属于配置与解析层。main.rs 和 files.rs 则构成最终的本地写回层。
再看真正参与界面构建、字段展示、分组控制、复制逻辑和保存链路的状态、配置项与方法。这里不把所有变量机械列满,而是只保留武器页运行中真正起作用的部分。
| 字段/配置项/方法 | 所属文件 | 作用 | 在界面中的体现 | 修改后的影响 |
|---|---|---|---|---|
itemEquipFileTabs |
src/pages/home/useHomePageController.js |
定义装备编辑的文件页签 | 顶部显示"武器物品、防具物品、腰带编辑..." | 决定当前进入哪个文件编辑器 |
itemEquipState.fileName |
useHomePageController.js / ItemEditTab.vue |
当前正在编辑的文件名 | 选中"武器物品"后值为 weapons.txt |
决定读取、分组、保存目标 |
loadItemEquipFile |
src/pages/home/itemEquipHelpers.js |
读取当前装备文件 | 页面进入、点击刷新时加载 weapons.txt |
重建 header、rows、字段映射 |
currentItemEquipFields |
src/pages/home/useHomePageController.js |
把表头、当前行和列元数据转成字段对象数组 | 右侧表单的标签、tooltip、当前值都来自这里 | 决定界面可消费的数据结构 |
resolveItemEquipGroupByField |
src/modules/itemEdit/common/itemEquipGrouping.js |
给当前字段判定业务分组 | 右侧显示"基础标识、需求与价格、攻防属性..." | 改变字段归属和显示位置 |
itemEquipGroupTabs |
src/pages/home/useHomePageController.js |
输出当前分组名列表 | 左侧组切换按钮 | 决定右侧当前显示哪一组 |
currentItemEquipActiveGroup |
src/pages/home/useHomePageController.js |
当前选中的字段分组对象 | 当前分组字段被渲染成输入网格 | 决定画面当前焦点 |
rowKeyword / rowFilterColumn |
ItemEquipEditor.vue / state |
行检索关键词与过滤列 | 页面顶部"数据检索"区域 | 缩小武器条目检索范围 |
itemRowPicker 相关状态 |
ItemEquipEditor.vue |
当前条目选择下拉 | 顶部按钮下拉列出武器条目 | 决定当前编辑的是哪一行 |
addOrCopyActionLabel |
ItemEquipEditor.vue |
区分"新增"与"复制" | 武器页按钮文字显示为"复制" | 决定新增逻辑还是复制逻辑 |
handleAddOrCopyItemEquipRow |
ItemEquipEditor.vue |
当前条目复制入口 | 点击"复制"按钮触发 | 复制当前武器行并生成新键值 |
saveItemEquipAndDescription |
ItemEquipEditor.vue |
联合保存武器表与描述 | 点击"保存"按钮触发 | 先保存 weapons.txt,再按条件保存描述 |
saveItemDescription |
ItemEquipEditor.vue |
保存名称/描述文本 | "装备描述"分组中保存描述 | 通过 saveItemNameEntryByKey 写回当前键值文本 |
saveItemEquipFile |
src/pages/home/itemEquipHelpers.js |
保存当前装备文件 | 页面主保存动作的核心实现 | 走 py_save_table_json 写回本地文件 |
setCachedTableResult |
src/pages/home/tableDataLoader.js |
保存后刷新前端缓存 | 界面无感,但避免旧内容回显 | 保持读取结果与最新文件一致 |
武器模块还很适合从数据字段角度单独整理一张表。weapons.txt.columns.json 给出的信息并不只是中文标签,还包含字段类型、中文说明和英文说明,这让页面能够在显示标签的同时提供 tooltip,也让分组逻辑能够借助 desc_zh、desc_en 做更稳妥的归类。
| 字段名 | 中文含义 | 所属分组 | 前端展示方式 | 实际用途说明 |
|---|---|---|---|---|
name |
名称 | 基础标识 | 文本输入 | 作为物品条目引用字段 |
code |
物品代码 | 基础标识 | 文本输入 | 武器底材唯一标识,复制时会参与生成新编码 |
namestr |
名称字符串键 | 基础标识 | 文本输入 | 对应名称文本键值,和描述编辑联动 |
version |
版本 | 基础标识 | 版本选择或文本输入 | 决定经典版或资料片模式 |
level |
物品等级 | 需求与价格 | 数字输入 | 决定掉落层级与生成门槛 |
levelreq |
角色等级需求 | 需求与价格 | 数字输入 | 决定角色可装备等级 |
reqstr |
力量需求 | 需求与价格 | 数字输入 | 限制使用武器的力量门槛 |
reqdex |
敏捷需求 | 需求与价格 | 数字输入 | 限制使用武器的敏捷门槛 |
cost |
NPC售卖基础价格 | 需求与价格 | 数字输入 | 决定商店售卖价格基线 |
gamble cost |
赌博价格 | 需求与价格 | 数字输入 | 决定赌博界面的金币成本 |
spawnable |
可随机生成 | 掉落与生成 | 布尔输入 | 决定该武器是否参与随机生成 |
rarity |
稀有度 | 掉落与生成 | 数字输入 | 控制出现概率和稀有程度 |
speed |
速度惩罚 | 攻防属性 | 数字输入 | 对武器而言影响攻击速度惩罚 |
mindam |
最小物理伤害 | 攻防属性 | 数字输入 | 控制单手最小伤害 |
maxdam |
最大物理伤害 | 攻防属性 | 数字输入 | 控制单手最大伤害 |
2handmindam |
双手最小伤害 | 攻防属性 | 数字输入 | 控制双手武器最小伤害 |
2handmaxdam |
双手最大伤害 | 攻防属性 | 数字输入 | 控制双手武器最大伤害 |
gemoffset |
宝石效果起始偏移 | 镶嵌与孔 | 数字输入 | 决定读取 gems.txt 的起始偏移 |
prop1 ~ prop12、par1 ~ par12、min1 ~ min12、max1 ~ max12 |
词条属性组 | 词条属性 / 技能与词缀 | 分组输入网格 | 用于定义自动属性、参数和上下限 |
从这些字段可以看出,武器页在工具中的价值并不只是"能改伤害"。它同时管理基础标识、掉落生成、装备需求、商店经济、镶嵌兼容和词条属性结构。也正因为字段跨度大,当前实现没有把 weapons.txt 还原成一张平铺大表,而是把它重组为更适合编辑的业务分组界面。
软件开发
从开发实现角度看,装备编辑-武器物品 的设计很有代表性。页面入口非常薄,实际重量集中在共享编辑器和共享数据链路上。这个模块没有为 weapons.txt 手工写出一份定制表单,而是依赖 currentItemEquipFields 把表格行转换成字段对象,再交给 resolveItemEquipGroupByField 按规则分组,最后由 ItemEquipEditor.vue 动态渲染出控件。这种组织方式的核心意义在于,武器、防具、杂项可以共享同一套编辑框架,字段变化只需要调整元数据和分组规则,不需要把模板层改成难以维护的条件分支堆积。
核心实现结构表如下。
| 实现层 | 核心文件/代码 | 作用说明 | 设计意义 |
|---|---|---|---|
| 页面入口层 | ItemEditTab.vue / ItemEquipWeaponsTab.vue |
把"武器物品"挂入装备编辑页,并把 weapons.txt 传给通用编辑器 |
保持模块入口极简 |
| 字段解析层 | currentItemEquipFields |
把 header + row + colMeta 转成界面字段对象 |
页面不直接处理原始二维数组 |
| 分组配置层 | itemEquip.grouping.json / itemEquipGrouping.js |
按列名、说明文本、范围组归类字段 | 用配置替代手写表单结构 |
| 视图渲染层 | ItemEquipEditor.vue |
提供搜索、条目选择、分组切换、字段编辑、描述面板、复制和保存 | 不同装备文件共享统一交互 |
| 行操作层 | handleAddOrCopyItemEquipRow |
基础装备文件走"复制"逻辑 | 适合武器底材在相似模板上扩展 |
| 数据读取层 | loadItemEquipFile / tableDataLoader.js |
读取 weapons.txt 并缓存结果 |
降低重复读取与状态抖动 |
| 保存链路层 | saveItemEquipFile / py_save_table_json / save_table_json |
恢复隐藏列、整表序列化、本地落盘、备份生成 | 保证保存完整性和回滚能力 |
| 描述联动层 | saveItemEquipAndDescription / saveItemDescription |
武器描述分组可与基础表保存串联 | 把基础条目和文本键值放在同一操作入口 |
页面入口代码
vue
<template>
<section class="item-edit-section">
<div class="settings-tabs">
<button
v-for="file in itemEquipFileTabs"
:key="`equip-file-${file.file}`"
class="settings-tab"
:class="{ active: itemEquipState.fileName === file.file }"
@click="switchItemEquipFile(file.file)"
>
{{ file.label }}
</button>
</div>
<section class="item-edit-card">
<ItemEquipWeapons v-if="itemEquipState.fileName === 'weapons.txt'" />
<ItemEquipArmor v-else-if="itemEquipState.fileName === 'armor.txt'" />
<ItemEquipBelts v-else-if="itemEquipState.fileName === 'belts.txt'" />
<ItemEquipMisc v-else-if="itemEquipState.fileName === 'misc.txt'" />
<ItemEquipSets v-else-if="itemEquipState.fileName === 'Sets.txt'" />
<ItemEquipSetItems v-else-if="itemEquipState.fileName === 'SetItems.txt'" />
<ItemEquipUnique v-else />
</section>
</section>
</template>
vue
<template>
<ItemEquipEditor file-name="weapons.txt" />
</template>
<script setup>
import ItemEquipEditor from '../ItemEquipEditor.vue'
</script>
这两段代码很能说明武器模块的入口策略。ItemEditTab.vue 决定武器物品作为装备编辑的一个文件页签出现,ItemEquipWeaponsTab.vue 则只做一件事,把 weapons.txt 交给通用编辑器。也就是说,武器页本身不是一套独立业务壳,而是共享装备编辑系统中的一个文件实例。
字段解析与分组代码
js
const currentItemEquipFields = computed(() => {
const rowIndex = currentItemEquipRowIndex.value
if (rowIndex < 0) return []
const row = itemEquipState.rows[rowIndex] || []
const hasMap = Object.keys(itemEquipState.colMeta || {}).length > 0
const isCubeMain = normKey(itemEquipState.fileName) === 'cubemain.txt'
return itemEquipState.header
.map((col, index) => {
const meta = itemEquipColMeta(col)
const zh = (meta?.zh || '').trim()
return {
index,
colName: col,
en: col,
zh,
meta,
tooltip: itemEquipColTooltip(col),
value: row[index] || '',
}
})
.filter((field) => {
if (!hasMap) return true
if (isCubeMain) {
const raw = String(field?.colName || '').trim()
if (raw.startsWith('*')) return false
if (normKey(raw) === 'ladder') return false
return true
}
return field.zh.length > 0
})
})
js
function resolveGroupByRule(key, rangeGroup, desc) {
for (const rule of RULES) {
if (rangeGroup && rule.rangeGroups.includes(rangeGroup)) return rule.group
if (rule.columns.includes(key)) return rule.group
if (rule.includes.some((item) => item && key.includes(item))) return rule.group
if (rule.prefixes.some((item) => item && key.startsWith(item))) return rule.group
if (rule.descKeywords.some((item) => item && desc.includes(item))) return rule.group
}
return ''
}
export function resolveItemEquipGroupByField(field) {
const colName = String(field?.colName || '').trim()
const meta = field?.meta || null
const rangeGroup = normKey(meta?.range_group || '')
const key = normKey(colName)
const desc = normKey(`${meta?.desc_zh || ''} ${meta?.desc_en || ''} ${meta?.zh || ''}`)
return resolveGroupByRule(key, rangeGroup, desc) || ITEM_EQUIP_GROUP_FALLBACK
}
json
{
"order": [
"基础标识",
"需求与价格",
"攻防属性",
"掉落与生成",
"显示与背包",
"镶嵌与孔",
"技能与词缀",
"词条属性",
"其他"
],
"fallback": "其他",
"rules": [
{
"group": "词条属性",
"range_groups": [
"prop1 (to prop12)",
"par1 (to par12)",
"min1 (to min12)",
"max1 (to max12)"
]
},
{
"group": "基础标识",
"columns": ["name", "index", "code", "namestr", "version", "*id", "id", "carry1"]
},
{
"group": "需求与价格",
"includes": ["req", "level", "cost"],
"desc_keywords": ["需求", "价格"]
}
]
}
当前武器页的解析思路并不是"遍历字段名然后写一堆 if"。控制器先把当前行转成结构化字段,再把列名、中文说明、英文说明和 range_group 一并交给分组解析器。这样一来,像 reqstr、reqdex、levelreq、cost 这类字段会自然落到"需求与价格",mindam、maxdam、2handmindam、2handmaxdam 会落到"攻防属性",prop1~12 一整组则会被 range_groups 收束成"词条属性"。这类方案对武器模块尤其合适,因为 weapons.txt 的字段数量较多,而且存在大量规律化列名。
通用编辑器与复制逻辑代码
vue
<template>
<div class="item-equip-editor">
<div v-if="itemEquipState.statusError" class="item-edit-status error">
{{ itemEquipState.statusText || ' ' }}
</div>
<div class="row item-edit-row data-search-row">
<label>数据检索</label>
<select v-model="itemEquipState.rowFilterColumn" class="field-filter-select">
<option value="">全部字段(名称/代码)</option>
<option
v-for="item in itemEquipFilterColumns"
:key="`equip-filter-${item.col}`"
:value="item.col"
:title="itemEquipColTooltip(item.col)"
>
{{ itemEquipFieldFilterLabel(item.col) }}
</option>
</select>
<input
v-model.trim="itemEquipState.rowKeyword"
type="text"
placeholder="输入关键字搜索条目 (name/code/index)"
/>
</div>
<div v-if="currentItemEquipRowIndex >= 0 && currentItemEquipActiveGroup" class="settings-split-layout item-equip-group-layout">
<aside class="settings-split-side item-equip-side">
<div class="charstats-side-list item-group-tabs">
<button
v-for="groupName in currentItemEquipGroupTabs"
:key="`equip-group-${groupName}`"
class="settings-tab"
:class="{ active: isGroupTabActive(groupName) }"
@click="itemEquipState.activeGroup = groupName"
>
{{ groupName }}
</button>
</div>
<div class="settings-split-side-actions item-edit-actions">
<button class="warning" :disabled="itemEquipState.loading" @click="loadItemEquipFile(true)">刷新</button>
<button class="success" :disabled="itemEquipState.loading || !itemEquipState.header.length" @click="saveItemEquipAndDescription">
保存
</button>
<button
v-if="showAddOrCopyAction"
class="info"
:disabled="itemEquipState.loading || !itemEquipState.header.length"
@click="handleAddOrCopyItemEquipRow"
>
{{ addOrCopyActionLabel }}
</button>
</div>
</aside>
</div>
</div>
</template>
js
const isBaseItemFile = computed(() => {
const file = normKey(itemEquipState.fileName)
return file === 'weapons.txt' || file === 'armor.txt' || file === 'misc.txt'
})
const addOrCopyActionLabel = computed(() =>
(isCopyModeFile.value || isBaseItemFile.value) ? '复制' : '新增',
)
async function handleAddOrCopyItemEquipRow() {
if (isCopyModeFile.value || isBaseItemFile.value) {
const rows = Array.isArray(itemEquipState.rows) ? itemEquipState.rows : []
const header = Array.isArray(itemEquipState.header) ? itemEquipState.header : []
if (!rows.length || !header.length) {
addItemEquipRow()
return
}
const current = getActiveRowIndex()
const sourceIndex = current >= 0 && current < rows.length ? current : 0
const sourceRow = Array.isArray(rows[sourceIndex]) ? rows[sourceIndex] : []
const clone = [...sourceRow]
while (clone.length < header.length) clone.push('')
const keyColIndex = resolveCopyNameKeyColIndex(header)
const codeColIndex = resolveCopyCodeColIndex(header)
const sourceKey = keyColIndex >= 0 ? String(sourceRow?.[keyColIndex] || '').trim() : ''
const targetKey = keyColIndex >= 0 ? buildCloneNameKey(sourceKey, rows, keyColIndex) : ''
const sourceCode = codeColIndex >= 0 ? String(sourceRow?.[codeColIndex] || '').trim() : ''
const targetCode = buildCloneCode(sourceCode, rows, codeColIndex)
if (keyColIndex >= 0 && targetKey) clone[keyColIndex] = targetKey
if (codeColIndex >= 0 && targetCode) clone[codeColIndex] = targetCode
const insertAt = rows.length
itemEquipState.rows.push(clone)
itemEquipState.sourceRows.push([...clone])
itemEquipState.activeRowIndex = insertAt
}
}
武器页最值得关注的一点,是它在基础装备文件上采用"复制"而不是"新增"逻辑。weapons.txt 被识别为 isBaseItemFile,按钮文案会显示为"复制",点击后会以当前武器行为模板生成一条新记录,并尝试为名称键值和物品代码生成新的目标值。这比从空白行逐字段填写更符合基础底材扩展场景,也更接近 MOD 制作里的真实工作流。
描述分组与联合保存代码
js
const descEnabledFiles = new Set(['weapons.txt', 'armor.txt', 'belts.txt', 'misc.txt', 'sets.txt', 'uniqueitems.txt'])
if (descEnabledFiles.has(normKey(itemEquipState.fileName)) && !list.some((item) => item.name === '装备描述')) {
list.push({ name: '装备描述', fields: [] })
}
async function saveItemDescription() {
const key = activeItemEditKey.value
if (!key) return
const zhCN = reverseMultilineForStorage(itemDescZhCN.value)
const zhTW = reverseMultilineForStorage(itemDescZhTW.value)
const enUS = resolveCurrentItemEnName()
await saveItemNameEntryByKey(key, zhCN, zhTW, enUS)
}
async function saveItemEquipAndDescription() {
const ok = await saveItemEquipFile()
if (!ok) return
if (!isDescriptionGroup.value) return
try {
await saveItemDescription()
} catch (error) {
appendLog?.(`同步保存描述失败: ${String(error)}`)
}
}
从当前代码能够确认,武器页不仅能编辑 weapons.txt 里的数值字段,还会在分组列表里自动补出一个"装备描述"分组。这个分组并不是来自 itemEquip.grouping.json,而是控制器按文件名动态追加的。保存按钮也不是只保存基础表,而是优先调用 saveItemEquipFile 保存当前武器数据,在"装备描述"分组处于激活状态时,再通过 saveItemNameEntryByKey 继续写入当前名称或描述键值。当前代码能够确认存在这条联合保存入口,但描述文本最终落到哪一份语言资源文件,需要跟进更深一层的文本存储逻辑才能完整下结论,因此本文只写到源码直接可见的接口边界。
读取与保存代码
js
async function loadItemEquipFile(force = false) {
if (!state.excelPath) {
setItemEquipStatus('请先在文件配置定位 TXT 目录', 'error')
return
}
try {
itemEquipState.loading = true
const file = itemEquipState.fileName || 'weapons.txt'
const result = await loadTableData(state.excelPath, file, { force })
itemEquipState.fileName = file
itemEquipState.encoding = result.encoding || 'utf-8'
itemEquipState.header = result.header || []
itemEquipState.rows = (result.rows || []).map((row) => [...row])
itemEquipState.sourceRows = cloneRows(result.rows || [])
await loadItemEquipColumnMapping(file)
await loadItemEquipSkillOptions()
await loadTreasureClassNameOptions()
rebuildItemEquipRowSearchCache()
setItemEquipStatus(`已加载 ${file},共 ${itemEquipState.rows.length} 行`, 'ok')
appendLog(`装备编辑已加载: ${file}`)
} catch (error) {
setItemEquipStatus(`加载失败: ${String(error)}`, 'error')
} finally {
itemEquipState.loading = false
}
}
js
async function saveItemEquipFile() {
if (!state.excelPath || !itemEquipState.header.length) {
setItemEquipStatus('请先加载道具文件', 'error')
return false
}
try {
const hiddenColIndexes = itemEquipState.header
.map((col, index) => ({ col, index }))
.filter(({ col }) => {
const meta = itemEquipColMeta(col)
if (!meta) return false
return String(meta?.zh || '').trim().length === 0
})
.map((item) => item.index)
const sourceRows = cloneRows(itemEquipState.sourceRows)
const rowsForSave = cloneRows(itemEquipState.rows).map((row, rowIndex) => {
const source = sourceRows[rowIndex]
if (!Array.isArray(source)) return row
for (const colIndex of hiddenColIndexes) {
row[colIndex] = String(source[colIndex] ?? '')
}
return row
})
const result = await invoke('py_save_table_json', {
folder: state.excelPath,
file: itemEquipState.fileName,
encoding: itemEquipState.encoding,
headerJson: JSON.stringify(itemEquipState.header),
rowsJson: JSON.stringify(rowsForSave),
backupPath: backupPath.value || buildBackupPath(state.modPath),
})
if (typeof syncSqliteTxtFile === 'function') {
await syncSqliteTxtFile(itemEquipState.fileName, { logPrefix: '装备编辑保存后 SQLite' })
}
setItemEquipStatus(`保存成功: ${itemEquipState.fileName}`, 'ok')
itemEquipState.rows = cloneRows(rowsForSave)
itemEquipState.sourceRows = cloneRows(rowsForSave)
setCachedTableResult(state.excelPath, itemEquipState.fileName, {
encoding: itemEquipState.encoding,
header: [...itemEquipState.header],
rows: cloneRows(rowsForSave),
row_count: itemEquipState.rows.length,
path: joinExcelFilePath(state.excelPath, itemEquipState.fileName),
})
appendLog(`装备编辑保存成功: ${itemEquipState.fileName}`)
return true
} catch (error) {
setItemEquipStatus(`保存失败: ${String(error)}`, 'error')
return false
} finally {
itemEquipState.loading = false
}
}
rust
#[tauri::command]
fn py_load_table(folder: String, file: String) -> Result<Value, String> {
commands::files::load_table(folder, file)
}
#[tauri::command]
fn py_save_table_json(
folder: String,
file: String,
encoding: String,
header_json: String,
rows_json: String,
backup_path: Option<String>,
) -> Result<Value, String> {
commands::files::save_table_json(folder, file, encoding, header_json, rows_json, backup_path)
}
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()
}))
}
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()
}))
}
这一段链路是当前武器模块最核心的工程部分。loadItemEquipFile 读取 weapons.txt 之后,不只是拿到表格内容,还会继续加载列映射、技能选项、掉落类名等辅助数据,让通用编辑器拥有足够的上下文去渲染 tooltip、选择器和搜索内容。saveItemEquipFile 在写回之前会把中文元数据为空的隐藏列从 sourceRows 里恢复回来,这一点很关键,它避免了界面未展示字段在保存时被误清空。武器页并没有单独的后端保存特判,而是沿用 py_save_table_json -> save_table_json -> write_table 这条通用整表保存链路。保存完成后,如上下文中存在 syncSqliteTxtFile,还会继续触发 SQLite 同步。当前代码能够确认存在这条可选链路,但 SQLite 在整个工具里的完整用途超出了武器模块本文范围,因此只保留事实描述,不做额外扩展。
从当前代码能够确认的内容主要集中在前端配置驱动、字段分组解析、本地 Tauri 文件写回和描述联动入口四层。游戏运行中是否会即时热加载 weapons.txt,当前仓库没有看到对应监听或运行时注入逻辑,因此文章只围绕本地文件编辑与保存链路展开。
操作演示
从软件使用角度看,武器物品页的交互路径很清晰。进入装备编辑后切到"武器物品",页面会围绕 weapons.txt 构建当前条目编辑环境。顶部区域负责快速检索和条目切换,左侧负责分组切换和保存动作,右侧负责分组字段编辑。与一般整表编辑器不同,武器页的主增量操作不是"新增空白行",而是"复制当前条目",这很贴合基础底材扩展的实际场景。
操作演示表如下。表中只写当前源码能够直接确认的界面行为和数据结果。
| 操作阶段 | 界面表现 | 操作动作 | 可确认结果 |
|---|---|---|---|
| 模块进入 | 装备编辑顶部显示多个文件页签 | 点击"武器物品" | itemEquipState.fileName 切到 weapons.txt,挂载 ItemEquipWeaponsTab |
| 初次读取 | 页面进入后开始加载数据 | 自动读取或手动点击刷新 | loadItemEquipFile 读取 weapons.txt,生成 header、rows、列映射和搜索缓存 |
| 条目检索 | 顶部有字段过滤下拉和关键字输入框 | 输入关键字或限制搜索列 | 按 name/code/index 或指定字段筛选武器条目 |
| 条目切换 | 顶部条目选择器列出当前武器行 | 点击某一条武器记录 | activeRowIndex 切到目标行,右侧字段同步刷新 |
| 分组切换 | 左侧显示"基础标识、需求与价格、攻防属性..." | 点击某个分组 | currentItemEquipActiveGroup 切换到目标组,右侧只展示这一组字段 |
| 基础属性修改 | 攻防属性组显示伤害、防御、速度相关字段 | 修改 mindam、maxdam、2handmindam 等数值 |
当前行内存数据立即更新 |
| 掉落与生成调整 | 掉落与生成组显示 spawnable、rarity、level 等字段 |
调整生成与稀有度参数 | 武器底材掉落相关数据被更新到当前行 |
| 需求与价格调整 | 需求与价格组显示 reqstr、reqdex、levelreq、cost |
修改使用条件和价格 | 装备门槛与商店成本参数被更新 |
| 词条属性维护 | 词条属性组集中显示 prop/par/min/max 系列字段 |
编辑属性代码、参数和范围 | 当前武器的自动属性结构被更新 |
| 复制当前武器 | 左侧动作按钮显示"复制" | 点击"复制" | 以当前行为模板复制一条新武器记录,并尝试生成新的名称键值和代码 |
| 描述编辑 | 部分武器页签会出现"装备描述"分组 | 编辑 zhCN、zhTW 文本并查看预览 |
当前名称或描述键值文本可通过联合保存入口写入 |
| 保存数据 | 点击"保存"按钮 | 保存当前修改 | saveItemEquipAndDescription 触发,先保存 weapons.txt,处于描述分组时再继续保存描述文本 |
| 缓存同步 | 保存后页面状态保持最新 | 再次读取或切回文件编辑区 | setCachedTableResult 已更新缓存,必要时触发同名文件视图刷新 |
| 本地落盘 | 保存成功后显示成功状态 | 无额外操作 | py_save_table_json 已把整表写回本地并生成备份 |
| 游戏内验证 | 返回游戏环境查看底材变化 | 外部验证 | 从当前代码能够确认文件已写回,运行时生效时机仍依赖具体环境 |
这套交互流对于武器底材编辑非常实用。若目标是微调已有武器伤害或需求,可以直接在当前条目上改值并保存;若目标是做一件全新的底材武器,复制当前行再改 name、code、伤害、需求、掉落参数通常比从空白行开始稳定得多。再加上"装备描述"分组提供的名称与描述联动入口,武器模块实际上覆盖了基础数据和文本显示两类常见工作内容。
总结
装备编辑-武器物品 是这套暗黑破坏神2 MOD 修改工具中非常典型的"通用编辑器承载具体文件"的模块。武器页本身足够轻,真正的复杂度被拆解到共享编辑器、字段元数据、分组规则、状态控制器和本地写回链路里。这样的结构让 weapons.txt 不再以原始大表的方式直接暴露,而是被重组为"可检索、可分组、可复制、可联合保存描述"的编辑页。
从开发实现看,这个模块的关键不在于写了多少武器专属代码,而在于它正确复用了项目现有基础设施。ItemEditTab.vue 负责入口切换,ItemEquipWeaponsTab.vue 负责文件包装,useHomePageController.js 负责把表格行解析成可渲染字段,itemEquip.grouping.json 负责业务归组,itemEquipHelpers.js 负责读取和保存,files.rs 负责真正的本地落盘。这种组织方式比把 weapons.txt 的所有列硬编码到页面里更适合长期维护,也更适合继续扩展其它装备文件。