大量更新、优化和修复 (#3013)

This commit is contained in:
吉吉喵
2026-03-20 08:43:58 +08:00
committed by GitHub
parent 61490dcfc9
commit dc986d6f25
25 changed files with 4203 additions and 2749 deletions

View File

@@ -1,237 +1,243 @@
# 背包材料统计 v2.62
作者:吉吉喵
<!-- 新增:全局图片样式,控制连续图片同行显示 -->
<style>
/* 1. 同行图片容器解决Flex拉伸问题 */
.img-row-container {
display: flex;
flex-wrap: wrap; /* 宽度不足时自动换行,避免挤压 */
gap: 15px; /* 图片间距,避免紧贴 */
justify-content: center; /* 整体居中,优化排版 */
align-items: flex-start; /* 关键!禁止垂直拉伸,图片按自身高度排列 */
margin: 15px 0;
max-width: 100%; /* 避免容器超宽,适配编辑器宽度 */
}
/* 2. 图片核心样式满足「X200px不缩放+等比例」 */
.auto-scale-img {
/* 小图保护宽度200px时保持原始尺寸不被拉伸 */
width: auto; /* 禁止强制宽度,优先用原始宽度 */
max-width: 100%; /* 小图200px宽度=原始宽度 */
/* 大图缩放宽度≥200px时最大缩至600px可自定义 */
min-width: auto; /* 排除最小宽度限制 */
max-width: 600px; /* 大图最大宽度,超过则等比例缩小 */
/* 比例锁定:无论何种布局,都保持原始宽高比 */
height: auto !important; /* 强制高度自动适配,覆盖所有拉伸样式 */
object-fit: contain; /* 极端情况(如容器强制固定高度)下,确保图片完整且比例不变 */
display: block; /* 避免图片下方多余空白 */
}
</style>
## 一、简介
可统计背包内养成道具、部分食物、素材的数量并根据「设定目标数量」「材料刷新CD」自动执行挖矿、采集、刷怪等路径。
### 核心优势
1. **自动CD判断**无需手动关注材料刷新状态脚本自动识别CD是否就绪
2. **灵活路径管理**:支持自定义添加路径,自动排除低效/无效路径;
3. **独立名单识别**不与路边NPC、神像交互可自定义识别名单操作见「四、问题解答Q4」
4. **实时弹窗保护**:内置弹窗模块(覆盖路边信件、过期物品、月卡、调查等场景),运行时全程保护路径不被弹窗干扰。
5. **自动黑名单**:内置拾取模块,联动材料统计,可识别爆满的路径材料,自动屏蔽。
6. **路径检测码**:根据路径生成检测码,可识别改名坐标未变的路径文件,自动匹配带检测码的路径记录。
7. **怪物材料防爆仓**对怪物材料路径添加多重机制防止蓝紫怪物材料爆仓怪物材料路径执行时不会拾其他的怪物材料除非pathing文件夹里存在且未超量
## 二、用前须知
1. 需具备基础电脑操作能力(如文件夹复制、路径查找);
2. 非1080p显示器使用前需要根据显示器调整背包物品界面的 拖动距离 推荐“一次划页稍小于4行材料的距离”
3. 脚本不自带路径文件,需手动对目标文件夹进行操作(步骤见「三、使用方法」)。
## 三、使用方法
### 3.1 基础教程(路径配置)
#### 步骤1订阅路径文件
在仓库中订阅所需的路径文件,参考以下截图操作:
<div class="img-row-container">
<img src="assets/Pic/Pic06.png" alt="订阅路径文件操作截图1" class="img-row-item">
<img src="assets/Pic/Pic09.png" alt="订阅路径文件操作截图2" class="img-row-item">
<img src="assets/Pic/Pic11.png" alt="订阅路径文件操作截图3" class="img-row-item">
<img src="assets/Pic/Pic04.png" alt="订阅路径文件操作截图4" class="img-row-item">
</div>
#### 步骤2复制路径到目标文件夹
1. 打开**路径源文件夹**(存放订阅的路径文件):
```
BetterGI\Repos\bettergi-scripts-list-git\repo\pathing
```
2. 根据需求,复制以下类型的路径文件夹(按需选择):
- 地方特产
- 敌人与魔物
- 矿物
- 食材与炼金
3. 粘贴到**脚本目标文件夹**(背包材料统计的路径读取目录):
```
BetterGI\User\JsScript\背包材料统计\pathing
```
#### 步骤2关键注意点
- 手动删除重复路径(例如“萃凝晶”可能存在多个重复路径,需手动清理),参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic01.png" alt="删除重复路径参考截图" class="img-row-item">
</div>
- `pathing` 文件夹仅支持**3层子文件夹**,若超过3层需手动削减(否则无法读取);
- 推荐优先配置「枫丹水下」路径:无队伍要求,但需提前开启水下锚点。
#### 步骤3按队伍分组管理路径
建议复制多份「背包材料统计」脚本,按队伍功能分组存放适配路径,避免路径混乱。示例如下:
| 分组名称 | 适配队伍组合 | 适用场景 | 特殊说明 |
|------------------------|-----------------------------------|---------------------------|-----------------------------------|
| 背包统计采集组(生存队) | 迪希雅 + 芭芭拉 + 瑶瑶 + 草神 | 常规材料采集 | 无草神时需批量搜索路径中“nahida_collect”并排除 |
| 背包统计刷怪组 | 火神 + 奶奶 + 钟离 + 万叶 | 挂机刷怪(获取怪物材料、含战斗的采集) | 确保队伍输出足够,能高效清理怪物 |
| 背包统计附魔材料组 | 钟离 + 芭芭拉 + 久岐忍 + 砂糖/班尼特 | 附魔类采集(需特定附魔) | 根据材料路径需求选择附魔角色,有战斗则增加输出角色 |
分组示例与无草神排除路径操作截图:
<div class="img-row-container">
<img src="assets/Pic/Pic03.png" alt="队伍分组管理路径示例截图" class="img-row-item">
<img src="assets/Pic/Pic05.png" alt="无草神时排除路径操作截图" class="img-row-item">
</div>
#### 步骤4打开脚本自定义设置
1. 找到配置组内已添加的「背包材料统计」JS
2. 右键点击该文件选择「修改JS 脚本自定义设置」;
3. 操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic02.png" alt="打开脚本自定义设置操作截图" class="img-row-item">
</div>
### 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如何排除不想要的路径
A1. 打开 `pathing` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\pathing`
2. 直接删除/移走目标材料/怪物的路径文件夹;
**其他方法**看「四、问题解答Q2」按格式要求填入对应的材料名或者从其他CD文件中复制过来在「JS 自定义设置」【采用的 CD 分类】中输入新建的文件名即可实现只加载该CD文件里材料的路径。
### Q2如何添加新材料
A1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\materialsCD`
2. 新建/编辑txt文件按格式填写`CD规则材料1材料2`(中文冒号+中文逗号CD规则参考自带文件“1次0点月落银宿影花`材料1`或`材料2`将会作为标准名;
3. **关键要求**路径文件夹名、材料图片名必须与“材料1或2”完全一致多层文件夹默认读取最外层标准名文件夹
4. 操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic07.png" alt="添加新材料操作截图1" class="img-row-item">
<img src="assets/Pic/Pic08.png" alt="添加新材料操作截图2" class="img-row-item">
</div>
### Q3如何识别不规范命名的路径文件夹如“纳塔食材一条龙”、“果园.json”
A1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可路径CD由“所在材料文件夹”决定
2. 例看「四、问题解答Q2」① 把“纳塔食材一条龙”作为标准名选择一个CD② 在「JS 自定义设置」【优先级材料】里填入:纳塔食材一条龙,③将“纳塔食材一条龙”的文件夹放置到`pathing` 文件夹;锄地路径可放置到“锄地”文件夹里(没有就新建一个)**此方法无法使用 背包材料统计 的优选路径功能!**
3. 「JS 自定义设置」勾选【取消扫描】后,就可以运行了!**此项不勾将无CD记录**
4. 例:“果园.json”放入“苹果”文件夹将按“苹果”的CD规则执行。
操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic14.png" alt="添加新路径文件夹操作截图1" class="img-row-item">
<img src="assets/Pic/Pic15.png" alt="添加新路径文件夹操作截图2" class="img-row-item">
<img src="assets/Pic/Pic16.png" alt="添加新路径文件夹操作截图2" class="img-row-item">
</div>
### Q4如何自定义识别名单
A1. 打开 `targetText` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\targetText`
2. 新建/编辑txt文件按格式填写`自定义名称:目标1,目标2`(英文冒号+英文逗号,例:“新材料:霜盏花,便携轴承,”);
3. 若需排除怪物掉落材料:找到“掉落.txt”删除对应材料名即可
4. 操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic12.png" alt="自定义识别名单操作截图1" class="img-row-item">
<img src="assets/Pic/Pic10.png" alt="自定义识别名单操作截图2" class="img-row-item">
</div>
### 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.txt0收获记录三次及以上同名路径记录就会触发排除
操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic13.png" alt="本地记录存放位置参考截图" class="img-row-item">
</div>
## 六、后言
本脚本目前处于测试阶段,欢迎反馈问题至 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.62
作者:吉吉喵
<!-- 新增:全局图片样式,控制连续图片同行显示 -->
<style>
/* 1. 同行图片容器解决Flex拉伸问题 */
.img-row-container {
display: flex;
flex-wrap: wrap; /* 宽度不足时自动换行,避免挤压 */
gap: 15px; /* 图片间距,避免紧贴 */
justify-content: center; /* 整体居中,优化排版 */
align-items: flex-start; /* 关键!禁止垂直拉伸,图片按自身高度排列 */
margin: 15px 0;
max-width: 100%; /* 避免容器超宽,适配编辑器宽度 */
}
/* 2. 图片核心样式满足「X200px不缩放+等比例」 */
.auto-scale-img {
/* 小图保护宽度200px时保持原始尺寸不被拉伸 */
width: auto; /* 禁止强制宽度,优先用原始宽度 */
max-width: 100%; /* 小图200px宽度=原始宽度 */
/* 大图缩放宽度≥200px时最大缩至600px可自定义 */
min-width: auto; /* 排除最小宽度限制 */
max-width: 600px; /* 大图最大宽度,超过则等比例缩小 */
/* 比例锁定:无论何种布局,都保持原始宽高比 */
height: auto !important; /* 强制高度自动适配,覆盖所有拉伸样式 */
object-fit: contain; /* 极端情况(如容器强制固定高度)下,确保图片完整且比例不变 */
display: block; /* 避免图片下方多余空白 */
}
</style>
## 一、简介
可统计背包内养成道具、部分食物、素材的数量并根据「设定目标数量」「材料刷新CD」自动执行挖矿、采集、刷怪等路径。
### 核心优势
1. **自动CD判断**无需手动关注材料刷新状态脚本自动识别CD是否就绪
2. **灵活路径管理**:支持自定义添加路径,自动排除低效/无效路径;
3. **独立名单识别**不与路边NPC、神像交互可自定义识别名单操作见「四、问题解答Q4」
4. **实时弹窗保护**:内置弹窗模块(覆盖路边信件、过期物品、月卡、调查等场景),运行时全程保护路径不被弹窗干扰。
5. **自动黑名单**:内置拾取模块,联动材料统计,可识别爆满的路径材料,自动屏蔽。
6. **路径检测码**:根据路径生成检测码,可识别改名坐标未变的路径文件,自动匹配带检测码的路径记录。
7. **怪物材料防爆仓**对怪物材料路径添加多重机制防止蓝紫怪物材料爆仓怪物材料路径执行时不会拾其他的怪物材料除非pathing文件夹里存在且未超量
## 二、用前须知
1. 需具备基础电脑操作能力(如文件夹复制、路径查找);
2. 非1080p显示器使用前需要根据显示器调整背包物品界面的 拖动距离 推荐“一次划页稍小于4行材料的距离”
3. 脚本不自带路径文件,需手动对目标文件夹进行操作(步骤见「三、使用方法」)。
## 三、使用方法
### 3.1 基础教程(路径配置)
#### 步骤1订阅路径文件
在仓库中订阅所需的路径文件,参考以下截图操作:
<div class="img-row-container">
<img src="assets/Pic/Pic06.png" alt="订阅路径文件操作截图1" class="img-row-item">
<img src="assets/Pic/Pic09.png" alt="订阅路径文件操作截图2" class="img-row-item">
<img src="assets/Pic/Pic11.png" alt="订阅路径文件操作截图3" class="img-row-item">
<img src="assets/Pic/Pic04.png" alt="订阅路径文件操作截图4" class="img-row-item">
</div>
#### 步骤2复制路径到目标文件夹
1. 打开**路径源文件夹**(存放订阅的路径文件):
```
BetterGI\Repos\bettergi-scripts-list-git\repo\pathing
```
2. 根据需求,复制以下类型的路径文件夹(按需选择):
- 地方特产
- 敌人与魔物
- 矿物
- 食材与炼金
3. 粘贴到**脚本目标文件夹**(背包材料统计的路径读取目录):
```
BetterGI\User\JsScript\背包材料统计\pathing
```
#### 步骤2关键注意点
- 手动删除重复路径(例如“萃凝晶”可能存在多个重复路径,需手动清理),参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic01.png" alt="删除重复路径参考截图" class="img-row-item">
</div>
- `pathing` 文件夹仅支持**6层子文件夹**,若超过6层需手动削减(否则无法读取);
- 推荐优先配置「枫丹水下」路径:无队伍要求,但需提前开启水下锚点。
#### 步骤3按队伍分组管理路径
建议复制多份「背包材料统计」脚本,按队伍功能分组存放适配路径,避免路径混乱。**例如把该JS复制三份改名后分别放入不同的配置组。给每个JS所在的配置组配置对应的切队等也可以单独添加切队JS。每个JS放置该队能适配的路径文件。分路径挺费时间按自己需要琢磨好队伍来。队伍适合的话包括抓鱼、禽肉、挖矿等等路径都可以适配不一定按采集刷怪附魔来。**示例如下:
| 分组名称 | 适配队伍组合 | 适用场景 | 特殊说明 |
|------------------------|-----------------------------------|---------------------------|-----------------------------------|
| 背包统计采集组(生存队) | 迪希雅 + 芭芭拉 + 瑶瑶 + 草神 | 常规材料采集 | 无草神时需批量搜索路径中“nahida_collect”并排除 |
| 背包统计刷怪组 | 火神 + 奶奶 + 钟离 + 万叶 | 挂机刷怪(获取怪物材料、含战斗的采集) | 确保队伍输出足够,能高效清理怪物 |
| 背包统计附魔材料组 | 钟离 + 芭芭拉 + 久岐忍 + 砂糖/班尼特 | 附魔类采集(需特定附魔) | 根据材料路径需求选择附魔角色,有战斗则增加输出角色 |
分组示例与无草神排除路径操作截图:
<div class="img-row-container">
<img src="assets/Pic/Pic03.png" alt="队伍分组管理路径示例截图" class="img-row-item">
<img src="assets/Pic/Pic05.png" alt="无草神时排除路径操作截图" class="img-row-item">
</div>
#### 步骤4打开脚本自定义设置
1. 找到配置组内已添加的「背包材料统计」JS
2. 右键点击该文件选择「修改JS 脚本自定义设置」;
3. 操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic02.png" alt="打开脚本自定义设置操作截图" class="img-row-item">
</div>
### 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如何排除不想要的路径
A1. 打开 `pathing` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\pathing`
2. 直接删除/移走目标材料/怪物的路径文件夹;
**其他方法**看「四、问题解答Q2」按格式要求填入对应的材料名或者从其他CD文件中复制过来在「JS 自定义设置」【采用的 CD 分类】中输入新建的文件名即可实现只加载该CD文件里材料的路径。
### Q2如何添加新材料
A1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\materialsCD`
2. 新建/编辑txt文件按格式填写`CD规则材料1材料2`(中文冒号+中文逗号CD规则参考自带文件“1次0点月落银宿影花`材料1`或`材料2`将会作为标准名;
3. **关键要求**路径文件夹名、材料图片名必须与“材料1或2”完全一致多层文件夹默认读取最外层标准名文件夹
4. 操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic07.png" alt="添加新材料操作截图1" class="img-row-item">
<img src="assets/Pic/Pic08.png" alt="添加新材料操作截图2" class="img-row-item">
</div>
### Q3如何识别不规范命名的路径文件夹如“纳塔食材一条龙”、“果园.json”
A1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可路径CD由“所在材料文件夹”决定
2. 例:看「四、问题解答Q2」① 把“纳塔食材一条龙”作为标准名选择一个CD② 在「JS 自定义设置」【优先级材料】里填入:纳塔食材一条龙,③将“纳塔食材一条龙”的文件夹放置到`pathing` 文件夹;锄地路径可放置到“锄地”文件夹里(没有就新建一个)**此方法无法使用 背包材料统计 的优选路径功能!**
3. 「JS 自定义设置」勾选【取消扫描】后,就可以运行了!**此项不勾将无CD记录**
4. 例:“果园.json”放入“苹果”文件夹将按“苹果”的CD规则执行。
操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic14.png" alt="添加新路径文件夹操作截图1" class="img-row-item">
<img src="assets/Pic/Pic15.png" alt="添加新路径文件夹操作截图2" class="img-row-item">
<img src="assets/Pic/Pic16.png" alt="添加新路径文件夹操作截图2" class="img-row-item">
</div>
### Q4如何自定义识别名单
A1. 打开 `targetText` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\targetText`
2. 新建/编辑txt文件按格式填写`自定义名称:目标1,目标2`(英文冒号+英文逗号,例:“新材料:霜盏花,便携轴承,”);
3. 若需排除怪物掉落材料:找到“掉落.txt”删除对应材料名即可
4. 操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic12.png" alt="自定义识别名单操作截图1" class="img-row-item">
<img src="assets/Pic/Pic10.png" alt="自定义识别名单操作截图2" class="img-row-item">
</div>
### 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.txt0收获记录三次及以上同名路径记录就会触发排除
操作参考截图:
<div class="img-row-container">
<img src="assets/Pic/Pic13.png" alt="本地记录存放位置参考截图" class="img-row-item">
</div>
## 六、后言
本脚本目前处于测试阶段,欢迎反馈问题至 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 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -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) {

File diff suppressed because it is too large Load Diff

View File

@@ -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 };
}

