diff --git a/repo/js/AutoFriendshipFight/README.md b/repo/js/AutoFriendshipFight/README.md index a21c606b0..fe41de66c 100644 --- a/repo/js/AutoFriendshipFight/README.md +++ b/repo/js/AutoFriendshipFight/README.md @@ -1,4 +1,7 @@ -### 作者:[秋云](https://github.com/physligl) +### 作者 +- [秋云](https://github.com/physligl)(原作者) +- 火山 +- 莫酱 > 自动化刷取角色好感度的脚本,支持盗宝团、愚人众、鳄鱼等敌人类型(蕈兽与雷萤术士为测试项)。通过自动触发和完成突发任务来获得好感度经验。 @@ -19,7 +22,7 @@ - **日限制**: 每日最多获得10次好感度奖励,50次任务触发,在别的地方触发过好感任务会扣减相应次数。 -- **循环上限**: 单次运行最多50次循环,自动检测好感任务触发,不再触发时自动结束。 +- **循环控制**: 运行次数由 `runTimes` 控制;若未触发任务或满足提前终止条件会自动结束。 - **平均时长**: 每次循环约1分钟,效率高时可以达到40秒单次循环。 - **成功率**: 配置正确的情况下接近98%,有概率丢失第一次好感任务。 @@ -66,7 +69,7 @@ - **练度要求**: 建议较高练度,纯好感队可能无法击败敌人 - **重要设置**: - 默认:❌ 关闭"自动检测战斗结束" - - 若勾选“禁用异步战斗(更稳定)”:✅ 需要开启并正确配置"自动检测战斗结束" + - 若勾选“禁用异步战斗(更稳定)”:✅ 需要开启并正确配置"自动检测战斗结束"(对所有敌人生效) - **推荐队伍配置** > 基于社区测试的高效角色推荐,其他效率高的角色欢迎通过 pull request 添加到本文档,需附带理由和演示视频。 @@ -80,15 +83,16 @@ #### **脚本设置** > 通过调度器脚本右键-修改JS自定义配置 - - 拾取模式 - - 缺对应材料时,建议选用:自动拾取 - - 不缺对应材料时,建议选用:不拾取 - - 避免每天运行,拾取過多不能分解的掉落物,导至不能分解的掉落物逹致上限 - - 敌人类型: 选择"盗宝团"或"愚人众" - - 运行次数: 10次或者50次 - - 目标是好感选择10次 - - 目标是锄地摩拉选择50次 - - 目标是掉落物暂时不支持 + - 禁用自动拾取实时任务(`disablePickup`) + - 不勾选:启用自动拾取(默认) + - 勾选:禁用自动拾取 + - 敌人类型: 支持"盗宝团"、"愚人众"、"鳄鱼"、"蕈兽"、"雷萤术士" + - 运行次数: + - 请输入正整数(默认10次) + - 刷好感通常建议 10 次以内,避免超过每日奖励上限 + - 清理丘丘人超时时间(`qiuQiuRen`,秒) + - 默认 0(不执行) + - 仅盗宝团有效,填入大于 0 的秒数后会先执行清理阶段 - 队伍名称: - 填入您的战斗队伍名称 - 不填默认当前队伍 @@ -101,9 +105,9 @@ - 单次战斗超时时间(默认120秒) - 战斗超时会直接取消任务,因此不建议过短 - 脚本会自动检测任务结束,识别精度很高 - - 禁用异步战斗(更稳定;需配置“战斗结束检测”;对所有敌人生效) - - 依赖“自动检测战斗结束”退出战斗 - - 开启后包括盗宝团在内的所有敌人都走同步战斗模式 + - 禁用异步战斗(更稳定;需配置“战斗结束检测”;对所有敌人生效) + - 开启后统一走同步战斗执行分支 + - 关闭时走异步战斗 + OCR 判定分支 #### 一键运行 配置完成后,直接运行脚本即可开始自动刷取好感度。 @@ -227,10 +231,7 @@ | 版本 | 更新内容 | |------|----------| -| **v1.5.8** | 稳定性与规范性优化:统一截图托管与退出清理,补强异步战斗收口;禁用异步战斗对所有敌人生效;路径统一为 `assets/`,代码风格与注释同步整理 | -| **v1.5.7** | 退出清理阶段补充释放模板匹配对象并延长宽限期,进一步降低宿主侧异步回调访问已释放对象的概率 | -| **v1.5.6** | 移除游泳统计持久化与相关配置,减少运行期写文件干扰;资源路径统一为 `assets/` | -| **v1.5.5** | 稳定性提升:退出释放资源后增加短延时,降低 ObjectDisposedException 概率;战斗任务清理逻辑优化,避免清理阶段卡住 | +| **v2.0.0** | `main.js` 重构版本:重点修复 V8 弹窗报错与内存溢出问题;统一敌人配置驱动与主循环流程;“禁用异步战斗”明确为对所有敌人生效;移除卡时间功能及相关配置入口 | | **v1.5.3** | 稳定性提升:资源释放更完整;盗宝团游泳统计仅在盗宝团启用;“禁用异步战斗”增加超时保护避免卡死;设置说明更易懂 | | **v1.4.2** | 新增拾取模式,能选择不拾取 | | **v1.3** | 新增愚人众支持,优化敌人类型切换 | diff --git a/repo/js/AutoFriendshipFight/assets/AutoPath/盗宝团-准备.json b/repo/js/AutoFriendshipFight/assets/AutoPath/盗宝团-准备.json index d4e850b5d..a9ecabbaa 100644 --- a/repo/js/AutoFriendshipFight/assets/AutoPath/盗宝团-准备.json +++ b/repo/js/AutoFriendshipFight/assets/AutoPath/盗宝团-准备.json @@ -31,8 +31,7 @@ "y": -3450.83, "type": "path", "move_mode": "dash", - "action": "fight", - "action_params": "" + "action": "" } ] } \ No newline at end of file diff --git a/repo/js/AutoFriendshipFight/main.js b/repo/js/AutoFriendshipFight/main.js index 6c20cc166..f9e1c83b4 100644 --- a/repo/js/AutoFriendshipFight/main.js +++ b/repo/js/AutoFriendshipFight/main.js @@ -1,506 +1,153 @@ -// ---------------------------- -// AutoFriendshipFight 主脚本 -// - 通过 OCR 识别“突发事件”任务文本来判定是否触发 -// - 通过地图路径文件导航到触发点/战斗点 -// - 通过自动战斗任务完成战斗,并用 OCR 判定胜负/是否离开触发区域 -// - 可选:后台检测是否出现经验/摩拉图标,用于提前终止循环 -// ---------------------------- - -// 默认配置(当 settings.json 未设置或设置非法时使用) -const DEFAULT_RUNS = 10; -const DEFAULT_OCR_TIMEOUT_SECONDS = 10; -const DEFAULT_FIGHT_TIMEOUT_SECONDS = 120; - -const ERROR_CODES = { - BATTLE_TIMEOUT: "BATTLE_TIMEOUT", -}; - -const ERR_MESSAGES = { - BATTLE_TIMEOUT: "战斗超时,未检测到结果", - SWIM_RECOVERED: "检测到游泳且自动回七天神像,视为本轮失败", -}; - // 掉落检测状态 // detectedExpOrMora:上一轮是否识别到经验/摩拉图标(用于判断“连续两轮都没有掉落则停”) // noExpOrMoraCount:连续“没有识别到掉落”的计数器 -// running:后台循环 detectExpOrMora 的停止开关(必须在任何退出路径上关闭,避免后台占用) let detectedExpOrMora = true; let noExpOrMoraCount = 0; let running = true; -const GAME_REGION_CACHE_SIZE = 5; -const GAME_REGION_MIN_INTERVAL_MS = 17; -const gameRegionManager = { - cache: [], - borrowCountByRegion: new Map(), - lastCaptureTs: 0, - isDisposing: false, - isCapturing: false, -}; +let fighting = false; +let consecutiveMaxRetryCount = 0; const warnedEnemyTypes = new Set(); -const ACTIVITY_LIST_OCR_REGION = { x: 0, y: 200, w: 300, h: 300 }; // 默认突发任务 OCR 关键词(敌人配置未提供时使用) const DEFAULT_OCR_KEYWORDS = ["突发", "任务", "打倒", "消灭", "敌人", "所有"]; // 各敌人类型的参数配置 // - ocrKeywords:用于 detectTaskTrigger / waitForBattleResult 的 OCR 关键字 -// - triggerPoint:触发点坐标(用于到位校验与二次导航) -// - targetCoords:战斗点坐标(用于到位校验) // - preparePath:准备路径(通常是传送点->触发区域附近) // - postBattlePath:战斗后补充路径(如拾取/对话) // - failReturnPath:失败后的回退路径(通常回到准备点/触发点) // - failReturnSleepMs:失败回退后额外等待(给加载/稳定留时间) // - initialDelayMs:首轮额外等待(给地图/加载/触发留时间) const ENEMY_CONFIG = { - 愚人众: { - ocrKeywords: [ - "买卖", - "不成", - "正义存", - "愚人众", - "禁止", - "危险", - "运输", - "打倒", - "盗宝团", - "丘丘人", - "今晚", - "伙食", - "所有人", - ], - targetCoords: { x: 4840.55, y: -3078.01 }, - triggerPoint: { x: 4783.79, y: -3065.62 }, - preparePath: "愚人众-准备", - failReturnPath: "愚人众-准备", - }, - 盗宝团: { - ocrKeywords: ["岛上", "无贼", "消灭", "鬼鬼祟祟", "盗宝团"], - targetCoords: { x: -2753.04, y: -3459.3025 }, - triggerPoint: { x: -2736.6, y: -3415.44 }, - swimRecoveryEnabled: true, - }, - 鳄鱼: { - ocrKeywords: ["张牙", "舞爪", "恶党", "鳄鱼", "打倒", "所有", "鳄鱼"], - targetCoords: { x: 3578.08, y: -500.75 }, - triggerPoint: { x: 3614.63, y: -521.6 }, - preparePath: "鳄鱼-准备", - failReturnPath: "鳄鱼-准备", - failReturnSleepMs: 5000, - initialDelayMs: 5000, - postBattlePath: "鳄鱼-拾取", - }, - 蕈兽: { - ocrKeywords: ["实验家", "变成", "实验品", "击败", "所有", "魔物"], - targetCoords: { x: 3794.55, y: -350.6 }, - triggerPoint: { x: 3749.38, y: -391.91 }, - preparePath: "蕈兽-准备", - postBattlePath: "蕈兽-对话", - }, - 雷萤术士: { - ocrKeywords: [ - "雷萤", - "术士", - "圆滚滚", - "不可食用", - "威撼", - "攀岩", - "消灭", - "准备", - "打倒", - "所有", - "魔物", - "盗宝团", - "击败", - "成员", - "盗亦无道", - ], - targetCoords: { x: 883.91, y: 656.63 }, - triggerPoint: { x: 881.92, y: 616.85 }, - preparePath: "雷萤术士-准备", - }, + "愚人众": { + ocrKeywords: ["买卖", "不成", "正义存", "愚人众", "禁止", "危险", "运输", "打倒", "盗宝团", "丘丘人", "今晚", "伙食", "所有人"], + preparePath: "愚人众-准备", + failReturnPath: "愚人众-准备", + }, + "盗宝团": { + ocrKeywords: ["岛上", "无贼", "消灭", "鬼鬼祟祟", "盗宝团"], + }, + "鳄鱼": { + ocrKeywords: ["张牙", "舞爪", "恶党", "鳄鱼", "打倒", "所有", "鳄鱼"], + preparePath: "鳄鱼-准备", + failReturnPath: "鳄鱼-准备", + failReturnSleepMs: 5000, + initialDelayMs: 5000, + postBattlePath: "鳄鱼-拾取", + }, + "蕈兽": { + ocrKeywords: ["实验家", "变成", "实验品", "击败", "所有", "魔物"], + preparePath: "蕈兽-准备", + postBattlePath: "蕈兽-对话", + }, + "雷萤术士": { + ocrKeywords: ["雷萤", "术士", "圆滚滚", "不可食用", "威撼", "攀岩", "消灭", "准备", "打倒", "所有", "魔物", "盗宝团", "击败", "成员", "盗亦无道"], + preparePath: "雷萤术士-准备", + }, }; // 经验/摩拉模板匹配资源 // 这里保留 Mat 引用,便于脚本结束时主动释放,降低长时间运行的资源占用风险 let expMat = file.ReadImageMatSync("assets/exp.png"); -let expRo = RecognitionObject.TemplateMatch( - expMat, - 74, - 341, - 207 - 74, - 803 - 341, -); +const expRo = RecognitionObject.TemplateMatch(expMat, 74, 341, 207 - 74, 803 - 341); expRo.Threshold = 0.85; expRo.Use3Channels = true; expRo.InitTemplate(); let moraMat = file.ReadImageMatSync("assets/mora.png"); -let moraRo = RecognitionObject.TemplateMatch( - moraMat, - 74, - 341, - 207 - 74, - 803 - 341, -); +const moraRo = RecognitionObject.TemplateMatch(moraMat, 74, 341, 207 - 74, 803 - 341); moraRo.Threshold = 0.85; moraRo.Use3Channels = true; moraRo.InitTemplate(); -/** - * 脚本入口:读取配置并执行主循环;退出时停止后台检测并释放模板资源。 - */ -(async function () { - const startTime = Date.now(); - let enemyType = "盗宝团"; - try { - // 可选:启用自动拾取实时任务 - if (convertToTrueIfNotBoolean(settings.pickupMode)) { - dispatcher.addTimer(new RealtimeTimer("AutoPick")); - log.info("已 启用 自动拾取任务"); - } else { - log.info("已 禁用 自动拾取任务"); - } - // 修复点:runTimes 必须用 let/const 声明,避免污染全局变量 - let runTimes = await calculateRunTimes(); - await switchPartyIfNeeded(settings.partyName); +const GAME_REGION_CACHE_SIZE = 3; // 游戏区域截图缓存大小上限 +const gameRegionManager = { + cache: [], // 缓存队列,保存近GAME_REGION_CACHE_SIZE张截图 + lastCapture: new Date(), + isDisposing: false, + isCapturing: false +}; - // 选择敌人类型(默认盗宝团) - enemyType = settings.enemyType || "盗宝团"; +let runTimes = parseNumericSetting(settings.runTimes, 10); +let enemyType = settings.enemyType || "盗宝团"; +const ocrTimeout = parseNumericSetting(settings.ocrTimeout, 10); +const fightTimeout = parseNumericSetting(settings.fightTimeout, 120); +(async function () { + const startTime = Date.now(); + await switchPartyIfNeeded(settings.partyName); log.info(`当前选择的敌人类型: ${enemyType}`); log.info(`${enemyType}好感开始...`); - - // 敌人准备流程(部分敌人需要先走准备路径/清理) - await prepareForEnemy(enemyType); - // 超时设置校验(非法则回退默认值) - const ocrTimeout = validateTimeoutSetting( - settings.ocrTimeout, - DEFAULT_OCR_TIMEOUT_SECONDS, - "OCR", - ); - const fightTimeout = validateTimeoutSetting( - settings.fightTimeout, - DEFAULT_FIGHT_TIMEOUT_SECONDS, - "战斗", - ); - - // 主循环入口 - await runFriendshipLoop(runTimes, ocrTimeout, fightTimeout, enemyType); - log.info(`${enemyType}好感运行总时长:${formatElapsedTime(startTime)}`); - } catch (error) { - if (isCancellationError(error)) { - log.info("脚本已取消"); - return; + if (settings.disablePickup) { + log.info("已 禁用 自动拾取任务"); + } else { + dispatcher.addTimer(new RealtimeTimer("AutoPick")); + log.info("已 启用 自动拾取任务"); } - log.error( - `脚本运行出错: ${error && error.message ? error.message : error}`, - ); - notification.error( - `脚本运行出错: ${error && error.message ? error.message : error}`, - ); - } finally { - // 修复点:无论成功/失败/异常,都必须停止后台循环与释放模板资源 - running = false; - // 给 .NET Task 续延留出执行时间,避免 V8 释放后访问已释放对象导致 ObjectDisposedException - await sleep(1000); - await flushGameRegionCache(); - safeDispose(expRo); - safeDispose(moraRo); - safeDispose(expMat); - safeDispose(moraMat); - expRo = null; - moraRo = null; - expMat = null; - moraMat = null; - } + //准备 + try { + // 清理丘丘人(仅盗宝团需要) + const qiuQiuRenTimeout = parseNumericSetting(settings.qiuQiuRen, 0); + if (qiuQiuRenTimeout > 0 && enemyType === "盗宝团") { + log.info("清理原住民..."); + await AutoPath("盗宝团-准备"); + + log.info("开始清理战斗,超时时间: {0}秒...", qiuQiuRenTimeout); + const clearCts = new CancellationTokenSource(); + try { + if (settings.disableAsyncFight) { + fighting = true; + await dispatcher.runTask(new SoloTask("AutoFight")); + fighting = false; + } else { + const clearTask = dispatcher.runTask(new SoloTask("AutoFight"), clearCts); + fighting = true; + const maxLoops = Math.ceil(4 * qiuQiuRenTimeout); + const timeoutTask = (async () => { + for (let i = 0; i < maxLoops; i++) { + try { await sleep(1) } catch (e) { break; } + if (!fighting) break; + await sleep(250); + } + if (fighting) { + try { clearCts.cancel(); } catch { } + } + })(); + await clearTask; + fighting = false; + await Promise.allSettled([clearTask, timeoutTask]); + } + } catch (e) { + log.warn(`清理战斗异常: ${e.message}`); + } finally { + fighting = false; + try { clearCts.cancel(); } catch { } + } + } + + const { preparePath } = getEnemyConfig(enemyType); + if (preparePath) { + log.info(`导航到${enemyType}触发点...`); + await AutoPath(preparePath); + } + } catch (error) { } + //主循环 + try { + await AutoFriendshipDev(); + log.info(`${enemyType}好感运行总时长:${LogTimeTaken(startTime)}`); + } catch (error) { log.error(`主循环中出现错误 ${error.message}`) } })(); /** - * 将 settings 中非布尔值按默认 true 处理(未设置视为开启)。 - * @param {*} value - * @returns {boolean} - */ -function convertToTrueIfNotBoolean(value) { - // settings 里可能是 undefined/字符串等非布尔值,此处按“未设置则视为开启”处理 - return typeof value === "boolean" ? value : true; -} - -/** - * 判断是否为取消任务导致的错误。 - * @param {*} error - * @returns {boolean} - */ -function isCancellationError(error) { - const msg = error && error.message ? String(error.message) : ""; - return msg.includes("取消自动任务") || msg.includes("A task was canceled."); -} - -/** - * 安全释放宿主对象资源(兼容 Dispose/dispose)。 - * @param {*} obj - */ -function safeDispose(obj) { - if (!obj) return; - const fn = - (typeof obj.Dispose === "function" ? obj.Dispose : null) || - (typeof obj.dispose === "function" ? obj.dispose : null); - if (!fn) return; - try { - fn.call(obj); - } catch { - void 0; - } -} - -/** - * 统一释放截图缓存中的旧对象,仅保留最近若干帧。 - * @returns {Promise} - */ -async function disposeOldGameRegions() { - gameRegionManager.isDisposing = true; - try { - while (gameRegionManager.cache.length > GAME_REGION_CACHE_SIZE) { - const disposableIndex = gameRegionManager.cache.findIndex( - (region) => - (gameRegionManager.borrowCountByRegion.get(region) || 0) <= 0, - ); - if (disposableIndex < 0) { - break; - } - const [oldestRegion] = gameRegionManager.cache.splice(disposableIndex, 1); - safeDispose(oldestRegion); - gameRegionManager.borrowCountByRegion.delete(oldestRegion); - } - } finally { - gameRegionManager.isDisposing = false; - } -} - -/** - * 标记托管截图被借用(调用方开始使用)。 - * @param {*} region - */ -function retainManagedGameRegion(region) { - if (!region) return; - const prev = gameRegionManager.borrowCountByRegion.get(region) || 0; - gameRegionManager.borrowCountByRegion.set(region, prev + 1); -} - -/** - * 标记托管截图归还(调用方结束使用)。 - * @param {*} region - */ -function releaseManagedGameRegion(region) { - if (!region) return; - const prev = gameRegionManager.borrowCountByRegion.get(region) || 0; - if (prev <= 1) { - gameRegionManager.borrowCountByRegion.delete(region); - } else { - gameRegionManager.borrowCountByRegion.set(region, prev - 1); - } -} - -/** - * 获取游戏区域截图(统一入口):带最小截图间隔与缓存清理。 - * 注意:调用方不应释放返回的 region,生命周期由缓存管理器统一托管。 - * @param {number} [minIntervalMs=17] - * @param {boolean} [asyncDispose=false] - * @returns {Promise<*>} - */ -async function getManagedGameRegion( - minIntervalMs = GAME_REGION_MIN_INTERVAL_MS, - asyncDispose = false, -) { - while (gameRegionManager.isCapturing) { - await sleep(1); - } - - gameRegionManager.isCapturing = true; - try { - const now = Date.now(); - if ( - now - gameRegionManager.lastCaptureTs >= minIntervalMs || - gameRegionManager.cache.length === 0 - ) { - while (gameRegionManager.isDisposing) { - await sleep(1); - } - const region = captureGameRegion(); - gameRegionManager.cache.push(region); - gameRegionManager.lastCaptureTs = now; - if (asyncDispose) { - disposeOldGameRegions(); - } else { - await disposeOldGameRegions(); - } - } - const region = - gameRegionManager.cache[gameRegionManager.cache.length - 1] || null; - retainManagedGameRegion(region); - return region; - } catch (error) { - log.error( - `获取游戏区域截图失败: ${error && error.message ? error.message : error}`, - ); - const region = - gameRegionManager.cache[gameRegionManager.cache.length - 1] || null; - retainManagedGameRegion(region); - return region; - } finally { - gameRegionManager.isCapturing = false; - } -} - -/** - * 释放截图缓存中的所有对象(脚本退出时调用)。 - * @returns {Promise} - */ -async function flushGameRegionCache() { - const waitFlagsDeadline = Date.now() + 3000; - while ( - (gameRegionManager.isCapturing || gameRegionManager.isDisposing) && - Date.now() < waitFlagsDeadline - ) { - await sleep(1); - } - if (gameRegionManager.isCapturing || gameRegionManager.isDisposing) { - log.warn("flushGameRegionCache 等待截图状态释放超时,跳过本次强制清理"); - return; - } - - gameRegionManager.isDisposing = true; - try { - const flushDeadline = Date.now() + 3000; - while (gameRegionManager.cache.length > 0) { - const disposableIndex = gameRegionManager.cache.findIndex( - (region) => - (gameRegionManager.borrowCountByRegion.get(region) || 0) <= 0, - ); - if (disposableIndex < 0) { - if (Date.now() >= flushDeadline) { - log.warn( - "flushGameRegionCache 等待借用截图归还超时,保留仍被借用的截图对象", - ); - break; - } - await sleep(1); - continue; - } - const [region] = gameRegionManager.cache.splice(disposableIndex, 1); - safeDispose(region); - gameRegionManager.borrowCountByRegion.delete(region); - } - const inCache = new Set(gameRegionManager.cache); - for (const region of Array.from( - gameRegionManager.borrowCountByRegion.keys(), - )) { - if (!inCache.has(region)) { - gameRegionManager.borrowCountByRegion.delete(region); - } - } - if (gameRegionManager.cache.length === 0) { - gameRegionManager.lastCaptureTs = 0; - } - } finally { - gameRegionManager.isDisposing = false; - } -} - -/** - * 创建带错误码的 Error,便于上层结构化处理。 - * @param {string} code - * @param {string} message - * @returns {Error} - */ -function createScriptError(code, message) { - const err = new Error(message); - err.code = code; - return err; -} - -/** - * 判断 CancellationTokenSource 是否已请求取消(兼容不同字段命名)。 - * @param {*} cts - * @returns {boolean} - */ -function isCtsCancellationRequested(cts) { - try { - const v = - cts?.isCancellationRequested ?? - cts?.IsCancellationRequested ?? - cts?.token?.isCancellationRequested ?? - cts?.token?.IsCancellationRequested; - return v === true; - } catch { - return false; - } -} - -/** - * 获取地图坐标,失败时返回 null(取消错误会透传)。 + * 获取地图坐标,失败时返回 null * @returns {{x:number,y:number}|null} */ function safeGetPositionFromMap() { - try { - return genshin.getPositionFromMap(); - } catch (error) { - if (isCancellationError(error)) { - throw error; + try { + return genshin.getPositionFromMap(); + } catch (error) { + return null; } - return null; - } -} - -/** - * 规范化 OCR 文本:转字符串并移除空白。 - * @param {*} text - * @returns {string} - */ -function normalizeOcrText(text) { - const s = text == null ? "" : String(text); - return s.replace(/\s+/g, ""); -} - -/** - * 从指定截图中提取活动列表 OCR 文本(多结果拼接),仅释放内部 OCR 结果对象。 - * @param {*} captureRegion - * @returns {string} - */ -function readActivityListTextFromCapture(captureRegion) { - let resList = null; - const chunks = []; - try { - const { x, y, w, h } = ACTIVITY_LIST_OCR_REGION; - resList = captureRegion.findMulti(RecognitionObject.ocr(x, y, w, h)); - for (let i = 0; i < resList.count; i++) { - let res = resList[i]; - try { - if (res && res.text) { - chunks.push(String(res.text)); - } - } finally { - safeDispose(res); - } - } - } finally { - safeDispose(resList); - } - return normalizeOcrText(chunks.join("")); -} - -/** - * 基于托管截图并 OCR 识别活动列表区域文本(统一封装)。 - * @returns {Promise} - */ -async function readActivityListText() { - let captureRegion = null; - try { - captureRegion = await getManagedGameRegion(); - if (!captureRegion) return ""; - return readActivityListTextFromCapture(captureRegion); - } finally { - releaseManagedGameRegion(captureRegion); - } } /** @@ -509,255 +156,87 @@ async function readActivityListText() { * @returns {object} */ function getEnemyConfig(enemyType) { - // 根据敌人类型返回配置对象(不存在则返回空对象,调用方负责兜底) - const cfg = ENEMY_CONFIG[enemyType]; - if (!cfg && !warnedEnemyTypes.has(enemyType)) { - warnedEnemyTypes.add(enemyType); - log.warn(`未知 enemyType: ${enemyType},将使用默认配置`); - } - return cfg || {}; -} - -/** - * 游泳回神像异常跟踪开关(统一判定入口,可配置扩展)。 - * 优先读取敌人配置 swimRecoveryEnabled,未配置时兼容旧行为(仅盗宝团启用)。 - * @param {string} enemyType - * @returns {{enabled:boolean}} - */ -function SwimTracker(enemyType) { - const cfg = getEnemyConfig(enemyType); - const enabled = - typeof cfg.swimRecoveryEnabled === "boolean" - ? cfg.swimRecoveryEnabled - : enemyType === "盗宝团"; - return { enabled }; -} - -function isSwimRecoveryEnabled(enemyType) { - return SwimTracker(enemyType).enabled; -} - -/** - * 敌人准备流程:执行 preparePath / 盗宝团可选清理丘丘人。 - * @param {string} enemyType - * @returns {Promise} - */ -async function prepareForEnemy(enemyType) { - // 清理丘丘人(仅盗宝团需要) - if (settings.qiuQiuRen && enemyType === "盗宝团") { - log.info("清理原住民..."); - await AutoPath("盗宝团-准备"); - } - - const { preparePath } = getEnemyConfig(enemyType); - if (preparePath) { - log.info(`导航到${enemyType}触发点...`); - await AutoPath(preparePath); - } + // 根据敌人类型返回配置对象(不存在则返回空对象,调用方负责兜底) + const cfg = ENEMY_CONFIG[enemyType]; + if (!cfg && !warnedEnemyTypes.has(enemyType)) { + warnedEnemyTypes.add(enemyType); + log.warn(`未知 enemyType: ${enemyType},将使用默认配置`); + } + return cfg || {}; } /** * 战后附加流程:按敌人配置跑 postBattlePath;蕈兽包含对话交互。 - * @param {string} enemyType * @returns {Promise} */ -async function runPostBattle(enemyType) { - // 战斗后处理:按敌人配置执行附加路径;部分敌人需要对话交互 - const { postBattlePath } = getEnemyConfig(enemyType); - if (postBattlePath) { - await AutoPath(postBattlePath); - } +async function runPostBattle() { + // 战斗后处理:按敌人配置执行附加路径;部分敌人需要对话交互 + const { postBattlePath } = getEnemyConfig(enemyType); + if (postBattlePath) { + await AutoPath(postBattlePath); + } - if (enemyType === "蕈兽") { - await sleep(50); - keyPress("F"); - await sleep(50); - keyPress("F"); - await sleep(500); - await genshin.chooseTalkOption("下次"); - await sleep(500); - } + if (enemyType === "蕈兽") { + await sleep(50); + keyPress("F"); + await sleep(50); + keyPress("F"); + await sleep(500); + await genshin.chooseTalkOption("下次"); + await sleep(500); + } } /** - * 失败恢复:回七天神像(可跳过)并走回退路径。 - * @param {string} enemyType - * @param {boolean} [skipTp=false] + * 失败恢复:回七天神像并走回退路径。 * @returns {Promise} */ -async function recoverAfterFailure(enemyType, skipTp = false) { - if (!skipTp) { +async function recoverAfterFailure() { + //失败统一返回七天神像 await genshin.tpToStatueOfTheSeven(); - } - const { failReturnPath, failReturnSleepMs, preparePath } = - getEnemyConfig(enemyType); - if (failReturnPath) { - await AutoPath(failReturnPath); - } else if (preparePath) { - await AutoPath(preparePath); - } else { - await AutoPath(`${enemyType}-触发点`); - } - if (failReturnSleepMs) { - await sleep(failReturnSleepMs); - } + const { failReturnPath, failReturnSleepMs, preparePath } = getEnemyConfig(enemyType); + if (failReturnPath) { + await AutoPath(failReturnPath); + } else if (preparePath) { + await AutoPath(preparePath); + } else { + await AutoPath(`${enemyType}-触发点`); + } + if (failReturnSleepMs) { + await sleep(failReturnSleepMs); + } } -// 执行 path 任务 + /** * 执行路径文件(AutoPath/*.json);失败返回 false,取消错误透传。 * @param {string} locationName * @returns {Promise} */ async function AutoPath(locationName) { - // 统一包装路径执行:避免 runFile 抛错导致整个脚本中断 - try { - const filePath = `assets/AutoPath/${locationName}.json`; - await pathingScript.runFile(filePath); - return true; - } catch (error) { - if (isCancellationError(error)) { - throw error; + // 统一包装路径执行:避免 runFile 抛错导致整个脚本中断 + try { + const filePath = `assets/AutoPath/${locationName}.json`; + await pathingScript.runFile(filePath); + return true; + } catch (error) { + log.error(`执行 ${locationName} 路径时发生错误: ${error.message}`); + return false; } - log.error(`执行 ${locationName} 路径时发生错误: ${error.message}`); - return false; - } } -/** - * 统一路径字符串用于比较(反斜杠转正斜杠并去重)。 - * @param {string} p - * @returns {string} - */ -function normalizePathForCompare(p) { - if (!p || typeof p !== "string") return ""; - return p.replace(/\\/g, "/").replace(/\/+/g, "/"); -} - -/** - * 安全判断文件是否存在(宿主缺少 exists API 时的兜底实现)。 - * @param {string} filePath - * @returns {boolean} - */ -function fileExistsSafe(filePath) { - try { - if (!filePath || typeof filePath !== "string") return false; - if (file.isFolder(filePath)) return false; - const normalized = normalizePathForCompare(filePath); - const lastSlash = normalized.lastIndexOf("/"); - const dir = lastSlash >= 0 ? normalized.slice(0, lastSlash) : "."; - const name = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; - const items = file.readPathSync(dir); - if (!items || items.length === 0) return false; - for (const item of items) { - const n = normalizePathForCompare(item); - if (n === normalized) return true; - const nLastSlash = n.lastIndexOf("/"); - const nName = nLastSlash >= 0 ? n.slice(nLastSlash + 1) : n; - if (nName === name) return true; - } - return false; - } catch { - return false; - } -} - -const battlePointCache = new Map(); - -/** - * 从战斗点路径文件中推导最后一个坐标(缓存);失败则回退配置的 targetCoords。 - * @param {string} enemyType - * @returns {{x:number,y:number}|null} - */ -function getBattlePointFromBattlePath(enemyType) { - if (battlePointCache.has(enemyType)) { - return battlePointCache.get(enemyType); - } - const filePath = `assets/AutoPath/${enemyType}-战斗点.json`; - let point = null; - try { - if (fileExistsSafe(filePath)) { - const raw = file.readTextSync(filePath); - const parsed = JSON.parse(raw); - const positions = - parsed && Array.isArray(parsed.positions) ? parsed.positions : null; - if (positions && positions.length > 0) { - const last = positions[positions.length - 1]; - if ( - last && - Number.isFinite(Number(last.x)) && - Number.isFinite(Number(last.y)) - ) { - point = { x: Number(last.x), y: Number(last.y) }; - } - } - } - } catch { - void 0; - } - if (!point) { - point = getTargetCoordinates(enemyType); - } - battlePointCache.set(enemyType, point); - return point; -} - -/** - * 执行战斗点路径;可选在最后节点注入 fight action(用于盗宝团旧流程)。 - * @param {string} enemyType - * @param {boolean} [injectFightAction=false] - * @returns {Promise} - */ -async function runBattlePathToBattlePoint( - enemyType, - injectFightAction = false, -) { - const filePath = `assets/AutoPath/${enemyType}-战斗点.json`; - if (!fileExistsSafe(filePath)) { - throw new Error(`缺少战斗点路径文件: ${filePath}`); - } - if (!injectFightAction) { - await pathingScript.runFile(filePath); - return; - } - const raw = file.readTextSync(filePath); - const parsed = JSON.parse(raw); - const positions = - parsed && Array.isArray(parsed.positions) ? parsed.positions : null; - if (!positions || positions.length === 0) { - throw new Error(`战斗点路径文件无有效 positions: ${filePath}`); - } - - const last = positions[positions.length - 1]; - const clonedPositions = positions.map((p) => ({ ...p })); - clonedPositions[clonedPositions.length - 1] = { - ...last, - action: "fight", - action_params: - last && typeof last.action_params === "string" ? last.action_params : "", - }; - - const payload = { - ...parsed, - positions: clonedPositions, - }; - await pathingScript.run(JSON.stringify(payload)); -} - -// 计算运行时长 /** * 计算从 startTime 到现在的耗时字符串。 * @param {number} startTimeParam * @returns {string} */ -function formatElapsedTime(startTimeParam) { - const currentTime = Date.now(); - const totalTimeInSeconds = (currentTime - startTimeParam) / 1000; - const minutes = Math.floor(totalTimeInSeconds / 60); - const seconds = totalTimeInSeconds % 60; - return `${minutes} 分 ${seconds.toFixed(0).padStart(2, "0")} 秒`; +function LogTimeTaken(startTimeParam) { + const currentTime = Date.now(); + const totalTimeInSeconds = (currentTime - startTimeParam) / 1000; + const minutes = Math.floor(totalTimeInSeconds / 60); + const seconds = totalTimeInSeconds % 60; + return `${minutes} 分 ${seconds.toFixed(0).padStart(2, '0')} 秒`; } -// 计算预估时间 /** * 估算完成时间(按平均单轮耗时线性推算)。 * @param {number} startTime @@ -765,501 +244,421 @@ function formatElapsedTime(startTimeParam) { * @param {number} total * @returns {string} */ -function estimateCompletionTime(startTime, current, total) { - if (current === 0) return "计算中..."; +function CalculateEstimatedCompletion(startTime, current, total) { + if (current === 0) return "计算中..."; - const elapsedTime = Date.now() - startTime; - const timePerTask = elapsedTime / current; - const remainingTasks = total - current; - const remainingTime = timePerTask * remainingTasks; - const completionDate = new Date(Date.now() + remainingTime); - return `${completionDate.toLocaleTimeString()} (约 ${Math.round(remainingTime / 60000)} 分钟)`; + const elapsedTime = Date.now() - startTime; + const timePerTask = elapsedTime / current; + const remainingTasks = total - current; + const remainingTime = timePerTask * remainingTasks; + const completionDate = new Date(Date.now() + remainingTime); + return `${completionDate.toLocaleTimeString()} (约 ${Math.round(remainingTime / 60000)} 分钟)`; } -// 检查并导航到触发点 /** - * 导航到触发点并做一次距离校验,偏离则二次导航。 - * @param {string} enemyType + * 导航到触发点 * @returns {Promise} */ -async function navigateToTriggerPoint(enemyType) { - await AutoPath(`${enemyType}-触发点`); - const triggerPoint = getTriggerPoint(enemyType); - if (!triggerPoint) { - log.warn(`未配置 ${enemyType} 的 triggerPoint,跳过触发点距离校验`); - return; - } - const pos = safeGetPositionFromMap(); +async function navigateToTriggerPoint() { + const path = `assets/AutoPath/${enemyType}-触发点.json`; + let triggerPoint = null; - if (pos) { - const distance = Math.sqrt( - Math.pow(pos.x - triggerPoint.x, 2) + Math.pow(pos.y - triggerPoint.y, 2), - ); - if (distance <= 8) { - log.info(`已到达触发点附近,距离: ${distance.toFixed(2)}米`); - } else { - log.info(`未到达触发点,当前距离: ${distance.toFixed(2)}米,正在导航...`); - await AutoPath(`${enemyType}-触发点`); + try { + const content = await file.readText(path); + const data = JSON.parse(content); + if (data.positions && Array.isArray(data.positions) && data.positions.length > 0) { + const lastPosition = data.positions[data.positions.length - 1]; + triggerPoint = { x: lastPosition.x, y: lastPosition.y }; + } + } catch (error) { + log.warn(`读取触发点配置失败: ${error.message}`); + } + + if (!triggerPoint) { + log.warn(`未配置 ${enemyType} 的 triggerPoint,跳过触发点距离校验`); + return; + } + + let retryCount = 0; + const maxRetries = 3; + + while (retryCount < maxRetries) { + try { await sleep(1) } catch (e) { break; } + if (!running) { + break; + } + const pos = safeGetPositionFromMap(); + + if (pos) { + const distance = Math.sqrt(Math.pow(pos.x - triggerPoint.x, 2) + Math.pow(pos.y - triggerPoint.y, 2)); + if (distance <= 8) { + log.info(`已到达触发点附近,距离: ${distance.toFixed(2)}米`); + return; + } else { + log.info(`未到达触发点,当前距离: ${distance.toFixed(2)}米,正在导航...`); + } + } + + await AutoPath(`${enemyType}-触发点`); + retryCount++; + } +} + +/** + * 导航到战斗点 + * @returns {Promise} + */ +async function navigateToBattlePoint() { + const path = `assets/AutoPath/${enemyType}-战斗点.json`; + let battlePoint = null; + + try { + const content = await file.readText(path); + const data = JSON.parse(content); + if (data.positions && Array.isArray(data.positions) && data.positions.length > 0) { + const lastPosition = data.positions[data.positions.length - 1]; + battlePoint = { x: lastPosition.x, y: lastPosition.y }; + } + } catch (error) { + log.warn(`读取战斗点配置失败: ${error.message}`); + } + + if (!battlePoint) { + log.warn(`未配置 ${enemyType} 的 battlePoint,跳过战斗点距离校验`); + return; + } + + let retryCount = 0; + const maxRetries = 3; + + while (retryCount < maxRetries) { + try { await sleep(1) } catch (e) { break; } + if (!running) { + break; + } + const pos = safeGetPositionFromMap(); + + if (pos) { + const distance = Math.sqrt(Math.pow(pos.x - battlePoint.x, 2) + Math.pow(pos.y - battlePoint.y, 2)); + if (distance <= 8) { + log.info(`已到达战斗点附近,距离: ${distance.toFixed(2)}米`); + return; + } else { + log.info(`未到达战斗点,当前距离: ${distance.toFixed(2)}米,正在导航...`); + } + } + + await AutoPath(`${enemyType}-战斗点`); + retryCount++; } - } } // OCR检测突发任务 /** * OCR 检测是否触发突发任务。 - * @param {number} ocrTimeout 秒 - * @param {string} enemyType * @returns {Promise} */ -async function detectTaskTrigger(ocrTimeout, enemyType) { - // 修复点:OCR 的截图对象与 findMulti 返回对象可能持有底层资源 - // 必须在 finally 中释放,避免长时间循环导致资源累积 - const ocrKeywords = getOcrKeywords(enemyType); - let ocrStatus = false; - let ocrStartTime = Date.now(); +async function detectTaskTrigger() { + // 修复点:OCR 的截图对象与 findMulti 返回对象可能持有底层资源 + // 必须在 finally 中释放,避免长时间循环导致资源累积 + const ocrKeywords = getOcrKeywords(enemyType); + let ocrStatus = false; + let ocrStartTime = Date.now(); - while (Date.now() - ocrStartTime < ocrTimeout * 1000 && !ocrStatus) { - try { - const activityText = await readActivityListText(); - for (const keyword of ocrKeywords) { - if (activityText.includes(keyword)) { - ocrStatus = true; - log.info("检测到突发任务触发"); - break; + while (Date.now() - ocrStartTime < ocrTimeout * 1000 && !ocrStatus) { + try { await sleep(1) } catch (e) { break; } + if (!running) { + break; + } + let resList = null; + try { + resList = (await getGameRegion()).findMulti(RecognitionObject.ocr(0, 200, 300, 300)); + for (let o = 0; o < resList.count; o++) { + let res = resList[o]; + for (let keyword of ocrKeywords) { + if (res && res.text && String(res.text).includes(keyword)) { + ocrStatus = true; + log.info("检测到突发任务触发"); + break; + } + } + if (ocrStatus) break; + } + } catch (error) { + log.error(`OCR检测突发任务过程中出错: ${error && error.message ? error.message : error}`); + } + + if (!ocrStatus) { + await sleep(1000); } - } - } catch (error) { - if (isCancellationError(error)) { - throw error; - } - log.error( - `OCR检测突发任务过程中出错: ${error && error.message ? error.message : error}`, - ); } - - if (!ocrStatus) { - await sleep(1000); - } - } - - return ocrStatus; + return ocrStatus; } -function cancelTaskSilently(cts) { - try { - cts?.cancel?.(); - } catch { - void 0; - } -} - -function silencePromiseRejection(task) { - task?.catch?.(() => {}); -} - -function isBattleTimeoutError(error) { - return ( - error && - (error.code === ERROR_CODES.BATTLE_TIMEOUT || - (error.message && String(error.message).includes("战斗超时"))) - ); -} - -function wrapBattleTask(battleTask) { - return battleTask - .then((value) => ({ kind: "battle_fulfilled", value })) - .catch((error) => ({ kind: "battle_rejected", error })); -} - -function wrapBattleDetectTask(battleDetectTask) { - return battleDetectTask - .then((status) => ({ kind: "detect_fulfilled", status })) - .catch((error) => ({ kind: "detect_rejected", error })); -} - -async function cleanupBattleTask( - battleTask, - awaitBattleTask, - awaitBattleTaskMs, -) { - if (!battleTask) return; - try { - const done = await Promise.race([ - battleTask.then(() => true).catch(() => true), - sleep(awaitBattleTask ? awaitBattleTaskMs : 5000).then(() => false), - ]); - if (!done) { - log.warn("AutoFight 未在超时内响应取消,后台任务可能仍在运行"); - silencePromiseRejection(battleTask); - } - } catch (error) { - if (!isCancellationError(error)) { - log.warn(`清理战斗任务时出错: ${error.message}`); - } - } -} - -async function executeBattleTasksSyncMode( - fightTimeout, - enemyType, - cts, - battlePointCoords, - taskContext, -) { - const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000); - taskContext.battleTask = dispatcher.runTask(new SoloTask("AutoFight"), cts); - const battleWrapped = wrapBattleTask(taskContext.battleTask); - const first = await Promise.race([ - battleWrapped, - sleep(maxDetectMs).then(() => ({ kind: "battle_timeout" })), - ]); - - if (first.kind === "battle_timeout") { - cancelTaskSilently(cts); - taskContext.awaitBattleTask = false; - throw first; - } - if (first.kind === "battle_rejected") { - throw first.error; - } - - const graceMs = Math.min(8000, maxDetectMs); - try { - const status = await waitForBattleResult( - graceMs, - enemyType, - cts, - battlePointCoords, - ); - return { success: status === "success", status }; - } catch (error) { - if (isBattleTimeoutError(error)) { - return { success: false, status: "auto_fight_ended" }; - } - throw error; - } -} - -async function executeBattleTasksAsyncMode( - fightTimeout, - enemyType, - cts, - battlePointCoords, - taskContext, -) { - taskContext.battleTask = dispatcher.runTask(new SoloTask("AutoFight"), cts); - const battleDetectTask = waitForBattleResult( - fightTimeout * 1000, - enemyType, - cts, - battlePointCoords, - ); - const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000); - const graceMs = Math.min(8000, maxDetectMs); - const battleWrapped = wrapBattleTask(taskContext.battleTask); - const detectWrapped = wrapBattleDetectTask(battleDetectTask); - - const first = await Promise.race([battleWrapped, detectWrapped]); - if (first.kind === "detect_fulfilled") { - log.info("战斗检测任务完成"); - cancelTaskSilently(cts); - return { success: first.status === "success", status: first.status }; - } - if (first.kind === "detect_rejected") { - throw first.error; - } - if (first.kind === "battle_rejected") { - throw first.error; - } - - const second = await Promise.race([ - detectWrapped, - sleep(graceMs).then(() => ({ kind: "detect_timeout" })), - ]); - if (second.kind === "detect_fulfilled") { - log.info("战斗检测任务完成"); - cancelTaskSilently(cts); - return { success: second.status === "success", status: second.status }; - } - if (second.kind === "detect_rejected") { - throw second.error; - } - - cancelTaskSilently(cts); - try { - await Promise.race([detectWrapped, sleep(1500)]); - } catch { - void 0; - } - return { success: false, status: "auto_fight_ended" }; -} - -function toBattleExecutionErrorResult(error, enemyType) { - if (isCancellationError(error)) { - throw error; - } - const msg = error && error.message ? String(error.message) : ""; - if ( - isSwimRecoveryEnabled(enemyType) && - (msg.includes("前往七天神像重试") || msg.includes("检测到游泳")) - ) { - log.warn(`战斗执行异常(已自动回七天神像): ${msg}`); - return { - success: false, - status: "recovered_to_statue", - errorMessage: msg, - }; - } - log.error(`战斗执行过程中出错: ${msg}`); - return { success: false, status: "error", errorMessage: msg }; -} - -// 执行战斗任务(并发执行战斗和结果检测) /** - * 执行 AutoFight 并用 OCR 判定战斗结果(支持同步/异步模式与超时保护)。 - * @param {number} fightTimeout 秒 - * @param {string} enemyType - * @param {*} cts CancellationTokenSource - * @param {{x:number,y:number}|null} battlePointCoords - * @returns {Promise<{success:boolean,status:string,errorMessage?:string}>} + * 执行 AutoFight 战斗任务。 + * @returns {Promise<{status:string,errorMessage?:string}>} + * + * @description + * 使用全局变量:settings.disableAsyncFight, fightTimeout, enemyType + * + * 支持两种战斗模式: + * - 同步模式 (disableAsyncFight=true): 直接执行 AutoFight,依赖配置组的"战斗结束检测"自行退出 + * - 异步模式 (默认): 启动战斗任务后等待 OCR 检测结果,检测到结果后取消战斗 + * + * 错误处理:捕获所有异常并返回错误信息,不向上抛出 + * 清理工作:无论成功失败,最终都会释放鼠标左键 */ -async function executeBattleTasks( - fightTimeout, - enemyType, - cts, - battlePointCoords, -) { - log.info("开始战斗!"); - const awaitBattleTaskMs = 10000; - const taskContext = { - battleTask: null, - awaitBattleTask: true, - }; +async function executeBattleTasks() { + log.info("开始战斗!"); + const cts = new CancellationTokenSource(); + try { + if (settings.disableAsyncFight) { + // 同步模式:直接执行战斗,依赖配置组的"战斗结束检测"自行退出 + await dispatcher.runTask(new SoloTask("AutoFight")); + return { status: "success" }; + } else { + // 异步模式:并发启动战斗 + OCR 检测结果 + const fightTask = dispatcher.runTask(new SoloTask("AutoFight"), cts); + fighting = true; + const statusPromise = waitForBattleResult(cts); + await fightTask; + fighting = false; + const results = await Promise.allSettled([fightTask, statusPromise]); + const status = results[1].value; + return { status }; + } + } catch (error) { + const msg = error && error.message ? String(error.message) : ""; + // 特别处理:如果是"取消自动任务"错误,视为成功 + if (msg.includes("取消自动任务")) { + return { status: "success" }; + } + log.error(`战斗执行过程中出错: ${msg}`); + return { status: "error", errorMessage: msg }; + } finally { + try { cts.cancel(); } catch { } + keyUp("VK_LBUTTON"); + } +} - try { - if (settings.disableAsyncFight) { - return await executeBattleTasksSyncMode( - fightTimeout, - enemyType, - cts, - battlePointCoords, - taskContext, - ); +/** + * OCR 轮询战斗结果:success/failure/out_of_area/cancelled;超时抛 BATTLE_TIMEOUT。 + * @param {*} cts - CancellationTokenSource,用于取消任务 + * @returns {Promise<"success"|"failure"|"out_of_area"|"cancelled">} + * + * @description + * 使用全局变量:fightTimeout, enemyType + */ +async function waitForBattleResult(cts = null) { + const timeout = fightTimeout * 1000; + // 战斗结果 OCR 判定: + // - 返回 "success":识别到成功关键字/特殊条件 + // - 返回 "failure":识别到失败关键字 + // - 返回 "out_of_area":连续多次识别不到事件关键字,认为离开触发区域 + // - 超时:抛出 Error("战斗超时,未检测到结果") + // + // 失败后的恢复统一在 executeSingleFriendshipRound 中串行处理,避免路径并发冲突 + const fightStartTime = Date.now(); + const successKeywords = ["事件", "完成"]; + const failureKeywords = ["失败"]; + const eventKeywords = getOcrKeywords(enemyType); + const pollIntervalMs = 500; + let notFind = 0; + + while (Date.now() - fightStartTime < timeout) { + try { await sleep(1) } catch (e) { break; } + if (!running) { + break; + } + if (!fighting) { + break; + } + let result = null; + let result2 = null; + try { + // 沿用最初版写死的 OCR 框(1080p 下的“事件完成”识别区域) + result = (await getGameRegion()).find(RecognitionObject.ocr(850, 150, 200, 80)); + result2 = (await getGameRegion()).find(RecognitionObject.ocr(0, 200, 300, 300)); + let text = result && result.text ? String(result.text) : ""; + text = text ? text.replace(/\s+/g, "") : ""; + let text2 = result2 && result2.text ? String(result2.text) : ""; + text2 = text2 ? text2.replace(/\s+/g, "") : ""; + if (enemyType === "蕈兽" && text2.includes("维沙瓦")) { + log.info("战斗结果:成功"); + try { cts.cancel(); } catch { } // 取消任务 + return "success"; + } + + // 检查成功关键词:只要开战后识别到“事件/完成”等关键词即可认为本轮结束 + if (Date.now() - fightStartTime >= 2000) { + for (let keyword of successKeywords) { + if (text.includes(keyword)) { + log.info("检测到战斗成功关键词: {0}", keyword); + log.info("战斗结果:成功"); + try { cts.cancel(); } catch { } // 取消任务 + return "success"; + } + } + } + + // 检查失败关键词 + for (let keyword of failureKeywords) { + if (text.includes(keyword)) { + log.warn("检测到战斗失败关键词: {0}", keyword); + try { cts.cancel(); } catch { } // 取消任务 + return "failure"; + } + } + if (enemyType !== "蕈兽") { + // 检查事件关键词 + let find = 0; + for (let keyword of eventKeywords) { + if (text2.includes(keyword)) { + find++; + } + } + + if (find === 0) { + notFind++; + log.info("未检测到任务触发关键词:{0} 次", notFind); + } else { + notFind = 0; + } + + if (notFind > 10) { + let nearBattlePoint = false; + if (battlePointCoords && Number.isFinite(Number(battlePointCoords.x)) && Number.isFinite(Number(battlePointCoords.y))) { + const pos = safeGetPositionFromMap(); + if (pos && typeof pos === "object") { + const dx = Number(pos.x) - Number(battlePointCoords.x); + const dy = Number(pos.y) - Number(battlePointCoords.y); + const dist = Math.sqrt(dx * dx + dy * dy); + nearBattlePoint = Number.isFinite(dist) && dist <= 25; + } + } + + if (nearBattlePoint) { + log.info("触发关键词消失但仍在战斗点附近,视为本轮结束"); + try { cts.cancel(); } catch { } // 取消任务 + return "success"; + } + + log.warn("不在任务触发区域,战斗失败"); + try { cts.cancel(); } catch { } // 取消任务 + return "out_of_area"; + + } + } + } + catch (error) { + log.error("OCR过程中出错", error); + } + // 统一的检查间隔 + await sleep(pollIntervalMs); } - return await executeBattleTasksAsyncMode( - fightTimeout, - enemyType, - cts, - battlePointCoords, - taskContext, - ); - } catch (error) { - if (error && error.kind === "battle_timeout") { - throw createScriptError( - ERROR_CODES.BATTLE_TIMEOUT, - ERR_MESSAGES.BATTLE_TIMEOUT, - ); - } - return toBattleExecutionErrorResult(error, enemyType); - } finally { - await cleanupBattleTask( - taskContext.battleTask, - taskContext.awaitBattleTask, - awaitBattleTaskMs, - ); - keyUp("VK_LBUTTON"); - } + + log.warn("在超时时间内未检测到战斗结果"); + try { cts.cancel(); } catch { } // 取消任务 } // 执行单次好感任务循环 /** * 执行单轮好感流程(触发检测→导航→战斗→判定→战后/恢复)。 * @param {number} roundIndex - * @param {number} ocrTimeout 秒 - * @param {number} fightTimeout 秒 - * @param {string} enemyType * @returns {Promise} 返回 false 表示整体应提前结束 */ -async function runTreasureHoarderAsyncRound( - fightTimeout, - enemyType, - battlePointCoords, -) { - const maxDetectMs = Math.max(0, Number(fightTimeout) * 1000); - const battleDetectCts = new CancellationTokenSource(); - const battleDetectPromise = waitForBattleResult( - maxDetectMs, - enemyType, - battleDetectCts, - battlePointCoords, - ); - log.info("开始战斗!"); +async function executeSingleFriendshipRound(roundIndex) { + // 单轮流程: + // 1) 导航到触发点附近 + // 2) 通过 OCR 判断是否已触发突发任务 + // 3) 导航到战斗点(避免与失败回退路径并发冲突) + // 4) 启动战斗 + OCR 判定胜负/是否离开区域 + // 5) 成功则执行战斗后流程;失败则回退到准备/触发点并进入下一轮 - const pathPromise = runBattlePathToBattlePoint(enemyType, true); - const pathWrapped = pathPromise - .then(() => ({ kind: "path_fulfilled" })) - .catch((error) => ({ kind: "path_rejected", error })); - const detectWrapped = wrapBattleDetectTask(battleDetectPromise); - const first = await Promise.race([pathWrapped, detectWrapped]); - - if (first.kind === "path_rejected") { - cancelTaskSilently(battleDetectCts); - const pathError = first.error; - if (isCancellationError(pathError)) throw pathError; - const msg = pathError && pathError.message ? String(pathError.message) : ""; - if (msg.includes("前往七天神像重试") || msg.includes("检测到游泳")) { - await recoverAfterFailure(enemyType, true); - throw new Error(ERR_MESSAGES.SWIM_RECOVERED); - } - throw pathError; - } - - const battleStatus = - first.kind === "detect_fulfilled" - ? first.status - : await battleDetectPromise; - if (first.kind === "detect_fulfilled") { - const pathSettled = await Promise.race([ - pathPromise.then(() => true).catch(() => true), - sleep(maxDetectMs).then(() => false), - ]); - if (!pathSettled) { - silencePromiseRejection(pathPromise); - log.warn("寻路任务未在预期时间内完成,已进入后台静默模式"); - throw createScriptError( - ERROR_CODES.BATTLE_TIMEOUT, - ERR_MESSAGES.BATTLE_TIMEOUT, - ); - } - } - - if (battleStatus === "cancelled") { - throw new Error("战斗任务已取消"); - } - if (battleStatus === "success") { - await runPostBattle(enemyType); - return true; - } - - await recoverAfterFailure(enemyType, false); - throw new Error(`战斗失败: ${battleStatus}`); -} - -async function runBattlePathWithSwimRecovery(enemyType) { - try { - await runBattlePathToBattlePoint(enemyType, enemyType === "盗宝团"); - } catch (error) { - if (isCancellationError(error)) throw error; - const msg = error && error.message ? String(error.message) : ""; - if ( - isSwimRecoveryEnabled(enemyType) && - (msg.includes("前往七天神像重试") || msg.includes("检测到游泳")) - ) { - await recoverAfterFailure(enemyType, true); - throw new Error(ERR_MESSAGES.SWIM_RECOVERED); - } - throw error; - } -} - -async function handleBattleResult(enemyType, battleResult) { - if (battleResult.status === "success") { - await runPostBattle(enemyType); - return true; - } - if (battleResult.status === "recovered_to_statue") { - // 此状态意味着游戏已自动回到七天神像,无需再次 TP - await recoverAfterFailure(enemyType, true); - throw new Error(ERR_MESSAGES.SWIM_RECOVERED); - } - await recoverAfterFailure(enemyType, false); - const msg = battleResult.errorMessage - ? String(battleResult.errorMessage) - : `战斗失败: ${battleResult.status}`; - throw new Error(msg); -} - -async function executeSingleFriendshipRound( - roundIndex, - ocrTimeout, - fightTimeout, - enemyType, -) { - // 单轮流程: - // 1) 导航到触发点附近 - // 2) 通过 OCR 判断是否已触发突发任务 - // 3) 导航到战斗点(避免与失败回退路径并发冲突) - // 4) 启动战斗 + OCR 判定胜负/是否离开区域 - // 5) 成功则执行战斗后流程;失败则回退到准备/触发点并进入下一轮 - // 导航到触发点 - await navigateToTriggerPoint(enemyType); - const { initialDelayMs } = getEnemyConfig(enemyType); - if (roundIndex === 0 && initialDelayMs) { - await sleep(initialDelayMs); - } - let initialDetected = false; - if (roundIndex === 0) { - initialDetected = await detectTaskTrigger( - Math.min( - Number(ocrTimeout) || DEFAULT_OCR_TIMEOUT_SECONDS, - DEFAULT_OCR_TIMEOUT_SECONDS, - ), - enemyType, - ); - } - if (!detectedExpOrMora && settings.loopTillNoExpOrMora) { - noExpOrMoraCount++; - log.warn("上次运行未检测到经验或摩拉"); - if (noExpOrMoraCount >= 2) { - log.warn("连续两次循环没有经验或摩拉掉落,提前终止"); - return false; - } - } else { - noExpOrMoraCount = 0; - detectedExpOrMora = false; - } - if (!initialDetected || roundIndex > 0) { - if (settings.use1000Stars) { - await genshin.wonderlandCycle(); + // 导航到触发点 + await navigateToTriggerPoint(); + //根据经验/摩拉决定是否需要终止任务 + if (!detectedExpOrMora && settings.loopTillNoExpOrMora) { + noExpOrMoraCount++; + log.warn("上次运行未检测到经验或摩拉"); + if (noExpOrMoraCount >= 2) { + log.warn("连续两次循环没有经验或摩拉掉落,提前终止"); + return false; + } } else { - await genshin.relogin(); + noExpOrMoraCount = 0; + detectedExpOrMora = false; } - } + //检测任务是否触发 + const { initialDelayMs } = getEnemyConfig(enemyType); - const ocrStatus = await detectTaskTrigger(ocrTimeout, enemyType); + if (roundIndex === 0 && initialDelayMs) { + await sleep(initialDelayMs); + } + let ocrStatus; + if (roundIndex === 0) { + ocrStatus = await detectTaskTrigger(); + } + if (!ocrStatus) { + if (settings.use1000Stars) { + await genshin.wonderlandCycle(); + } else { + await genshin.relogin(); + } + ocrStatus = await detectTaskTrigger(); + } + if (!ocrStatus) { + // 本轮未检测到突发任务:按设计直接结束整个脚本循环 + notification.send(`未识别到突发任务,${enemyType}好感结束`); + log.info(`未识别到突发任务,${enemyType}好感结束`); + return false; // 返回 false 表示需要终止循环 + } + //导航至战斗点 + await navigateToBattlePoint(); - if (!ocrStatus) { - // 本轮未检测到突发任务:按设计直接结束整个脚本循环 - notification.send(`未识别到突发任务,${enemyType}好感结束`); - log.info(`未识别到突发任务,${enemyType}好感结束`); - return false; // 返回 false 表示需要终止循环 - } + const maxRetryCount = 2; + let retryCount = 0; - // 修复点:先确认触发,再执行导航;避免“未触发时仍去战斗点”以及失败时多路径并发 - const battlePointCoords = getBattlePointFromBattlePath(enemyType); - if (!battlePointCoords) { - throw new Error(`未配置 ${enemyType} 的 targetCoords`); - } + while (true) { + try { await sleep(1) } catch (e) { break; } + if (!running) { + break; + } + const battleResult = await executeBattleTasks(); - if (enemyType === "盗宝团" && !settings.disableAsyncFight) { - return await runTreasureHoarderAsyncRound( - fightTimeout, - enemyType, - battlePointCoords, - ); - } + if (battleResult.status === "success") { + consecutiveMaxRetryCount = 0; // 重置连续最大重试计数器 + await runPostBattle(); + return true; + } - await runBattlePathWithSwimRecovery(enemyType); + // 战斗失败,执行恢复 + log.warn(`战斗失败,状态: ${battleResult.status},错误信息: ${battleResult.errorMessage || '无'}`); - const battleCts = new CancellationTokenSource(); - const battleResult = await executeBattleTasks( - fightTimeout, - enemyType, - battleCts, - battlePointCoords, - ); + if (retryCount >= maxRetryCount) { + consecutiveMaxRetryCount++; + log.warn(`已尝试恢复 ${maxRetryCount} 次,第 ${consecutiveMaxRetryCount} 次触发最大重试`); - return await handleBattleResult(enemyType, battleResult); + if (consecutiveMaxRetryCount >= 2) { + log.error(`连续两次达到最大重试次数,终止任务`); + notification.send(`${enemyType}好感任务失败,连续两次达到最大重试次数`); + return false; + } + + log.info("尝试容错处理:传送至七天神像并切换队伍"); + await genshin.teleportToStatue(); + await switchPartyIfNeeded(settings.partyName); + log.info("容错处理完成,进入下一轮"); + return true; + } + + await recoverAfterFailure(); + retryCount++; + log.info(`第 ${retryCount} 次恢复后,重新导航至战斗点...`); + await navigateToBattlePoint(); + log.info(`第 ${retryCount} 次恢复后,重新执行战斗...`); + } } // 记录进度信息 @@ -1270,97 +669,49 @@ async function executeSingleFriendshipRound( * @param {number} totalRounds */ function logProgress(startTime, currentRound, totalRounds) { - const estimatedCompletion = estimateCompletionTime( - startTime, - currentRound + 1, - totalRounds, - ); - const currentTime = formatElapsedTime(startTime); - log.info( - `当前进度:${currentRound + 1}/${totalRounds} (${(((currentRound + 1) / totalRounds) * 100).toFixed(1)}%)`, - ); - log.info(`当前运行总时长:${currentTime}`); - log.info(`预计完成时间:${estimatedCompletion}`); + const estimatedCompletion = CalculateEstimatedCompletion(startTime, currentRound + 1, totalRounds); + const currentTime = LogTimeTaken(startTime); + log.info(`当前进度:${currentRound + 1}/${totalRounds} (${((currentRound + 1) / totalRounds * 100).toFixed(1)}%)`); + log.info(`当前运行总时长:${currentTime}`); + log.info(`预计完成时间:${estimatedCompletion}`); } // 执行 N 次好感任务并输出日志 - 重构后的主函数 /** * 主循环:执行指定次数;在未触发/连续无掉落等条件下提前退出。 - * @param {number} times - * @param {number} ocrTimeout 秒 - * @param {number} fightTimeout 秒 - * @param {string} enemyType * @returns {Promise} */ -async function runFriendshipLoop( - times, - ocrTimeout, - fightTimeout, - enemyType = "盗宝团", -) { - // 主循环:执行指定次数或在 detectTaskTrigger 判定“未触发”时提前退出 - // 修复点:finally 中统一关闭 running 并等待 detectExpOrMoraTask 退出,避免后台循环残留 - const startFirstTime = Date.now(); - let detectExpOrMoraTask; - let cancelled = false; - let successCount = 0; - let failureCount = 0; - try { - if (settings.loopTillNoExpOrMora) { - detectExpOrMoraTask = detectExpOrMora(); - } - for (let i = 0; i < times; i++) { - try { - await sleep(1); - } catch { - break; - } - try { - const success = await executeSingleFriendshipRound( - i, - ocrTimeout, - fightTimeout, - enemyType, - ); - if (!success) { - break; +async function AutoFriendshipDev() { + const startFirstTime = Date.now(); + let detectExpOrMoraTask; + let cancelled = false; + let successCount = 0; + let failureCount = 0; + try { + if (settings.loopTillNoExpOrMora) { + detectExpOrMoraTask = detectExpOrMora(); } - successCount++; - logProgress(startFirstTime, i, times); - } catch (error) { - if (isCancellationError(error)) { - cancelled = true; - throw error; + for (let i = 0; i < runTimes; i++) { + try { await sleep(1); } catch (e) { break; } + try { + const success = await executeSingleFriendshipRound(i); + if (!success) + break; + successCount++; + logProgress(startFirstTime, i, runTimes); + } catch (error) { + continue; + } } - failureCount++; - log.error(`第 ${i + 1} 轮好感任务失败: ${error.message}`); - if (error && error.code === ERROR_CODES.BATTLE_TIMEOUT) { - throw error; + } finally { + if (!cancelled) { + log.info(`本次运行统计:成功 ${successCount} 次,失败 ${failureCount} 次`); } - if (error.message && error.message.includes("战斗超时")) { - throw error; + running = false; + if (detectExpOrMoraTask) { + try { await detectExpOrMoraTask; } catch { } } - continue; - } } - if (!cancelled) { - log.info(`${enemyType} 好感已完成`); - } - } finally { - if (!cancelled) { - log.info( - `本次运行统计:成功 ${successCount} 次,失败 ${failureCount} 次`, - ); - } - running = false; - if (detectExpOrMoraTask) { - try { - await detectExpOrMoraTask; - } catch { - void 0; - } - } - } } /** @@ -1368,309 +719,82 @@ async function runFriendshipLoop( * @returns {Promise} */ async function detectExpOrMora() { - // 后台循环:通过模板匹配检测经验/摩拉图标 - // 注意:该循环依赖 running 停止;必须保证任何退出路径都会把 running=false - while (running) { - try { - await sleep(1); - } catch { - break; + // 后台循环:通过模板匹配检测经验/摩拉图标 + // 注意:该循环依赖 running 停止;必须保证任何退出路径都会把 running=false + while (running) { + try { await sleep(1); } catch (e) { break; } + if (!detectedExpOrMora) { + let res1 = null; + let res2 = null; + try { + res1 = (await getGameRegion()).find(expRo); + if (res1.isExist()) { + log.info("识别到经验"); + detectedExpOrMora = true; + continue; + } + res2 = (await getGameRegion()).find(moraRo); + if (res2.isExist()) { + log.info("识别到摩拉"); + detectedExpOrMora = true; + continue; + } + } catch (e) { + log.error(`检测经验和摩拉掉落过程中出现错误 ${e.message}`); + } + } else { + await sleep(200); + } + await sleep(200); } - let gameRegion = null; - if (!detectedExpOrMora) { - let res1 = null; - let res2 = null; - try { - gameRegion = await getManagedGameRegion(17, false); - if (!gameRegion) { - await sleep(50); - continue; - } - res1 = gameRegion.find(expRo); - if (res1.isExist()) { - log.info("识别到经验"); - detectedExpOrMora = true; - continue; - } - res2 = gameRegion.find(moraRo); - if (res2.isExist()) { - log.info("识别到摩拉"); - detectedExpOrMora = true; - continue; - } - } catch (e) { - log.error(`检测经验和摩拉掉落过程中出现错误 ${e.message}`); - } finally { - safeDispose(res1); - safeDispose(res2); - releaseManagedGameRegion(gameRegion); - } - } else { - //无需检测时额外等待200 - await sleep(200); - } - await sleep(200); - } } /** - * 读取并校验运行次数设置,非法则回退默认值。 - * @returns {Promise} + * 解析数值类型配置项 + * @param {*} value 配置原始值(可能是字符串、数字、undefined、null) + * @param {number} defaultVal 默认值 + * @returns {number} 若 value 为空字符串/undefined/null 或转换后为 NaN,返回 defaultVal;否则返回转换后的数值(包含 0) */ -async function calculateRunTimes() { - // 从 settings 读取次数并校验;非法则回退默认值 - log.info("请确保队伍满员,并为队伍配置相应的战斗策略"); - // 计算运行次数 - let runTimes = Number(settings.runTimes); - if (!isPositiveInteger(runTimes)) { - log.warn("请输入正确的次数,必须是正整数!"); - log.warn(`运行次数重置为 ${DEFAULT_RUNS} 次!`); - runTimes = DEFAULT_RUNS; - } - - log.info(`当前设置的运行次数: ${runTimes}`); - return runTimes; +function parseNumericSetting(value, defaultVal) { + if (value === undefined || value === null || value === '') return defaultVal; + const n = Number(value); + return isNaN(n) || n === 0 ? defaultVal : n; } -// 验证输入是否是正整数 -/** - * 判断是否为正整数。 - * @param {*} value - * @returns {boolean} - */ -function isPositiveInteger(value) { - return Number.isInteger(value) && value > 0; -} - -// 根据敌人类型获取OCR关键词 /** * 获取 OCR 关键词(优先敌人配置,否则回退默认)。 * @param {string} enemyType * @returns {string[]} */ function getOcrKeywords(enemyType) { - // OCR 关键词获取:优先使用敌人类型配置,否则回退到默认关键词 - const { ocrKeywords } = getEnemyConfig(enemyType); - return ocrKeywords || DEFAULT_OCR_KEYWORDS; + // OCR 关键词获取:优先使用敌人类型配置,否则回退到默认关键词 + const { ocrKeywords } = getEnemyConfig(enemyType); + return ocrKeywords || DEFAULT_OCR_KEYWORDS; } -// 根据敌人类型获取目标战斗点坐标 -/** - * 获取目标战斗点坐标配置。 - * @param {string} enemyType - * @returns {{x:number,y:number}|undefined} - */ -function getTargetCoordinates(enemyType) { - const { targetCoords } = getEnemyConfig(enemyType); - return targetCoords; -} - -/** - * 获取触发点坐标配置。 - * @param {string} enemyType - * @returns {{x:number,y:number}|undefined} - */ -function getTriggerPoint(enemyType) { - const { triggerPoint } = getEnemyConfig(enemyType); - return triggerPoint; -} - -// 队伍切换相关 /** * 可选切换队伍;失败则尝试回七天神像后重试。 * @param {string} partyName * @returns {Promise} */ async function switchPartyIfNeeded(partyName) { - // 可选队伍切换:为空则直接回到主界面,避免停留在菜单/对话等状态 - if (!partyName) { - await genshin.returnMainUi(); - return; - } - try { - log.info("正在尝试切换至" + partyName); - if (!(await genshin.switchParty(partyName))) { - log.info("切换队伍失败,前往七天神像重试"); - await genshin.tpToStatueOfTheSeven(); - const switchedAfterTp = await genshin.switchParty(partyName); - if (!switchedAfterTp) { - const errMsg = "队伍切换失败,回神像重试后仍未成功"; - log.error(errMsg); - notification.error(errMsg); + // 可选队伍切换:为空则直接回到主界面,避免停留在菜单/对话等状态 + if (!partyName) { await genshin.returnMainUi(); - throw new Error(errMsg); - } + return; } - } catch (error) { - if (isCancellationError(error)) { - throw error; - } - log.error("队伍切换失败,可能处于联机模式或其他不可切换状态"); - notification.error(`队伍切换失败,可能处于联机模式或其他不可切换状态`); - await genshin.returnMainUi(); - throw error; - } -} - -/** - * OCR 轮询战斗结果:success/failure/out_of_area/cancelled;超时抛 BATTLE_TIMEOUT。 - * @param {number} timeout 毫秒 - * @param {string} enemyType - * @param {*} cts CancellationTokenSource - * @param {{x:number,y:number}|null} battlePointCoords - * @returns {Promise<"success"|"failure"|"out_of_area"|"cancelled">} - */ -async function waitForBattleResult( - timeout = 2 * 60 * 1000, - enemyType = "盗宝团", - cts = null, - battlePointCoords = null, -) { - // 战斗结果 OCR 判定: - // - 返回 "success":识别到成功关键字/特殊条件 - // - 返回 "failure":识别到失败关键字 - // - 返回 "out_of_area":连续多次识别不到事件关键字,认为离开触发区域 - // - 超时:抛出 Error("战斗超时,未检测到结果") - // - // 修复点:这里不做 tp/跑路径,只做“结果判定 + cts.cancel()” - // 失败后的恢复统一在 executeSingleFriendshipRound 中串行处理,避免路径并发冲突 - const fightStartTime = Date.now(); - const successKeywords = ["事件", "完成"]; - const failureKeywords = ["失败"]; - const eventKeywords = getOcrKeywords(enemyType); - const pollIntervalMs = 1000; - let notFind = 0; - - while (Date.now() - fightStartTime < timeout) { - if (!cts) { - cts = new CancellationTokenSource(); - } - if (isCtsCancellationRequested(cts)) { - return "cancelled"; - } - let capture = null; - let result = null; try { - capture = await getManagedGameRegion(17, false); - if (!capture) { - await sleep(pollIntervalMs); - continue; - } - // 沿用最初版写死的 OCR 框(1080p 下的“事件完成”识别区域) - result = capture.find(RecognitionObject.ocr(850, 150, 200, 80)); - const text = normalizeOcrText(result && result.text ? result.text : ""); - const text2 = readActivityListTextFromCapture(capture); - if (enemyType === "蕈兽" && text2.includes("维沙瓦")) { - log.info("战斗结果:成功"); - try { - cts.cancel(); - } catch { - void 0; - } // 取消任务 - return "success"; - } - - // 检查成功关键词:只要开战后识别到“事件/完成”等关键词即可认为本轮结束 - if (Date.now() - fightStartTime >= 2000) { - for (const keyword of successKeywords) { - if (text.includes(keyword)) { - log.info("检测到战斗成功关键词: {0}", keyword); - log.info("战斗结果:成功"); - try { - cts.cancel(); - } catch { - void 0; - } // 取消任务 - return "success"; - } + log.info("正在尝试切换至" + partyName); + if (!await genshin.switchParty(partyName)) { + log.info("切换队伍失败,前往七天神像重试"); + await genshin.tpToStatueOfTheSeven(); + await genshin.switchParty(partyName); } - } - - // 检查失败关键词 - for (const keyword of failureKeywords) { - if (text.includes(keyword)) { - log.warn("检测到战斗失败关键词: {0}", keyword); - try { - cts.cancel(); - } catch { - void 0; - } // 取消任务 - return "failure"; - } - } - if (enemyType !== "蕈兽") { - // 检查事件关键词 - let find = 0; - for (const keyword of eventKeywords) { - if (text2.includes(keyword)) { - find++; - } - } - - if (find === 0) { - notFind++; - log.info("未检测到任务触发关键词:{0} 次", notFind); - } else { - notFind = 0; - } - - if (notFind > 10) { - let nearBattlePoint = false; - if ( - battlePointCoords && - Number.isFinite(Number(battlePointCoords.x)) && - Number.isFinite(Number(battlePointCoords.y)) - ) { - const pos = safeGetPositionFromMap(); - if (pos && typeof pos === "object") { - const dx = Number(pos.x) - Number(battlePointCoords.x); - const dy = Number(pos.y) - Number(battlePointCoords.y); - const dist = Math.sqrt(dx * dx + dy * dy); - nearBattlePoint = Number.isFinite(dist) && dist <= 25; - } - } - - if (nearBattlePoint) { - log.info("触发关键词消失但仍在战斗点附近,视为本轮结束"); - try { - cts.cancel(); - } catch { - void 0; - } // 取消任务 - return "success"; - } - - log.warn("不在任务触发区域,战斗失败"); - try { - cts.cancel(); - } catch { - void 0; - } // 取消任务 - return "out_of_area"; - } - } - } catch (error) { - log.error("OCR过程中出错: {0}", error); - // 出错后继续循环,不进行额外嵌套处理 - } finally { - safeDispose(result); - releaseManagedGameRegion(capture); + } catch { + log.error("队伍切换失败,可能处于联机模式或其他不可切换状态"); + notification.error(`队伍切换失败,可能处于联机模式或其他不可切换状态`); + await genshin.returnMainUi(); } - - // 统一的检查间隔 - await sleep(pollIntervalMs); - } - - log.warn("在超时时间内未检测到战斗结果"); - try { - cts.cancel(); - } catch { - void 0; - } // 取消任务 - throw createScriptError( - ERROR_CODES.BATTLE_TIMEOUT, - ERR_MESSAGES.BATTLE_TIMEOUT, - ); } /** @@ -1681,17 +805,78 @@ async function waitForBattleResult( * @returns {number} - 验证后的超时时间(秒) */ function validateTimeoutSetting(value, defaultValue, timeoutType) { - // 转换为数字 - const timeout = Number(value); + // 转换为数字 + const timeout = Number(value); - // 检查是否为有效数字且大于0 - if (!isFinite(timeout) || timeout <= 0) { - log.warn( - `${timeoutType} 超时设置无效,必须是大于0的数字,将使用默认值 ${defaultValue} 秒`, - ); - return defaultValue; - } + // 检查是否为有效数字且大于0 + if (!isFinite(timeout) || timeout <= 0) { + log.warn(`${timeoutType} 超时设置无效,必须是大于0的数字,将使用默认值 ${defaultValue} 秒`); + return defaultValue; + } - log.info(`${timeoutType}超时设置为 ${timeout} 秒`); - return timeout; + log.info(`${timeoutType}超时设置为 ${timeout} 秒`); + return timeout; } + +/** + * 获取游戏区域截图,根据时间间隔决定是否重新捕获 + * + * @param {number} [minInterval=17] - 最小截图间隔(毫秒),默认17ms(约60fps) + * @param {boolean} [asyncDispose=false] - 是否异步释放旧截图,默认false + * @returns {Promise} 游戏区域截图对象 + * + * @description + * 使用 gameRegionManager 对象管理以下属性: + * - cache: 缓存队列,保存近5张截图 + * - lastCapture: 上一次捕获游戏区域的时间戳 + * - isDisposing: 标记是否正在释放旧截图,用于安全锁 + * - isCapturing: 标记是否正在执行截图操作,用于全局锁 + */ +async function getGameRegion(minInterval = 17, asyncDispose = false) { + async function disposeOldGameRegion() { + gameRegionManager.isDisposing = true; + try { + // 当缓存队列超过GAME_REGION_CACHE_SIZE个时,销毁最旧的截图 + while (gameRegionManager.cache.length > GAME_REGION_CACHE_SIZE) { + const oldestRegion = gameRegionManager.cache.shift(); + if (oldestRegion) { + oldestRegion.dispose(); + } + } + } catch (error) { + log.error(`释放旧游戏区域截图失败: ${error.message}`); + } finally { + gameRegionManager.isDisposing = false; + } + } + + // 等待其他任务完成截图 + while (gameRegionManager.isCapturing) { + await sleep(1); + } + + gameRegionManager.isCapturing = true; + try { + if (new Date() - gameRegionManager.lastCapture >= minInterval || gameRegionManager.cache.length === 0) { + while (gameRegionManager.isDisposing) { + await sleep(1); + } + gameRegionManager.lastCapture = new Date(); + const newRegion = captureGameRegion(); + gameRegionManager.cache.push(newRegion); + + // 根据参数决定是否等待释放完成 + if (asyncDispose) { + disposeOldGameRegion(); + } else { + await disposeOldGameRegion(); + } + } + } catch (error) { + log.error(`获取游戏区域截图失败: ${error.message}`); + } finally { + gameRegionManager.isCapturing = false; + // 返回最新的截图 + return gameRegionManager.cache[gameRegionManager.cache.length - 1]; + } +} \ No newline at end of file diff --git a/repo/js/AutoFriendshipFight/manifest.json b/repo/js/AutoFriendshipFight/manifest.json index 9349865f8..e051ad70b 100644 --- a/repo/js/AutoFriendshipFight/manifest.json +++ b/repo/js/AutoFriendshipFight/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 1, - "name": "战斗好感:自动好感度&卡时间", - "version": "1.5.8", + "name": "战斗好感:自动好感度", + "version": "2.0.0", "bgi_version": "0.45.1", "tags": [ "好感", @@ -11,11 +11,19 @@ "鳄鱼", "兽肉" ], - "description": "通过战斗类突发事件刷好感度,刷盗宝团、愚人众材料,概率掉落兽肉,小怪锄地,卡时间等,请配合战斗策略使用。盗宝团部分在 HZYgrandma & 愚溪的原始脚本上改编。当前版本可能无法以任意方式拾取掉落物,如果有拾取掉落物需求,请勿更新。", + "description": "通过战斗类突发事件自动刷好感度,支持盗宝团、愚人众、鳄鱼、蕈兽、雷萤术士等路线,请配合战斗策略使用。", "authors": [ { "name": "秋云", "links": "https://github.com/physligl" + }, + { + "name": "火山", + "links": "https://github.com/RRRR623" + }, + { + "name": "mno", + "links": "https://github.com/Bedrockx" } ], "settings_ui": "settings.json", diff --git a/repo/js/AutoFriendshipFight/settings.json b/repo/js/AutoFriendshipFight/settings.json index 615015e02..16111f8d3 100644 --- a/repo/js/AutoFriendshipFight/settings.json +++ b/repo/js/AutoFriendshipFight/settings.json @@ -1,9 +1,8 @@ [ { - "name": "pickupMode", + "name": "disablePickup", "type": "checkbox", - "label": "是否启用自动拾取实时任务", - "default": true + "label": "禁用自动拾取实时任务" }, { "name": "enemyType", @@ -20,8 +19,9 @@ }, { "name": "qiuQiuRen", - "type": "checkbox", - "label": "是否清理丘丘人\n【默认否,仅对盗宝团有效,选是会清理丘丘人之后再开始任务】" + "type": "input-text", + "label": "清理丘丘人超时时间(秒)\n【默认0不执行,仅对盗宝团有效,超出时间自动结束清理】", + "default": "0" }, { "name": "disableAsyncFight", @@ -52,12 +52,12 @@ "name": "ocrTimeout", "type": "input-text", "label": "OCR超时时间\n【选填,默认为10秒,如果经常提前判定未识别到任务,请适当调大】", - "default": 10 + "default": "10" }, { "name": "fightTimeout", "type": "input-text", "label": "战斗超时时间\n【选填,默认为120秒】", - "default": 120 + "default": "120" } ]