装备编辑-其他物品 对应的是 misc.txt 这张杂项物品数据表。在暗黑破坏神2 的 TXT 数据体系中,misc.txt 不是某一类单纯装备底材表,而是承载了大量"非武器、非防具"的基础物品配置。它既包含 name、code、namestr 这类基础标识字段,也包含 spawnable、rarity、level、cost 这类生成与经济字段,还包含 useable、stackable、minstack、maxstack、quest、missiletype、spellicon、pspell 等与可使用、堆叠、任务、投射物和法术行为相关的字段。也就是说,这个模块影响的不是单一装备强度,而是杂项物品在游戏中的生成、使用、堆叠、显示和特殊行为。

从当前项目源码来看,装备编辑-其他物品 继续沿用装备编辑模块的共享架构。页面层只有一个很薄的 ItemEquipMiscTab.vue,它把 misc.txt 交给通用 ItemEquipEditor。真正的字段解析、分组展示、条目检索、复制、描述编辑和保存逻辑,都由公共控制器、公共分组配置和 Tauri 本地写回链路完成。和腰带编辑不同,misc.txt 被当前通用编辑器识别为基础装备文件,因此页面主操作按钮显示为"复制",适合在已有杂项物品条目上扩展新记录。
文章目录
文件说明
从模块结构看,装备编辑-其他物品 并不是独立开发的一张表单页,而是共享装备编辑体系中的一个文件实例。ItemEditTab.vue 负责在装备编辑页签中切换到 misc.txt,ItemEquipMiscTab.vue 负责把文件名传给 ItemEquipEditor,控制器负责把表头、当前行和列元数据转换成界面可消费字段,公共 grouping 配置负责把字段归入不同业务分组,最终由 Tauri 命令把整张 misc.txt 写回本地文件。