View File

@@ -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));
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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 // 标记是否需要释放
};
}

View File

@@ -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;
}
}

View File

@@ -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<Object>} 结构化记录列表含runTime、quantityChange
*/
function getHistoricalPathRecords(resourceKey, pathName, recordDir, noRecordDir, isFood = false, cache = {}, pathingFilePath) {
const contentCode = pathingFilePath ? generatePathContentCode(pathingFilePath) : null;
const hasValidContentCode = contentCode && contentCode !== "00000000";
const cleanPathName = pathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json');
const isFoodSuffix = isFood ? CONSTANTS.FOOD_EXP_RECORD_SUFFIX : ".txt";
const recordFile = `${recordDir}/${resourceKey}${isFoodSuffix}`;
const cacheKey = `${recordFile}|${cleanPathName}|${contentCode || "00000000"}|${hasValidContentCode ? "code" : "name"}`;
if (cache[cacheKey]) {
if (debugLog) log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从缓存复用记录:${cacheKey}`);
return cache[cacheKey];
}
const records = [];
let targetFile = recordFile;
let content = "";
try {
content = safeReadTextSync(targetFile);
} catch (mainErr) {
targetFile = `${noRecordDir}/${resourceKey}${isFoodSuffix}`;
try {
content = safeReadTextSync(targetFile);
log.debug(`${CONSTANTS.LOG_MODULES.RECORD}从备用目录读取记录:${targetFile}`);
} catch (backupErr) {
log.debug(`${CONSTANTS.LOG_MODULES.RECORD}${resourceKey}的历史记录:${targetFile}`);
cache[cacheKey] = records;
return records;
}
}
const lines = content.split('\n');
const recordBlocks = content.split('\n\n').filter(block => block.includes('路径名: '));
recordBlocks.forEach(block => {
const blockLines = block.split('\n').map(line => line.trim()).filter(line => line);
let runTime = 0;
let quantityChange = {};
let isTargetPath = false;
let recordContentCode = "00000000";
blockLines.forEach(line => {
if (line.startsWith('路径名: ')) {
const recordPathName = line.split('路径名: ')[1];
const cleanRecordPathName = recordPathName.replace(/_[0-9a-fA-F]{8}\.json$/, '.json');
if (cleanRecordPathName === cleanPathName) {
isTargetPath = true;
}
}
if (line.startsWith('内容检测码: ')) {
recordContentCode = line.split('内容检测码: ')[1] || "00000000";
}
if (line.startsWith('运行时间: ')) {
runTime = parseInt(line.split('运行时间: ')[1].split('秒')[0], 10) || 0;
}
if (line.startsWith('本次EXP获取: ')) {
const exp = parseInt(line.split('本次EXP获取: ')[1], 10) || 0;
quantityChange = { exp: exp };
} else if (line.startsWith('数量变化: ')) {
try {
quantityChange = JSON.parse(line.split('数量变化: ')[1]) || {};
} catch (e) {
log.warn(`${CONSTANTS.LOG_MODULES.RECORD}解析数量变化失败:${line}`);
}
}
});
let shouldInclude = false;
if (hasValidContentCode) {
shouldInclude = recordContentCode === contentCode && runTime > 0;
} else {
shouldInclude = isTargetPath && runTime > 0;
}
if (shouldInclude) {
records.push({ runTime, quantityChange, contentCode: recordContentCode });
}
});
cache[cacheKey] = records;
if (debugLog) log.debug(`${CONSTANTS.LOG_MODULES.RECORD}读取记录并缓存:${cacheKey}${records.length}条)`);
return records;
}
/**
* 基于历史runTime预估路径总耗时默认5分钟
* @param {Object} entry - 路径条目
* @param {string} recordDir - 记录目录
* @param {string} noRecordDir - 备用目录
* @param {Object} cache - 缓存对象
* @returns {number} 预估耗时(秒)
*/
function estimatePathTotalTime(entry, recordDir, noRecordDir, cache = {}) {
const { resourceName, monsterName, path: pathingFilePath } = entry;
const pathName = basename(pathingFilePath);
const isFood = resourceName && isFoodResource(resourceName);
let resourceKey = isFood ? resourceName : (monsterName || resourceName);
if (!resourceKey) {
log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}无资源关联默认按120秒2分钟预估`);
return 120;
}
const historicalRecords = getHistoricalPathRecords(
resourceKey,
pathName,
recordDir,
noRecordDir,
isFood,
cache,
pathingFilePath
);
if (historicalRecords.length === 0) {
log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName} 无正常运行记录默认耗时120秒`);
return 120;
}
const recentRecords = [...historicalRecords].reverse().slice(0, 5);
const avgRunTime = Math.round(
recentRecords.reduce((sum, record) => sum + record.runTime, 0) / recentRecords.length
);
log.debug(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}历史runTime最近5条${recentRecords.map(r => r.runTime)}秒,预估耗时:${avgRunTime}`);
return avgRunTime;
}

