Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions repo/js/AutoTrainingPlanFarm/README.md
Original file line number Diff line number Diff line change
@@ -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 的树脂耗尽模式,刷完树脂就结束
- 一行读不出秘境名(一般是周本或未开放副本)时按正常结束处理。结束该插件
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
79 changes: 79 additions & 0 deletions repo/js/AutoTrainingPlanFarm/assets/debug_boxes.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>识别框调试</title>
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: transparent; }
#boxes { position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; }
.box { position: absolute; border: 2px solid #00e5ff; box-sizing: border-box; pointer-events: none; }
.box .label {
position: absolute; left: 2px; top: 2px; max-width: 100%;
font: 12px/1.3 "Microsoft YaHei", sans-serif; color: #fff;
background: rgba(0, 0, 0, 0.65); padding: 1px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
</style>
</head>
<body>
<div id="boxes"></div>
<script>
function pct(v, total) {
const n = Number(v);
if (!Number.isFinite(n) || total <= 0) return 0;
return n / total * 100;
}

const HOLD_MS = 1200;
let holdTimer = null;

function scheduleClear() {
clearTimeout(holdTimer);
holdTimer = setTimeout(function () {
document.getElementById('boxes').innerHTML = '';
}, HOLD_MS);
}

function render(msg) {
const host = document.getElementById('boxes');
host.innerHTML = '';
let data = msg && msg.data;
if (typeof data === 'string') {
try { data = JSON.parse(data); } catch (e) { data = {}; }
}
if (!data) return;

const W = Number(data.w) || 1;
const H = Number(data.h) || 1;
const list = Array.isArray(data.boxes) ? data.boxes : [];
for (const b of list) {
const x = Number(b.x) || 0;
const y = Number(b.y) || 0;
const w = Number(b.w) || 0;
const h = Number(b.h) || 0;
const div = document.createElement('div');
div.className = 'box';
div.style.left = pct(x, W) + '%';
div.style.top = pct(y, H) + '%';
div.style.width = Math.max(pct(w, W), 0.05) + '%';
div.style.height = Math.max(pct(h, H), 0.05) + '%';
div.style.borderColor = b.color || '#00e5ff';
const label = document.createElement('div');
label.className = 'label';
label.textContent = (b.label || '') + ' [' + [x, y, w, h].map(Math.round).join(',') + ']';
div.appendChild(label);
host.appendChild(div);
}
scheduleClear();
}

window.htmlMask.onMessage = function (msg) {
if (!msg) return;
if (msg.url === '/boxes/update') render(msg);
else if (msg.url === '/boxes/clear') {
clearTimeout(holdTimer);
document.getElementById('boxes').innerHTML = '';
}
};
</script>
</body>
</html>
213 changes: 213 additions & 0 deletions repo/js/AutoTrainingPlanFarm/main.js
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
achenjins marked this conversation as resolved.

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();
}
})();
22 changes: 22 additions & 0 deletions repo/js/AutoTrainingPlanFarm/manifest.json
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions repo/js/AutoTrainingPlanFarm/settings.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
]
Loading