暗黑破坏神2 MOD修改工具装备编辑武器物品

装备编辑-武器物品 对应的是 weapons.txt 这张基础装备数据表。在暗黑破坏神2 的 TXT 体系里,武器并不是单纯的掉落对象,而是一组会持续参与战斗、商店、需求判定、词缀生成、名称显示和描述文本联动的基础条目。像 mindammaxdam2handmindam2handmaxdam 这样的字段直接影响伤害区间,reqstrreqdexlevelreq 影响角色装备门槛,spawnableraritylevel 又决定它在掉落体系中的出现方式。只要 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 装备编辑状态装配 生成 itemEquipFileTabscurrentItemEquipFieldsitemEquipGroupTabscurrentItemEquipActiveGroup 等运行时数据 解析与状态层核心
src/pages/home/itemEquipHelpers.js JS 装备数据读取与保存 负责 loadItemEquipFileupdateItemEquipCelladdItemEquipRowsaveItemEquipFile 当前模块前端数据层
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_tablepy_save_table_json 给前端调用 命令桥接层
src-tauri/src/commands/files.rs Rust 本地 TXT 读写命令 负责 weapons.txt 的编码读取、表头解析、整表写回与备份生成 当前模块后端数据层

把这些文件按层次拆开后,当前模块的组织方式就会很清楚。ItemEditTab.vue 属于页面壳层,决定"武器物品"在装备编辑中的位置。ItemEquipWeaponsTab.vue 属于页面包装层,职责只是把文件名交给通用编辑器。ItemEquipEditor.vue 属于界面层,处理武器条目切换、分组展示、描述编辑和复制保存。useHomePageController.jsitemEquipHelpers.jstableDataLoader.js 属于状态与数据层。itemEquip.grouping.jsonitemEquipGrouping.jsweapons.txt.columns.json 属于配置与解析层。main.rsfiles.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_zhdesc_en 做更稳妥的归类。

字段名 中文含义 所属分组 前端展示方式 实际用途说明
name 名称 基础标识 文本输入 作为物品条目引用字段
code 物品代码 基础标识 文本输入 武器底材唯一标识,复制时会参与生成新编码
namestr 名称字符串键 基础标识 文本输入 对应名称文本键值,和描述编辑联动
version 版本 基础标识 版本选择或文本输入 决定经典版或资料片模式
level 物品等级 需求与价格 数字输入 决定掉落层级与生成门槛
levelreq 角色等级需求 需求与价格 数字输入 决定角色可装备等级
reqstr 力量需求 需求与价格 数字输入 限制使用武器的力量门槛
reqdex 敏捷需求 需求与价格 数字输入 限制使用武器的敏捷门槛
cost NPC售卖基础价格 需求与价格 数字输入 决定商店售卖价格基线
gamble cost 赌博价格 需求与价格 数字输入 决定赌博界面的金币成本
spawnable 可随机生成 掉落与生成 布尔输入 决定该武器是否参与随机生成
rarity 稀有度 掉落与生成 数字输入 控制出现概率和稀有程度
speed 速度惩罚 攻防属性 数字输入 对武器而言影响攻击速度惩罚
mindam 最小物理伤害 攻防属性 数字输入 控制单手最小伤害
maxdam 最大物理伤害 攻防属性 数字输入 控制单手最大伤害
2handmindam 双手最小伤害 攻防属性 数字输入 控制双手武器最小伤害
2handmaxdam 双手最大伤害 攻防属性 数字输入 控制双手武器最大伤害
gemoffset 宝石效果起始偏移 镶嵌与孔 数字输入 决定读取 gems.txt 的起始偏移
prop1 ~ prop12par1 ~ par12min1 ~ min12max1 ~ 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 一并交给分组解析器。这样一来,像 reqstrreqdexlevelreqcost 这类字段会自然落到"需求与价格",mindammaxdam2handmindam2handmaxdam 会落到"攻防属性",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,生成 headerrows、列映射和搜索缓存
条目检索 顶部有字段过滤下拉和关键字输入框 输入关键字或限制搜索列 name/code/index 或指定字段筛选武器条目
条目切换 顶部条目选择器列出当前武器行 点击某一条武器记录 activeRowIndex 切到目标行,右侧字段同步刷新
分组切换 左侧显示"基础标识、需求与价格、攻防属性..." 点击某个分组 currentItemEquipActiveGroup 切换到目标组,右侧只展示这一组字段
基础属性修改 攻防属性组显示伤害、防御、速度相关字段 修改 mindammaxdam2handmindam 等数值 当前行内存数据立即更新
掉落与生成调整 掉落与生成组显示 spawnableraritylevel 等字段 调整生成与稀有度参数 武器底材掉落相关数据被更新到当前行
需求与价格调整 需求与价格组显示 reqstrreqdexlevelreqcost 修改使用条件和价格 装备门槛与商店成本参数被更新
词条属性维护 词条属性组集中显示 prop/par/min/max 系列字段 编辑属性代码、参数和范围 当前武器的自动属性结构被更新
复制当前武器 左侧动作按钮显示"复制" 点击"复制" 以当前行为模板复制一条新武器记录,并尝试生成新的名称键值和代码
描述编辑 部分武器页签会出现"装备描述"分组 编辑 zhCNzhTW 文本并查看预览 当前名称或描述键值文本可通过联合保存入口写入
保存数据 点击"保存"按钮 保存当前修改 saveItemEquipAndDescription 触发,先保存 weapons.txt,处于描述分组时再继续保存描述文本
缓存同步 保存后页面状态保持最新 再次读取或切回文件编辑区 setCachedTableResult 已更新缓存,必要时触发同名文件视图刷新
本地落盘 保存成功后显示成功状态 无额外操作 py_save_table_json 已把整表写回本地并生成备份
游戏内验证 返回游戏环境查看底材变化 外部验证 从当前代码能够确认文件已写回,运行时生效时机仍依赖具体环境

