自动循环使用料理 v1.1更新 (#1615)

* 自动循环使用料理 v1.1更新

* 自动循环使用料理js文件遗漏补充

manifest.json文件更新
This commit is contained in:
呱呱z
2025-08-17 12:36:07 +08:00
committed by GitHub
parent 22fded7c30
commit 62ba65c7c6
4 changed files with 426 additions and 80 deletions

View File

@@ -0,0 +1,83 @@
# 🍱 自动补充 Buff 类药物使用说明
## ✨ 核心功能
- 🎯 **双模式智能切换**
`坐标定位`(灵活便捷) + `名称搜索`(稳定高效)
- ⏲️ **独立冷却管理**
为每个食物单独设置使用间隔(秒级精度)
- 🔄 **多队列并行执行**
同时运行多组食物方案,互不干扰
---
## 🛠️ 配置指南
### 1⃣ 星标前置,坐标定位模式(推荐)
**格式**`行,列,间隔秒数(可选)`
**示例**`"2,3; 4,5,60"`
| 配置项 | 说明 | 默认值 |
| -------- | ------------------------------- | ------ |
| `2,3` | 第 2 行第 3 列食物 | 300 秒 |
| `4,5,60` | 第 4 行第 5 列食物60 秒间隔) | - |
> ✅ **优势**:执行速度最快,稳定性最佳
> 📌 **提示**:打开背包从左上角开始计数(第 1 行第 1 列=1,1
---
### 2⃣ 名称搜索模式
**格式**`食物全称,间隔秒数(可选),序号(可选)`
**示例**`"甜甜花酿鸡,120; 提瓦特煎蛋,60,2"`
| 配置项 | 说明 |
| ----------------- | ------------------------------ |
| `甜甜花酿鸡,120` | 使用第一个匹配项120 秒间隔) |
| `提瓦特煎蛋,60,2` | 使用第二个匹配项60 秒间隔) |
> ⚠️ **注意**:名称必须完全一致(含符号和空格)
> 🔍 搜索逻辑:按背包顺序从左 → 右,上 → 下扫描
---
## ⚠️ 关键限制
### ❗ 禁用弹窗类药物
```diff
- 生命回复/复活类食物(如"提瓦特煎蛋"
+ 仅支持无确认弹窗的增益类食物(如"蒙德土豆饼"
```
> 脚本会自动取消弹窗操作,相关食物无法生效
### ⚡ 参数规则
| 参数 | 说明 | 默认值 | 范围 |
| ------ | ------------------ | ------ | ---------- |
| 间隔 | 使用冷却时间(秒) | 300 | ≥30 |
| 序号 | 同名食物选择顺序 | 1 | ≥1 |
| 分隔符 | 条目:`;``` | - | 兼容中英文 |
| | 参数:`,``` | | |
---
## 🚀 最佳实践
1. **模式选择优先级**
```mermaid
graph LR
A[首选坐标模式] --> B[名称模式备用]
```
2. **冷却时间设置**
- 常规增益300 秒
- 强力 BUFF≥300 秒
3. **多队列避坑指南**
- 避免配置同一位置的食物
- 同类食物间隔时间保持一致
- 首次使用前手动整理背包
> 📅 最后更新2025.08 | ⚙️ 适用版本v0.48+

View File

