diff --git a/repo/js/MiliastraExperiencePlayback/README.md b/repo/js/MiliastraExperiencePlayback/README.md new file mode 100644 index 000000000..0e026f85e --- /dev/null +++ b/repo/js/MiliastraExperiencePlayback/README.md @@ -0,0 +1,50 @@ +# 🌌 千星奇域·每周经验刷取(回放通关版) + +### 🌟 功能介绍 + +- 🔁 自动重复通关指定的奇域关卡。 +- 🎥 依据用户预先录制的多个通关流程,随机选取一个执行通关。 +- 🧺 支持删除关卡存档后重新挑战(默认关闭),从而提升刷取效率。 +- 📅 自动追踪每周经验获取进度,达到上限后再次执行将自动跳过。 +- 🏞️ 执行结束后自动返回提瓦特大陆,避免影响其他自动化流程。 + +## 📋 使用必读 + +- 请勿在禁止联机的区域(如银月之庭等)运行本脚本,否则将无法运行。 +- 如果开启“删除关卡存档”功能, **建议提前清理无用存档** ,保持存档列表简洁,以提升查找效率、减少误差。 +- 若内置的通关流程失效,请使用程序的 **[录制回放]** 功能,自行录制 **进入关卡后的通关流程** 。 +- 录制完成后,打开录制回放所在目录,将录制的脚本拷贝到本脚本目录下的 `assets/playbacks` 文件夹中。 +- 右键编辑脚本配置,根据你所录制的流程调整相关设置(包括奇域关卡、通关回放文件池、每次通关获取的经验值等)。 +- 建议选择人气较高、不易下架、流程简单的单人游玩关卡进行录制(例如石头模拟器、风景打卡类、抽卡模拟器等)。 +- 根据成就完成数量的不同,每次通关可额外获得 **50~250** 点经验值。内置的通关流程设计相对保守,每次通关可获得 20 点基础经验 + 50 点成就经验。 +- ~~人有多大胆,地有多大产~~。**建议适当延长录制时的通关时间**,以降低关卡下架风险和自身运行风险。 + +### 📖 使用方法 + +- 将本脚本添加至您的配置组中。 +- 右键点击脚本自定义配置项。 +- 配置好计划通关的关卡信息、通关回放文件等。 +- 点击运行按钮。 + +### 🛠️ 脚本配置 + +| 配置项 | 描述 | 备注 | 默认值 | +| ---------------------- | ------------------------ | ---------------------------------------- | ----------------------------- | +| room | 奇域关卡关键词或关卡GUID | | 20134075027 | +| playbacks | 通关回放文件池 | 自行录制拷贝到assets/playbacks,逗号分隔 | 通关回放1.json,通关回放2.json | +| expPerAttempt | 每次通关获取的经验值 | 如果勾选删除关卡存档,请自行增加 | 20 | +| deleteStageSave | 删除关卡存档 | 可重复达成成就,获取更多经验值 | false | +| deleteStageSaveKeyword | 删除关卡存档关键字 | 关卡存档视图中的[关卡]列 | 深渊100层 | +| expWeeklyLimit | 每周可获取的经验值上限 | | 4000 | +| force | 忽略本周经验值已达上限 | | false | +| thisAttempts | 指定通关次数 | 0表示自动判断 | 0 | +| goToTeyvat | 完成后返回提瓦特大陆 | | true | + +### ❗ 注意事项 + +- 请确保游戏窗口分辨率比例为 `16:9`,否则可能影响脚本正常运行。 +- 建议提前设置好快捷键,便于在需要时快速暂停或终止脚本。 + +## 🐛 问题反馈 + +[breadgrocery](https://github.com/breadgrocery/miliastra-experience-playback) diff --git a/repo/js/MiliastraExperiencePlayback/assets/Checkbox_Checked.png b/repo/js/MiliastraExperiencePlayback/assets/Checkbox_Checked.png new file mode 100644 index 000000000..08cfcd7a7 Binary files /dev/null and b/repo/js/MiliastraExperiencePlayback/assets/Checkbox_Checked.png differ diff --git a/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Beyond_Hall.png b/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Beyond_Hall.png new file mode 100644 index 000000000..0e9d0b73f Binary files /dev/null and b/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Beyond_Hall.png differ diff --git a/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Beyond_Recommend.png b/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Beyond_Recommend.png new file mode 100644 index 000000000..1aa058e64 Binary files /dev/null and b/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Beyond_Recommend.png differ diff --git a/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Close.png b/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Close.png new file mode 100644 index 000000000..d45f8d314 Binary files /dev/null and b/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Close.png differ diff --git a/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Gacha.png b/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Gacha.png new file mode 100644 index 000000000..57e7cbfb2 Binary files /dev/null and b/repo/js/MiliastraExperiencePlayback/assets/UI_BtnIcon_Gacha.png differ diff --git a/repo/js/MiliastraExperiencePlayback/assets/UI_Icon_Leave.png b/repo/js/MiliastraExperiencePlayback/assets/UI_Icon_Leave.png new file mode 100644 index 000000000..176894ec0 Binary files /dev/null and b/repo/js/MiliastraExperiencePlayback/assets/UI_Icon_Leave.png differ diff --git a/repo/js/MiliastraExperiencePlayback/assets/UI_Icon_Leave_Right.png b/repo/js/MiliastraExperiencePlayback/assets/UI_Icon_Leave_Right.png new file mode 100644 index 000000000..849e5ffd8 Binary files /dev/null and b/repo/js/MiliastraExperiencePlayback/assets/UI_Icon_Leave_Right.png differ diff --git a/repo/js/MiliastraExperiencePlayback/assets/playbacks/通关回放1.json b/repo/js/MiliastraExperiencePlayback/assets/playbacks/通关回放1.json new file mode 100644 index 000000000..4703d4c4b --- /dev/null +++ b/repo/js/MiliastraExperiencePlayback/assets/playbacks/通关回放1.json @@ -0,0 +1,23 @@ +{ + "macroEvents": [ + { "type": 0, "keyCode": 65, "mouseX": 0, "mouseY": 0, "time": 547 }, + { "type": 0, "keyCode": 32, "mouseX": 0, "mouseY": 0, "time": 1109 }, + { "type": 1, "keyCode": 32, "mouseX": 0, "mouseY": 0, "time": 1218 }, + { "type": 1, "keyCode": 65, "mouseX": 0, "mouseY": 0, "time": 8015 }, + { "type": 0, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9188 }, + { "type": 1, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9344 }, + { "type": 0, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9531 }, + { "type": 1, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9703 }, + { "type": 0, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9906 }, + { "type": 1, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 10078 } + ], + "info": { + "name": "", + "description": "", + "x": 319, + "y": 278, + "width": 1920, + "height": 1080, + "recordDpi": 1.5 + } +} diff --git a/repo/js/MiliastraExperiencePlayback/assets/playbacks/通关回放2.json b/repo/js/MiliastraExperiencePlayback/assets/playbacks/通关回放2.json new file mode 100644 index 000000000..b7bca841e --- /dev/null +++ b/repo/js/MiliastraExperiencePlayback/assets/playbacks/通关回放2.json @@ -0,0 +1,23 @@ +{ + "macroEvents": [ + { "type": 0, "keyCode": 68, "mouseX": 0, "mouseY": 0, "time": 547 }, + { "type": 0, "keyCode": 32, "mouseX": 0, "mouseY": 0, "time": 1109 }, + { "type": 1, "keyCode": 32, "mouseX": 0, "mouseY": 0, "time": 1218 }, + { "type": 1, "keyCode": 68, "mouseX": 0, "mouseY": 0, "time": 8015 }, + { "type": 0, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9188 }, + { "type": 1, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9344 }, + { "type": 0, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9531 }, + { "type": 1, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9703 }, + { "type": 0, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 9906 }, + { "type": 1, "keyCode": 49, "mouseX": 0, "mouseY": 0, "time": 10078 } + ], + "info": { + "name": "", + "description": "", + "x": 319, + "y": 278, + "width": 1920, + "height": 1080, + "recordDpi": 1.5 + } +} diff --git a/repo/js/MiliastraExperiencePlayback/main.js b/repo/js/MiliastraExperiencePlayback/main.js new file mode 100644 index 000000000..f7f6958fb --- /dev/null +++ b/repo/js/MiliastraExperiencePlayback/main.js @@ -0,0 +1,905 @@ +/** + * Better Genshin Impact JavaScript + * Bundled with BetterGI CLI (https://www.npmjs.com/package/@bettergi/cli) + * + * This file is automatically generated and should not be edited. + */ + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/workflow.js +var defaultMaxAttempts = 5; +var defaultRetryInterval = 1e3; +var waitForAction = async (condition, retryAction, options) => { + const { maxAttempts = defaultMaxAttempts, retryInterval = defaultRetryInterval } = options || {}; + for (let i = 0; i < maxAttempts; i++) { + if (i === 0 && condition()) + return true; + await retryAction?.(); + await sleep(retryInterval); + if (condition()) + return true; + } + return false; +}; +var waitForRegionAppear = async (regionProvider, retryAction, options) => { + return waitForAction(() => { + const region = regionProvider(); + return region != null && region.isExist(); + }, retryAction, options); +}; +var waitForRegionDisappear = async (regionProvider, retryAction, options) => { + return waitForAction(() => { + const region = regionProvider(); + return !region || !region.isExist(); + }, retryAction, options); +}; + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/asserts.js +var assertRegionAppearing = async (regionProvider, message, retryAction, options) => { + const isAppeared = await waitForRegionAppear(regionProvider, retryAction, options); + if (!isAppeared) { + throw new Error(message); + } +}; +var assertRegionDisappearing = async (regionProvider, message, retryAction, options) => { + const isDisappeared = await waitForRegionDisappear(regionProvider, retryAction, options); + if (!isDisappeared) { + throw new Error(message); + } +}; + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/exception.js +var getErrorMessage = (err) => { + if (err && "message" in err && typeof err.message === "string") + return err.message; + return err && typeof err === "object" ? JSON.stringify(err) : "Unknown error"; +}; +var isHostException = (err) => { + return err && "hostException" in err; +}; + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/mouse.js +var simulateScroll = async (scrollAmountInClicks, times) => { + const script = { + macroEvents: Array(times).fill({ type: 6, mouseX: 0, mouseY: scrollAmountInClicks, time: 0 }), + info: { name: "", description: "", x: 0, y: 0, width: 1920, height: 1080, recordDpi: 1.5 } + }; + await keyMouseScript.run(JSON.stringify(script)); +}; +var mouseScrollDown = (height, algorithm = (h) => Math.floor(h / 18)) => { + return simulateScroll(-120, algorithm(height)); +}; +var mouseScrollDownLines = (lines, lineHeight = 175) => { + return mouseScrollDown(lines * lineHeight); +}; + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/ocr.js +var findImageWithinBounds = (image, x, y, w, h, config = {}) => { + const ir = captureGameRegion(); + try { + const mat = typeof image === "string" ? file.readImageMatSync(image) : image; + const ro = RecognitionObject.templateMatch(mat, x, y, w, h); + if (Object.keys(config).length > 0) { + Object.assign(ro, config) && ro.initTemplate(); + } + const region = ir.find(ro); + return region.isExist() ? region : void 0; + } catch (err) { + log.warn(`${err.message || err}`); + } finally { + ir.dispose(); + } +}; +var findFirst = (ir, ro, predicate) => { + const candidates = ir.findMulti(ro); + for (let i = 0; i < candidates.count; i++) { + if (predicate(candidates[i])) + return candidates[i]; + } + return void 0; +}; +var findTextWithinBounds = (text, x, y, w, h, options, config = {}) => { + const { ignoreCase = true, contains = false } = options || {}; + const searchText = ignoreCase ? text.toLowerCase() : text; + const ir = captureGameRegion(); + try { + const ro = RecognitionObject.ocr(x, y, w, h); + if (Object.keys(config).length > 0) { + Object.assign(ro, config) && ro.initTemplate(); + } + return findFirst(ir, ro, (region) => { + const itemText = ignoreCase ? region.text.toLowerCase() : region.text; + const isMatch = contains ? itemText.includes(searchText) : itemText === searchText; + return isMatch && region.isExist(); + }); + } catch (err) { + log.warn(`${err.message || err}`); + } finally { + ir.dispose(); + } +}; +var findTextWithinListView = async (text, listView, matchOptions, retryOptions, config = {}) => { + const { x, y, w, h, lineHeight, scrollLines = 1, paddingX = 10, paddingY = 10 } = listView; + const { maxAttempts = 30, retryInterval = 1e3 } = retryOptions || {}; + const findTargetText = () => findTextWithinBounds(text, x, y, w, h, matchOptions, config); + let lastTextRegion; + const isReachedBottom = () => { + const textRegion = findFirst(captureGameRegion(), RecognitionObject.ocr(x, y, w, h), (region) => { + return region.isExist() && region.text.trim().length > 0; + }); + if (textRegion) { + if (lastTextRegion?.text === textRegion.text && Math.abs(textRegion.y - lastTextRegion.y) < lineHeight) { + return true; + } else { + lastTextRegion = textRegion; + return false; + } + } + return true; + }; + const isTextFoundOrBottomReached = await waitForAction(() => findTargetText() != void 0 || isReachedBottom(), async () => { + moveMouseTo(x + w - paddingX, y + paddingY); + await sleep(50); + await mouseScrollDownLines(scrollLines, lineHeight); + }, { maxAttempts, retryInterval }); + return isTextFoundOrBottomReached ? findTargetText() : void 0; +}; + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/misc.js +var deepMerge = (...objects) => { + const isPlainObject = (input) => input?.constructor === Object; + return objects.reduce((result, obj) => { + return Object.entries(obj).reduce((acc, [key, value]) => { + const recursive = isPlainObject(acc[key]) && isPlainObject(value); + acc[key] = recursive ? deepMerge(acc[key], value) : value; + return acc; + }, result); + }, {}); +}; + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/time.js +var getNextMonday4AM = () => { + const now = /* @__PURE__ */ new Date(); + const result = new Date(now); + result.setHours(4, 0, 0, 0); + const currentDay = now.getDay(); + const daysUntilNextMonday = currentDay === 1 && now.getHours() < 4 ? 0 : 8 - currentDay; + result.setDate(now.getDate() + daysUntilNextMonday); + return result; +}; +var parseDuration = (duration) => { + return { + h: Math.floor(duration / 36e5), + m: Math.floor(duration % 36e5 / 6e4), + s: Math.floor(duration % 6e4 / 1e3), + ms: Math.floor(duration % 1e3) + }; +}; +var formatDurationAsClock = (duration) => { + return Object.values(parseDuration(duration)).slice(0, 3).map((num) => String(num).padStart(2, "0")).join(":"); +}; +var formatDurationAsReadable = (duration) => { + return Object.entries(parseDuration(duration)).filter(([, value]) => value > 0).map(([unit, value]) => `${value}${unit}`).join(" "); +}; + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/progress.js +var ProgressTracker = class { + total = 0; + current = 0; + startTime = Date.now(); + formatter; + interval; + lastPrintTime = 0; + constructor(total, config) { + const { formatter, interval = 3e3 } = config || {}; + this.total = total; + this.formatter = formatter || this.defaultFormatter; + this.interval = interval; + } + defaultFormatter = (logger, message, progress) => { + logger("[🚧 {pct} ⏳ {eta}]: {msg}", progress.formatted.percentage.padStart(6), progress.current > 0 && progress.elapsed > 0 ? progress.formatted.remaining : "--:--:--", message); + }; + tick(options) { + const { increment = 1, message, force = false } = options || {}; + this.current = Math.min(this.current + increment, this.total); + if (message) + this.print(message, force); + return this.current === this.total; + } + complete(message) { + this.current = this.total; + this.print(message, true); + } + reset() { + this.current = 0; + this.startTime = Date.now(); + this.lastPrintTime = 0; + } + print(message, force = false, logger = log.info) { + if (force || this.shouldPrint()) { + this.formatter(logger, message, this.getProgress()); + this.printed(); + } + } + shouldPrint() { + return Date.now() - this.lastPrintTime >= this.interval; + } + printed() { + this.lastPrintTime = Date.now(); + } + getProgress() { + const percentage = this.current / this.total; + const elapsed = Date.now() - this.startTime; + const average = this.current > 0 ? elapsed / this.current : 0; + const remaining = (this.total - this.current) * average; + return { + current: this.current, + total: this.total, + percentage, + elapsed, + average, + remaining, + formatted: { + percentage: `${(percentage * 100).toFixed(1)}%`, + elapsed: formatDurationAsReadable(elapsed), + average: formatDurationAsReadable(average), + remaining: formatDurationAsClock(remaining) + } + }; + } +}; + +// node_modules/.pnpm/@bettergi+utils@0.1.19/node_modules/@bettergi/utils/dist/store.js +var useStore = (name) => { + const filePath = `store/${name}.json`; + const obj = (() => { + try { + const storeFiles = [...file.readPathSync("store")].map((path) => path.replace(/\\/g, "/")); + if (!storeFiles.includes(filePath)) + throw new Error("File does not exist"); + const text = file.readTextSync(filePath); + return JSON.parse(text); + } catch { + return {}; + } + })(); + const createProxy = (target, parentPath = []) => { + if (typeof target !== "object" || target === null) { + return target; + } + return new Proxy(target, { + get: (target2, key) => { + const value = Reflect.get(target2, key); + return typeof value === "object" && value !== null ? createProxy(value, [...parentPath, key]) : value; + }, + set: (target2, key, value) => { + const success = Reflect.set(target2, key, value); + if (success) { + Promise.resolve().then(() => { + file.writeTextSync(filePath, JSON.stringify(obj, null, 2)); + }); + } + return success; + }, + deleteProperty: (target2, key) => { + const success = Reflect.deleteProperty(target2, key); + if (success) { + Promise.resolve().then(() => { + file.writeTextSync(filePath, JSON.stringify(obj, null, 2)); + }); + } + return success; + } + }); + }; + return createProxy(obj); +}; +var useStoreWithDefaults = (name, defaults) => { + const store = useStore(name); + Object.assign(store, deepMerge(defaults, store)); + return store; +}; + +// src/config.ts +var userConfig = { + room: settings.room || "20134075027", + playbacks: (settings.playbacks || "通关回放1.json,通关回放2.json").replace(/,/g, ",").split(",").map((str) => str.trim()).filter(Boolean), + expPerAttempt: Math.max(1, Number(settings.expPerAttempt || "20")), + deleteStageSave: settings.deleteStageSave ?? false, + deleteStageSaveKeyword: settings.deleteStageSaveKeyword || "深渊100层", + expWeeklyLimit: Math.max(1, Number(settings.expWeeklyLimit || "4000")), + force: settings.force ?? false, + thisAttempts: Math.max(0, Number(settings.thisAttempts || "0")), + goToTeyvat: settings.goToTeyvat ?? true +}; + +// src/regions.ts +//! 查找确认按钮 +var findConfirmBtn = () => { + return findTextWithinBounds("确认", 480, 720, 960, 145); +}; +//! 查找标题文字 +var findHeaderTitle = (title, contains) => { + return findTextWithinBounds(title, 0, 0, 300, 95, { contains }); +}; +//! 查找底部按钮文字 +var findBottomBtnText = (text, contains) => { + return findTextWithinBounds(text, 960, 980, 960, 100, { contains }); +}; +//! 查找关闭对话框按钮 +var findCloseDialog = () => { + const img = "assets/UI_BtnIcon_Close.png"; + const iro = findImageWithinBounds(img, 480, 216, 960, 648, { + threshold: 0.85 + }); + iro?.drawSelf("group_img"); + return iro; +}; +//! 查找抽卡按钮(判断处于大世界条件一) +var findGachaBtn = () => { + const img = "assets/UI_BtnIcon_Gacha.png"; + const iro = findImageWithinBounds(img, 960, 0, 960, 80, { threshold: 0.85 }); + iro?.drawSelf("group_img"); + return iro; +}; +//! 查找推荐奇域按钮(判断处于大世界条件二) +var findBeyondRecommendBtn = () => { + const img = "assets/UI_BtnIcon_Beyond_Recommend.png"; + const iro = findImageWithinBounds(img, 960, 0, 960, 80, { threshold: 0.85 }); + iro?.drawSelf("group_img"); + return iro; +}; +//! 查找奇域大厅按钮(判断处于奇域大厅) +var findBeyondHallBtn = () => { + const img = "assets/UI_BtnIcon_Beyond_Hall.png"; + const iro = findImageWithinBounds(img, 200, 0, 150, 100, { threshold: 0.85 }); + iro?.drawSelf("group_img"); + return iro; +}; +//! 全部奇域按钮 +var findAllWonderlandsBtn = () => { + return findTextWithinBounds("全部", 1320, 0, 600, 95, { contains: true }); +}; +//! 查找奇域搜索输入框 +var findSearchWonderlandInput = () => { + return findTextWithinBounds("搜索", 0, 120, 1920, 60, { contains: true }); +}; +//! 查找奇域搜索输入框清除按钮 +var findClearInputBtn = () => { + return findTextWithinBounds("清除", 0, 120, 1920, 60); +}; +//! 查找搜索奇域按钮 +var findSearchWonderlandBtn = () => { + return findTextWithinBounds("搜索", 0, 120, 1920, 60, { contains: true }); +}; +//! 查找搜索过于频繁提示 +var findSearchWonderlandThrottleMsg = () => { + return findTextWithinBounds("过于频繁", 0, 0, 1920, 300, { contains: true }); +}; +//! 查找第一个奇域搜索结果名称 +var findFirstSearchResultText = () => { + const ir = captureGameRegion(); + const ro = RecognitionObject.ocr(240, 390, 300, 50); + return (() => { + const list = ir.findMulti(ro); + for (let i = 0; i < list.count; i++) { + if (list[i] && list[i].isExist()) { + return list[i].text; + } + } + })(); +}; +//! 点击选择第一个搜索结果 +var clickToChooseFirstSearchResult = () => { + click(355, 365); +}; +//! 查找进入房间快捷键按钮 +var findEnterRoomShortcut = () => { + return findTextWithinBounds("房间", 1580, 110, 320, 390, { contains: true }); +}; +//! 查找退出房间按钮 +var findLeaveRoomBtn = () => { + const img = "assets/UI_Icon_Leave_Right.png"; + const iro = findImageWithinBounds(img, 1570, 0, 350, 100); + iro?.drawSelf("group_img"); + return iro; +}; +//! 查找跳转大厅按钮 +var findGoToLobbyBtn = () => { + return findTextWithinBounds("大厅", 880, 840, 1040, 110, { + contains: true + }); +}; +//! 查找创建房间按钮 +var findCreateRoomBtn = () => { + return findTextWithinBounds("房间", 960, 140, 960, 70, { contains: true }); +}; +//! 点击加入准备区 +var clickToPrepare = () => { + click(770, 275); +}; +//! 加入准备区提示 +var findPrepareMsg = () => { + return findTextWithinBounds("加入准备", 576, 432, 768, 216, { + contains: true + }); +}; +//! 查找奇域收藏 +var findBeyondFavoritesBtn = () => { + return findTextWithinBounds("收藏", 0, 880, 200, 200, { + contains: true + }); +}; +//! 查找管理关卡按钮 +var findManageStagesBtn = () => { + return findTextWithinBounds("管理", 1320, 0, 600, 95, { contains: true }); +}; +//! 查找编辑关卡存档按钮 +var findEditStageSaveBtn = () => { + return findTextWithinBounds("管理", 1220, 980, 700, 100); +}; +//! 查找要删除的存档位置 +var findSaveToDeletePos = (keyword) => findTextWithinListView( + keyword, + { + x: 210, + y: 250, + w: 1650, + h: 710, + scrollLines: 7, + lineHeight: 95 + }, + { contains: true } +); +//! 查找局外存档列头 +var findExternalSaveColumnPos = () => { + return findTextWithinBounds("局外", 55, 190, 1810, 50, { contains: true }); +}; +//! 查找删除局外存档复选框已选中状态 +var findDeleteExternalSaveChecked = (colPos) => { + const img = "assets/Checkbox_Checked.png"; + const iro = findImageWithinBounds(img, colPos, 250, 290, 710, { + threshold: 0.6, + use3Channels: false + }); + iro?.drawSelf("group_img"); + return iro; +}; +//! 查找删除关卡存档按钮 +var findDeleteStageSaveBtn = () => { + return findTextWithinBounds("删除所选", 1220, 980, 700, 100); +}; +//! 查找关卡退出按钮 +var findStageEscBtn = () => { + const img = "assets/UI_Icon_Leave.png"; + const iro = findImageWithinBounds(img, 0, 0, 100, 100, { threshold: 0.75 }); + iro?.drawSelf("group_img"); + return iro; +}; +//! 查找中断挑战按钮 +var findExitStageBtn = () => { + return findTextWithinBounds("中断挑战", 576, 324, 768, 432); +}; +//! 查找跳过奇域等级提升页面 +var findSkipLevelUpMsg = () => { + return findTextWithinBounds("空白处", 610, 950, 700, 60, { contains: true }); +}; +//! 查找返回提瓦特按钮 +var findGotTeyvatBtn = () => { + return findTextWithinBounds("返回", 1500, 0, 300, 95, { contains: true }); +}; + +// src/lobby.ts +//! 判断是否处于奇域大厅 +var isInLobby = () => findBeyondHallBtn() !== void 0; +//! 判断是否处于提瓦特大陆 +var isInTeyvat = () => { + return findGachaBtn() !== void 0 && findBeyondRecommendBtn() !== void 0; +}; +//! 退出大厅返回提瓦特大陆 +var exitLobbyToTeyvat = async () => { + if (!userConfig.goToTeyvat) return; + if (isInTeyvat()) { + log.warn("已处于提瓦特大陆,跳过"); + return; + } + log.info("打开当前大厅..."); + await assertRegionAppearing( + () => findHeaderTitle("大厅", true), + "打开当前大厅超时", + () => { + keyPress("VK_F2"); + }, + { maxAttempts: 10, retryInterval: 2e3 } + ); + log.info("返回提瓦特大陆..."); + const done = await waitForAction( + isInTeyvat, + () => { + findGotTeyvatBtn()?.click(); + findConfirmBtn()?.click(); + }, + { maxAttempts: 120 } + ); + if (!done) throw new Error("返回提瓦特大陆超时"); +}; + +// src/room.ts +var isInRoom = () => findHeaderTitle("房间", true) !== void 0; +//! 打开人气奇域 +var goToRecommendedWonderlands = async () => { + log.info("打开人气奇域界面..."); + await assertRegionAppearing( + () => findHeaderTitle("人气", true), + "打开人气奇域界面超时", + () => { + keyPress("VK_F6"); + } + ); +}; +//! 创建并进入奇域房间 +var createRoom = async (room) => { + await goToRecommendedWonderlands(); + log.info("打开全部奇域界面..."); + await assertRegionAppearing( + () => findHeaderTitle("全部", true), + "打开全部奇域界面超时", + () => { + findAllWonderlandsBtn()?.click(); + } + ); + await sleep(1500); + //! 记录搜索前的第一个奇域名称 + let iwnt; + let wi = 0; + while (iwnt === void 0) { + if (wi > 20) break; + iwnt = findFirstSearchResultText(); + await sleep(500); + wi += 1; + } + if (iwnt === void 0) throw new Error("加载全部奇域列表超时"); + log.info("搜索前的第一个奇域名称: {iwnt}", iwnt); + log.info("粘贴奇域关卡文本: {room}", room); + await assertRegionAppearing(findClearInputBtn, "粘贴关卡文本超时", () => { + const input = findSearchWonderlandInput(); + if (input) { + input.click(); + inputText(room); + } + }); + //! 等待搜索结果变化 + let fswnt; + log.info("搜索奇域关卡: {room}", room); + await waitForAction( + () => { + if (fswnt === void 0) return false; + //! 检测搜索过于频繁提示 + if (findSearchWonderlandThrottleMsg()) return true; + //! 检测搜索结果是否变化 + return fswnt.toLocaleLowerCase().trim() !== iwnt.toLocaleLowerCase().trim(); + }, + async () => { + findSearchWonderlandBtn()?.click(); + await sleep(1e3); + fswnt = findFirstSearchResultText(); + }, + { maxAttempts: 30, retryInterval: 200 } + ); + log.info("打开奇域介绍..."); + await assertRegionAppearing( + findCreateRoomBtn, + "打开奇域介绍超时", + () => { + const goToLobbyButton = findGoToLobbyBtn(); + if (goToLobbyButton) { + log.info("当前不在大厅,前往大厅..."); + goToLobbyButton.click(); + } else { + log.info("选择第一个奇域关卡..."); + clickToChooseFirstSearchResult(); + } + }, + { maxAttempts: 60 } + ); + log.info("创建并进入房间..."); + await assertRegionAppearing( + () => findHeaderTitle("房间", true), + "创建并进入房间超时", + () => { + findCreateRoomBtn()?.click(); + }, + { maxAttempts: 60 } + ); +}; +//! 进入奇域房间 +var enterRoom = async (room) => { + const inLobby = isInLobby(); + if (inLobby) { + const enterButton = findEnterRoomShortcut(); + if (enterButton) { + log.info("当前已存在房间,进入房间...", room); + await assertRegionAppearing( + () => findHeaderTitle("房间", true), + "进入房间超时", + () => { + keyPress("VK_P"); + } + ); + return; + } + } + log.info("当前不在房间内,创建房间...", room); + await createRoom(room); +}; +//! 离开房间 +var leaveRoom = async () => { + //! 当前在大厅,且存在房间 + if (isInLobby() && findEnterRoomShortcut() !== void 0 || isInRoom()) { + log.info("当前存在房间,离开房间..."); + //! 先进入房间 + await assertRegionAppearing( + () => findHeaderTitle("房间", true), + "进入房间超时", + () => { + keyPress("VK_P"); + } + ); + //! 离开房间 + await assertRegionAppearing( + findBeyondHallBtn, + "离开房间超时", + async () => { + findLeaveRoomBtn()?.click(); + await sleep(1e3); + findConfirmBtn()?.click(); + }, + { maxAttempts: 5 } + ); + } +}; + +// src/save.ts +//! 进入管理关卡存档界面 +var goToManageStageSave = async () => { + //! 打开人气奇域 + await goToRecommendedWonderlands(); + //! 打开奇域收藏->管理关卡 + await assertRegionAppearing( + findEditStageSaveBtn, + "打开编辑关卡存档按钮超时", + async () => { + //! 点击奇域收藏 + findBeyondFavoritesBtn()?.click(); + await sleep(300); + //! 点击管理关卡 + findManageStagesBtn()?.click(); + await sleep(300); + }, + { maxAttempts: 5 } + ); +}; +//! 删除关卡存档 +var deleteStageSave = async () => { + if (!userConfig.deleteStageSave || userConfig.deleteStageSaveKeyword.trim() === "") { + log.info("未启用删除关卡存档,跳过"); + return; + } + try { + //! 进入管理关卡存档界面 + await goToManageStageSave(); + //! 选中要删除的关卡的局外存档 + const stagePos = await findSaveToDeletePos(userConfig.deleteStageSaveKeyword); + if (stagePos === void 0) { + log.warn("未找到要删除的关卡存档,跳过"); + return; + } + const colPos = findExternalSaveColumnPos(); + if (colPos === void 0) { + log.warn("无法确定关卡的局外存档列位置,跳过"); + return; + } + //! 进入编辑模式 + await assertRegionDisappearing( + findEditStageSaveBtn, + "进入编辑模式超时", + () => { + keyPress("VK_F"); + }, + { maxAttempts: 5 } + ); + //! 计算勾选框位置并点击 + const [cx, cy] = [(colPos.x * 2 + colPos.width) / 2, stagePos.y + 20]; + await assertRegionAppearing( + () => findDeleteExternalSaveChecked(colPos.x), + "勾选要删除的局外存档超时", + () => { + click(Math.ceil(cx), Math.ceil(cy)); + }, + { maxAttempts: 5, retryInterval: 1500 } + ); + //! 点击删除所选按钮 + await assertRegionDisappearing( + () => findDeleteExternalSaveChecked(colPos.x), + "删除关卡存档超时", + async () => { + //! 特征较为脆弱,多次确认,确保成功删除 + findConfirmBtn()?.click(); + await sleep(500); + findConfirmBtn()?.click(); + findDeleteStageSaveBtn()?.click(); + await sleep(1e3); + findConfirmBtn()?.click(); + await sleep(500); + findConfirmBtn()?.click(); + }, + { + maxAttempts: 5 + } + ); + } catch (err) { + if (isHostException(err)) throw err; + log.warn("删除关卡存档失败: {error}", getErrorMessage(err)); + } finally { + //! 返回大厅 + await genshin.returnMainUi(); + } +}; + +// src/stage.ts +//! 可用的执行通关回放文件列表 +var availablePlaybackFiles = () => { + const files = [...file.readPathSync("assets/playbacks")].map((path) => path.replace(/\\/g, "/")); + return userConfig.playbacks.map((file2) => `assets/playbacks/${file2}`).filter((path) => files.includes(path)); +}; +//! 确保通关回放文件存在 +var ensurePlaybackFilesExist = () => { + const list = availablePlaybackFiles(); + if (list.length === 0) { + throw new Error("未找到任何通关回放文件,请确保已录制回放并拷贝到 assets/playbacks 目录下"); + } +}; +var playStage = async () => { + //! 等待进入关卡 + await assertRegionAppearing( + findStageEscBtn, + "等待进入关卡超时", + async () => { + findBottomBtnText("开始游戏")?.click(); + findBottomBtnText("准备", true)?.click(); + //! 判断是否已经加入准备区 + if (findPrepareMsg()) { + log.info("加入准备区..."); + await assertRegionDisappearing(findPrepareMsg, "等待加入准备区提示消失超时"); + clickToPrepare(); + } + }, + { maxAttempts: 60 } + ); + //! 关闭游戏说明对话框 + await assertRegionDisappearing( + findCloseDialog, + "关闭游戏说明对话框超时", + () => { + findCloseDialog()?.click(); + }, + { maxAttempts: 10, retryInterval: 500 } + ); + //! 执行随机通关回放文件 + await execStagePlayback(); + await sleep(3e3); + //! 退出关卡返回大厅 + await exitStageToLobby(); +}; +//! 执行通关回放文件(随机抽取) +var execStagePlayback = async () => { + const list = availablePlaybackFiles(); + const file2 = list[Math.floor(Math.random() * list.length)]; + log.info("执行通关回放文件: {file}", file2); + await keyMouseScript.runFile(file2); +}; +//! 退出关卡 +var exitStage = async () => { + if (findStageEscBtn() === void 0) return; + log.warn("关卡超时,尝试退出关卡..."); + await assertRegionAppearing( + findExitStageBtn, + "等待中断挑战按钮出现超时", + () => { + keyPress("VK_ESCAPE"); + }, + { maxAttempts: 5, retryInterval: 2e3 } + ); + findExitStageBtn()?.click(); + await genshin.returnMainUi(); +}; +//! 退出关卡返回大厅 +var exitStageToLobby = async () => { + if (isInLobby()) { + log.warn("已处于奇域大厅,跳过"); + return; + } + log.info("退出关卡返回大厅..."); + const done = await waitForAction( + isInLobby, + async () => { + //! 跳过奇域等级提升页面(奇域等级每逢11、21、31、41级时出现加星页面) + findSkipLevelUpMsg()?.click(); + //! 点击底部 “返回大厅” 按钮 + findBottomBtnText("大厅", true)?.click(); + }, + { maxAttempts: 60 } + ); + if (!done) { + await exitStage(); + throw new Error("退出关卡返回大厅超时"); + } +}; + +// main.ts +(async function() { + //! 初始化游戏环境 + setGameMetrics(1920, 1080, 1.5); + await genshin.returnMainUi(); + //! 确保通关回放文件存在 + ensurePlaybackFilesExist(); + //! 初始化数据存储 + const store = useStoreWithDefaults("data", { + weekly: { expGained: 0, attempts: 0 }, + nextWeek: getNextMonday4AM().getTime() + }); + //! 新的一周开始,重置经验值数据 + if (Date.now() >= store.nextWeek) { + store.weekly = { expGained: 0, attempts: 0 }; + store.nextWeek = getNextMonday4AM().getTime(); + } + //! 检查本周经验值是否已达上限 + if (store.weekly.expGained >= userConfig.expWeeklyLimit) { + if (userConfig.force) { + log.warn("本周获取经验值已达上限,强制执行"); + } else { + log.warn("本周获取经验值已达上限,跳过执行"); + return; + } + } + //! 计算本次本周剩余可获取经验值 + let expRemaining = userConfig.expWeeklyLimit - store.weekly.expGained; + expRemaining = expRemaining > 0 ? expRemaining : userConfig.expWeeklyLimit; + //! 计算需要进行的尝试次数 + let attempts = Math.ceil(expRemaining / userConfig.expPerAttempt); + attempts = userConfig.thisAttempts > 0 ? userConfig.thisAttempts : attempts; + //! 离开当前所在房间(如果存在) + await leaveRoom(); + //! 创建进度追踪器 + const tracker = new ProgressTracker(attempts); + //! 迭代尝试 + try { + for (let i = 0; i < attempts; i++) { + tracker.print(`开始本周第 ${store.weekly.attempts + 1} 次奇域挑战...`); + //! 删除关卡存档 + await deleteStageSave(); + //! 进入房间 + await enterRoom(userConfig.room); + //! 游玩关卡 + await playStage(); + //! 关卡结束,更新数据存储 + store.weekly.attempts += 1; + store.weekly.expGained += userConfig.expPerAttempt; + tracker.tick({ increment: 1 }); + //! 本周已获取经验值达到上限,跳出循环 + if (store.weekly.expGained >= userConfig.expWeeklyLimit) { + if (!userConfig.force) { + log.warn("本周已获取经验值达到上限,停止执行"); + break; + } + } + } + } catch (err) { + //! 发生主机异常(如:任务取消异常等),无法再继续执行 + if (isHostException(err)) throw err; + //! 发生脚本流程异常,尝试退出关卡(如果在关卡中) + await exitStage(); + log.error("脚本执行出错: {error}", getErrorMessage(err)); + } + //! 返回提瓦特大陆 + await exitLobbyToTeyvat(); +})(); diff --git a/repo/js/MiliastraExperiencePlayback/manifest.json b/repo/js/MiliastraExperiencePlayback/manifest.json new file mode 100644 index 000000000..2b1708d7d --- /dev/null +++ b/repo/js/MiliastraExperiencePlayback/manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 1, + "name": "千星奇域·每周经验刷取(回放通关版)", + "version": "0.1.0", + "bgi_version": "0.53.0", + "description": "千星奇域·每周经验刷取(回放通关版)", + "authors": [ + { + "name": "breadgrocery", + "link": "https://github.com/breadgrocery/miliastra-experience-playback" + } + ], + "main": "main.js", + "settings_ui": "settings.json", + "saved_files": [ + "store/*.json", + "playbacks/*.json" + ] +} diff --git a/repo/js/MiliastraExperiencePlayback/settings.json b/repo/js/MiliastraExperiencePlayback/settings.json new file mode 100644 index 000000000..b52df9902 --- /dev/null +++ b/repo/js/MiliastraExperiencePlayback/settings.json @@ -0,0 +1,56 @@ +[ + { + "type": "input-text", + "name": "room", + "label": "奇域关卡关键词或关卡GUID", + "default": "20134075027" + }, + { + "type": "input-text", + "name": "playbacks", + "label": "通关回放文件池(自行录制拷贝到assets/playbacks,逗号分隔)", + "default": "通关回放1.json,通关回放2.json" + }, + { + "type": "input-text", + "name": "expPerAttempt", + "label": "每次通关获取的经验值(如果勾选删除关卡存档,请自行增加)", + "default": "20" + }, + { + "type": "checkbox", + "name": "deleteStageSave", + "label": "删除关卡存档(可重复达成成就,获取更多经验值)", + "default": false + }, + { + "type": "input-text", + "name": "deleteStageSaveKeyword", + "label": "删除关卡存档关键字(关卡存档视图中的[关卡]列)", + "default": "深渊100层" + }, + { + "type": "input-text", + "name": "expWeeklyLimit", + "label": "每周可获取的经验值上限", + "default": "4000" + }, + { + "type": "checkbox", + "name": "force", + "label": "忽略本周经验值已达上限", + "default": false + }, + { + "type": "input-text", + "name": "thisAttempts", + "label": "指定通关次数(0表示自动判断)", + "default": "0" + }, + { + "type": "checkbox", + "name": "goToTeyvat", + "label": "完成后返回提瓦特大陆", + "default": true + } +]