Files
Patrick-Ze c437b65dde js: CD-Aware-AutoGather: 修复两个corner case (#2863)
Fixes #2855, Fixes #2861: 新地图无法获取坐标时改用时间差判断是否更新CD
Fixes #2860: 修复背包物品与API物品名不匹配时脚本异常终止的问题
2026-02-11 21:04:58 +08:00

450 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @author Ayaka-Main
* @link https://github.com/Patrick-Ze
* @description 对背包API的易用包装。使用方法: 将此文件放在脚本目录下的 lib 文件夹中,然后在你的脚本开头处执行下面这行:
eval(file.readTextSync("lib/inventory.js"));
*/
let scriptContext = {
version: "1.1",
};
// 原本是csv格式但是为了方便js重用还是内置在代码中
const csvText = `物品,刷新机制,背包分类
「冷鲜肉」,12小时,材料
发光髓,12小时,材料
蝴蝶翅膀,12小时,材料
晶蝶,12小时,材料
晶核,12小时,材料
鳗肉,12小时,材料
螃蟹,12小时,材料
禽肉,12小时,材料
青蛙,12小时,材料
鳅鳅宝玉,12小时,材料
神秘的肉,12小时,材料
兽肉,12小时,材料
蜥蜴尾巴,12小时,材料
鱼肉,12小时,材料
沉玉仙茗,24小时,材料
便携轴承,46小时,材料
冰雾花花朵,46小时,材料
苍晶螺,46小时,材料
赤念果,46小时,材料
初露之源,46小时,材料
悼灵花,46小时,材料
嘟嘟莲,46小时,材料
绯樱绣球,46小时,材料
风车菊,46小时,材料
钩钩果,46小时,材料
鬼兜虫,46小时,材料
海灵芝,46小时,材料
海露花,46小时,材料
虹彩蔷薇,46小时,材料
湖光铃兰,46小时,材料
劫波莲,46小时,材料
晶化骨髓,46小时,材料
绝云椒椒,46小时,材料
枯叶紫英,46小时,材料
浪沫羽鳃,46小时,材料
烈焰花花蕊,46小时,材料
琉璃百合,46小时,材料
琉璃袋,46小时,材料
琉鳞石,46小时,材料
落落莓,46小时,材料
鸣草,46小时,材料
慕风蘑菇,46小时,材料
霓裳花,46小时,材料
帕蒂沙兰,46小时,材料
蒲公英籽,46小时,材料
奇异的「牙齿」,46小时,材料
青蜜莓,46小时,材料
清水玉,46小时,材料
清心,46小时,材料
柔灯铃,46小时,材料
肉龙掌,46小时,材料
塞西莉亚花,46小时,材料
沙脂蛹,46小时,材料
珊瑚真珠,46小时,材料
圣金虫,46小时,材料
石珀,46小时,材料
树王圣体菇,46小时,材料
霜盏花,46小时,材料
天云草实,46小时,材料
万相石,46小时,材料
微光角菌,46小时,材料
小灯草,46小时,材料
星螺,46小时,材料
血斛,46小时,材料
夜泊石,46小时,材料
幽灯蕈,46小时,材料
幽光星星,46小时,材料
月莲,46小时,材料
月落银,46小时,材料
冬凌草,46小时,材料
松珀香,46小时,材料
云岩裂叶,46小时,材料
灼灼彩菊,46小时,材料
子探测单元,46小时,材料
白铁块,每2天0点,材料
电气水晶,每2天0点,材料
星银矿石,每2天0点,材料
萃凝晶,每3天0点,材料
虹滴晶,每3天0点,材料
水晶块,每3天0点,材料
紫晶块,每3天0点,材料
白灵果,每天0点,材料
白萝卜,每天0点,材料
薄荷,每天0点,材料
澄晶实,每天0点,材料
墩墩桃,每天0点,材料
海草,每天0点,材料
寒涌石,每天0点,材料
红果果菇,每天0点,材料
胡萝卜,每天0点,材料
金鱼草,每天0点,材料
堇瓜,每天0点,材料
烬芯花,每天0点,材料
久雨莲,每天0点,材料
颗粒果,每天0点,材料
苦种,每天0点,材料
莲蓬,每天0点,材料
马尾,每天0点,材料
蘑菇,每天0点,材料
茉洁草,每天0点,材料
鸟蛋,每天0点,材料
树莓,每天0点,材料
松果,每天0点,材料
松茸,每天0点,材料
宿影花,每天0点,材料
甜甜花,每天0点,材料
铁块,每天0点,材料
汐藻,每天0点,材料
夏槲果,每天0点,材料
香辛果,每天0点,材料
须弥蔷薇,每天0点,材料
枣椰,每天0点,材料
竹笋,每天0点,材料
泡泡桔,每天0点,食物
苹果,每天0点,食物
日落果,每天0点,食物
星蕈,每天0点,食物
烛伞蘑菇,每天0点,食物
`
// 材料文件夹名: 背包物品名准确地说是API返回的材料名
const renameMap = { "晶蝶": "晶核", "「冷鲜肉」": "冷鲜肉"};
const supportFile = "native_supported.json";
const materialMetadata = {};
function parseCsvTextToDict() {
// 1. 将文本按行分割成数组
const lines = csvText.trim().split("\n");
// 预期标题是:['物品', '刷新机制', '背包分类']
const headers = lines[0].split(",").map((header) => header.trim());
// 检查标题是否符合预期,防止解析错误
if (headers[0] !== "物品" || headers.length < 3) {
console.error("CSV格式的标题行不符合预期。");
return {};
}
// 确定我们需要的值对应的索引
const cdIndex = headers.indexOf("刷新机制");
const typeIndex = headers.indexOf("背包分类");
// 3. 处理数据行,构建结果字典
const resultDict = {};
// 从第二行(索引 1开始遍历数据
const typeMap = {"食物": "Food", "材料": "Materials"};
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (line === "") continue; // 跳过空行
// 将数据行按逗号分割
const values = line.split(",").map((value) => value.trim());
// 确保数据行有足够的值
if (values.length > Math.max(cdIndex, typeIndex) && values[0]) {
const itemName = values[0];
const cdValue = values[cdIndex];
const typeValue = typeMap[values[typeIndex]];
// 存入结果字典,以物品名为 key
resultDict[itemName] = {
cd: cdValue,
type: typeValue,
};
}
}
return resultDict;
}
/**
* 获取需要手动扫描的材料及其对应路径
* @returns {Object} 格式为 { "材料名": "文件完整路径" } 的对象
*/
function getManualOcrMaterials() {
const scanDir = "assets/images/CustomScan";
const items = file.ReadPathSync(scanDir);
// 1. 读取本地记录文件,获取已支持列表
let native_supported = {};
try {
const content = readTextSync(supportFile);
if (content) {
native_supported = JSON.parse(content);
}
} catch (error) {
log.debug(`读取记录文件失败: ${error.toString()}`);
}
// 2. 核心逻辑:建立材料名与路径的映射关系,并过滤已支持材料
const manualDict = {};
for (const itemPath of items) {
// 提取材料名(不含扩展名)
const itemName = splitExt(basename(itemPath))[0];
// 差集计算:只有当 BetterGI 不支持该材料时,才加入待扫描字典
if (!native_supported.hasOwnProperty(itemName)) {
manualDict[itemName] = itemPath;
}
}
return manualDict;
}
const manualOcrMaterials = getManualOcrMaterials();
async function manualOcr(normalizedItemList, shouldStop, sharedResults) {
// 1. 预加载:将所有待识别材料的模板初始化并存储,避免在循环中重复读取文件
const templates = {};
let remainingItems = [];
for (const itemName of normalizedItemList) {
const mat = file.readImageMatSync(manualOcrMaterials[itemName]);
const ro = RecognitionObject.TemplateMatch(mat);
ro.threshold = 0.85;
templates[itemName] = ro;
remainingItems.push(itemName); // 加入待扫描清单
}
const _parseInt = (value, defaultValue = 0) => {
const parsed = parseInt(value.trim(), 10);
return Number.isNaN(parsed) ? defaultValue : parsed;
};
if (remainingItems.length > 0) {
log.info(`将通过补充OCR识别{0}等{1}类物品的数量`, remainingItems[0], remainingItems.length);
}
// 2. 扫描循环:直到所有材料都找到,或者当前画面不再有新目标
while (remainingItems.length > 0 && !shouldStop()) {
// 使用 withCapture 确保每一帧 region 都能正确 dispose
const foundInThisFrame = await withCapture(async (region) => {
const foundList = [];
for (const itemName of remainingItems) {
const result = region.find(templates[itemName]);
if (result.isExist()) {
// 计算 OCR 区域坐标, 抄了 吉吉喵 大佬的方法
const tolerance = 1;
const rect = {
x: result.x - tolerance,
y: result.y + 97 - tolerance,
width: 66 + 2 * tolerance,
height: 22 + 2 * tolerance,
};
// drawRegion(rect); // 调试用
let ocrResult = region.find(RecognitionObject.ocr(rect.x, rect.y, rect.width, rect.height));
if (ocrResult && ocrResult.text) {
const count = _parseInt(ocrResult.text, -2);
sharedResults[itemName] = count;
log.info("{0}: {1}", itemName, count);
foundList.push(itemName); // 记录本帧识别到的条目
}
}
}
return foundList; // 将本帧找到的列表返回给外部
});
// 3. 更新剩余待扫描列表
if (foundInThisFrame.length > 0) {
remainingItems = remainingItems.filter((item) => !foundInThisFrame.includes(item));
}
if (remainingItems.length > 0 && !shouldStop()) {
await sleep(100); // 如果仍有剩余材料,短暂停顿
} else {
break; // 全部找到,退出循环
}
}
return sharedResults;
}
/**
* 获取背包中物品的数量。
* 如果没有找到,则为-1如果找到了但数字识别失败则为-2
*/
async function getItemCount(itemList=null) {
if (Object.keys(materialMetadata).length === 0) {
Object.assign(materialMetadata, parseCsvTextToDict());
}
if (typeof itemList === "string") {
itemList = [itemList];
} else if (itemList == null || itemList.length === 0) {
itemList = Object.keys(materialMetadata);
}
const normalizedItemList = itemList.map((itemName) => {
return renameMap[itemName] || itemName;
});
const result = await mainScan(normalizedItemList);
return result;
}
/**
* 合并扫描结果并实现特征库自动更新(自愈)
* @param {Object} nativeResults - BetterGI 返回的结果 { "鸣草": 10, ... }
* @param {Object} manualResults - 手动 OCR 返回的结果 { "新材料": 5, ... }
* @param {string[]} targetManualList - 本次扫描中,原本判定为需要“手动处理”的材料名清单
* @returns {Object} 合并后的最终清单
*/
function mergeAndAutoRepair(nativeResults, manualResults, manualCandidateList) {
// 1. 以原生结果为基础,合并手动结果
const finalData = { ...nativeResults };
const newlySupported = [];
for (const name in manualResults) {
// 如果 nativeResults 里没有这个材料,采用手动结果
if (!finalData[name] || finalData[name] < 0) {
finalData[name] = manualResults[name];
}
}
// 2. 自愈检查:遍历原本以为需要手动的列表
for (const name of manualCandidateList) {
// 如果 BetterGI 的返回结果里包含了这个材料说明特征库更新了
if (nativeResults.hasOwnProperty(name) && nativeResults[name] > 0) {
newlySupported.push(name);
}
}
// 3. 更新本地记录文件
if (newlySupported.length > 0) {
updateNativeSupportedJson(newlySupported);
}
return finalData;
}
/**
* 更新本地受支持列表的持久化存储
*/
function updateNativeSupportedJson(newItems) {
let registry = {};
try {
registry = JSON.parse(readTextSync(supportFile) || "{}");
} catch(e) {}
const today = new Date().toISOString().split('T')[0];
newItems.forEach(item => {
registry[item] = today;
log.info(`发现{0}已被内置接口支持未来将跳过它的补充OCR`, item);
});
file.WriteTextSync(supportFile, JSON.stringify(registry, null, 2));
}
async function mainScan(normalizedItemList) {
const sharedOcrResults = {}; // 创建共享对象
let isApiFinished = false;
const manualList = normalizedItemList.filter(name => manualOcrMaterials.hasOwnProperty(name));
// 并发启动
const apiPromise = getItemCountWithApi(normalizedItemList, sharedOcrResults)
.finally(() => isApiFinished = true);
const manualPromise = manualOcr(manualList, () => isApiFinished, sharedOcrResults);
// 等待全部完成
const [apiResults, _] = await Promise.all([apiPromise, manualPromise]);
// 最后合并结果(此时 sharedOcrResults 已经包含了所有 OCR 成功识别的内容)
const results = mergeAndAutoRepair(apiResults, sharedOcrResults, manualList);
const finalResults = {};
const reverseRenameMap = Object.entries(renameMap).reduce((acc, [key, value]) => {
acc[value] = key;
return acc;
}, {});
for (const itemName of normalizedItemList) {
// 如果某个元素没有找到,则不会存在对应的键值,赋值为-1以保持与单个物品查找时一致的行为
const originName = reverseRenameMap[itemName] || itemName;
finalResults[originName] = results[itemName] ?? -1;
}
return finalResults;
}
async function getItemCountWithApi(normalizedItemList, sharedOcrResults) {
const groupByType = {};
for (const itemName of normalizedItemList) {
const metadata = materialMetadata[itemName];
const itemType = metadata?.type ?? "Materials";
if (!metadata?.type) {
log.warn("未查找到{0}所属的背包分类,默认它是{1}", itemName, itemType);
}
groupByType[itemType] = groupByType[itemType] || [];
groupByType[itemType].push(itemName);
}
let results = {};
for (const type in groupByType) {
const names = groupByType[type];
const countResult = await dispatcher.runTask(new SoloTask("CountInventoryItem", {
"gridScreenName": type,
"itemNames": names,
}));
Object.assign(results, countResult);
}
// 逻辑:既不在 API 的结果里,也不在 OCR 已经实时识别到的结果里
const missingItems = normalizedItemList.filter(name =>
!(name in results) && !(name in sharedOcrResults)
);
if (missingItems.length > 0) {
// 即使在白天,大多数情况也能识别成功,因此不作为常态机制,仅在失败时使用
log.info(`${missingItems.length}个物品识别失败,调整时间和视角后重试`);
await genshin.returnMainUi();
await genshin.setTime(0, 0);
await sleep(300);
moveMouseBy(0, 9280);
await sleep(300);
// 只针对缺失的物品进行重试
for (const type in groupByType) {
const namesToRetry = groupByType[type].filter(name => missingItems.includes(name));
if (namesToRetry.length > 0) {
const retryCountResult = await dispatcher.runTask(new SoloTask("CountInventoryItem", {
"gridScreenName": type,
"itemNames": namesToRetry,
}));
// 将重试结果合并到原始 results 中
Object.assign(results, retryCountResult);
}
}
// 恢复视角
await genshin.returnMainUi();
keyPress("MBUTTON");
}
return results;
}
function getItemCD(itemName) {
if (Object.keys(materialMetadata).length === 0) {
Object.assign(materialMetadata, parseCsvTextToDict());
}
return materialMetadata[itemName]?.cd || null;
}