模块文件职责表如下。这里既列出当前主链路实际运行的文件,也保留 misc 目录下存在但当前主编辑链路未看到直接引用的标准化 grouping/layout 文件。
| 文件名 | 文件类型 | 模块职责 | 与界面或数据处理的关系 | 备注 |
|---|---|---|---|---|
src/modules/itemEdit/ItemEditTab.vue |
Vue | 装备编辑总入口 | 顶部文件页签切换到 misc.txt 时挂载其他物品子页 |
当前模块页面入口 |
src/modules/itemEdit/misc/ItemEquipMiscTab.vue |
Vue | 其他物品页包装组件 | 仅把 file-name="misc.txt" 传给通用编辑器 |
当前模块最薄的一层 |
src/modules/itemEdit/ItemEquipEditor.vue |
Vue | 通用装备编辑器 | 提供搜索、条目切换、分组切换、复制、保存和描述编辑 | 当前模块主界面 |
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/misc/misc.grouping.json |
JSON | 其他物品模块标准化分组配置 | 定义"基础信息/其他"两组的包装配置 | 当前源码未看到直接引用 |
src/modules/itemEdit/misc/misc.layout.json |
JSON | 其他物品模块布局顺序 | 定义 misc 包装配置的组顺序 |
当前源码未看到直接引用 |
src/modules/itemEdit/misc/miscGrouping.js |
JS | 其他物品模块分组解析封装 | 通过 groupingFactory 输出解析器接口 |
当前源码未看到直接引用 |
src/assets/resources/index.json |
JSON | 列元数据索引 | 把 misc.txt.columns.json 纳入统一资源索引 |
元数据入口 |
src/assets/resources/files/misc.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 读写命令 | 负责 misc.txt 的编码读取、表头解析、整表写回和备份生成 |
当前模块后端数据层 |
把这些文件按层级拆开后,当前模块的结构就很清楚了。ItemEditTab.vue 和 ItemEquipMiscTab.vue 属于页面壳层,负责把"其他物品"放进正确的装备编辑入口。ItemEquipEditor.vue 属于通用视图层,负责把当前行渲染成分组表单。useHomePageController.js 和 itemEquipHelpers.js 属于状态和数据层,负责字段转换、读取、复制、保存和表头校验。misc.txt.columns.json 与公共 grouping 文件属于配置与解析层。files.rs 则是真正的本地落盘层。
再看真正参与界面构建、字段展示、分组控制、复制行为和保存逻辑的方法与状态。
| 字段/配置项/方法 | 所属文件 | 作用 | 在界面中的体现 | 修改后的影响 |
|---|---|---|---|---|
itemEquipFileTabs |
src/pages/home/useHomePageController.js |
定义装备编辑文件页签 | 顶部显示"武器物品、防具物品、腰带编辑、其他物品..." | 决定当前进入哪个文件编辑器 |
itemEquipState.fileName |
useHomePageController.js / ItemEditTab.vue |
当前编辑中的文件名 | 选中"其他物品"后值为 misc.txt |
决定读取、分组和保存目标 |
loadItemEquipFile |
src/pages/home/itemEquipHelpers.js |
读取当前装备文件 | 页面进入、点击刷新时加载 misc.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 |
条目检索关键词与过滤列 | 页面顶部"数据检索"区域 | 缩小杂项物品条目搜索范围 |
isBaseItemFile |
ItemEquipEditor.vue |
判断是否属于基础装备文件 | misc.txt 被识别为基础装备文件 |
影响按钮文案和复制行为 |
addOrCopyActionLabel |
ItemEquipEditor.vue |
区分"新增"与"复制" | 其他物品页按钮文字显示为"复制" | 决定当前主增量动作 |
handleAddOrCopyItemEquipRow |
ItemEquipEditor.vue |
当前条目复制入口 | 点击"复制"按钮触发 | 复制当前杂项物品行并生成新键值 |
validateItemEquipHeaderByFile |
src/pages/home/itemEquipHelpers.js |
保存前校验文件表头 | 界面无直接显示,保存时执行 | misc.txt 必须具备 name、compactsave、code、levelreq 等签名列 |
saveItemEquipAndDescription |
ItemEquipEditor.vue |
联合保存杂项物品表与描述 | 点击"保存"按钮触发 | 先保存 misc.txt,处于描述分组时再保存描述 |
saveItemEquipFile |
src/pages/home/itemEquipHelpers.js |
保存当前装备文件 | 页面主保存动作的核心实现 | 走 py_save_table_json 写回本地文件 |
setCachedTableResult |
src/pages/home/tableDataLoader.js |
保存后刷新前端缓存 | 界面无感,但避免旧数据回显 | 保持前端读取结果与最新文件一致 |
misc.txt 的字段跨度很大,因此很适合从数据字段角度额外整理一张表。下表只选取当前源码中能够从 misc.txt.columns.json 直接确认的代表性字段,不补写无法确认的额外逻辑。
| 字段名 | 中文含义 | 所属分组 | 前端展示方式 | 实际用途说明 |
|---|---|---|---|---|
name |
名称 | 基础标识 | 文本输入 | 定义杂项物品条目的引用名称 |
code |
代码 | 基础标识 | 文本输入 | 作为物品唯一引用代码,复制时会参与新编码生成 |
namestr |
名称字符串 | 基础标识 | 文本输入 | 对应基础物品名称文本键 |
version |
版本 | 基础标识 | 版本选择或文本输入 | 区分经典模式与资料片模式 |
compactsave |
紧凑存档 | 基础标识 / 其他 | 布尔输入 | 控制存档是否只保存基础属性 |
spawnable |
可生成 | 掉落与生成 | 布尔输入 | 控制该杂项物品是否参与随机生成 |
rarity |
稀有度 | 掉落与生成 | 数字输入 | 控制随机生成概率 |
level |
等级 | 需求与价格 | 数字输入 | 控制基础物品等级与掉落门槛 |
levelreq |
等级需求 | 需求与价格 | 数字输入 | 控制角色使用该物品所需等级 |
cost |
基础价格 | 需求与价格 | 数字输入 | 控制 NPC 售卖基础价格 |
gamble cost |
赌博价格 | 需求与价格 | 数字输入 | 控制赌博界面金币价格 |
auto prefix |
自动前缀 | 技能与词缀 | 文本输入 | 与 automagic.txt 的自动词缀组相关 |
useable |
可使用 | 其他 | 布尔输入 | 控制物品是否可通过右键使用 |
stackable |
可堆叠 | 其他 | 布尔输入 | 控制是否使用数量字段与堆叠逻辑 |
minstack |
最小堆叠 | 其他 | 数字输入 | 控制允许的最小数量 |
maxstack |
最大堆叠 | 其他 | 数字输入 | 控制允许的最大数量 |
spawnstack |
生成堆叠 | 其他 | 数字输入 | 控制生成时的数量 |
type |
类型 | 其他 | 文本输入 | 引用 ItemTypes.txt 中的物品类型 |
type2 |
次类型 | 其他 | 文本输入或数字输入 | 引用次级物品类型 |
belt |
腰带可用 | 其他 | 文本输入 | 控制腰带物品使用的 belts.txt 索引 |
quest |
任务 | 其他 | 文本输入 | 控制任务物品标记和相关任务功能 |
questdiffcheck |
任务难度检测 | 其他 | 布尔输入 | 与任务物品在不同难度下的处理相关 |
missiletype |
投射物类型 | 其他 | 文本输入 | 引用 Missiles.txt 中的投射物 ID |
spellicon |
法术图标 | 技能与词缀 | 数字输入 | 决定物品使用法术显示图标 |
pspell |
使用法术 | 技能与词缀 | 文本输入 | 选择物品使用时的 spell function |
从字段结构可以看出,其他物品 模块承担的是非常宽的杂项物品编辑职责。它不像武器模块主要围绕伤害,也不像防具模块主要围绕防御,而是把可使用、可堆叠、任务、投射物、音效、显示、生成和价格都集中在同一张表里。因此,这个模块单独作为装备编辑页签存在是合理的。
软件开发
从开发视角看,装备编辑-其他物品 是共享装备编辑器体系里一个字段跨度较大的模块。它仍然使用 ItemEquipEditor.vue 做 UI,使用 currentItemEquipFields 把当前行转换成字段对象,使用 itemEquip.grouping.json 和 itemEquipGrouping.js 做字段归组,使用 itemEquipHelpers.js 做读取、校验和保存。与腰带模块不同的是,misc.txt 被当前逻辑识别为基础装备文件,因此复制按钮和复制链路都会启用。与武器、防具不同的是,misc.txt 在保存前存在明确表头签名校验,这说明项目作者对这张表的风险边界做了更强约束。
核心实现结构表如下。
| 实现层 | 核心文件/代码 | 作用说明 | 设计意义 |
|---|---|---|---|
| 页面入口层 | ItemEditTab.vue / ItemEquipMiscTab.vue |
把"其他物品"挂入装备编辑页,并把 misc.txt 传给通用编辑器 |
保持模块入口极简 |
| 字段解析层 | currentItemEquipFields |
把 header + row + colMeta 转成界面字段对象 |
页面不直接处理原始二维数组 |
| 分组配置层 | itemEquip.grouping.json / itemEquipGrouping.js |
按列名、说明文本和范围组归类字段 | 用配置替代硬编码表单 |
| 视图渲染层 | ItemEquipEditor.vue |
提供搜索、条目选择、分组切换、字段编辑、复制、保存和描述编辑 | 不同装备文件共享统一交互 |
| 复制操作层 | handleAddOrCopyItemEquipRow |
misc.txt 被识别为基础装备文件,因此走复制逻辑 |
适合基于已有杂项物品扩展新条目 |
| 表头校验层 | validateItemEquipHeaderByFile |
misc.txt 保存前校验 name、compactsave、code、levelreq |
降低误选错误文件后的保存风险 |
| 数据读取层 | loadItemEquipFile / tableDataLoader.js |
读取 misc.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="misc.txt" />
</template>
<script setup>
import ItemEquipEditor from '../ItemEquipEditor.vue'
</script>
这两段代码说明,其他物品页并没有独立页面壳层。ItemEditTab.vue 负责在文件页签里切换到 misc.txt,ItemEquipMiscTab.vue 只负责把文件名传入通用装备编辑器。当前模块的差异不在入口层,而在字段元数据、分组归类、复制逻辑和表头校验层。
字段解析与分组代码
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
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": "基础标识",
"columns": ["name", "index", "code", "namestr", "version", "*id", "id", "carry1"]
},
{
"group": "需求与价格",
"includes": ["req", "level", "cost"],
"desc_keywords": ["需求", "价格"]
},
{
"group": "掉落与生成",
"includes": ["spawn", "rarity", "prob", "drop"],
"desc_keywords": ["掉落", "生成"]
},
{
"group": "显示与背包",
"includes": ["inv", "gfx", "transform", "component"],
"desc_keywords": ["显示", "背包"]
},
{
"group": "技能与词缀",
"prefixes": ["prop", "par", "min", "max"],
"includes": ["skill", "spell"],
"desc_keywords": ["技能", "词缀"]
}
]
}
这一层代码说明,其他物品页的字段分组并不是在模板里写死的。控制器先把当前行转成统一字段对象,字段对象里包含列名、中文名、tooltip、当前值和元数据。分组解析器再根据列名、range_group、中文说明、英文说明和关键词判断字段属于哪个业务组。对于 misc.txt 来说,name、code、namestr 会进入"基础标识",levelreq、cost、gamble cost 会进入"需求与价格",spawnable、rarity 会进入"掉落与生成",spellicon、pspell 这类字段则更容易进入"技能与词缀"或 fallback 组。这样做的意义在于,即使 misc.txt 字段跨度很大,页面仍然能用相同的表单骨架完成展示。
从当前代码能够确认,misc 目录下也存在 miscGrouping.js、misc.grouping.json 和 misc.layout.json 这一套标准化封装文件,但主编辑链路没有直接引用它们。当前运行时主要依赖公共 itemEquipGrouping。这说明其他物品模块已经具备独立配置扩展点,但实际展示仍由共享装备编辑体系统一管理。
复制行为与描述保存代码
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
}
}
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)}`)
}
}
misc.txt 在当前编辑器里被视为基础装备文件,因此主按钮显示为"复制"。这和腰带模块不同,腰带页走新增空白行,而其他物品页更适合从已有药剂、卷轴、钥匙、任务物品或其他杂项条目复制一份,再调整 name、code、type、堆叠、使用和生成相关字段。复制逻辑会尝试生成新的名称键和代码,并把复制后的行追加到表末尾。
从当前代码还能确认,misc.txt 被纳入"装备描述"分组的动态追加范围。也就是说,其他物品页不仅可以编辑基础 TXT 字段,还可以在描述分组中通过 saveItemNameEntryByKey 写回当前名称或描述键值。至于描述文本最终落入哪一份外部语言资源文件,当前片段无法完整确认,因此本文只写到当前源码明确暴露的保存入口。
读取、校验与保存代码
js
function validateItemEquipHeaderByFile() {
const file = normKey(itemEquipState.fileName)
const header = Array.isArray(itemEquipState.header) ? itemEquipState.header : []
const keys = new Set(header.map((h) => normKey(h)))
const signatures = {
'treasureclassex.txt': ['Treasure Class', 'Picks', 'Item1', 'Prob1'],
'misc.txt': ['name', 'compactsave', 'code', 'levelreq'],
'monstats.txt': ['Id', 'TreasureClass', 'TreasureClass(N)'],
'monstats2.txt': ['Id', 'Height', 'OverlayHeight'],
}
// ...
}
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 check = validateItemEquipHeaderByFile()
if (!check.ok) {
setItemEquipStatus(check.reason, 'error')
appendLog(`装备编辑保存已拦截: ${check.reason}`)
return false
}
itemEquipState.loading = true
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
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()
}))
}
这条链路说明了其他物品模块的数据闭环。读取时,前端通过 loadTableData 间接调用 py_load_table,拿到表头、行数据和编码信息,再加载 misc.txt.columns.json 对应的列元数据。保存时,saveItemEquipFile 会先执行 validateItemEquipHeaderByFile,其中 misc.txt 明确要求存在 name、compactsave、code、levelreq 这几个签名列。这个校验可以降低错误文件被当作杂项物品表保存的风险。写回前,代码还会把界面未展示的隐藏列从 sourceRows 恢复回来,避免保存时丢失原始数据。Tauri Rust 侧负责解析 JSON、恢复字符串二维表、按原编码写回,并生成备份路径。
从当前代码能够确认,完整后端保存链路是本地 Tauri 文件写回,不是远程 HTTP 接口。游戏运行中是否即时热加载 misc.txt,当前仓库没有看到对应监听或热更新实现,因此本文只围绕本地编辑和本地保存展开。
操作演示
从软件使用路径看,其他物品页的操作方式和武器、防具保持一致,但编辑内容更偏向杂项物品功能。进入装备编辑后切到"其他物品",页面会围绕 misc.txt 构建当前条目编辑环境。顶部区域负责检索和切换条目,左侧负责分组切换和保存动作,右侧负责编辑当前分组下的字段。
操作演示表如下。表中的行为都来自当前源码能够直接确认的页面逻辑和数据链路。
| 操作阶段 | 界面表现 | 操作动作 | 可确认结果 |
|---|---|---|---|
| 模块进入 | 装备编辑顶部显示多个文件页签 | 点击"其他物品" | itemEquipState.fileName 切到 misc.txt,挂载 ItemEquipMiscTab |
| 初次读取 | 页面进入后加载数据 | 自动读取或点击刷新 | loadItemEquipFile 读取 misc.txt,生成 header、rows、列元数据和搜索缓存 |
| 条目检索 | 顶部有字段过滤下拉和关键字输入框 | 输入关键字或限制搜索列 | 按 name/code/index 或指定字段筛选杂项物品 |
| 条目切换 | 顶部条目选择器列出当前记录 | 点击某一条记录 | activeRowIndex 切到目标行,右侧字段同步刷新 |
| 分组切换 | 左侧显示分组按钮 | 点击某个分组 | currentItemEquipActiveGroup 切换到目标组,右侧只显示该组字段 |
| 编辑基础标识 | 基础标识组显示 name、code、namestr 等字段 |
修改标识字段 | 当前杂项物品的引用键值被更新 |
| 编辑生成经济 | 需求与价格、掉落与生成组显示 level、levelreq、cost、spawnable、rarity 等字段 |
修改数值或开关 | 物品生成、等级门槛和价格相关参数被更新 |
| 编辑使用与堆叠 | 其他分组中显示 useable、stackable、minstack、maxstack 等字段 |
修改堆叠与使用参数 | 杂项物品的使用和数量逻辑被更新到当前行 |
| 编辑任务与投射物 | 其他分组中显示 quest、questdiffcheck、missiletype 等字段 |
修改字段值 | 任务标记和投射物引用参数被更新 |
| 编辑法术显示 | 技能与词缀或其他分组中显示 spellicon、pspell 等字段 |
修改法术相关字段 | 物品使用法术和图标相关参数被更新 |
| 复制当前条目 | 左侧动作按钮显示"复制" | 点击"复制" | 当前杂项物品行被复制,并尝试生成新的名称键和代码 |
| 描述编辑 | 分组中可出现"装备描述" | 编辑文本并保存 | 当前名称或描述键值可通过联合保存入口写入 |
| 保存数据 | 点击"保存"按钮 | 保存当前修改 | 表头校验通过后,py_save_table_json 写回 misc.txt 并生成备份 |
| 缓存同步 | 保存后页面状态保持最新 | 再次读取或切换文件 | setCachedTableResult 更新缓存,避免旧数据回显 |
| 游戏内验证 | 返回游戏环境查看杂项物品表现 | 外部验证 | 从当前代码能够确认文件已写回,运行时生效时机仍依赖具体环境 |
从操作角度看,其他物品页适合处理大量"行为型物品"。例如调整可堆叠数量、改变任务标记、修改使用法术、调整掉落与价格参数,都可以在同一页完成。复制当前条目的设计也很适合制作相似杂项物品,因为许多杂项物品之间往往共享相近结构,只需要改动少数字段即可形成新记录。
总结
装备编辑-其他物品 是这套暗黑破坏神2 MOD 修改工具里字段跨度最大的一类基础物品编辑模块。它并不聚焦伤害或防御,而是把生成、经济、堆叠、使用、任务、投射物、音效、显示和法术行为集中到一张 misc.txt 表里。当前工具没有把这些字段硬编码成专属页面,而是通过共享 ItemEquipEditor、公共字段分组、列元数据和本地保存链路,把复杂表结构整理成可检索、可分组、可复制、可保存的编辑界面。
从开发实现看,这个模块的价值在于复用和约束并存。一方面,它复用了装备编辑通用入口、字段解析、分组渲染、描述编辑和 Tauri 写回链路。另一方面,它又在 validateItemEquipHeaderByFile 中为 misc.txt 添加了明确表头签名,降低了误保存风险。对于长期维护 MOD 工具的开发者来说,这种设计比写死一张杂项物品大表更稳,也更适合后续继续扩展专属分组或专属控件。