这套交互流对于武器底材编辑非常实用。若目标是微调已有武器伤害或需求,可以直接在当前条目上改值并保存;若目标是做一件全新的底材武器,复制当前行再改 namecode、伤害、需求、掉落参数通常比从空白行开始稳定得多。再加上"装备描述"分组提供的名称与描述联动入口,武器模块实际上覆盖了基础数据和文本显示两类常见工作内容。

总结

装备编辑-武器物品 是这套暗黑破坏神2 MOD 修改工具中非常典型的"通用编辑器承载具体文件"的模块。武器页本身足够轻,真正的复杂度被拆解到共享编辑器、字段元数据、分组规则、状态控制器和本地写回链路里。这样的结构让 weapons.txt 不再以原始大表的方式直接暴露,而是被重组为"可检索、可分组、可复制、可联合保存描述"的编辑页。

从开发实现看,这个模块的关键不在于写了多少武器专属代码,而在于它正确复用了项目现有基础设施。ItemEditTab.vue 负责入口切换,ItemEquipWeaponsTab.vue 负责文件包装,useHomePageController.js 负责把表格行解析成可渲染字段,itemEquip.grouping.json 负责业务归组,itemEquipHelpers.js 负责读取和保存,files.rs 负责真正的本地落盘。这种组织方式比把 weapons.txt 的所有列硬编码到页面里更适合长期维护,也更适合继续扩展其它装备文件。

相关推荐
Mr数据杨6 小时前
暗黑破坏神2 MOD修改工具游戏设置怪物等级
mod·游戏工具·暗黑破坏神2重置版
cqbzcsq2 个月前
MC Forge 1.20.1 mod开发学习笔记(战利品、标签、配方)
java·笔记·学习·mod·mc
cqbzcsq2 个月前
MC Forge1.20.1 mod开发学习笔记(个人向)
笔记·学习·mod·mc·forge
中二病码农不会遇见C++学姐3 个月前
文明六mod制作
mod
金山毒霸电脑医生3 个月前
植物大战僵尸融合版下载与安装教程:PC/安卓/iOS 全面指南
游戏·ios·植物大战僵尸·植物大战僵尸融合版·游戏工具
njsgcs3 个月前
sifumod经验3 水管臂问题
mod
长城20247 个月前
求余运算和数学模运算的知识了解
mod·取余·求余·求模·数学模运算·mod运算
小草cys8 个月前
Minecraft 1.18.2 或更高版本(如1.19.2、1.20.1)选择模组mod加载器
mod·minecraft
G果2 年前
matlab 小数取余 rem 和 mod有 bug
matlab·debug·rem·mod·取余