diff --git a/repo/js/背包材料统计/README.md b/repo/js/背包材料统计/README.md
index 86f99f8cb..4c5426462 100644
--- a/repo/js/背包材料统计/README.md
+++ b/repo/js/背包材料统计/README.md
@@ -1,237 +1,243 @@
-# 背包材料统计 v2.62
-作者:吉吉喵
-
-
-
-
-
-## 一、简介
-可统计背包内养成道具、部分食物、素材的数量,并根据「设定目标数量」「材料刷新CD」自动执行挖矿、采集、刷怪等路径。
-
-### 核心优势
-1. **自动CD判断**:无需手动关注材料刷新状态,脚本自动识别CD是否就绪;
-2. **灵活路径管理**:支持自定义添加路径,自动排除低效/无效路径;
-3. **独立名单识别**:不与路边NPC、神像交互;可自定义识别名单(操作见「四、问题解答Q4」);
-4. **实时弹窗保护**:内置弹窗模块(覆盖路边信件、过期物品、月卡、调查等场景),运行时全程保护路径不被弹窗干扰。
-5. **自动黑名单**:内置拾取模块,联动材料统计,可识别爆满的路径材料,自动屏蔽。
-6. **路径检测码**:根据路径生成检测码,可识别改名单坐标未变的路径文件,自动匹配带检测码的路径记录。
-7. **怪物材料防爆仓**:对怪物材料路径添加多重机制,防止蓝紫怪物材料爆仓(怪物材料路径执行时,不会拾其他的怪物材料,除非pathing文件夹里存在且未超量)。
-
-
-## 二、用前须知
-1. 需具备基础电脑操作能力(如文件夹复制、路径查找);
-2. 非1080p显示器,使用前需要根据显示器调整背包物品界面的 拖动距离 ,推荐“一次划页稍小于4行材料的距离”;
-3. 脚本不自带路径文件,需手动对目标文件夹进行操作(步骤见「三、使用方法」)。
-
-
-## 三、使用方法
-### 3.1 基础教程(路径配置)
-#### 步骤1:订阅路径文件
-在仓库中订阅所需的路径文件,参考以下截图操作:
-
-
-#### 步骤2:复制路径到目标文件夹
-1. 打开**路径源文件夹**(存放订阅的路径文件):
- ```
- BetterGI\Repos\bettergi-scripts-list-git\repo\pathing
- ```
-2. 根据需求,复制以下类型的路径文件夹(按需选择):
- - 地方特产
- - 敌人与魔物
- - 矿物
- - 食材与炼金
-3. 粘贴到**脚本目标文件夹**(背包材料统计的路径读取目录):
- ```
- BetterGI\User\JsScript\背包材料统计\pathing
- ```
-
-#### 步骤2关键注意点
-- 手动删除重复路径(例如“萃凝晶”可能存在多个重复路径,需手动清理),参考截图:
-
-

-
-- `pathing` 文件夹仅支持**3层子文件夹**,若超过3层需手动削减(否则无法读取);
-- 推荐优先配置「枫丹水下」路径:无队伍要求,但需提前开启水下锚点。
-
-#### 步骤3:按队伍分组管理路径
-建议复制多份「背包材料统计」脚本,按队伍功能分组存放适配路径,避免路径混乱。示例如下:
-
-| 分组名称 | 适配队伍组合 | 适用场景 | 特殊说明 |
-|------------------------|-----------------------------------|---------------------------|-----------------------------------|
-| 背包统计采集组(生存队) | 迪希雅 + 芭芭拉 + 瑶瑶 + 草神 | 常规材料采集 | 无草神时,需批量搜索路径中“nahida_collect”并排除 |
-| 背包统计刷怪组 | 火神 + 奶奶 + 钟离 + 万叶 | 挂机刷怪(获取怪物材料、含战斗的采集) | 确保队伍输出足够,能高效清理怪物 |
-| 背包统计附魔材料组 | 钟离 + 芭芭拉 + 久岐忍 + 砂糖/班尼特 | 附魔类采集(需特定附魔) | 根据材料路径需求选择附魔角色,有战斗则增加输出角色 |
-
-分组示例与无草神排除路径操作截图:
-
-

-

-
-
-#### 步骤4:打开脚本自定义设置
-1. 找到配置组内已添加的「背包材料统计」JS;
-2. 右键点击该文件,选择「修改JS 脚本自定义设置」;
-3. 操作参考截图:
-
-

-
-
-
-### 3.2 JS 自定义设置(核心配置项)
-| 配置项 | 功能说明 | 操作建议 |
-|----------------------|--------------------------------------------------------------------------|--------------------------------------------------------------------------|
-| 1. 目标数量 | 仅当背包材料数量**低于此值**时,该材料的路径才会被纳入执行序列 | 这是个统一值,管理路径下全部材料的目标数量 |
-| 2. 优先级材料 | 无视“目标数量”限制,直接纳入执行序列顶层(最高优先级) | 填写当前急需材料(例:虹滴晶,巡陆艇) |
-| 3. 时间成本 | 当一个路径有3-5次运行记录后,自动计算“单材料获取时间”;超过30秒则跳过该路径 | 保持默认30秒即可,无需频繁修改(可过滤低效路径) |
-| 4. 发送通知 | ① 每类材料跑完通知一次;② 全部材料跑完汇总通知一次(需开启BGI通知) | 建议开启,方便实时了解进度(接收端如企业微信需自行配置) |
-| 5. 取消扫描 | 取消“每个路径执行后”的背包扫描,仅保留“全部执行前/后”2次扫描 | 有效路径记录达3条以上时可以开启,可节约运行时间 |
-| 6. 仅 pathing 材料 | 仅扫描 `pathing` 文件夹内的材料,跳过其他分类,大幅缩短扫描时间 | 路径配置完成后开启,提升脚本运行效率 |
-| 7. 弹窗名 | 不选则默认循环执行 `assets\imageClick` 文件夹下所有弹窗;填写则仅执行指定弹窗 | 推荐默认,需单独适配某类弹窗时填写(例:月卡,复苏) |
-| 8. 采用的 CD 分类 | 不选则默认执行 `materialsCD` 文件夹内配置的CD分类;填写则仅执行指定CD分类 | 新增材料时,需在该文件夹同步配置CD规则(操作见「四、问题解答Q2」) |
-| 9. 终止时刻 | 不填则不执行定时终止;路径无时间记录时,会预判路径耗时5分钟,且预留2分钟空闲 | 填写需要按24小时格式(例:4:10) |
-| 10. 采用的识别名单 | 不选则默认执行 `targetText` 文件夹内配置的识别名单;填写则仅执行指定识别名单 | 新增名单时,需符合配置规则(操作见「四、问题解答Q4」) |
-| 11. 超量阈值 | 首次扫描后,超量的路径材料,将从识别名单中剔除,默认9000 | 不推荐9999,怪物材料有几千就够了,采用默认数值,可自动避免爆背包 |
-| 12. 拖动距离 | 解决非1080p分辨率下“划页过头”问题,需调整到“一次划页≤4行” | 拖动点建议选“第五行材料附近”;大于1080p屏可适当减小数值 |
-
-
-## 四、注意事项
-1. **禁止联机请求**:联机请求会遮挡背包菜单,导致材料识别失败。建议在本脚本前添加「AutoPermission」权限设置JS(仓库可查),默认禁止联机;
-2. **文件夹层级限制**:`pathing` 文件夹仅支持3层子文件夹,超过需手动削减(否则路径无法读取);
-3. **食物识别强制要求**:背包食物界面**第一行必须包含8种食物**(苹果、日落果、星蕈、活化的星蕈、枯焦的星蕈、泡泡桔、烛伞蘑菇、美味的宝石闪闪),缺少则这些食物无法识别;
-4. **关键文件备份**:建议不定期备份 `pathing` 文件夹(路径文件)和 `pathing_record` 文件夹(路径运行记录),便于丢失后或被污染后,记录能恢复如初;
-5. **OCR配置**:默认最新,调整识别名单时,用的是V5Auto;
-6. **手动终止运行**:手动终止路径会被记录成noRecord模式,只参与CD计算,如果是【取消扫描】模式,不会储存当前记录的材料数目,两种情况都随意。
-
-
-## 五、问题解答
-
-### Q1:如何排除不想要的路径?
-A:1. 打开 `pathing` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\pathing`);
- 2. 直接删除/移走目标材料/怪物的路径文件夹;
- **其他方法**:看「四、问题解答Q2」,按格式要求填入对应的材料名(或者从其他CD文件中复制过来),在「JS 自定义设置」【采用的 CD 分类】中输入新建的文件名,即可实现只加载该CD文件里材料的路径。
-
-### Q2:如何添加新材料?
-A:1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\materialsCD`);
- 2. 新建/编辑txt文件,按格式填写:`CD规则:材料1,材料2`(中文冒号+中文逗号,CD规则参考自带文件,例:“1次0点:月落银,宿影花,”),`材料1`或`材料2`将会作为标准名;
- 3. **关键要求**:路径文件夹名、材料图片名必须与“材料1或2”完全一致(多层文件夹默认读取最外层标准名文件夹);
- 4. 操作参考截图:
-
-

-

-
-
-### Q3:如何识别不规范命名的路径文件夹(如“纳塔食材一条龙”、“果园.json”)?
-A:1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可(路径CD由“所在材料文件夹”决定)。
- 2. 例:看「四、问题解答Q2」,① 把“纳塔食材一条龙”作为标准名,选择一个CD,② 在「JS 自定义设置」【优先级材料】里填入:纳塔食材一条龙,③将“纳塔食材一条龙”的文件夹放置到`pathing` 文件夹;锄地路径可放置到“锄地”文件夹里(没有就新建一个)**此方法无法使用 背包材料统计 的优选路径功能!**
- 3. 「JS 自定义设置」勾选【取消扫描】后,就可以运行了!**此项不勾,将无CD记录!**;
- 4. 例:“果园.json”放入“苹果”文件夹,将按“苹果”的CD规则执行。
- 操作参考截图:
-
-
-
-### Q4:如何自定义识别名单?
-A:1. 打开 `targetText` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\targetText`);
- 2. 新建/编辑txt文件,按格式填写:`自定义名称:目标1,目标2`(英文冒号+英文逗号,例:“新材料:霜盏花,便携轴承,”);
- 3. 若需排除怪物掉落材料:找到“掉落.txt”,删除对应材料名即可;
- 4. 操作参考截图:
-
-

-

-
-
-### Q5:如何取消路径执行后扫描背包?
-A:在「JS自定义设置」中勾选“取消扫描”(依旧会保留“全部材料执行始/末”的2次扫描)。
-
-### Q6:扫描背包少一行、拖动距离异常怎么办?
-A:在「JS自定义设置」中调整“拖动距离”,推荐“一次划页稍小于4行材料的距离”(拖动点建议选第5行材料附近)。
-
-### Q7:本地记录保存在哪里?
-A:记录文件夹位于 `BetterGI\User\JsScript\背包材料统计\` 下,各文件功能如下:
- - `overwrite_record`:所有历史记录(按材料分类储存);
- - `history_record`:勾选“材料分类”后的专属记录;
- - `latest_record.txt`:最近几种材料的记录(有上限,仅存最新数据);
- - `pathing_record`:单个路径的完整记录(含运行时间、收获量,需重点备份),材料收集汇总.txt(始末差值记录),标准名-0.txt(0收获记录,三次及以上同名路径记录,就会触发排除);
- 操作参考截图:
-
-

-
-
-
-## 六、后言
-本脚本目前处于测试阶段,欢迎反馈问题至 QQ 频道号:bettergiv1。
-
-
-## 七、更新日志
-| 版本号 | 更新内容 |
-|---------|--------------------------------------------------------------------------|
-| v0.1 | 新增OCR名单功能,输出图片名与材料名 |
-| v1.0 | 新增图包(仅含素材) |
-| v1.1 | 图包扩展(素材+养成道具) |
-| v1.2 | 新增识图分类功能 |
-| v1.3 | 优化:加速材料寻找(新增前位材料识别) |
-| v1.31 | 调整本地记录存储逻辑 |
-| v1.32 | 新增后位材料识别功能 |
-| v2.0 | 开发版:支持多组材料、多个分类;移除前/后位材料识别 |
-| v2.1 | 新增CD管理功能 |
-| v2.2 | 优化路径顺序、材料数量判断逻辑 |
-| v2.21 | 修改路径储存路径 |
-| v2.22 | 精简日志输出内容 |
-| v2.23 | 优化部分函数性能 |
-| v2.24 | 修复“空路径无法使用背包统计”等bug |
-| v2.25 | 恢复前/后位材料识别(加速扫描);新增“仅扫描路径材料”选项(降低内存占用) |
-| v2.26 | 修复材料时间读取错误;新增路径材料时间成本计算功能 |
-| v2.27 | 修复“材料数计算错误”“目标数量临界值异常”“3识别成三”等bug |
-| v2.28 | 材料变更时自动更新初始数量;排除0位移/0数量路径记录;新增材料名0后缀本地记录;新增背包弹窗识别 |
-| v2.29 | 新增排除提示;调整平均时间成本计算逻辑;过滤异常值记录 |
-| v2.30 | 更改路径专注模式默认值;增加日志提示;移除调试日志 |
-| v2.40 | 优化背包识别时的内存占用;新增通知功能 |
-| v2.41 | 修复“勾选分类的本地记录”bug;新增“仅背包统计”选项;补充记录损坏处理说明 |
-| v2.42 | 新增“无路径间扫描”“noRecord模式”(适合成熟路径);新增怪物材料CD文件 |
-| v2.50 | 新增独立名单拾取、弹窗模块;支持怪物名识别 |
-| v2.51 | 自定义设置新增“拖动距离/拖动点”;新增月卡弹窗识别;路径材料超量自动上黑名单;修复怪物0收获记录 |
-| v2.52 | 自定义设置新增“超量阈值”和“识别名单”输入框;新增多层弹窗逻辑 |
-| v2.54 | 自定义设置新增“终止时刻”,修复bug,新增“添加不规范命名的路径文件夹”说明,新增一个“锄地”的怪物路径CD |
-| v2.55 | 修复超量名单不生效 |
-| v2.56 | 更新诺德卡莱图包 |
-| v2.57 | 补全圣遗物无CD管理bug |
-| v2.58 | 优化背包扫图逻辑 |
-| v2.59 | 修复自动拾取匹配bug,改为双向匹配 |
-| v2.60 | 手动终止路径会被记录成noRecord模式,只参与CD计算;增加当前路线预估时长日志;材料分类升级多选框UI,刚需bgi v0.55版本;优化文件是否存在逻辑;降级ReadTextSync报错;检测码识别路径 |
-| v2.61 | 背包材料识别机制加速;修复手动终止路径noRecord模式的额外条件判断不生效;全局图片缓存;识别名单、CD文件和弹窗升级多选框UI!!!!!!注意UI修改了,配置组里需要删除并重新添加该js!!!!!! |
-| v2.62 | 修复凌晨CD计算bug,修复超量名单潜在记录污染bug | |
\ No newline at end of file
+# 背包材料统计 v2.62
+作者:吉吉喵
+
+
+
+
+
+## 一、简介
+可统计背包内养成道具、部分食物、素材的数量,并根据「设定目标数量」「材料刷新CD」自动执行挖矿、采集、刷怪等路径。
+
+### 核心优势
+1. **自动CD判断**:无需手动关注材料刷新状态,脚本自动识别CD是否就绪;
+2. **灵活路径管理**:支持自定义添加路径,自动排除低效/无效路径;
+3. **独立名单识别**:不与路边NPC、神像交互;可自定义识别名单(操作见「四、问题解答Q4」);
+4. **实时弹窗保护**:内置弹窗模块(覆盖路边信件、过期物品、月卡、调查等场景),运行时全程保护路径不被弹窗干扰。
+5. **自动黑名单**:内置拾取模块,联动材料统计,可识别爆满的路径材料,自动屏蔽。
+6. **路径检测码**:根据路径生成检测码,可识别改名但坐标未变的路径文件,自动匹配带检测码的路径记录。
+7. **怪物材料防爆仓**:对怪物材料路径添加多重机制,防止蓝紫怪物材料爆仓(怪物材料路径执行时,不会拾其他的怪物材料,除非pathing文件夹里存在且未超量)。
+
+
+## 二、用前须知
+1. 需具备基础电脑操作能力(如文件夹复制、路径查找);
+2. 非1080p显示器,使用前需要根据显示器调整背包物品界面的 拖动距离 ,推荐“一次划页稍小于4行材料的距离”;
+3. 脚本不自带路径文件,需手动对目标文件夹进行操作(步骤见「三、使用方法」)。
+
+
+## 三、使用方法
+### 3.1 基础教程(路径配置)
+#### 步骤1:订阅路径文件
+在仓库中订阅所需的路径文件,参考以下截图操作:
+
+
+#### 步骤2:复制路径到目标文件夹
+1. 打开**路径源文件夹**(存放订阅的路径文件):
+ ```
+ BetterGI\Repos\bettergi-scripts-list-git\repo\pathing
+ ```
+2. 根据需求,复制以下类型的路径文件夹(按需选择):
+ - 地方特产
+ - 敌人与魔物
+ - 矿物
+ - 食材与炼金
+3. 粘贴到**脚本目标文件夹**(背包材料统计的路径读取目录):
+ ```
+ BetterGI\User\JsScript\背包材料统计\pathing
+ ```
+
+#### 步骤2关键注意点
+- 手动删除重复路径(例如“萃凝晶”可能存在多个重复路径,需手动清理),参考截图:
+
+

+
+- `pathing` 文件夹仅支持**6层子文件夹**,若超过6层需手动削减(否则无法读取);
+- 推荐优先配置「枫丹水下」路径:无队伍要求,但需提前开启水下锚点。
+
+#### 步骤3:按队伍分组管理路径
+建议复制多份「背包材料统计」脚本,按队伍功能分组存放适配路径,避免路径混乱。**例如:把该JS复制三份,改名后,分别放入不同的配置组。给每个JS所在的配置组,配置对应的切队等(也可以单独添加切队JS)。每个JS放置该队能适配的路径文件。分路径挺费时间,按自己需要,琢磨好队伍来。队伍适合的话,包括抓鱼、禽肉、挖矿等等路径都可以适配,不一定按采集刷怪附魔来。**示例如下:
+
+| 分组名称 | 适配队伍组合 | 适用场景 | 特殊说明 |
+|------------------------|-----------------------------------|---------------------------|-----------------------------------|
+| 背包统计采集组(生存队) | 迪希雅 + 芭芭拉 + 瑶瑶 + 草神 | 常规材料采集 | 无草神时,需批量搜索路径中“nahida_collect”并排除 |
+| 背包统计刷怪组 | 火神 + 奶奶 + 钟离 + 万叶 | 挂机刷怪(获取怪物材料、含战斗的采集) | 确保队伍输出足够,能高效清理怪物 |
+| 背包统计附魔材料组 | 钟离 + 芭芭拉 + 久岐忍 + 砂糖/班尼特 | 附魔类采集(需特定附魔) | 根据材料路径需求选择附魔角色,有战斗则增加输出角色 |
+
+分组示例与无草神排除路径操作截图:
+
+

+

+
+
+#### 步骤4:打开脚本自定义设置
+1. 找到配置组内已添加的「背包材料统计」JS;
+2. 右键点击该文件,选择「修改JS 脚本自定义设置」;
+3. 操作参考截图:
+
+

+
+
+
+### 3.2 JS 自定义设置(核心配置项)
+| 配置项 | 功能说明 | 操作建议 |
+|----------------------|--------------------------------------------------------------------------|--------------------------------------------------------------------------|
+| 1. 目标数量 | 仅当背包材料数量**低于此值**时,该材料的路径才会被纳入执行序列 | 推荐默认,管理路径下全部材料的目标数量 |
+| 2. 优先级材料 | 无视“目标数量”限制,直接纳入执行序列顶层(最高优先级) | 填写当前急需材料(例:虹滴晶,巡陆艇);无则默认 |
+| 3. 时间成本 | 当一个路径有3~5次运行记录后,自动计算“单材料获取时间”;50代表该材料类型的前50%的中高效路径 | 保持默认即可,无需频繁修改(可过滤低效路径) |
+| 4. 发送通知 | ① 每类材料跑完通知一次;② 全部材料跑完汇总通知一次(需开启BGI通知) | 建议开启,方便实时了解进度(接收端如企业微信需自行配置) |
+| 5. 取消扫描 | 取消“每个路径执行后”的背包扫描,仅保留“全部执行前/后”2次扫描 | 推荐默认;有效路径记录达3条以上时可以开启,可节约运行时间 |
+| 6. 怪物材料过滤 | 默认只过滤掉📁pathing中已有的超量材料,若勾选,则只拾取📁pathing已有的未超量材料,不拾取其他材料 | 推荐背包爆炸的玩家开启; |
+| 7. 超量阈值 | 首次扫描后,超量的材料,将从识别名单中剔除,默认9000 | 推荐默认,可自动避免爆背包 |
+| 8. 统计选择 | 📁pathing材料:包含一般材料和怪物材料路径;【扫描额外的分类】:背包材料统计,联动生成超量材料;【测算模式】:可预估执行时间,无记录默认2分钟 | 根据需要选择,【扫描额外的分类】扫描到的超量材料会在自动拾取里排除; |
+| 9. 弹窗名 | 不选则默认循环执行 `assets\imageClick` 文件夹下所有弹窗;填写则仅执行指定弹窗 | 推荐默认,需单独适配某类弹窗时填写(例:月卡,复苏) |
+| 10. 弹窗循环间隔 | 不选则默认15秒 | 推荐默认 |
+| 11. 采用的 CD 分类 | 不选则默认执行 `materialsCD` 文件夹内配置的CD分类;填写则仅执行指定CD分类 | 推荐默认;新增材料时,需在该文件夹同步配置CD规则(操作见「四、问题解答Q2」) |
+| 12. 终止时刻 | 不填则不执行定时终止;路径无时间记录时,会预判路径耗时5分钟,且预留2分钟空闲 | 推荐默认;填写需要按24小时格式(例:4:10) |
+| 13. 采用的识别名单 | 不选则默认执行 `targetText` 文件夹内配置的识别名单;填写则仅执行指定识别名单 | 推荐默认;新增名单时,需符合配置规则(操作见「四、问题解答Q4」) |
+| 14. 拖动距离 | 解决非1080p分辨率下“划页过头”问题,需调整到“一次划页≤4行” | 拖动点建议选“第五行材料附近”;大于1080p屏可适当减小数值 |
+
+
+## 四、注意事项
+1. **禁止联机请求**:联机请求会遮挡背包菜单,导致材料识别失败。建议在本脚本前添加「AutoPermission」权限设置JS(仓库可查),默认禁止联机;
+2. **文件夹层级限制**:`pathing` 文件夹仅支持6层子文件夹,超过需手动削减(否则路径无法读取);
+3. **仓库路径交叉资源点**:把明显重复的同资源路径删除,减少提炼路径的时间;
+4. **关键文件备份**:建议不定期备份 `pathing` 文件夹(路径文件)和 `pathing_record` 文件夹(路径运行记录),便于丢失后或被污染后,记录能恢复如初;
+5. **OCR配置**:默认最新,调整识别名单时,用的是V5Auto;
+6. **手动终止运行**:手动终止路径会被记录成noRecord模式,只参与CD计算,如果是【取消扫描】模式,不会储存当前记录的材料数目,两种情况都随意。
+
+
+## 五、问题解答
+
+### Q1:如何排除不想要的路径?
+A:1. 打开 `pathing` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\pathing`);
+ 2. 直接删除/移走目标材料/怪物的路径文件夹;
+ **其他方法**:看「四、问题解答Q2」,按格式要求填入对应的材料名(或者从其他CD文件中复制过来),在「JS 自定义设置」【采用的 CD 分类】中输入新建的文件名,即可实现只加载该CD文件里材料的路径。
+
+### Q2:如何添加新材料?
+A:1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\materialsCD`);
+ 2. 新建/编辑txt文件,按格式填写:`CD规则:材料1,材料2`(中文冒号+中文逗号,CD规则参考自带文件,例:“1次0点:月落银,宿影花,”),`材料1`或`材料2`将会作为标准名;
+ 3. **关键要求**:路径文件夹名、材料图片名必须与“材料1或2”完全一致(多层文件夹默认读取最外层标准名文件夹);
+ 4. 操作参考截图:
+
+

+

+
+
+### Q3:如何识别不规范命名的路径文件夹(如“纳塔食材一条龙”、“果园.json”)?
+A:1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可(路径CD由“所在材料文件夹”决定)。
+ 2. 例:看「四、问题解答Q2」,① 把“纳塔食材一条龙”作为标准名,选择一个CD,② 在「JS 自定义设置」【优先级材料】里填入:纳塔食材一条龙,③将“纳塔食材一条龙”的文件夹放置到`pathing` 文件夹;锄地路径可放置到“锄地”文件夹里(没有就新建一个)**此方法无法使用 背包材料统计 的优选路径功能!**
+ 3. 「JS 自定义设置」勾选【取消扫描】后,就可以运行了!**此项不勾,将无CD记录!**;
+ 4. 例:“果园.json”放入“苹果”文件夹,将按“苹果”的CD规则执行。
+ 操作参考截图:
+
+
+
+### Q4:如何自定义识别名单?
+A:1. 打开 `targetText` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\targetText`);
+ 2. 新建/编辑txt文件,按格式填写:`自定义名称:目标1,目标2`(英文冒号+英文逗号,例:“新材料:霜盏花,便携轴承,”);
+ 3. 若需排除怪物掉落材料:找到“掉落.txt”,删除对应材料名即可;
+ 4. 操作参考截图:
+
+

