diff --git a/repo/js/AutoTrainingPlanFarm/README.md b/repo/js/AutoTrainingPlanFarm/README.md new file mode 100644 index 0000000000..32831ea563 --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/README.md @@ -0,0 +1,33 @@ +# 培养计划自动刷取 + +读取原神冒险之证-秘境-提升指南里的培养标注,自动刷角色天赋书和武器突破材料。解决天赋/武器副本开启时间杂乱,游戏外刷本配置麻烦问题。 + +## 使用前 + +- BetterGI 0.63.0 及以上 +- 在游戏内先给角色配好培养计划,不添加无法使用。 +- 简体中文客户端,推荐 1920x1080;16:9 分辨率能自适应,其它分辨率没测试过 + +## 使用 + +1. 把插件添加进调度器,可选择加入一条龙配置中 +2. 建议一条龙配置中,在插件后放一个固定的自动秘境配置,比如一个固定的圣遗物本。当天无所需天赋书和武器突破材料开放时可以由自动秘境来清体力。也可在前配置一个刷固定次数的自动秘境,控制刷天赋消耗的体力 +3. 在游戏中将想培养的角色加入培养计划中:冒险之证-秘境-提升指南-选角色-右上角加入培养计划-设置角色天赋/角色武器目标等级 +4. 插件运行后会自动执行打开提升指南,逐行读材料、算材料缺口、刷本,会优先刷第一条。目前仅支持刷角色天赋书和武器突破材料 +5. 一行刷满自动切下一行;没有标注、全是周本或树脂耗尽时自动结束 +6. 该插件不会计算角色合成材料时的获取加成,最后升级的天赋书可能会略微溢出 + +## 设置(不推荐做修改) + +- markerThreshold:标注图标匹配阈值,0~1,默认 0.9 +- saveFile:需求清单文件名,默认 plan_needs.json +- debugShowBoxes:在游戏画面上显示识别框,默认关 +- partyName:队伍名,留空用 BGI 本体的设置 +- sundaySelectedValue:周日/限时全开领奖兜底,1/2/3 对应上/中/下,留空用 BGI 本体的设置(周末会自动识别领哪个奖励,空着就行) + +## 固定行为 + +- 自动打开提升指南;材料齐了自动停止;最大轮数不限制 +- 树脂优先用浓缩,其次原粹 40/次;原粹只剩 20~39 时会自动用一次 20 档 +- 缺口很大或树脂不够 2/3 刷时,会走 BGI 的树脂耗尽模式,刷完树脂就结束 +- 一行读不出秘境名(一般是周本或未开放副本)时按正常结束处理。结束该插件 diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/guide_marker_green.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/guide_marker_green.png new file mode 100644 index 0000000000..abab15e77a Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/guide_marker_green.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num0_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num0_white.png new file mode 100644 index 0000000000..1b956465c7 Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num0_white.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num1_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num1_white.png new file mode 100644 index 0000000000..541dd87bff Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num1_white.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num2_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num2_white.png new file mode 100644 index 0000000000..00b861825b Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num2_white.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num3_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num3_white.png new file mode 100644 index 0000000000..3895ed6137 Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num3_white.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num4_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num4_white.png new file mode 100644 index 0000000000..160fcaab4b Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num4_white.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num5_white.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num5_white.png new file mode 100644 index 0000000000..fb48f36b90 Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/num5_white.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_condensed.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_condensed.png new file mode 100644 index 0000000000..ea666e45db Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_condensed.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_original.png b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_original.png new file mode 100644 index 0000000000..d1785dabf0 Binary files /dev/null and b/repo/js/AutoTrainingPlanFarm/assets/RecognitionObject/resin_original.png differ diff --git a/repo/js/AutoTrainingPlanFarm/assets/debug_boxes.html b/repo/js/AutoTrainingPlanFarm/assets/debug_boxes.html new file mode 100644 index 0000000000..63c1c13c45 --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/assets/debug_boxes.html @@ -0,0 +1,79 @@ + + + + +识别框调试 + + + +
+ + + diff --git a/repo/js/AutoTrainingPlanFarm/main.js b/repo/js/AutoTrainingPlanFarm/main.js new file mode 100644 index 0000000000..ae4643ea30 --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/main.js @@ -0,0 +1,213 @@ +// 培养计划自动刷取 v0.1.10(坐标基准 1920x1080,运行时按分辨率缩放) +import { getS, setMetrics, sY, touchActivity } from "./src/core/common.js"; +import { debugEnsureMask, debugSetCanvas } from "./src/core/debug-overlay.js"; +import { initMarkerTemplate, disposeMarkerResources, scanMarkersStable, dedupeMarkers, disposeMarkers, clusterRows, ROW_Y_BOUNDS } from "./src/core/markers.js"; +import { ensureGuidePage, isGuidePage, waitGuideMarkers, processRow, disposeResinResources, calibrateFirstRow, FIRST_ROW_ANCHOR_Y } from "./src/plan/guide.js"; +import { runDomainPhase } from "./src/plan/domain-run.js"; + +(async function () { + try { + setGameMetrics(1920, 1080, 1.25); + + // 计算截图相对 1920x1080 的缩放比例 + { + let probe = null; + try { + probe = captureGameRegion(); + setMetrics(probe.width, probe.height); + debugSetCanvas(probe.width, probe.height); + log.info("[截图] {w}x{h},缩放 {sx}/{sy}", probe.width, probe.height, (probe.width / 1920).toFixed(3), (probe.height / 1080).toFixed(3)); + } finally { + try { if (probe) probe.dispose(); } catch (e) { } + } + } + + // 提前打开调试覆盖层 + debugEnsureMask(); + + const matchThreshold = parseFloat(getS("markerThreshold", "0.9")); + if (isNaN(matchThreshold) || matchThreshold <= 0 || matchThreshold > 1) { + throw new Error("markerThreshold 必须是 0~1 之间的数字"); + } + let saveFile = String(getS("saveFile", "plan_needs.json")); + if (!/^[^\\/:*?"<>|]+\.json$/i.test(saveFile)) { + log.warn("saveFile 非法(只允许 .json 文件名),已回退为 plan_needs.json"); + saveFile = "plan_needs.json"; + } + + // 写需求清单(空计划也写,避免日常流程读到上一次的旧清单) + const writeSummary = (items) => { + const summary = { + generatedAt: new Date().toISOString(), + count: items.length, + items + }; + const ok = file.writeTextSync(saveFile, JSON.stringify(summary, null, 2)); + if (!ok) throw new Error("保存需求清单失败: " + saveFile); + }; + + // 读取模板并按当前分辨率缩放 + initMarkerTemplate(matchThreshold); + + // 完全重扫,和第一次打开脚本一样;首行没标注就先做一次固定位置校准 + const rescanRows = async () => { + await ensureGuidePage(); + touchActivity(); + + const firstRowY = sY(FIRST_ROW_ANCHOR_Y); + let visible = dedupeMarkers(await scanMarkersStable(1000)); + if (!visible.some(m => Math.abs(m.y - firstRowY) < sY(30))) { + log.warn("首行区域(y≈{y})未识别到标注,执行一次首行校准后重试", firstRowY); + disposeMarkers(visible); + await calibrateFirstRow(); + touchActivity(); + visible = dedupeMarkers(await scanMarkersStable(1000)); + } + const visibleCount = visible.length; + disposeMarkers(visible); + log.info("[扫描] 识别到标注 {count} 个", visibleCount); + + if (visibleCount === 0) return null; + return clusterRows(visible).map((r, idx) => ({ + index: idx + 1, + y: Math.max(sY(ROW_Y_BOUNDS.min), Math.min(sY(ROW_Y_BOUNDS.max), Math.round(r.y))), + names: new Map(), + nextRank: 1 + })); + }; + + // 清单只保留“当前处理行”:每次都先清空再扫描当前行 + const entries = []; + let stopByNoDomain = false; + + let rows = await rescanRows(); + if (!rows) { + // 校准后仍无任何标注:确认页面后按无待刷材料正常结束 + if (isGuidePage()) { + log.warn("已确认在提升指南页面且无培养标注:本日无待刷材料,正常结束"); + writeSummary([]); + log.info("清单: {file}", saveFile); + return; + } + throw new Error("扫描不到标注,且未确认在提升指南页面,请检查游戏状态"); + } + + let ri = 0; + while (true) { + if (ri >= rows.length) break; // 本快照里的行已全部处理完 + + const row = rows[ri]; + + // 换行时重新打开提升指南页 + if (ri > 0) { + log.info("[行] 切换到 y={y}", row.y); + await ensureGuidePage(); + touchActivity(); + if (!(await waitGuideMarkers(15000))) { + log.warn("切换行后 15 秒内未检测到标注图标,重试一次"); + if (!(await waitGuideMarkers(15000))) { + throw new Error("切换行后 30 秒内未检测到标注图标,请检查游戏状态"); + } + } + } + + // A 方案:文件只保留当前行。处理新行前先清掉上一条记录。 + entries.length = 0; + + let rowOk = false; + let rowResult = null; + try { + rowResult = await processRow(row, entries); + rowOk = true; + } catch (e) { + log.error("[行] 第 {i} 行识别失败,本行结果已清空,不再刷取: {err}", row.index, e.message); + entries.length = 0; + } + const rowEntries = entries.slice(); + + // 周本/未开放副本/秘境名 OCR 彻底失败:正常结束,不抛错 + if (rowResult && rowResult.status === "noDomain") { + log.warn("第 {i} 行无可刷秘境(周本/未开放副本),正常结束", row.index); + stopByNoDomain = true; + writeSummary([]); + break; + } + + if (!rowOk) { + log.warn("[行] 第 {i} 行未成功完成,跳过自动秘境,继续下一行", row.index); + ri++; + continue; + } + + log.info("[行] 第 {i} 行完成,新增 {n} 个材料", row.index, rowEntries.length); + + // 保存当前行清单 + writeSummary(entries); + + // 刷副本(固定开启):只刷这一行的材料 + if (rowEntries.length > 0) { + let domainResult = null; + try { + domainResult = await runDomainPhase(entries, Math.round(row.y)); + } catch (e) { + // 出错时先落盘当前清单,再抛给全局 catch + log.error("[自动秘境] 第 {i} 行执行异常,保存当前清单后停止: {err}", row.index, (e && e.message) ? e.message : String(e)); + try { + writeSummary(entries); + log.info("清单: {file}", saveFile); + } catch (e2) { + log.error("[自动秘境] 保存清单失败: {err}", (e2 && e2.message) ? e2.message : String(e2)); + } + throw e; + } + // 树脂耗尽或刷新失败:整体流程完成,不再处理后续行 + if (domainResult && domainResult.stopScript) { + log.info("[自动秘境] 已停止(树脂耗尽或刷新失败),不再处理后续行"); + break; + } + // 本行已消失、下方行上移:完全重扫,和第一次打开脚本一样 + if (domainResult && domainResult.rowGone) { + log.info("当前行已完成并消失,重新扫描剩余行"); + rows = await rescanRows(); + if (!rows) { + if (isGuidePage()) { + log.warn("重新扫描无标注:本日无待刷材料,正常结束"); + writeSummary([]); + log.info("清单: {file}", saveFile); + return; + } + throw new Error("重新扫描不到标注,且未确认在提升指南页面,请检查游戏状态"); + } + ri = 0; + continue; + } + } + + // 本行未消失(材料已够但页面仍显示),按第一次运行逻辑切到本快照下一行 + ri++; + } + + // 最终清单(A 方案:写当前/最后一次的清单;noDomain 已在分支内落盘空清单) + if (!stopByNoDomain) { + writeSummary(entries); + } + + if (stopByNoDomain) { + log.info("无可刷秘境,正常结束,共记录 {count} 个材料", entries.length); + } else { + log.info("扫描完成,共 {count} 个材料", entries.length); + } + for (const e of entries) { + log.info(" {name} [{label}] {needText} {source}", e.material, e.qualityLabel, e.needText, e.source); + } + log.info("清单: {file}", saveFile); + + } catch (e) { + const msg = (e && e.message) ? e.message : String(e); + log.error("脚本执行失败,已停止: {err}", msg); + throw e; + } finally { + disposeMarkerResources(); + disposeResinResources(); + } +})(); diff --git a/repo/js/AutoTrainingPlanFarm/manifest.json b/repo/js/AutoTrainingPlanFarm/manifest.json new file mode 100644 index 0000000000..5c153ac1c1 --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/manifest.json @@ -0,0 +1,22 @@ +{ + "manifest_version": 1, + "name": "培养计划自动刷取", + "version": "0.1.10", + "bgi_version": "0.63.0", + "description": "自动读取原神冒险之证-秘境-提升指南页面中培养计划标注的材料需求,记录后自动规划刷取。仅支持武器突破材料与角色天赋副本,需将角色加入培养计划", + "authors": [ + { + "name": "锦瑟", + "link": "https://github.com/achenjins" + } + ], + "settings_ui": "settings.json", + "main": "main.js", + "saved_files": [ + "plan_needs.json" + ], + "library": [ + "." + ], + "allow_js_notification": true +} \ No newline at end of file diff --git a/repo/js/AutoTrainingPlanFarm/settings.json b/repo/js/AutoTrainingPlanFarm/settings.json new file mode 100644 index 0000000000..60c384ec48 --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/settings.json @@ -0,0 +1,44 @@ +[ + { + "type": "separator" + }, + { + "name": "debugShowBoxes", + "type": "checkbox", + "label": "调试:在游戏画面上叠加显示识别框(OCR区域/模板匹配/颜色裁剪)", + "default": false + }, + { + "name": "markerThreshold", + "type": "input-text", + "label": "标注图标匹配阈值(0~1,默认0.9)", + "default": "0.9" + }, + { + "name": "saveFile", + "type": "input-text", + "label": "需求清单保存文件名", + "default": "plan_needs.json" + }, + { + "type": "separator" + }, + { + "name": "partyName", + "type": "input-text", + "label": "队伍名称(留空用 BGI 本体设置)", + "default": "" + }, + { + "name": "sundaySelectedValue", + "type": "select", + "label": "周日/限时全开领哪个材料奖励,可自动识别,不建议改(1/2/3=上/中/下,留空用BGI本体设置)", + "options": [ + "", + "1", + "2", + "3" + ], + "default": "" + } +] \ No newline at end of file diff --git a/repo/js/AutoTrainingPlanFarm/src/core/common.js b/repo/js/AutoTrainingPlanFarm/src/core/common.js new file mode 100644 index 0000000000..1065bf8a93 --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/src/core/common.js @@ -0,0 +1,56 @@ +// 通用工具:设置读取、名称清洗、缩放坐标、看门狗、编辑距离 + +export let scaleX = 1; +export let scaleY = 1; + +// 8 秒无操作看门狗 +let lastActivity = Date.now(); + +export function setMetrics(w, h) { + scaleX = w / 1920; + scaleY = h / 1080; +} + +// 所有 1920x1080 参考坐标按实际截图尺寸缩放 +export function sX(v) { return Math.round(v * scaleX); } +export function sY(v) { return Math.round(v * scaleY); } + +export function touchActivity() { + lastActivity = Date.now(); +} + +export function assertAlive() { + if (Date.now() - lastActivity > 8000) { + throw new Error("8 秒无操作,超时退出"); + } +} + +export function parkMouse() { + try { + const [gw, gh] = getGameMetrics(); + moveMouseTo(Math.round(gw / 2), Math.round(gh / 6)); + } catch (e) { } +} + +export function canonicalName(name) { + return String(name || "").replace(/[「」『』\[\]()()]/g, "").trim(); +} + +export function getS(name, def) { + const v = settings ? settings[name] : undefined; + return v === undefined || v === null ? def : v; +} + +export function levDistance(a, b) { + const m = a.length, n = b.length; + const dp = []; + for (let i = 0; i <= m; i++) dp.push(new Array(n + 1)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + } + } + return dp[m][n]; +} diff --git a/repo/js/AutoTrainingPlanFarm/src/core/debug-overlay.js b/repo/js/AutoTrainingPlanFarm/src/core/debug-overlay.js new file mode 100644 index 0000000000..d1fd69ced3 --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/src/core/debug-overlay.js @@ -0,0 +1,57 @@ +// 调试可视化(htmlMask):仅 debugShowBoxes=true 且存在 assets/debug_boxes.html 时生效,失败不影响主流程 +import { getS } from "./common.js"; + +let debugMaskId = null; +const debugBoxes = []; +let debugCapW = 0; +let debugCapH = 0; + +export function debugSetCanvas(w, h) { + debugCapW = w; + debugCapH = h; +} + +export function debugEnsureMask() { + try { + if (getS("debugShowBoxes", false) === false) return false; + if (typeof htmlMask === "undefined" || !htmlMask || !htmlMask.show) return false; + if (!debugMaskId || !htmlMask.exists || !htmlMask.exists(debugMaskId)) { + debugMaskId = htmlMask.show("assets/debug_boxes.html", "plan-debug-boxes"); + } + return !!debugMaskId; + } catch (e) { + return false; + } +} + +export function debugFrameStart() { + debugBoxes.length = 0; + if (!debugEnsureMask()) return; + try { + htmlMask.send(debugMaskId, "/boxes/clear", "{}"); + } catch (e) { } +} + +export function debugBox(label, x, y, w, h, color) { + if (!label || !debugEnsureMask()) return; + if (!debugCapW || !debugCapH) return; + const box = { + label: String(label).slice(0, 80), + x: Math.max(0, Math.round(x)), + y: Math.max(0, Math.round(y)), + w: Math.max(1, Math.round(w)), + h: Math.max(1, Math.round(h)), + color: color || "#00e5ff" + }; + const idx = debugBoxes.findIndex(b => b.label === box.label); + if (idx >= 0) debugBoxes[idx] = box; + else { + if (debugBoxes.length >= 80) debugBoxes.shift(); + debugBoxes.push(box); + } + try { + htmlMask.send(debugMaskId, "/boxes/update", JSON.stringify({ + w: debugCapW, h: debugCapH, boxes: debugBoxes + })); + } catch (e) { } +} diff --git a/repo/js/AutoTrainingPlanFarm/src/core/markers.js b/repo/js/AutoTrainingPlanFarm/src/core/markers.js new file mode 100644 index 0000000000..4ea745f43e --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/src/core/markers.js @@ -0,0 +1,205 @@ +// 培养计划标注图标:模板加载、扫描、按行聚合 +import { sX, sY, scaleX, scaleY, parkMouse, touchActivity, assertAlive } from "./common.js"; +import { debugFrameStart, debugBox } from "./debug-overlay.js"; + +const MARKER_PATH = "assets/RecognitionObject/guide_marker_green.png"; + +// 右半屏搜索区域 +const MARKER_ROI = { x: 900, y: 280, w: 1020, h: 600 }; + +// 行 y 合法范围(ROI 边界内留出模板高度余量) +export const ROW_Y_BOUNDS = { min: 288, max: 848 }; + +let markerMat = null; +let markerRo = null; +let matchThreshold = 0.9; +// 扫描异常日志节流:避免稳定扫描循环反复刷屏 +let lastScanErrorAt = 0; + +// 读取模板并按当前分辨率缩放;必须在 setMetrics 之后调用 +export function initMarkerTemplate(threshold) { + matchThreshold = threshold; + markerMat = file.readImageMatSync(MARKER_PATH); + if (!markerMat || markerMat.empty() || markerMat.width <= 0 || markerMat.height <= 0) { + throw new Error("标注图标模板读取失败或为空: " + MARKER_PATH); + } + const tw = Math.max(4, Math.round(markerMat.width * scaleX)); + const th = Math.max(4, Math.round(markerMat.height * scaleY)); + if (tw !== markerMat.width || th !== markerMat.height) { + const resized = new Mat(); + OpenCvSharp.OpenCvSharp.Cv2.Resize(markerMat, resized, new OpenCvSharp.OpenCvSharp.Size(tw, th)); + try { markerMat.dispose(); } catch (e) { } + markerMat = resized; + log.info("[模板] 缩放为 {w}x{h}", tw, th); + } +} + +export function scanMarkers() { + let cap = null; + let results = null; + try { + debugFrameStart(); + parkMouse(); + cap = captureGameRegion(); + debugBox("标记ROI", sX(MARKER_ROI.x), sY(MARKER_ROI.y), sX(MARKER_ROI.w), sY(MARKER_ROI.h), "#00e676"); + // 复用同一个识别规则对象,避免每次截图都重建灰度/遮罩 Mat + if (!markerRo) { + markerRo = RecognitionObject.TemplateMatch(markerMat, true); + } + markerRo.Threshold = matchThreshold; + results = cap.findMulti(markerRo); + + const arr = []; + for (let i = 0; i < results.count; i++) { + const r = results[i]; + const drop = () => { try { r.dispose(); } catch (e) { } }; + + // 分数缺失/非有限(含 Infinity/NaN)直接丢弃 + if (r.matchScore === undefined || r.matchScore === null) { drop(); continue; } + const score = Number(r.matchScore); + if (!Number.isFinite(score)) { drop(); continue; } + + // 只保留右半屏目标区域(按分辨率缩放) + if (r.x < sX(MARKER_ROI.x) || r.y < sY(MARKER_ROI.y)) { drop(); continue; } + if (r.x + r.width > sX(MARKER_ROI.x) + sX(MARKER_ROI.w)) { drop(); continue; } + if (r.y + r.height > sY(MARKER_ROI.y) + sY(MARKER_ROI.h)) { drop(); continue; } + + // 尺寸过滤:模板 24x32,按分辨率缩放后允许一定容差 + if (r.width < sX(16) || r.width > sX(40)) { drop(); continue; } + if (r.height < sY(20) || r.height > sY(48)) { drop(); continue; } + const ratio = r.height / r.width; + if (ratio < 0.8 || ratio > 1.6) { drop(); continue; } + + // 二次分数过滤(防止 ClearScript 属性赋值未生效) + if (score < matchThreshold) { drop(); continue; } + + debugBox("标记@" + r.x + "," + r.y, r.x, r.y, r.width, r.height, "#ff1744"); + touchActivity(); + arr.push({ + x: r.x, y: r.y, + width: r.width, height: r.height, + cx: r.x + r.width / 2, cy: r.y + r.height / 2, + score, + region: r + }); + } + // results 集合由调用方通过 marker.region.dispose() 释放 + return arr; + } catch (e) { + if (Date.now() - lastScanErrorAt > 10000) { + lastScanErrorAt = Date.now(); + log.warn("[标记扫描] 异常,按无标注处理: {err}", e && e.message ? e.message : String(e)); + } + try { + if (results) { + for (let i = 0; i < results.count; i++) { + try { results[i].dispose(); } catch (e2) { } + } + } + } catch (e2) { } + return []; + } finally { + try { if (cap) cap.dispose(); } catch (e) { } + // 注意:不要在这里释放 results,marker.region 还引用着其中的对象 + } +} + +export function disposeMarkers(markers) { + for (const m of markers) { + try { + if (m.region) { + m.region.dispose(); + m.region = null; + } + } catch (e) { } + } +} + +export function disposeMarkerResources() { + try { + if (markerRo) { + try { if (markerRo.TemplateImageGreyMat) markerRo.TemplateImageGreyMat.dispose(); } catch (e) { } + try { if (markerRo.MaskMat) markerRo.MaskMat.dispose(); } catch (e) { } + markerRo = null; + } + } catch (e) { } + try { if (markerMat) markerMat.dispose(); } catch (e) { } + markerMat = null; +} + +export function dedupeMarkers(markers) { + const sorted = markers.slice().sort((a, b) => a.y - b.y || a.x - b.x); + const out = []; + for (const m of sorted) { + const dup = out.some(o => Math.abs(o.x - m.x) < sX(20) && Math.abs(o.y - m.y) < sY(20)); + if (dup) { + // 被去重的结果立即释放 + try { + if (m.region) { + m.region.dispose(); + m.region = null; + } + } catch (e) { } + } else { + out.push(m); + } + } + return out; +} + +export function clusterRows(markers) { + const rows = []; + for (const m of markers) { + // 同一行:y 差值小于 10(按分辨率缩放) + let row = rows.find(r => Math.abs(r.y - m.y) < sY(10)); + if (!row) { + row = { y: m.y, markers: [] }; + rows.push(row); + } + row.markers.push(m); + } + rows.sort((a, b) => a.y - b.y); + for (const r of rows) r.markers.sort((a, b) => a.x - b.x); + return rows; +} + +export function guideMarkerCount() { + const ms = scanMarkers(); + const n = ms.length; + disposeMarkers(ms); + return n; +} + +export async function scanMarkersStable(timeoutMs = 1000) { + const start = Date.now(); + while (Date.now() - start <= timeoutMs) { + assertAlive(); + const ms = scanMarkers(); + if (ms.length > 0) return ms; + disposeMarkers(ms); + await sleep(250); + } + return []; +} + +export async function scanRowMarkers(rowY, timeoutMs = 1000) { + const start = Date.now(); + while (Date.now() - start <= timeoutMs) { + assertAlive(); + const all = scanMarkers(); + const kept = []; + const dropped = []; + for (const m of all) { + if (Math.abs(m.y - rowY) < sY(10)) { + kept.push(m); + } else { + dropped.push(m); + } + } + disposeMarkers(dropped); + if (kept.length > 0) return kept; + disposeMarkers(kept); + await sleep(250); + } + return []; +} diff --git a/repo/js/AutoTrainingPlanFarm/src/plan/domain-run.js b/repo/js/AutoTrainingPlanFarm/src/plan/domain-run.js new file mode 100644 index 0000000000..f3a5259c9b --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/src/plan/domain-run.js @@ -0,0 +1,238 @@ +// 自动秘境执行:同一行可能包含多个材料系列,按系列逐个刷。 +// 每批在提升指南页 OCR 树脂,按当前系列缺口选 3/2/1 刷一查;树脂不够时用耗尽模式 +import { getS } from "../core/common.js"; +import { buildPlan, checkPlan } from "./solver.js"; +import { ensureGuidePage, refreshPlanFromGuide, readResin } from "./guide.js"; + +export async function runDomainPhase(entries, rowY) { + let current = entries.filter(e => Number(e.rowY) === rowY); + if (current.length === 0) { + return { stopScript: false, rowGone: false }; + } + + let plan = buildPlan(current); + let zeroCounts = plan.flat.map(() => 0); + + // 行内可能有多个系列:只有所有系列都满足时才整行跳过 + const fullStatus = checkPlan(plan, zeroCounts); + if (fullStatus.ok) { + const firstDomain = current.find(e => e.domainName); + log.info("库存已满足需求(剩余 {s}),跳过刷取", fullStatus.surplusLowUnits); + try { notification.send("材料已齐,无需刷取:" + (firstDomain ? firstDomain.domainName : "")); } catch (e) { } + return { stopScript: false, rowGone: false }; + } + + const singleStatus = (group) => + checkPlan({ groups: [group], flat: group.items }, zeroCounts); + const pickUnsatisfied = () => { + for (const g of plan.groups) { + const st = singleStatus(g); + if (!st.ok) return { group: g, status: st }; + } + return null; + }; + const pickItem = (group) => + group.items.find(i => i.entry && i.entry.domainName) || group.items[0]; + + let picked = pickUnsatisfied(); + if (!picked) { + // 正常不会走到这里,按整行完成处理 + return { stopScript: false, rowGone: false }; + } + let targetGroup = picked.group; + let item = pickItem(targetGroup); + if (!item || !item.entry.domainName) { + log.warn("未记录到秘境名,跳过自动秘境"); + return { stopScript: false, rowGone: false }; + } + + // 已从配置移除:固定最大秘境轮数不受限(沿用原默认 9999) + const maxRounds = 9999; + + let stopScript = false; + // 该行是否已经消失(下方行会上移;main 用这个值触发完全重扫) + let rowGone = false; + + const param = new AutoDomainParam(); + param.DomainName = item.entry.domainName; + // 不设置 DomainRoundNum:默认 9999,由指定树脂计数/树脂耗尽判定退出时机 + param.RewardRecognitionEnabled = false; + // AutoDomainParam 会继承 BGI 本体配置:显式清零,只允许本插件显式指定的树脂档 + param.CondensedResinUseCount = 0; + param.OriginalResin20UseCount = 0; + param.OriginalResin40UseCount = 0; + param.OriginalResinUseCount = 0; + param.TransientResinUseCount = 0; + param.FragileResinUseCount = 0; + // 不执行 BGI 本体的圣遗物分解 + param.AutoArtifactSalvage = false; + + // 周日/限时全开奖励序号:图标位置优先,其次 sundaySelectedValue,最后保持 BGI 本体配置 + // 切换系列时重设,避免沿用上一系列的档位 + const bgiSundayDefault = String(param.SundaySelectedValue || ""); + const updateRewardSelection = () => { + const pos = item.entry.sundayPos; + if (pos === 1 || pos === 2 || pos === 3) { + param.SundaySelectedValue = String(pos); + log.info("[奖励序号] 按图标位置: {v}", pos); + } else { + const setting = String(getS("sundaySelectedValue", "")); + if (setting === "1" || setting === "2" || setting === "3") { + param.SundaySelectedValue = setting; + log.info("[奖励序号] 使用设置值: {v}", setting); + } else { + param.SundaySelectedValue = bgiSundayDefault; + } + } + }; + updateRewardSelection(); + + const party = String(getS("partyName", "")); + if (party) param.PartyName = party; + + log.info("[自动秘境] {name}", item.entry.domainName); + + let totalRounds = 0; + for (let batchIndex = 1; totalRounds < maxRounds; batchIndex++) { + // 缺口只按当前系列算 + await ensureGuidePage(); + const d = singleStatus(targetGroup).deficitLowUnits; + log.info("[缺口] 当前系列 {g},缺口 {d}(低品质单位)", targetGroup.base, d); + + let modeName; + let exhaust = false; + let roundsThisBatch; + let resin = null; + let original40Use = 0; + let original20Use = 0; + + if (d > 160) { + // 当前系列缺口 >160:直接耗尽模式,一把刷完并结束整体流程 + modeName = "树脂耗尽模式"; + exhaust = true; + roundsThisBatch = maxRounds - totalRounds; + param.SpecifyResinUse = false; + param.CondensedResinUseCount = 0; + param.OriginalResin40UseCount = 0; + param.OriginalResin20UseCount = 0; + param.OriginalResinUseCount = 0; + } else { + resin = await readResin(); + if (resin.rounds <= 0) { + log.info("树脂不足,自动停止刷取"); + try { notification.send("树脂不足,停止刷取:" + item.entry.domainName); } catch (e) { } + stopScript = true; + break; + } + const need = d >= 120 ? 3 : (d >= 50 ? 2 : 1); + if (need >= 2 && resin.rounds < need) { + // 树脂不够 2/3 刷:也走耗尽模式 + modeName = "树脂耗尽模式"; + exhaust = true; + roundsThisBatch = maxRounds - totalRounds; + param.SpecifyResinUse = false; + param.CondensedResinUseCount = 0; + param.OriginalResin40UseCount = 0; + param.OriginalResin20UseCount = 0; + param.OriginalResinUseCount = 0; + } else { + // 浓缩优先:1 个浓缩 = 1 次;剩余轮数用原粹树脂, + // 先按 40/次分配,40 不够的轮次用 20/次补(原粹剩余 20-39 时) + const condensedUse = Math.min(resin.condensed, need); + const originalUse = need - condensedUse; + original40Use = Math.min(originalUse, Math.floor(resin.original / 40)); + original20Use = Math.max(0, originalUse - original40Use); + modeName = need >= 3 ? "三刷一查" : (need >= 2 ? "两刷一查" : "一刷一查"); + roundsThisBatch = need; + param.SpecifyResinUse = true; + param.CondensedResinUseCount = condensedUse; + param.OriginalResin40UseCount = original40Use; + param.OriginalResin20UseCount = original20Use; + param.OriginalResinUseCount = 0; + } + } + + totalRounds += roundsThisBatch; + if (exhaust) { + log.info("[批次] {batch}:{mode}(刷到树脂耗尽),当前系列缺口 {d}", + batchIndex, modeName, d); + } else { + log.info("[批次] {batch}:{mode},当前系列缺口 {d},树脂 浓缩{c}/原粹{o},本批 {n} 轮(原粹40x{f}/20x{t}),累计 {total}/{max}", + batchIndex, modeName, d, resin.condensed, resin.original, roundsThisBatch, original40Use, original20Use, totalRounds, maxRounds); + } + + // 回主界面交给 BGI(传送、进本、战斗、领奖都由 BGI 负责) + await genshin.returnMainUi(); + await sleep(1000); + + try { + await dispatcher.runAutoDomainTask(param); + + // 仅树脂耗尽模式:刷取结束即整体流程完成 + if (exhaust) { + log.info("[树脂耗尽模式] 刷取结束,流程完成"); + try { notification.send("树脂耗尽模式刷取结束,停止刷取:" + item.entry.domainName); } catch (e) { } + stopScript = true; + break; + } + + // 批后完整重扫该行(与重开脚本第一次运行完全一致) + const refreshed = await refreshPlanFromGuide(entries, rowY, item.entry.domainName); + if (!refreshed) { + log.warn("[刷新] 重扫失败,停止流程"); + stopScript = true; + break; + } + + current = entries.filter(e => Number(e.rowY) === rowY); + if (current.length === 0) { + // 该行已无标注(已消失):通知 main 完全重扫剩余行 + log.info("该行已无待刷材料"); + rowGone = true; + break; + } + + // 重建当前行计划,继续挑“还有缺口”的系列;都满足才算该行完成 + plan = buildPlan(current); + zeroCounts = plan.flat.map(() => 0); + const nextPicked = pickUnsatisfied(); + if (!nextPicked) { + const afterStatus = checkPlan(plan, zeroCounts); + log.info("材料已集齐,停止刷取(剩余 {s})", afterStatus.surplusLowUnits); + try { notification.send("材料已集齐,停止刷取:" + item.entry.domainName); } catch (e) { } + break; + } + + targetGroup = nextPicked.group; + item = pickItem(targetGroup); + if (!item.entry.domainName) { + // 与首次运行一致:该系列秘境名识别不到就不刷,继续下一行 + log.warn("重扫后未识别到可刷秘境,停止该行刷取"); + break; + } + param.DomainName = item.entry.domainName; + updateRewardSelection(); + + const missing = [].concat(...nextPicked.status.groups.map(g => g.missing || [])); + log.info("[缺口] 第 {batch} 批后,下一系列缺口 {d},各档 {missing}", + batchIndex, nextPicked.status.deficitLowUnits, JSON.stringify(missing)); + } catch (e) { + if (String(e.message).includes("树脂不足") || String(e.message).includes("未找到可用的树脂")) { + log.info("树脂耗尽,自动停止刷取"); + try { notification.send("树脂耗尽,停止刷取:" + item.entry.domainName); } catch (e2) { } + // 无论是否耗尽模式:树脂类错误都结束整体流程,避免逐行空转 + stopScript = true; + } else { + log.error("第 {batch} 批自动秘境失败: {err}", batchIndex, e.message); + try { notification.error("自动秘境失败:" + e.message); } catch (e2) { } + // 树脂耗尽模式下无论以何种方式结束,都不再继续后续流程 + if (exhaust) { + stopScript = true; + } + } + break; + } + } + + return { stopScript, rowGone }; +} diff --git a/repo/js/AutoTrainingPlanFarm/src/plan/guide.js b/repo/js/AutoTrainingPlanFarm/src/plan/guide.js new file mode 100644 index 0000000000..64d78ac144 --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/src/plan/guide.js @@ -0,0 +1,642 @@ +// 提升指南页面:导航、行扫描、秘境名识别、校准滑动、刷新 +import { sX, sY, scaleX, scaleY, parkMouse, canonicalName, levDistance, touchActivity, assertAlive } from "../core/common.js"; +import { debugFrameStart, debugBox } from "../core/debug-overlay.js"; +import { guideMarkerCount, scanRowMarkers, disposeMarkers } from "../core/markers.js"; +import { ocrText, clickTextInRegion, clickGuideButton, processMarker } from "./popup.js"; + +// 首行固定锚点 y(1920x1080 基准):启动扫描用它判断“第一行区域”是否有标注 +export const FIRST_ROW_ANCHOR_Y = 365; + +// BGI 内置可传送秘境名(与 tp.json 一致),用于把行 OCR 到的文字标准化 +const KNOWN_DOMAINS = [ + "铭记之谷", "忘却之峡", "仲夏庭园", "塞西莉亚苗圃", "无妄引咎密宫", "太山府", + "震雷连山密宫", "孤云凌霄之处", "华池岩岫", "芬德尼尔之顶", "山脊守望", "菫色之庭", + "椛染之庭", "砂流之庭", "沉眠之庭", "昏识塔", "缘觉塔", "有顶塔", "岩中幽谷", + "赤金的城墟", "熔铁的孤塞", "深潮的余响", "苍白的遗荣", "罪祸的终末", "临瀑之城", + "褪色的剧场", "蕴火的幽墟", "深古瞭望所", "虹灵的净土", "荒废砌造坞", "无光的深都", + "霜凝的机枢", "失落的月庭", "月童的库藏", "山风的荆冕", "荒坠的圣迹", "逆悬的冰河", + "妄念的创痕" +]; + +// 树脂图标模板:透明背景已转纯绿 (0,255,0),useMask=true 时绿色不参与匹配 +const RESIN_CONDENSED_PATH = "assets/RecognitionObject/resin_condensed.png"; +const RESIN_ORIGINAL_PATH = "assets/RecognitionObject/resin_original.png"; +const RESIN_TEMPLATE_THRESHOLD = 0.9; + +let resinCondensedMat = null; +let resinCondensedRo = null; +let resinOriginalMat = null; +let resinOriginalRo = null; +let resinDigitRos = null; + +function ensureResinTemplates() { + if (resinCondensedRo && resinOriginalRo && resinDigitRos) return; + const loadResized = (path) => { + let m = file.readImageMatSync(path); + if (!m || m.empty() || m.width <= 0 || m.height <= 0) { + throw new Error("树脂图标模板读取失败: " + path); + } + const tw = Math.max(4, Math.round(m.width * scaleX)); + const th = Math.max(4, Math.round(m.height * scaleY)); + if (tw !== m.width || th !== m.height) { + const resized = new Mat(); + OpenCvSharp.OpenCvSharp.Cv2.Resize(m, resized, new OpenCvSharp.OpenCvSharp.Size(tw, th)); + try { m.dispose(); } catch (e) { } + m = resized; + } + return m; + }; + resinCondensedMat = loadResized(RESIN_CONDENSED_PATH); + resinOriginalMat = loadResized(RESIN_ORIGINAL_PATH); + resinCondensedRo = RecognitionObject.TemplateMatch(resinCondensedMat, true); + resinOriginalRo = RecognitionObject.TemplateMatch(resinOriginalMat, true); + resinCondensedRo.Threshold = RESIN_TEMPLATE_THRESHOLD; + resinOriginalRo.Threshold = RESIN_TEMPLATE_THRESHOLD; + + // 浓缩数字用白色数字模板匹配(素材与 OCRCountResin 一致) + resinDigitRos = []; + for (let v = 0; v <= 5; v++) { + const mat = loadResized("assets/RecognitionObject/num" + v + "_white.png"); + const ro = RecognitionObject.TemplateMatch(mat); + ro.Threshold = 0.8; + resinDigitRos.push({ ro, mat, value: v }); + } +} + +export function disposeResinResources() { + try { + if (resinCondensedRo) { + try { if (resinCondensedRo.TemplateImageGreyMat) resinCondensedRo.TemplateImageGreyMat.dispose(); } catch (e) { } + try { if (resinCondensedRo.MaskMat) resinCondensedRo.MaskMat.dispose(); } catch (e) { } + resinCondensedRo = null; + } + } catch (e) { } + try { + if (resinOriginalRo) { + try { if (resinOriginalRo.TemplateImageGreyMat) resinOriginalRo.TemplateImageGreyMat.dispose(); } catch (e) { } + try { if (resinOriginalRo.MaskMat) resinOriginalRo.MaskMat.dispose(); } catch (e) { } + resinOriginalRo = null; + } + } catch (e) { } + try { if (resinCondensedMat) resinCondensedMat.dispose(); } catch (e) { } + try { if (resinOriginalMat) resinOriginalMat.dispose(); } catch (e) { } + resinCondensedMat = null; + resinOriginalMat = null; + if (resinDigitRos) { + for (const d of resinDigitRos) { + try { if (d.ro.TemplateImageGreyMat) d.ro.TemplateImageGreyMat.dispose(); } catch (e) { } + try { if (d.ro.MaskMat) d.ro.MaskMat.dispose(); } catch (e) { } + try { if (d.mat) d.mat.dispose(); } catch (e) { } + } + resinDigitRos = null; + } +} + +export function isGuidePage() { + if (guideMarkerCount() > 0) return true; + const title = ocrText(sX(400), sY(200), sX(300), sY(100), "页面标题OCR", "#00b0ff"); + return title.includes("提升指南"); +} + +export async function waitGuideMarkers(timeoutMs = 20000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const n = guideMarkerCount(); + if (n > 0) { + log.info("检测到标注图标 {n} 个", n); + return true; + } + await sleep(1000); + } + return false; +} + +export async function ensureGuidePage() { + if (isGuidePage()) { + log.info("已在提升指南页面"); + return; + } + log.info("未检测到提升指南页面,自动打开"); + await genshin.returnMainUi(); + await sleep(800); + keyPress("F1"); + await sleep(1500); + + if (!(await clickTextInRegion("秘境", sX(200), sY(350), sX(200), sY(150), 15000))) { + throw new Error("未能找到冒险之证左侧的「秘境」按钮"); + } + await sleep(1000); + + // 点完「秘境」后,先 OCR 找到并点击「提升指南」按钮(不用固定坐标) + if (!isGuidePage()) { + log.info("点击「提升指南」按钮"); + if (!(await clickGuideButton(15000))) { + throw new Error("未能找到并点击「提升指南」按钮"); + } + await sleep(800); + } + + // 页面可能没有任何标注:确认标题即可,不必等标注出现 + const start = Date.now(); + while (Date.now() - start < 10000) { + if (isGuidePage()) { + log.info("已进入提升指南页面"); + return; + } + await sleep(500); + } + throw new Error("10 秒内未确认提升指南页面,请检查游戏状态"); +} + +export function qualityLabelForRank(rank) { + return "rank" + rank; +} + +export function sundayPosForX(x) { + const rules = [[1, 1421], [2, 1336], [3, 1257]]; + for (const [pos, center] of rules) { + if (Math.abs(x - sX(center)) <= sX(25)) return pos; + } + return 0; +} + +export async function swipeRowRight(captureY) { + const startX = sX(1100); + const endX = sX(1400); + log.info("[校准滑动] y={y} ({start}->{end})", captureY, startX, endX); + + let cap = null; + try { + cap = captureGameRegion(); + cap.moveTo(startX, captureY); + await sleep(100); + leftButtonDown(); + const steps = 20; + for (let i = 1; i <= steps; i++) { + const x = startX + (endX - startX) * i / steps; + cap.moveTo(Math.round(x), captureY); + await sleep(20); + } + leftButtonUp(); + } catch (e) { + try { leftButtonUp(); } catch (e2) { } + log.error("[校准滑动] 失败: {err}", e.message); + } finally { + try { if (cap) cap.dispose(); } catch (e) { } + } + parkMouse(); + await sleep(1000); +} + +// 启动阶段首行区域无标注时:对固定位置做一次校准(从左到右滑动两次) +export async function calibrateFirstRow() { + const y = sY(FIRST_ROW_ANCHOR_Y); + log.info("[首行校准] 首行区域无标注,固定位置校准 y={y}", y); + await swipeRowRight(y); + await swipeRowRight(y); +} + +export async function swipeRowPressHold(captureY) { + const startX = sX(1400); + const endX = startX - sX(400); + log.info("[主力滑动] y={y} ({start}->{end})", captureY, startX, endX); + + let cap = null; + try { + cap = captureGameRegion(); + cap.moveTo(startX, captureY); + await sleep(100); + leftButtonDown(); + await sleep(400); // 按住0.4s + const steps = 25; + for (let i = 1; i <= steps; i++) { + const x = startX + (endX - startX) * i / steps; + cap.moveTo(Math.round(x), captureY); + await sleep(16); + } + await sleep(400); // 划完不松手,保持0.4s + leftButtonUp(); + } catch (e) { + try { leftButtonUp(); } catch (e2) { } + log.error("[主力滑动] 失败: {err}", e.message); + } finally { + try { if (cap) cap.dispose(); } catch (e) { } + } + // 松开后先等 400ms,让滑动手势完全结束,再移动鼠标去点空处 + await sleep(400); + parkMouse(); + // 只在主力滑动后:点一下中心偏上的空处消除标注悬停放大,再等 500ms 让动画回落 + try { + const [gw, gh] = getGameMetrics(); + click(Math.round(gw / 2), Math.round(gh / 6)); + } catch (e) { } + await sleep(500); +} + +export async function readDomainName(rowY) { + const text = await ocrText(sX(760), rowY - sY(15), sX(300), sY(45), "行秘境名OCR", "#00b0ff"); + log.info("[秘境OCR] y={y}: {text}", rowY, text); + + // 1. 直接包含 + for (const d of KNOWN_DOMAINS) { + if (text.includes(d)) return d; + } + + // 2. 逐行与标准名做编辑距离匹配(如 OCR 把「菫」认成「董」) + const lines = String(text).split(/\n/).map(s => s.trim()).filter(s => s.length >= 2); + for (const line of lines) { + for (const d of KNOWN_DOMAINS) { + if (Math.abs(line.length - d.length) <= 1 && levDistance(line, d) <= 1) { + log.info("[秘境OCR] {line} -> {d}", line, d); + return d; + } + } + } + return ""; +} + +// 树脂:图标模板定位 + 数字条 OCR;图标没匹配到时回退固定坐标区域 +// 原粹白字预处理:单独截图,二值化 + 反色后 OCR(不污染主截图) +function ocrWhiteDigits(x, y, w, h) { + let cap = null, crop = null, grey = null, bin = null, inv = null, bgr = null, res = null; + try { + cap = captureGameRegion(); + crop = cap.deriveCrop(x, y, w, h); + grey = crop.srcMat.cvtColor(OpenCvSharp.OpenCvSharp.ColorConversionCodes.BGR2GRAY); + bin = grey.threshold(180, 255, OpenCvSharp.OpenCvSharp.ThresholdTypes.Binary); + inv = new Mat(); + OpenCvSharp.OpenCvSharp.Cv2.BitwiseNot(bin, inv); + bgr = inv.cvtColor(OpenCvSharp.OpenCvSharp.ColorConversionCodes.GRAY2BGR); + bgr.copyTo(crop.srcMat, null); + res = crop.find(RecognitionObject.ocrThis); + if (res && !res.isEmpty()) return (res.text || "").trim(); + } catch (e) { + // 预处理失败,回退普通 OCR + } finally { + try { if (res) res.dispose(); } catch (e) { } + try { if (bgr) bgr.dispose(); } catch (e) { } + try { if (inv) inv.dispose(); } catch (e) { } + try { if (bin) bin.dispose(); } catch (e) { } + try { if (grey) grey.dispose(); } catch (e) { } + try { if (crop) crop.dispose(); } catch (e) { } + try { if (cap) cap.dispose(); } catch (e) { } + } + return ""; +} + +export async function readResin() { + ensureResinTemplates(); + debugFrameStart(); + + let condensed = 0; + let digitInfo = "未命中"; + let originalText = ""; + let cap = null; + let cRegion = null; + let oRegion = null; + let cScore = 0; + let oScore = 0; + try { + parkMouse(); + cap = captureGameRegion(); + // 用 findMulti 取最高有限分:单个 find 的 matchScore 可能缺失/Infinity,导致误判未命中 + const pickBest = (ro) => { + let results = null; + let best = null; + try { + results = cap.findMulti(ro); + for (let k = 0; k < results.count; k++) { + const r = results[k]; + const s = Number(r.matchScore); + if (!Number.isFinite(s)) { + try { r.dispose(); } catch (e) { } + continue; + } + if (!best || s > best.score) { + if (best) { try { best.region.dispose(); } catch (e) { } } + best = { score: s, region: r, x: r.x, y: r.y, w: r.width, h: r.height }; + } else { + try { r.dispose(); } catch (e) { } + } + } + } catch (e) { + // 无候选 + } + return best; + }; + const cInfo = pickBest(resinCondensedRo); + const oInfo = pickBest(resinOriginalRo); + if (cInfo) { + cRegion = cInfo.region; + cScore = cInfo.score; + } + if (oInfo) { + oRegion = oInfo.region; + oScore = oInfo.score; + } + + const readDigitsAt = (x, y, w, h) => { + let res = null; + try { + res = cap.find(RecognitionObject.ocr(x, y, w, h)); + if (res && !res.isEmpty()) return (res.text || "").trim(); + } catch (e) { + // 失败按空处理,外层回退 + } finally { + try { if (res) res.dispose(); } catch (e) { } + } + return ""; + }; + + if (cRegion && !cRegion.isEmpty()) { + log.info("[树脂定位] 浓缩图标 @({x},{y},{w},{h}) 分={s}", + cRegion.x, cRegion.y, cRegion.width, cRegion.height, cScore.toFixed(3)); + // 浓缩数字:白色数字模板匹配,验证区=图标左上角起 90x40 + const region = { x: cRegion.x, y: cRegion.y, w: sX(90), h: sY(40) }; + debugBox("浓缩数字区", region.x, region.y, region.w, region.h, "#448aff"); + let best = null; + for (const d of resinDigitRos) { + let r = null; + try { r = cap.find(d.ro); } catch (e) { } + if (!r || r.isEmpty()) { + try { if (r) r.dispose(); } catch (e) { } + continue; + } + const s = Number(r.matchScore); + const ok = Number.isFinite(s) && + r.x >= region.x && r.x <= region.x + region.w && + r.y >= region.y && r.y <= region.y + region.h; + if (!ok || (best && s <= best.score)) { + try { r.dispose(); } catch (e) { } + continue; + } + if (best) { try { best.region.dispose(); } catch (e) { } } + best = { score: s, region: r, value: d.value, x: r.x, y: r.y, w: r.width, h: r.height }; + } + if (best) { + condensed = best.value; + digitInfo = "值=" + best.value + " 分=" + best.score.toFixed(3) + + " @(" + best.x + "," + best.y + "," + best.w + "," + best.h + ")"; + debugBox("浓缩数字" + best.value, best.x, best.y, best.w, best.h, "#ff4081"); + try { best.region.dispose(); } catch (e) { } + } + } else { + log.warn("[树脂] 浓缩图标未命中,浓缩按 0"); + } + + if (oRegion && !oRegion.isEmpty()) { + log.info("[树脂定位] 原粹图标 @({x},{y},{w},{h}) 分={s}", + oRegion.x, oRegion.y, oRegion.width, oRegion.height, oScore.toFixed(3)); + // 数字起点在图标右缘左侧:左移 5px,130x50 + const x = oRegion.x + oRegion.width - sX(5); + const y = Math.max(0, oRegion.y + Math.round((oRegion.height - sY(50)) / 2)); + debugBox("原粹数字条", x, y, sX(130), sY(50), "#ffc107"); + originalText = ocrWhiteDigits(x, y, sX(130), sY(50)); + if (!originalText) originalText = readDigitsAt(x, y, sX(130), sY(50)); + // 图标定位后的 OCR 为空时,回退 ResinCalibration 标定的固定区域(仅原粹) + if (!originalText) { + const oFx = sX(1350), oFy = sY(200), oFw = sX(140), oFh = sY(50); + log.info("[树脂] 原粹图标定位 OCR 为空,回退固定区域 ({x},{y},{w},{h})", oFx, oFy, oFw, oFh); + debugBox("原粹OCR(固定回退)", oFx, oFy, oFw, oFh, "#ffc107"); + originalText = readDigitsAt(oFx, oFy, oFw, oFh); + } + } else { + debugBox("原粹树脂OCR(回退)", sX(1350), sY(200), sX(140), sY(50), "#ffc107"); + originalText = await ocrText(sX(1350), sY(200), sX(140), sY(50), "原粹树脂OCR", "#ffc107"); + } + } finally { + try { if (cRegion) cRegion.dispose(); } catch (e) { } + try { if (oRegion) oRegion.dispose(); } catch (e) { } + try { if (cap) cap.dispose(); } catch (e) { } + } + + // 抗干扰:只保留数字和斜杠 + const cleanDigits = (t) => String(t || "").replace(/[^0-9/]/g, ""); + const firstInt = (t) => { + const m = String(t || "").match(/\d+/); + return m ? parseInt(m[0], 10) : 0; + }; + + let original = 0; + { + const raw = String(originalText || "").replace(/\s+/g, ""); + const m = raw.match(/(\d+)\/(\d+)/) || cleanDigits(originalText).match(/(\d+)\/(\d+)/); + original = m ? parseInt(m[1], 10) : firstInt(cleanDigits(originalText)); + } + + // 浓缩优先:1 个浓缩 = 1 次;原粹每 40 可刷 1 次, + // 剩余 20-39 也算可刷 1 次(20 树脂档),BGI 指定使用时按 40 优先、最后 40 不够才用 20 + const originalRounds = Math.floor(original / 40) + (original % 40 >= 20 ? 1 : 0); + const rounds = condensed + originalRounds; + log.info("[树脂] 浓缩 {c}({ci}),原粹 {o}({ot}),可刷 {r} 轮(浓缩优先,原粹40优先,余20-39补1次20)", + condensed, digitInfo, original, originalText, rounds); + return { condensed, original, originalRounds, rounds }; +} + +export async function refreshPlanFromGuide(entries, rowY, expectedDomainName) { + log.info("重新扫描培养计划"); + await ensureGuidePage(); + + // 原行身份校验:下方行上移后会占据原 y。 + // 此处读到“不同的秘境名”说明当前行已完成消失,不要用下一行的材料冒充本行重扫结果。 + if (expectedDomainName) { + const observed = await readDomainName(rowY); + if (observed && observed !== expectedDomainName && + levDistance(canonicalName(observed), canonicalName(expectedDomainName)) > 1) { + log.info("[刷新] 原行已完成(位置秘境名变为 {obs}),视为本行完成,等待切换下一行", observed); + for (let i = entries.length - 1; i >= 0; i--) { + if (Number(entries[i].rowY) === rowY) entries.splice(i, 1); + } + return true; + } + } + + // 与重开脚本完全一致:按 processRow 完整重扫该行 + const row = { index: 0, y: rowY, names: new Map(), nextRank: 1, domainName: "" }; + const before = entries.length; + try { + const result = await processRow(row, entries); + if (result && result.status === "noDomain") { + // 读不到域名,再判断原行是真的消失了,还是有标注但无可刷秘境(周本) + const check = await scanRowMarkers(rowY, 1000); + const stillHasMarkers = check.length > 0; + disposeMarkers(check); + if (!stillHasMarkers) { + // 原行已无标注 = 需求已全部满足,继续走下方删旧条目并返回 true + log.info("[刷新] 原行已无标注,视为全部满足"); + } else { + // 有标注但读不到秘境名:周本/不可刷,停止后续流程 + log.info("[刷新] 该行有标注但无可刷秘境,停止后续处理"); + return false; + } + } + } catch (e) { + if (String(e.message || "").includes("校准后仍识别不到标注")) { + // 该行已无标注 = 需求已全部满足 + log.info("[刷新] 该行已无标注,视为全部满足"); + } else { + log.error("[刷新] 重扫失败: {err}", e.message); + return false; + } + } + + // 该行重扫前的旧条目全部移除,以本次重扫结果为准(等价于重开脚本) + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (Number(e.rowY) !== rowY) continue; + if (i < before) { + entries.splice(i, 1); + } + } + return true; +} + +export async function clickAndRecord(m, row, entries) { + const info = await processMarker(m); + if (!info) return; + + const key = canonicalName(info.name); + const orderRank = row.nextRank; + let rank = orderRank; + if (info.colorRankTop && info.colorRankTop >= 1 && info.colorRankTop <= 4) { + rank = info.colorRankTop; + } + + let dup = false; + for (const [k, rk] of row.names) { + // 完全同名无条件去重;错 1 字只有品质档位相同才算重复,避免把“一角/一片”这类不同档合并 + if (k === key || (rk === rank && levDistance(k, key) <= 1)) { + dup = true; + break; + } + } + if (dup) { + log.info("[重复] {name} 已记录(含同档错1字),跳过", info.name); + return; + } + + row.names.set(key, rank); + row.nextRank = orderRank + 1; + touchActivity(); + // 品质按颜色绝对4档:金1/紫2/蓝3/绿4;颜色失败按点击顺序兜底 + const label = qualityLabelForRank(rank); + entries.push({ + material: info.name, + category: info.category, + source: info.source, + have: info.have, + need: info.need, + needText: info.needText, + synthText: info.synthText, + synthCount: info.synthCount || 0, + qualityRank: rank, + qualityLabel: label, + colorRankTop: info.colorRankTop || null, + orderRank, + domainName: row.domainName || "", + sundayPos: sundayPosForX(m.cx), + rowY: Math.round(row.y), + clickX: Math.round(m.cx), + clickY: Math.round(m.cy) + }); + log.info("[品质] {name} {label}", info.name, label); +} + +export async function processRow(row, entries) { + log.info("[行] 第 {n} 行 y={y}", row.index, row.y); + touchActivity(); + + // 读不到秘境名就重试一次;仍失败按周本/不可刷处理,返回 noDomain + row.domainName = await readDomainName(row.y); + if (!row.domainName) { + log.info("未识别秘境名,重试一次"); + const retryDomain = await readDomainName(row.y); + if (!retryDomain) { + log.warn("该行有标注但秘境名读取失败,按周本/无可刷秘境处理"); + return { status: "noDomain" }; + } + row.domainName = retryDomain; + } + log.info("[秘境] {domain}", row.domainName || "(未识别)"); + + // 左→右校准两次 + await swipeRowRight(row.y); + await swipeRowRight(row.y); + log.info("校准完成"); + + // 识别标注,失败则校准重试 + let ms0 = await scanRowMarkers(row.y, 3000); + if (ms0.length === 0) { + log.info("未识别标注,校准后重试"); + for (let i = 0; i < 3; i++) { + await swipeRowRight(row.y); + } + ms0 = await scanRowMarkers(row.y, 5000); + if (ms0.length === 0) { + throw new Error("校准后仍识别不到标注(卡死 5 秒),自动结束脚本"); + } + } + disposeMarkers(ms0); + log.info("[扫描] 识别到该行标注"); + + // 点击该行可见标注(第 1 次识别) + { + assertAlive(); + let ms = await scanRowMarkers(row.y); + try { + ms.sort((a, b) => a.x - b.x); + log.info("[扫描] 该行可见标注 {k} 个", ms.length); + for (const m of ms) { + assertAlive(); + await clickAndRecord(m, row, entries); + } + } finally { + disposeMarkers(ms); + } + } + + // 滑动结果要和第一次识别比“名称+品质”:数量相同、名称容错 1 字、品质档相同才算一致, + // 避免把“无垢之海的银杯/金杯”这种错 1 字但品质不同的材料当成同一批 + const firstSeen = new Map([...row.names]); + + const samePassFuzzy = (a, b) => + a.size === b.size && + [...a].every(([name, rank]) => + [...b].some(([otherName, otherRank]) => + rank === otherRank && levDistance(name, otherName) <= 1)); + + // 第 1 次滑动后与初始一致才跳过第 2 次,然后恢复校准 + for (let s = 1; s <= 2; s++) { + assertAlive(); + await swipeRowPressHold(row.y); + + let ms = await scanRowMarkers(row.y); + try { + if (ms.length === 0) { + log.info("[滑动] 第 {s} 次后未发现标注", s); + continue; + } + ms.sort((a, b) => a.x - b.x); + log.info("[滑动] 第 {s} 次后识别 {k} 个标注", s, ms.length); + for (const m of ms) { + await clickAndRecord(m, row, entries); + } + if (s === 1 && firstSeen.size > 0 && samePassFuzzy(firstSeen, row.names)) { + log.info("[滑动] 识别结果与初始一致(名称+品质),跳过第 2 次滑动"); + break; + } + if (s === 1 && firstSeen.size > 0) { + log.info("[滑动] 识别结果包含新品质/新材料,继续第 2 次滑动"); + } + } finally { + disposeMarkers(ms); + } + } + + // 恢复校准 + log.info("滑动识别完成,恢复校准"); + await swipeRowRight(row.y); + await swipeRowRight(row.y); + + return { status: "ok", count: row.names.size }; +} diff --git a/repo/js/AutoTrainingPlanFarm/src/plan/popup.js b/repo/js/AutoTrainingPlanFarm/src/plan/popup.js new file mode 100644 index 0000000000..be1be720ba --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/src/plan/popup.js @@ -0,0 +1,540 @@ +// 材料弹窗:OCR 读取、名称/需求/来源/可合成解析、品质颜色、点击 OCR 文本 +import { sX, sY, scaleX, scaleY, parkMouse, touchActivity, assertAlive, levDistance } from "../core/common.js"; +import { debugFrameStart, debugBox } from "../core/debug-overlay.js"; + +// 弹窗整块 OCR 区域 +const POPUP_OCR_RECT = { x: 710, y: 55, w: 500, h: 960 }; + +// 材料弹窗 OCR 时把鼠标停到识别框(y 从约 55 开始)上方,避免遮挡文字/颜色取样 +function parkMouseAbovePopup() { + try { + const [gw] = getGameMetrics(); + moveMouseTo(Math.round(gw / 2), 24); + } catch (e) { } +} + +export function ocrText(x, y, w, h, label, color) { + let cap = null; + let res = null; + try { + debugFrameStart(); + debugBox(label, x, y, w, h, color); + parkMouse(); + cap = captureGameRegion(); + res = cap.find(RecognitionObject.ocr(x, y, w, h)); + if (res && !res.isEmpty()) { + return (res.text || "").trim(); + } + return ""; + } catch (e) { + return ""; + } finally { + try { if (res) res.dispose(); } catch (e) { } + try { if (cap) cap.dispose(); } catch (e) { } + } +} + +export function readPopupLines() { + let cap = null; + let results = null; + try { + debugFrameStart(); + debugBox("弹窗整块OCR", sX(POPUP_OCR_RECT.x), sY(POPUP_OCR_RECT.y), sX(POPUP_OCR_RECT.w), sY(POPUP_OCR_RECT.h), "#ffd600"); + parkMouseAbovePopup(); + cap = captureGameRegion(); + results = cap.findMulti(RecognitionObject.ocr( + sX(POPUP_OCR_RECT.x), sY(POPUP_OCR_RECT.y), sX(POPUP_OCR_RECT.w), sY(POPUP_OCR_RECT.h) + )); + const lines = []; + for (let i = 0; i < results.count; i++) { + const r = results[i]; + const t = (r.text || "").replace(/\s+/g, "").trim(); + if (t) { + lines.push({ text: t, x: r.x, y: r.y, width: r.width, height: r.height }); + debugBox("弹窗文本:" + t + "@" + r.x + "," + r.y, r.x, r.y, r.width, r.height, "#4fc3f7"); + } + } + lines.sort((a, b) => a.y - b.y || a.x - b.x); + return lines; + } catch (e) { + return []; + } finally { + try { + if (results) { + for (let i = 0; i < results.count; i++) { + try { results[i].dispose(); } catch (e) { } + } + } + } catch (e) { } + try { if (cap) cap.dispose(); } catch (e) { } + } +} + +export function findAnchorLine(lines, keyword) { + for (const l of lines) { + if (l.text.includes(keyword)) return l; + } + return null; +} + +// 可合成数量与数字同在一行:先精确匹配,标签错 1 字也认,最后取该行末尾数字 +function extractSynthCount(text) { + const t = String(text || "").replace(/\s+/g, ""); + if (!t) return 0; + + const exact = t.match(/可合成数量[::]?(\d+)/); + if (exact) return parseInt(exact[1], 10) || 0; + + const labels = ["可合成数量:", "可合成数量:", "可合成数量"]; + const digits = t.match(/\d+/g) || []; + for (const d of digits) { + const prefix = t.slice(0, t.indexOf(d)); + if (prefix && labels.some(label => levDistance(prefix, label) <= 1)) { + return parseInt(d, 10) || 0; + } + } + return digits.length > 0 ? (parseInt(digits[digits.length - 1], 10) || 0) : 0; +} + +export function collectBelowAnchor(lines, anchor, maxDy, excludeRe) { + if (!anchor) return ""; + const parts = []; + for (const l of lines) { + if (l.y < anchor.y + anchor.height - sY(5)) continue; + if (l.y > anchor.y + sY(maxDy)) continue; + if (l.x < anchor.x - sX(60) || l.x > sX(1400)) continue; + if (excludeRe && excludeRe.test(l.text)) continue; + parts.push(l.text); + } + return parts.join(""); +} + +export function extractPopupName(lines) { + const ex = /素材|来源|培养需求|可合成/; + const yLimit = sY(360); + let first = null; + for (const l of lines) { + if (l.y > yLimit) break; + if (l.text.length >= 2 && !ex.test(l.text)) { + first = l; + break; + } + } + if (first) { + // 同一行的碎片(PaddleOCR 可能把名称拆成多段)按 x 拼接 + const parts = []; + for (const l of lines) { + if (Math.abs(l.y - first.y) <= sY(30) && l.x < sX(1400) && !ex.test(l.text)) { + parts.push(l); + } + } + parts.sort((a, b) => a.x - b.x); + return parts.map(p => p.text).join(""); + } + // 兜底:取顶部第一行并裁掉可能粘在一起的分类后缀 + for (const l of lines) { + if (l.y > yLimit) break; + if (l.text.length >= 2) { + return l.text.replace(/(角色|武器)?天赋?突破?素材.*$/, "").trim(); + } + } + return ""; +} + +export function parseNeedFromLines(lines, needAnchor) { + const slashRe = /(\d+)\/(\d+)/; + const candidates = []; + + if (needAnchor) { + const own = needAnchor.text.match(slashRe); + if (own) return { have: parseInt(own[1], 10), need: parseInt(own[2], 10) }; + + for (const l of lines) { + if (Math.abs(l.y - needAnchor.y) > sY(70)) continue; + if (l.x < needAnchor.x - sX(80)) continue; + const m = l.text.match(slashRe); + if (m) candidates.push({ line: l, m }); + } + } + + if (candidates.length === 0) { + for (const l of lines) { + const m = l.text.match(slashRe); + if (m) candidates.push({ line: l, m }); + } + } + + if (candidates.length === 0) { + // 没有 x/y 格式时:取靠近「培养需求」的所有数字,最后一个视为需求数 + const nums = []; + const addNums = (text) => { + const ms = text.match(/\d+/g); + if (ms) ms.forEach(n => nums.push(parseInt(n, 10))); + }; + if (needAnchor) addNums(needAnchor.text); + if (needAnchor) { + for (const l of lines) { + if (Math.abs(l.y - needAnchor.y) <= sY(70) && l.x >= needAnchor.x - sX(80)) addNums(l.text); + } + } + if (nums.length > 0) return { have: nums.length >= 2 ? nums[0] : 0, need: nums[nums.length - 1] }; + return null; + } + + if (needAnchor) { + candidates.sort((a, b) => + Math.abs(a.line.y - needAnchor.y) - Math.abs(b.line.y - needAnchor.y)); + } + const c = candidates[0]; + return { have: parseInt(c.m[1], 10), need: parseInt(c.m[2], 10) }; +} + +export async function clickTextInRegion(text, x, y, w, h, timeoutMs = 12000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + let cap = null; + let results = null; + let clicked = false; + try { + debugFrameStart(); + debugBox("点击OCR:" + text, x, y, w, h, "#ff9100"); + parkMouse(); + cap = captureGameRegion(); + results = cap.findMulti(RecognitionObject.ocr(x, y, w, h)); + for (let i = 0; i < results.count; i++) { + const r = results[i]; + const t = (r.text || "").replace(/\s+/g, ""); + if (t.includes(text)) { + log.info("[点击] {text} @({x},{y},{w},{h})", text, r.x, r.y, r.width, r.height); + r.click(); + clicked = true; + break; + } + } + } catch (e) { + } finally { + try { + if (results) { + for (let i = 0; i < results.count; i++) { + try { results[i].dispose(); } catch (e) { } + } + } + } catch (e) { } + try { if (cap) cap.dispose(); } catch (e) { } + } + if (clicked) return true; + await sleep(400); + } + return false; +} + +export async function clickGuideButton(timeoutMs = 15000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + let cap = null; + let results = null; + const found = []; + try { + debugFrameStart(); + parkMouse(); + cap = captureGameRegion(); + debugBox("全屏OCR:提升指南", 0, 0, cap.width, cap.height, "#ff5252"); + results = cap.findMulti(RecognitionObject.ocr(0, 0, cap.width, cap.height)); + for (let i = 0; i < results.count; i++) { + const r = results[i]; + if ((r.text || "").replace(/\s+/g, "").includes("提升指南")) { + const cy = r.y + r.height / 2; + found.push({ cy, r }); + } + } + if (found.length > 0) { + // 一律选 y 最小的那个;选定后本次不再点其它候选 + found.sort((a, b) => a.cy - b.cy); + const best = found[0]; + log.info("[点击] 提升指南 @({x},{y},{w},{h})", best.r.x, best.r.y, best.r.width, best.r.height); + best.r.click(); + return true; + } + } catch (e) { + } finally { + try { + if (results) { + for (let i = 0; i < results.count; i++) { + try { results[i].dispose(); } catch (e) { } + } + } + } catch (e) { } + try { if (cap) cap.dispose(); } catch (e) { } + } + await sleep(500); + } + return false; +} + +export async function waitPopupName() { + // 等弹窗入场动画:第一次识别前固定等 1s + await sleep(1000); + for (let i = 0; i < 20; i++) { + assertAlive(); + const name = extractPopupName(readPopupLines()); + if (name) { + touchActivity(); + return name; + } + await sleep(400); + } + return ""; +} + +export function popupStillOpen(lines) { + return !!(findAnchorLine(lines, "培养需求") || findAnchorLine(lines, "来源")); +} + +export async function closePopup(name) { + keyPress("ESCAPE"); + await sleep(500); + let closedCount = 0; + for (let i = 0; i < 16; i++) { + assertAlive(); + const lines = readPopupLines(); + if (!popupStillOpen(lines)) { + // 连续两次确认锚点消失才认为已关闭,防止单次 OCR 抖动误判 + closedCount++; + if (closedCount >= 2) { + touchActivity(); + return true; + } + } else { + closedCount = 0; + if (name) { + const current = extractPopupName(lines); + if (current && !current.includes(name.substring(0, Math.min(2, name.length)))) { + touchActivity(); + return true; + } + } + } + await sleep(250); + } + keyPress("ESCAPE"); + await sleep(500); + return false; +} + +export async function scrollPopupUp() { + let cap = null; + try { + cap = captureGameRegion(); + const cx = sX(960); + const y1 = sY(700); + const y2 = sY(250); + cap.moveTo(cx, y1); + await sleep(100); + leftButtonDown(); + const steps = 20; + for (let i = 1; i <= steps; i++) { + const y = y1 + (y2 - y1) * i / steps; + cap.moveTo(cx, Math.round(y)); + await sleep(16); + } + leftButtonUp(); + } catch (e) { + try { leftButtonUp(); } catch (e2) { } + log.error("弹窗向上滑动失败: {err}", e.message); + } finally { + try { if (cap) cap.dispose(); } catch (e) { } + } + parkMouse(); + await sleep(1000); +} + +export async function readPopup() { + const name = await waitPopupName(); + if (!name) return null; + + const lines = readPopupLines(); + const sourceAnchor = findAnchorLine(lines, "来源"); + const needAnchor = findAnchorLine(lines, "培养需求"); + const synthAnchor = findAnchorLine(lines, "可合成"); + + // 「来源」文字在锚点下方;一次没读到就稍等重试 + let source = collectBelowAnchor(lines, sourceAnchor, 120, /来源|培养需求|可合成/); + if (!source) { + await sleep(600); + const linesRetry = readPopupLines(); + const sourceAnchorRetry = findAnchorLine(linesRetry, "来源"); + if (sourceAnchorRetry) { + source = collectBelowAnchor(linesRetry, sourceAnchorRetry, 120, /来源|培养需求|可合成/); + } + } + + const parsed = parseNeedFromLines(lines, needAnchor); + // 数字与「可合成数量」标签同在一行:只取锚点行文本解析,不再拼接下方 80px 的其它行 + let synthText = synthAnchor ? synthAnchor.text : ""; + let synthCount = extractSynthCount(synthText); + log.info("[可合成数量] {text} -> {n}", synthText, synthCount); + + // 类型行:顶部名称下方、y<360 的「XX素材」行 + let category = ""; + for (const l of lines) { + if (l.y > sY(360)) break; + if (/素材/.test(l.text) && l.y > 0) { + category = l.text; + break; + } + } + + const needText = parsed + ? `${parsed.have}/${parsed.need}` + : (needAnchor ? needAnchor.text : ""); + + // 按材料名行背景色判品质(金1/紫2/蓝3/绿4),失败为 null + const nameLine = popupNameLine(lines); + const colorRankTop = qualityFromPopupColor(nameLine); + + // rank1/2/3 且没识别到可合成数量时,向上滑一下补识别;名字等字段要先取完再滑 + if (colorRankTop >= 1 && colorRankTop <= 3 && synthCount === 0) { + log.info("[可合成数量] 未识别,上滑补充 (rank{rank})", colorRankTop); + await scrollPopupUp(); + + const lines2 = readPopupLines(); + const synthAnchor2 = findAnchorLine(lines2, "可合成"); + const synthText2 = synthAnchor2 ? synthAnchor2.text : ""; + const count2 = extractSynthCount(synthText2); + log.info("[可合成数量] 补充识别 {text} -> {n}", synthText2, count2); + if (count2 > 0) { + synthText = synthText2; + synthCount = count2; + } + + // 滑动后「来源」可能也重新可见,之前没读到就顺带补一次 + if (!source) { + const sourceAnchor2 = findAnchorLine(lines2, "来源"); + if (sourceAnchor2) { + source = collectBelowAnchor(lines2, sourceAnchor2, 120, /来源|培养需求|可合成/); + } + } + } + + return { + name, + category, + source, + needText, + have: parsed ? parsed.have : 0, + need: parsed ? parsed.need : 0, + synthText, + synthCount, + colorRankTop, + foundAt: Date.now() + }; +} + +export function popupNameLine(lines) { + const ex = /素材|来源|培养需求|可合成/; + const yLimit = sY(360); + for (const l of lines) { + if (l.y > yLimit) break; + if (l.text.length >= 2 && !ex.test(l.text)) return l; + } + for (const l of lines) { + if (l.y > yLimit) break; + if (l.text.length >= 2) return l; + } + return null; +} + +export function qualityFromPopupColor(nameLine) { + if (!nameLine) return null; + const x = Math.max(0, nameLine.x - sX(12)); + const y = Math.max(0, nameLine.y - sY(12)); + const w = Math.min(sX(1920) - x, nameLine.width + sX(60)); + const h = Math.min(sY(1080) - y, nameLine.height + sY(24)); + if (w <= 0 || h <= 0) return null; + + let cap = null, crop = null, hsv = null; + try { + debugFrameStart(); + debugBox("品质颜色裁剪", x, y, w, h, "#e040fb"); + parkMouseAbovePopup(); + cap = captureGameRegion(); + crop = cap.deriveCrop(x, y, w, h); + const cvt = OpenCvSharp.OpenCvSharp.ColorConversionCodes; + hsv = crop.srcMat.cvtColor(cvt.BGR2HSV); + + const ranges = [ + [1, "金/橙", 0, 15, 60, 255, 80, 255], + [2, "紫", 125, 145, 70, 255, 80, 255], + [3, "蓝", 100, 120, 70, 255, 80, 255], + [4, "绿", 70, 90, 90, 255, 80, 255] + ]; + + let best = null; + for (const [rankTop, name, loH, hiH, loS, hiS, loV, hiV] of ranges) { + let mask = null; + try { + mask = new Mat(); + OpenCvSharp.OpenCvSharp.Cv2.InRange( + hsv, + new OpenCvSharp.OpenCvSharp.Scalar(loH, loS, loV), + new OpenCvSharp.OpenCvSharp.Scalar(hiH, hiS, hiV), + mask + ); + const cnt = OpenCvSharp.OpenCvSharp.Cv2.CountNonZero(mask); + if (!best || cnt > best.cnt) best = { rankTop, name, cnt }; + } catch (e) { + // 忽略单档调用失败 + } finally { + try { if (mask) mask.dispose(); } catch (e) { } + } + } + + if (best && best.cnt >= Math.round(500 * scaleX * scaleY)) { + log.info("[品质颜色] {name} {cnt}px", best.name, best.cnt); + return best.rankTop; + } + return null; + } catch (e) { + return null; + } finally { + try { if (hsv) hsv.dispose(); } catch (e) { } + try { if (crop) crop.dispose(); } catch (e) { } + try { if (cap) cap.dispose(); } catch (e) { } + } +} + +export async function processMarker(m) { + log.info("[点击] 标注 ({x},{y},{w},{h})", m.x, m.y, m.width, m.height); + try { + if (m.region) { + m.region.click(); + m.region.dispose(); + m.region = null; + } else { + return null; + } + } catch (e) { + log.error("点击标注失败: {err}", e.message); + try { if (m.region) { m.region.dispose(); m.region = null; } } catch (e2) { } + return null; + } + await sleep(900); + + const info = await readPopup(); + if (!info) { + log.warn("未识别到材料弹窗,ESC 跳过"); + await closePopup(""); + return null; + } + + // 有效弹窗必须同时有「来源」和「培养需求」;否则是误点/弹窗未弹出 + if (!info.source || !info.needText || info.need <= 0 || info.name.includes("秘境")) { + log.warn("弹窗无效 (name={name}, source={source}, need={need}),跳过", info.name, info.source, info.need); + await closePopup(info.name); + return null; + } + + log.info("[材料] {name} | {source} | {needText}", info.name, info.source, info.needText); + await closePopup(info.name); + return info; +} diff --git a/repo/js/AutoTrainingPlanFarm/src/plan/solver.js b/repo/js/AutoTrainingPlanFarm/src/plan/solver.js new file mode 100644 index 0000000000..fb290666ad --- /dev/null +++ b/repo/js/AutoTrainingPlanFarm/src/plan/solver.js @@ -0,0 +1,198 @@ +// 材料齐集判断:系列分组、方向贪心、可合成数量约束枚举 +import { canonicalName, levDistance } from "../core/common.js"; + +export function seriesKey(name) { + const n = canonicalName(name); + const di = n.indexOf("的"); + const zi = n.indexOf("之"); + let idx = -1; + if (di > 0 && zi > 0) idx = Math.min(di, zi); + else if (di > 0) idx = di; + else if (zi > 0) idx = zi; + return idx > 0 ? n.slice(0, idx) : n; +} + +export function buildPlan(entries) { + const items0 = entries.map(e => { + const norm = canonicalName(e.material); + return { entry: e, norm, series: seriesKey(norm) }; + }); + + const keyClusters = []; + for (const it of items0) { + let cluster = keyClusters.find(c => levDistance(c.key, it.series) <= 1); + if (!cluster) { + cluster = { key: it.series }; + keyClusters.push(cluster); + } + it.groupKey = cluster.key; + } + + const map = new Map(); + for (const it of items0) { + if (!map.has(it.groupKey)) map.set(it.groupKey, []); + map.get(it.groupKey).push(it); + } + + const groups = []; + const flat = []; + let idx = 0; + for (const [base, arr] of map) { + // 一律按颜色定档:金1/紫2/蓝3/绿4;颜色失败按点击顺序兜底。 + // 不按“正好3个材料”特判:4档系列可能只缺 3 个,特判会整体错位 + arr.sort((a, b) => + ((a.entry.qualityRank || 99) - (b.entry.qualityRank || 99)) || + ((a.entry.orderRank || 99) - (b.entry.orderRank || 99)) + ); + arr.forEach((item, order) => { + // 后缀按每个材料自己的系列名切,避免 OCR 错字导致后缀错位 + item.suffix = item.norm.slice(item.series.length).replace(/^[的之]/, ""); + item.localRank = Math.min(4, Math.max(1, Number(item.entry.qualityRank) || order + 1)); + item.entry.qualityRank = item.localRank; + item.globalIdx = idx++; + flat.push(item); + }); + // 永远按4档权重链 27/9/3/1 计算 + groups.push({ base, items: arr, maxRank: 4 }); + } + + return { groups, flat }; +} + +export function directionalGreedy(h, need) { + const n = h.length; + const w = (i) => Math.pow(3, n - 1 - i); + let carry = 0; + let deficit = 0; + const missing = new Array(n).fill(0); + for (let i = n - 1; i >= 0; i--) { + const avail = h[i] + carry; + const gap = Math.max(0, need[i] - avail); + if (gap > 0) { + missing[i] = gap; + deficit += gap * w(i); + carry = 0; + } else { + carry = Math.floor((avail - need[i]) / 3); // 3低 → 1高 + } + } + return { deficit, missing }; +} + +export function solveUnknownMinDeficit(n, mask, knownHave, synthKnown, need) { + const w = (i) => Math.pow(3, n - 1 - i); + let best = null; + + function dfs(j, h, lowerA) { + let cands; + if (mask[j]) { + cands = [knownHave[j]]; + } else if (j > 0 && mask[j - 1] && synthKnown[j - 1] !== null) { + const c = (j === n - 1) ? 0 : Math.floor(lowerA / 3); + const base = 3 * synthKnown[j - 1] - c; + cands = [base, base + 1, base + 2].filter(v => v >= 0); + } else { + cands = [0, 1, 2]; + } + + for (const val of cands) { + h[j] = val; + const A = (j === n - 1) ? val : val + Math.floor(lowerA / 3); + if (j > 0 && mask[j - 1] && synthKnown[j - 1] !== null && Math.floor(A / 3) !== synthKnown[j - 1]) continue; + + if (j === 0) { + const r = directionalGreedy(h, need); + let surplus = 0; + if (r.deficit === 0) { + let haveLow = 0; + let needLow = 0; + for (let i = 0; i < n; i++) { + haveLow += h[i] * w(i); + needLow += need[i] * w(i); + } + surplus = Math.max(0, haveLow - needLow); + } + if (best === null || + r.deficit < best.deficit || + (r.deficit === best.deficit && surplus > best.surplus)) { + best = { deficit: r.deficit, missing: r.missing, surplus }; + } + } else { + dfs(j - 1, h, A); + } + } + } + + dfs(n - 1, new Array(n).fill(0), 0); + return best; +} + +export function checkPlan(plan, counts) { + let allOk = true; + let totalDeficit = 0; + let totalSurplus = 0; + const groups = []; + + for (const g of plan.groups) { + const n = g.maxRank; + const need = new Array(n).fill(0); + + // 权重统一4档:index0(金)=27,index1(紫)=9,index2(蓝)=3,index3(绿)=1 + const weightOf = (i) => Math.pow(3, n - 1 - i); + + const mask = new Array(n).fill(false); + const knownHave = new Array(n).fill(0); + const synthKnown = new Array(n - 1).fill(null); + + for (const item of g.items) { + const r = (item.localRank || 1) - 1; + mask[r] = true; + knownHave[r] += (Number(item.entry.have) || 0) + (Number(counts[item.globalIdx]) || 0); + need[r] += Number(item.entry.need) || 0; + if (r < n - 1) { + const sc = Number(item.entry.synthCount) || 0; + synthKnown[r] = sc > 0 ? sc : null; + } + } + + // 用「可合成数量」约束枚举库存组合,取最乐观解(缺口最小,其次剩余最多;宁可少刷) + const solved = solveUnknownMinDeficit(n, mask, knownHave, synthKnown, need); + let deficit; + let surplus; + let missing; + if (solved === null) { + log.warn("[合成补偿] 约束矛盾,放弃补偿,按已扫库存计算"); + const fb = directionalGreedy(knownHave, need); + deficit = fb.deficit; + missing = fb.missing; + if (deficit === 0) { + let haveLow = 0; + let needLow = 0; + for (let i = 0; i < n; i++) { + haveLow += knownHave[i] * weightOf(i); + needLow += need[i] * weightOf(i); + } + surplus = Math.max(0, haveLow - needLow); + } else { + surplus = 0; + } + } else { + deficit = solved.deficit; + surplus = solved.surplus; + missing = solved.missing; + } + + const ok = deficit === 0; + if (!ok) allOk = false; + totalDeficit += deficit; + totalSurplus += surplus; + groups.push({ base: g.base, ok, deficitLowUnits: deficit, surplusLowUnits: surplus, missing }); + } + + return { + ok: allOk, + deficitLowUnits: totalDeficit, + surplusLowUnits: totalSurplus, + groups + }; +}