@@ -1,56 +1,155 @@
/*********************** 配置与常量 ***********************/
// 用户输入,格式如:"2,3;4,5"
const queue1 = settings.queue1 || "";
const queue2 = settings.queue2 || "";
const queue3 = settings.queue3 || "";
const queue4 = settings.queue4 || "";
const queueList = [
{ queue: queue1, seconds: 300 },
{ queue: queue2, seconds: 900 },
{ queue: queue3, seconds: 1800 },
{ queue: queue4, seconds: 1500 }
{ queue: queue1 }
];
// 用户配置
let isBusy = false; // 全局互斥锁,控制界面操作互斥
let eatQueue = []; // 全局吃药队列,所有任务都往这里推送目标
// 模板识别对象
const InventoryInterfaceRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/InventoryInterface.png"),
0, 0, 140, 100
); // 【背包界面】图标
const DisabledFoodInterfaceRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/DisabledFoodInterface.png"),
0, 0, 1920, 100
); // 【食物界面-未处于】图标
const FoodInterfaceRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/FoodInterface.png"),
0, 0, 1920, 100
); // 【食物界面-已处于】图标
const FilterButtonRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/FilterButton.png"),
0, 0, 665, 1080
); // 【筛选】图标
const SearchButtonRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/SearchButton.png"),
0, 0, 665, 1080
); // 【搜索】图标
const ConfirmFilterButtonRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/ConfirmFilterButton.png"),
0, 0, 665, 1080
); // 【确认筛选】按钮
const ResetButtonRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/ResetButton.png"),
0, 0, 665, 1080
); // 【重置】按钮
const UseButtonRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/UseButton.png"),
1570, 985, 230, 65
); // 【使用】按钮
const CancelButtonRo = RecognitionObject.TemplateMatch(
file.ReadImageMatSync("Assets/RecognitionObject/CancelButton.png"),
510, 740, 905, 100
); // 【取消】按钮
/*********************** 工具函数 ***********************/
// 解析用户输入,返回目标数组,得到所有目标行列
function parseTargets(queue) {
/*
/*
步骤详解:
1.分组
.split(/[;]/)
支持中英文分号分割,比如 "1,2;3,45,6" 都能正确分组。
.split(/[;]/)
支持中英文分号分割,比如 "1,2;3,45,6" 都能正确分组。
2.去除空格
.map(pair => pair.trim())
去掉每组前后的空格,防止用户输入 " 1,2 " 这样的内容出错。
.map(pair => pair.trim())
去掉每组前后的空格,防止用户输入 " 1,2 " 这样的内容出错。
3.过滤空项
.filter(pair => pair.length > 0)
如果用户输入多余分号(如 "1,2;;3,4"),会产生空字符串,这里直接过滤掉。
4.分割行列并转数字
.map(pair => pair.split(/[,]/).map(s => Number(s.trim())))
支持中英文逗号分割,并把每个数字字符串转为数字类型。
也会去除数字前后的空格。
5.只保留合法的行列对
.filter(arr => arr.length === 2 && arr.every(n => !isNaN(n)))
只保留长度为2且都是数字的数组防止用户输入 "1,," 或 "a,b" 这样的无效内容。
6.转为对象格式
.map(([row, col]) => ({ row, col }))
最终输出为 { row: 1, col: 2 } 这样的对象,方便后续处理。
.filter(pair => pair.length > 0)
如果用户输入多余分号(如 "1,2;;3,4"),会产生空字符串,这里直接过滤掉。
*/
return queue
.split(/[;]/) // ① 按中英文分号分割每组
.map(pair => pair.trim()) // ② 去除每组前后空格
.filter(pair => pair.length > 0) // ③ 过滤掉空字符串(防止多余分隔符导致空项)
.map(pair => pair.split(/[,]/).map(s => Number(s.trim()))) // ④ 按中英文逗号分割,并转为数字
.filter(arr => arr.length === 2 && arr.every(n => !isNaN(n))) // ⑤ 只保留合法的 [row, col] 数组
.map(([row, col]) => ({ row, col })); // ⑥ 转为对象格式 {row, col}
function parseTargets(queue, defaultTime = 300) {
try {
const result = [];
const seenCoord = new Set(); // 只存已经出现过的 "r,c"
const seenName = new Map(); // key = "name"value = {idx} 用于覆盖
const list = queue
.split(/[;]/)
.map(s => s.trim())
.filter(Boolean)
for (const s of list) {
const parts = s.split(/[,]/).map(v => v.trim());
// 1. 行列坐标
if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^-?\d+$/.test(parts[1])) {
const [row, col, time] = parts.map(Number);
const key = `${row},${col}`;
if (!seenCoord.has(key)) {
seenCoord.add(key);
result.push({
type: 'coord',
data: { row, col },
time: (!isNaN(time) && time > 0) ? time : defaultTime,
lastTime: 0
});
}
continue;
}
// 2. 食物名
if (parts[0]) {
const name = parts[0];
let time = defaultTime, nth = 1;
if (parts.length === 2) {
// 只有两个参数时,第二个参数总是 time
const t = Number(parts[1]);
if (!isNaN(t) && t > 0) time = t;
} else if (parts.length === 3) {
// 三个参数时,第二个是 time第三个是 nth
const t = Number(parts[1]), n = Number(parts[2]);
if (!isNaN(t) && t > 0) time = t;
if (!isNaN(n) && n >= 1 && n <= 40 && n === Math.floor(n)) nth = n;
}
// 后出现的覆盖先出现的
if (seenName.has(name)) {
result[seenName.get(name)] = {
type: 'search',
data: { name, nth },
time,
lastTime: 0
};
} else {
seenName.set(name, result.length);
result.push({
type: 'search',
data: { name, nth },
time,
lastTime: 0
});
}
}
}
return result;
} catch (error) {
log.error(`解析队列时发生错误: ${error.message}`);
return []; // 返回空数组表示解析失败
}
}
/**
* 清空吃药队列并返回被删除的元素。
* @returns {Array} 被删除的元素数组,如果清空失败则返回空数组。
*/
function flushEatQueue() {
try {
return eatQueue.splice(0);// splice 会原地清空并返回被删元素
} catch (error) {
log.error(`清空吃药队列时发生异常: ${error.message}`);
return []; // 返回空数组表示清空失败
}
}
// 计算料理图标的坐标,只生成需要的行列
@@ -62,8 +161,8 @@ function computeGridCoordinates(targets) {
try {
for (const row of allRows) {
for (const col of allCols) {
const ProcessingX = Math.round(182.5 + (col - 1) * 145);
const ProcessingY = Math.round(197.5 + (row - 1) * 175);
const ProcessingX = Math.round(185 + (col - 1) * 145);
const ProcessingY = Math.round(200 + (row - 1) * 175);
gridCoordinates.push({ row, col, x: ProcessingX, y: ProcessingY });
}
}
@@ -74,65 +173,244 @@ function computeGridCoordinates(targets) {
}
// 模板匹配并点击
async function findAndClick(target) {
try {
const gameRegion = captureGameRegion();
const found = gameRegion.find(target);
if (found && found.isExist()) {
found.click();
}
gameRegion.dispose();
} catch (error) {
log.error(`findAndClick 出错: ${error.message}`);
}
}
/**
* 捕获游戏区域并查找指定模板,返回匹配结果对象
* @param {object} templateRo 模板识别对象
* @returns {object} 匹配结果对象
*/
function findInGameRegion(templateRo) {
try {
const gameRegion = captureGameRegion();
const result = gameRegion.find(templateRo);
gameRegion.dispose();
return result;
} catch (error) {
log.error(`findInGameRegion 出错: ${error.message}`);
}
}
/**
* 点击“使用”后,检测并处理可能出现的弹窗
* @param {number} timeout 最长等待时间(ms)
* @returns {Promise<boolean>} true=检测到弹窗并已点击取消false=无弹窗
*/
async function handlePopupAfterUse(timeout = 300) {
const start = Date.now();
while (Date.now() - start < timeout) {
const cancel = findInGameRegion(CancelButtonRo);
if (cancel.isExist()) {
log.warn("检测到弹窗,点击【取消】关闭");
try {
toast("检测到弹窗,已自动取消");
} catch (e) { /* 某些环境无 toast忽略 */ }
cancel.click();
await sleep(300); // 给界面一点时间消失
return true;
}
await sleep(100);
}
// 超时未匹配到,视为无弹窗
return false;
}
/*********************** 主要逻辑函数 ***********************/
// ---------- 算下一次该推送谁 ----------
function nextTick(targets) {
const now = Date.now();
// 找出距离下一次推送最近的那条记录
let minDelay = 1000; // 默认最小 1 秒
for (const t of targets) {
const due = (t.lastPushTime || 0) + t.time * 1000;
const delay = Math.max(0, due - now);
if (delay < minDelay) minDelay = delay;
}
// 不能为 0至少给 50 ms
return Math.max(50, minDelay);
}
// 定时将本组要吃的药加入全局队列
async function runQueueTask(queue, seconds) {
async function runQueueTask(queue) {
const targets = parseTargets(queue);
while (true) {
// 立即推一次
// 给每个目标初始化 lastPushTime
targets.forEach(t => t.lastPushTime = 0);
// 把推送逻辑写成内部函数,立即执行一次,再定时执行
const pushOnce = () => {
const now = Date.now();
// 极端保护:队列过长时报警并强制清空
if (eatQueue.length > 1000) {
log.error("警告eatQueue 长度超过1000已强制清空");
eatQueue = [];
flushEatQueue();
}
// 到点时把本组要吃的药加入全局队列
eatQueue.push(...targets);
//log.info(`eatQueue 当前长度: ${eatQueue.length}`); // 日志监控
await sleep(seconds * 1000);
// 把本组目标推送到全局队列(去重)
for (const t of targets) {
if (now - (t.lastPushTime || 0) >= t.time * 1000) {
// 真正到点才推送
const key = t.type === 'coord'
? `${t.data.row},${t.data.col}`
: t.data.name; // 同名食物也算重复
if (!eatQueue.some(e =>
(e.type === 'coord' && key === `${e.data.row},${e.data.col}`) ||
(e.type === 'search' && key === e.data.name)
)) {
eatQueue.push(t);
} t.lastPushTime = now; // 更新上一次推送时间
}
}
};
// 立即推一次,然后循环
pushOnce();
while (true) {
const wait = nextTick(targets);
await sleep(wait);
pushOnce();
}
}
// 吃药调度器,批量处理所有吃药请求
async function eatDispatcher() {
let firstRun = true;
while (true) {
if (eatQueue.length === 0) {
await sleep(500);
await sleep(200);
continue;
}
// 合并短时间内的请求等待1.5秒收集更多任务
await sleep(1500);
// 只有非首次才等待 1.5 s 做批量
if (!firstRun) await sleep(1500);
firstRun = false;
// 再次检查队列合并所有1.5秒内的请求
while (isBusy) await sleep(200); // 等待空闲
isBusy = true; // 上锁
const batch = flushEatQueue();
try {
keyPress("B"); await sleep(1000);//开启背包
click(865, 50); await sleep(100);//点击料理区域
let InventoryInterface = findInGameRegion(InventoryInterfaceRo);
//开启背包
// 取出所有要吃的药,去重,保证每个 row,col 只点一次
const uniqueMap = new Map();
for (const t of eatQueue) {
if (typeof t.row === 'number' && typeof t.col === 'number') {
uniqueMap.set(`${t.row},${t.col}`, { row: t.row, col: t.col });
if (!InventoryInterface.isExist()) {
log.info("未检测到背包界面,尝试返回主界面并打开背包");
await genshin.returnMainUi();
keyPress("B"); await sleep(1000);
} else {
log.info("检测到处于背包界面");
}
let FoodInterface = findInGameRegion(FoodInterfaceRo);
if (!FoodInterface.isExist()) {
log.info("未处于食物界面,准备点击食物界面图标");
await findAndClick(DisabledFoodInterfaceRo); await sleep(600);// 点击食物界面图标
} else {
log.info("已经处于食物界面,准备点击料理区域");
}
// 计算提前补药的时间窗口
const now = Date.now();
const PRE_TIME = 5000; // 5秒提前量
// 只处理5秒内需要补的药
const coordTasks = batch.filter(t => t.type === 'coord' && (now - (t.lastTime || 0) + PRE_TIME >= t.time * 1000));// 行列
const searchTasks = batch.filter(t => t.type === 'search' && (now - (t.lastTime || 0) + PRE_TIME >= t.time * 1000));// 食物名
/* ---------- 2. 处理坐标点击 ---------- */
if (coordTasks.length) {
const grid = computeGridCoordinates(
coordTasks.map(t => t.data)
);
const now = Date.now();
for (const tk of coordTasks) {
if (now - (tk.lastTime || 0) >= tk.time * 1000) {
const c = grid.find(g =>
g.row === tk.data.row && g.col === tk.data.col);
if (c) {
click(c.x, c.y); await sleep(50);
await findAndClick(UseButtonRo);
await handlePopupAfterUse();
tk.lastTime = now;
}
}
}
}
const batch = Array.from(uniqueMap.values());
eatQueue = []; // 清空全局队列,准备下一轮
const gridCoordinates = computeGridCoordinates(batch);
/* ---------- 3. 处理搜索吃药 ---------- */
for (const tk of searchTasks) {
const now = Date.now();
if (now - (tk.lastTime || 0) < tk.time * 1000) continue;
try {
/* 3-2 判断是否已处于搜索界面(通过 SearchButton 是否存在) */
let SearchButton = findInGameRegion(SearchButtonRo);
if (!SearchButton.isExist()) {
log.info("未处于搜索界面,先点【筛选】");
await findAndClick(FilterButtonRo);
await sleep(300);
} else {
log.info("已处于搜索界面,直接点击搜索框");
}
/* 3-3 点击搜索框 -> 输入食物名 -> 确认筛选 */
await findAndClick(SearchButtonRo);
await sleep(200);
if (typeof inputText === 'function') {
inputText(tk.data.name); // 推荐:直接输入字符串
} else {
log.error("请实现 inputText 方法以支持中文输入");
}
await sleep(200);
await findAndClick(ConfirmFilterButtonRo);
await sleep(1000);
/* 3-4 只有 nth>1 时才需要精确定位并点击,否则直接点第 1 个 */
const nth = tk.data.nth || 1;
// 点击料理图标并使用。遍历目标,查找并点击对应坐标
for (const target of batch) {
const coord = gridCoordinates.find(g => g.row === target.row && g.col === target.col);
if (coord) {
click(coord.x, coord.y); await sleep(50); // 点击料理图标
click(1760, 1025); await sleep(50); // 点击使用
if (nth !== 1) {
// 默认第 1 个已高亮,无需再计算坐标
// 计算第 n 个食物坐标(一行 8 个)
const col = ((nth - 1) % 8) + 1;
const row = Math.floor((nth - 1) / 8) + 1;
const x = Math.round(182.5 + (col - 1) * 145);
const y = Math.round(197.5 + (row - 1) * 175);
click(x, y);
await sleep(300);
}
/* 3-5 使用 */
await findAndClick(UseButtonRo);
await handlePopupAfterUse();
/* 3-6 重置筛选 */
await findAndClick(FilterButtonRo);
await sleep(200);
await findAndClick(ResetButtonRo);
await sleep(200);
tk.lastTime = now;
} catch (err) {
log.error(`搜索吃药流程异常: ${err.message}`);
}
}
log.info("批量吃药完成,准备返回主界面");
await genshin.returnMainUi(); // 返回主界面
} catch (error) {
log.error(`批量吃药时发生异常: ${error.message}`);
await genshin.returnMainUi(); // 返回主界面
} finally {
isBusy = false; // 无论如何都解锁
}
@@ -149,7 +427,7 @@ async function eatDispatcher() {
// 启动吃药调度器和各组任务,并等待它们(实际上这些都是死循环,不会退出)
await Promise.all([
eatDispatcher(), // 启动吃药调度器
...queueList.map(({ queue, seconds }) => runQueueTask(queue, seconds)) // 启动各组任务
...queueList.map(({ queue }) => runQueueTask(queue)) // 启动各组任务
]);
})();

View File

@@ -1,9 +1,9 @@
{
"manifest_version": 1,
"name": "自动循环使用料理",
"version": "1.0",
"name": "自动循环使用背包料理",
"version": "1.1",
"bgi_version": "0.41.0",
"description": "循环使用背包中指定位置的料理支持300秒、600秒、1800秒、1500秒循环模式。需要在设置中配置料理行列位置。使用前请先将背包中的料理标星置处理。",
"description": "循环使用背包中的料理。需要在设置中配置料理行列位置或名称。建议使用行列模式,将料理标星置处理。",
"tags": ["循环料理使用"],
"authors": [
{

View File

@@ -2,21 +2,6 @@
{
"name": "queue1",
"type": "input-text",
"label": "料理的行列位置 使用【逗号】分隔\n多个料理使用【分号】分隔\n\n300秒循环模式"
},
{
"name": "queue2",
"type": "input-text",
"label": "900秒循环模式"
},
{
"name": "queue3",
"type": "input-text",
"label": "1800秒循环模式"
},
{
"name": "queue4",
"type": "input-text",
"label": "1500秒循环模式"
"label": "自动循环补Buff类药\n料理的行补药间隔 \n料理名补药间隔搜索后食用第几个\n补药间隔默认300s,搜索食用默认第1个\n使用示例【2,2,900;致水神】\n900s循环使用第二行第二个料理\n搜索“致水神”后使用第1个\n多条用分号隔开"
}
]