+

+
+
+### Q5:如何取消路径执行后扫描背包?
+A:在「JS自定义设置」中勾选“取消扫描”(依旧会保留“全部材料执行始/末”的2次扫描)。
+
+### Q6:扫描背包少一行、拖动距离异常怎么办?
+A:在「JS自定义设置」中调整“拖动距离”,推荐“一次划页稍小于4行材料的距离”(拖动点建议选第5行材料附近)。
+
+### Q7:本地记录保存在哪里?
+A:记录文件夹位于 `BetterGI\User\JsScript\背包材料统计\` 下,各文件功能如下:
+ - `overwrite_record`:所有历史记录(按材料分类储存);
+ - `history_record`:勾选“材料分类”后的专属记录;
+ - `latest_record.txt`:最近几种材料的记录(有上限,仅存最新数据);
+ - `pathing_record`:单个路径的完整记录(含运行时间、收获量,需重点备份),材料收集汇总.txt(始末差值记录),标准名-0.txt(0收获记录,三次及以上同名路径记录,就会触发排除);
+ 操作参考截图:
+
+

+
+
+## 六、后言
+本脚本目前处于测试阶段,欢迎反馈问题至 QQ 频道号:bettergiv1。
+
+
+## 七、更新日志
+| 版本号 | 更新内容 |
+|---------|--------------------------------------------------------------------------|
+| v0.1 | 新增OCR名单功能,输出图片名与材料名 |
+| v1.0 | 新增图包(仅含素材) |
+| v1.1 | 图包扩展(素材+养成道具) |
+| v1.2 | 新增识图分类功能 |
+| v1.3 | 优化:加速材料寻找(新增前位材料识别) |
+| v1.31 | 调整本地记录存储逻辑 |
+| v1.32 | 新增后位材料识别功能 |
+| v2.0 | 开发版:支持多组材料、多个分类;移除前/后位材料识别 |
+| v2.1 | 新增CD管理功能 |
+| v2.2 | 优化路径顺序、材料数量判断逻辑 |
+| v2.21 | 修改路径储存路径 |
+| v2.22 | 精简日志输出内容 |
+| v2.23 | 优化部分函数性能 |
+| v2.24 | 修复“空路径无法使用背包统计”等bug |
+| v2.25 | 恢复前/后位材料识别(加速扫描);新增“仅扫描路径材料”选项(降低内存占用) |
+| v2.26 | 修复材料时间读取错误;新增路径材料时间成本计算功能 |
+| v2.27 | 修复“材料数计算错误”“目标数量临界值异常”“3识别成三”等bug |
+| v2.28 | 材料变更时自动更新初始数量;排除0位移/0数量路径记录;新增材料名0后缀本地记录;新增背包弹窗识别 |
+| v2.29 | 新增排除提示;调整平均时间成本计算逻辑;过滤异常值记录 |
+| v2.30 | 更改路径专注模式默认值;增加日志提示;移除调试日志 |
+| v2.40 | 优化背包识别时的内存占用;新增通知功能 |
+| v2.41 | 修复“勾选分类的本地记录”bug;新增“仅背包统计”选项;补充记录损坏处理说明 |
+| v2.42 | 新增“无路径间扫描”“noRecord模式”(适合成熟路径);新增怪物材料CD文件 |
+| v2.50 | 新增独立名单拾取、弹窗模块;支持怪物名识别 |
+| v2.51 | 自定义设置新增“拖动距离/拖动点”;新增月卡弹窗识别;路径材料超量自动上黑名单;修复怪物0收获记录 |
+| v2.52 | 自定义设置新增“超量阈值”和“识别名单”输入框;新增多层弹窗逻辑 |
+| v2.54 | 自定义设置新增“终止时刻”,修复bug,新增“添加不规范命名的路径文件夹”说明,新增一个“锄地”的怪物路径CD |
+| v2.55 | 修复超量名单不生效 |
+| v2.56 | 更新诺德卡莱图包 |
+| v2.57 | 补全圣遗物无CD管理bug |
+| v2.58 | 优化背包扫图逻辑 |
+| v2.59 | 修复自动拾取匹配bug,改为双向匹配 |
+| v2.60 | 手动终止路径会被记录成noRecord模式,只参与CD计算;增加当前路线预估时长日志;材料分类升级多选框UI,刚需bgi v0.55版本;优化文件是否存在逻辑;降级ReadTextSync报错;检测码识别路径 |
+| v2.61 | 背包材料识别机制加速;修复手动终止路径noRecord模式的额外条件判断不生效;全局图片缓存;识别名单、CD文件和弹窗升级多选框UI!!!!!!注意UI修改了,配置组里需要删除并重新添加该js!!!!!! |
+| v2.62 | 修复凌晨CD计算bug,修复超量名单潜在记录污染bug |
+| v2.63 | !!!重要更新!!!时间成本机制修改以及对应UI修改 |
+| v2.64 | 移除位移检测模块 |
+| v2.65 | 修复测算模式、0记录检测码等bug,优化汇总日志,调整默认目标数量至1000 |
+| v2.66 | 修复翻页兜底逻辑,增加材料数量模板匹配,材料数量OCR失败后截图保存,【扫描额外的分类】联动超量材料 |
+| v2.67 | 修复怪物材料映射斜杠逻辑,更新OCR错字映射表,优先级材料按自定义顺序,修复怪物材料过滤bug |
\ No newline at end of file
diff --git a/repo/js/背包材料统计/assets/regions/1.png b/repo/js/背包材料统计/assets/regions/1.png
new file mode 100644
index 000000000..f2b09924d
Binary files /dev/null and b/repo/js/背包材料统计/assets/regions/1.png differ
diff --git a/repo/js/背包材料统计/assets/regions/11.png b/repo/js/背包材料统计/assets/regions/11.png
new file mode 100644
index 000000000..c1657cdc4
Binary files /dev/null and b/repo/js/背包材料统计/assets/regions/11.png differ
diff --git a/repo/js/背包材料统计/assets/regions/17.png b/repo/js/背包材料统计/assets/regions/17.png
new file mode 100644
index 000000000..ab2e6eb80
Binary files /dev/null and b/repo/js/背包材料统计/assets/regions/17.png differ
diff --git a/repo/js/背包材料统计/assets/regions/2.png b/repo/js/背包材料统计/assets/regions/2.png
new file mode 100644
index 000000000..36e4d919d
Binary files /dev/null and b/repo/js/背包材料统计/assets/regions/2.png differ
diff --git a/repo/js/背包材料统计/assets/regions/4.png b/repo/js/背包材料统计/assets/regions/4.png
new file mode 100644
index 000000000..f2dcd2c3f
Binary files /dev/null and b/repo/js/背包材料统计/assets/regions/4.png differ
diff --git a/repo/js/背包材料统计/assets/regions/5.png b/repo/js/背包材料统计/assets/regions/5.png
new file mode 100644
index 000000000..67403be4d
Binary files /dev/null and b/repo/js/背包材料统计/assets/regions/5.png differ
diff --git a/repo/js/背包材料统计/assets/regions/6.png b/repo/js/背包材料统计/assets/regions/6.png
new file mode 100644
index 000000000..7def2a400
Binary files /dev/null and b/repo/js/背包材料统计/assets/regions/6.png differ
diff --git a/repo/js/背包材料统计/assets/regions/7.png b/repo/js/背包材料统计/assets/regions/7.png
new file mode 100644
index 000000000..dde911ea0
Binary files /dev/null and b/repo/js/背包材料统计/assets/regions/7.png differ
diff --git a/repo/js/背包材料统计/lib/autoPick.js b/repo/js/背包材料统计/lib/autoPick.js
index dc90dafd3..dd910d1bd 100644
--- a/repo/js/背包材料统计/lib/autoPick.js
+++ b/repo/js/背包材料统计/lib/autoPick.js
@@ -106,7 +106,7 @@ async function findFIcon(recognitionObject, timeout = 10, ra = null) {
// 定义Scroll.png识别对象(无修改,与OCR无关)
const ScrollRo = RecognitionObject.TemplateMatch(
- file.ReadImageMatSync("assets/Scroll.png"),
+ file.ReadImageMatSync("assets/Scroll.png"),
1055, 521, 15, 35 // 识别范围:x=1055, y=521, width=15, height=35
);
@@ -131,8 +131,8 @@ async function alignAndInteractTarget(targetTextsOrFunc, fDialogueRo, textxRange
}
recognitionCount.clear();
const currentTime = Date.now();
-
- if (currentTime - lastLogTime >= 10000) {
+
+ if (currentTime - lastLogTime >= 15000) {
log.info("独立OCR识别中...");
lastLogTime = currentTime;
}
@@ -156,20 +156,24 @@ async function alignAndInteractTarget(targetTextsOrFunc, fDialogueRo, textxRange
const targetTexts = typeof targetTextsOrFunc === 'function' ? targetTextsOrFunc() : targetTextsOrFunc;
- const yRange = { min: fRes.y - 3, max: fRes.y + 37 };
- const { results: ocrResults, screenshot: ocrScreenshot } = await performOcr(
+ const region = {
+ x: textxRange.min,
+ y: fRes.y - 3,
+ width: textxRange.max - textxRange.min,
+ height: 40
+ };
+ const { results: ocrResults, screenshot: ocrScreenshot, shouldDispose } = await performOcr(
targetTexts,
- textxRange,
- yRange,
+ region,
cachedFrame,
10,
5
);
- ocrScreenshots.push(ocrScreenshot);
+ ocrScreenshots.push({ screenshot: ocrScreenshot, shouldDispose });
let foundTarget = false;
for (const targetText of targetTexts) {
- const targetResult = ocrResults.find(res =>
+ const targetResult = ocrResults.find(res =>
res.text.includes(targetText) || targetText.includes(res.text)
);
if (!targetResult) continue;
@@ -190,6 +194,14 @@ async function alignAndInteractTarget(targetTextsOrFunc, fDialogueRo, textxRange
}
if (!foundTarget) {
+ if (ocrResults.length > 0) {
+ const fCenterY = fRes.y + fRes.height / 2;
+ for (const res of ocrResults) {
+ const centerY = res.y + res.height / 2;
+ const yDiff = Math.abs(centerY - fCenterY);
+ log.debug(`未匹配: "${res.text}" Y中心: ${centerY.toFixed(1)}, F图标Y中心: ${fCenterY.toFixed(1)}, 差值: ${yDiff.toFixed(1)}, 容忍度: ${texttolerance}`);
+ }
+ }
await keyMouseScript.runFile(`assets/滚轮下翻.json`);
}
}
@@ -197,13 +209,21 @@ async function alignAndInteractTarget(targetTextsOrFunc, fDialogueRo, textxRange
log.error(`对齐交互异常: ${error.message}`);
} finally {
if (cachedFrame) {
- if (cachedFrame.Dispose) cachedFrame.Dispose();
- else if (cachedFrame.dispose) cachedFrame.dispose();
+ try {
+ if (cachedFrame.Dispose) cachedFrame.Dispose();
+ else if (cachedFrame.dispose) cachedFrame.dispose();
+ } catch (e) {
+ log.debug(`释放缓存帧失败(可能已释放): ${e.message}`);
+ }
}
- for (const screenshot of ocrScreenshots) {
- if (screenshot) {
- if (screenshot.Dispose) screenshot.Dispose();
- else if (screenshot.dispose) screenshot.dispose();
+ for (const { screenshot, shouldDispose } of ocrScreenshots) {
+ if (screenshot && shouldDispose) {
+ try {
+ if (screenshot.Dispose) screenshot.Dispose();
+ else if (screenshot.dispose) screenshot.dispose();
+ } catch (e) {
+ log.debug(`释放OCR截图失败(可能已释放): ${e.message}`);
+ }
}
}
if (state.cancelRequested) {
diff --git a/repo/js/背包材料统计/lib/backStats.js b/repo/js/背包材料统计/lib/backStats.js
index 6d060f7cf..ab60eabae 100644
--- a/repo/js/背包材料统计/lib/backStats.js
+++ b/repo/js/背包材料统计/lib/backStats.js
@@ -1,5 +1,3 @@
-eval(safeReadTextSync("lib/region.js"));
-
const holdX = Math.min(1920, Math.max(0, Math.floor(Number(settings.HoldX) || 1050)));
const holdY = Math.min(1080, Math.max(0, Math.floor(Number(settings.HoldY) || 750)));
const totalPageDistance = Math.max(10, Math.floor(Number(settings.PageScrollDistance) || 711));
@@ -31,19 +29,19 @@ const materialTypeMap = {
// 材料前位定义
const materialPriority = {
- "祝圣精华": 2,
- "锻造素材": 1,
- "怪物掉落素材": 1,
- "采集食物": 1,
- "一般素材": 2,
- "周本素材": 2,
- "料理": 2,
- "烹饪食材": 3,
- "角色突破素材": 3,
- "木材": 4,
- "宝石": 4,
- "鱼饵鱼类": 5,
- "角色天赋素材": 5,
+ "祝圣精华": 2,
+ "锻造素材": 1,
+ "怪物掉落素材": 1,
+ "采集食物": 1,
+ "一般素材": 2,
+ "周本素材": 2,
+ "料理": 2,
+ "烹饪食材": 3,
+ "角色突破素材": 3,
+ "木材": 4,
+ "宝石": 4,
+ "鱼饵鱼类": 5,
+ "角色天赋素材": 5,
"武器突破素材": 6
};
@@ -118,9 +116,53 @@ async function recognizeText(ocrRegion, timeout = 100, retryInterval = 20, maxAt
}
const sortedResults = Object.keys(frequencyMap).sort((a, b) => frequencyMap[b] - frequencyMap[a]);
+ if (sortedResults.length === 0) {
+ const templateMatchResult = await recognizeNumberByTemplate(ra, ocrRegion);
+ if (templateMatchResult) {
+ return templateMatchResult;
+ }
+ saveFailedOcrRegion(ra, ocrRegion);
+ }
return sortedResults.length > 0 ? sortedResults[0] : false;
}
+// 模板匹配识别数字
+async function recognizeNumberByTemplate(ra, ocrRegion) {
+ const regionDir = "assets/regions";
+ const numberFiles = readAllFilePaths(regionDir, 0, 1, ['.png']);
+
+ for (const filePath of numberFiles) {
+ const num = parseInt(basename(filePath).replace('.png', ''), 10);
+ if (isNaN(num)) continue;
+
+ const templateMat = file.readImageMatSync(filePath);
+ if (templateMat.empty()) continue;
+
+ const expandPx = 5;
+ const matchX = ocrRegion.x;
+ const matchY = ocrRegion.y - expandPx;
+ const matchW = ocrRegion.width;
+ const matchH = ocrRegion.height + 2 * expandPx;
+
+ const matchRo = RecognitionObject.TemplateMatch(
+ templateMat,
+ matchX,
+ matchY,
+ matchW,
+ matchH
+ );
+ matchRo.threshold = 0.8;
+
+ const result = ra.find(matchRo);
+ if (result.isExist()) {
+ log.info(`[模板匹配] ${num}.png 识别成功`);
+ return String(num);
+ }
+ }
+
+ return null;
+}
+
// 优化后的滑动页面函数(基于通用函数)
async function scrollPage(totalDistance, stepDistance = 10, delayMs = 5) {
await mouseDrag({
@@ -151,16 +193,24 @@ async function scrollBackPage(totalDistance, stepDistance = 10, delayMs = 5) {
}
// 并行模板匹配函数
-async function parallelTemplateMatch(ra, materials, x, y, width, height, threshold = 0.8, enableMouseMove = true) {
+async function parallelTemplateMatch(ra, materials, x, y, width, height, threshold = 0.8, enableMouseMove = true, enableColorCheck = true) {
const matchPromises = materials.map(({ name, mat }) => {
return new Promise((resolve) => {
try {
const recognitionObject = RecognitionObject.TemplateMatch(mat, x, y, width, height);
recognitionObject.threshold = threshold;
- recognitionObject.Use3Channels = true;
-
+ // recognitionObject.Use3Channels = true;
+
const result = ra.find(recognitionObject);
if (result.isExist() && result.x !== 0 && result.y !== 0) {
+ if (enableColorCheck && typeof compareColor === 'function') {
+ const colorCheck = compareColor(mat, ra, result);
+ if (!colorCheck.isMatch) {
+ if (debugLog) log.debug(`颜色验证失败 [${name}]: avgDiff=${JSON.stringify(colorCheck.avgDiff)}, stdDiff=${JSON.stringify(colorCheck.stdDiff)}`);
+ resolve({ name, result: null });
+ return;
+ }
+ }
if (enableMouseMove) {
moveMouseTo(result.x, result.y);
}
@@ -174,7 +224,7 @@ async function parallelTemplateMatch(ra, materials, x, y, width, height, thresho
}
});
});
-
+
return await Promise.all(matchPromises);
}
@@ -201,20 +251,20 @@ async function batchRecognizeText(ocrRegions, ra, timeout = 200, retryInterval =
});
});
});
-
+
return await Promise.all(ocrPromises);
}
// 合并OCR区域函数(用于密集区域的批量处理)
function mergeOcrRegions(regions) {
if (regions.length === 0) return [];
-
+
// 按y坐标排序
regions.sort((a, b) => a.region.y - b.region.y);
-
+
const merged = [];
let currentMerge = { ...regions[0] };
-
+
for (let i = 1; i < regions.length; i++) {
const region = regions[i];
// 检查是否可以合并(垂直距离小于阈值)
@@ -229,7 +279,7 @@ function mergeOcrRegions(regions) {
}
}
merged.push(currentMerge);
-
+
return merged;
}
@@ -316,26 +366,62 @@ function filterMaterialsByPriority(materialsCategory) {
return finalFilteredMaterials
}
-// 扫描材料
+ // 扫描材料
+async function performFullColumnScan(ra, materialCategories, materialImages, recognizedMaterials, startX, startY, columnWidth, columnHeight, maxColumns, OffsetWidth, tolerance) {
+ const ocrRegions = [];
+
+ for (let column = 0; column < maxColumns; column++) {
+ const scanX0 = startX + column * OffsetWidth;
+ const scanX = Math.round(scanX0);
+
+ const materialsToMatch = materialCategories
+ .filter(({ name }) => !recognizedMaterials.has(name))
+ .map(({ name }) => {
+ const mat = materialImages[name];
+ return mat ? { name, mat } : null;
+ })
+ .filter(Boolean);
+
+ if (materialsToMatch.length > 0) {
+ const matchResults = await parallelTemplateMatch(ra, materialsToMatch, scanX, startY, columnWidth, columnHeight, 0.85);
+
+ for (const { name, result } of matchResults) {
+ if (result) {
+ recognizedMaterials.add(name);
+ const ocrRegion = {
+ x: result.x - tolerance,
+ y: result.y + 97 - tolerance,
+ width: 66 + 2 * tolerance,
+ height: 22 + 2 * tolerance
+ };
+ ocrRegions.push({ region: ocrRegion, name });
+ }
+ }
+ }
+ }
+
+ return ocrRegions;
+}
+
async function scanMaterials(materialsCategory, materialCategoryMap, isPostPriority = false) {
// 使用全局图片缓存
const materialImages = globalMaterialImageCache;
const currentType = materialTypeMap[materialsCategory];
const currentPriority = materialPriority[materialsCategory];
-
+
const priorityMaterialNames = [];
const finalFilteredMaterials = await filterMaterialsByPriority(materialsCategory);
-
+
const currentTypeMaterials = finalFilteredMaterials.filter(category => materialTypeMap[category] === currentType);
-
+
for (const category of currentTypeMaterials) {
const materialIconDir = `assets/images/${category}`;
const materialIconFilePaths = file.ReadPathSync(materialIconDir);
for (const filePath of materialIconFilePaths) {
const name = basename(filePath).replace(".png", "");
priorityMaterialNames.push({ category, name });
-
+
if (!materialImages[name]) {
const mat = getCachedImageMat(filePath);
if (!mat.empty()) {
@@ -390,16 +476,17 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior
const startY = 121;
const OffsetWidth = 146.428; // 146.428
const columnWidth = 123;
- const columnHeight = 680;
+ const columnHeight = 710;
const maxColumns = 8;
// 跟踪已截图的区域(避免重复)
- const capturedRegions = new Set();
+ const capturedRegions = new Set();
// 扫描状态
let hasFoundFirstMaterial = false;
let lastFoundTime = null;
let shouldEndScan = false;
let foundPriorityMaterial = false;
+ let isEighthColumnScan = false; // 当前是否为第八列扫描(用于跳过5秒超时终止)
// 俏皮话逻辑
const scanPhrases = [
@@ -429,62 +516,21 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior
tempPhrases.sort(() => Math.random() - 0.5); // 打乱数组顺序,确保随机性
let phrasesStartTime = Date.now();
let previousScreenshot = null; // 用于存储上一次翻页前的截图
-
+
// 后续优先级材料扫描:滑条重置后先检查第八列是否有前位材料
if (isPostPriority) {
- log.info(`后续优先级材料扫描 - 检查第八列前位材料`);
- const ra = captureGameRegion();
-
- const lowerPriorityMaterials = currentTypeMaterials.filter(category => materialPriority[category] < currentPriority);
- log.info(`检查前位材料分类: ${lowerPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`);
- const lowerPriorityMatches = [];
-
- for (const category of lowerPriorityMaterials) {
- const categoryMaterials = priorityMaterialNames
- .filter(({ name, category: cat }) => cat === category)
- .map(({ name }) => {
- const mat = materialImages[name];
- return mat ? { name, mat } : null;
- })
- .filter(Boolean);
-
- if (categoryMaterials.length > 0) {
- const matchResults = await parallelTemplateMatch(ra, categoryMaterials, 1142, startY, columnWidth, columnHeight, 0.8);
- const foundMaterials = matchResults.filter(r => r.result).map(r => r.name);
- if (foundMaterials.length > 0) {
- log.info(`第八列识别到前位材料 [${category}]: ${foundMaterials.join(', ')}`);
- }
- lowerPriorityMatches.push(...matchResults.filter(r => r.result));
- }
- }
-
- log.info(`第八列前位材料总数: ${lowerPriorityMatches.length}`);
- if (lowerPriorityMatches.length === 0) {
- log.info(`未发现前位材料,回退一页`);
- await scrollBackPage(totalPageDistance, 10, 5);
- await sleep(500);
- } else {
- log.info(`发现前位材料,照常继续扫描`);
- }
- }
-
- // 扫描背包中的材料
- for (let scroll = 0; scroll <= pageScrollCount; scroll++) {
-
- const ra = captureGameRegion();
- foundPriorityMaterial = false;
-
- const finalFilteredMaterials = await filterMaterialsByPriority(materialsCategory);
-
- const currentTypeMaterials = finalFilteredMaterials.filter(category => materialTypeMap[category] === currentType);
-
- log.info(`第八列扫描 - 当前分类: ${materialsCategory}, 优先级: ${currentPriority}`);
-
- if (currentPriority <= 2) {
+ if (debugLog) log.info(`后续优先级材料扫描 - 检查第八列前位材料`);
+
+ let backPageCount = 0;
+ let foundLowerPriority = false;
+
+ while (backPageCount < pageScrollCount && !foundLowerPriority) {
+ const ra = captureGameRegion();
+
const lowerPriorityMaterials = currentTypeMaterials.filter(category => materialPriority[category] < currentPriority);
- log.info(`检查前位材料分类: ${lowerPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`);
+ if (debugLog) log.info(`检查前位材料分类: ${lowerPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`);
const lowerPriorityMatches = [];
-
+
for (const category of lowerPriorityMaterials) {
const categoryMaterials = priorityMaterialNames
.filter(({ name, category: cat }) => cat === category && !recognizedMaterials.has(name))
@@ -493,29 +539,82 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior
return mat ? { name, mat } : null;
})
.filter(Boolean);
-
+
if (categoryMaterials.length > 0) {
const matchResults = await parallelTemplateMatch(ra, categoryMaterials, 1142, startY, columnWidth, columnHeight, 0.8);
const foundMaterials = matchResults.filter(r => r.result).map(r => r.name);
if (foundMaterials.length > 0) {
- log.info(`第八列识别到前位材料 [${category}]: ${foundMaterials.join(', ')}`);
+ if (debugLog) log.info(`第八列识别到前位材料 [${category}]: ${foundMaterials.join(', ')}`);
}
lowerPriorityMatches.push(...matchResults.filter(r => r.result));
}
}
+
+ if (lowerPriorityMatches.length > 0) {
+ foundLowerPriority = true;
+ if (debugLog) log.info(`发现前位材料,照常继续扫描`);
+ } else {
+ backPageCount++;
+ if (backPageCount < pageScrollCount) {
+ log.info(`未发现前位材料,回退一页 (${backPageCount}/${pageScrollCount})`);
+ await scrollBackPage(totalPageDistance, 10, 5);
+ await sleep(500);
+ } else {
+ log.info(`已回退${pageScrollCount}页,仍未发现前位材料,继续扫描`);
+ }
+ }
+ }
+ }
+
+ // 扫描背包中的材料
+ for (let scroll = 0; scroll <= pageScrollCount; scroll++) {
- log.info(`第八列前位材料总数: ${lowerPriorityMatches.length}`);
+ const ra = captureGameRegion();
+ foundPriorityMaterial = false;
+
+ const finalFilteredMaterials = await filterMaterialsByPriority(materialsCategory);
+
+ const currentTypeMaterials = finalFilteredMaterials.filter(category => materialTypeMap[category] === currentType);
+
+ if (debugLog) log.info(`第八列扫描 - 当前分类: ${materialsCategory}, 优先级: ${currentPriority}`);
+ isEighthColumnScan = true; // 标记当前为第八列扫描
+
+ if (currentPriority <= 2) {
+ const lowerPriorityMaterials = currentTypeMaterials.filter(category => materialPriority[category] < currentPriority);
+ if (debugLog) log.info(`检查前位材料分类: ${lowerPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`);
+ const lowerPriorityMatches = [];
+
+ for (const category of lowerPriorityMaterials) {
+ const categoryMaterials = priorityMaterialNames
+ .filter(({ name, category: cat }) => cat === category && !recognizedMaterials.has(name))
+ .map(({ name }) => {
+ const mat = materialImages[name];
+ return mat ? { name, mat } : null;
+ })
+ .filter(Boolean);
+
+ if (categoryMaterials.length > 0) {
+ const matchResults = await parallelTemplateMatch(ra, categoryMaterials, 1142, startY, columnWidth, columnHeight, 0.8);
+ const foundMaterials = matchResults.filter(r => r.result).map(r => r.name);
+ if (foundMaterials.length > 0 && debugLog) {
+ if (debugLog) log.info(`第八列识别到前位材料 [${category}]: ${foundMaterials.join(', ')}`);
+ }
+ lowerPriorityMatches.push(...matchResults.filter(r => r.result));
+ }
+ }
+
+ if (debugLog) log.info(`第八列前位材料总数: ${lowerPriorityMatches.length}`);
if (lowerPriorityMatches.length < 4) {
- log.info(`前位材料少于4张,触发全列扫描`);
+ if (debugLog) log.info(`前位材料少于4张,触发全列扫描`);
foundPriorityMaterial = true;
} else {
- log.info(`4张都是前位材料,继续翻页`);
+ if (debugLog) log.info(`4张都是前位材料,继续翻页`);
}
} else {
const currentOrHigherPriorityMaterials = currentTypeMaterials.filter(category => materialPriority[category] >= currentPriority);
- log.info(`检查同位/后位材料分类: ${currentOrHigherPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`);
+ if (debugLog) log.info(`检查同位/后位材料分类: ${currentOrHigherPriorityMaterials.map(c => `${c}(优先级${materialPriority[c]})`).join(', ')}`);
const currentOrHigherMatches = [];
-
+
for (const category of currentOrHigherPriorityMaterials) {
const categoryMaterials = priorityMaterialNames
.filter(({ name, category: cat }) => cat === category && !recognizedMaterials.has(name))
@@ -524,87 +623,43 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior
return mat ? { name, mat } : null;
})
.filter(Boolean);
-
+
if (categoryMaterials.length > 0) {
const matchResults = await parallelTemplateMatch(ra, categoryMaterials, 1142, startY, columnWidth, columnHeight, 0.8);
const foundMaterials = matchResults.filter(r => r.result).map(r => r.name);
- if (foundMaterials.length > 0) {
- log.info(`第八列识别到同位/后位材料 [${category}]: ${foundMaterials.join(', ')}`);
+ if (foundMaterials.length > 0 && debugLog) {
+ if (debugLog) log.info(`第八列识别到同位/后位材料 [${category}]: ${foundMaterials.join(', ')}`);
}
currentOrHigherMatches.push(...matchResults.filter(r => r.result));
}
}
-
- log.info(`第八列同位/后位材料总数: ${currentOrHigherMatches.length}`);
+
+ if (debugLog) log.info(`第八列同位/后位材料总数: ${currentOrHigherMatches.length}`);
if (currentOrHigherMatches.length > 0) {
- log.info(`发现同位/后位材料,触发全列扫描`);
+ if (debugLog) log.info(`发现同位/后位材料,触发全列扫描`);
foundPriorityMaterial = true;
} else {
- log.info(`未发现同位/后位材料,继续翻页`);
+ if (debugLog) log.info(`未发现同位/后位材料,继续翻页`);
}
}
-
- // 只有发现目标材料时,才执行全列扫描
- if (foundPriorityMaterial) {
- log.info(`开始全列扫描`);
- const ocrRegions = [];
- const matchedMaterials = [];
-
- for (let column = 0; column < maxColumns; column++) {
- const scanX0 = startX + column * OffsetWidth;
- const scanX = Math.round(scanX0);
-
- // 准备当前列需要扫描的材料
- const materialsToMatch = materialCategories
- .filter(({ name }) => !recognizedMaterials.has(name))
- .map(({ name }) => {
- const mat = materialImages[name];
- return mat ? { name, mat } : null;
- })
- .filter(Boolean);
-
- if (materialsToMatch.length > 0) {
- // 并行扫描当前列的所有材料
- const matchResults = await parallelTemplateMatch(ra, materialsToMatch, scanX, startY, columnWidth, columnHeight, 0.85);
-
- // 收集匹配结果和OCR区域
- for (const { name, result } of matchResults) {
- if (result) {
- recognizedMaterials.add(name);
-
- // drawAndClearRedBox(result, ra, 100);// 调用异步函数绘制红框并延时清除
- const ocrRegion = {
- x: result.x - tolerance,
- y: result.y + 97 - tolerance,
- width: 66 + 2 * tolerance,
- height: 22 + 2 * tolerance
- };
- ocrRegions.push({ region: ocrRegion, name });
- matchedMaterials.push({ name, result });
-
- if (!hasFoundFirstMaterial) {
- hasFoundFirstMaterial = true;
- lastFoundTime = Date.now();
- } else {
- lastFoundTime = Date.now();
- }
- }
- }
- }
+
+ // 只有发现目标材料时,才执行全列扫描
+ if (foundPriorityMaterial) {
+ if (debugLog) log.info(`开始全列扫描`);
+ const ocrRegions = await performFullColumnScan(ra, materialCategories, materialImages, recognizedMaterials, startX, startY, columnWidth, columnHeight, maxColumns, OffsetWidth, tolerance);
+
+ // 批量处理OCR
+ if (ocrRegions.length > 0) {
+ const ocrResults = await batchRecognizeText(ocrRegions, ra);
+
+ // 处理OCR结果
+ for (const { name, result } of ocrResults) {
+ materialInfo.push({ name, count: result || "?" });
}
-
- // 批量处理OCR
- if (ocrRegions.length > 0) {
- const ocrResults = await batchRecognizeText(ocrRegions, ra);
-
- // 处理OCR结果
- for (const { name, result } of ocrResults) {
- materialInfo.push({ name, count: result || "?" });
- }
- }
- } else {
- log.info(`未发现目标材料,跳过`);
}
+ } else {
+ log.info(`未发现目标材料,跳过`);
+ }
// 每5秒输出一句俏皮话
const phrasesTime = Date.now();
@@ -625,7 +680,29 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior
break;
}
- if (hasFoundFirstMaterial && Date.now() - lastFoundTime > 5000) {
+ // 检查是否需要启动画面比较兜底逻辑
+ let useScreenComparison = false;
+ if (!shouldEndScan && scroll < pageScrollCount && previousScreenshot) {
+ // 检查是否满足兜底条件:
+ // 1. 已找到至少一个材料,但长时间未发现新材料
+ // 2. 或连续多次未发现任何材料
+ // 注意:第八列扫描时不触发兜底逻辑
+ if (recognizedMaterials.size > 0) {
+ const noNewMaterialTime = Date.now() - lastFoundTime;
+ if (noNewMaterialTime > 5000 && !isEighthColumnScan) { // 5秒未发现新材料,且不是第八列扫描
+ useScreenComparison = true;
+ log.info(`5秒未发现新材料,启动画面比较兜底逻辑`);
+ }
+ } else if (scroll > 2 && !isEighthColumnScan) { // 连续翻页2次以上仍未发现任何材料,且不是第八列扫描
+ useScreenComparison = true;
+ log.info(`连续翻页${scroll}次未发现任何材料,启动画面比较兜底逻辑`);
+ }
+ }
+
+ isEighthColumnScan = false; // 第八列扫描结束,重置标志
+
+ // 5秒未发现新材料时结束扫描,但如果触发兜底逻辑或第八列扫描则跳过此检查
+ if (hasFoundFirstMaterial && Date.now() - lastFoundTime > 5000 && !useScreenComparison && !isEighthColumnScan) {
log.info("未发现新的材料,结束扫描");
shouldEndScan = true;
break;
@@ -640,25 +717,7 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior
region: { x: 400, y: 400, width: 600, height: 100 }, // 长条形状比较区域
mat: ra.DeriveCrop(400, 400, 600, 100).SrcMat // 外扩1像素
};
-
- // 检查是否需要启动画面比较兜底逻辑
- let useScreenComparison = false;
- if (!shouldEndScan && scroll < pageScrollCount && previousScreenshot) {
- // 检查是否满足兜底条件:
- // 1. 已找到至少一个材料,但长时间未发现新材料
- // 2. 或连续多次未发现任何材料
- if (recognizedMaterials.size > 0) {
- const noNewMaterialTime = Date.now() - lastFoundTime;
- if (noNewMaterialTime > 5000) { // 5秒未发现新材料
- useScreenComparison = true;
- log.info(`5秒未发现新材料,启动画面比较兜底逻辑`);
- }
- } else if (scroll > 5) { // 连续翻页5次以上仍未发现任何材料
- useScreenComparison = true;
- log.info(`连续翻页${scroll}次未发现任何材料,启动画面比较兜底逻辑`);
- }
- }
-
+
// 滑动到下一页
if (scroll < pageScrollCount && !shouldEndScan) {
if (useScreenComparison && previousScreenshot) {
@@ -670,55 +729,22 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior
previousScreenshot.region.width + 2,
previousScreenshot.region.height + 2
);
- matchRo.threshold = 0.95; // 高阈值,确保区域变化足够明显
- matchRo.Use3Channels = true;
-
+ matchRo.threshold = 0.85; // 高阈值,确保区域变化足够明显
+ // matchRo.Use3Channels = true;
+
const matchResult = ra.find(matchRo);
if (matchResult.isExist()) {
log.info("连续翻页画面无明显变化,执行最后一次全列扫描");
-
- // 执行最后一次全列扫描
- log.info("执行最后一次全列扫描");
- for (let column = 0; column < maxColumns; column++) {
- const scanX0 = startX + column * OffsetWidth;
- const scanX = Math.round(scanX0);
- for (let i = 0; i < materialCategories.length; i++) {
- const { name } = materialCategories[i];
- if (recognizedMaterials.has(name)) {
- continue; // 如果已经识别过,跳过
- }
-
- const mat = materialImages[name];
- const recognitionObject = RecognitionObject.TemplateMatch(mat, scanX, startY, columnWidth, columnHeight);
- recognitionObject.threshold = 0.85;
- recognitionObject.Use3Channels = true;
-
- const result = ra.find(recognitionObject);
-
- if (result.isExist() && result.x !== 0 && result.y !== 0) {
- recognizedMaterials.add(name);
- moveMouseTo(result.x, result.y);
-
- const ocrRegion = {
- x: result.x - tolerance,
- y: result.y + 97 - tolerance,
- width: 66 + 2 * tolerance,
- height: 22 + 2 * tolerance
- };
- const ocrResult = await recognizeText(ocrRegion, 200, 10, 5, 2, ra);
- materialInfo.push({ name, count: ocrResult || "?" });
-
- if (!hasFoundFirstMaterial) {
- hasFoundFirstMaterial = true;
- lastFoundTime = Date.now();
- } else {
- lastFoundTime = Date.now();
- }
- }
- await sleep(imageDelay);
+
+ const ocrRegions = await performFullColumnScan(ra, materialCategories, materialImages, recognizedMaterials, startX, startY, columnWidth, columnHeight, maxColumns, OffsetWidth, tolerance);
+
+ if (ocrRegions.length > 0) {
+ const ocrResults = await batchRecognizeText(ocrRegions, ra);
+ for (const { name, result } of ocrResults) {
+ materialInfo.push({ name, count: result || "?" });
}
}
-
+
log.info("最后一次全列扫描完成,结束扫描");
shouldEndScan = true;
break;
@@ -726,13 +752,14 @@ async function scanMaterials(materialsCategory, materialCategoryMap, isPostPrior
log.info("连续翻页画面有变化,继续扫描");
}
}
-
+
// 执行翻页
await scrollPage(-totalPageDistance, 10, 5);
- // 减少等待时间,提高翻页速度
- await sleep(50);
+ // 兜底逻辑启动时增加等待时间,确保画面稳定
+ const scrollDelay = useScreenComparison ? 500 : 50;
+ await sleep(scrollDelay);
}
-
+
// 更新上一次的截图
previousScreenshot = currentScreenshot;
}
@@ -760,7 +787,7 @@ ${Array.from(unmatchedMaterialNames).join(",")}
const overwriteFilePath = `overwrite_record/${materialsCategory}.txt`; // 所有的历史记录分类储存
const latestFilePath = "latest_record.txt"; // 所有的历史记录集集合
if (pathingMode.onlyCategory) {
- writeFile(categoryFilePath, logContent);
+ writeFile(categoryFilePath, logContent);
}
writeFile(overwriteFilePath, logContent);
writeFile(latestFilePath, logContent); // 覆盖模式?
@@ -836,230 +863,232 @@ function dynamicMaterialGrouping(materialCategoryMap) {
// 主逻辑函数
async function MaterialPath(materialCategoryMap, cachedFrame = null) {
try {
- // 1. 先记录原始名称与别名的映射关系(用于最后反向转换)
- const nameMap = new Map();
- Object.values(materialCategoryMap).flat().forEach(originalName => {
- const aliasName = MATERIAL_ALIAS[originalName] || originalName;
- nameMap.set(aliasName, originalName); // 存储:别名→原始名
- });
+ // 1. 先记录原始名称与别名的映射关系(用于最后反向转换)
+ const nameMap = new Map();
+ Object.values(materialCategoryMap).flat().forEach(originalName => {
+ const aliasName = MATERIAL_ALIAS[originalName] || originalName;
+ nameMap.set(aliasName, originalName); // 存储:别名→原始名
+ });
- // 2. 转换materialCategoryMap为别名(用于内部处理)
- const processedMap = {};
- Object.entries(materialCategoryMap).forEach(([category, names]) => {
- processedMap[category] = names.map(name => MATERIAL_ALIAS[name] || name);
- });
- materialCategoryMap = processedMap;
+ // 2. 转换materialCategoryMap为别名(用于内部处理)
+ const processedMap = {};
+ Object.entries(materialCategoryMap).forEach(([category, names]) => {
+ processedMap[category] = names.map(name => MATERIAL_ALIAS[name] || name);
+ });
+ materialCategoryMap = processedMap;
- const maxStage = 4; // 最大阶段数
- let stage = 0; // 当前阶段
- let currentGroupIndex = 0; // 当前处理的分组索引
- let currentCategoryIndex = 0; // 当前处理的分类索引
- let materialsCategory = ""; // 当前处理的材料分类名称
- const allLowCountMaterials = []; // 用于存储所有识别到的低数量材料信息
+ const maxStage = 4; // 最大阶段数
+ let stage = 0; // 当前阶段
+ let currentGroupIndex = 0; // 当前处理的分组索引
+ let currentCategoryIndex = 0; // 当前处理的分类索引
+ let materialsCategory = ""; // 当前处理的材料分类名称
+ const allLowCountMaterials = []; // 用于存储所有识别到的低数量材料信息
+
+ // 添加状态变量,记录上一个分类的信息
+ let prevCategory = null;
+ let prevCategoryObject = null;
+ let prevPriority = null;
+ let prevGroup = null;
+ let skipSliderReset = false; // 是否跳过滑条重置
+
+ const sortedGroups = dynamicMaterialGrouping(materialCategoryMap);
+// log.info("材料 动态[分组]结果:");
+ sortedGroups.forEach(group => {
+ log.info(`类型 ${group.type} | 包含分类: ${group.categories.join(', ')}`);
+});
- // 添加状态变量,记录上一个分类的信息
- let prevCategory = null;
- let prevCategoryObject = null;
- let prevPriority = null;
- let prevGroup = null;
- let skipSliderReset = false; // 是否跳过滑条重置
+ let loopCount = 0;
+ const maxLoopCount = 200; // 合理阈值,正常流程约50-100次循环
- const sortedGroups = dynamicMaterialGrouping(materialCategoryMap);
- // log.info("材料 动态[分组]结果:");
- sortedGroups.forEach(group => {
- log.info(`类型 ${group.type} | 包含分类: ${group.categories.join(', ')}`);
- });
+ while (stage <= maxStage && loopCount <= maxLoopCount) { // ===== 补充优化:加入循环次数限制 =====
+ loopCount++;
+ switch (stage) {
+ case 0: // 返回主界面
+ log.info("返回主界面");
+ await genshin.returnMainUi();
+ await sleep(500);
+ stage = 1; // 进入下一阶段
+ break;
- let loopCount = 0;
- const maxLoopCount = 200; // 合理阈值,正常流程约50-100次循环
+ case 1: // 打开背包界面
+ // log.info("打开背包界面");
+ keyPress("B"); // 打开背包界面
+ // 暂时注释掉OCR暂停逻辑
+ // state.ocrPaused = true;
+ // log.info("背包扫描开始,已暂停OCR拾取任务");
+ await sleep(800); // 减少等待时间
- while (stage <= maxStage && loopCount <= maxLoopCount) { // ===== 补充优化:加入循环次数限制 =====
- loopCount++;
- switch (stage) {
- case 0: // 返回主界面
- log.info("返回主界面");
- await genshin.returnMainUi();
- await sleep(500);
- stage = 1; // 进入下一阶段
- break;
-
- case 1: // 打开背包界面
- // log.info("打开背包界面");
- keyPress("B"); // 打开背包界面
- state.ocrPaused = true;
- log.info("背包扫描开始,已暂停OCR拾取任务");
- await sleep(800); // 减少等待时间
+ cachedFrame?.dispose();
+ cachedFrame = captureGameRegion();
+ const backpackResult = await recognizeImage(BagpackRo, cachedFrame, 2000, 500, true, "背包");
+ if (backpackResult.isDetected) {
+ // log.info("成功识别背包图标");
+ stage = 2; // 进入下一阶段
+ } else {
+ log.warn("未识别到背包图标,重新尝试");
+ // ===== 补充优化:连续回退时释放资源 =====
cachedFrame?.dispose();
- cachedFrame = captureGameRegion();
+ stage = 0; // 回退
+ }
+ break;
+
+ case 2: // 按分组处理材料分类
+ if (currentGroupIndex < sortedGroups.length) {
+ const group = sortedGroups[currentGroupIndex];
+
+ if (currentCategoryIndex < group.categories.length) {
+ materialsCategory = group.categories[currentCategoryIndex];
+ const offset = materialTypeMap[materialsCategory];
+ const menuClickX = Math.round(575 + (offset - 1) * 96.25);
+ // log.info(`点击坐标 (${menuClickX},75)`);
+ click(menuClickX, 75);
+
+ await sleep(500);
- const backpackResult = await recognizeImage(BagpackRo, cachedFrame, 2000, 500, true, "背包");
- if (backpackResult.isDetected) {
- // log.info("成功识别背包图标");
- stage = 2; // 进入下一阶段
- } else {
- log.warn("未识别到背包图标,重新尝试");
- // ===== 补充优化:连续回退时释放资源 =====
cachedFrame?.dispose();
- stage = 0; // 回退
- }
- break;
+ cachedFrame = captureGameRegion();
- case 2: // 按分组处理材料分类
- if (currentGroupIndex < sortedGroups.length) {
- const group = sortedGroups[currentGroupIndex];
-
- if (currentCategoryIndex < group.categories.length) {
- materialsCategory = group.categories[currentCategoryIndex];
- const offset = materialTypeMap[materialsCategory];
- const menuClickX = Math.round(575 + (offset - 1) * 96.25);
- // log.info(`点击坐标 (${menuClickX},75)`);
- click(menuClickX, 75);
-
- await sleep(500);
-
- cachedFrame?.dispose();
- cachedFrame = captureGameRegion();
-
- stage = 3; // 进入下一阶段
- } else {
- currentGroupIndex++;
- currentCategoryIndex = 0; // 重置分类索引
- stage = 2; // 继续处理下一组
- }
+ stage = 3; // 进入下一阶段
} else {
- stage = 5; // 跳出循环
+ currentGroupIndex++;
+ currentCategoryIndex = 0; // 重置分类索引
+ stage = 2; // 继续处理下一组
}
- break;
+ } else {
+ stage = 5; // 跳出循环
+ }
+ break;
- case 3: // 识别材料分类
- let CategoryObject = getCategoryObject(materialsCategory);
- if (!CategoryObject) {
- log.error("未知的材料分类");
- // ===== 补充优化:异常时释放资源并退出 =====
- cachedFrame?.dispose();
- stage = 0; // 回退到阶段0
- return;
+ case 3: // 识别材料分类
+ let CategoryObject = getCategoryObject(materialsCategory);
+ if (!CategoryObject) {
+ log.error("未知的材料分类");
+ // ===== 补充优化:异常时释放资源并退出 =====
+ cachedFrame?.dispose();
+ stage = 0; // 回退到阶段0
+ return;
+ }
+
+ const CategoryResult = await recognizeImage(CategoryObject, cachedFrame, 2000, 500, true, materialsCategory);
+ if (CategoryResult.isDetected) {
+ log.info(`识别到${materialsCategory} 所在分类。`);
+ stage = 4; // 进入下一阶段
+ } else {
+ log.warn("未识别到材料分类图标,重新尝试");
+ // log.warn(`识别结果:${JSON.stringify(CategoryResult)}`);
+ // ===== 补充优化:连续回退时释放资源 =====
+ cachedFrame?.dispose();
+ stage = 2; // 回退到阶段2
+ }
+ break;
+
+ case 4: // 扫描材料
+ log.info("芭芭拉,冲鸭!");
+
+ // 判断是否需要重置滑条
+ if (!skipSliderReset) {
+ await moveMouseTo(1288, 124); // 移动鼠标至滑条顶端
+ await sleep(200);
+ leftButtonDown(); // 长按左键重置材料滑条
+ await sleep(300);
+ leftButtonUp();
+ await sleep(200);
+ } else {
+ log.info("同一大类且为后位材料,跳过滑条重置");
+ // 不重置滑条,直接从当前位置开始检查第八列
+ }
+
+ // 判断是否是后续优先级材料(优先级高于前一个分类)
+ const currentPriority = materialPriority[materialsCategory];
+ const isPostPriority = prevPriority !== null && currentPriority > prevPriority;
+ if (isPostPriority) {
+ log.info(`后续优先级材料扫描 - 当前优先级: ${currentPriority}, 前位优先级: ${prevPriority}`);
+ }
+
+ // 扫描材料并获取低于目标数量的材料
+ const lowCountMaterials = await scanMaterials(materialsCategory, materialCategoryMap, isPostPriority);
+ allLowCountMaterials.push(lowCountMaterials);
+
+ // 保存当前分类信息,用于下一个分类的判断
+ prevCategory = materialsCategory;
+ prevPriority = materialPriority[materialsCategory];
+
+ // 获取当前分类的CategoryObject
+ const currentCategoryObject = getCategoryObject(materialsCategory);
+ prevCategoryObject = currentCategoryObject;
+ prevGroup = sortedGroups[currentGroupIndex];
+
+ currentCategoryIndex++;
+
+ // 判断下一个分类是否是同一个大类CategoryObject下的后位材料
+ let nextCategory = null;
+ let nextCategoryObject = null;
+ let nextPriority = null;
+
+ // 检查是否还有下一个分类
+ if (currentGroupIndex < sortedGroups.length) {
+ const group = sortedGroups[currentGroupIndex];
+ if (currentCategoryIndex < group.categories.length) {
+ nextCategory = group.categories[currentCategoryIndex];
+
+ // 获取下一个分类的CategoryObject
+ nextCategoryObject = getCategoryObject(nextCategory);
+
+ // 获取下一个分类的优先级
+ nextPriority = materialPriority[nextCategory];
}
+ }
+
+ // 判断是否跳过滑条重置:同一大类且为后位材料
+ if (nextCategory &&
+ nextCategoryObject === prevCategoryObject &&
+ nextPriority > prevPriority) {
+ skipSliderReset = true;
+ } else {
+ skipSliderReset = false;
+ }
+
+ stage = 2; // 返回阶段2处理下一个分类
+ break;
- const CategoryResult = await recognizeImage(CategoryObject, cachedFrame, 2000, 500, true, materialsCategory);
- if (CategoryResult.isDetected) {
- log.info(`识别到${materialsCategory} 所在分类。`);
- stage = 4; // 进入下一阶段
- } else {
- log.warn("未识别到材料分类图标,重新尝试");
- // log.warn(`识别结果:${JSON.stringify(CategoryResult)}`);
- // ===== 补充优化:连续回退时释放资源 =====
- cachedFrame?.dispose();
- stage = 2; // 回退到阶段2
- }
- break;
-
- case 4: // 扫描材料
- log.info("芭芭拉,冲鸭!");
-
- // 判断是否需要重置滑条
- if (!skipSliderReset) {
- await moveMouseTo(1288, 124); // 移动鼠标至滑条顶端
- await sleep(200);
- leftButtonDown(); // 长按左键重置材料滑条
- await sleep(300);
- leftButtonUp();
- await sleep(200);
- } else {
- log.info("同一大类且为后位材料,跳过滑条重置");
- // 不重置滑条,直接从当前位置开始检查第八列
- }
-
- // 判断是否是后续优先级材料(优先级高于前一个分类)
- const currentPriority = materialPriority[materialsCategory];
- const isPostPriority = prevPriority !== null && currentPriority > prevPriority;
- if (isPostPriority) {
- log.info(`后续优先级材料扫描 - 当前优先级: ${currentPriority}, 前位优先级: ${prevPriority}`);
- }
-
- // 扫描材料并获取低于目标数量的材料
- const lowCountMaterials = await scanMaterials(materialsCategory, materialCategoryMap, isPostPriority);
- allLowCountMaterials.push(lowCountMaterials);
-
- // 保存当前分类信息,用于下一个分类的判断
- prevCategory = materialsCategory;
- prevPriority = materialPriority[materialsCategory];
-
- // 获取当前分类的CategoryObject
- const currentCategoryObject = getCategoryObject(materialsCategory);
- prevCategoryObject = currentCategoryObject;
- prevGroup = sortedGroups[currentGroupIndex];
-
- currentCategoryIndex++;
-
- // 判断下一个分类是否是同一个大类CategoryObject下的后位材料
- let nextCategory = null;
- let nextCategoryObject = null;
- let nextPriority = null;
-
- // 检查是否还有下一个分类
- if (currentGroupIndex < sortedGroups.length) {
- const group = sortedGroups[currentGroupIndex];
- if (currentCategoryIndex < group.categories.length) {
- nextCategory = group.categories[currentCategoryIndex];
-
- // 获取下一个分类的CategoryObject
- nextCategoryObject = getCategoryObject(nextCategory);
-
- // 获取下一个分类的优先级
- nextPriority = materialPriority[nextCategory];
- }
- }
-
- // 判断是否跳过滑条重置:同一大类且为后位材料
- if (nextCategory &&
- nextCategoryObject === prevCategoryObject &&
- nextPriority > prevPriority) {
- skipSliderReset = true;
- } else {
- skipSliderReset = false;
- }
-
- stage = 2; // 返回阶段2处理下一个分类
- break;
-
- case 5: // 所有分组处理完毕
- log.info("所有分组处理完毕,返回主界面");
- await genshin.returnMainUi();
- stage = maxStage + 1; // 确保退出循环
- break;
- }
+ case 5: // 所有分组处理完毕
+ log.info("所有分组处理完毕,返回主界面");
+ await genshin.returnMainUi();
+ stage = maxStage + 1; // 确保退出循环
+ break;
}
+ }
- // ===== 补充优化:循环超限处理,防止卡死 =====
- if (loopCount > maxLoopCount) {
- log.error(`主循环次数超限(${maxLoopCount}次),强制退出`);
- cachedFrame?.dispose();
- await genshin.returnMainUi();
- return [];
- }
-
- await genshin.returnMainUi(); // 返回主界面
- log.info("扫描流程结束");
-
-
- // 3. 处理完成后,将输出结果转换回原始名称
- const finalResult = allLowCountMaterials.map(categoryMaterials => {
- return categoryMaterials.map(material => {
- // 假设material包含name属性,将别名转回原始名
- return {
- ...material,
- name: nameMap.get(material.name) || material.name // 反向映射
- };
- });
- });
-
+ // ===== 补充优化:循环超限处理,防止卡死 =====
+ if (loopCount > maxLoopCount) {
+ log.error(`主循环次数超限(${maxLoopCount}次),强制退出`);
cachedFrame?.dispose();
- return finalResult; // 返回转换后的结果(如"晶蝶")
+ await genshin.returnMainUi();
+ return [];
+ }
+
+ await genshin.returnMainUi(); // 返回主界面
+ log.info("扫描流程结束");
+
+
+ // 3. 处理完成后,将输出结果转换回原始名称
+ const finalResult = allLowCountMaterials.map(categoryMaterials => {
+ return categoryMaterials.map(material => {
+ // 假设material包含name属性,将别名转回原始名
+ return {
+ ...material,
+ name: nameMap.get(material.name) || material.name // 反向映射
+ };
+ });
+ });
+
+ cachedFrame?.dispose();
+ return finalResult; // 返回转换后的结果(如"晶蝶")
} finally {
- state.ocrPaused = false;
- log.info("背包扫描结束,已恢复OCR拾取任务");
+ // 暂时注释掉OCR恢复逻辑
+ // state.ocrPaused = false;
+ // log.info("背包扫描结束,已恢复OCR拾取任务");
}
}
diff --git a/repo/js/背包材料统计/lib/colorDetection.js b/repo/js/背包材料统计/lib/colorDetection.js
new file mode 100644
index 000000000..1c6791e61
--- /dev/null
+++ b/repo/js/背包材料统计/lib/colorDetection.js
@@ -0,0 +1,417 @@
+const COLOR_TOLERANCE = 20;
+const PIXEL_SAMPLE_RADIUS = 2;
+const STD_TOLERANCE = 20;
+const OUTLIER_THRESHOLD = 1.5;
+const POSITION_TOLERANCE = 200;
+
+function compareColorTolerance(pixel1, pixel2, tolerance) {
+ const bDiff = Math.abs(pixel1.Item0 - pixel2.Item0);
+ const gDiff = Math.abs(pixel1.Item1 - pixel2.Item1);
+ const rDiff = Math.abs(pixel1.Item2 - pixel2.Item2);
+ return bDiff <= tolerance && gDiff <= tolerance && rDiff <= tolerance;
+}
+
+function compareColor(templateMat, cachedFrame, result, detectRange = null) {
+ const regionMat = cachedFrame.SrcMat;
+
+ if (!detectRange) {
+ detectRange = {
+ x: 0,
+ y: 0,
+ width: regionMat.cols,
+ height: regionMat.rows
+ };
+ }
+
+ const resultCenterX = Math.floor(result.x + result.width / 2);
+ const resultCenterY = Math.floor(result.y + result.height / 2);
+
+ const templateSize = Math.min(templateMat.cols, templateMat.rows);
+ const radius = Math.min(PIXEL_SAMPLE_RADIUS, Math.floor(templateSize / 2) - 1);
+
+ const { bestPosition } = getBestRegionPosition(
+ templateMat,
+ regionMat,
+ resultCenterX,
+ resultCenterY,
+ radius,
+ detectRange
+ );
+
+ const templateCenterX = Math.floor(templateMat.cols / 2);
+ const templateCenterY = Math.floor(templateMat.rows / 2);
+
+ const { templatePixels, regionPixels, matchedPixels } = getMatchedPixelsAroundCenter(
+ templateMat, templateCenterX, templateCenterY,
+ regionMat, bestPosition.x, bestPosition.y,
+ radius, COLOR_TOLERANCE
+ );
+
+ const matchedTemplatePixels = matchedPixels.map(p => p.template);
+ const matchedRegionPixels = matchedPixels.map(p => p.region);
+
+ const templateDist = analyzeColorDistribution(matchedTemplatePixels);
+ const regionDist = analyzeColorDistribution(matchedRegionPixels);
+
+ const matchedCount = matchedPixels.length;
+ const totalCount = templatePixels.length;
+
+ const bDiff = Math.abs(templateDist.avg.Item0 - regionDist.avg.Item0);
+ const gDiff = Math.abs(templateDist.avg.Item1 - regionDist.avg.Item1);
+ const rDiff = Math.abs(templateDist.avg.Item2 - regionDist.avg.Item2);
+
+ const stdBDiff = Math.abs(templateDist.std.Item0 - regionDist.std.Item0);
+ const stdGDiff = Math.abs(templateDist.std.Item1 - regionDist.std.Item1);
+ const stdRDiff = Math.abs(templateDist.std.Item2 - regionDist.std.Item2);
+
+ const isMatch = stdBDiff <= STD_TOLERANCE && stdGDiff <= STD_TOLERANCE && stdRDiff <= STD_TOLERANCE;
+
+ return {
+ isMatch: isMatch,
+ avgDiff: { b: bDiff, g: gDiff, r: rDiff },
+ stdDiff: { b: stdBDiff, g: stdGDiff, r: stdRDiff },
+ matchedCount: matchedCount,
+ totalCount: totalCount
+ };
+}
+
+function getPixelsAroundCenter(mat, centerX, centerY, radius) {
+ const pixels = [];
+ for (let y = centerY - radius; y <= centerY + radius; y++) {
+ for (let x = centerX - radius; x <= centerX + radius; x++) {
+ if (x >= 0 && y >= 0 && x < mat.cols && y < mat.rows) {
+ try {
+ const pixel = mat.Get(OpenCvSharp.OpenCvSharp.Vec3b, y, x);
+ pixels.push(pixel);
+ } catch (error) {
+ log.debug(`获取像素(${x}, ${y})失败: ${error.message}`);
+ }
+ }
+ }
+ }
+ return pixels;
+}
+
+function getMatchedPixelsAroundCenter(templateMat, templateCenterX, templateCenterY, regionMat, regionCenterX, regionCenterY, radius, tolerance) {
+ const templatePixels = [];
+ const regionPixels = [];
+ const matchedPixels = [];
+
+ for (let y = -radius; y <= radius; y++) {
+ for (let x = -radius; x <= radius; x++) {
+ const tx = templateCenterX + x;
+ const ty = templateCenterY + y;
+ const rx = regionCenterX + x;
+ const ry = regionCenterY + y;
+
+ if (tx >= 0 && ty >= 0 && tx < templateMat.cols && ty < templateMat.rows &&
+ rx >= 0 && ry >= 0 && rx < regionMat.cols && ry < regionMat.rows) {
+ try {
+ const templatePixel = templateMat.Get(OpenCvSharp.OpenCvSharp.Vec3b, ty, tx);
+ const regionPixel = regionMat.Get(OpenCvSharp.OpenCvSharp.Vec3b, ry, rx);
+
+ templatePixels.push(templatePixel);
+ regionPixels.push(regionPixel);
+
+ const bDiff = Math.abs(templatePixel.Item0 - regionPixel.Item0);
+ const gDiff = Math.abs(templatePixel.Item1 - regionPixel.Item1);
+ const rDiff = Math.abs(templatePixel.Item2 - regionPixel.Item2);
+
+ if (bDiff <= tolerance && gDiff <= tolerance && rDiff <= tolerance) {
+ matchedPixels.push({ template: templatePixel, region: regionPixel });
+ }
+ } catch (error) {
+ log.debug(`获取像素失败: ${error.message}`);
+ }
+ }
+ }
+ }
+
+ return { templatePixels, regionPixels, matchedPixels };
+}
+
+function removeOutliers(pixels, threshold) {
+ if (pixels.length <= 1) return pixels;
+
+ let sumB = 0, sumG = 0, sumR = 0;
+ for (const pixel of pixels) {
+ sumB += pixel.Item0;
+ sumG += pixel.Item1;
+ sumR += pixel.Item2;
+ }
+ const meanB = sumB / pixels.length;
+ const meanG = sumG / pixels.length;
+ const meanR = sumR / pixels.length;
+
+ let sumSqB = 0, sumSqG = 0, sumSqR = 0;
+ for (const pixel of pixels) {
+ sumSqB += Math.pow(pixel.Item0 - meanB, 2);
+ sumSqG += Math.pow(pixel.Item1 - meanG, 2);
+ sumSqR += Math.pow(pixel.Item2 - meanR, 2);
+ }
+ const stdB = Math.sqrt(sumSqB / pixels.length);
+ const stdG = Math.sqrt(sumSqG / pixels.length);
+ const stdR = Math.sqrt(sumSqR / pixels.length);
+
+ const filteredPixels = [];
+ for (const pixel of pixels) {
+ const bDiff = Math.abs(pixel.Item0 - meanB);
+ const gDiff = Math.abs(pixel.Item1 - meanG);
+ const rDiff = Math.abs(pixel.Item2 - meanR);
+
+ if (bDiff <= threshold * stdB && gDiff <= threshold * stdG && rDiff <= threshold * stdR) {
+ filteredPixels.push(pixel);
+ }
+ }
+
+ return filteredPixels.length > 0 ? filteredPixels : pixels;
+}
+
+function calculateAverageColor(pixels) {
+ if (pixels.length === 0) {
+ return { Item0: 0, Item1: 0, Item2: 0 };
+ }
+
+ let sumB = 0, sumG = 0, sumR = 0;
+ for (const pixel of pixels) {
+ sumB += pixel.Item0;
+ sumG += pixel.Item1;
+ sumR += pixel.Item2;
+ }
+
+ return {
+ Item0: Math.round(sumB / pixels.length),
+ Item1: Math.round(sumG / pixels.length),
+ Item2: Math.round(sumR / pixels.length)
+ };
+}
+
+function analyzeColorDistribution(pixels) {
+ if (pixels.length === 0) {
+ return {
+ count: 0,
+ min: { Item0: 0, Item1: 0, Item2: 0 },
+ max: { Item0: 0, Item1: 0, Item2: 0 },
+ avg: { Item0: 0, Item1: 0, Item2: 0 },
+ std: { Item0: 0, Item1: 0, Item2: 0 }
+ };
+ }
+
+ let minB = 255, minG = 255, minR = 255;
+ let maxB = 0, maxG = 0, maxR = 0;
+ let sumB = 0, sumG = 0, sumR = 0;
+
+ for (const pixel of pixels) {
+ minB = Math.min(minB, pixel.Item0);
+ minG = Math.min(minG, pixel.Item1);
+ minR = Math.min(minR, pixel.Item2);
+
+ maxB = Math.max(maxB, pixel.Item0);
+ maxG = Math.max(maxG, pixel.Item1);
+ maxR = Math.max(maxR, pixel.Item2);
+
+ sumB += pixel.Item0;
+ sumG += pixel.Item1;
+ sumR += pixel.Item2;
+ }
+
+ const avgB = sumB / pixels.length;
+ const avgG = sumG / pixels.length;
+ const avgR = sumR / pixels.length;
+
+ let sumSqB = 0, sumSqG = 0, sumSqR = 0;
+ for (const pixel of pixels) {
+ sumSqB += Math.pow(pixel.Item0 - avgB, 2);
+ sumSqG += Math.pow(pixel.Item1 - avgG, 2);
+ sumSqR += Math.pow(pixel.Item2 - avgR, 2);
+ }
+
+ const stdB = Math.sqrt(sumSqB / pixels.length);
+ const stdG = Math.sqrt(sumSqG / pixels.length);
+ const stdR = Math.sqrt(sumSqR / pixels.length);
+
+ return {
+ count: pixels.length,
+ min: { Item0: minB, Item1: minG, Item2: minR },
+ max: { Item0: maxB, Item1: maxG, Item2: maxR },
+ avg: { Item0: Math.round(avgB), Item1: Math.round(avgG), Item2: Math.round(avgR) },
+ std: { Item0: Math.round(stdB), Item1: Math.round(stdG), Item2: Math.round(stdR) }
+ };
+}
+
+function analyzeRowColumnDifferences(templateMat, regionMat, centerX, centerY, radius, detectRect = null) {
+ const templateRows = [];
+ const regionRows = [];
+
+ for (let y = -radius; y <= radius; y++) {
+ const templateRow = [];
+ const regionRow = [];
+
+ for (let x = -radius; x <= radius; x++) {
+ const templateX = Math.floor(templateMat.cols / 2) + x;
+ const templateY = Math.floor(templateMat.rows / 2) + y;
+ if (templateX >= 0 && templateY >= 0 && templateX < templateMat.cols && templateY < templateMat.rows) {
+ try {
+ templateRow.push(templateMat.Get(OpenCvSharp.OpenCvSharp.Vec3b, templateY, templateX));
+ } catch (e) {}
+ }
+
+ const regionX = centerX + x;
+ const regionY = centerY + y;
+
+ let isInDetectRange = true;
+ if (detectRect) {
+ if (regionX < detectRect.x || regionX >= detectRect.x + detectRect.width ||
+ regionY < detectRect.y || regionY >= detectRect.y + detectRect.height) {
+ isInDetectRange = false;
+ }
+ }
+
+ if (isInDetectRange && regionX >= 0 && regionY >= 0 && regionX < regionMat.cols && regionY < regionMat.rows) {
+ try {
+ regionRow.push(regionMat.Get(OpenCvSharp.OpenCvSharp.Vec3b, regionY, regionX));
+ } catch (e) {}
+ }
+ }
+
+ if (templateRow.length > 0 && regionRow.length > 0) {
+ templateRows.push(templateRow);
+ regionRows.push(regionRow);
+ }
+ }
+
+ const rowDiffs = [];
+ for (let i = 0; i < Math.min(templateRows.length, regionRows.length); i++) {
+ const templateRow = templateRows[i];
+ const regionRow = regionRows[i];
+
+ let totalDiff = 0;
+ const minLength = Math.min(templateRow.length, regionRow.length);
+
+ for (let j = 0; j < minLength; j++) {
+ const tPixel = templateRow[j];
+ const rPixel = regionRow[j];
+ totalDiff += Math.abs(tPixel.Item0 - rPixel.Item0) +
+ Math.abs(tPixel.Item1 - rPixel.Item1) +
+ Math.abs(tPixel.Item2 - rPixel.Item2);
+ }
+
+ rowDiffs.push(totalDiff / minLength);
+ }
+
+ const colDiffs = [];
+ const maxCol = Math.min(
+ templateRows[0]?.length || 0,
+ regionRows[0]?.length || 0
+ );
+
+ for (let j = 0; j < maxCol; j++) {
+ let totalDiff = 0;
+ const minRow = Math.min(templateRows.length, regionRows.length);
+
+ for (let i = 0; i < minRow; i++) {
+ const tPixel = templateRows[i][j];
+ const rPixel = regionRows[i][j];
+ totalDiff += Math.abs(tPixel.Item0 - rPixel.Item0) +
+ Math.abs(tPixel.Item1 - rPixel.Item1) +
+ Math.abs(tPixel.Item2 - rPixel.Item2);
+ }
+
+ colDiffs.push(totalDiff / minRow);
+ }
+
+ return {
+ rowDiffs,
+ colDiffs,
+ avgRowDiff: rowDiffs.length > 0 ? rowDiffs.reduce((a, b) => a + b, 0) / rowDiffs.length : 0,
+ avgColDiff: colDiffs.length > 0 ? colDiffs.reduce((a, b) => a + b, 0) / colDiffs.length : 0
+ };
+}
+
+function getBestRegionPosition(templateMat, regionMat, initialCenterX, initialCenterY, radius, detectRect = null, maxAttempts = 4) {
+ const initialDiffs = analyzeRowColumnDifferences(templateMat, regionMat, initialCenterX, initialCenterY, radius, detectRect);
+
+ let positions;
+ if (initialDiffs.avgColDiff > initialDiffs.avgRowDiff) {
+ const leftColDiff = initialDiffs.colDiffs[Math.floor(initialDiffs.colDiffs.length / 2) - 1] || 0;
+ const rightColDiff = initialDiffs.colDiffs[Math.floor(initialDiffs.colDiffs.length / 2) + 1] || 0;
+
+ if (leftColDiff > rightColDiff) {
+ positions = [
+ { x: initialCenterX, y: initialCenterY, name: "原始位置" },
+ { x: initialCenterX - 1, y: initialCenterY, name: "左移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY - 1, name: "左上移1像素" },
+ { x: initialCenterX, y: initialCenterY - 1, name: "上移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY - 1, name: "右上移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY, name: "右移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY + 1, name: "右下移1像素" },
+ { x: initialCenterX, y: initialCenterY + 1, name: "下移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY + 1, name: "左下移1像素" }
+ ];
+ } else {
+ positions = [
+ { x: initialCenterX, y: initialCenterY, name: "原始位置" },
+ { x: initialCenterX + 1, y: initialCenterY, name: "右移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY + 1, name: "右下移1像素" },
+ { x: initialCenterX, y: initialCenterY + 1, name: "下移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY + 1, name: "左下移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY, name: "左移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY - 1, name: "左上移1像素" },
+ { x: initialCenterX, y: initialCenterY - 1, name: "上移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY - 1, name: "右上移1像素" }
+ ];
+ }
+ } else {
+ const topRowDiff = initialDiffs.rowDiffs[Math.floor(initialDiffs.rowDiffs.length / 2) - 1] || 0;
+ const bottomRowDiff = initialDiffs.rowDiffs[Math.floor(initialDiffs.rowDiffs.length / 2) + 1] || 0;
+
+ if (topRowDiff > bottomRowDiff) {
+ positions = [
+ { x: initialCenterX, y: initialCenterY, name: "原始位置" },
+ { x: initialCenterX, y: initialCenterY - 1, name: "上移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY - 1, name: "右上移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY, name: "右移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY + 1, name: "右下移1像素" },
+ { x: initialCenterX, y: initialCenterY + 1, name: "下移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY + 1, name: "左下移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY, name: "左移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY - 1, name: "左上移1像素" }
+ ];
+ } else {
+ positions = [
+ { x: initialCenterX, y: initialCenterY, name: "原始位置" },
+ { x: initialCenterX, y: initialCenterY + 1, name: "下移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY + 1, name: "左下移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY, name: "左移1像素" },
+ { x: initialCenterX - 1, y: initialCenterY - 1, name: "左上移1像素" },
+ { x: initialCenterX, y: initialCenterY - 1, name: "上移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY - 1, name: "右上移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY, name: "右移1像素" },
+ { x: initialCenterX + 1, y: initialCenterY + 1, name: "右下移1像素" }
+ ];
+ }
+ }
+
+ let bestPosition = positions[0];
+ let bestDiff = Infinity;
+
+ const positionDiffs = [];
+
+ for (const pos of positions) {
+ const diffs = analyzeRowColumnDifferences(templateMat, regionMat, pos.x, pos.y, radius, detectRect);
+ const totalDiff = diffs.avgRowDiff + diffs.avgColDiff;
+ positionDiffs.push({ name: pos.name, diff: totalDiff });
+
+ if (totalDiff < bestDiff) {
+ bestDiff = totalDiff;
+ bestPosition = pos;
+ }
+ }
+
+ // log.debug(`位置调整分析: 原始位置行列差异 - 行: ${Math.round(initialDiffs.avgRowDiff)}, 列: ${Math.round(initialDiffs.avgColDiff)}`);
+ // for (const pd of positionDiffs) {
+ // log.debug(` ${pd.name}: 总差异 = ${Math.round(pd.diff)}`);
+ // }
+
+ return { bestPosition, bestDiff };
+}
\ No newline at end of file
diff --git a/repo/js/背包材料统计/lib/exp.js b/repo/js/背包材料统计/lib/exp.js
index 5816c5ed5..e0330d918 100644
--- a/repo/js/背包材料统计/lib/exp.js
+++ b/repo/js/背包材料统计/lib/exp.js
@@ -1,197 +1,206 @@
-// ===================== 狗粮模式专属函数 =====================
-// 1. 狗粮分解配置与OCR区域(保留原配置,无修改)
-const AUTO_SALVAGE_CONFIG = {
- autoSalvage3: settings.autoSalvage3 || "是",
- autoSalvage4: settings.autoSalvage4 || "是"
-};
-const EXP_OCRREGIONS = {
- expStorage: { x: 1472, y: 883, width: 170, height: 34 },
- expCount: { x: 1472, y: 895, width: 170, height: 34 }
-};
-
-// 处理数字文本:保留原逻辑(复用全局数字替换能力)
-function processNumberText(text) {
- let correctedText = text || "";
- let removedSymbols = [];
-
- // 替换错误字符(依赖全局 numberReplaceMap)
- for (const [wrong, correct] of Object.entries(numberReplaceMap)) {
- correctedText = correctedText.replace(new RegExp(wrong, 'g'), correct);
- }
-
- // 提取纯数字
- let finalText = '';
- for (const char of correctedText) {
- if (/[0-9]/.test(char)) {
- finalText += char;
- } else if (char.trim() !== '') {
- removedSymbols.push(char);
- }
- }
-
- return {
- processedText: finalText,
- removedSymbols: [...new Set(removedSymbols)]
- };
-}
-
-// 4. OCR识别EXP(核心改造:用 performOcr 替代重复OCR逻辑)
-async function recognizeExpRegion(regionName, initialRa = null, timeout = 2000) {
- // 1. 基础校验(保留原逻辑)
- const ocrRegion = EXP_OCRREGIONS[regionName];
- if (!ocrRegion) {
- log.error(`[狗粮OCR] 无效区域:${regionName}`);
- return { success: false, expCount: 0, screenshot: null }; // 新增返回截图(便于调试)
- }
-
- log.info(`[狗粮OCR] 识别${regionName}(x=${ocrRegion.x}, y=${ocrRegion.y})`);
- let ocrScreenshot = null; // 存储performOcr返回的有效截图
-
- try {
- // 2. 转换OCR区域格式:ocrRegion(x,y,width,height) → xRange/yRange(min/max)
- const xRange = {
- min: ocrRegion.x,
- max: ocrRegion.x + ocrRegion.width
- };
- const yRange = {
- min: ocrRegion.y,
- max: ocrRegion.y + ocrRegion.height
- };
-
- // 3. 调用新版 performOcr(自动重截图、资源管理、异常处理)
- // 目标文本传空数组:识别数字无需匹配特定文本,仅需提取内容
- const { results, screenshot } = await performOcr(
- [""], // targetTexts:空数组(数字识别无特定目标)
- xRange, // 转换后的X轴范围
- yRange, // 转换后的Y轴范围
- initialRa, // 初始截图(外部传入)
- timeout, // 超时时间(复用原参数)
- 50 // 重试间隔(默认50ms,比原500ms更灵敏)
- );
- ocrScreenshot = screenshot; // 暂存截图,后续返回或释放
-
- // 4. 处理OCR结果(保留原数字处理+日志逻辑)
- if (results.length > 0) {
- const { originalText, text: correctedText } = results[0]; // 从performOcr拿原始/修正文本
- log.info(`[狗粮OCR] 原始文本:${originalText}`); // 保持原日志格式
-
- // 用原processNumberText提纯数字
- const { processedText, removedSymbols } = processNumberText(correctedText);
- if (removedSymbols.length > 0) {
- log.info(`[狗粮OCR] 去除无效字符:${removedSymbols.join(', ')}`); // 保留原日志
- }
-
- const expCount = processedText ? parseInt(processedText, 10) : 0;
- log.info(`[狗粮OCR] ${regionName}结果:${expCount}`); // 保留原日志
- return { success: true, expCount, screenshot: ocrScreenshot }; // 返回截图(调试用)
- }
-
- } catch (error) {
- // 捕获performOcr未处理的异常(如参数错误)
- log.error(`[狗粮OCR] ${regionName}识别异常:${error.message}`);
- // 异常时释放截图资源
- if (ocrScreenshot) {
- if (ocrScreenshot.Dispose) ocrScreenshot.Dispose();
- else if (ocrScreenshot.dispose) ocrScreenshot.dispose();
- }
- }
-
- // 5. 识别失败/超时(保留原逻辑)
- log.error(`[狗粮OCR] ${regionName}超时未识别,默认0`);
- return { success: false, expCount: 0, screenshot: ocrScreenshot }; // 超时也返回截图(排查用)
-}
-
-// 5. 狗粮分解流程(调整:适配recognizeExpRegion的新返回值,优化资源释放)
-async function executeSalvageWithOCR() {
- log.info("[狗粮分解] 开始执行分解流程");
- let storageExp = 0;
- let countExp = 0;
- let cachedFrame = null;
- let ocrScreenshots = []; // 存储识别过程中产生的截图(统一释放)
-
- try {
- keyPress("B");
- await sleep(1000);
-
- const coords = [
- [670, 40], // 打开背包
- [660, 1010], // 打开分解
- [300, 1020], // 打开分解选项页面
- [200, 300, 500, AUTO_SALVAGE_CONFIG.autoSalvage3 !== '否'], // 3星(按配置)
- [200, 380, 500, AUTO_SALVAGE_CONFIG.autoSalvage4 !== '否'], // 4星(按配置)
- [340, 1000], // 确认选择
- [1720, 1015], // 点击是否分解
- [1320, 756], // 确认分解
- [1840, 45, 1500], // 退出
- [1840, 45], // 退出
- [1840, 45], // 退出
- ];
-
- for (const coord of coords) {
- const [x, y, delay = 1000, condition = true] = coord;
- if (condition) {
- click(x, y);
- await sleep(delay);
- log.debug(`[狗粮分解] 点击(${x},${y}),延迟${delay}ms`);
-
- // 分解前识别储存EXP(适配新的recognizeExpRegion返回值)
- if (x === 660 && y === 1010) {
- // 释放旧缓存帧
- if (cachedFrame) {
- if (cachedFrame.Dispose) cachedFrame.Dispose();
- else if (cachedFrame.dispose) cachedFrame.dispose();
- }
- // 捕获新帧
- cachedFrame = captureGameRegion();
- // 调用改造后的recognizeExpRegion(接收expCount和screenshot)
- const { expCount, screenshot } = await recognizeExpRegion("expStorage", cachedFrame, 1000);
- storageExp = expCount;
- ocrScreenshots.push(screenshot); // 收集截图(后续统一释放)
- }
-
- // 分解后识别新增EXP(同上,适配新返回值)
- if (x === 340 && y === 1000) {
- if (cachedFrame) {
- if (cachedFrame.Dispose) cachedFrame.Dispose();
- else if (cachedFrame.dispose) cachedFrame.dispose();
- }
- cachedFrame = captureGameRegion();
- const { expCount, screenshot } = await recognizeExpRegion("expCount", cachedFrame, 1000);
- countExp = expCount;
- ocrScreenshots.push(screenshot); // 收集截图
- }
- }
- }
-
- // 计算并返回结果(保留原逻辑)
- const totalExp = countExp - storageExp;
- log.info(`[狗粮分解] 完成,新增EXP:${totalExp}(分解前:${storageExp},分解后:${countExp})`);
- return { success: true, totalExp: Math.max(totalExp, 0) };
-
- } catch (error) {
- log.error(`[狗粮分解] 失败:${error.message}`);
- return { success: false, totalExp: 0 };
-
- } finally {
- // 最终统一释放所有资源(避免内存泄漏)
- // 1. 释放缓存帧
- if (cachedFrame) {
- if (cachedFrame.Dispose) cachedFrame.Dispose();
- else if (cachedFrame.dispose) cachedFrame.dispose();
- }
- // 2. 释放OCR过程中产生的截图
- for (const screenshot of ocrScreenshots) {
- if (screenshot) {
- if (screenshot.Dispose) screenshot.Dispose();
- else if (screenshot.dispose) screenshot.dispose();
- }
- }
- log.debug("[狗粮分解] 所有资源已释放");
- }
-}
-
-// 6. 判断是否为狗粮资源(保留原逻辑,无修改)
-function isFoodResource(resourceName) {
- const foodKeywords = ["12h狗粮", "24h狗粮"];
- return resourceName && foodKeywords.some(keyword => resourceName.includes(keyword));
+// ===================== 狗粮模式专属函数 =====================
+// 1. 狗粮分解配置与OCR区域(保留原配置,无修改)
+const AUTO_SALVAGE_CONFIG = {
+ autoSalvage3: settings.autoSalvage3 || "是",
+ autoSalvage4: settings.autoSalvage4 || "是"
+};
+const EXP_OCRREGIONS = {
+ expStorage: { x: 1472, y: 883, width: 170, height: 34 },
+ expCount: { x: 1472, y: 895, width: 170, height: 34 }
+};
+
+// 处理数字文本:保留原逻辑(复用全局数字替换能力)
+function processNumberText(text) {
+ let correctedText = text || "";
+ let removedSymbols = [];
+
+ // 替换错误字符(依赖全局 numberReplaceMap)
+ for (const [wrong, correct] of Object.entries(numberReplaceMap)) {
+ correctedText = correctedText.replace(new RegExp(wrong, 'g'), correct);
+ }
+
+ // 提取纯数字
+ let finalText = '';
+ for (const char of correctedText) {
+ if (/[0-9]/.test(char)) {
+ finalText += char;
+ } else if (char.trim() !== '') {
+ removedSymbols.push(char);
+ }
+ }
+
+ return {
+ processedText: finalText,
+ removedSymbols: [...new Set(removedSymbols)]
+ };
+}
+
+// 4. OCR识别EXP(核心改造:用 performOcr 替代重复OCR逻辑)
+async function recognizeExpRegion(regionName, initialRa = null, timeout = 2000) {
+ // 1. 基础校验(保留原逻辑)
+ const ocrRegion = EXP_OCRREGIONS[regionName];
+ if (!ocrRegion) {
+ log.error(`[狗粮OCR] 无效区域:${regionName}`);
+ return { success: false, expCount: 0, screenshot: null }; // 新增返回截图(便于调试)
+ }
+
+ log.info(`[狗粮OCR] 识别${regionName}(x=${ocrRegion.x}, y=${ocrRegion.y})`);
+ let ocrScreenshot = null; // 存储performOcr返回的有效截图
+ let shouldDispose = false; // 标记是否需要释放截图
+
+ try {
+ // 2. 调用新版 performOcr(自动重截图、资源管理、异常处理)
+ // 目标文本传空数组:识别数字无需匹配特定文本,仅需提取内容
+ const { results, screenshot, shouldDispose: disposeFlag } = await performOcr(
+ [""], // targetTexts:空数组(数字识别无特定目标)
+ ocrRegion, // 识别区域 { x, y, width, height }
+ initialRa, // 初始截图(外部传入)
+ timeout, // 超时时间(复用原参数)
+ 500 // 重试间隔(默认500ms)
+ );
+ ocrScreenshot = screenshot; // 暂存截图,后续返回或释放
+ shouldDispose = disposeFlag; // 记录是否需要释放
+
+ // 4. 处理OCR结果(保留原数字处理+日志逻辑)
+ if (results.length > 0) {
+ log.info(`[狗粮OCR] 共识别到${results.length}个文本块`);
+ let maxExpCount = 0;
+ let bestResult = null;
+ let hasValidNumber = false;
+
+ // 遍历所有识别结果,找到最大的数字
+ for (let i = 0; i < results.length; i++) {
+ const result = results[i];
+ const { originalText, text: correctedText } = result;
+ log.info(`[狗粮OCR] [${i+1}/${results.length}] 原始文本:"${originalText}"`);
+
+ // 用原processNumberText提纯数字
+ const { processedText, removedSymbols } = processNumberText(correctedText);
+ if (removedSymbols.length > 0) {
+ log.info(`[狗粮OCR] [${i+1}/${results.length}] 去除无效字符:${removedSymbols.join(', ')}`);
+ }
+
+ const expCount = processedText ? Math.abs(parseInt(processedText, 10)) : 0;
+ log.info(`[狗粮OCR] [${i+1}/${results.length}] 提取数字:${expCount}(processedText="${processedText}")`);
+
+ if (!isNaN(expCount) && (expCount > maxExpCount || !hasValidNumber)) {
+ maxExpCount = expCount;
+ bestResult = result;
+ hasValidNumber = true;
+ log.info(`[狗粮OCR] [${i+1}/${results.length}] 更新最大值:${maxExpCount}`);
+ }
+ }
+
+ log.info(`[狗粮OCR] ${regionName}最终结果:${maxExpCount}`);
+ return { success: true, expCount: maxExpCount, screenshot: ocrScreenshot };
+ }
+
+ } catch (error) {
+ // 捕获performOcr未处理的异常(如参数错误)
+ log.error(`[狗粮OCR] ${regionName}识别异常:${error.message}`);
+ // 异常时释放截图资源(仅当需要释放时)
+ if (ocrScreenshot && shouldDispose) {
+ if (ocrScreenshot.Dispose) ocrScreenshot.Dispose();
+ else if (ocrScreenshot.dispose) ocrScreenshot.dispose();
+ }
+ }
+
+ // 5. 识别失败/超时(保留原逻辑)
+ log.error(`[狗粮OCR] ${regionName}未识别到文本,默认0`);
+ return { success: false, expCount: 0, screenshot: ocrScreenshot, shouldDispose }; // 超时也返回截图(排查用)
+}
+
+// 5. 狗粮分解流程(调整:适配recognizeExpRegion的新返回值,优化资源释放)
+async function executeSalvageWithOCR() {
+ log.info("[狗粮分解] 开始执行分解流程");
+ let storageExp = 0;
+ let countExp = 0;
+ let cachedFrame = null;
+ let ocrScreenshots = []; // 存储识别过程中产生的截图(统一释放)
+
+ try {
+ keyPress("B");
+ await sleep(1000);
+
+ const coords = [
+ [670, 40], // 打开背包
+ [660, 1010], // 打开分解
+ [300, 1020], // 打开分解选项页面
+ [200, 300, 500, AUTO_SALVAGE_CONFIG.autoSalvage3 !== '否'], // 3星(按配置)
+ [200, 380, 500, AUTO_SALVAGE_CONFIG.autoSalvage4 !== '否'], // 4星(按配置)
+ [340, 1000], // 确认选择
+ [1720, 1015], // 点击是否分解
+ [1320, 756], // 确认分解
+ [1840, 45, 1500], // 退出
+ [1840, 45], // 退出
+ [1840, 45], // 退出
+ ];
+
+ for (const coord of coords) {
+ const [x, y, delay = 1000, condition = true] = coord;
+ if (condition) {
+ click(x, y);
+ await sleep(delay);
+ log.debug(`[狗粮分解] 点击(${x},${y}),延迟${delay}ms`);
+
+ // 分解前识别储存EXP(适配新的recognizeExpRegion返回值)
+ if (x === 660 && y === 1010) {
+ cachedFrame?.dispose();
+ cachedFrame = captureGameRegion();
+ // 调用改造后的recognizeExpRegion(接收expCount、screenshot和shouldDispose)
+ const { expCount, screenshot, shouldDispose } = await recognizeExpRegion("expStorage", cachedFrame, 1000);
+ storageExp = expCount;
+ ocrScreenshots.push({ screenshot, shouldDispose }); // 收集截图和释放标记
+ }
+
+ // 分解后识别新增EXP(同上,适配新返回值)
+ if (x === 340 && y === 1000) {
+ cachedFrame?.dispose();
+ cachedFrame = captureGameRegion();
+ const { expCount, screenshot, shouldDispose } = await recognizeExpRegion("expCount", cachedFrame, 1000);
+ countExp = expCount;
+ ocrScreenshots.push({ screenshot, shouldDispose }); // 收集截图
+ }
+ }
+ }
+
+ // 计算并返回结果(保留原逻辑)
+ const totalExp = countExp - storageExp;
+ log.info(`[狗粮分解] 完成,新增EXP:${totalExp}(分解前:${storageExp},分解后:${countExp})`);
+ return { success: true, totalExp: Math.max(totalExp, 0) };
+
+ } catch (error) {
+ log.error(`[狗粮分解] 失败:${error.message}`);
+ return { success: false, totalExp: 0 };
+
+ } finally {
+ // 最终统一释放所有资源(避免内存泄漏)
+ // 1. 释放缓存帧
+ if (cachedFrame) {
+ try {
+ if (cachedFrame.Dispose) cachedFrame.Dispose();
+ else if (cachedFrame.dispose) cachedFrame.dispose();
+ } catch (e) {
+ log.debug(`[狗粮分解] 释放缓存帧失败(可能已释放): ${e.message}`);
+ }
+ }
+ // 2. 释放OCR过程中产生的截图(仅释放需要释放的)
+ for (const { screenshot, shouldDispose } of ocrScreenshots) {
+ if (screenshot && shouldDispose) {
+ try {
+ if (screenshot.Dispose) screenshot.Dispose();
+ else if (screenshot.dispose) screenshot.dispose();
+ } catch (e) {
+ log.debug(`[狗粮分解] 释放OCR截图失败(可能已释放): ${e.message}`);
+ }
+ }
+ }
+ log.debug("[狗粮分解] 所有资源已释放");
+ }
+}
+
+// 6. 判断是否为狗粮资源(保留原逻辑,无修改)
+function isFoodResource(resourceName) {
+ const foodKeywords = ["12h狗粮", "24h狗粮"];
+ return resourceName && foodKeywords.some(keyword => resourceName.includes(keyword));
}
\ No newline at end of file
diff --git a/repo/js/背包材料统计/lib/file.js b/repo/js/背包材料统计/lib/file.js
index f566018ff..352b4c64d 100644
--- a/repo/js/背包材料统计/lib/file.js
+++ b/repo/js/背包材料统计/lib/file.js
@@ -1,206 +1,288 @@
-// ======================== 全局工具函数(只定义1次,所有函数共用)========================
-// 1. 路径标准化函数(统一处理,消除重复)
-function normalizePath(path) {
- if (!path || typeof path !== 'string') return '';
- let standardPath = path.replace(/\\/g, '/').replace(/\/+/g, '/');
- return standardPath.endsWith('/') ? standardPath.slice(0, -1) : standardPath;
-}
-
-// 2. 提取路径最后一级名称
-function basename(filePath) {
- if (!filePath || typeof filePath !== 'string') return '';
- const normalizedPath = normalizePath(filePath);
- const lastSlashIndex = normalizedPath.lastIndexOf('/');
- return lastSlashIndex !== -1 ? normalizedPath.substring(lastSlashIndex + 1) : normalizedPath;
-}
-
-function pathExists(path) {
- try {
- return file.isFolder(path);
- } catch {
- return false;
- }
-}
-
-/*
-// 如果路径存在且返回的是数组,则认为是目录Folder
-function pathExists(path) {
- try { return file.readPathSync(path)?.length >= 0; }
- catch { return false; }
-}
-// 判断文件是否存在(非目录且能读取)
-function fileExists(filePath) {
- try {
- // 先排除目录(是目录则不是文件)
- if (file.isFolder(filePath)) {
- return false;
- }
- // 尝试读取文件(能读则存在)
- file.readTextSync(filePath);
- return true;
- } catch {
- return false;
- }
-}
-*/
-
-// 判断文件是否存在(基于readPathSync + 已有工具函数,不读取文件内容)
-function fileExists(filePath) {
- // 1. 基础参数校验(复用已有逻辑)
- if (!filePath || typeof filePath !== 'string') {
- log.debug(`[文件检查] 路径无效:${filePath}`);
- return false;
- }
-
- try {
- // 2. 路径标准化(复用已有normalizePath,统一分隔符)
- const normalizedFilePath = normalizePath(filePath);
-
- // 3. 拆分「文件所在目录」和「文件名」(核心步骤)
- // 3.1 提取纯文件名(复用已有basename)
- const fileName = basename(normalizedFilePath);
- // 3.2 提取文件所在的目录路径(基于标准化路径拆分)
- // 修复:当没有目录结构时,使用'.'表示当前目录,而不是'/'(避免越界访问)
- const dirPath = normalizedFilePath.lastIndexOf('/') !== -1
- ? normalizedFilePath.substring(0, normalizedFilePath.lastIndexOf('/'))
- : '.';
-
- // 4. 先判断目录是否存在
- if (!pathExists(dirPath)) {
- log.debug(`[文件检查] 文件所在目录不存在:${dirPath}`);
- return false;
- }
-
- // 5. 用readPathSync读取目录下的所有一级子项
- const dirItems = file.readPathSync(dirPath);
- if (!dirItems || dirItems.length === 0) {
- log.debug(`[文件检查] 目录为空,无目标文件:${dirPath}`);
- return false;
- }
-
- let isFileExist = false;
- for (let i = 0; i < dirItems.length; i++) {
- const item = dirItems[i];
- const normalizedItem = normalizePath(item);
- if (pathExists(normalizedItem)) {
- continue;
- }
- const itemFileName = basename(normalizedItem);
- if (normalizedItem === normalizedFilePath || itemFileName === fileName) {
- isFileExist = true;
- break;
- }
- }
-
- // 7. 日志反馈结果
- if (isFileExist) {
- // log.debug(`[文件检查] 文件存在:${filePath}`);
- } else {
- // log.debug(`[文件检查] 文件不存在:${filePath}`);
- }
- return isFileExist;
-
- } catch (error) {
- // 捕获目录读取失败等异常
- log.debug(`[文件检查] 检查失败:${filePath},错误:${error.message}`);
- return false;
- }
-}
-/**
- * 安全读取文本文件(封装存在性校验+异常处理)
- * @param {string} filePath - 文件路径
- * @param {any} defaultValue - 读取失败/文件不存在时的默认返回值(默认空字符串)
- * @returns {any} 文件内容(成功)| defaultValue(失败)
- */
-function safeReadTextSync(filePath, defaultValue = "") {
- try {
- // 第一步:校验文件是否存在
- if (!fileExists(filePath)) {
- log.debug(`${CONSTANTS.LOG_MODULES.RECORD}文件不存在,跳过读取: ${filePath}`);
- return defaultValue;
- }
- // 第二步:读取文件(捕获读取异常)
- const content = file.readTextSync(filePath);
- // log.debug(`${CONSTANTS.LOG_MODULES.RECORD}成功读取文件: ${filePath}`);
- return content;
- } catch (error) {
- log.debug(`${CONSTANTS.LOG_MODULES.RECORD}读取文件失败: ${filePath} → 原因:${error.message}`);
- return defaultValue;
- }
-}
-
-// 带深度限制的非递归文件夹读取
-function readAllFilePaths(dir, depth = 0, maxDepth = 3, includeExtensions = ['.png', '.json', '.txt'], includeDirs = false) {
- if (!pathExists(dir)) {
- log.error(`目录 ${dir} 不存在`);
- return [];
- }
-
- try {
- const filePaths = [];
- const stack = [[dir, depth]]; // 存储(路径, 当前深度)的栈
-
- while (stack.length > 0) {
- const [currentDir, currentDepth] = stack.pop();
- const entries = file.readPathSync(currentDir);
-
- for (const entry of entries) {
- const isDirectory = pathExists(entry);
- if (isDirectory) {
- if (includeDirs) filePaths.push(entry);
- if (currentDepth < maxDepth) stack.push([entry, currentDepth + 1]);
- } else {
- const ext = entry.substring(entry.lastIndexOf('.')).toLowerCase();
- if (includeExtensions.includes(ext)) filePaths.push(entry);
- }
- }
- }
- // log.info(`完成目录 ${dir} 的递归读取,共找到 ${filePaths.length} 个文件`);
- return filePaths;
- } catch (error) {
- log.error(`读取目录 ${dir} 错误: ${error}`);
- return [];
- }
-}
-// 新记录在最上面20250531 isAppend默认就是true追加
-function writeFile(filePath, content, isAppend = true, maxRecords = 36500) {
- try {
- if (isAppend) {
- // 读取现有内容,处理文件不存在的情况
- let existingContent = "";
- try {
- existingContent = safeReadTextSync(filePath);
- } catch (err) {
- // 文件不存在时视为空内容
- existingContent = "";
- }
-
- // 分割现有记录并过滤空记录
- const records = existingContent.split("\n\n").filter(Boolean);
-
- // 新内容放在最前面,形成完整记录列表
- const allRecords = [content, ...records];
-
- // 只保留最新的maxRecords条(超过则删除最老的)
- const keptRecords = allRecords.slice(0, maxRecords);
-
- // 拼接记录并写入文件
- const finalContent = keptRecords.join("\n\n");
- const result = file.writeTextSync(filePath, finalContent, false);
-
- // log.info(result ? `[追加] 成功写入: ${filePath}` : `[追加] 写入失败: ${filePath}`);
- return result;
- } else {
- // 覆盖模式直接写入
- const result = file.writeTextSync(filePath, content, false);
- // log.info(result ? `[覆盖] 成功写入: ${filePath}` : `[覆盖] 写入失败: ${filePath}`);
- return result;
- }
- } catch (error) {
- // 出错时尝试创建/写入文件
- const result = file.writeTextSync(filePath, content, false);
- log.info(result ? `[新建/恢复] 成功处理: ${filePath}` : `[新建/恢复] 处理失败: ${filePath}`);
- return result;
- }
-}
-
+// ======================== 全局工具函数(只定义1次,所有函数共用)========================
+// 1. 路径标准化函数(统一处理,消除重复)
+function normalizePath(path) {
+ if (!path || typeof path !== 'string') return '';
+ let standardPath = path.replace(/\\/g, '/').replace(/\/+/g, '/');
+ return standardPath.endsWith('/') ? standardPath.slice(0, -1) : standardPath;
+}
+
+// 2. 提取路径最后一级名称
+function basename(filePath) {
+ if (!filePath || typeof filePath !== 'string') return '';
+ const normalizedPath = normalizePath(filePath);
+ const lastSlashIndex = normalizedPath.lastIndexOf('/');
+ return lastSlashIndex !== -1 ? normalizedPath.substring(lastSlashIndex + 1) : normalizedPath;
+}
+
+function pathExists(path) {
+ try {
+ return file.isFolder(path);
+ } catch {
+ return false;
+ }
+}
+
+/*
+// 如果路径存在且返回的是数组,则认为是目录Folder
+function pathExists(path) {
+ try { return file.readPathSync(path)?.length >= 0; }
+ catch { return false; }
+}
+// 判断文件是否存在(非目录且能读取)
+function fileExists(filePath) {
+ try {
+ // 先排除目录(是目录则不是文件)
+ if (file.isFolder(filePath)) {
+ return false;
+ }
+ // 尝试读取文件(能读则存在)
+ file.readTextSync(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+*/
+
+// 判断文件是否存在(基于readPathSync + 已有工具函数,不读取文件内容)
+function fileExists(filePath) {
+ // 1. 基础参数校验(复用已有逻辑)
+ if (!filePath || typeof filePath !== 'string') {
+ log.debug(`[文件检查] 路径无效:${filePath}`);
+ return false;
+ }
+
+ try {
+ // 2. 路径标准化(复用已有normalizePath,统一分隔符)
+ const normalizedFilePath = normalizePath(filePath);
+
+ // 3. 拆分「文件所在目录」和「文件名」(核心步骤)
+ // 3.1 提取纯文件名(复用已有basename)
+ const fileName = basename(normalizedFilePath);
+ // 3.2 提取文件所在的目录路径(基于标准化路径拆分)
+ // 修复:当没有目录结构时,使用'.'表示当前目录,而不是'/'(避免越界访问)
+ const dirPath = normalizedFilePath.lastIndexOf('/') !== -1
+ ? normalizedFilePath.substring(0, normalizedFilePath.lastIndexOf('/'))
+ : '.';
+
+ // 4. 先判断目录是否存在
+ if (!pathExists(dirPath)) {
+ log.debug(`[文件检查] 文件所在目录不存在:${dirPath}`);
+ return false;
+ }
+
+ // 5. 用readPathSync读取目录下的所有一级子项
+ const dirItems = file.readPathSync(dirPath);
+ if (!dirItems || dirItems.length === 0) {
+ log.debug(`[文件检查] 目录为空,无目标文件:${dirPath}`);
+ return false;
+ }
+
+ let isFileExist = false;
+ for (let i = 0; i < dirItems.length; i++) {
+ const item = dirItems[i];
+ const normalizedItem = normalizePath(item);
+ if (pathExists(normalizedItem)) {
+ continue;
+ }
+ const itemFileName = basename(normalizedItem);
+ if (normalizedItem === normalizedFilePath || itemFileName === fileName) {
+ isFileExist = true;
+ break;
+ }
+ }
+
+ // 7. 日志反馈结果
+ if (isFileExist) {
+ // log.debug(`[文件检查] 文件存在:${filePath}`);
+ } else {
+ // log.debug(`[文件检查] 文件不存在:${filePath}`);
+ }
+ return isFileExist;
+
+ } catch (error) {
+ // 捕获目录读取失败等异常
+ log.debug(`[文件检查] 检查失败:${filePath},错误:${error.message}`);
+ return false;
+ }
+}
+/**
+ * 安全读取文本文件(封装存在性校验+异常处理)
+ * @param {string} filePath - 文件路径
+ * @param {any} defaultValue - 读取失败/文件不存在时的默认返回值(默认空字符串)
+ * @returns {any} 文件内容(成功)| defaultValue(失败)
+ */
+function safeReadTextSync(filePath, defaultValue = "") {
+ try {
+ // 第一步:校验文件是否存在
+ if (!fileExists(filePath)) {
+ log.debug(`${CONSTANTS.LOG_MODULES.RECORD}文件不存在,跳过读取: ${filePath}`);
+ return defaultValue;
+ }
+ // 第二步:读取文件(捕获读取异常)
+ const content = file.readTextSync(filePath);
+ // log.debug(`${CONSTANTS.LOG_MODULES.RECORD}成功读取文件: ${filePath}`);
+ return content;
+ } catch (error) {
+ log.debug(`${CONSTANTS.LOG_MODULES.RECORD}读取文件失败: ${filePath} → 原因:${error.message}`);
+ return defaultValue;
+ }
+}
+
+// 带深度限制的非递归文件夹读取
+function readAllFilePaths(dir, depth = 0, maxDepth = 6, includeExtensions = ['.png', '.json', '.txt'], includeDirs = false) {
+ if (!pathExists(dir)) {
+ log.error(`目录 ${dir} 不存在`);
+ return [];
+ }
+
+ try {
+ const filePaths = [];
+ const stack = [[dir, depth]]; // 存储(路径, 当前深度)的栈
+
+ while (stack.length > 0) {
+ const [currentDir, currentDepth] = stack.pop();
+ const entries = file.readPathSync(currentDir);
+
+ for (const entry of entries) {
+ const isDirectory = pathExists(entry);
+ if (isDirectory) {
+ if (includeDirs) filePaths.push(entry);
+ if (currentDepth < maxDepth) stack.push([entry, currentDepth + 1]);
+ } else {
+ const ext = entry.substring(entry.lastIndexOf('.')).toLowerCase();
+ if (includeExtensions.includes(ext)) filePaths.push(entry);
+ }
+ }
+ }
+ // log.info(`完成目录 ${dir} 的递归读取,共找到 ${filePaths.length} 个文件`);
+ return filePaths;
+ } catch (error) {
+ log.error(`读取目录 ${dir} 错误: ${error}`);
+ return [];
+ }
+}
+
+// ==============================================
+// 全局图片缓存(避免重复加载)
+// ==============================================
+const globalImageCache = new Map();
+
+/**
+ * 获取缓存的图像Mat(避免重复加载)
+ * @param {string} filePath - 图像文件路径
+ * @returns {Mat} 图像Mat对象
+ */
+function getCachedImageMat(filePath) {
+ if (globalImageCache.has(filePath)) {
+ return globalImageCache.get(filePath);
+ }
+ const mat = file.readImageMatSync(filePath);
+ if (!mat.empty()) {
+ globalImageCache.set(filePath, mat);
+ }
+ return mat;
+}
+
+// ==============================================
+// 图像分类映射
+// ==============================================
+const imageMapCache = new Map(); // 图像分类映射缓存
+const loggedResources = new Set(); // 已记录的资源名
+
+/**
+ * 创建图像分类映射(目录到分类的映射)
+ * @param {string} imagesDir - 图像目录
+ * @returns {Object} 图像名到分类的映射
+ */
+const createImageCategoryMap = (imagesDir) => {
+ const map = {};
+ const imageFiles = readAllFilePaths(imagesDir, 0, 1, ['.png']);
+
+ for (const imagePath of imageFiles) {
+ const pathParts = imagePath.split(/[\\/]/);
+ if (pathParts.length < 3) continue;
+
+ const imageName = pathParts.pop()
+ .replace(/\.png$/i, '')
+ .trim()
+ .toLowerCase();
+
+ if (!(imageName in map)) {
+ map[imageName] = pathParts[2];
+ }
+ }
+
+ return map;
+};
+
+/**
+ * 匹配图像并获取材料分类
+ * @param {string} resourceName - 资源名
+ * @param {string} imagesDir - 图像目录
+ * @returns {string|null} 材料分类(null=未匹配)
+ */
+function matchImageAndGetCategory(resourceName, imagesDir) {
+ const processedName = (MATERIAL_ALIAS[resourceName] || resourceName).toLowerCase();
+
+ if (!imageMapCache.has(imagesDir)) {
+ log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}初始化图像分类缓存:${imagesDir}`);
+ imageMapCache.set(imagesDir, createImageCategoryMap(imagesDir));
+ }
+
+ const result = imageMapCache.get(imagesDir)[processedName] ?? null;
+ if (result) {
+ // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}匹配分类:${result}`);
+ } else {
+ // log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}资源${resourceName}未匹配到分类`);
+ }
+
+ if (!loggedResources.has(processedName)) {
+ loggedResources.add(processedName);
+ }
+
+ return result;
+}
+
+// 新记录在最上面20250531 isAppend默认就是true追加
+function writeFile(filePath, content, isAppend = true, maxRecords = 36500) {
+ try {
+ if (isAppend) {
+ // 读取现有内容,处理文件不存在的情况
+ let existingContent = "";
+ try {
+ existingContent = safeReadTextSync(filePath);
+ } catch (err) {
+ // 文件不存在时视为空内容
+ existingContent = "";
+ }
+
+ // 分割现有记录并过滤空记录
+ const records = existingContent.split("\n\n").filter(Boolean);
+
+ // 新内容放在最前面,形成完整记录列表
+ const allRecords = [content, ...records];
+
+ // 只保留最新的maxRecords条(超过则删除最老的)
+ const keptRecords = allRecords.slice(0, maxRecords);
+
+ // 拼接记录并写入文件
+ const finalContent = keptRecords.join("\n\n");
+ const result = file.writeTextSync(filePath, finalContent, false);
+
+ // log.info(result ? `[追加] 成功写入: ${filePath}` : `[追加] 写入失败: ${filePath}`);
+ return result;
+ } else {
+ // 覆盖模式直接写入
+ const result = file.writeTextSync(filePath, content, false);
+ // log.info(result ? `[覆盖] 成功写入: ${filePath}` : `[覆盖] 写入失败: ${filePath}`);
+ return result;
+ }
+ } catch (error) {
+ // 出错时尝试创建/写入文件
+ const result = file.writeTextSync(filePath, content, false);
+ log.info(result ? `[新建/恢复] 成功处理: ${filePath}` : `[新建/恢复] 处理失败: ${filePath}`);
+ return result;
+ }
+}
+
diff --git a/repo/js/背包材料统计/lib/imageClick.js b/repo/js/背包材料统计/lib/imageClick.js
index eb019618c..603e836b0 100644
--- a/repo/js/背包材料统计/lib/imageClick.js
+++ b/repo/js/背包材料统计/lib/imageClick.js
@@ -4,7 +4,7 @@ async function preloadImageResources(specificNames) {
function hasIconFolder(dirPath) {
try {
- const entries = readAllFilePaths(dirPath, 0, 0, [], true);
+ const entries = readAllFilePaths(dirPath, 0, 0, [], true);
return entries.some(entry => normalizePath(entry).endsWith('/icon'));
} catch (e) {
log.error(`检查目录【${dirPath}】是否有icon文件夹失败:${e.message}`);
@@ -24,7 +24,7 @@ async function preloadImageResources(specificNames) {
const targetDirs = subDirs.filter(subDir => {
const dirName = basename(subDir);
- const hasIcon = hasIconFolder(subDir);
+ const hasIcon = hasIconFolder(subDir);
const matchName = isAll ? true : preSpecificNames.includes(dirName);
return hasIcon && matchName;
});
@@ -62,14 +62,14 @@ async function preloadImageResources(specificNames) {
try {
const configContent = safeReadTextSync(configPath);
popupConfig = { ...popupConfig, ...JSON.parse(configContent) };
- isSpecialModule = popupConfig.isSpecial === true
- && typeof popupConfig.detectRegion === 'object'
- && popupConfig.detectRegion !== null
- && popupConfig.detectRegion.x != null
- && popupConfig.detectRegion.y != null
- && popupConfig.detectRegion.width != null
- && popupConfig.detectRegion.height != null
- && popupConfig.detectRegion.width > 0
+ isSpecialModule = popupConfig.isSpecial === true
+ && typeof popupConfig.detectRegion === 'object'
+ && popupConfig.detectRegion !== null
+ && popupConfig.detectRegion.x != null
+ && popupConfig.detectRegion.y != null
+ && popupConfig.detectRegion.width != null
+ && popupConfig.detectRegion.height != null
+ && popupConfig.detectRegion.width > 0
&& popupConfig.detectRegion.height > 0;
specialDetectRegion = isSpecialModule ? popupConfig.detectRegion : null;
// log.info(`【${dirName}】加载配置成功:${isFirstLevel ? '第一级' : '第二级'} | 模块类型:${isSpecialModule ? '特殊模块' : '普通模块'}`);
@@ -83,7 +83,7 @@ async function preloadImageResources(specificNames) {
const entries = readAllFilePaths(subDir, 0, 1, [], true);
const iconDir = entries.find(entry => normalizePath(entry).endsWith('/icon'));
const iconFilePaths = readAllFilePaths(iconDir, 0, 0, ['.png', '.jpg', '.jpeg']);
-
+
if (iconFilePaths.length === 0) {
log.warn(`【${dirName}】特殊模块无有效icon文件,跳过`);
continue;
@@ -96,7 +96,7 @@ async function preloadImageResources(specificNames) {
log.error(`【${dirName}】特殊模块加载图标失败:${filePath}`);
continue;
}
- iconRecognitionObjects.push({
+ iconRecognitionObjects.push({
name: basename(filePath),
ro: RecognitionObject.TemplateMatch(mat, 0, 0, 1920, 1080),
iconDir,
@@ -131,7 +131,7 @@ async function preloadImageResources(specificNames) {
const entries = readAllFilePaths(subDir, 0, 1, [], true);
const iconDir = entries.find(entry => normalizePath(entry).endsWith('/icon'));
const pictureDir = entries.find(entry => normalizePath(entry).endsWith('/Picture'));
-
+
if (!pictureDir) {
log.warn(`【${dirName}】普通模块无Picture文件夹,跳过`);
continue;
@@ -139,7 +139,7 @@ async function preloadImageResources(specificNames) {
const iconFilePaths = readAllFilePaths(iconDir, 0, 0, ['.png', '.jpg', '.jpeg']);
const pictureFilePaths = readAllFilePaths(pictureDir, 0, 0, ['.png', '.jpg', '.jpeg']);
-
+
// 仅在资源为空时警告
if (iconFilePaths.length === 0) {
log.warn(`【${dirName}】普通模块无有效icon文件,跳过`);
@@ -157,10 +157,10 @@ async function preloadImageResources(specificNames) {
log.error(`【${dirName}】加载图标失败:${filePath}`);
continue;
}
- iconRecognitionObjects.push({
+ iconRecognitionObjects.push({
name: basename(filePath),
ro: RecognitionObject.TemplateMatch(mat, 0, 0, 1920, 1080),
- iconDir
+ iconDir
});
}
@@ -171,9 +171,9 @@ async function preloadImageResources(specificNames) {
log.error(`【${dirName}】加载图库图片失败:${filePath}`);
continue;
}
- pictureRegions.push({
+ pictureRegions.push({
name: basename(filePath),
- region: new ImageRegion(mat, 0, 0)
+ region: new ImageRegion(mat, 0, 0)
});
}
@@ -216,7 +216,7 @@ async function imageClickBackgroundTask() {
log.info("imageClick后台任务已启动");
// 配置参数
- const taskDelay = Math.min(999, Math.max(1, Math.floor(Number(settings.PopupClickDelay) || 15))) * 1000;
+ const taskDelay = Math.min(999, Math.max(1, Math.floor(Number(settings.PopupClickDelay) || 15)))*1000;
let specificNames = [];
try {
specificNames = Array.from(settings.PopupNames || []);
@@ -265,10 +265,10 @@ async function imageClickBackgroundTask() {
// 打印资源检测结果
log.info("\n==================== 现有弹窗加载结果 ====================");
log.info("1. 一级弹窗(共" + firstLevelDirs.length + "个):");
- firstLevelDirs.forEach((res, idx) => log.info(` ${idx + 1}. 【${res.dirName}】`));
+ firstLevelDirs.forEach((res, idx) => log.info(` ${idx+1}. 【${res.dirName}】`));
const secondLevelResources = preloadedResources.filter(res => !res.isFirstLevel);
log.info("\n2. 二级弹窗(共" + secondLevelResources.length + "个):");
- secondLevelResources.forEach((res, idx) => log.info(` ${idx + 1}. 【${res.dirName}】`));
+ secondLevelResources.forEach((res, idx) => log.info(` ${idx+1}. 【${res.dirName}】`));
log.info("=============================================================\n");
// 核心逻辑:外循环遍历所有一级弹窗
@@ -327,7 +327,7 @@ async function imageClickBackgroundTask() {
// log.info(`===== 外循环结束:等待${taskDelay/1000}秒后开始下一次循环 =====`);
await sleep(taskDelay);
}
-
+
log.info("imageClick后台任务结束");
return { success: true };
}
@@ -348,14 +348,14 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u
for (const foundRegion of foundRegions) {
const tolerance = 1;
const iconMat = file.readImageMatSync(`${normalizePath(foundRegion.iconDir)}/${foundRegion.iconName}`);
-
+
const { detectRegion } = popupConfig;
const defaultX = foundRegion.region.x - tolerance;
const defaultY = foundRegion.region.y - tolerance;
const defaultWidth = foundRegion.region.width + 2 * tolerance;
const defaultHeight = foundRegion.region.height + 2 * tolerance;
const recognitionObject = RecognitionObject.TemplateMatch(
- iconMat,
+ iconMat,
detectRegion?.x ?? defaultX,
detectRegion?.y ?? defaultY,
detectRegion?.width ?? defaultWidth,
@@ -372,7 +372,7 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u
useNewScreenshot,
dirName
);
-
+
if (result.isDetected && result.x !== 0 && result.y !== 0) {
hasAnyIconDetected = true;
isAnySuccess = true;
@@ -400,7 +400,7 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u
const pressDelay = popupConfig.loopDelay || 1000;
for (let i = 0; i < pressCount; i++) {
keyPress(targetKey); // 保留原始按键逻辑
- log.info(`【${dirName}】弹窗触发按键【${targetKey}】${i + 1}次`);
+ log.info(`【${dirName}】弹窗触发按键【${targetKey}】${i+1}次`);
if (i < pressCount - 1) await sleep(pressDelay); // 非最后一次加间隔
}
log.info(`【${dirName}】弹窗触发按键【${targetKey}】,共${pressCount}次,间隔${pressDelay}ms`);
@@ -413,10 +413,16 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u
log.error(`【${dirName}】弹窗OCR配置不全,跳过`);
break;
}
- const ocrResults = await performOcr(targetTexts, xRange, yRange, timeout, ra);
+ const region = {
+ x: xRange.min,
+ y: yRange.min,
+ width: xRange.max - xRange.min,
+ height: yRange.max - yRange.min
+ };
+ const { results: ocrResults } = await performOcr(targetTexts, region, ra, timeout);
if (ocrResults.length > 0) {
- const ocrActualX = Math.round(ocrResults[0].x + ocrResults[0].width / 2) + xOffset;
- const ocrActualY = Math.round(ocrResults[0].y + ocrResults[0].height / 2) + yOffset;
+ const ocrActualX = Math.round(ocrResults[0].x + ocrResults[0].width/2) + xOffset;
+ const ocrActualY = Math.round(ocrResults[0].y + ocrResults[0].height/2) + yOffset;
// 新增:OCR点击加循环(默认1次,0间隔,与原逻辑一致)
const ocrCount = popupConfig.loopCount;
const ocrDelay = popupConfig.loopDelay;
@@ -466,7 +472,7 @@ async function imageClick(preloadedResources, ra = null, specificNames = null, u
const defaultDelay = popupConfig.loopDelay;
for (let i = 0; i < defaultCount; i++) {
await click(actualX, actualY); // 保留原始默认点击逻辑
- log.info(`点击【${dirName}】弹窗:(${actualX}, ${actualY})${i + 1}次`);
+ log.info(`点击【${dirName}】弹窗:(${actualX}, ${actualY})${i+1}次`);
if (i < defaultCount - 1) await sleep(defaultDelay); // 非最后一次加间隔
}
isAnySuccess = true;
diff --git a/repo/js/背包材料统计/lib/ocr.js b/repo/js/背包材料统计/lib/ocr.js
index d8f0db10e..1774c1938 100644
--- a/repo/js/背包材料统计/lib/ocr.js
+++ b/repo/js/背包材料统计/lib/ocr.js
@@ -5,38 +5,104 @@ const replacementMap = {
"盞": "盏",
"攜": "携",
"於": "于",
- "卵": "卯"
+ "卵": "卯",
+ "亥": "刻",
+ "脈": "脉",
+ "黄": "夤",
+ "黃": "夤",
+ "问": "间",
+ "谭": "镡"
};
+/**
+ * 计算两个字符串的相似度(基于最长公共子序列)
+ * @param {string} str1 - 字符串1
+ * @param {string} str2 - 字符串2
+ * @returns {number} 相似度(0-1之间的小数)
+ */
+function calculateSimilarity(str1, str2) {
+ if (!str1 || !str2) return 0;
+
+ const len1 = str1.length;
+ const len2 = str2.length;
+
+ // 动态规划计算最长公共子序列长度
+ const dp = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));
+
+ for (let i = 1; i <= len1; i++) {
+ for (let j = 1; j <= len2; j++) {
+ if (str1[i - 1] === str2[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ const lcsLength = dp[len1][len2];
+ const maxLength = Math.max(len1, len2);
+
+ return maxLength === 0 ? 0 : lcsLength / maxLength;
+}
+
+/**
+ * 保存OCR无法识别的区域截图
+ * @param {Object} screenshot - 截图对象
+ * @param {Object} xRangeOrRegion - X轴范围 { min, max } 或区域 { x, y, width, height }
+ * @param {Object} yRange - Y轴范围 { min, max }(可选,如果xRangeOrRegion是区域则不需要)
+ * @param {string} saveDir - 保存目录,默认 'user/regions'
+ */
+async function saveFailedOcrRegion(screenshot, xRangeOrRegion, yRange = null, saveDir = 'user/regions') {
+ log.info(`[saveFailedOcrRegion] 开始保存, screenshot: ${!!screenshot}`);
+ if (!screenshot) {
+ log.warn("[保存失败OCR] 截图为空,跳过保存");
+ return;
+ }
+
+ let region;
+ if (yRange === null) {
+ region = xRangeOrRegion;
+ } else {
+ region = {
+ x: xRangeOrRegion.min,
+ y: yRange.min,
+ width: xRangeOrRegion.max - xRangeOrRegion.min,
+ height: yRange.max - yRange.min
+ };
+ }
+
+ log.info(`[saveFailedOcrRegion] region: ${JSON.stringify(region)}`);
+ await saveAllOcrRegionImages(screenshot, [region], {}, saveDir);
+}
+
/**
* 执行OCR识别并匹配目标文本(失败自动重截图,返回结果+有效截图)
* @param {string[]} targetTexts - 待匹配的目标文本列表
- * @param {Object} xRange - X轴范围 { min: number, max: number }
- * @param {Object} yRange - Y轴范围 { min: number, max: number }
+ * @param {Object} region - 识别区域 { x: number, y: number, width: number, height: number }
* @param {Object} ra - 初始图像捕获对象(外部传入,需确保已初始化)
* @param {number} timeout - 超时时间(毫秒),默认200ms
* @param {number} interval - 重试间隔(毫秒),默认50ms
* @returns {Promise<{
* results: Object[], // 识别结果数组(含文本、坐标)
- * screenshot: Object // 有效截图(成功时用的截图/超时前最后一次截图)
+ * screenshot: Object, // 有效截图(成功时用的截图/超时前最后一次截图)
+ * shouldDispose: boolean // 是否需要释放此截图(true=需要释放,false=外部传入的截图)
* }>}
*/
-async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200, interval = 50) {
+async function performOcr(targetTexts, region, ra = null, timeout = 200, interval = 50) {
// 正则特殊字符转义工具函数(避免替换时的正则语法错误)
const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const startTime = Date.now(); // 记录开始时间,用于超时判断
let currentScreenshot = ra; // 跟踪当前有效截图(初始为外部传入的原图)
+ let isExternalRa = true; // 标记当前截图是否为外部传入(不释放外部资源)
// 1. 初始参数校验(提前拦截无效输入)
if (!currentScreenshot) {
throw new Error("初始图像捕获对象(ra)未初始化,请传入有效截图");
}
- const regionWidth = xRange.max - xRange.min;
- const regionHeight = yRange.max - yRange.min;
- if (regionWidth <= 0 || regionHeight <= 0) {
- throw new Error(`无效的识别区域:宽=${regionWidth}, 高=${regionHeight}`);
+ if (region.width <= 0 || region.height <= 0) {
+ throw new Error(`无效的识别区域:宽=${region.width}, 高=${region.height}`);
}
// 在超时时间内循环重试识别(处理临时失败,自动重截图)
@@ -45,6 +111,7 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200,
if (!currentScreenshot) {
log.error("currentScreenshot为null,尝试重新捕获");
currentScreenshot = captureGameRegion();
+ isExternalRa = false; // 重新捕获的截图不是外部传入的
await sleep(interval);
continue;
}
@@ -52,9 +119,11 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200,
try {
// 2. 执行OCR识别(基于当前有效截图的指定区域)
const resList = currentScreenshot.findMulti(
- RecognitionObject.ocr(xRange.min, yRange.min, regionWidth, regionHeight)
+ RecognitionObject.ocr(region.x, region.y, region.width, region.height)
);
+ // log.debug(`OCR识别到${resList.count}个文本块`);
+
// 3. 处理识别结果(文本修正 + 目标匹配)
const results = [];
for (let i = 0; i < resList.count; i++) {
@@ -69,10 +138,22 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200,
correctedText = correctedText.replace(new RegExp(escapedWrong, 'g'), correctChar);
}
- // 3.2 匹配目标文本(双向匹配,避免重复添加同一结果)
- const isTargetMatched = targetTexts.some(target =>
- correctedText.includes(target) || target.includes(correctedText)
- );
+ // 3.2 匹配目标文本(双向匹配 + 相似度特殊通道)
+ const isTargetMatched = targetTexts.length === 0 || targetTexts.some(target => {
+ // 通道1:双向匹配(要求至少2个字符)
+ if (correctedText.length >= 2 && (correctedText.includes(target) || target.includes(correctedText))) {
+ return true;
+ }
+
+ // 通道2:相似度大于75%(特殊通道)
+ const similarity = calculateSimilarity(correctedText, target);
+ if (similarity >= 0.75) {
+ log.debug(`相似度匹配: "${correctedText}" vs "${target}" = ${(similarity * 100).toFixed(1)}%`);
+ return true;
+ }
+
+ return false;
+ });
if (isTargetMatched) {
results.push({
text: correctedText, // 最终修正后的文本
@@ -83,31 +164,39 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200,
}
}
+ // log.debug(`OCR最终返回${results.length}个匹配结果`);
+
// 4. 识别成功:返回「结果数组 + 本次成功用的截图」
// log.info(`OCR识别完成,匹配到${results.length}个目标文本`);
return {
results: results,
- screenshot: currentScreenshot // 成功截图(与结果对应的有效画面)
+ screenshot: currentScreenshot, // 成功截图(与结果对应的有效画面)
+ shouldDispose: !isExternalRa // 标记是否需要释放(true=需要释放,false=外部传入的截图)
};
} catch (error) {
// 5. 识别失败:释放旧截图→重新捕获→更新当前截图
- if (currentScreenshot) {
- // 检查是否存在释放方法,支持不同可能的命名
- if (typeof currentScreenshot.Dispose === 'function') {
- currentScreenshot.Dispose();
- } else if (typeof currentScreenshot.dispose === 'function') {
- currentScreenshot.dispose();
+ if (currentScreenshot && !isExternalRa) {
+ // 只释放非外部传入的截图
+ try {
+ if (typeof currentScreenshot.Dispose === 'function') {
+ currentScreenshot.Dispose();
+ } else if (typeof currentScreenshot.dispose === 'function') {
+ currentScreenshot.dispose();
+ }
+ log.debug("已释放旧截图资源,准备重新捕获");
+ } catch (e) {
+ log.debug(`释放旧截图失败(可能已释放): ${e.message}`);
}
- log.debug("已释放旧截图资源,准备重新捕获");
}
-
+
// 重新捕获后增加null校验
currentScreenshot = captureGameRegion();
+ isExternalRa = false; // 重新捕获的截图不是外部传入的
if (!currentScreenshot) {
log.error("重新捕获截图失败,返回了null值");
}
-
+
log.error(`OCR识别异常(已重新截图,将重试): ${error.message}`);
await sleep(5); // 短暂等待,避免高频截图占用CPU/内存
}
@@ -117,8 +206,12 @@ async function performOcr(targetTexts, xRange, yRange, ra = null, timeout = 200,
// 6. 超时未成功:返回「空结果 + 超时前最后一次截图」
log.warn(`OCR识别超时(超过${timeout}ms)`);
+ log.info(`[OCR] 准备保存失败区域截图, screenshot: ${!!currentScreenshot}, region: ${JSON.stringify(region)}`);
+ await saveFailedOcrRegion(currentScreenshot, region);
+ log.info(`[OCR] 保存失败区域截图完成`);
return {
results: [],
- screenshot: currentScreenshot // 超时前最后一次有效截图(可用于排查原因)
+ screenshot: currentScreenshot, // 超时前最后一次有效截图(可用于排查原因)
+ shouldDispose: !isExternalRa // 标记是否需要释放
};
}
diff --git a/repo/js/背包材料统计/lib/pathProcessor.js b/repo/js/背包材料统计/lib/pathProcessor.js
new file mode 100644
index 000000000..78d003e21
--- /dev/null
+++ b/repo/js/背包材料统计/lib/pathProcessor.js
@@ -0,0 +1,827 @@
+// ==============================================
+// 路径处理模块
+// ==============================================
+
+/**
+ * 极简封装:用路径和当前目标发通知,然后执行路径
+ * @param {string} pathingFilePath - 路径文件路径
+ * @param {string} currentMaterialName - 当前材料名
+ * @param {number} estimatedTime - 预计耗时(秒)
+ * @returns {Promise} 执行结果
+ */
+async function runPathAndNotify(pathingFilePath, currentMaterialName, estimatedTime = null) {
+ const pathName = basename(pathingFilePath);
+ if (notify) {
+ let notifyMsg = `当前执行路径:${pathName}\n目标:${currentMaterialName || '未知'}`;
+ if (estimatedTime !== null) {
+ notifyMsg += `\n预计耗时:${formatTime(estimatedTime)}`;
+ }
+ notification.Send(notifyMsg);
+ }
+ return await pathingScript.runFile(pathingFilePath);
+}
+
+const MIN_RECORD_TIME = 15;
+
+function findRefreshCD(resourceKey, CDCategories) {
+ for (const [categoryName, cdInfo] of Object.entries(CDCategories)) {
+ if (allowedCDCategories.length > 0 && !allowedCDCategories.includes(categoryName)) continue;
+ for (const [cdKey, cdItems] of Object.entries(cdInfo)) {
+ if (cdItems.includes(resourceKey)) {
+ return JSON.parse(cdKey);
+ }
+ }
+ }
+ return null;
+}
+
+async function executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime, logModule) {
+ const startTime = new Date().toLocaleString();
+ log.info(`→ 开始执行地图追踪任务: "${pathName}"`);
+ await runPathAndNotify(pathingFilePath, currentMaterialName, estimatedTime);
+ const endTime = new Date().toLocaleString();
+ const runTime = (new Date(endTime) - new Date(startTime)) / 1000;
+ log.info(`→ 脚本执行结束: "${pathName}", 耗时: ${formatTime(runTime)}`);
+ return { startTime, endTime, runTime };
+}
+
+function generateRecordContent(pathingFilePath, pathName, startTime, endTime, runTime, changes, contentCode = null) {
+ const code = contentCode || (pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000");
+ const changeStr = typeof changes === 'string' ? changes : JSON.stringify(changes);
+ return `路径名: ${pathName}\n内容检测码: ${code}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${changeStr}\n\n`;
+}
+
+async function handlePathError(error, pathingFilePath, pathName, resourceKey, startTime, endTime, runTime, shouldSkipRecord, isCancelled) {
+ const canRecord = runTime > MIN_RECORD_TIME;
+
+ if (!canRecord) {
+ return;
+ }
+
+ if (isCancelled || !shouldSkipRecord) {
+ const content = generateRecordContent(
+ pathingFilePath,
+ pathName,
+ startTime,
+ endTime,
+ runTime,
+ error.message
+ );
+ writeContentToFile(`${CONSTANTS.NO_RECORD_DIR}/${resourceKey}.txt`, content);
+ log.info(`${CONSTANTS.LOG_MODULES.RECORD}异常路径已记录:${pathName}(运行时间${runTime.toFixed(1)}秒)`);
+ }
+}
+
+function checkPathSkipConditions(canRunCD, pathCheckResult, isTimeCostOk, perTime, estimatedTime, pathName, logModule) {
+ if (!canRunCD) {
+ log.info(`${logModule}${pathName} 跳过 | CD未刷新 | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`);
+ return true;
+ }
+ if (!(pathCheckResult === true || pathCheckResult.valid)) {
+ log.info(`${logModule}${pathName} 跳过 | ${pathCheckResult.reason || '0记录频率超限'} | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`);
+ return true;
+ }
+ if (!isTimeCostOk) {
+ log.info(`${logModule}${pathName} 跳过 | 时间成本不合格 | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`);
+ return true;
+ }
+ return false;
+}
+
+/**
+ * 处理狗粮路径条目(完整校验:CD+时间成本+频率+运行时间+距离)
+ * @param {Object} entry - 路径条目 { path, resourceName }
+ * @param {Object} accumulators - 累加器 { foodExpAccumulator, currentMaterialName }
+ * @param {string} recordDir - 记录目录
+ * @param {string} noRecordDir - 无记录目录
+ * @param {Object} CDCategories - CD分类配置
+ * @param {number} timeCost - 时间成本阈值
+ * @param {Object} pathRecordCache - 记录缓存
+ * @returns {Object} 更新后的累加器
+ */
+async function processFoodPathEntry(entry, accumulators, recordDir, noRecordDir, CDCategories, timeCost, pathRecordCache, timeCostStats) {
+ const { path: pathingFilePath, resourceName } = entry;
+ const pathName = basename(pathingFilePath);
+ const { foodExpAccumulator, currentMaterialName: prevMaterialName } = accumulators;
+
+ let startTime = null;
+ let runTime = 0;
+
+ try {
+ const refreshCD = findRefreshCD(resourceName, CDCategories);
+ if (!refreshCD) {
+ log.debug(`${CONSTANTS.LOG_MODULES.CD}狗粮材料【${resourceName}】未找到CD配置,跳过路径:${pathName}`);
+ await sleep(1);
+ return accumulators;
+ }
+
+ const pathCheckResult = checkPathNameFrequency(resourceName, pathName, recordDir, true, pathingFilePath);
+ if (pathCheckResult !== true && !pathCheckResult.valid) {
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}狗粮路径${pathName} 跳过 | ${pathCheckResult.reason}`);
+ await sleep(1);
+ return accumulators;
+ }
+
+ const currentTime = getCurrentTimeInHours();
+ const lastEndTime = getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath);
+ const perTime = noRecord ? null : calculatePerTime(
+ resourceName,
+ pathName,
+ recordDir,
+ noRecordDir,
+ true,
+ pathRecordCache,
+ pathingFilePath
+ );
+
+ const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, resourceName }, recordDir, noRecordDir);
+ const canRunCD = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName);
+ const isTimeCostOk = noRecord || perTime === null || isTimeCostQualified(perTime, resourceName, timeCost, timeCostStats);
+
+ if (checkPathSkipConditions(canRunCD, pathCheckResult, isTimeCostOk, perTime, estimatedTime, pathName, `${CONSTANTS.LOG_MODULES.PATH}狗粮路径`)) {
+ await sleep(1);
+ return accumulators;
+ }
+
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}狗粮路径${pathName} 执行 | 时间成本:${perTime ?? '忽略'}秒/EXP | 预计耗时:${estimatedTime}秒`);
+
+ let currentMaterialName = prevMaterialName;
+ if (currentMaterialName !== resourceName) {
+ if (prevMaterialName && foodExpAccumulator[prevMaterialName]) {
+ const prevMsg = `材料[${prevMaterialName}]收集完成,累计EXP:${foodExpAccumulator[prevMaterialName]}`;
+ await sendNotificationInChunks(prevMsg, notification.Send);
+ }
+ currentMaterialName = resourceName;
+ foodExpAccumulator[resourceName] = 0;
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}切换至狗粮材料【${resourceName}】`);
+ }
+
+ const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime);
+ startTime = timing.startTime;
+ runTime = timing.runTime;
+
+ const { success, totalExp } = await executeSalvageWithOCR();
+ foodExpAccumulator[resourceName] += totalExp;
+
+ const foodRecordContent = generateRecordContent(pathingFilePath, pathName, startTime, timing.endTime, runTime, { "EXP": totalExp });
+ const recordDirFinal = noRecord ? noRecordDir : recordDir;
+
+ const canRecord = runTime > MIN_RECORD_TIME;
+ if (canRecord && !state.cancelRequested) {
+ if (totalExp === 0) {
+ const zeroExpFilePath = `${recordDirFinal}/${resourceName}${CONSTANTS.FOOD_ZERO_EXP_SUFFIX}`;
+ writeContentToFile(zeroExpFilePath, foodRecordContent);
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName} EXP=0,写入0记录文件:${zeroExpFilePath}`);
+ } else {
+ const normalExpFilePath = `${recordDirFinal}/${resourceName}${CONSTANTS.FOOD_EXP_RECORD_SUFFIX}`;
+ writeContentToFile(normalExpFilePath, foodRecordContent);
+ const foodMsg = `狗粮路径【${pathName}】执行完成\n耗时:${runTime.toFixed(1)}秒\n本次EXP:${totalExp}\n累计EXP:${foodExpAccumulator[resourceName]}`;
+ await sendNotificationInChunks(foodMsg, notification.Send);
+ }
+ } else {
+ if (state.cancelRequested) {
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName}手动终止,跳过记录`);
+ } else {
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}狗粮路径${pathName} 不满足记录条件:运行时间${runTime.toFixed(1)}秒(需>${MIN_RECORD_TIME}秒)`);
+ }
+ }
+
+ await sleep(1);
+ return { ...accumulators, foodExpAccumulator, currentMaterialName };
+ } catch (error) {
+ if (startTime) {
+ const endTime = new Date().toLocaleString();
+ runTime = (new Date(endTime) - new Date(startTime)) / 1000;
+ await handlePathError(error, pathingFilePath, pathName, resourceName, startTime, endTime, runTime, noRecord, state.cancelRequested);
+ }
+ throw error;
+ }
+}
+
+/**
+ * 处理怪物路径条目
+ * @param {Object} entry - 路径条目 { path, monsterName, resourceName }
+ * @param {Object} context - 上下文 { CDCategories, timeCost, recordDir, noRecordDir, imagesDir, ... }
+ * @returns {Object} 更新后的上下文
+ */
+async function processMonsterPathEntry(entry, context) {
+ const { path: pathingFilePath, monsterName } = entry;
+ const pathName = basename(pathingFilePath);
+ const {
+ CDCategories, timeCost, recordDir, noRecordDir, imagesDir,
+ materialCategoryMap, flattenedLowCountMaterials,
+ currentMaterialName: prevMaterialName,
+ materialAccumulatedDifferences, globalAccumulatedDifferences,
+ pathRecordCache, timeCostStats
+ } = context;
+
+ let startTime = null;
+ let runTime = 0;
+ let shouldRunAsNoRecord = false;
+
+ try {
+ const monsterMaterials = monsterToMaterials[monsterName] || [];
+ const allExcess = monsterMaterials.every(mat => excessMaterialNames.includes(mat));
+ if (allExcess) {
+ log.warn(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${monsterName}】所有材料已超量,跳过路径:${pathName}`);
+ await sleep(1);
+ return context;
+ }
+
+ const refreshCD = findRefreshCD(monsterName, CDCategories);
+ if (!refreshCD) {
+ log.debug(`${CONSTANTS.LOG_MODULES.MONSTER}怪物【${monsterName}】未找到CD配置,跳过路径:${pathName}`);
+ await sleep(1);
+ return context;
+ }
+
+ const currentTime = getCurrentTimeInHours();
+ const lastEndTime = getLastRunEndTime(monsterName, pathName, recordDir, noRecordDir, pathingFilePath);
+ const pathCheckResult = checkPathNameFrequency(monsterName, pathName, recordDir, false, pathingFilePath);
+ const perTime = noRecord ? null : calculatePerTime(
+ monsterName,
+ pathName,
+ recordDir,
+ noRecordDir,
+ false,
+ pathRecordCache,
+ pathingFilePath
+ );
+
+ const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, monsterName }, recordDir, noRecordDir);
+ const canRunCD = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName);
+ const isTimeCostOk = noRecord || perTime === null || isTimeCostQualified(perTime, monsterName, timeCost, timeCostStats);
+
+ if (checkPathSkipConditions(canRunCD, pathCheckResult, isTimeCostOk, perTime, estimatedTime, pathName, `${CONSTANTS.LOG_MODULES.PATH}怪物路径`)) {
+ await sleep(1);
+ return context;
+ }
+
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}怪物路径${pathName} 执行 | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`);
+
+ const resourceCategoryMap = {};
+ const materials = monsterToMaterials[monsterName] || [];
+
+ ocrContext.currentPathType = 'monster';
+ ocrContext.currentTargetMaterials = materials;
+
+ materials.forEach(mat => {
+ const category = matchImageAndGetCategory(mat, imagesDir);
+ if (category) {
+ if (!resourceCategoryMap[category]) resourceCategoryMap[category] = [];
+ if (!resourceCategoryMap[category].includes(mat)) {
+ resourceCategoryMap[category].push(mat);
+ }
+ }
+ });
+ log.debug(`${CONSTANTS.LOG_MODULES.MONSTER}怪物${monsterName}的扫描分类:${JSON.stringify(resourceCategoryMap)}`);
+
+ let currentMaterialName = prevMaterialName;
+ let updatedFlattened = flattenedLowCountMaterials;
+
+ const hasExcessMaterial = materials.some(mat => excessMaterialNames.includes(mat));
+ shouldRunAsNoRecord = noRecord || hasExcessMaterial;
+
+ if (shouldRunAsNoRecord) {
+ if (currentMaterialName !== monsterName) {
+ currentMaterialName = monsterName;
+ materialAccumulatedDifferences[monsterName] = {};
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}noRecord模式:切换目标至怪物【${monsterName}】`);
+ }
+
+ const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime);
+ startTime = timing.startTime;
+ runTime = timing.runTime;
+
+ if (runTime > MIN_RECORD_TIME) {
+ const noRecordContent = generateRecordContent(pathingFilePath, pathName, startTime, timing.endTime, runTime, "noRecord模式忽略");
+ writeContentToFile(`${noRecordDir}/${monsterName}.txt`, noRecordContent);
+ } else {
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}怪物路径${pathName}运行时间不足(${runTime.toFixed(1)}秒 < ${MIN_RECORD_TIME}秒),跳过noRecord记录`);
+ }
+ } else {
+ if (currentMaterialName !== monsterName) {
+ if (prevMaterialName && materialAccumulatedDifferences[prevMaterialName]) {
+ const prevMsg = `目标[${prevMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[prevMaterialName])}`;
+ await sendNotificationInChunks(prevMsg, notification.Send);
+ }
+ currentMaterialName = monsterName;
+ const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap);
+ updatedFlattened = updatedLowCountMaterials
+ .flat()
+ .sort((a, b) => parseInt(a.count, 10) - parseInt(b.count, 10));
+ materialAccumulatedDifferences[monsterName] = {};
+ }
+
+ const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime);
+ startTime = timing.startTime;
+ runTime = timing.runTime;
+
+ const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap);
+ const flattenedUpdated = updatedLowCountMaterials.flat().sort((a, b) => a.count - b.count);
+
+ const materialCountDifferences = {};
+ flattenedUpdated.forEach(updated => {
+ const original = updatedFlattened.find(m => m.name === updated.name);
+ if (original) {
+ const updatedCount = parseInt(updated.count);
+ const originalCount = parseInt(original.count);
+ let diff = updatedCount - originalCount;
+
+ if (isNaN(updatedCount) || isNaN(originalCount)) {
+ diff = NaN;
+ } else if (diff < 0) {
+ diff = null;
+ }
+
+ if ((diff !== 0 && !isNaN(diff)) || materials.includes(updated.name)) {
+ materialCountDifferences[updated.name] = diff;
+ if (!isNaN(diff)) {
+ globalAccumulatedDifferences[updated.name] = (globalAccumulatedDifferences[updated.name] || 0) + diff;
+ materialAccumulatedDifferences[monsterName][updated.name] = (materialAccumulatedDifferences[monsterName][updated.name] || 0) + diff;
+ }
+ }
+ }
+ });
+
+ updatedFlattened = updatedFlattened.map(m => {
+ const updated = flattenedUpdated.find(u => u.name === m.name);
+ return updated ? { ...m, count: updated.count } : m;
+ });
+
+ log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}怪物路径${pathName}数量变化: ${JSON.stringify(materialCountDifferences)}`);
+
+ recordRunTime(monsterName, pathName, timing.startTime, timing.endTime, runTime, recordDir, materialCountDifferences, pathingFilePath);
+ }
+
+ await sleep(1);
+ return {
+ ...context,
+ currentMaterialName,
+ flattenedLowCountMaterials: updatedFlattened,
+ materialAccumulatedDifferences,
+ globalAccumulatedDifferences
+ };
+ } catch (error) {
+ if (startTime) {
+ const endTime = new Date().toLocaleString();
+ runTime = (new Date(endTime) - new Date(startTime)) / 1000;
+ await handlePathError(error, pathingFilePath, pathName, monsterName, startTime, endTime, runTime, shouldRunAsNoRecord, false);
+ }
+ throw error;
+ }
+}
+
+/**
+ * 处理普通材料路径条目
+ * @param {Object} entry - 路径条目 { path, resourceName }
+ * @param {Object} context - 上下文(同怪物路径)
+ * @returns {Object} 更新后的上下文
+ */
+async function processNormalPathEntry(entry, context) {
+ const { path: pathingFilePath, resourceName } = entry;
+ const pathName = basename(pathingFilePath);
+ const {
+ CDCategories, timeCost, recordDir, noRecordDir,
+ materialCategoryMap, flattenedLowCountMaterials,
+ currentMaterialName: prevMaterialName,
+ materialAccumulatedDifferences, globalAccumulatedDifferences,
+ pathRecordCache, timeCostStats
+ } = context;
+
+ let startTime = null;
+ let runTime = 0;
+
+ try {
+ const refreshCD = findRefreshCD(resourceName, CDCategories);
+ if (!refreshCD) {
+ log.debug(`${CONSTANTS.LOG_MODULES.MATERIAL}材料【${resourceName}】未找到CD配置,跳过路径:${pathName}`);
+ await sleep(1);
+ return context;
+ }
+
+ const currentTime = getCurrentTimeInHours();
+ const lastEndTime = getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath);
+ const pathCheckResult = checkPathNameFrequency(resourceName, pathName, recordDir, false, pathingFilePath);
+ const perTime = noRecord ? null : calculatePerTime(resourceName, pathName, recordDir, noRecordDir, false, pathRecordCache, pathingFilePath);
+
+ const estimatedTime = estimatePathTotalTime({ path: pathingFilePath, resourceName }, recordDir, noRecordDir);
+ const canRunCD = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName);
+ const isTimeCostOk = noRecord || perTime === null || isTimeCostQualified(perTime, resourceName, timeCost, timeCostStats);
+
+ if (checkPathSkipConditions(canRunCD, pathCheckResult, isTimeCostOk, perTime, estimatedTime, pathName, `${CONSTANTS.LOG_MODULES.PATH}材料路径`)) {
+ await sleep(1);
+ return context;
+ }
+
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}材料路径${pathName} 执行 | 时间成本:${perTime ?? '忽略'} | 预计耗时:${estimatedTime}秒`);
+
+ const resourceCategoryMap = {};
+ for (const [cat, list] of Object.entries(materialCategoryMap)) {
+ if (list.includes(resourceName)) {
+ resourceCategoryMap[cat] = [resourceName];
+ break;
+ }
+ }
+
+ let currentMaterialName = prevMaterialName;
+ let updatedFlattened = flattenedLowCountMaterials;
+
+ if (noRecord) {
+ if (currentMaterialName !== resourceName) {
+ currentMaterialName = resourceName;
+ materialAccumulatedDifferences[resourceName] = {};
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}noRecord模式:切换目标至材料【${resourceName}】`);
+ }
+
+ const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime);
+ startTime = timing.startTime;
+ runTime = timing.runTime;
+
+ if (runTime > MIN_RECORD_TIME) {
+ const noRecordContent = generateRecordContent(pathingFilePath, pathName, startTime, timing.endTime, runTime, "noRecord模式忽略");
+ writeContentToFile(`${noRecordDir}/${resourceName}.txt`, noRecordContent);
+ } else {
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}材料路径${pathName}运行时间不足(${runTime.toFixed(1)}秒 < ${MIN_RECORD_TIME}秒),跳过noRecord记录`);
+ }
+ } else {
+ if (currentMaterialName !== resourceName) {
+ if (prevMaterialName && materialAccumulatedDifferences[prevMaterialName]) {
+ const prevMsg = `目标[${prevMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[prevMaterialName])}`;
+ await sendNotificationInChunks(prevMsg, notification.Send);
+ }
+ currentMaterialName = resourceName;
+ const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap);
+ updatedFlattened = updatedLowCountMaterials
+ .flat()
+ .sort((a, b) => parseInt(a.count, 10) - parseInt(b.count, 10));
+ materialAccumulatedDifferences[resourceName] = {};
+ }
+
+ const timing = await executePathWithTiming(pathingFilePath, pathName, currentMaterialName, estimatedTime);
+ startTime = timing.startTime;
+ runTime = timing.runTime;
+
+ const updatedLowCountMaterials = await MaterialPath(resourceCategoryMap);
+ const flattenedUpdated = updatedLowCountMaterials.flat().sort((a, b) => a.count - b.count);
+
+ const materialCountDifferences = {};
+ flattenedUpdated.forEach(updated => {
+ const original = updatedFlattened.find(m => m.name === updated.name);
+ if (original) {
+ const updatedCount = parseInt(updated.count);
+ const originalCount = parseInt(original.count);
+ let diff = updatedCount - originalCount;
+
+ if (isNaN(updatedCount) || isNaN(originalCount)) {
+ diff = NaN;
+ } else if (diff < 0) {
+ diff = null;
+ }
+
+ if ((diff !== 0 && !isNaN(diff)) || updated.name === resourceName) {
+ materialCountDifferences[updated.name] = diff;
+ if (!isNaN(diff)) {
+ globalAccumulatedDifferences[updated.name] = (globalAccumulatedDifferences[updated.name] || 0) + diff;
+ materialAccumulatedDifferences[resourceName][updated.name] = (materialAccumulatedDifferences[resourceName][updated.name] || 0) + diff;
+ }
+ }
+ }
+ });
+
+ updatedFlattened = updatedFlattened.map(m => {
+ const updated = flattenedUpdated.find(u => u.name === m.name);
+ return updated ? { ...m, count: updated.count } : m;
+ });
+
+ log.info(`${CONSTANTS.LOG_MODULES.MATERIAL}材料路径${pathName}数量变化: ${JSON.stringify(materialCountDifferences)}`);
+
+ recordRunTime(resourceName, pathName, timing.startTime, timing.endTime, runTime, recordDir, materialCountDifferences, pathingFilePath);
+ }
+
+ await sleep(1);
+ return {
+ ...context,
+ currentMaterialName,
+ flattenedLowCountMaterials: updatedFlattened,
+ materialAccumulatedDifferences,
+ globalAccumulatedDifferences
+ };
+ } catch (error) {
+ if (startTime) {
+ const endTime = new Date().toLocaleString();
+ runTime = (new Date(endTime) - new Date(startTime)) / 1000;
+ await handlePathError(error, pathingFilePath, pathName, resourceName, startTime, endTime, runTime, noRecord, state.cancelRequested);
+ }
+ throw error;
+ }
+}
+
+/**
+ * 批量处理所有路径(核心修改:时间预判+缓存传递)
+ * @param {Object[]} allPaths - 所有路径条目
+ * @param {Object} CDCategories - CD分类配置
+ * @param {Object} materialCategoryMap - 材料分类映射
+ * @param {number} timeCost - 时间成本阈值
+ * @param {Object[]} flattenedLowCountMaterials - 低数量材料列表
+ * @param {string|null} currentMaterialName - 当前处理的材料名
+ * @param {string} recordDir - 记录目录
+ * @param {string} noRecordDir - 无记录目录
+ * @param {string} imagesDir - 图像目录
+ * @param {string} endTimeStr - 指定终止时间
+ * @returns {Object} 处理结果
+ */
+async function processAllPaths(allPaths, CDCategories, materialCategoryMap, timeCost, flattenedLowCountMaterials, currentMaterialName, recordDir, noRecordDir, imagesDir, endTimeStr) {
+ try {
+ let foodExpAccumulator = {};
+ const globalAccumulatedDifferences = {};
+ const materialAccumulatedDifferences = {};
+ const pathRecordCache = {};
+
+ if (pathingMode.estimateMode) {
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}[测算模式] 开始路径分析...`);
+
+ const allTimeCostData = collectAllTimeCostData(allPaths, recordDir, noRecordDir, pathRecordCache);
+
+ const timeCostStats = {};
+ for (const [resourceKey, data] of Object.entries(allTimeCostData)) {
+ timeCostStats[resourceKey] = calculateTimeCostStats(data);
+ }
+
+ const resourceAnalysis = {};
+ let totalQualifiedPaths = 0;
+ let totalPaths = 0;
+ let totalEstimatedTime = 0;
+ let failedCD = 0;
+ let failedFrequency = 0;
+ let passedTimeCost = 0;
+ let failedTimeCost = 0;
+ let insufficientRecords = 0;
+
+ for (const entry of allPaths) {
+ const { path: pathingFilePath, resourceName, monsterName } = entry;
+ const pathName = basename(pathingFilePath);
+ const resourceKey = monsterName || resourceName || '未知';
+
+ let refreshCD = null;
+ for (const [categoryName, cdInfo] of Object.entries(CDCategories)) {
+ if (allowedCDCategories.length > 0 && !allowedCDCategories.includes(categoryName)) continue;
+ for (const [cdKey, cdItems] of Object.entries(cdInfo)) {
+ if (cdItems.includes(resourceKey)) {
+ refreshCD = JSON.parse(cdKey);
+ break;
+ }
+ }
+ if (refreshCD) break;
+ }
+
+ const currentTime = getCurrentTimeInHours();
+ const lastEndTime = getLastRunEndTime(resourceKey, pathName, recordDir, noRecordDir, pathingFilePath);
+ const canRunCD = canRunPathingFile(currentTime, lastEndTime, refreshCD, pathName);
+
+ const isFood = resourceName && isFoodResource(resourceName);
+ const pathCheckResult = checkPathNameFrequency(resourceKey, pathName, recordDir, isFood, pathingFilePath);
+
+ const perTime = calculatePerTime(
+ resourceKey,
+ pathName,
+ recordDir,
+ noRecordDir,
+ isFood,
+ pathRecordCache,
+ pathingFilePath
+ );
+
+ const isTimeCostOk = perTime === null || isTimeCostQualified(
+ perTime,
+ resourceKey,
+ timeCost,
+ timeCostStats
+ );
+
+ if (!resourceAnalysis[resourceKey]) {
+ resourceAnalysis[resourceKey] = {
+ total: 0,
+ qualified: 0,
+ perTimeSum: 0,
+ perTimeCount: 0,
+ qualifiedPerTimeSum: 0,
+ estimatedTimeSum: 0
+ };
+ }
+
+ resourceAnalysis[resourceKey].total++;
+ totalPaths++;
+
+ if (!canRunCD) {
+ failedCD++;
+ }
+ if (!(pathCheckResult === true || pathCheckResult.valid)) {
+ failedFrequency++;
+ }
+ if (perTime === null) {
+ insufficientRecords++;
+ } else if (isTimeCostOk) {
+ passedTimeCost++;
+ } else {
+ failedTimeCost++;
+ }
+
+ if (canRunCD && (pathCheckResult === true || pathCheckResult.valid) && isTimeCostOk) {
+ resourceAnalysis[resourceKey].qualified++;
+ totalQualifiedPaths++;
+
+ const estimatedTime = estimatePathTotalTime(entry, recordDir, noRecordDir, pathRecordCache);
+ resourceAnalysis[resourceKey].estimatedTimeSum += estimatedTime;
+ totalEstimatedTime += estimatedTime;
+
+ if (perTime !== null) {
+ resourceAnalysis[resourceKey].qualifiedPerTimeSum += perTime;
+ }
+ }
+
+ if (perTime !== null) {
+ resourceAnalysis[resourceKey].perTimeSum += perTime;
+ resourceAnalysis[resourceKey].perTimeCount++;
+ }
+ }
+
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}\n===== 测算模式分析结果 =====`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}时间成本:${timeCost}%`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}总路径数:${totalPaths}`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}未通过(CD未刷新):${failedCD}条`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}未通过(0记录超限):${failedFrequency}条`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}未达标(时间成本不合格):${failedTimeCost}条`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}直通达标(记录不足):${insufficientRecords}条`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}达标路径数:${totalQualifiedPaths}条(同时通过三个校验)`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}达标占比:${totalPaths > 0 ? ((totalQualifiedPaths / totalPaths) * 100).toFixed(1) : 0}%`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}预计耗时:${formatTime(totalEstimatedTime)}`);
+
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}\n各材料平均时间成本及达标情况:`);
+ for (const [resourceKey, data] of Object.entries(resourceAnalysis)) {
+ const avgPerTime = data.perTimeCount > 0 ? (data.perTimeSum / data.perTimeCount).toFixed(4) : '无记录';
+ const qualifiedRatio = data.total > 0 ? ((data.qualified / data.total) * 100).toFixed(1) : '0.0';
+ const totalEstimatedTime = formatTime(data.estimatedTimeSum);
+ const avgQualifiedPerTime = data.qualified > 0 ? (data.qualifiedPerTimeSum / data.qualified).toFixed(4) : '无达标';
+ const thresholdValue = timeCostStats[resourceKey] ? (timeCostStats[resourceKey].percentiles[timeCost] || timeCostStats[resourceKey].median).toFixed(4) : '无数据';
+
+ const isFood = resourceKey && isFoodResource(resourceKey);
+ const isMonster = monsterToMaterials.hasOwnProperty(resourceKey);
+ let unit = '秒/个';
+ if (isFood) {
+ unit = '秒/EXP';
+ } else if (isMonster) {
+ unit = '秒/中位材料';
+ }
+
+ log.info(`${CONSTANTS.LOG_MODULES.PATH} --------------------------------------`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH} ${resourceKey}:`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH} 达标平均:${avgQualifiedPerTime}${unit}`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH} 设置的${timeCost}%分位阈值:${thresholdValue}${unit}`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH} 所有有记录路径的平均:${avgPerTime}${unit}`);
+ log.info(`${CONSTANTS.LOG_MODULES.PATH} 达标:${qualifiedRatio}% (${data.qualified}/${data.total}),总耗时:${totalEstimatedTime}`);
+ }
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}=============================\n`);
+
+ if (notify) {
+ const estimateMsg = `【测算模式】分析完成\n总路径数:${totalPaths}\n达标路径数:${totalQualifiedPaths}\n达标占比:${totalPaths > 0 ? ((totalQualifiedPaths / totalPaths) * 100).toFixed(1) : 0}%\n预计耗时:${formatTime(totalEstimatedTime)}`;
+ await sendNotificationInChunks(estimateMsg, notification.Send);
+ }
+
+ state.completed = true;
+ state.cancelRequested = true;
+ return {
+ currentMaterialName: null,
+ flattenedLowCountMaterials: [],
+ globalAccumulatedDifferences: {},
+ foodExpAccumulator
+ };
+ }
+
+ if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.RECORD}[百分位数系统] 开始收集时间成本数据...`);
+ const allTimeCostData = collectAllTimeCostData(allPaths, recordDir, noRecordDir, pathRecordCache);
+
+ const timeCostStats = {};
+ for (const [resourceKey, data] of Object.entries(allTimeCostData)) {
+ timeCostStats[resourceKey] = calculateTimeCostStats(data);
+ if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.RECORD}[百分位数系统] ${resourceKey}统计:样本数=${timeCostStats[resourceKey].count}, 平均值=${timeCostStats[resourceKey].mean}, 中位数=${timeCostStats[resourceKey].median}`);
+ if (debugLog) {
+ log.info(` 标准差:${timeCostStats[resourceKey].stdDev}`);
+ log.info(` 百分位数:${JSON.stringify(timeCostStats[resourceKey].percentiles)}`);
+ }
+ }
+ if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.RECORD}[百分位数系统] 时间成本阈值:${timeCost}%`);
+ if (debugLog) log.info(`${CONSTANTS.LOG_MODULES.RECORD}[百分位数系统] 时间成本数据收集完成,共${Object.keys(timeCostStats).length}个资源`);
+
+ let context = {
+ CDCategories, timeCost, recordDir, noRecordDir, imagesDir,
+ materialCategoryMap, flattenedLowCountMaterials,
+ currentMaterialName, materialAccumulatedDifferences,
+ globalAccumulatedDifferences,
+ pathRecordCache,
+ timeCostStats
+ };
+
+ for (const entry of allPaths) {
+ if (state.cancelRequested) {
+ log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到手动终止指令,停止路径处理`);
+ break;
+ }
+
+ let skipPath = false;
+ if (endTimeStr) {
+ const isValidEndTime = /^\d{1,2}[::]\d{1,2}$/.test(endTimeStr);
+ if (isValidEndTime) {
+ const remainingMinutes = getRemainingMinutesToEndTime(endTimeStr);
+ if (remainingMinutes <= 0) {
+ log.warn(`${CONSTANTS.LOG_MODULES.MAIN}已过指定终止时间(${endTimeStr}),停止路径处理`);
+ state.cancelRequested = true;
+ break;
+ }
+
+ const pathTotalTimeSec = estimatePathTotalTime(
+ entry,
+ recordDir,
+ noRecordDir,
+ pathRecordCache
+ );
+ const pathTotalTimeMin = pathTotalTimeSec / 60;
+ const requiredMin = pathTotalTimeMin + 2;
+
+ if (remainingMinutes <= requiredMin) {
+ log.warn(`${CONSTANTS.LOG_MODULES.MAIN}时间不足:剩余${remainingMinutes}分钟,需${requiredMin}分钟(含2分钟空闲)`);
+ state.cancelRequested = true;
+ skipPath = true;
+ break;
+ } else {
+ log.debug(`${CONSTANTS.LOG_MODULES.MAIN}时间充足:剩余${remainingMinutes}分钟,需${requiredMin}分钟`);
+ }
+ } else {
+ log.warn(`${CONSTANTS.LOG_MODULES.MAIN}终止时间格式无效(${endTimeStr}),跳过定时判断`);
+ }
+ }
+
+ if (skipPath) break;
+
+ const { path: pathingFilePath, resourceName, monsterName } = entry;
+
+ try {
+ if (resourceName && isFoodResource(resourceName)) {
+ const result = await processFoodPathEntry(
+ entry,
+ {
+ foodExpAccumulator,
+ currentMaterialName: context.currentMaterialName
+ },
+ recordDir,
+ noRecordDir,
+ CDCategories,
+ timeCost,
+ context.pathRecordCache,
+ context.timeCostStats
+ );
+ foodExpAccumulator = result.foodExpAccumulator;
+ context.currentMaterialName = result.currentMaterialName;
+ } else if (monsterName) {
+ context = await processMonsterPathEntry(entry, context);
+ } else if (resourceName) {
+ context = await processNormalPathEntry(entry, context);
+ } else {
+ log.warn(`${CONSTANTS.LOG_MODULES.PATH}跳过无效路径条目:${JSON.stringify(entry)}`);
+ }
+ } catch (singleError) {
+ log.error(`${CONSTANTS.LOG_MODULES.PATH}处理路径出错,已跳过:${singleError.message}`);
+
+ await sleep(1);
+ if (state.cancelRequested) {
+ log.warn(`${CONSTANTS.LOG_MODULES.PATH}检测到终止指令,停止处理`);
+ break;
+ }
+ }
+ }
+
+ if (context.currentMaterialName) {
+ if (isFoodResource(context.currentMaterialName) && foodExpAccumulator[context.currentMaterialName]) {
+ const finalMsg = `狗粮材料[${context.currentMaterialName}]收集完成,累计EXP:${foodExpAccumulator[context.currentMaterialName]}`;
+ await sendNotificationInChunks(finalMsg, notification.Send);
+ } else if (materialAccumulatedDifferences[context.currentMaterialName]) {
+ const finalMsg = `目标[${context.currentMaterialName}]收集完成,累计获取:${JSON.stringify(materialAccumulatedDifferences[context.currentMaterialName])}`;
+ await sendNotificationInChunks(finalMsg, notification.Send);
+ }
+ }
+
+ return {
+ currentMaterialName: context.currentMaterialName,
+ flattenedLowCountMaterials: context.flattenedLowCountMaterials,
+ globalAccumulatedDifferences: context.globalAccumulatedDifferences,
+ foodExpAccumulator
+ };
+
+ } catch (error) {
+ log.error(`${CONSTANTS.LOG_MODULES.PATH}路径处理整体错误:${error.message}`);
+ throw error;
+ } finally {
+ log.info(`${CONSTANTS.LOG_MODULES.PATH}路径组处理结束`);
+ state.completed = true;
+ }
+}
diff --git a/repo/js/背包材料统计/lib/recordManager.js b/repo/js/背包材料统计/lib/recordManager.js
new file mode 100644
index 000000000..628cbc772
--- /dev/null
+++ b/repo/js/背包材料统计/lib/recordManager.js
@@ -0,0 +1,438 @@
+// ==============================================
+// 记录管理模块
+// ==============================================
+
+// ==============================================
+// 内容检测码生成(通用哈希逻辑)
+// ==============================================
+/**
+ * 生成内容检测码(基于路径位置数据)
+ * @param {Array} positions - 路径位置数组
+ * @returns {string} 8位十六进制检测码
+ */
+function generateContentCode(positions) {
+ try {
+ const serialized = JSON.stringify(
+ positions.map(pos => ({
+ type: pos.type,
+ x: parseFloat(pos.x).toFixed(2),
+ y: parseFloat(pos.y).toFixed(2)
+ }))
+ );
+ let hash = 0;
+ for (let i = 0; i < serialized.length; i++) {
+ const char = serialized.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash;
+ }
+ return ((hash >>> 0).toString(16).padStart(8, '0')).slice(-8);
+ } catch (error) {
+ log.warn(`生成检测码失败: ${error.message},使用默认值`);
+ return "00000000";
+ }
+}
+
+/**
+ * 从文件名中提取内容检测码
+ * @param {string} fileName - 文件名
+ * @returns {string|null} 内容检测码,未找到返回null
+ */
+function extractContentCodeFromFileName(fileName) {
+ const match = fileName.match(/_([0-9a-fA-F]{8})\.json$/);
+ return match ? match[1].toLowerCase() : null;
+}
+
+/**
+ * 读取路径文件并生成内容检测码
+ * @param {string} pathingFilePath - 路径文件路径
+ * @returns {string} 内容检测码
+ */
+function generatePathContentCode(pathingFilePath) {
+ try {
+ const fileName = basename(pathingFilePath);
+ const extractedCode = extractContentCodeFromFileName(fileName);
+ if (extractedCode) {
+ return extractedCode;
+ }
+
+ const content = safeReadTextSync(pathingFilePath);
+ if (!content) {
+ log.warn(`${CONSTANTS.LOG_MODULES.PATH}路径文件为空: ${pathingFilePath}`);
+ return "00000000";
+ }
+
+ const pathData = JSON.parse(content);
+ const positions = pathData.positions || [];
+
+ if (!Array.isArray(positions) || positions.length === 0) {
+ log.warn(`${CONSTANTS.LOG_MODULES.PATH}路径文件无有效位置数据: ${pathingFilePath}`);
+ return "00000000";
+ }
+
+ return generateContentCode(positions);
+ } catch (error) {
+ log.warn(`${CONSTANTS.LOG_MODULES.PATH}生成路径检测码失败: ${error.message}`);
+ return "00000000";
+ }
+}
+
+// ==============================================
+// 记录管理函数
+// ==============================================
+
+/**
+ * 写入内容到文件(追加模式)
+ * @param {string} filePath - 目标文件路径
+ * @param {string} content - 要写入的内容
+ */
+function writeContentToFile(filePath, content) {
+ try {
+ let existingContent = '';
+ try {
+ existingContent = safeReadTextSync(filePath);
+ } catch (readError) {
+ log.debug(`${CONSTANTS.LOG_MODULES.RECORD}文件不存在或读取失败: ${filePath}`);
+ }
+
+ const updatedContent = content + existingContent;
+ const result = file.writeTextSync(filePath, updatedContent, false);
+ if (result) {
+ log.info(`${CONSTANTS.LOG_MODULES.RECORD}记录成功: ${filePath}`);
+ } else {
+ log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录失败: ${filePath}`);
+ }
+ } catch (error) {
+ log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录失败: ${error}`);
+ }
+}
+
+/**
+ * 检查路径名出现频率(避免重复无效路径)
+ * @param {string} resourceName - 资源名
+ * @param {string} pathName - 路径名
+ * @param {string} recordDir - 记录目录
+ * @param {boolean} isFood - 是否为狗粮路径
+ * @returns {boolean|Object} 是否允许运行(true=允许),false时返回详细信息对象
+ */
+function checkPathNameFrequency(resourceName, pathName, recordDir, isFood = false, pathingFilePath = null) {
+ let suffix = CONSTANTS.ZERO_COUNT_SUFFIX;
+ if (isFood) {
+ suffix = CONSTANTS.FOOD_ZERO_EXP_SUFFIX;
+ }
+ const recordPath = `${recordDir}/${resourceName}${suffix}`;
+ let totalCount = 0;
+
+ const currentContentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : null;
+ const hasValidContentCode = currentContentCode && currentContentCode !== "00000000";
+
+ try {
+ const content = safeReadTextSync(recordPath);
+ const lines = content.split('\n');
+
+ let currentPathName = null;
+ let recordContentCode = null;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+
+ if (line.startsWith('路径名: ')) {
+ currentPathName = line.split('路径名: ')[1];
+ } else if (line.startsWith('内容检测码: ')) {
+ recordContentCode = line.split('内容检测码: ')[1];
+ } else if (line === '' && currentPathName && recordContentCode) {
+ if (hasValidContentCode) {
+ if (recordContentCode === currentContentCode) {
+ totalCount++;
+ }
+ } else {
+ if (currentPathName === pathName) {
+ totalCount++;
+ }
+ }
+ currentPathName = null;
+ recordContentCode = null;
+ }
+ }
+ } catch (error) {
+ log.debug(`${CONSTANTS.LOG_MODULES.RECORD}目录${recordDir}中无${resourceName}${suffix}记录,跳过检查`);
+ }
+
+ if (totalCount >= 3) {
+ const typeDesc = isFood ? "狗粮" : "普通材料";
+ return {
+ valid: false,
+ reason: `0记录频率超限(累计${totalCount}次)`
+ };
+ }
+
+ return true;
+}
+
+/**
+ * 记录路径运行时间与材料变化
+ * @param {string} resourceName - 资源名(普通材料名/怪物名)
+ * @param {string} pathName - 路径名
+ * @param {string} startTime - 开始时间
+ * @param {string} endTime - 结束时间
+ * @param {number} runTime - 运行时间(秒)
+ * @param {string} recordDir - 记录目录
+ * @param {Object} materialCountDifferences - 材料数量变化
+ * @param {string} pathingFilePath - 路径文件路径
+ */
+function recordRunTime(resourceName, pathName, startTime, endTime, runTime, recordDir, materialCountDifferences = {}, pathingFilePath) {
+ const recordPath = `${recordDir}/${resourceName}.txt`;
+ const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : "00000000";
+ const normalContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`;
+
+ try {
+ if (runTime > 5) {
+ const isError = typeof materialCountDifferences === 'string' || (typeof materialCountDifferences === 'object' && Object.values(materialCountDifferences).some(v => typeof v === 'string'));
+
+ if (isError) {
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}检测到错误状态,跳过0记录`);
+ return;
+ }
+
+ const isMonsterPath = monsterToMaterials.hasOwnProperty(resourceName);
+ if (isMonsterPath) {
+ const monsterTargetMaterials = monsterToMaterials[resourceName] || [];
+ let monsterMaterialsTotal = 0;
+ monsterTargetMaterials.forEach(targetMat => {
+ monsterMaterialsTotal += (materialCountDifferences[targetMat] || 0);
+ });
+ if (monsterMaterialsTotal === 0) {
+ const zeroMonsterPath = `${recordDir}/${resourceName}${CONSTANTS.ZERO_COUNT_SUFFIX}`;
+ const zeroMonsterContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`;
+ writeContentToFile(zeroMonsterPath, zeroMonsterContent);
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}怪物【${resourceName}】对应材料总数量为0,已写入单独文件: ${zeroMonsterPath}`);
+ }
+ }
+
+ for (const [material, count] of Object.entries(materialCountDifferences)) {
+ if (material === resourceName && count === 0) {
+ const zeroMaterialPath = `${recordDir}/${material}${CONSTANTS.ZERO_COUNT_SUFFIX}`;
+ const zeroMaterialContent = `路径名: ${pathName}\n内容检测码: ${contentCode}\n开始时间: ${startTime}\n结束时间: ${endTime}\n运行时间: ${runTime}秒\n数量变化: ${JSON.stringify(materialCountDifferences)}\n\n`;
+ writeContentToFile(zeroMaterialPath, zeroMaterialContent);
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}材料数目为0,已写入单独文件: ${zeroMaterialPath}`);
+ }
+ }
+
+ const hasZeroMaterial = Object.values(materialCountDifferences).includes(0);
+
+ if (!hasZeroMaterial) {
+ writeContentToFile(recordPath, normalContent);
+ } else {
+ if (hasZeroMaterial) log.warn(`${CONSTANTS.LOG_MODULES.RECORD}存在材料数目为0的情况: ${JSON.stringify(materialCountDifferences)}`);
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}未写入正常记录: ${recordPath}`);
+ }
+ } else {
+ log.warn(`${CONSTANTS.LOG_MODULES.RECORD}运行时间小于5秒,未满足记录条件: ${recordPath}`);
+ }
+ } catch (error) {
+ log.error(`${CONSTANTS.LOG_MODULES.RECORD}记录运行时间失败: ${error}`);
+ }
+}
+
+/**
+ * 获取上次运行结束时间
+ * @param {string} resourceName - 资源名
+ * @param {string} pathName - 路径名
+ * @param {string} recordDir - 记录目录
+ * @param {string} noRecordDir - 无记录目录
+ * @returns {string|null} 上次结束时间字符串(null=无记录)
+ */
+function getLastRunEndTime(resourceName, pathName, recordDir, noRecordDir, pathingFilePath) {
+ const checkDirs = [recordDir, noRecordDir];
+ let latestEndTime = null;
+
+ const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : null;
+ const hasValidContentCode = contentCode && contentCode !== "00000000";
+
+ const cleanPathName = pathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json');
+
+ checkDirs.forEach(dir => {
+ const recordPath = `${dir}/${resourceName}.txt`;
+ try {
+ const content = safeReadTextSync(recordPath);
+ const lines = content.split('\n');
+
+ const recordBlocks = content.split('\n\n').filter(block => block.includes('路径名: '));
+
+ recordBlocks.forEach(block => {
+ const blockLines = block.split('\n');
+ let blockPathName = '';
+ let blockContentCode = '00000000';
+ let blockEndTime = null;
+
+ blockLines.forEach(line => {
+ if (line.startsWith('路径名: ')) {
+ blockPathName = line.split('路径名: ')[1];
+ } else if (line.startsWith('内容检测码: ')) {
+ blockContentCode = line.split('内容检测码: ')[1] || '00000000';
+ } else if (line.startsWith('结束时间: ')) {
+ blockEndTime = line.split('结束时间: ')[1];
+ }
+ });
+
+ const cleanBlockPathName = blockPathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json');
+
+ let isMatch = false;
+ if (hasValidContentCode) {
+ isMatch = blockContentCode === contentCode;
+ } else {
+ isMatch = cleanBlockPathName === cleanPathName;
+ }
+
+ if (isMatch && blockEndTime) {
+ const endTime = new Date(blockEndTime);
+ if (!latestEndTime || endTime > new Date(latestEndTime)) {
+ latestEndTime = blockEndTime;
+ }
+ }
+ });
+ } catch (error) {
+ log.debug(`${CONSTANTS.LOG_MODULES.RECORD}目录${dir}中无${resourceName}记录,跳过检查`);
+ }
+ });
+
+ return latestEndTime;
+}
+
+/**
+ * 公共函数:读取路径历史记录(支持缓存复用,避免重复读文件)
+ * @param {string} resourceKey - 记录键(怪物名/材料名)
+ * @param {string} pathName - 路径名
+ * @param {string} recordDir - 主记录目录
+ * @param {string} noRecordDir - 备用记录目录
+ * @param {boolean} isFood - 是否为狗粮路径
+ * @param {Object} cache - 缓存对象(单次路径处理周期内有效)
+ * @returns {Array