View File

@@ -5,16 +5,16 @@
var globalLatestRa = null;
async function recognizeImage(
recognitionObject,
ra,
timeout = 1000,
interval = 500,
useNewScreenshot = false,
recognitionObject,
ra,
timeout = 1000,
interval = 500,
useNewScreenshot = false,
iconType = null
) {
let startTime = Date.now();
globalLatestRa = ra;
const originalRa = ra;
globalLatestRa = ra;
const originalRa = ra;
let tempRa = null; // 用于管理临时创建的资源
try {
@@ -23,7 +23,11 @@ async function recognizeImage(
if (useNewScreenshot) {
// 释放之前的临时资源
if (tempRa) {
tempRa.dispose();
try {
tempRa.dispose();
} catch (e) {
log.debug(`释放临时截图失败(可能已释放): ${e.message}`);
}
}
tempRa = captureGameRegion();
currentRa = tempRa;
@@ -58,7 +62,11 @@ async function recognizeImage(
} finally {
// 释放临时资源但保留全局引用的资源
if (tempRa && tempRa !== globalLatestRa) {
tempRa.dispose();
try {
tempRa.dispose();
} catch (e) {
log.debug(`释放临时截图失败(可能已释放): ${e.message}`);
}
}
}
@@ -84,10 +92,10 @@ async function drawAndClearRedBox(searchRegion, ra, delay = 500) {
searchRegion.width, searchRegion.height
);
drawRegion.DrawSelf("icon"); // 绘制红框
// 等待指定时间
await sleep(delay);
// 清除红框 - 使用更可靠的方式
if (drawRegion && typeof drawRegion.DrawSelf === 'function') {
// 可能需要使用透明绘制来清除或者绘制一个0大小的区域
@@ -102,50 +110,3 @@ async function drawAndClearRedBox(searchRegion, ra, delay = 500) {
}
}
}
// 截图保存函数
function imageSaver(mat, saveFile) {
// 获取当前时间并格式化为 "YYYY-MM-DD_HH-MM-SS"
const now = new Date();
const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}-${String(now.getSeconds()).padStart(2, '0')}`;
// 获取当前脚本所在的目录
const scriptDir = getScriptDirPath();
if (!scriptDir) {
log.error("无法获取脚本目录");
return;
}
// 构建完整的目标目录路径和文件名
const savePath = `${scriptDir}/${saveFile}/screenshot_${timestamp}.png`;
const tempFilePath = `${scriptDir}/${saveFile}`;
// 检查临时文件是否存在,如果不存在则创建目录
try {
// 尝试读取临时文件
file.readPathSync(tempFilePath);
log.info("目录存在,继续执行保存图像操作");
} catch (error) {
log.error(`确保目录存在时出错: ${error}`);
return;
}
// 保存图像
try {
mat.saveImage(savePath);
// log.info(`图像已成功保存到: ${savePath}`);
} catch (error) {
log.error(`保存图像失败: ${error}`);
}
}
// 获取脚本目录
function getScriptDirPath() {
try {
safeReadTextSync(`temp-${Math.random()}.txt`);
} catch (e) {
const match = e.toString().match(/'([^']+)'/);
return match ? match[1].replace(/\\[^\\]+$/, "") : null;
}
return null;
}

View File

@@ -0,0 +1,317 @@
// ==============================================
// 时间成本系统模块
// ==============================================
/**
* 解析材料CD文件内容转换为刷新时间与材料的映射
* @param {string} content - CD文件内容
* @returns {Object} 刷新时间JSON字符串到材料列表的映射
*/
function parseMaterialContent(content) {
if (!content) {
log.warn(`${CONSTANTS.LOG_MODULES.CD}文件内容为空`);
return {};
}
const lines = content.split('\n').map(line => line.trim());
const materialCDInfo = {};
lines.forEach(line => {
if (!line.includes('')) return;
const [refreshCD, materials] = line.split('');
if (!refreshCD || !materials) return;
let refreshCDInHours;
if (refreshCD.includes('次0点')) {
const times = parseInt(refreshCD.split('次')[0], 10);
if (isNaN(times)) {
log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`);
return;
}
refreshCDInHours = { type: 'midnight', times: times };
} else if (refreshCD.includes('点')) {
const hours = parseFloat(refreshCD.replace('点', ''));
if (isNaN(hours)) {
log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`);
return;
}
refreshCDInHours = { type: 'specific', hour: hours };
} else if (refreshCD.includes('小时')) {
const hours = parseFloat(refreshCD.replace('小时', ''));
if (isNaN(hours)) {
log.error(`${CONSTANTS.LOG_MODULES.CD}无效的刷新时间格式:${refreshCD}`);
return;
}
refreshCDInHours = hours;
} else if (refreshCD === '即时刷新') {
refreshCDInHours = { type: 'instant' };
} else {
log.error(`${CONSTANTS.LOG_MODULES.CD}未知的刷新时间格式:${refreshCD}`);
return;
}
let materialList = materials
.split(/[,]\s*/)
.map(material => material.trim())
.filter(material => material !== '');
materialCDInfo[JSON.stringify(refreshCDInHours)] = materialList;
});
return materialCDInfo;
}
/**
* 格式化时间显示,根据时间大小自动选择单位
* @param {number} seconds - 时间(秒)
* @returns {string} 格式化后的时间字符串
*/
function formatTime(seconds) {
const formatValue = (value) => {
if (value % 1 === 0) {
return value.toString();
}
return value.toFixed(4);
};
if (seconds >= 86400) {
return `${formatValue(seconds / 86400)}`;
} else if (seconds >= 3600) {
return `${formatValue(seconds / 3600)}小时`;
} else if (seconds >= 60) {
return `${formatValue(seconds / 60)}分钟`;
} else {
return `${formatValue(seconds)}`;
}
}
const MaterialType = {
NORMAL: 'normal',
MONSTER: 'monster',
FOOD: 'food'
};
const timeCostStatistics = {
[MaterialType.NORMAL]: [],
[MaterialType.MONSTER]: [],
[MaterialType.FOOD]: []
};
/**
* 获取材料类型
* @param {string} resourceName - 资源名
* @returns {string} 材料类型
*/
function getMaterialType(resourceName) {
if (isFoodResource(resourceName)) {
return MaterialType.FOOD;
}
if (monsterToMaterials.hasOwnProperty(resourceName)) {
return MaterialType.MONSTER;
}
return MaterialType.NORMAL;
}
/**
* 收集所有路径的时间成本数据(按具体材料/怪物分组)
* @param {Object[]} allPaths - 所有路径条目
* @param {string} recordDir - 记录目录
* @param {string} noRecordDir - 无记录目录
* @param {Object} cache - 缓存对象
* @returns {Object} 按材料名/怪物名分组的时间成本数据
*/
function collectAllTimeCostData(allPaths, recordDir, noRecordDir, cache = {}) {
const statistics = {};
allPaths.forEach(entry => {
const { path: pathingFilePath, resourceName, monsterName } = entry;
const pathName = basename(pathingFilePath);
const resourceKey = monsterName || resourceName;
if (!resourceKey) return;
const perTime = calculatePerTime(
resourceName || monsterName,
pathName,
recordDir,
noRecordDir,
isFoodResource(resourceName || monsterName),
cache,
pathingFilePath
);
if (perTime !== null && !isNaN(perTime) && perTime > 0) {
if (!statistics[resourceKey]) {
statistics[resourceKey] = [];
}
statistics[resourceKey].push(perTime);
}
});
return statistics;
}
/**
* 计算百分位数
* @param {number[]} data - 数据数组
* @param {number} percentile - 百分位数0-100
* @returns {number} 百分位数值
*/
function calculatePercentile(data, percentile) {
if (data.length === 0) return 0;
const sorted = [...data].sort((a, b) => a - b);
const index = (percentile / 100) * (sorted.length - 1);
const lower = Math.floor(index);
const upper = Math.ceil(index);
const weight = index - lower;
if (upper >= sorted.length) return sorted[sorted.length - 1];
if (lower === upper) return sorted[lower];
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}
/**
* 计算时间成本统计信息
* @param {number[]} data - 时间成本数据数组
* @returns {Object} 统计信息
*/
function calculateTimeCostStats(data) {
if (data.length === 0) {
return {
count: 0,
mean: 0,
median: 0,
stdDev: 0,
percentiles: {}
};
}
const sorted = [...data].sort((a, b) => a - b);
const mean = data.reduce((sum, val) => sum + val, 0) / data.length;
const variance = data.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length;
const stdDev = Math.sqrt(variance);
const percentiles = {};
for (let p = 1; p <= 100; p++) {
percentiles[p] = calculatePercentile(sorted, p);
}
return {
count: data.length,
mean: parseFloat(mean.toFixed(4)),
median: parseFloat(calculatePercentile(sorted, 50).toFixed(4)),
stdDev: parseFloat(stdDev.toFixed(4)),
percentiles
};
}
/**
* 判断路径时间成本是否合格(基于百分位数)
* @param {number} perTime - 路径的时间成本
* @param {string} resourceKey - 资源键(材料名/怪物名)
* @param {number} thresholdPercentile - 百分位数阈值1-100
* @param {Object} statistics - 时间成本统计数据(按资源键分组)
* @returns {boolean} 是否合格
*/
function isTimeCostQualified(perTime, resourceKey, thresholdPercentile, statistics) {
if (perTime === null || perTime === undefined) return true;
const stats = statistics[resourceKey];
if (!stats || stats.count === 0) {
log.debug(`${CONSTANTS.LOG_MODULES.RECORD}资源${resourceKey}无统计数据,默认合格`);
return true;
}
const thresholdValue = stats.percentiles[thresholdPercentile] || stats.median;
const isQualified = perTime <= thresholdValue;
if (debugLog) {
log.info(`${CONSTANTS.LOG_MODULES.RECORD}时间成本校验:${resourceKey} | 路径成本:${perTime.toFixed(4)} | 阈值(${thresholdPercentile}%${thresholdValue.toFixed(4)} | 结果:${isQualified ? '合格' : '不合格'}`);
}
return isQualified;
}
/**
* 计算单次时间成本(秒/单位材料)(复用缓存)
* @param {string} resourceName - 资源名
* @param {string} pathName - 路径名
* @param {string} recordDir - 记录目录
* @param {string} noRecordDir - 备用目录
* @param {boolean} isFood - 是否为狗粮路径
* @param {Object} cache - 缓存对象
* @returns {number|null} 时间成本
*/
function calculatePerTime(resourceName, pathName, recordDir, noRecordDir, isFood = false, cache = {}, pathingFilePath) {
const isMonster = monsterToMaterials.hasOwnProperty(resourceName);
const historicalRecords = getHistoricalPathRecords(
resourceName,
pathName,
recordDir,
noRecordDir,
isFood,
cache,
pathingFilePath
);
if (historicalRecords.length < 3) {
if (debugLog) log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}历史记录不足3条无法计算时间成本`);
return null;
}
const completeRecords = [];
if (isMonster) {
const monsterMaterials = monsterToMaterials[resourceName];
const gradeRatios = [3, 1, 1/3];
historicalRecords.forEach(record => {
const { runTime, quantityChange } = record;
let totalMiddleCount = 0;
monsterMaterials.forEach((mat, index) => {
const count = quantityChange[mat] || 0;
totalMiddleCount += count * (gradeRatios[index] || 1);
});
totalMiddleCount = parseFloat(totalMiddleCount.toFixed(2));
if (totalMiddleCount > 0) {
completeRecords.push(parseFloat((runTime / totalMiddleCount).toFixed(2)));
}
});
} else if (isFood) {
historicalRecords.forEach(record => {
const { runTime, quantityChange } = record;
const expValue = quantityChange.exp || 0;
if (expValue > 0) {
completeRecords.push(parseFloat((runTime / expValue).toFixed(2)));
}
});
} else {
historicalRecords.forEach(record => {
const { runTime, quantityChange } = record;
if (quantityChange[resourceName] !== undefined && quantityChange[resourceName] !== 0) {
completeRecords.push(parseFloat((runTime / quantityChange[resourceName]).toFixed(2)));
}
});
}
if (completeRecords.length < 3) {
if (debugLog) log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}有效记录不足3条无法计算时间成本`);
return null;
}
const recentRecords = completeRecords.slice(-5).filter(r => !isNaN(r) && r !== Infinity);
const mean = recentRecords.reduce((acc, val) => acc + val, 0) / recentRecords.length;
const stdDev = Math.sqrt(recentRecords.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / recentRecords.length);
const filteredRecords = recentRecords.filter(r => Math.abs(r - mean) <= 1 * stdDev);
if (filteredRecords.length === 0) {
log.warn(`${CONSTANTS.LOG_MODULES.RECORD}路径${pathName}记录数据差异过大,无法计算有效时间成本`);
return null;
}
return parseFloat((filteredRecords.reduce((acc, val) => acc + val, 0) / filteredRecords.length).toFixed(2));
}

View File

@@ -10,43 +10,66 @@ function updateSettingsOptions() {
log.info("settings.json内容长度: " + settingsContent.length);
var settings = JSON.parse(settingsContent);
log.info("settings.json解析成功配置项数量: " + settings.length);
var hasChanges = false;
var popupDirs = readAllFilePaths("assets/imageClick", 0, 2, [], true)
.filter(function (dirPath) {
.filter(function(dirPath) {
var entries = readAllFilePaths(dirPath, 0, 0, [], true);
return entries.some(function (entry) {
return entries.some(function(entry) {
return normalizePath(entry).endsWith('/icon');
});
})
.filter(function (dirPath) {
.filter(function(dirPath) {
return !normalizePath(dirPath).includes('/其他/');
})
.map(function (dirPath) {
.map(function(dirPath) {
return basename(dirPath);
})
.sort();
log.info("扫描到弹窗目录数量: " + popupDirs.length);
var popupSetting = settings.find(function (s) {
return s.name === "PopupNames";
});
var cdCategories = readAllFilePaths("materialsCD", 0, 1, ['.txt'])
.map(function(filePath) {
return basename(filePath).replace('.txt', '');
})
.sort();
var pickCategories = readAllFilePaths("targetText", 0, 1, ['.txt'])
.map(function(filePath) {
return basename(filePath).replace('.txt', '');
})
.sort();
var popupSetting = null;
var cdSetting = null;
var pickSetting = null;
for (var i = 0; i < settings.length; i++) {
var setting = settings[i];
if (setting.name === "PopupNames") {
popupSetting = setting;
} else if (setting.name === "CDCategories") {
cdSetting = setting;
} else if (setting.name === "PickCategories") {
pickSetting = setting;
}
}
if (popupSetting) {
log.info("找到PopupNames配置项");
var existingOptions = popupSetting.options || [];
log.info("现有options数量: " + existingOptions.length);
var existingSet = {};
for (var k = 0; k < existingOptions.length; k++) {
existingSet[existingOptions[k]] = true;
}
var popupSet = {};
for (var p = 0; p < popupDirs.length; p++) {
popupSet[popupDirs[p]] = true;
}
var newOptions = [];
var removedOptions = [];
for (var m = 0; m < popupDirs.length; m++) {
@@ -59,10 +82,10 @@ function updateSettingsOptions() {
removedOptions.push(existingOptions[n]);
}
}
log.info("新增options数量: " + newOptions.length);
log.info("删除options数量: " + removedOptions.length);
if (newOptions.length > 0 || removedOptions.length > 0) {
popupSetting.options = popupDirs;
hasChanges = true;
@@ -78,16 +101,7 @@ function updateSettingsOptions() {
} else {
log.info("未找到PopupNames配置项");
}
var cdCategories = readAllFilePaths("materialsCD", 0, 1, ['.txt'])
.map(function (filePath) {
return basename(filePath).replace('.txt', '');
})
.sort();
var cdSetting = settings.find(function (s) {
return s.name === "CDCategories";
});
if (cdSetting) {
var existingOptions = cdSetting.options || [];
var existingSet = {};
@@ -121,16 +135,7 @@ function updateSettingsOptions() {
}
}
}
var pickCategories = readAllFilePaths("targetText", 0, 1, ['.txt'])
.map(function (filePath) {
return basename(filePath).replace('.txt', '');
})
.sort();
var pickSetting = settings.find(function (s) {
return s.name === "PickCategories";
});
if (pickSetting) {
var existingOptions = pickSetting.options || [];
var existingSet = {};
@@ -164,7 +169,7 @@ function updateSettingsOptions() {
}
}
}
if (hasChanges) {
var updatedContent = JSON.stringify(settings, null, 2);
file.writeTextSync(SETTINGS_FILE, updatedContent, false);

View File

@@ -0,0 +1,233 @@
// 带Y坐标间距校验的Rarity定位函数
async function locateRarityAndCropColumn(ra, scanX0, startY, columnHeight, minYGap) {
// 配置参数完全沿用你代码中的设定78x13星级图、±2px扩展、阈值
const config = {
maxRows: 4, // 目标最多4个区域你需求中“未必有四个”按实际识别返回
columnYMin: 205, // 材料列Y下限236-85=151151-121=30236-30=206206-1=205
columnYMax: 844, // 材料列Y上限最后一页星级Y值843+1
globalThreshold: 0.85, // 你代码中已设置的全局识别阈值
localThreshold: 0.8, // 你代码中已设置的局部验证阈值
expandPx: 2, // 你确认的±2px扩展适配78x13星级图
rarityW: 78, // 你代码中定义的星级图宽度
rarityH: 13, // 你代码中定义的星级图高度
rarityMatDir: "assets/rarity/" // 你代码中星级素材的固定路径
};
const scanX = Math.round(scanX0);
log.debug(`[处理开始] 列X=${scanX}有效Y范围${config.columnYMin}-${config.columnYMax}`);
// ---------------------- 步骤1找基准Y复用你提供的recognizeImage函数 ----------------------
let baseX = null;
let baseY = null;
for (let star = 1; star <= 5; star++) { // 遍历1-5星素材你代码中星级范围
const matPath = `${config.rarityMatDir}${star}.png`;
const rarityMat = file.readImageMatSync(matPath); // 你代码中用的素材加载方式
if (rarityMat.empty()) { // 你代码中素材加载失败的处理逻辑
log.warn(`[基准识别] 素材加载失败:${matPath}`);
await sleep(30); // 你代码中常用的短延迟,避免高频报错
continue;
}
// 全局识别对象X宽123与你代码中一致覆盖整列
const globalRo = RecognitionObject.TemplateMatch(
rarityMat,
scanX, // 与scanMaterials传递的scanX一致列起始X
config.columnYMin, // 材料列顶部Y
123, // 你代码中固定的图标宽度(非星级图宽,适配列扫描)
columnHeight // 与scanMaterials传递的columnHeight一致
);
globalRo.threshold = config.globalThreshold;
globalRo.Use3Channels = true; // 你代码中启用的3通道识别
// 复用你提供的recognizeImage函数带超时、重试避免阻塞
const result = await recognizeImage(globalRo, ra, 200, 50);
if (result.isDetected) { // 识别成功则取Y坐标你代码中用result.y作为基准
baseX = result.x;
baseY = result.y;
log.debug(`[基准识别] 找到${star}星基准Y=${baseY}`);
break;
}
await sleep(30);
}
// 无基准则返回空(你代码中基准识别失败的处理逻辑)
if (baseY === null) {
log.debug(`[基准识别] 无有效基准,返回空`);
return [];
}
// ---------------------- 步骤2先下后上推导候选Y你需求中的优先顺序 ----------------------
const candidateYs = [baseY]; // 初始包含基准Y
let currentY;
// 2.1 优先向下推导Y递增与你需求“先向下算”一致
currentY = baseY + minYGap; // minYGap=176scanMaterials调用时传递的参数
while (candidateYs.length < config.maxRows && currentY <= config.columnYMax) {
if (!candidateYs.includes(currentY)) {
candidateYs.push(currentY);
log.debug(`[向下推导] 新增Y=${currentY},当前总数:${candidateYs.length}`);
}
currentY += minYGap;
}
// 2.2 不足则向上补Y递减与你需求“超阈值向上算”一致
const need = config.maxRows - candidateYs.length;
if (need > 0) {
currentY = baseY - minYGap;
let added = 0;
while (added < need && currentY >= config.columnYMin) {
if (!candidateYs.includes(currentY)) {
candidateYs.push(currentY);
added++;
log.debug(`[向上补全] 新增Y=${currentY},已补${added}/${need}`);
}
currentY -= minYGap;
}
}
// 整理候选Y去重→过滤超界→排序你代码中常用的数组处理逻辑
const sortedYs = [...new Set(candidateYs)]
.filter(y => y >= config.columnYMin && y <= config.columnYMax)
.sort((a, b) => a - b) // 从上到下排序(符合游戏界面布局)
.slice(0, config.maxRows); // 最多保留4个
log.debug(`[候选整理] 最终候选Y${sortedYs.join(', ')}(共${sortedYs.length}个)`);
// ---------------------- 步骤3局部验证避免空裁图你代码中的验证逻辑 ----------------------
const validYs = [];
for (const y of sortedYs) {
// 局部验证范围:按星级图尺寸+扩展计算你确认的78x13+±2px
const localXStart = baseX - config.expandPx;
const localXWidth = config.rarityW + 2 * config.expandPx; // 78+4=82px
const localYStart = y - config.expandPx;
const localYHeight = config.rarityH + 2 * config.expandPx; // 13+4=17px
let isVerified = false;
// 遍历1-5星验证与基准识别逻辑一致
for (let star = 1; star <= 5; star++) {
const matPath = `${config.rarityMatDir}${star}.png`;
const rarityMat = file.readImageMatSync(matPath);
if (rarityMat.empty()) continue;
const region = {
x:localXStart,
y:localYStart,
width:localXWidth,
height:localYHeight}
await drawAndClearRedBox(region, ra, 20);// 调用异步函数绘制红框并延时清除
const localRo = RecognitionObject.TemplateMatch(
rarityMat,
localXStart,
localYStart,
localXWidth,
localYHeight
);
localRo.threshold = config.localThreshold;
localRo.Use3Channels = true;
// 再次复用recognizeImage验证确保与基准识别逻辑统一
const result = await recognizeImage(localRo, ra, 50, 10); // 超时减半,加快验证
if (result.isDetected) {
isVerified = true;
break;
}
await sleep(10);
}
if (isVerified) {
validYs.push(y);
log.debug(`[局部验证] Y=${y} 通过`);
} else {
log.debug(`[局部验证] Y=${y} 无匹配星级,排除`);
}
}
// ---------------------- 步骤4生成裁图区域与你代码中固定偏移一致 ----------------------
const ocrRegions = validYs.map(y => ({
x: Math.round(scanX0 + 29.5), // 你代码中固定的X偏移scanX0+29.5
y: y - 85, // 你代码中固定的Y偏移y-85
width: 64, // 你代码中固定的裁图宽度
height: 64 // 你代码中固定的裁图高度
}));
log.debug(`[处理完成] 有效裁图区域:${ocrRegions.length}Y${validYs.join(', ')}`);
return ocrRegions;
}
/**
* 保存OCR裁图区域扩大2像素识图用识别到的图片名命名
* @param {Object} ra - 游戏区域捕获对象来自captureGameRegion()
* @param {Array} ocrRegions - 原始裁图区域数组含x,y,width,height
* @param {Object} materialImages - 材料图片缓存key:材料名value:图片Mat对象来自scanMaterials
* @param {string} [saveDir='assets/regions'] - 保存目录
*/
async function saveAllOcrRegionImages(ra, ocrRegions, materialImages, saveDir = 'assets/regions') {
// Fixed: Added proper parameter validation at function entry
if (!ra || !ocrRegions || !Array.isArray(ocrRegions) || ocrRegions.length === 0) {
log.warn('saveAllOcrRegionImages: Invalid parameters provided');
return;
}
// 保留你原有函数的初始化检查仅补充materialImages参数因需要它匹配图片名
log.debug(`【保存OCR区域图像】区域数量${ocrRegions?.length || 0}`);
if (!ra || !ocrRegions || ocrRegions.length === 0 || !materialImages) {
log.error("【保存OCR区域图像】源图像或区域列表为空");
return;
}
for (const region of ocrRegions) {
// 你原有函数的变量仅新增matchedMatName用于存图片名
let fileName = "";
let matchedMatName = "unknown"; // 默认名(未匹配到时用)
let croppedRegion = null;
// 保留你原有函数的红框绘制逻辑
await drawAndClearRedBox(region, ra, 50);
try {
// ---------------------- 核心修改1裁图区域扩大2像素作为识图区域 ----------------------
// 原有裁图区域region.x, region.y, region.width, region.height
// 扩大2像素上下左右各扩2px总宽高各+4复用你原代码的坐标逻辑无陌生函数
const detectX = region.x - 2;
const detectY = region.y - 2;
const detectW = region.width + 4;
const detectH = region.height + 4;
log.debug(`【识图区域扩大】原区域(${region.x},${region.y}) → 扩大后(${detectX},${detectY},${detectW},${detectH})`);
// ---------------------- 核心修改2用扩大区域识别图片名复用你原代码的模板匹配 ----------------------
// materialImages是你scanMaterials函数中已有的“材料图片缓存”不是陌生变量
// RecognitionObject.TemplateMatch是你原代码识别材料用的方法无陌生函数
for (const [matName, mat] of Object.entries(materialImages)) {
if (mat.empty()) continue; // 跳过空图(你原代码的逻辑)
// 用扩大后的区域做模板匹配和你scanMaterials识别材料的逻辑完全一致
const matchRo = RecognitionObject.TemplateMatch(mat, detectX, detectY, detectW, detectH);
matchRo.threshold = 0.85; // 和你原代码识别阈值一致
matchRo.Use3Channels = true; // 你原代码启用的3通道识别
const matchResult = ra.find(matchRo); // 你原代码的识别方法
if (matchResult.isExist()) {
matchedMatName = matName; // 匹配到后,获取图片名
break; // 找到匹配项就退出循环
}
}
// ---------------------- 核心修改3用识别到的图片名命名文件保留你原有保存逻辑 ----------------------
// 文件名图片名_时间戳.png避免重名复用你原代码的Date.now()
fileName = `${matchedMatName}_${Date.now()}`;
// 保留你原有函数的裁图逻辑(裁的是原区域,不是扩大后的区域)
croppedRegion = ra.DeriveCrop(region.x, region.y, region.width, region.height);
if (!croppedRegion || croppedRegion.SrcMat.empty()) throw new Error("裁剪失败");
// 保留你原有函数的保存逻辑
file.WriteImageSync(`${saveDir}/${fileName}.png`, croppedRegion.SrcMat);
log.info(`【保存OCR区域图像】成功${saveDir}/${fileName}.png`);
} catch (error) {
// 保留你原有函数的错误处理逻辑
log.error(`【保存OCR区域图像】失败${fileName || '未知区域'}${error.message}`);
} finally {
// 保留你原有函数的资源释放逻辑
if (croppedRegion && typeof croppedRegion.dispose === 'function') {
croppedRegion.dispose();
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 1,
"name": "背包统计采集系统",
"version": "2.62",
"version": "2.67",
"bgi_version": "0.55",
"description": "可统计背包养成道具、部分食物、素材的数量根据设定数量、根据材料刷新CD执行挖矿、采集、刷怪等的路径。优势\n+ 1. 自动判断材料CD不需要管材料CD有没有好\n+ 2. 可以随意添加路径,能自动排除低效、无效路径;\n+ 3. 有独立名单识别不会交互路边的npc或是神像可自定义识别名单具体方法看【问题解答】增减识别名单\n+ 4. 有实时的弹窗模块,提供了常见的几种:路边信件、过期物品、月卡、调查;\n+ 5. 可识别爆满的路径材料自动屏蔽更多详细内容查看readme.md;可在我的主页下载 路径重命名 工具JS给路径名批量添加检测码方便识别。",
"saved_files": [
@@ -9,6 +9,7 @@
"history_record/",
"overwrite_record/",
"latest_record.txt",
"user/",
"pathing_record/"
],
"authors": [

View File

@@ -2,7 +2,7 @@
{
"name": "TargetCount",
"type": "input-text",
"label": "js目录下默认扫描的文件结构\n./📁BetterGI/📁User/📁JsScript/📁背包材料统计/\n 📁pathing/\n 📁 食材与炼金/\n 📁 薄荷/\n 📄 薄荷1.json\n 📁 薄荷效率/\n 📄 薄荷-吉吉喵.json\n 📁 苹果/\n 📄 旅行者的果园.json\n----------------------------------\n目标数量默认5000\n给📁pathing下材料设定的目标数"
"label": "js目录下默认扫描的文件结构\n./📁BetterGI/📁User/📁JsScript/📁背包材料统计/\n 📁pathing/\n 📁 食材与炼金/\n 📁 薄荷/\n 📄 薄荷1.json\n 📁 薄荷效率/\n 📄 薄荷-吉吉喵.json\n 📁 苹果/\n 📄 旅行者的果园.json\n----------------------------------\n目标数量默认1000\n给📁pathing下材料设定的目标数"
},
{
"name": "TargetresourceName",
@@ -12,23 +12,7 @@
{
"name": "TimeCost",
"type": "input-text",
"label": "----------------------------------\n时间成本秒\n一单位材料的平均耗时默认30"
},
{
"name": "PickCategories",
"type": "multi-checkbox",
"label": "====================\n\n采用的识别名单默认全部\n不勾选则默认",
"options": [
"交互",
"宝箱",
"掉落",
"采集"
]
},
{
"name": "ExceedCount",
"type": "input-text",
"label": "----------------------------------\n超量阈值默认:9000),超量的路径材料将也不拾取\n普通材料超量或者怪物掉落的三个材料都超量则跳过其路径"
"label": "----------------------------------\n时间成本百分位数1-100\n50代表该材料类型的平均时间成本,值越小越严格\n默认50只接受前50%的中高效路径)"
},
{
"name": "notify",
@@ -38,15 +22,26 @@
{
"name": "noRecord",
"type": "checkbox",
"label": "----------------------------------\n取消扫描。默认否\n勾选将不进行单路径的扫描但保留运行时间记录\n(推荐路径记录炼成后启用)"
"label": "----------------------------------\n取消扫描。默认否\n勾选将不进行单路径的背包/摩拉扫描,但保留运行时间记录\n(推荐路径记录炼成后启用)"
},
{
"name": "noMonsterFilter",
"type": "checkbox",
"label": "----------------------------------\n怪物材料过滤。默认否\n默认只过滤掉📁pathing中已有的超量材料\n若勾选只拾取📁pathing已有的未超量材料不拾取其他材料"
},
{
"name": "ExceedCount",
"type": "input-text",
"label": "----------------------------------\n超量阈值默认:9000)\n超量的路径材料将不拾取\n普通材料或者怪物掉落的三个材料都超量不加载其路径"
},
{
"name": "Pathing",
"type": "multi-checkbox",
"label": "====================\n统计选择📁pathing下的材料或【扫描额外的分类】的材料。默认仅📁pathing材料",
"label": "====================\n统计选择默认:仅📁pathing材料\n\n📁pathing材料一般材料和怪物材料路径\n【扫描额外的分类】背包材料统计联动生成超量材料\n【测算模式】可预估执行时间无记录默认2分钟",
"options": [
"📁pathing材料",
"【扫描额外的分类】"
"【扫描额外的分类】",
"【测算模式】"
],
"default": [
"📁pathing材料"
@@ -75,6 +70,17 @@
"烹饪用食材"
]
},
{
"name": "PickCategories",
"type": "multi-checkbox",
"label": "====================\n\nOCR采用的识别名单默认全部\n不勾选则默认",
"options": [
"交互",
"宝箱",
"掉落",
"采集"
]
},
{
"name": "PopupNames",
"type": "multi-checkbox",
@@ -115,21 +121,21 @@
{
"name": "HoldX",
"type": "input-text",
"label": "------------------------\n(0,0)———> X 增加\n |\n |\n V Y 增加\n\n翻页拖动点X坐标0~1920(默认1050)"
"label": "----------------------------------\n(0,0)———> X 增加\n |\n |\n V Y 增加\n\n翻页拖动点X坐标0~1920(默认1050)"
},
{
"name": "HoldY",
"type": "input-text",
"label": "------------------------\n翻页拖动点Y坐标0~1080(默认750)"
"label": "----------------------------------\n翻页拖动点Y坐标0~1080(默认750)"
},
{
"name": "PageScrollDistance",
"type": "input-text",
"label": "------------------------\n拖动距离(默认711像素点)推荐一次划页稍小于4行材料的距离"
"label": "----------------------------------\n拖动距离(默认711像素点)推荐一次划页稍小于4行材料的距离"
},
{
"name": "debugLog",
"type": "checkbox",
"label": "------------------------\n调试日志。默认否\n输出详细的调试信息"
"label": "----------------------------------\n调试日志。默认否\n输出详细的调试信息"
}
]