大量更新、优化和修复 (#3013)
@@ -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. 图片核心样式:满足「X<200px不缩放+等比例」 */
|
||||
.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:如何排除不想要的路径?
|
||||
A:1. 打开 `pathing` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\pathing`);
|
||||
2. 直接删除/移走目标材料/怪物的路径文件夹;
|
||||
**其他方法**:看「四、问题解答Q2」,按格式要求填入对应的材料名(或者从其他CD文件中复制过来),在「JS 自定义设置」【采用的 CD 分类】中输入新建的文件名,即可实现只加载该CD文件里材料的路径。
|
||||
|
||||
### Q2:如何添加新材料?
|
||||
A:1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\materialsCD`);
|
||||
2. 新建/编辑txt文件,按格式填写:`CD规则:材料1,材料2`(中文冒号+中文逗号,CD规则参考自带文件,例:“1次0点:月落银,宿影花,”),`材料1`或`材料2`将会作为标准名;
|
||||
3. **关键要求**:路径文件夹名、材料图片名必须与“材料1或2”完全一致(多层文件夹默认读取最外层标准名文件夹);
|
||||
4. 操作参考截图:
|
||||
<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”)?
|
||||
A:1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可(路径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:如何自定义识别名单?
|
||||
A:1. 打开 `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.txt(0收获记录,三次及以上同名路径记录,就会触发排除);
|
||||
操作参考截图:
|
||||
<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. 图片核心样式:满足「X<200px不缩放+等比例」 */
|
||||
.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:如何排除不想要的路径?
|
||||
A:1. 打开 `pathing` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\pathing`);
|
||||
2. 直接删除/移走目标材料/怪物的路径文件夹;
|
||||
**其他方法**:看「四、问题解答Q2」,按格式要求填入对应的材料名(或者从其他CD文件中复制过来),在「JS 自定义设置」【采用的 CD 分类】中输入新建的文件名,即可实现只加载该CD文件里材料的路径。
|
||||
|
||||
### Q2:如何添加新材料?
|
||||
A:1. 打开 `materialsCD` 文件夹(脚本路径:`BetterGI\User\JsScript\背包材料统计\materialsCD`);
|
||||
2. 新建/编辑txt文件,按格式填写:`CD规则:材料1,材料2`(中文冒号+中文逗号,CD规则参考自带文件,例:“1次0点:月落银,宿影花,”),`材料1`或`材料2`将会作为标准名;
|
||||
3. **关键要求**:路径文件夹名、材料图片名必须与“材料1或2”完全一致(多层文件夹默认读取最外层标准名文件夹);
|
||||
4. 操作参考截图:
|
||||
<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”)?
|
||||
A:1. 将不规范的文件夹/文件,放入**适配的材料文件夹**中即可(路径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:如何自定义识别名单?
|
||||
A:1. 打开 `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.txt(0收获记录,三次及以上同名路径记录,就会触发排除);
|
||||
操作参考截图:
|
||||
<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 |
|
||||
BIN
repo/js/背包材料统计/assets/regions/1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
repo/js/背包材料统计/assets/regions/11.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
repo/js/背包材料统计/assets/regions/17.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
repo/js/背包材料统计/assets/regions/2.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
repo/js/背包材料统计/assets/regions/4.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
repo/js/背包材料统计/assets/regions/5.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
repo/js/背包材料统计/assets/regions/6.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
repo/js/背包材料统计/assets/regions/7.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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) {
|
||||
|
||||
417
repo/js/背包材料统计/lib/colorDetection.js
Normal 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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 // 标记是否需要释放
|
||||
};
|
||||
}
|
||||
|
||||
827
repo/js/背包材料统计/lib/pathProcessor.js
Normal 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;
|
||||
}
|
||||
}
|
||||
438
repo/js/背包材料统计/lib/recordManager.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
317
repo/js/背包材料统计/lib/timeCostSystem.js
Normal 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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
233
repo/js/背包材料统计/lib/writeImage.js
Normal 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=151;151-121=30;236-30=206;206-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=176(scanMaterials调用时传递的参数)
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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输出详细的调试信息"
|
||||
}
|
||||
]
|
||||