383 lines
13 KiB
JavaScript
383 lines
13 KiB
JavaScript
eval(file.readTextSync("lib/ocr.js"));
|
||
|
||
const rewardIcon = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/RewardIcon.png"), 1430, 760, 100, 70);
|
||
const tavernRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/TavernIcon.png"), 800, 450, 500, 330);
|
||
const adventurersRo = RecognitionObject.TemplateMatch(file.ReadImageMatSync("assets/AdventurersGuild.png"), 800, 450, 500, 330);
|
||
|
||
const stateMachine = {
|
||
"任意界面": { "猫尾酒馆进门": teleportToTheCatsTail },
|
||
"猫尾酒馆进门": { "邀请界面": gotoInvitationPanel },
|
||
"邀请界面": { "牌组编辑器界面": gotoDeckDesignPanel, "对战": startChallenge },
|
||
"牌组编辑器界面": { "邀请界面": goBackInvitationPanel },
|
||
"对战": {}, // 对战结束后将状态维护至 猫尾酒馆进门
|
||
};
|
||
let currentState = "任意界面";
|
||
|
||
const outFile = "卡牌熟练度.json";
|
||
let allProficiencys = {};
|
||
try {
|
||
allProficiencys = JSON.parse(file.readTextSync(outFile));
|
||
} catch (error) {
|
||
log.debug("历史熟练度文件不存在");
|
||
}
|
||
|
||
(async function () {
|
||
setGameMetrics(1920, 1080, 1.25);
|
||
log.info("首次运行前请阅读脚本说明");
|
||
log.info("请确保已进入双人联机模式");
|
||
await sleep(1000);
|
||
|
||
let runMode = settings.runMode;
|
||
if (!runMode) {
|
||
runMode = "作为主账号:速刷熟练度";
|
||
log.info("未配置运行模式,默认以{0}模式运行", runMode);
|
||
}
|
||
|
||
if (runMode === "作为主账号:速刷熟练度") {
|
||
await runAsMain(settings.targetProficiency);
|
||
} else if (runMode === "作为辅助账号:快速投降") {
|
||
await runAsPartner();
|
||
} else if (runMode === "扫描当前账号卡牌熟练度") {
|
||
currentState = await getCurrentState();
|
||
await goToTargetState("牌组编辑器界面");
|
||
allProficiencys = await scanCardsProficiency(true);
|
||
file.writeTextSync(outFile, JSON.stringify(allProficiencys, null, 2));
|
||
log.info("卡牌熟练度数据已写入{0}", outFile);
|
||
} else {
|
||
log.error("不支持的运行模式: {0}", runMode);
|
||
}
|
||
})();
|
||
|
||
// 需要刷熟练度的角色的执行逻辑
|
||
async function runAsMain(targetProficiency) {
|
||
log.info("主账号开始工作,如需停止请按下你设置的BetterGI停止按键");
|
||
if (!targetProficiency) {
|
||
targetProficiency = 30;
|
||
}
|
||
let loopCount = -1;
|
||
|
||
currentState = await getCurrentState();
|
||
log.info("当前界面: {0}", currentState);
|
||
await goToTargetState("牌组编辑器界面");
|
||
let teamProficiencys = await scanCardsProficiency(false);
|
||
loopCount = targetProficiency - Math.min(...Object.values(teamProficiencys));
|
||
if (loopCount > 0) {
|
||
log.info("需重复执行{0}次以达成{1}熟练度", loopCount, targetProficiency);
|
||
await goToTargetState("邀请界面");
|
||
}
|
||
|
||
while (loopCount > 0) {
|
||
await goToTargetState("对战");
|
||
try {
|
||
await sleep(10);
|
||
} catch (error) {
|
||
log.info("用户停止运行");
|
||
break;
|
||
}
|
||
loopCount--;
|
||
log.info(`对局结束,当前熟练度 ${targetProficiency - loopCount}/${targetProficiency}`);
|
||
currentState = "猫尾酒馆进门";
|
||
await waitTpFinish();
|
||
};
|
||
|
||
// 执行过对战,且对战结束
|
||
if (loopCount === 0 && currentState === "猫尾酒馆进门") {
|
||
await goToTargetState("牌组编辑器界面");
|
||
teamProficiencys = await scanCardsProficiency(false, true);
|
||
}
|
||
Object.assign(allProficiencys, teamProficiencys);
|
||
recommendNextTeam(targetProficiency);
|
||
file.writeTextSync(outFile, JSON.stringify(allProficiencys, null, 2));
|
||
}
|
||
|
||
// 小号的执行逻辑
|
||
async function runAsPartner() {
|
||
log.info("辅助账号开始工作,如需停止请按下你设置的BetterGI停止按键");
|
||
while (true) {
|
||
log.info("等待对局邀请");
|
||
await waitForTextAppear("点击进行准备", [820, 70, 296, 31], 1800000);
|
||
keyPress("Y");
|
||
await recognizeTextAndClick("接受", [1177, 713, 82, 55]);
|
||
|
||
log.info("等待加载");
|
||
await waitForTextAppear("七圣召唤", [902, 870, 119, 46]);
|
||
|
||
await waitForTextAppear("请选择要替换的手牌", [847, 232, 229, 39], 30000);
|
||
log.info("选择初始手牌");
|
||
await recognizeTextAndClick("确定", [937, 922, 82, 54], 30000);
|
||
|
||
log.info("等待进入牌桌界面");
|
||
await waitForTextAppear("出战角色", [1766, 850, 118, 43]);
|
||
|
||
log.info("认输");
|
||
click(1864, 46);
|
||
await sleep(500);
|
||
await recognizeTextAndClick("放弃对局", [1543, 172, 112, 43]);
|
||
await waitForTextAppear("确定要放弃", [785, 497, 339, 39]);
|
||
await recognizeTextAndClick("确认", [1142, 732, 78, 51]);
|
||
|
||
await waitForTextAppear("退出挑战", [913, 893, 130, 43], 60000);
|
||
await sleep(300);
|
||
await recognizeTextAndClick("退出挑战", [913, 893, 130, 43]);
|
||
log.info("退出挑战");
|
||
|
||
try {
|
||
await sleep(3000);
|
||
} catch (error) {
|
||
log.info("用户停止运行");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 从刚传送至猫尾酒馆的状态前往邀请界面 */
|
||
async function gotoInvitationPanel() {
|
||
keyDown("MBUTTON");
|
||
await sleep(500);
|
||
|
||
keyDown("A");
|
||
await sleep(1500);
|
||
keyUp("A");
|
||
|
||
await sleep(1000);
|
||
await keyMouseScript.runFile(`assets/ALT点击.json`);
|
||
await sleep(500);
|
||
await recognizeTextAndClick("联机对局", [1217, 411, 220, 280]);
|
||
await sleep(500);
|
||
await keyMouseScript.runFile(`assets/ALT释放.json`);
|
||
}
|
||
|
||
/** 从邀请界面前往牌组编辑器界面 */
|
||
async function gotoDeckDesignPanel() {
|
||
click(1306, 747);
|
||
log.info("等待进入牌组界面");
|
||
await recognizeTextAndClick("编辑牌组", [733, 997, 129, 48]);
|
||
await waitForTextAppear("更改牌组外观", [1592, 186, 139, 35]);
|
||
}
|
||
|
||
/** 从牌组编辑器界面返回邀请界面 */
|
||
async function goBackInvitationPanel() {
|
||
keyPress("VK_ESCAPE");
|
||
await waitForTextAppear("更改牌组外观", [1592, 186, 139, 35]);
|
||
keyPress("VK_ESCAPE");
|
||
await waitForTextAppear("编辑牌组", [733, 997, 129, 48]);
|
||
keyPress("VK_ESCAPE");
|
||
await waitForTextAppear("我方出战牌组", [1248, 688, 156, 36]);
|
||
}
|
||
|
||
/** 从邀请界面进入对战模式(并完成对战) */
|
||
async function startChallenge() {
|
||
log.info("邀请队友");
|
||
for (let i = 0; i < 60; i++) {
|
||
await recognizeTextAndClick("邀请队友", [1332, 886, 130, 49]);
|
||
let r = await waitForTextAppear("正在匹配对局", [870, 385, 184, 42], 500);
|
||
if (r.success) {
|
||
break;
|
||
}
|
||
await sleep(500);
|
||
}
|
||
|
||
log.info("等待对方同意");
|
||
await waitForTextAppear("正在匹配对局", [871, 386, 182, 39], 30000);
|
||
|
||
log.info("等待加载");
|
||
await waitForTextAppear("七圣召唤", [902, 870, 119, 46]);
|
||
|
||
await waitForTextAppear("请选择要替换的手牌", [847, 232, 229, 39], 30000);
|
||
log.info("选择初始手牌");
|
||
await recognizeTextAndClick("确定", [937, 922, 82, 54], 30000);
|
||
|
||
log.info("等待对方认输");
|
||
await waitForTextAppear("退出挑战", [913, 893, 130, 43], 60000);
|
||
await sleep(300);
|
||
await recognizeTextAndClick("退出挑战", [913, 893, 130, 43]);
|
||
|
||
currentState = "猫尾酒馆进门";
|
||
}
|
||
|
||
function recommendNextTeam(targetProficiency) {
|
||
log.info("当前牌组中卡牌已达成熟练度目标,请更换牌组内角色");
|
||
const nextTeam = Object.fromEntries(
|
||
Object.entries(allProficiencys)
|
||
.filter(([_, v]) => v < targetProficiency)
|
||
.slice(0, 3)
|
||
);
|
||
if (Object.keys(nextTeam).length > 0) {
|
||
let text = JSON.stringify(nextTeam).replace(/[{}"]/g, "");
|
||
log.info("熟练度未满的角色推荐: {0}", text);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 等待传送结束
|
||
* @param {Int} timeout 单位为ms
|
||
* @note 参考了七圣召唤七日历练脚本
|
||
*/
|
||
async function waitTpFinish(timeout = 30000) {
|
||
const region = RecognitionObject.ocr(1690, 230, 75, 350); // 队伍名称区域
|
||
const startTime = new Date();
|
||
|
||
await sleep(1000); //点击传送后等待一段时间避免误判
|
||
while (new Date() - startTime < timeout) {
|
||
let res = captureGameRegion().find(region);
|
||
if (!res.isEmpty()) {
|
||
await sleep(600); //传送结束后有僵直
|
||
return;
|
||
}
|
||
await sleep(100);
|
||
}
|
||
throw new Error("传送时间超时");
|
||
}
|
||
|
||
/** 传送到猫尾酒馆并等待传送后摇结束 */
|
||
async function teleportToTheCatsTail() {
|
||
await genshin.moveMapTo(-867, 2281, "蒙德");
|
||
await genshin.setBigMapZoomLevel(1.0);
|
||
let clickIcon = null;
|
||
for (let i = 0; i < 5; i++) {
|
||
const region = captureGameRegion();
|
||
const tarvern = region.find(tavernRo);
|
||
clickIcon = tarvern.isExist() ? tarvern : region.find(adventurersRo);
|
||
if (clickIcon.isExist()) {
|
||
clickIcon.click();
|
||
await sleep(500);
|
||
break;
|
||
}
|
||
await sleep(500);
|
||
}
|
||
if (!(clickIcon && clickIcon.isExist())) {
|
||
throw new Error("找不到猫尾酒馆,如果2P标志遮挡了酒馆图标,请将2P玩家传送至别处");
|
||
}
|
||
await recognizeTextAndClick("猫尾酒馆", [1320, 560, 300, 410]);
|
||
await recognizeTextAndClick("传送至", [1580, 980, 225, 59]);
|
||
await waitTpFinish();
|
||
}
|
||
|
||
async function getCurrentState() {
|
||
let window = captureGameRegion();
|
||
const invite = window.find(RecognitionObject.ocr(1248, 688, 156, 36));
|
||
if (invite.text && invite.text.includes("我方出战牌组")) {
|
||
return "邀请界面";
|
||
}
|
||
const deck = window.find(RecognitionObject.ocr(1592, 186, 139, 35));
|
||
if (deck.text && deck.text.includes("更改牌组外观")) {
|
||
return "牌组编辑器界面";
|
||
}
|
||
return "任意界面";
|
||
}
|
||
|
||
async function scanCardsProficiency(scanAll = false, getReward = false) {
|
||
let proficiencys = {};
|
||
let last_char = "";
|
||
let character = null;
|
||
let retry = 0;
|
||
|
||
for (let i = 0; i < 5; i++) {
|
||
if (scanAll) {
|
||
click(198, 717);
|
||
} else {
|
||
click(700, 276);
|
||
}
|
||
let r = await waitForTextAppear("卡牌", [867, 266, 68, 47], 500);
|
||
if (r.success) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
while (true) {
|
||
let captureRegion = captureGameRegion();
|
||
|
||
if (getReward) {
|
||
const icon = captureRegion.find(rewardIcon);
|
||
if (icon.isExist()) {
|
||
icon.click();
|
||
await sleep(200);
|
||
icon.click();
|
||
await sleep(200);
|
||
}
|
||
}
|
||
|
||
let proficiency = null;
|
||
let proficiencyResult = captureRegion.find(RecognitionObject.ocr(855, 780, 210, 40));
|
||
if (proficiencyResult.text) {
|
||
const match = proficiencyResult.text.match(/(\d+)/);
|
||
proficiency = parseInt(match[0]);
|
||
}
|
||
|
||
character = null;
|
||
let ch_result = captureRegion.find(RecognitionObject.ocr(851, 326, 398, 65));
|
||
if (ch_result.text) {
|
||
character = ch_result.text.trim().replace(/t/g, "七");
|
||
} else {
|
||
retry++;
|
||
if (retry >= 3) {
|
||
const skill = captureRegion.find(RecognitionObject.ocr(897, 441, 300, 58));
|
||
if (skill.text) {
|
||
log.warn("OCR无法识别当前角色名称,使用技能名作为替代");
|
||
character = `?${skill.text.trim()}`;
|
||
} else {
|
||
log.warn("OCR无法识别当前角色名称,跳过");
|
||
click(1635, 538);
|
||
retry = 0;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (last_char === character) {
|
||
log.info("已到达角色列表末尾");
|
||
keyPress("VK_ESCAPE");
|
||
await sleep(400);
|
||
break;
|
||
}
|
||
if (proficiency !== null && character !== null) {
|
||
proficiencys[character] = proficiency;
|
||
log.info("{ch} = {v}", character, proficiency);
|
||
click(1635, 538);
|
||
retry = 0;
|
||
last_char = character;
|
||
}
|
||
await sleep(400);
|
||
}
|
||
|
||
const sortedProficiencys = Object.entries(proficiencys).sort((a, b) => b[1] - a[1]);
|
||
proficiencys = Object.fromEntries(sortedProficiencys);
|
||
return proficiencys;
|
||
}
|
||
|
||
/**
|
||
* 使用BFS方法计算应当如何到达目标状态
|
||
*/
|
||
function searchPathWithBFS(start, target) {
|
||
const queue = [[start, []]];
|
||
const visited = new Set();
|
||
|
||
while (queue.length > 0) {
|
||
const [current, ops] = queue.shift();
|
||
if (current === target) return ops;
|
||
if (visited.has(current)) continue;
|
||
visited.add(current);
|
||
|
||
const transitions = stateMachine[current] || {};
|
||
for (const [nextState, op] of Object.entries(transitions)) {
|
||
queue.push([nextState, [...ops, op]]);
|
||
}
|
||
}
|
||
|
||
// 无法到达时走最保守的路径
|
||
if (start !== "任意界面") {
|
||
return searchPathWithBFS("任意界面", target);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function goToTargetState(target) {
|
||
// log.info("切换状态: {0} -> {1}", currentState, target);
|
||
const pathList = searchPathWithBFS(currentState, target);
|
||
for (const fn of pathList) {
|
||
// log.info("执行函数: {0}", fn.name);
|
||
await fn();
|
||
}
|
||
currentState = target;
|
||
}
|