//当前js版本2.5.0 //自定义配置变量预声明 let accountName; let pickup_Mode; let dumpers; let findFInterval; let checkDelay; let rollingDelay; let pickupDelay; let timeMove; let timeMoveUp; let timeMoveDown; let priorityTags; let excludeTags; let operationMode; let efficiencyIndex; let targetEliteNum; let targetMonsterNum; let partyName; let groupSettings; let groupTags; //模板与识别对象预加载 const itemFullRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/itemFull.png"), 0, 0, 1920, 1080); const frozenRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/解除冰冻.png"), 1379, 574, 1463 - 1379, 613 - 574); const revivalRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/复苏.png"), 755, 915, 1117 - 755, 1037 - 915); revivalRo.Threshold = 0.95; revivalRo.InitTemplate(); const cookingRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/烹饪界面.png"), 1547, 965, 1815 - 1547, 1059 - 965); cookingRo.Threshold = 0.95; cookingRo.InitTemplate(); const whiteFurinaRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/白芙图标.png"), 1634, 967, 1750 - 1634, 1070 - 967); whiteFurinaRo.Threshold = 0.97; whiteFurinaRo.InitTemplate(); const flyingRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/飞行状态.png"), 1657, 945, 1758 - 1657, 1029 - 945); flyingRo.Threshold = 0.97; flyingRo.InitTemplate(); const fIconRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync('assets/F_Dialogue.png'), 1102, 335, 34, 400); fIconRo.Threshold = 0.95; fIconRo.InitTemplate(); const mainUIRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/MainUI.png"), 0, 0, 150, 150); const scrollRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/拾取滚轮.png"), 1017, 496, 1093 - 581, 581 - 496); //全局通用变量声明 let gameRegion; let targetItems; let doFurinaSwitch = false; let lastRoll = new Date(); let blacklist = []; let blacklistSet = new Set(); let state; let pathings; let localeWorks; let lastEatBuff = 0; let currentFood = ""; (async function () { //通用预处理 await loadConfig(); let switchPartyTask; if (["运行锄地路线", "启用仅指定怪物模式"].includes(operationMode)) { switchPartyTask = switchPartyIfNeeded(partyName); } if (settings.disableAsync) { await switchPartyTask; } targetItems = await loadTargetItems(); localeWorks = await checkLocaleTimeSupport(); dispatcher.AddTrigger(new RealtimeTimer("AutoSkip")); await loadBlacklist(true); await rotateWarnIfAccountEmpty(); if (operationMode === "启用仅指定怪物模式") { await filterPathingsByTargetMonsters(); await updateRecords(pathings, accountName); if (!settings.disableAsync) { await switchPartyTask; } await processPathingsByGroup(pathings, accountName); return; } else { //预处理路线并建立对象 pathings = await processPathings(groupTags); //按照用户配置标记路线 await markPathings(pathings, groupTags, priorityTags, excludeTags); //找出最优组合 await findBestRouteGroups(pathings, efficiencyIndex, targetEliteNum, targetMonsterNum); //分配到不同路径组 await assignGroups(pathings, groupTags); //根据操作模式选择不同的处理方式 if (operationMode === "调试路线分配") { await printGroupSummary(); log.info("开始复制并输出地图追踪文件\n请前往js文件夹查看"); await copyPathingsByGroup(pathings); await updateRecords(pathings, accountName); } else if (operationMode === "运行锄地路线") { if (!settings.disableAsync) { await switchPartyTask; } await validateTeamAndConfig(); log.info("开始运行锄地路线"); await updateRecords(pathings, accountName); await processPathingsByGroup(pathings, accountName); } else { log.info("强制刷新所有运行记录"); await initializeCdTime(pathings, ""); await updateRecords(pathings, accountName); } } })(); /* ========================= ① 启动与配置 ========================= * 负责:读取/生成用户配置、检测本地时间格式、提示未打开自定义配置等 * 为后续所有模块提供经过校验的 settings 与全局常量 * ============================================================= */ /** * 配置加载 / 创建函数 * 依赖全局变量:settings、accountName * 1. 当 groupIndex 为"路径组一"时,把当前 UI 上的字段落盘到 settings/{accountName}.json; * 2. 其它组则尝试读盘,失败就报错并 10s 后退出; * 3. 无论读写,最终都把结果同步回全局 settings; * 4. 若配置里出现"莫酱""汐酱"关键词,直接终止脚本。 */ async function loadConfig() { operationMode = settings.operationMode || "运行锄地路线"; accountName = settings.accountName || "默认账户"; if (operationMode !== '启用仅指定怪物模式') { const FORBIDDEN = ['莫酱', '汐酱']; /* -------- 1. 构造 10 个分组标签 + 其它字段的默认值 -------- */ const buildCfgObj = () => ({ tagsForGroup1: settings.tagsForGroup1 || '', tagsForGroup2: settings.tagsForGroup2 || '', tagsForGroup3: settings.tagsForGroup3 || '', tagsForGroup4: settings.tagsForGroup4 || '', tagsForGroup5: settings.tagsForGroup5 || '', tagsForGroup6: settings.tagsForGroup6 || '', tagsForGroup7: settings.tagsForGroup7 || '', tagsForGroup8: settings.tagsForGroup8 || '', tagsForGroup9: settings.tagsForGroup9 || '', tagsForGroup10: settings.tagsForGroup10 || '', disableSelfOptimization: settings.disableSelfOptimization ?? false, efficiencyIndex: settings.efficiencyIndex ?? 0.25, curiosityFactor: settings.curiosityFactor ?? '0', ignoreRate: settings.ignoreRate ?? 0, targetEliteNum: settings.targetEliteNum ?? 400, targetMonsterNum: settings.targetMonsterNum ?? 2000, priorityTags: settings.priorityTags ?? '', excludeTags: settings.excludeTags ?? '' }); /* -------- 2. 关键词黑名单检查 -------- */ const checkForbidden = (cfgStr) => { FORBIDDEN.forEach(word => { if (cfgStr.includes(word)) { log.error(`路线选择与分组配置中包含关键词(${word}),强制终止!`); throw new Error('禁止以汐酱或莫酱作为关键词'); } }); }; /* -------- 3. 主逻辑 -------- */ if (settings.groupIndex === '路径组一') { const cfg = buildCfgObj(); const cfgStr = JSON.stringify(cfg, null, 2); checkForbidden(cfgStr); const filePath = `settings/${accountName}.json`; file.writeText(filePath, cfgStr, false); } else { let cfg; try { const raw = await file.readText(`settings/${accountName}.json`); cfg = JSON.parse(raw); } catch (e) { log.error(`配置文件 settings/${accountName}.json 不存在或格式错误,请先在"路径组一"运行一次!`); throw new Error('无可用的配置文件'); } /* 把读到的字段同步回全局 settings */ for (const key in cfg) { settings[key] = cfg[key]; } } } //加载自定义配置 pickup_Mode = settings.pickup_Mode || "模板匹配拾取,拾取狗粮和怪物材料"; dumpers = settings.activeDumperMode ? settings.activeDumperMode.split(',').map(Number).filter(num => [1, 2, 3, 4].includes(num)) : []; findFInterval = Math.max(16, Math.min(200, +settings.findFInterval || 100)); checkDelay = Math.round(findFInterval / 2); rollingDelay = (+settings.rollingDelay || 32); pickupDelay = (+settings.pickupDelay || 100); timeMove = (+settings.timeMove || 1000); timeMoveUp = Math.round(timeMove * 0.45); timeMoveDown = Math.round(timeMove * 0.55); priorityTags = (settings.priorityTags || "").split(",").map(tag => tag.trim()).filter(tag => tag.length > 0); excludeTags = (settings.excludeTags || "").split(",").map(tag => tag.trim()).filter(tag => tag.length > 0); if (!pickup_Mode.includes("模板匹配")) { excludeTags.push("沙暴"); log.warn("拾取模式不是模板匹配,无法处理沙暴路线,自动排除所有沙暴路线"); } efficiencyIndex = settings.efficiencyIndex === undefined ? 0.25 : isNaN(Number(settings.efficiencyIndex)) || String(Number(settings.efficiencyIndex)) !== String(settings.efficiencyIndex) ? 0.25 : Number(settings.efficiencyIndex) < 0 ? 0 : Number(settings.efficiencyIndex); targetEliteNum = Math.max(0, +settings.targetEliteNum || 400) + 5; // 预留漏怪 targetMonsterNum = Math.max(0, +(settings.targetMonsterNum ?? 2000)) + 25; // 预留漏怪 partyName = settings.partyName || ""; groupSettings = Array.from({ length: 10 }, (_, i) => settings[`tagsForGroup${i + 1}`] || (i === 0 ? '蕈兽' : '') ); groupTags = groupSettings.map(str => str.split(',').filter(Boolean)); groupTags[0] = [...new Set(groupTags.flat())]; } /** * 检测本机 toLocaleString() 是否能被 Date.parse 正确解析。 * 若解析失败,会连续输出 3 条警告并阻塞 5 秒,最后返回 false。 * @returns {boolean} true -> 本地时间可用 * false -> 只能退而用 UTC 时间 */ async function checkLocaleTimeSupport() { const localStr = new Date().toLocaleString(); const ok = !isNaN(Date.parse(localStr)); if (!ok) { ['当前设备本地时间格式无法解析', '建议不要使用12小时时间制', '已将记录改为使用 utc 时间'].forEach(t => log.warn(`${t}`)); await sleep(5000); } return ok; } /** * 自定义配置未启用警告 * 若 settings.accountName 为空,则在控制台滚动输出 5 次提示, * 提醒用户先阅读 README 后再使用,防止因未配置导致后续逻辑异常。 * 依赖全局:settings、log、sleep */ async function rotateWarnIfAccountEmpty() { if (!settings.accountName) { for (let i = 0; i < 5; i++) { let originalMessage = " 请先阅读js文件夹中的【README.md】后使用"; let offset = i % originalMessage.length; let message = originalMessage.slice(-offset) + originalMessage.slice(0, -offset); log.error(message); await sleep(500); } } } /* ========================= ② 路线预处理与策略计算 ========================= * 负责:解析路线 JSON → 计算怪物数量/收益/时间 → 按用户标签、优先级、排除词过滤 * 最终产出:已标记 selected + group 的最优路线集合 * ====================================================================== */ /** * 路线预处理核心函数 * 1. 读取 assets/monsterInfo.json 建立怪物-收益映射表 * 2. 扫描 pathing/ 目录下所有 *.json 路线文件,反序列化 info.description * 提取「预计用时」与「怪物清单」并计算普通/精英怪数量及对应摩拉收益 * 3. 根据 settings.ignoreRate 过滤高小怪占比路线;按 groupTags[0] 反查补 tag * 4. 若开启自我优化且存在历史运行时长,则对「预计用时」做削峰填谷取均值 * 返回已附加 {t, m, e, mora_m, mora_e, tags, map_name, ...} 的完整路径对象数组 * 依赖全局:settings、accountName、file、initializeCdTime、readFolder */ async function processPathings(groupTags) { // 读取怪物信息 const monsterInfoContent = await file.readText("assets/monsterInfo.json"); const monsterInfoObject = JSON.parse(monsterInfoContent); // 读取路径文件夹中的所有文件 log.info("开始读取路径文件"); let pathings = await readFolder("pathing", "json"); //加载路线cd信息 log.info("路径文件读取完成,开始加载cd信息"); await initializeCdTime(pathings, accountName); // 定义解析 description 的函数 function parseDescription(desc) { const routeInfo = { time: 60, // 预计用时初始化为60秒 monsterInfo: {} }; // 正则表达式匹配预计用时 const timeMatch = desc.match(/预计用时([\d\.]+)秒/); if (timeMatch) { routeInfo.time = parseFloat(timeMatch[1]); } // 正则表达式匹配怪物信息 const monsterMatch = desc.match(/包含以下怪物:(.*?)。/); if (monsterMatch) { const monsterList = monsterMatch[1].split('、'); monsterList.forEach(monsterStr => { const [countStr, monsterName] = monsterStr.split('只'); const count = Math.ceil(parseFloat(countStr.trim()) || 0); routeInfo.monsterInfo[monsterName.trim()] = count; }); } return routeInfo; } let index = 0 log.info("cd信息加载完成,开始处理路线详细信息"); // 遍历每个路径文件并处理 for (const pathing of pathings) { index++; pathing.index = index; const pathingContent = await file.readText(pathing.fullPath); const parsedContent = JSON.parse(pathingContent); const description = parsedContent.info?.description || ""; pathing.tags = parsedContent.info?.tags || []; // 解析 description 获取预计用时和怪物信息 const routeInfo = parseDescription(description); //pathing 对象的属性 pathing.t = routeInfo.time; // description 中有值则覆盖 pathing.monsterInfo = routeInfo.monsterInfo; pathing.m = 0; // 普通怪物数量 pathing.e = 0; // 精英怪物数量 pathing.original_e = 0; // 原始精英数量(用于统计被忽略的) pathing.mora_m = 0; // 普通怪物摩拉值 pathing.mora_e = 0; // 精英怪物摩拉值 // 处理怪物信息 for (const [monsterName, count] of Object.entries(routeInfo.monsterInfo)) { const monster = monsterInfoObject.find(m => m.name === monsterName); if (monster) { if (monster.type === "普通") { pathing.m += count; // 增加普通怪物数量 pathing.mora_m += count * 40.5 * monster.moraRate; // 增加普通怪物摩拉值 } else if (monster.type === "精英") { pathing.e += count; // 增加精英怪物数量 pathing.mora_e += count * 200 * monster.moraRate; // 增加精英怪物摩拉值 pathing.original_e += count; // 记录原始精英数量 } if (monster.moraRate > 1) { pathing.tags.push('高收益'); if (monster.type === "精英") { pathing.tags.push('精英高收益'); } } // 添加标签 if (monster.tags && monster.tags.length > 0) { pathing.tags.push(...monster.tags); } } } // ===== 根据 settings.ignoreRate 过滤 ===== const ignoreRate = Number(settings.ignoreRate) || 100; if (Number.isInteger(ignoreRate) && ignoreRate > 0) { const protectTags = ['精英高收益', '高危', '传奇']; const hasProtectTag = protectTags.some(tag => pathing.tags.includes(tag)); if (!hasProtectTag && pathing.e > 0) { // ① 先保证有精英 const ratio = pathing.m / pathing.e; // ② 再计算比例(e 已 > 0) if (ratio >= ignoreRate) { // ③ 比例达标才清零 pathing.e = 0; pathing.mora_e = 0; } } } const allTags = groupTags[0]; // 已经是 [...new Set(...)] 的结果 // 2. 待匹配文本:路径名 + 描述 const textToMatch = (pathing.fullPath + " " + (description || "")); // 3. 反查补 tag allTags.forEach(tag => { if (textToMatch.includes(tag)) { pathing.tags.push(tag); } }); // 去除重复标签 pathing.tags = [...new Set(pathing.tags)]; // 处理 map_name 属性 pathing.map_name = parsedContent.info?.map_name || "Teyvat"; // 如果有 map_name,则使用其值,否则默认为 "Teyvat" } for (const pathing of pathings) { if (!settings.disableSelfOptimization && pathing.records) { // 1. 安全解析 + 边界校验 let cf = 0; // 默认 if (settings?.curiosityFactor != null) { const parsed = parseFloat(String(settings.curiosityFactor)); if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) cf = parsed; } // 2. 构造 7 条内部样本 const raw = Array.isArray(pathing.records) ? pathing.records.filter(v => v > 0) : []; const pool = []; for (let i = 0; i < 7; i++) { pool.push(i < raw.length ? raw[i] : pathing.t * (1 - cf)); // 补齐时带好奇系数 } // 3. 削峰填谷 → 均值 const copy = [...pool]; const max = Math.max(...copy); const min = Math.min(...copy); const maxIdx = copy.indexOf(max); const minIdx = copy.indexOf(min); if (maxIdx > -1) copy.splice(maxIdx, 1); if (minIdx > -1 && copy.length) copy.splice(copy.indexOf(min), 1); const avg = copy.reduce((a, b) => a + b, 0) / copy.length; pathing.t = avg; } } log.info("预处理阶段完成"); return pathings; // 返回处理后的 pathings 数组 } /** * 路线打标与过滤 * 1. 将「仅第 0 组独有」的标签视为互斥标签:路线一旦包含则直接置 unavailable * 2. 若路线文件名、已有标签或所含怪物名命中 excludeTags,同样置 unavailable * 3. 命中 priorityTags 的路线打上 prioritized 标记,后续选路时会被优先保留 * 4. 最终给每条路线新增: * available(bool)- 是否可参与后续选路 * prioritized(bool)- 是否优先保留 * 依赖:pathings、groupTags、priorityTags、excludeTags */ async function markPathings(pathings, groupTags, priorityTags, excludeTags) { // 取出第 0 组并剔除与其他 9 组重复的标签 const uniqueTags = groupTags[0].filter(tag => !groupTags.slice(1).some(arr => arr.includes(tag)) ); pathings.forEach(pathing => { pathing.tags = pathing.tags || []; pathing.monsterInfo = pathing.monsterInfo || {}; pathing.prioritized = false; const containsUniqueTag = uniqueTags.some(uniqueTag => pathing.tags.includes(uniqueTag)); const containsExcludeTag = excludeTags.some(excludeTag => { const fullPathContainsExcludeTag = pathing.fullPath && pathing.fullPath.includes(excludeTag); const tagsContainExcludeTag = pathing.tags.some(tag => tag.includes(excludeTag)); const monsterInfoContainsExcludeTag = Object.keys(pathing.monsterInfo).some(monsterName => monsterName.includes(excludeTag)); return fullPathContainsExcludeTag || tagsContainExcludeTag || monsterInfoContainsExcludeTag; }); const containsPriorityTag = priorityTags.some(priorityTag => { const fullPathContainsPriorityTag = pathing.fullPath && pathing.fullPath.includes(priorityTag); const tagsContainPriorityTag = pathing.tags.some(tag => tag.includes(priorityTag)); const monsterInfoContainsPriorityTag = Object.keys(pathing.monsterInfo).some(monsterName => monsterName.includes(priorityTag)); return fullPathContainsPriorityTag || tagsContainPriorityTag || monsterInfoContainsPriorityTag; }); pathing.available = !(containsUniqueTag || containsExcludeTag); pathing.prioritized = containsPriorityTag; }); } /** * 最优路线组合生成器 * 1. 为每条可用路线计算精英收益效率 E1 与小怪收益效率 E2(含好奇系数修正) * 2. 先按 E1 降序选够 targetEliteNum,再按 E2 降序补够 targetMonsterNum * 3. 迭代微调精英门槛,并贪心剔除非优先路线,使总量恰好落在目标区间 * 4. 按 settings.sortMode 重排最终路线顺序,输出总精英/小怪/收益/用时 * 返回:pathings[] 各元素新增 selected(bool)及排序 * 依赖:pathings(已含 mora_e/mora_m/t/e/available/prioritized) */ async function findBestRouteGroups(pathings, efficiencyIndex, targetEliteNum, targetMonsterNum) { log.info("开始根据配置寻找路线组合"); /* ========== 0. 原初始化不动 ========== */ let nextTargetEliteNum = targetEliteNum; let iterationCount = 0; let totalSelectedElites = 0; let totalSelectedMonsters = 0; let totalGainCombined = 0; let totalTimeCombined = 0; let monsterRouteElite = 0; let maxE1 = -Infinity, maxE2 = -Infinity; let minE1 = Infinity, minE2 = Infinity; pathings.forEach(p => { p.selected = false; if (p.e !== 0) { p.E1 = (efficiencyIndex * p.mora_e - p.t) / p.e; } else { p.E1 = null; } if (p.m !== 0) { p.E2 = (efficiencyIndex * p.mora_m - p.t) / p.m; } else { p.E2 = null; } if (p.e !== 0) maxE1 = Math.max(maxE1, p.E1); if (p.m !== 0) maxE2 = Math.max(maxE2, p.E2); if (p.e !== 0) minE1 = Math.min(minE1, p.E1 ?? Infinity); if (p.m !== 0) minE2 = Math.min(minE2, p.E2 ?? Infinity); }); pathings.forEach(p => { if (p.e === 0) { p.E1 = minE1 - 1; } if (p.m === 0) { p.E2 = minE2 - 1; } if (p.prioritized) { p.E1 += (maxE1 - minE1 + 2); p.E2 += (maxE2 - minE2 + 2); } }); /* ========== 1. 原两轮选择逻辑照搬,只是去掉“提前 break” ========== */ function selectRoutesByEliteTarget(targetEliteNum) { pathings.forEach(p => p.selected = false); totalSelectedElites = 0; totalSelectedMonsters = 0; totalGainCombined = 0; totalTimeCombined = 0; pathings.sort((a, b) => b.E1 - a.E1); for (const p of pathings) { if (p.e > 0 && p.available && (totalSelectedElites + p.e <= targetEliteNum + 2)) { // 留一点余量 p.selected = true; totalSelectedElites += p.e; totalSelectedMonsters += p.m; totalGainCombined += p.mora_e + p.mora_m; totalTimeCombined += p.t; } } } function selectRoutesByMonsterTarget(targetMonsterNum) { monsterRouteElite = 0; pathings.sort((a, b) => b.E2 - a.E2); for (const p of pathings) { if (p.m > 0 && p.available && !p.selected && (totalSelectedMonsters + p.m < targetMonsterNum + 5)) { p.selected = true; totalSelectedElites += p.e; monsterRouteElite += p.e; totalSelectedMonsters += p.m; totalGainCombined += p.mora_m; totalTimeCombined += p.t; } } } /* ========== 2. 迭代:直到“双达标”才停 ========== */ while (iterationCount < 100) { selectRoutesByEliteTarget(nextTargetEliteNum); selectRoutesByMonsterTarget(targetMonsterNum); // 新条件:总量必须落在区间里 if (totalSelectedElites >= targetEliteNum && totalSelectedElites <= iterationCount / 20 && totalSelectedMonsters >= targetMonsterNum && totalSelectedMonsters <= iterationCount / 4) { break; } // 只调精英目标:若当前选多了就降门槛,选少了就抬门槛 const eliteGap = targetEliteNum - totalSelectedElites; nextTargetEliteNum += Math.round(0.7 * eliteGap); // 可正可负,自动收敛 iterationCount++; } /* ========== 3. 最小不可再减集合(贪心逆筛,不碰优先路线) ========== */ // 1. 只留非优先的已选路线,按E1升序、E2升序排(差的先删) const selectedList = pathings .filter(p => p.selected && !p.prioritized && !p.tags.includes('精英高收益') ) .sort((a, b) => a.E1 - b.E1 || a.E2 - b.E2); // 2. 试删 for (const p of selectedList) { const newE = totalSelectedElites - p.e; const newM = totalSelectedMonsters - p.m; if (newE >= targetEliteNum && newM >= targetMonsterNum) { //log.info("调试-删掉了一条路线") p.selected = false; totalSelectedElites = newE; totalSelectedMonsters = newM; totalGainCombined -= p.mora_e + p.mora_m; totalTimeCombined -= p.t; } } /* ========== 4. 小怪标签 & 排序 & 日志,保持原样 ========== */ pathings.forEach(p => { // 1. 统一先删掉旧的小怪标签(不管之前有没有) p.tags = p.tags.filter(t => t !== '小怪'); // 2. 按最新条件重新判断 if (p.selected && p.e === 0 && !p.tags.includes('传奇') && !p.tags.includes('高危')) { p.tags.push('小怪'); } }); switch (settings.sortMode) { case "效率降序": log.info("使用效率降序运行"); pathings.sort((a, b) => b.E1 - a.E1 || b.E2 - a.E2); break; case "高收益优先": log.info("使用高收益优先运行"); pathings.sort((a, b) => { const aHigh = a.tags.includes("高收益") ? 1 : 0; const bHigh = b.tags.includes("高收益") ? 1 : 0; return bHigh - aHigh || a.fileName.localeCompare(b.fileName); // 有标签的在前,同标签按文件名排序 }); break; default: log.info("使用原文件顺序运行"); pathings.sort((a, b) => a.fileName.localeCompare(b.fileName)); // 按文件名排序 } log.info("路线组合结果如下:"); log.info(`总精英怪数量: ${totalSelectedElites.toFixed(0)}`); log.info(`总普通怪数量: ${totalSelectedMonsters.toFixed(0)}`); log.info(`总收益: ${totalGainCombined.toFixed(0)} 摩拉`); const h = Math.floor(totalTimeCombined / 3600); const m = Math.floor((totalTimeCombined % 3600) / 60); const s = totalTimeCombined % 60; log.info(`预计总用时: ${h} 时 ${m} 分 ${s.toFixed(0)} 秒`); if (totalSelectedElites < targetEliteNum - 5 || totalSelectedMonsters < targetMonsterNum - 25 || totalSelectedElites > targetEliteNum * 1.1) { log.warn("警告,可能条件填写不合理,分配结果与目标存在较大差异"); await sleep(5000); } } /** * 把已选路线分配到 10 个用户分组 * 规则: * 1. 只处理 selected 的路线,其余保持 group=0 * 2. 若路线不含第 0 组任何标签 → 直接分到组 1 * 3. 否则按 groupTags[1]...groupTags[9] 顺序匹配,命中即分到对应组(2-10) * 结果:pathing.group = 1..10,后续按组批量执行 * 依赖:pathings(已有 selected & tags)、groupTags */ async function assignGroups(pathings, groupTags) { // 遍历 pathings 数组 pathings.forEach(pathing => { if (pathing.selected) { pathing.group = 0; if (!groupTags[0].some(tag => pathing.tags.includes(tag))) { pathing.group = 1; } else { // 依次判断 groupTags[1] ~ groupTags[9] for (let i = 1; i <= 9; i++) { if (groupTags[i].some(tag => pathing.tags.includes(tag))) { pathing.group = i + 1; break; } } } } }); } /** * 仅指定怪物模式入口 * 1. 解析用户填写的目标怪物字符串(中文逗号分隔) * 2. 强制使用“路径组一”并构造空分组,避免后续数组越界 * 3. 调用 processPathings 取得全部路线对象 * 4. 逐路线在「文件名 + 描述」中全文匹配任一目标怪物关键字 * 结果:pathings[].selected = 是否命中;pathings[].group = 1 或 0 * 依赖:settings、file、processPathings、pathings(全局) */ async function filterPathingsByTargetMonsters() { // 1. 日志 & 检查空值 if (settings.targetMonsters) { log.info(`当前目标怪物信息:${settings.targetMonsters}`); } else { log.error(`当前目标怪物为空,请阅读readme后重新检查自定义配置`); } // 2. 拆分成数组 const targetMonsters = (settings.targetMonsters || "") .split(",") // 中文逗号 .map(s => s.trim()) .filter(Boolean); // 3. 固定用路径组一 settings.groupIndex = "路径组一"; // 4. 构造空分组,避免后续越界 const fakeGroupTags = Array.from({ length: 10 }, () => []); // 5. 预处理拿到完整路线 pathings = await processPathings(fakeGroupTags); // 6. 逐路线匹配 description 与文件名 for (const p of pathings) { let desc = ''; try { const raw = await file.readText(p.fullPath); desc = (JSON.parse(raw).info?.description || ''); } catch { /* 忽略读失败 */ } const textToSearch = (p.fullPath || '') + ' ' + desc; p.selected = targetMonsters.some(m => textToSearch.includes(m)); p.group = p.selected ? 1 : 0; // 选中→组1,否则组0 } const selectedCount = pathings.filter(p => p.selected).length; log.info(`目标怪物模式:共找到 ${selectedCount} 条相关路线`); } /* ========================= ③ 运行前校验与调试输出 ========================= * 负责:队伍合理性检查、四神队禁断、窗口分辨率警告 * 调试模式下导出各组统计与路线文件,供人工核对 * ====================================================================== */ /** * 完整的队伍校验流程(原逻辑 0 改动,仅把最末尾的 return 换成 throw 终止脚本) * 1. 打印当前队伍(依赖全局 getAvatars) * 2. 按配置项逐项校验,标记 haveProblem * 3. 四神队检测 -> 抛错终止 * 依赖全局:settings、log、sleep、genshin、targetEliteNum、targetMonsterNum、getAvatars * @throws {Error} 四神队齐全时抛出 'FOUR_GODS_TEAM_FORBIDDEN' 终止脚本 */ async function validateTeamAndConfig() { const avatars = Array.from(getAvatars?.() || []); // 1. 打印队伍 const teamStr = avatars.join('、'); log.info('当前队伍:' + teamStr); let haveProblem = false; // 2. 校验阶段 if (settings.skipCheck) { log.warn("确认跳过校验阶段,任何包括但不限于漏怪、卡死、不拾取等问题均由自己配置与队伍等引起,与脚本和路线无关"); } else { if (targetEliteNum <= 350 && targetMonsterNum >= 100) { log.warn("目标怪物数量配置不合理,请重新阅读 readme 相关部分"); await sleep(5000); haveProblem = true; } if (genshin.width !== 1920 || genshin.height !== 1080) { log.warn("游戏窗口非 1920×1080,可能导致图像识别失败,造成拾取等行为异常"); await sleep(5000); haveProblem = true; } if (avatars.includes('钟离')) { log.warn("当前队伍包含钟离,请重新阅读 readme 相关部分"); await sleep(5000); haveProblem = true; } if (!['芙宁娜', '爱可菲'].some(n => avatars.includes(n))) { log.warn("未携带合适的输出角色(芙宁娜/爱可菲),建议重新阅读 readme 相关部分"); await sleep(5000); haveProblem = true; } if (!['茜特菈莉', '伊涅芙', '莱依拉', '蓝砚', '琦良良', '迪希雅', '迪奥娜'] .some(n => avatars.includes(n))) { log.warn("未携带合适的抗打断角色(茜特菈莉/伊涅芙/莱依拉/蓝砚/白术/琦良良/迪希雅/迪奥娜)"); await sleep(5000); haveProblem = true; } if (haveProblem) { log.warn("校验未通过,请按照以上提示修改,或者在自定义配置中勾选以跳过校验阶段"); await sleep(5000); log.warn("校验未通过,请按照以上提示修改,或者在自定义配置中勾选以跳过校验阶段"); await sleep(5000); log.warn("校验未通过,请按照以上提示修改,或者在自定义配置中勾选以跳过校验阶段"); await sleep(5000); log.warn("继续运行视为同意以下免责声明:任何包括但不限于漏怪、卡死、不拾取等问题均由自己配置与队伍等引起,与脚本和路线无关"); } } // 3. 四神队检测:抛出即终止 if (['钟离', '芙宁娜', '纳西妲', '雷电将军'].every(n => avatars.includes(n))) { log.warn("禁止使用四神队,请重新阅读 readme 相关部分"); await sleep(5000); throw new Error('禁止使用四神队'); } } /** * 调试-分组汇总打印 * 仅统计 group=1..10 且 selected 的路线,累加精英数、小怪数、总收益与总时长 * 输出每组的路线条数、精英/小怪数量、预计收益(摩拉)与预计用时(时:分:秒) * 用于“调试路线分配”模式快速核对各组工作量 * 将汇总结果写入 调试结果/路线分配结果.txt 文件 * 依赖全局:pathings */ async function printGroupSummary() { const groupNames = [ '路径组一', '路径组二', '路径组三', '路径组四', '路径组五', '路径组六', '路径组七', '路径组八', '路径组九', '路径组十' ]; // 创建结果字符串 let resultText = "路线分配结果汇总\n"; resultText += "=".repeat(50) + "\n\n"; // 统计所有选中的路线 const selectedPathings = pathings.filter(p => p.selected); resultText += `总选中路线数: ${selectedPathings.length} 条\n\n`; let totalElites = 0, totalMonsters = 0, totalGain = 0, totalTime = 0, totalIgnoredElites = 0; for (let g = 1; g <= 10; g++) { const groupPath = pathings.filter(p => p.group === g && p.selected); if (groupPath.length === 0) continue; // 跳过空组 let elites = 0, monsters = 0, gain = 0, time = 0, ignoredElites = 0; for (const p of groupPath) { elites += p.e || 0; monsters += p.m || 0; gain += p.mora_e + p.mora_m || 0; time += p.t || 0; ignoredElites += (p.original_e || 0) - (p.e || 0); } // 累加到总计 totalElites += elites; totalMonsters += monsters; totalGain += gain; totalTime += time; totalIgnoredElites += ignoredElites; const h = Math.floor(time / 3600); const m = Math.floor((time % 3600) / 60); const s = time % 60; // 获取该组的标签配置 const tagsKey = `tagsForGroup${g}`; const groupTags = settings[tagsKey] || ''; const tagType = g === 1 ? "排除的标签" : "选择的标签"; // 构建输出内容 const outputLines = [ `${groupNames[g - 1]} 总计:`, ` ${tagType}:【${groupTags}】`, ` 路线条数: ${groupPath.length}`, ` 精英怪数: ${elites.toFixed(0)}`, ` 被忽视精英数: ${ignoredElites.toFixed(0)}`, ` 小怪数 : ${monsters.toFixed(0)}`, ` 预计收益: ${gain.toFixed(0)} 摩拉`, ` 预计用时: ${h} 时 ${m} 分 ${s.toFixed(0)} 秒`, "" // 空行 ]; // 输出到控制台和结果文本 outputLines.forEach(line => { if (line === "") { // 空行不输出到控制台,但保留在结果文本中 resultText += "\n"; } else { log.info(line); resultText += line + "\n"; } }); } // 添加总计信息 const totalH = Math.floor(totalTime / 3600); const totalM = Math.floor((totalTime % 3600) / 60); const totalS = totalTime % 60; resultText += "=".repeat(50) + "\n"; resultText += "总体统计:\n"; resultText += ` 总路线数: ${selectedPathings.length} 条\n`; resultText += ` 总精英怪: ${totalElites.toFixed(0)}\n`; resultText += ` 被忽视精英怪数: ${totalIgnoredElites.toFixed(0)}\n`; resultText += ` 总小怪数: ${totalMonsters.toFixed(0)}\n`; resultText += ` 总收益 : ${totalGain.toFixed(0)} 摩拉\n`; resultText += ` 总用时 : ${totalH} 时 ${totalM} 分 ${totalS.toFixed(0)} 秒\n`; resultText += "=".repeat(50) + "\n\n"; // 其他配置信息 resultText += "配置参数:\n"; resultText += ` 摩拉/耗时权衡因数: ${settings.efficiencyIndex || 0.25}\n`; resultText += ` 好奇系数: ${settings.curiosityFactor || 0}\n`; resultText += ` 忽略比例: ${settings.ignoreRate || 0}\n`; resultText += ` 目标精英数: ${settings.targetEliteNum || 400}\n`; resultText += ` 目标小怪数: ${settings.targetMonsterNum ?? 2000}\n`; resultText += ` 优先级标签: ${settings.priorityTags || ''}\n`; resultText += ` 排除标签: ${settings.excludeTags || ''}\n\n`; // 写入文件 const filePath = "调试结果/路线分配结果.txt"; try { await file.writeText(filePath, resultText, false); log.info(`路线分配结果已保存至: ${filePath}`); } catch (error) { log.error(`保存路线分配结果文件失败: ${error.message}`); } } /** * 调试-按组导出路线文件 * 仅复制被选中的路线(selected===true)到本地调试目录 * 输出结构:调试结果/group{1..10}/原相对路径/文件名.json * 用于“调试路线分配”模式,人工核对各组最终路线清单 * 依赖:file 读写接口、pathings(已有 selected & group) */ async function copyPathingsByGroup(pathings) { // 遍历 pathings 数组 for (const pathing of pathings) { // 只处理 selected 为 true 的项 if (pathing.selected) { // 读取文件内容 const content = await file.readText(pathing.fullPath); // 构造目标路径 const groupFolder = `调试结果/group${pathing.group}`; const targetPath = `${groupFolder}/${pathing.fullPath}`; // 写入文件内容 await file.writeText(targetPath, content, false); } } } /* ========================= ④ 路线执行引擎 ========================= * 负责:单条路线的真正执行(地图追踪)、并发拾取、异常状态检测、泥头车放技能 * 通过 Promise.allSettled 并发跑主任务+拾取+异常监控+泥头车 * ============================================================== */ /** * 单路线执行与并发监控 * 1. 前置处理:白芙切黑芙、吃料理buff、水下路线补螃蟹技能 * 2. 并发启动四个子任务: * - pathingTask:真正执行地图追踪文件 * - pickupTask:模板匹配拾取物品 * - errorProcessTask:冻结/白芙/烹饪界面检测与脱困 * - blacklistTask:背包满时OCR识别并拉黑多余物品 * - dumperTask(可选):接近战斗坐标时自动切人放E * 3. 全部子任务完成后返回,state.running 被置 false * 依赖全局:settings、state、pathings、targetItems、dumpers、doFurinaSwitch、lastEatBuff 等 */ async function runPath(fullPath, map_name, pm, pe) { //当需要切换芙宁娜形态时,执行一次强制黑芙 if (doFurinaSwitch) { log.info("上条路线识别到白芙,开始强制切换黑芙") doFurinaSwitch = false; await pathingScript.runFile("assets/强制黑芙.json"); } if (settings.eatBuff) { if (new Date() - lastEatBuff > 300 * 1000) { // 1. 数据预处理:分割、去空、去重 let res = settings.eatBuff .split(',') .map(item => item.trim()) .filter(item => item.length > 0) .filter((item, index, arr) => arr.indexOf(item) === index); // 无有效数据时提前返回 if (res.length === 0) { log.error("无有效的食物配置"); return; } // 2. 优化排序:currentFood 置顶以减少筛选操作 if (currentFood && res.includes(currentFood)) { res = [currentFood, ...res.filter(item => item !== currentFood)]; } await genshin.returnMainUi(); keyPress("B"); let type = "食物" await findAndClick([`assets/背包界面/${type}1.png`, `assets/背包界面/${type}2.png`]); // 3. 遍历数组,逐项执行 for (const item of res) { if (currentFood !== item) { await sleep(300); await findAndClick(['assets/筛选1.png', 'assets/筛选2.png']); await findAndClick("assets/重置.png"); await sleep(300); await findAndClick("assets/搜索.png"); await sleep(300); await findAndClick("assets/搜索成功点击.png"); // 真正输入当前这一项 log.info(`搜索${item}`); currentFood = item; inputText(item); await findAndClick("assets/确认筛选.png"); while (await findAndClick("assets/确认筛选.png", false, 2, 3)) { await sleep(16); } } await findAndClick("assets/使用.png"); } await genshin.returnMainUi(); lastEatBuff = new Date(); } } /* ===== 1. 取得当前路线对象 ===== */ let currentPathing = null; for (let i = 0; i < pathings.length; i++) { if (pathings[i].fullPath === fullPath) { currentPathing = pathings[i]; break; } } if (currentPathing.tags) { if (currentPathing.tags.includes("水下")) { log.info("当前路线为水下路线,检查螃蟹技能"); let skillRes = await findAndClick("assets/螃蟹技能图标.png", false, 1000); if (!skillRes) { log.info("识别到没有螃蟹技能,前往获取"); await pathingScript.runFile("assets/学习螃蟹技能.json"); } } } /* ===== 2. 重排 targetItems:当前路线拾取过的提前 ===== */ if (targetItems && currentPathing && currentPathing.items && currentPathing.items.length) { // 用对象当 Set 做 O(1) 查询 const history = {}; for (let i = 0; i < currentPathing.items.length; i++) { history[currentPathing.items[i]] = true; } // 排序:命中历史 -> 提前,其余保持原序 targetItems.sort(function (a, b) { const aHit = history[a.itemName] ? 1 : 0; const bHit = history[b.itemName] ? 1 : 0; return bHit - aHit; // 1 在前,0 在后 }); } /* ===== 3. 原逻辑不变 ===== */ state = { running: true, currentPathing: currentPathing }; /* ---------- 主任务 ---------- */ const pathingTask = (async () => { // 从 fullPath 中提取纯文件名(去掉路径和扩展名) const fileName = fullPath.split(/[\\/]/).pop(); let doLogMonsterCount = true; log.info(`开始执行路线: ${fileName}`); await fakeLog(`${fileName}`, true); try { await pathingScript.runFile(fullPath); } catch (error) { log.error(`执行地图追踪出现错误${error.message}`); } try { await sleep(1); } catch (e) { doLogMonsterCount = false; } if (settings.logMonsterCount && doLogMonsterCount) { const m = Math.floor(pm); const e = Math.floor(pe); const lines = []; for (let i = 0; i < m; i++) lines.push('交互或拾取:"小怪"'); for (let i = 0; i < e; i++) lines.push('交互或拾取:"精英"'); if (lines.length) log.debug(lines.join('\n')); } await fakeLog(`${fileName}`, false); state.running = false; })(); /* ---------- 伴随任务 ---------- */ const pickupTask = (async () => { if (pickup_Mode.includes("模板匹配")) { await recognizeAndInteract(); } })(); const errorProcessTask = (async () => { let errorProcessCount = 0; while (state.running) { if (errorProcessCount % 5 === 0) { //每约250毫秒进行一次冻结检测和白芙检测 if (await findAndClick(frozenRo, false, 2, 3)) { log.info("检测到冻结,尝试挣脱"); for (let m = 0; m < 3; m++) { keyPress("VK_SPACE"); await sleep(30); } continue; } if (!doFurinaSwitch) { if (await findAndClick(whiteFurinaRo, false, 2, 3)) { log.info("检测到白芙,本路线运行结束后切换芙宁娜形态"); doFurinaSwitch = true; continue; } } if (await findAndClick(revivalRo, true, 2, 3)) { log.info("识别到复苏按钮,点击"); await sleep(500); continue; } } if (errorProcessCount % 100 === 0) { //每约5000毫秒进行一次烹饪检测 if (await findAndClick(cookingRo, false, 2, 3)) { log.info("检测到烹饪界面,尝试脱离"); keyPress("VK_ESCAPE"); await sleep(500); continue; } } errorProcessCount++; await sleep(50); } })(); const blacklistTask = (async () => { async function checkItemFull() { const maxAttempts = 1; let attempts = 0; while (attempts < maxAttempts && state.running) { try { gameRegion.dispose(); gameRegion = captureGameRegion(); const result = gameRegion.find(itemFullRo); if (result.isExist()) { return true; } } catch (error) { log.error(`识别图像时发生异常: ${error.message}`); if (!state.running) break; return false; } attempts++; } return false; } /** * 计算匹配度:itemName中文部分在识别文本中出现的最长长度占总长度的比例 * @param {string} cnPart itemName的中文部分 * @param {string} ocrText OCR识别到的文本 * @returns {number} 0~1 */ function calcMatchRatio(cnPart, ocrText) { if (!cnPart || !ocrText) return 0; const len = cnPart.length; let maxMatch = 0; // 滑动窗口找最长连续子串 for (let i = 0; i <= ocrText.length - len; i++) { let match = 0; for (let j = 0; j < len; j++) { if (ocrText[i + j] === cnPart[j]) match++; } maxMatch = Math.max(maxMatch, match); } return maxMatch / len; } if (pickup_Mode.includes("模板匹配")) { while (state.running) { let sleepTimes = 0; while (state.running && sleepTimes < 30) { await sleep(50); sleepTimes++; } if (!state.running) break; if (await checkItemFull()) { const TEXT_X = 560, TEXT_Y = 450, TEXT_W = 1360 - 560, TEXT_H = 620 - 450; let ocrText = null; try { const resList = gameRegion.findMulti( RecognitionObject.ocr(TEXT_X, TEXT_Y, TEXT_W, TEXT_H) ); if (resList.count) { let longest = resList[0]; for (let i = 1; i < resList.count; i++) { if (resList[i].text.length > longest.text.length) longest = resList[i]; } ocrText = longest.text.replace(/[^\u4e00-\u9fa5]/g, ''); } } catch (e) { log.error(`OCR异常: ${e.message}`); } if (ocrText) { log.info(`识别到背包已满,识别到文本:${ocrText}`); const ratioMap = new Map(); // itemName -> ratio for (const targetItem of targetItems) { const cnPart = targetItem.itemName.replace(/[^\u4e00-\u9fa5]/g, ''); const ratio = calcMatchRatio(cnPart, ocrText); if (ratio > 0.75) { ratioMap.set(targetItem.itemName, ratio); } } if (ratioMap.size > 0) { // 找出最大匹配度 const maxRatio = Math.max(...ratioMap.values()); // 所有等于最大匹配度的项 const names = Array.from(ratioMap.entries()) .filter(([, r]) => r === maxRatio) .map(([n]) => n) .sort(); // 排序方便日志 log.warn(`以下物品匹配度最高且≥75%(${(maxRatio * 100).toFixed(1)}%),加入黑名单:${names.join('、')}`); for (const nm of names) { if (!blacklistSet.has(nm)) { blacklistSet.add(nm); blacklist.push(nm); } } await loadBlacklist(false); } } } } } })(); /* ---------- 泥头车任务 ---------- */ let dumperTask = null; if (dumpers.length > 0) { dumperTask = dumper(fullPath, map_name); } /* ---------- 并发等待 ---------- */ await Promise.allSettled([ pathingTask, pickupTask, errorProcessTask, blacklistTask, dumperTask ].filter(Boolean)); } /** * 模板匹配拾取主循环 * 持续识别屏幕 F 图标 → 根据物品名模板匹配 → 黑名单过滤 → 按键拾取 * 同时通过滚轮上下翻页扩大识别范围,避免漏捡 * 将本次拾取记录回写至当前路线对象,用于下次优先识别 * 依赖全局:state、targetItems、blacklistSet、pickupDelay、rollingDelay 等 */ async function recognizeAndInteract() { //log.info("调试-开始执行图像识别与拾取任务"); let lastcenterYF = 0; let lastItemName = ""; let thisMoveUpTime = 0; let lastMoveDown = 0; gameRegion = captureGameRegion(); let itemName; //主循环 while (state.running) { //log.info("调试-交互拾取进行中"); gameRegion.dispose(); gameRegion = captureGameRegion(); let centerYF = await findFIcon(); if (!centerYF) { if (new Date() - lastRoll >= 200) { lastRoll = new Date(); if (await hasScroll()) { await keyMouseScript.runFile(`assets/滚轮下翻.json`); } } continue; } let foundTarget = false; if (pickup_Mode.includes("模板匹配")) { let time1 = new Date(); itemName = await performTemplateMatch(centerYF); let time2 = new Date(); //log.info(`调试-本次识别用时${time2 - time1}毫秒`); } if (itemName) { //log.info(`调试-识别到物品${itemName}`); if (Math.abs(lastcenterYF - centerYF) <= 20 && lastItemName === itemName) { //log.info("调试-相同物品名和相近y坐标,本次不拾取"); await sleep(160); lastcenterYF = -20; lastItemName = null; } else { if (blacklistSet.has(itemName)) { //log.warn(`识别到黑名单物品${itemName},不拾取`); } else { keyPress("F"); log.info(`交互或拾取:"${itemName}"`); // 把本次拾取加入当前路线名单,保持最多 20 个 if (state.currentPathing) { state.currentPathing.items.push(itemName); // 去重 + 保留最后 20 个 state.currentPathing.items = [...new Set(state.currentPathing.items)].slice(-20); } lastcenterYF = centerYF; lastItemName = itemName; await sleep(pickupDelay); //foundTarget = true; } } } else { //log.warn("未识别到结果"); //await refreshTargetItems(centerYF); lastItemName = ""; } if (!foundTarget) { //log.info(`调试-执行滚轮动作`); const currentTime = new Date().getTime(); if (currentTime - lastMoveDown > timeMoveUp) { await keyMouseScript.runFile(`assets/滚轮下翻.json`); if (thisMoveUpTime === 0) thisMoveUpTime = currentTime; if (currentTime - thisMoveUpTime >= timeMoveDown) { lastMoveDown = currentTime; thisMoveUpTime = 0; } } else { await keyMouseScript.runFile(`assets/滚轮上翻.json`); } await sleep(rollingDelay); } } async function performTemplateMatch(centerYF) { /* 一次性切 6 种宽度(0-5 汉字) */ const regions = []; for (let cn = 0; cn <= 5; cn++) { // 0~5 共 6 档 const w = 12 + 28 * Math.min(cn, 5) + 2; regions[cn] = gameRegion.DeriveCrop(1219, centerYF - 15, w, 30); } try { for (const it of targetItems) { const cnLen = Math.min( [...it.itemName].filter(c => c >= '\u4e00' && c <= '\u9fff').length, 5 ); // 0-5 if (regions[cnLen].find(it.roi).isExist()) { return it.itemName; } } } catch (e) { log.error(`performTemplateMatch: ${e.message}`); } finally { regions.forEach(r => r.dispose()); } return null; } async function findFIcon() { try { let result = gameRegion.find(fIconRo); if (result.isExist()) { return Math.round(result.y + result.height / 2); } } catch (error) { log.error(`识别图像时发生异常: ${error.message}`); if (!state.running) return null; } await sleep(checkDelay); return null; } } /** * 泥头车自动放 E * 读取当前路线坐标,若检测到即将进入战斗点(5-30 像素)且路线未使用按键 T, * 则按 dumpers 列表循环切人放 E,CD 10 秒;若检测到复活界面则立即退出 * 返回前内部定义 isRevivalUI 用于自救 * 依赖全局:state、dumpers、lastDumperTimer、dumperCD */ async function dumper(pathFilePath, map_name) { //log.info("开始泥头车"); let lastDumperTimer = 0; const dumperCD = 10000; try { const pathingContent = await file.readText(pathFilePath); const parsedContent = JSON.parse(pathingContent); const positions = parsedContent.positions; // 初始化 disableDumper 为 false let disableDumper = false; // 初始化 fightPositions 数组 let fightPositions = []; // 遍历 positions 数组 for (const pos of positions) { // 检查 action_params 是否包含 keypress(T) if (pos.action_params && pos.action_params.includes('keypress(T)')) { disableDumper = true; } // 如果 action 是 "fight",则添加到 fightPositions if (pos.action === "fight") { fightPositions.push({ x: pos.x, y: pos.y, used: false }); } } //6.3强制使用sift的地图不开启泥头车 const info = parsedContent.info; if (info.map_match_method && info.map_match_method === "SIFT") { disableDumper = true; } if (!disableDumper) { while (state.running) { //log.info("调试-泥头车循环"); let sleepTimes = 0; while (state.running && sleepTimes < 10) { await sleep(50); sleepTimes++; } if (!state.running) break; await sleep(1); if (await isMainUI() && !await findAndClick(flyingRo, false, 2, 3)) { //log.info("调试-获取坐标"); //在主界面才尝试获取坐标 let dumperDistance = 0; try { let shouldPressKeys = false; const currentPosition = await genshin.getPositionFromMap(map_name); if (!currentPosition) { continue; } for (let i = 0; i < fightPositions.length; i++) { const fightPos = fightPositions[i]; if (fightPos.used) { continue; } const distance = Math.sqrt( Math.pow(currentPosition.x - fightPos.x, 2) + Math.pow(currentPosition.y - fightPos.y, 2) ); if (distance <= 30) { fightPositions[i].used = true; } if (distance > 5 && distance <= 30) { if ((new Date() - lastDumperTimer) > dumperCD) { shouldPressKeys = true; lastDumperTimer = new Date(); dumperDistance = distance; } } } if (shouldPressKeys) { log.info(`距离下个战斗地点距离${dumperDistance.toFixed(2)},启用泥头车`); for (const key of dumpers) { log.info(`[泥头车]:尝试切换${key}号角色施放e技能`) keyPress(String(key)); await sleep(400); keyPress('e'); await sleep(400); keyPress('e'); await sleep(400); keyPress('e'); await sleep(400); } for (let i = 0; i < 10; i++) { if (await isRevivalUI()) { //检测到复苏界面时,退出复苏界面 keyPress("VK_ESCAPE"); await sleep(500); await genshin.returnMainUi(); } else { break; } } } } catch (error) { } } } } else { log.info("当前路线含有按键T,不启用泥头车"); } } catch (error) { log.error(`执行泥头车时出现异常: ${error.message}`); } //检查是否在复活界面 async function isRevivalUI() { // 修改后的图像路径 const imagePath = "assets/RevivalUI.png"; // 修改后的识别区域(左上角区域) const xMin = 450; const yMin = 200; const width = 1000; // 识别区域宽度 const height = 250; // 识别区域高度 let template = file.ReadImageMatSync(imagePath); let recognitionObject = RecognitionObject.TemplateMatch(template, xMin, yMin, width, height); // 尝试次数设置为 10 次 const maxAttempts = 10; let attempts = 0; let dodispose = false; while (attempts < maxAttempts && state.running) { try { if (!gameRegion) { gameRegion = captureGameRegion(); dodispose = true; } let result = gameRegion.find(recognitionObject); if (result.isExist()) { return true; // 如果找到图标,返回 true } } catch (error) { log.error(`识别图像时发生异常: ${error.message}`); return false; // 发生异常时返回 false } attempts++; // 增加尝试次数 await sleep(200); // 每次检测间隔 200 毫秒 if (dodispose) { gameRegion.dispose(); } } return false; // 如果尝试次数达到上限或取消,返回 false } } /* ========================= ⑤ 批量调度与数据持久化 ========================= * 负责:按组依次执行路线、刷新时间(CD)判断、坐标偏差校验、运行耗时记录 * 把本次运行结果写回 records/{账户}.json 与黑名单文件 * ======================================================================= */ /** * 批量调度与持久化主入口 * 1. 按用户选择的“路径组X”筛选路线,输出组内总计精英/小怪/收益/时长 * 2. 循环执行组内每条路线:CD未到则跳过;否则runPath()并记录真实耗时 * 3. 坐标偏差校验,失败≥1次且未禁用检查时放弃写入运行数据 * 4. 计算下次刷新时间(CD)并写回 records/{accountName}.json * 依赖:settings、pathings、runPath、updateRecords、isTimeRestricted 等 */ async function processPathingsByGroup(pathings, accountName) { let lastX = 0; let lastY = 0; if (settings.enableCoordCheck) { try { await genshin.returnMainUi(); const miniMapPosition = await genshin.getPositionFromMap(pathing.map_name); if (miniMapPosition) { // 更新坐标 lastX = miniMapPosition.X; lastY = miniMapPosition.Y; } } catch (error) { log.error(`获取坐标时发生错误:${error.message}`); } } // 定义路径组名称到组号的映射(10 个) const groupMapping = { "路径组一": 1, "路径组二": 2, "路径组三": 3, "路径组四": 4, "路径组五": 5, "路径组六": 6, "路径组七": 7, "路径组八": 8, "路径组九": 9, "路径组十": 10 }; // 从全局 settings 中获取用户选择的路径组名称 const selectedGroupName = settings.groupIndex || "路径组一"; // 默认值为 "路径组一" // 将路径组名称映射到组号 const targetGroup = groupMapping[selectedGroupName]; // 初始化变量,用于标记当前路径是该组的第几个 let groupPathCount = 0; // 获取该组的总路径数 const totalPathsInGroup = pathings.filter(pathing => pathing.group === targetGroup).length; if (pickup_Mode === "bgi原版拾取") { dispatcher.addTimer(new RealtimeTimer("AutoPick")); rollingDelay = 160; } // 初始化统计变量 let totalElites = 0; // 总精英怪数量 let totalMonsters = 0; // 总小怪数量 let totalGain = 0; // 总收益 let totalEstimatedTime = 0; // 预计总时间 // 遍历 pathings 数组,计算当前组的总计精英怪数量、小怪数量、总计收益和预计总时间 for (const pathing of pathings) { if (pathing.group === targetGroup) { totalElites += pathing.e || 0; // 精英怪数量 totalMonsters += pathing.m || 0; // 小怪数量 totalGain += pathing.mora_e + pathing.mora_m || 0; // 收益 totalEstimatedTime += pathing.t || 0; // 预计时间 } } // 将预计总时间转换为时、分、秒表示 const hours = Math.floor(totalEstimatedTime / 3600); const minutes = Math.floor((totalEstimatedTime % 3600) / 60); const seconds = totalEstimatedTime % 60; // 输出当前组的总计信息 log.info(`当前组 ${selectedGroupName} 的总计信息:`); log.info(`精英怪数量: ${totalElites.toFixed(0)}`); log.info(`小怪数量: ${totalMonsters.toFixed(0)}`); if (settings.operationMode != "启用仅指定怪物模式") { log.info(`预计收益: ${totalGain.toFixed(0)} 摩拉`); } log.info(`预计用时: ${hours} 时 ${minutes} 分 ${seconds.toFixed(0)} 秒`); const groupStartTime = new Date(); let remainingEstimatedTime = totalEstimatedTime; let skippedTime = 0; // 遍历 pathings 数组 for (const pathing of pathings) { // 检查路径是否属于指定的组 if (pathing.group === targetGroup) { // 增加路径计数 groupPathCount++; if (await isTimeRestricted(settings.timeRule, Math.ceil(pathing.t / 40))) { break; } // 输出当前路径的序号信息 log.info(`开始处理第 ${targetGroup} 组第 ${groupPathCount}/${totalPathsInGroup} 个${pathing.fileName}`); // 获取当前时间 const now = new Date(); // 检查 cdTime 是否晚于当前时间 const cdTime = new Date(pathing.cdTime); if (cdTime > now) { log.info(`该路线未刷新,跳过。`); skippedTime += pathing.t; remainingEstimatedTime -= pathing.t; continue; } // 输出路径已刷新并开始处理的信息 log.info(`该路线已刷新,开始处理。`); // 调用 runPath 函数处理路径 await runPath(pathing.fullPath, pathing.map_name, pathing.m, pathing.e); try { await sleep(1); } catch (error) { break; } const pathTime = new Date() - now; pathing.records = [...pathing.records, pathTime / 1000].slice(-7); remainingEstimatedTime -= pathing.t; const actualUsedTime = (new Date() - groupStartTime) / 1000; const predictRemainingTime = remainingEstimatedTime * actualUsedTime / (totalEstimatedTime - remainingEstimatedTime - skippedTime); // 将预计剩余时间转换为时、分、秒表示 const remaininghours = Math.floor(predictRemainingTime / 3600); const remainingminutes = Math.floor((predictRemainingTime % 3600) / 60); const remainingseconds = predictRemainingTime % 60; log.info(`当前进度:第 ${targetGroup} 组第 ${groupPathCount}/${totalPathsInGroup} 个 ${pathing.fileName}已完成,该组预计剩余: ${remaininghours} 时 ${remainingminutes} 分 ${remainingseconds.toFixed(0)} 秒`); let fileEndX = 0, fileEndY = 0; try { const raw = file.readTextSync(pathing.fullPath); const json = JSON.parse(raw); if (Array.isArray(json.positions)) { for (let i = json.positions.length - 1; i >= 0; i--) { const p = json.positions[i]; if (p.type !== 'orientation' && typeof p.x === 'number' && typeof p.y === 'number') { fileEndX = p.x; fileEndY = p.y; break; } } } } catch (e) { /* 读文件失败就留 0,0 继续走后面逻辑 */ } let coordAbnormal = false; if (settings.enableCoordCheck) { try { await genshin.returnMainUi(); const miniMapPosition = await genshin.getPositionFromMap(pathing.map_name); if (miniMapPosition) { const diffX = Math.abs(lastX - miniMapPosition.X); const diffY = Math.abs(lastY - miniMapPosition.Y); const endDiffX = Math.abs(fileEndX - miniMapPosition.X); const endDiffY = Math.abs(fileEndY - miniMapPosition.Y); lastX = miniMapPosition.X; lastY = miniMapPosition.Y; if ((diffX + diffY) < 5 || (endDiffX + endDiffY) > 30) { coordAbnormal = true; } } } catch (error) { log.error(`获取坐标时发生错误:${error.message}`); coordAbnormal = true; } } await genshin.returnMainUi(); let mainUiRes = await isMainUI(2000); let reconnectRes = await findAndClick(["assets/确认.png", "assets/重新连接服务器.png"], true, 300); if ((coordAbnormal && settings.enableCoordCheck) || !mainUiRes || reconnectRes) { log.error("路线未正常完成、坐标获取异常或不处于主界面,不记录运行数据"); notification.send(`路线${pathing.fileName}:路线未正常完成、坐标获取异常或不处于主界面,不记录运行数据`); continue; } // 计算下一个 UTC 时间的晚上 8 点(即北京时间凌晨四点) let newCDTime = new Date(now); newCDTime.setUTCHours(20, 0, 0, 0); // 设置为 UTC 时间的 20:00 if (newCDTime <= now) { // 如果设置的时间小于等于当前时间,说明需要取下一个晚上 8 点 newCDTime.setUTCHours(20 + 24, 0, 0, 0); // 设置为下一个 UTC 时间的 20:00 } if (pathing.m !== 0 && !pathing.tags.includes("传奇")) { const nowPlus12h = new Date(now.getTime() + 12 * 3600 * 1000); // now + 12h if (newCDTime < nowPlus12h) { newCDTime = nowPlus12h; } } // 更新路径的 cdTime pathing.cdTime = newCDTime.toLocaleString(); if (!localeWorks) pathing.cdTime = newCDTime.toISOString(); await updateRecords(pathings, accountName); } } } /** * 初始化或更新路线CD与运行记录 * 读取 records/{accountName}.json: * - 为每条路线赋予 cdTime(本地或UTC)与最近7次运行时长 * - 拾取历史仅保留最后20个不重复项 * 若记录文件缺失则初始化为7条-1 * 依赖:file、accountName、localeWorks */ async function initializeCdTime(pathings, accountName) { try { const filePath = `records/${accountName}.json`; const fileContent = await file.readText(filePath); const cdTimeData = JSON.parse(fileContent); pathings.forEach(pathing => { let entry = null; for (let i = 0; i < cdTimeData.length; i++) { if (cdTimeData[i].fileName === pathing.fileName) { entry = cdTimeData[i]; break; } } // 读取 cdTime pathing.cdTime = entry ? new Date(entry.cdTime).toLocaleString() : new Date(0).toLocaleString(); if (!localeWorks) pathing.cdTime = entry ? new Date(entry.cdTime).toISOString() : new Date(0).toISOString(); // 确保当前 records 是数组 const current = Array.isArray(pathing.records) ? pathing.records : new Array(7).fill(-1); // 读取文件中的 records(若缺失则为空数组) const loaded = (entry && Array.isArray(entry.records)) ? entry.records : []; // 合并:文件中的 records(倒序最新在前)→ 追加到当前数组末尾 // 再整体倒序恢复正确顺序,截取最新 7 项 pathing.records = [...current, ...loaded.reverse()].slice(-7); // 读取历史拾取名单,只保留最后 20 个不重复 const rawItems = (entry && Array.isArray(entry.items)) ? entry.items : []; pathing.items = [...new Set(rawItems)].slice(-20); // 去重 + 截断 }); } catch (error) { // 文件不存在或解析错误,初始化为 6 个 -1 pathings.forEach(pathing => { pathing.cdTime = new Date(0).toLocaleString(); pathing.records = new Array(7).fill(-1); }); if (!localeWorks) pathings.forEach(pathing => { pathing.cdTime = new Date(0).toISOString(); pathing.records = new Array(7).fill(-1); }); } } /** * 回写运行记录 * 把当前 pathings 数组中的 cdTime、records、items、标签、预计用时 * 按文件名为主键写入 records/{accountName}.json(倒序,仅保留>0的时长) * 供下次启动时 initializeCdTime() 加载 * 依赖:file、accountName */ async function updateRecords(pathings, accountName) { try { const filePath = `records/${accountName}.json`; const cdTimeData = pathings.map(pathing => ({ fileName: pathing.fileName, 标签: pathing.tags, 预计用时: pathing.t.toFixed(2), cdTime: pathing.cdTime, records: [...pathing.records] .reverse() .filter(v => v > 0) .map(v => Number(v.toFixed(2))), items: pathing.items })); await file.writeText(filePath, JSON.stringify(cdTimeData, null, 2), false); } catch (error) { log.error(`更新 cdTime 时出错: ${error.message}`); } } /** * 黑名单加载/保存 * @param {boolean} merge true 时先与本地文件合并再去重;false 仅重写内存到磁盘 * 内存结构:blacklist 数组 + blacklistSet Set 用于O(1)查询 * 文件路径:blacklists/{accountName}.json * 依赖:file、accountName、blacklist/blacklistSet */ async function loadBlacklist(merge = false) { try { if (merge) { const raw = await file.readText(`blacklists/${accountName}.json`); const arr = JSON.parse(raw); blacklist = [...new Set([...blacklist, ...arr])]; } blacklistSet.clear(); blacklist.forEach(item => blacklistSet.add(item)); } catch (err) { log.error(`读取黑名单失败: ${err.message}`); blacklist = []; blacklistSet.clear(); } await file.writeText(`blacklists/${accountName}.json`, JSON.stringify(blacklist, null, 2), false); } /* ========================= ⑥ 底层工具 ========================= * 负责:队伍切换、时间规则判断、模板匹配点击、OCR、日志伪造、目录递归读取等 * 供以上各模块随时调用 * =========================================================== */ //切换队伍 async function switchPartyIfNeeded(partyName) { if (!partyName) { await genshin.returnMainUi(); return; } try { log.info("正在尝试切换至" + partyName); if (!await genshin.switchParty(partyName)) { log.info("切换队伍失败,前往七天神像重试"); await genshin.tpToStatueOfTheSeven(); await genshin.switchParty(partyName); } } catch { log.error("队伍切换失败,可能处于联机模式或其他不可切换状态"); notification.error(`队伍切换失败,可能处于联机模式或其他不可切换状态`); await genshin.returnMainUi(); } } /** * 判断当前是否位于主界面 * @param {number} maxDuration 最大允许耗时(毫秒) */ async function isMainUI(maxDuration = 10) { const start = Date.now(); let dodispose = false; while (Date.now() - start < maxDuration) { if (!gameRegion) { gameRegion = captureGameRegion(); dodispose = true; } try { const result = gameRegion.find(mainUIRo); if (result.isExist()) return true; } catch (error) { log.error(`识别图像时发生异常: ${error.message}`); return false; // 一旦出现异常直接退出,不再重试 } await sleep(checkDelay); // 识别间隔 if (dodispose) { gameRegion.dispose(); dodispose = false; // 已经释放,标记避免重复 dispose } } /* 超时仍未识别到,返回失败 */ return false; } /** * 判断当前是否存在拾取滚轮图标 * @param {number} maxDuration 最大允许耗时(毫秒) */ async function hasScroll(maxDuration = 10) { const start = Date.now(); let dodispose = false; while (Date.now() - start < maxDuration) { if (!gameRegion) { gameRegion = captureGameRegion(); dodispose = true; } try { const result = gameRegion.find(scrollRo); if (result.isExist()) return true; } catch (error) { log.error(`识别图像时发生异常: ${error.message}`); return false; // 一旦出现异常直接退出,不再重试 } await sleep(checkDelay); // 识别间隔 if (dodispose) { gameRegion.dispose(); dodispose = false; // 已经释放,标记避免重复 dispose } } /* 超时仍未识别到,返回失败 */ return false; } // 加载拾取物图片 async function loadTargetItems() { let targetItemPath; if (pickup_Mode === "模板匹配拾取,拾取狗粮和怪物材料") { targetItemPath = "assets/targetItems/"; } else if (pickup_Mode === "模板匹配拾取,只拾取狗粮") { targetItemPath = "assets/targetItems/其他/"; } else { return null; } log.info("开始加载模板图片"); const items = await readFolder(targetItemPath, "png"); // 统一预加载模板 for (const it of items) { try { it.template = file.ReadImageMatSync(it.fullPath); it.itemName = it.fileName.replace(/\.png$/i, ''); it.roi = RecognitionObject.TemplateMatch(it.template); // 新增:解析括号中的阈值 const match = it.fullPath.match(/[((](.*?)[))]/); // 匹配英文或中文括号 let itsThreshold; if (match) { const val = parseFloat(match[1]); itsThreshold = (!isNaN(val) && val >= 0 && val <= 1) ? val : 0.9; } else { itsThreshold = 0.9; } it.roi.Threshold = itsThreshold; it.roi.InitTemplate(); } catch (error) { } } log.info("模板图片加载完成"); return items; } async function performOcr(centerYF) { const TEXT_X = 1210, TEXT_W = 250; // 1210 ~ 1460 const TEXT_Y = centerYF - 30, TEXT_H = 60; try { const resList = gameRegion.findMulti( RecognitionObject.ocr(TEXT_X, TEXT_Y, TEXT_W, TEXT_H) ); if (!resList.count) return null; // 取最长串 let longest = resList[0]; for (let i = 1; i < resList.count; i++) { if (resList[i].text.length > longest.text.length) longest = resList[i]; } // 只要中文 return longest.text.replace(/[^\u4e00-\u9fa5]/g, ''); } catch (e) { log.error(`OCR异常: ${e.message}`); return null; } } async function refreshTargetItems(centerYF) { const TARGET_DIR = 'assets/targetItems'; /* 1. 一次截屏 */ const rawText = await performOcr(centerYF); if (!rawText) { log.warn('未识别到文字'); return; } const itemName = rawText.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ''); if (!itemName) { log.warn('未提取到有效物品名'); return; } const CAP_X = 1220; // 左侧固定 let CAP_W = 12 + 28 * (itemName.length); // 动态宽度 if (itemName.length > 4) { CAP_W = 32 + 30 * 4;//过长时只取前五个字的区域 } const CAP_Y = centerYF - 14; const CAP_H = 28; const mat = gameRegion.DeriveCrop(CAP_X, CAP_Y, CAP_W, CAP_H).SrcMat; /* 2. 纯 for 循环重名检测 */ let finalName = itemName; let seq = 1; while (true) { let hit = false; for (let i = 0; i < targetItems.length; i++) { if (targetItems[i].itemName === finalName) { hit = true; break; } } if (!hit) break; // 没找到重名,可用 finalName = itemName + '(' + seq + ')'; seq++; } /* 3. 保存 & 入库 */ const fullPath = TARGET_DIR + '/' + finalName + '.png'; file.WriteImageSync(fullPath, mat); targetItems.push({ fullPath: fullPath, fileName: finalName + '.png', itemName: finalName, template: file.ReadImageMatSync(fullPath) }); log.info('已新增拾取物:' + finalName); } /** * 递归读取目录下所有文件 * @param {string} folderPath 起始目录 * @param {string} [ext=''] 需要的文件后缀,空字符串表示不限制;例如 'json' 或 '.json' 均可 * @returns {Array<{fullPath:string, fileName:string, folderPathArray:string[]}>} */ async function readFolder(folderPath, ext = '') { // 统一后缀格式:确保前面有一个点,且全小写 const targetExt = ext ? (ext.startsWith('.') ? ext : `.${ext}`).toLowerCase() : ''; const folderStack = [folderPath]; const files = []; while (folderStack.length > 0) { const currentPath = folderStack.pop(); const filesInSubFolder = file.ReadPathSync(currentPath); // 同步读取当前目录 const subFolders = []; for (const filePath of filesInSubFolder) { if (file.IsFolder(filePath)) { subFolders.push(filePath); // 子目录稍后处理 } else { // 后缀过滤 if (targetExt) { const fileExt = filePath.toLowerCase().slice(filePath.lastIndexOf('.')); if (fileExt !== targetExt) continue; } const fileName = filePath.split('\\').pop(); const folderPathArray = filePath.split('\\').slice(0, -1); files.push({ fullPath: filePath, fileName, folderPathArray }); } } // 保持同层顺序,reverse 后仍按原顺序入栈 folderStack.push(...subFolders.reverse()); } return files; } /** * 伪造 BetterGenshinImpact 的js/地图追踪日志 * 1. 执行地图追踪等任务时,输出日志来让日志分析可以看到地图追踪的信息。 * 2. 支持两种任务类型:JS 脚本 与 地图追踪任务(通过 isJs 切换)。 * 3. 支持可选耗时统计,仅在“结束”时拼接到日志中。 * * 参数: * @param {string} name 任务名称,会原样输出到日志里 * @param {boolean} isStart true → 开始日志;false → 结束日志 * @param {number} [duration=0] 耗时(毫秒),仅在结束日志中用到;为 0 时不显示耗时 * @param {boolean} [isJs=false] 任务类型:true 为 JS 脚本,false 为地图追踪任务 * * 示例: * // 地图追踪开始 * await fakeLog('采集路线', true); * * // JS 脚本结束,耗时 12.5 秒 * await fakeLog('自动钓鱼', false, 12500, true); */ async function fakeLog(name, isStart, duration = 0, isJs = false) { await sleep(1); const currentTime = Date.now(); /* ---------------- 时间格式化 ---------------- */ const t = new Date(currentTime); const hh = String(t.getHours()).padStart(2, '0'); const mm = String(t.getMinutes()).padStart(2, '0'); const ss = String(t.getSeconds()).padStart(2, '0'); const msec = String(t.getMilliseconds()).padStart(3, '0'); const formattedTime = `${hh}:${mm}:${ss}.${msec}`; /* ---------------- 耗时格式化(仅结束用) ---------------- */ const totalSec = duration / 1000; const durationMinutes = Math.floor(totalSec / 60); const durationSeconds = (totalSec % 60).toFixed(3); /* ---------------- 四分支,输出与旧版完全一致 ---------------- */ if (isJs && isStart) { const logMessage = `正在伪造js开始的日志记录\n\n` + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + `------------------------------\n\n` + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + `→ 开始执行JS脚本: "${name}"`; log.debug(logMessage); } if (isJs && !isStart) { const logMessage = `正在伪造js结束的日志记录\n\n` + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + `→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}分${durationSeconds}秒\n\n` + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + `------------------------------`; log.debug(logMessage); } if (!isJs && isStart) { const logMessage = `正在伪造地图追踪开始的日志记录\n\n` + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + `------------------------------\n\n` + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + `→ 开始执行地图追踪任务: "${name}"`; log.debug(logMessage); } if (!isJs && !isStart) { const logMessage = `正在伪造地图追踪结束的日志记录\n\n` + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + `→ 脚本执行结束: "${name}", 耗时: ${durationMinutes}分${durationSeconds}秒\n\n` + `[${formattedTime}] [INF] BetterGenshinImpact.Service.ScriptService\n` + `------------------------------`; log.debug(logMessage); } } /** * 检查当前时间是否处于限制时间内或即将进入限制时间 * @param {string} timeRule - 时间规则字符串,格式如 "8, 8-11, 23:11-23:55" * @param {number} [threshold=5] - 接近限制时间的阈值(分钟) * @returns {Promise} - 如果处于限制时间内或即将进入限制时间,则返回 true,否则返回 false */ async function isTimeRestricted(timeRule, threshold = 5) { if (!timeRule) return false; // 兼容中英文逗号、冒号 const ruleClean = timeRule .replace(/,/g, ',') .replace(/:/g, ':'); const now = new Date(); const currentHour = now.getHours(); const currentMinute = now.getMinutes(); const currentTotal = currentHour * 60 + currentMinute; for (const seg of ruleClean.split(',').map(s => s.trim())) { if (!seg) continue; let startStr, endStr; if (seg.includes('-')) { [startStr, endStr] = seg.split('-').map(s => s.trim()); } else { startStr = endStr = seg.trim(); } const parseTime = (str, isEnd) => { if (str.includes(':')) { const [h, m] = str.split(':').map(Number); return { h, m }; } // 单独小时:start 8→8:00,end 8→8:59 const h = Number(str); return { h, m: isEnd ? 59 : 0 }; }; const start = parseTime(startStr, false); const end = parseTime(endStr, true); const startTotal = start.h * 60 + start.m; const endTotal = end.h * 60 + end.m; const effectiveEnd = endTotal >= startTotal ? endTotal : endTotal + 24 * 60; if ( (currentTotal >= startTotal && currentTotal < effectiveEnd) || (currentTotal + 24 * 60 >= startTotal && currentTotal + 24 * 60 < effectiveEnd) ) { log.warn("处于限制时间内"); return true; } let nextStartTotal = startTotal; if (nextStartTotal <= currentTotal) nextStartTotal += 24 * 60; const waitMin = nextStartTotal - currentTotal; if (waitMin > 0 && waitMin <= threshold) { log.warn(`接近限制时间,等待 ${waitMin} 分钟`); await genshin.tpToStatueOfTheSeven(); await sleep(waitMin * 60 * 1000); return true; } } log.info("不处于限制时间"); return false; } /** * 通用找图/找RO并可选点击(支持单图片文件路径、单RO、图片文件路径数组、RO数组) * @param {string|string[]|RecognitionObject|RecognitionObject[]} target * @param {boolean} [doClick=true] 是否点击 * @param {number} [timeout=3000] 识别时间上限(ms) * @param {number} [interval=50] 识别间隔(ms) * @param {number} [retType=0] 0-返回布尔;1-返回 Region 结果 * @param {number} [preClickDelay=50] 点击前等待 * @param {number} [postClickDelay=50] 点击后等待 * @returns {boolean|Region} 根据 retType 返回是否成功或最终 Region */ async function findAndClick(target, doClick = true, timeout = 3000, interval = 50, retType = 0, preClickDelay = 50, postClickDelay = 50) { try { // 1. 统一转成 RecognitionObject 数组 let ros = []; if (Array.isArray(target)) { ros = target.map(t => (typeof t === 'string') ? RecognitionObject.TemplateMatch(file.ReadImageMatSync(t)) : t ); } else { ros = [(typeof target === 'string') ? RecognitionObject.TemplateMatch(file.ReadImageMatSync(target)) : target]; } const start = Date.now(); let found = null; while (Date.now() - start <= timeout) { const gameRegion = captureGameRegion(); try { // 依次尝试每一个 ro for (const ro of ros) { const res = gameRegion.find(ro); if (!res.isEmpty()) { // 找到 found = res; if (doClick) { await sleep(preClickDelay); res.click(); await sleep(postClickDelay); } break; // 成功即跳出 for } } if (found) break; // 成功即跳出 while } finally { gameRegion.dispose(); } await sleep(interval); // 没找到时等待 } // 3. 按需返回 return retType === 0 ? !!found : (found || null); } catch (error) { log.error(`执行通用识图时出现错误:${error.message}`); return retType === 0 ? false : null; } }