diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index 7ea0001c..00000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: Bug report / 错误报告 -about: Create a report to help us improve / 创建报告以帮助我们改进 -title: "[bug]请补充标题内容" -labels: bug ---- - - - -- 系统环境 / System Environment: - - -- BetterGI版本号 / BetterGI Version: - - -- 问题描述 / Description of the issue: - - -- 复现步骤 / Reproduction steps: - diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..aee9294e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,41 @@ +name: Bug report / 错误报告 +description: 提交可复现的问题,帮助我们定位错误 / Report a reproducible problem +title: "[bug] " +labels: + - BUG +body: + - type: textarea + id: environment + attributes: + label: 系统环境 + description: System Environment,例如系统版本、运行环境 + placeholder: Win11 / BetterGI 0.58.0 / .NET 8 + validations: + required: true + - type: input + id: version + attributes: + label: BetterGI 版本号 + description: BetterGI Version + placeholder: 0.58.0 + validations: + required: true + - type: textarea + id: description + attributes: + label: 问题描述 + description: Description of the issue + placeholder: 请详细描述你遇到的问题,可直接粘贴日志并附带截图 + validations: + required: true + - type: textarea + id: steps + attributes: + label: 复现步骤 + description: Reproduction steps + placeholder: |- + 1. 打开…… + 2. 点击…… + 3. 出现…… + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index 9e8b792b..00000000 --- a/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: I have feature requesting / 我有新功能请求 -about: Your request may come as a surprise. / 你的请求也许成为惊喜 -title: "[feature] " -labels: feature 功能请求 ---- - -- Your feature requesting / 你的新功能请求 - diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 00000000..35820331 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,30 @@ +name: I have feature requesting / 我有功能需求 +description: 提出新的功能想法 / Share a feature request +title: "[feature] " +labels: + - 功能建议 +body: + - type: textarea + id: request + attributes: + label: 功能请求 + description: Feature Request + placeholder: 请简要说明你希望新增什么能力 + validations: + required: true + - type: textarea + id: scenario + attributes: + label: 使用场景 + description: Use Case + placeholder: 这个功能要解决什么问题?在哪些场景下使用? + validations: + required: true + - type: textarea + id: extra + attributes: + label: 补充信息 + description: Optional + placeholder: 可选,补充示意图、参考方案或限制条件 + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/normal.md b/.github/ISSUE_TEMPLATE/normal.md deleted file mode 100644 index 2b03cdcf..00000000 --- a/.github/ISSUE_TEMPLATE/normal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: Feel free to express / 随便写写 -about: Record something / 简单写写记录记录 ---- - diff --git a/.github/ISSUE_TEMPLATE/normal.yml b/.github/ISSUE_TEMPLATE/normal.yml new file mode 100644 index 00000000..ad71e485 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/normal.yml @@ -0,0 +1,12 @@ +name: Feel free to express / 自由描述 +description: 记录你想反馈的内容 / Share general feedback +title: "[feedback] " +body: + - type: textarea + id: description + attributes: + label: 描述 + description: Description + placeholder: 请描述你想反馈的内容 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index 1f03a9e0..00000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: I have a question / 我有疑问 -about: Solve your problem / 解决你的疑问 -title: "[question] " -labels: question ---- - -- Problem Description / 问题描述 -- 请尽量提供问题相关的截图/日志等内容 diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 00000000..81c97c14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,22 @@ +name: I have a question / 我有疑问 +description: 提交你的问题,我们会尽量帮助你解决 / Ask a question +title: "[question] " +labels: + - 问题咨询 +body: + - type: textarea + id: question + attributes: + label: 问题描述 + description: Problem Description + placeholder: 请清楚描述你的问题 + validations: + required: true + - type: textarea + id: tried + attributes: + label: 已尝试内容 + description: Screenshots / logs / what you already tried + placeholder: 可附上截图、日志,以及你已经尝试过的排查过程 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/suggestion.md b/.github/ISSUE_TEMPLATE/suggestion.md deleted file mode 100644 index 7c3bf708..00000000 --- a/.github/ISSUE_TEMPLATE/suggestion.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: I have suggestions / 我有建议 -about: Your suggestions may benefit everyone / 你的建议可能让所有人受益 -title: "[suggestion] " -labels: suggestion ---- - -- Your suggestions / 你的建议 - diff --git a/.github/ISSUE_TEMPLATE/suggestion.yml b/.github/ISSUE_TEMPLATE/suggestion.yml new file mode 100644 index 00000000..8d24b45e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/suggestion.yml @@ -0,0 +1,22 @@ +name: I have suggestions / 我有建议 +description: 分享对项目的改进建议 / Share an improvement suggestion +title: "[suggestion] " +labels: + - 功能建议 +body: + - type: textarea + id: suggestion + attributes: + label: 建议内容 + description: Suggestion + placeholder: 请描述你的建议内容 + validations: + required: true + - type: textarea + id: benefit + attributes: + label: 预期收益 + description: Expected Benefit + placeholder: 这个建议会给用户或项目带来什么帮助? + validations: + required: true diff --git a/.github/repo-bot.yml b/.github/repo-bot.yml new file mode 100644 index 00000000..465dda94 --- /dev/null +++ b/.github/repo-bot.yml @@ -0,0 +1,229 @@ +runtime: + languageMode: auto + dryRun: false + +providers: + openAiCompatible: + enabled: true + baseUrl: https://api.openai.com/v1 + model: gpt-5.2-codex + apiStyle: responses + timeoutMs: 30000 + +issues: + autoProcessing: + skipCreatedBefore: auto + validation: + enabled: true + fallbackTemplateKey: normal + commentAnchor: issue-bot:validation + templates: + - key: bug + detect: + markers: + - bug + titlePrefixes: + - "[bug]" + requiredSections: + - id: environment + aliases: + - 系统环境 + - System Environment + - id: version + aliases: + - BetterGI 版本号 + - BetterGI Version + - id: description + aliases: + - 问题描述 + - Description of the issue + - id: steps + aliases: + - 复现步骤 + - Reproduction steps + labels: + whenValid: + - BUG + whenInvalid: + - 需要更多信息 + - key: feature + detect: + markers: + - feature + titlePrefixes: + - "[feature]" + requiredSections: + - id: request + aliases: + - 功能请求 + - Feature Request + - id: scenario + aliases: + - 使用场景 + - Use Case + labels: + whenValid: + - 功能建议 + whenInvalid: + - 需要更多信息 + - key: suggestion + detect: + markers: + - suggestion + titlePrefixes: + - "[suggestion]" + requiredSections: + - id: suggestion + aliases: + - 建议内容 + - Suggestion + - id: benefit + aliases: + - 预期收益 + - Expected Benefit + labels: + whenValid: + - 功能建议 + whenInvalid: + - 需要更多信息 + - key: question + detect: + markers: + - question + titlePrefixes: + - "[question]" + requiredSections: + - id: question + aliases: + - 问题描述 + - Problem Description + - id: tried + aliases: + - 已尝试内容 + - What I Tried + labels: + whenValid: + - 问题咨询 + whenInvalid: + - 需要更多信息 + - key: normal + detect: + markers: + - normal + titlePrefixes: + - "[feedback]" + - "[normal]" + requiredSections: + - id: description + aliases: + - 描述 + - Description + labels: + whenValid: [] + whenInvalid: + - 需要更多信息 + duplicateDetection: + enabled: true + bypassLabels: + - 跳过重复检测 + duplicateLabel: 重复 + searchResultLimit: 50 + candidateLimit: 20 + aiReviewMaxCandidates: 3 + thresholds: + exact: 0.995 + highConfidence: 0.93 + reviewMin: 0.82 + similarityComment: + enabled: true + commentAnchor: issue-bot:similar-issues + minScore: 0.3 + maxCandidates: 3 + labeling: + enabled: true + autoCreateMissing: true + managed: + - BUG + - 功能建议 + - 问题咨询 + - 需要更多信息 + - 重复 + definitions: + BUG: + color: d73a4a + description: 程序存在缺陷或异常行为。 + 功能建议: + color: a2eeef + description: 新功能建议或现有功能改进。 + 问题咨询: + color: d876e3 + description: 使用问题、求助或咨询。 + # 需要更多信息: + # color: fbca04 + # description: 当前 Issue 缺少必要信息。 + 重复: + color: cfd3d7 + description: 已存在相同或高度相似的问题。 + keywordRules: [] + aiClassification: + enabled: false + maxLabels: 3 + minConfidence: 0.65 + include: [] + exclude: + - BUG + - 功能请求 + - 重复 + - 不会修复 + - 使用问题 + - 需要帮助 + - 已完成 + - 未解决 + - 待确认 + - P0 + - P1 + - 需要文档 + - 优化点 + - 急急急 + prompt: 优先给 BetterGI Issue 选择能反映具体功能模块、子系统或使用场景的标签,例如一条龙、调度器、脚本、设置项、地图追踪;避免只选择宽泛的流程或状态标签。 + sourceRepository: + owner: babalae + repo: better-genshin-impact + aiHelp: + enabled: true + triggerLabels: [] + commentAnchor: issue-bot:ai + projectContext: + enabled: true + includeRepositoryMetadata: true + includeReadme: true + readmeMaxChars: 3000 + profile: + name: BetterGI + aliases: + - BGI + - Better Genshin Impact + summary: BetterGI is a desktop automation assistant for Genshin Impact. + techStack: + - C# + - WPF + - .NET + commands: + enabled: true + mentions: + - "@bot" + - "@bettergi-repo-bot" + access: collaborators + fix: + enabled: true + commentAnchor: issue-bot:fix + refresh: + enabled: true + +pullRequests: + review: + enabled: false + labeling: + enabled: false + summary: + enabled: false diff --git a/.github/workflows/mirrorchyan_release_note.yml b/.github/workflows/mirrorchyan_release_note.yml index b43c6f2a..bb628028 100644 --- a/.github/workflows/mirrorchyan_release_note.yml +++ b/.github/workflows/mirrorchyan_release_note.yml @@ -7,6 +7,7 @@ on: jobs: mirrorchyan_release_note: + if: github.repository_owner == 'babalae' runs-on: macos-latest steps: diff --git a/.github/workflows/mirrorchyan_uploading.yml b/.github/workflows/mirrorchyan_uploading.yml index e650ef7b..438c2340 100644 --- a/.github/workflows/mirrorchyan_uploading.yml +++ b/.github/workflows/mirrorchyan_uploading.yml @@ -7,6 +7,7 @@ on: jobs: mirrorchyan: + if: github.repository_owner == 'babalae' runs-on: windows-latest steps: - name: 📥 Download release diff --git a/.github/workflows/repo-bot.yml b/.github/workflows/repo-bot.yml new file mode 100644 index 00000000..ce64b5ab --- /dev/null +++ b/.github/workflows/repo-bot.yml @@ -0,0 +1,55 @@ +name: Repo Bot + +on: + issues: + types: + - opened + - edited + - reopened + - labeled + issue_comment: + types: + - created + - edited + +jobs: + repo-bot: + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + steps: + - name: Detect GitHub App configuration + id: auth-mode + shell: bash + env: + REPO_BOT_GITHUB_APP_ID: ${{ vars.REPO_BOT_GITHUB_APP_ID }} + REPO_BOT_GITHUB_APP_PRIVATE_KEY: ${{ secrets.REPO_BOT_GITHUB_APP_PRIVATE_KEY }} + run: | + if [ -n "$REPO_BOT_GITHUB_APP_ID" ] && [ -n "$REPO_BOT_GITHUB_APP_PRIVATE_KEY" ]; then + echo "use_app=true" >> "$GITHUB_OUTPUT" + else + echo "use_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create GitHub App token + if: ${{ steps.auth-mode.outputs.use_app == 'true' }} + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.REPO_BOT_GITHUB_APP_ID }} + private-key: ${{ secrets.REPO_BOT_GITHUB_APP_PRIVATE_KEY }} + + - name: Run Repo Bot + uses: ddaodan/bettergi-github-bot@v1 + env: + GITHUB_TOKEN: ${{ github.token }} + REPO_BOT_GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + REPO_BOT_AI_API_KEY: ${{ secrets.REPO_BOT_AI_API_KEY }} + REPO_BOT_AI_BASE_URL: ${{ vars.REPO_BOT_AI_BASE_URL }} + with: + config-path: .github/repo-bot.yml + config-overrides-json: ${{ vars.REPO_BOT_CONFIG_OVERRIDES_JSON }} diff --git a/.gitignore b/.gitignore index 97605ab9..3f896d07 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ github_actions_cache/ *.zip -# IDE & AI tools +# Rider .idea .trae .claude diff --git a/BetterGenshinImpact/App.xaml b/BetterGenshinImpact/App.xaml index 292c73f3..e92bc90e 100644 --- a/BetterGenshinImpact/App.xaml +++ b/BetterGenshinImpact/App.xaml @@ -28,6 +28,7 @@ + diff --git a/BetterGenshinImpact/App.xaml.cs b/BetterGenshinImpact/App.xaml.cs index ffbb5f69..524c83de 100644 --- a/BetterGenshinImpact/App.xaml.cs +++ b/BetterGenshinImpact/App.xaml.cs @@ -57,8 +57,7 @@ public partial class App : Application .UseElevated() .UseSingleInstance("BetterGI") .ConfigureLogging(builder => { builder.ClearProviders(); }) - .ConfigureServices( - (context, services) => + .ConfigureServices((context, services) => { // 提前初始化配置 var configService = new ConfigService(); @@ -80,7 +79,7 @@ public partial class App : Application rollingInterval: RollingInterval.Day, retainedFileCountLimit: 31, retainedFileTimeLimit: TimeSpan.FromDays(21)) - .WriteTo.Console(outputTemplate: + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .MinimumLevel.Debug() .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) @@ -95,23 +94,24 @@ public partial class App : Application Log.Logger = loggerConfiguration.CreateLogger(); services.AddSingleton(); services.AddSingleton(); - - if ("zh-Hans".Equals(all.OtherConfig.UiCultureInfoName, StringComparison.OrdinalIgnoreCase)) - { - services.AddLogging(c => c.AddSerilog()); - } - else - { - services.AddLogging(logging => - { - logging.ClearProviders(); - logging.SetMinimumLevel(LogLevel.Debug); - logging.AddFilter("Microsoft", LogLevel.Warning); - logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); - logging.AddFilter("Quartz", LogLevel.Information); - logging.Services.AddSingleton(); - }); - } + + services.AddLogging(c => c.AddSerilog()); + // if ("zh-Hans".Equals(all.OtherConfig.UiCultureInfoName, StringComparison.OrdinalIgnoreCase)) + // { + // services.AddLogging(c => c.AddSerilog()); + // } + // else + // { + // services.AddLogging(logging => + // { + // logging.ClearProviders(); + // logging.SetMinimumLevel(LogLevel.Debug); + // logging.AddFilter("Microsoft", LogLevel.Warning); + // logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); + // logging.AddFilter("Quartz", LogLevel.Information); + // logging.Services.AddSingleton(); + // }); + // } services.AddLocalization(); @@ -193,20 +193,20 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - + services.AddSingleton(TimeProvider.System); services.AddSingleton(); // Configuration //services.Configure(context.Configuration.GetSection(nameof(AppConfig))); - + I18N.Culture = new CultureInfo("zh-Hans"); // #1846 } ) @@ -264,10 +264,19 @@ public partial class App : Application } catch (Exception ex) { - // DEBUG only, no overhead Debug.WriteLine(ex); ConsoleHelper.WriteError($"应用程序启动失败: {ex.Message}"); + try + { + HandleException(ex); + } + catch (Exception ex2) + { + Debug.WriteLine(ex2); + ConsoleHelper.WriteError($"应用程序启动失败打印日志时又失败了: {ex2.Message}"); + } + if (Debugger.IsAttached) { Debugger.Break(); @@ -283,12 +292,12 @@ public partial class App : Application base.OnExit(e); ConsoleHelper.WriteLine("BetterGI 应用程序正在关闭..."); - + TempManager.CleanUp(); await _host.StopAsync(); _host.Dispose(); - + // 释放控制台窗口 ConsoleHelper.FreeConsoleWindow(); } @@ -390,4 +399,4 @@ public partial class App : Application // log GetLogger().LogDebug(e, "UnHandle Exception"); } -} +} \ No newline at end of file diff --git a/BetterGenshinImpact/BetterGenshinImpact.csproj b/BetterGenshinImpact/BetterGenshinImpact.csproj index acc9d9bd..5742a834 100644 --- a/BetterGenshinImpact/BetterGenshinImpact.csproj +++ b/BetterGenshinImpact/BetterGenshinImpact.csproj @@ -2,7 +2,7 @@ BetterGI - 0.57.1-alpha.1 + 0.59.2-alpha.3 false WinExe net8.0-windows10.0.22621.0 @@ -44,9 +44,9 @@ - - - + + + @@ -68,7 +68,7 @@ - + diff --git a/BetterGenshinImpact/Core/BgiVision/BvPage.cs b/BetterGenshinImpact/Core/BgiVision/BvPage.cs index 0c6ac8de..950de111 100644 --- a/BetterGenshinImpact/Core/BgiVision/BvPage.cs +++ b/BetterGenshinImpact/Core/BgiVision/BvPage.cs @@ -2,9 +2,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.Core.Recognition; -using BetterGenshinImpact.Core.Recognition.OCR; using BetterGenshinImpact.Core.Simulator; -using BetterGenshinImpact.GameTask; using BetterGenshinImpact.GameTask.Common; using BetterGenshinImpact.GameTask.Model.Area; using Fischless.WindowsInput; @@ -120,56 +118,4 @@ public class BvPage { GameCaptureRegion.GameRegion1080PPosClick(x, y); } - - /// - /// 使用模糊匹配判断截图中是否包含目标文字。 - /// 通过 自动选择最佳实现(DP 模糊匹配或普通 OCR + 字符串比较)。 - /// - /// 目标字符串 - /// 感兴趣区域,default 表示全屏 - /// 匹配阈值 (0~1),null 使用配置中的默认阈值 - /// 是否匹配成功 - public bool OcrMatch(string target, Rect rect = default, double? threshold = null) - { - var matchService = OcrFactory.PaddleMatch; - var actualThreshold = threshold - ?? TaskContext.Instance().Config.OtherConfig.OcrConfig.OcrMatchDefaultThreshold; - - var screen = TaskControl.CaptureToRectArea(); - try - { - var roi = rect == default ? null : screen.DeriveCrop(rect); - try - { - var score = matchService.OcrMatch((roi ?? screen).SrcMat, target); - return score >= actualThreshold; - } - finally - { - roi?.Dispose(); - } - } - finally - { - screen.Dispose(); - } - } - - /// - /// 重复截图并使用模糊匹配,等待目标文字出现。 - /// 超时返回 false 而非抛异常。 - /// - /// 目标字符串 - /// 感兴趣区域,default 表示全屏 - /// 匹配阈值 (0~1),null 使用配置中的默认阈值 - /// 超时时间(毫秒),null 使用 DefaultTimeout - /// 是否在超时前匹配成功 - public async Task WaitForOcrMatch(string target, Rect rect = default, double? threshold = null, int? timeout = null) - { - var actualTimeout = timeout ?? DefaultTimeout; - var retryCount = DefaultRetryInterval > 0 ? actualTimeout / DefaultRetryInterval : 1; - - return await NewRetry.WaitForAction(() => OcrMatch(target, rect, threshold), - _cancellationToken, retryCount, DefaultRetryInterval); - } -} +} \ No newline at end of file diff --git a/BetterGenshinImpact/Core/Config/AllConfig.cs b/BetterGenshinImpact/Core/Config/AllConfig.cs index ad793b3a..086982a1 100644 --- a/BetterGenshinImpact/Core/Config/AllConfig.cs +++ b/BetterGenshinImpact/Core/Config/AllConfig.cs @@ -1,5 +1,4 @@ using BetterGenshinImpact.GameTask; -using BetterGenshinImpact.GameTask.AutoCook; using BetterGenshinImpact.GameTask.AutoDomain; using BetterGenshinImpact.GameTask.AutoFight; using BetterGenshinImpact.GameTask.AutoFishing; @@ -23,6 +22,7 @@ using BetterGenshinImpact.GameTask.AutoStygianOnslaught; using BetterGenshinImpact.GameTask.GetGridIcons; using BetterGenshinImpact.GameTask.AutoEat; using BetterGenshinImpact.GameTask.AutoLeyLineOutcrop; +using BetterGenshinImpact.GameTask.AutoCook; using BetterGenshinImpact.GameTask.MapMask; using BetterGenshinImpact.GameTask.SkillCd; using BetterGenshinImpact.GameTask.UseRedeemCode; @@ -129,11 +129,6 @@ public partial class AllConfig : ObservableObject /// public QuickTeleportConfig QuickTeleportConfig { get; set; } = new(); - /// - /// 自动烹饪配置 - /// - public AutoCookConfig AutoCookConfig { get; set; } = new(); - /// /// 自动打牌配置 /// @@ -179,6 +174,8 @@ public partial class AllConfig : ObservableObject /// 自动地脉花配置 /// public AutoLeyLineOutcropConfig AutoLeyLineOutcropConfig { get; set; } = new(); + + public AutoCookConfig AutoCookConfig { get; set; } = new(); /// /// 地图遮罩 @@ -269,7 +266,6 @@ public partial class AllConfig : ObservableObject AutoSkipConfig.PropertyChanged += OnAnyPropertyChanged; AutoFishingConfig.PropertyChanged += OnAnyPropertyChanged; QuickTeleportConfig.PropertyChanged += OnAnyPropertyChanged; - AutoCookConfig.PropertyChanged += OnAnyPropertyChanged; MacroConfig.PropertyChanged += OnAnyPropertyChanged; HotKeyConfig.PropertyChanged += OnAnyPropertyChanged; AutoWoodConfig.PropertyChanged += OnAnyPropertyChanged; @@ -280,6 +276,7 @@ public partial class AllConfig : ObservableObject AutoRedeemCodeConfig.PropertyChanged += OnAnyPropertyChanged; AutoEatConfig.PropertyChanged += OnAnyPropertyChanged; AutoLeyLineOutcropConfig.PropertyChanged += OnAnyPropertyChanged; + AutoCookConfig.PropertyChanged += OnAnyPropertyChanged; MapMaskConfig.PropertyChanged += OnAnyPropertyChanged; AutoMusicGameConfig.PropertyChanged += OnAnyPropertyChanged; TpConfig.PropertyChanged += OnAnyPropertyChanged; diff --git a/BetterGenshinImpact/Core/Config/HardwareAccelerationConfig.cs b/BetterGenshinImpact/Core/Config/HardwareAccelerationConfig.cs index 100e9d85..fff63370 100644 --- a/BetterGenshinImpact/Core/Config/HardwareAccelerationConfig.cs +++ b/BetterGenshinImpact/Core/Config/HardwareAccelerationConfig.cs @@ -14,10 +14,10 @@ public partial class HardwareAccelerationConfig : ObservableObject private InferenceDeviceType _inferenceDevice = InferenceDeviceType.Cpu; /// - /// 是否强制OCR使用CPU推理。在某些环境上使用GPU进行OCR推理会导致性能下降(比如很多使用DirectML推理的情况下)。默认关闭。 + /// 是否强制OCR使用CPU推理。在某些环境上使用GPU进行OCR推理会导致性能下降(比如很多使用DirectML推理的情况下)。默认开启。 /// [ObservableProperty] - private bool _cpuOcr = false; + private bool _cpuOcr = true; #region 一般GPU加速设置 diff --git a/BetterGenshinImpact/Core/Config/HotKeyConfig.cs b/BetterGenshinImpact/Core/Config/HotKeyConfig.cs index c511e611..a174d6e3 100644 --- a/BetterGenshinImpact/Core/Config/HotKeyConfig.cs +++ b/BetterGenshinImpact/Core/Config/HotKeyConfig.cs @@ -163,6 +163,13 @@ public partial class HotKeyConfig : ObservableObject [ObservableProperty] private string _autoFishingGameHotkeyType = HotKeyTypeEnum.KeyboardMonitor.ToString(); + // 自动烹饪开始/停止 + [ObservableProperty] + private string _autoCookGameHotkey = ""; + + [ObservableProperty] + private string _autoCookGameHotkeyType = HotKeyTypeEnum.KeyboardMonitor.ToString(); + // 自动寻路 [ObservableProperty] private string _autoTrackPathHotkey = ""; diff --git a/BetterGenshinImpact/Core/Config/MaskWindowConfig.cs b/BetterGenshinImpact/Core/Config/MaskWindowConfig.cs index 98cfccd1..49143e8b 100644 --- a/BetterGenshinImpact/Core/Config/MaskWindowConfig.cs +++ b/BetterGenshinImpact/Core/Config/MaskWindowConfig.cs @@ -63,13 +63,6 @@ public partial class MaskWindowConfig : ObservableObject [ObservableProperty] private bool _showFps = false; - /// - /// 作为原神子窗体 - /// 有些bug没解决 - /// - [ObservableProperty] - private bool _useSubform = false; - /// /// 遮罩文本透明度 (0.0-1.0) /// diff --git a/BetterGenshinImpact/Core/Config/OtherConfig.cs b/BetterGenshinImpact/Core/Config/OtherConfig.cs index fccba007..ac65d2b7 100644 --- a/BetterGenshinImpact/Core/Config/OtherConfig.cs +++ b/BetterGenshinImpact/Core/Config/OtherConfig.cs @@ -106,46 +106,6 @@ public partial class OtherConfig : ObservableObject /// [ObservableProperty] private PaddleOcrModelConfig _paddleOcrModelConfig = PaddleOcrModelConfig.V4Auto; - - /// - /// 允许OCR结果中出现连续重复字符(关闭CTC重复字符折叠) - /// - [ObservableProperty] - private bool _allowDuplicateChar; - - /// - /// 切换队伍时使用 OcrMatch 模糊匹配代替正则表达式匹配 - /// - [ObservableProperty] - private bool _useOcrMatchForPartySwitch = true; - - /// - /// OcrMatch 模糊匹配的默认阈值 (0~1),分数 ≥ 阈值视为匹配成功 - /// - [ObservableProperty] - private double _ocrMatchDefaultThreshold = 0.8; - - partial void OnOcrMatchDefaultThresholdChanged(double value) - { - if (value is <= 0 or > 1) - { - OcrMatchDefaultThreshold = Math.Clamp(value, 0.01, 1); - } - } - - /// - /// PaddleOCR 识别置信度阈值 (0~1),低于此阈值的字符将被过滤 - /// - [ObservableProperty] - private double _paddleOcrThreshold = 0.5; - - partial void OnPaddleOcrThresholdChanged(double value) - { - if (value is < 0 or >= 1) - { - PaddleOcrThreshold = Math.Clamp(value, 0, 0.99); - } - } } //public partial class OtherConfig : ObservableObject diff --git a/BetterGenshinImpact/Core/Config/PathingPartyConfig.cs b/BetterGenshinImpact/Core/Config/PathingPartyConfig.cs index 59356af3..82a55958 100644 --- a/BetterGenshinImpact/Core/Config/PathingPartyConfig.cs +++ b/BetterGenshinImpact/Core/Config/PathingPartyConfig.cs @@ -21,6 +21,9 @@ public partial class PathingPartyConfig : ObservableObject // 切换到队伍的名称 [ObservableProperty] private string _partyName = string.Empty; + + [JsonIgnore] + public bool SkipPartySwitch { get; set; } // 切换队伍前是否前往须弥七天神像 [ObservableProperty] diff --git a/BetterGenshinImpact/Core/Monitor/MouseKeyMonitor.cs b/BetterGenshinImpact/Core/Monitor/MouseKeyMonitor.cs index e6a5867a..e33aae14 100644 --- a/BetterGenshinImpact/Core/Monitor/MouseKeyMonitor.cs +++ b/BetterGenshinImpact/Core/Monitor/MouseKeyMonitor.cs @@ -102,6 +102,11 @@ public partial class MouseKeyMonitor // Debug.WriteLine("KeyDown: \t{0}", e.KeyCode); GlobalKeyMouseRecord.Instance.GlobalHookKeyDown(e, Kernel32.GetTickCount()); + if (SystemControl.IsGenshinImpactActive()) + { + ChatUiHotkeyGuard.PrimeFromChatKey(e.KeyCode); + } + // 热键按下事件 HotKeyDown(sender, e); diff --git a/BetterGenshinImpact/Core/Recognition/OCR/Engine/OcrUtils.cs b/BetterGenshinImpact/Core/Recognition/OCR/Engine/OcrUtils.cs index d82aec52..f4be7343 100644 --- a/BetterGenshinImpact/Core/Recognition/OCR/Engine/OcrUtils.cs +++ b/BetterGenshinImpact/Core/Recognition/OCR/Engine/OcrUtils.cs @@ -16,9 +16,9 @@ public static class OcrUtils /// 预处理速度比unsafe快5倍以上,且吃的资源还少 /// /// 输入图像,若不是灰度图会转换 - /// tensor的Memory,用完需要释放 + /// tensor的Memory,用完需要释放 /// - public static Tensor ToTensorYapDnn(Mat inputImage, out IMemoryOwner tensorMemoryOwner) + public static Tensor ToTensorYapDnn(Mat inputImage, out IMemoryOwner tensorMemoryOwnser) { using var rt = new ResourcesTracker(); Mat dst; @@ -40,10 +40,10 @@ public static class OcrUtils // 使用向量运算代替循环 var blob = rt.T(CvDnn.BlobFromImage(padded, 1.0 / 255.0, default, default, false, false)); var nCols = padded.Cols * padded.Rows; - tensorMemoryOwner = MemoryPool.Shared.Rent(nCols); + tensorMemoryOwnser = MemoryPool.Shared.Rent(nCols); // 内存复制,如果直接传指针构建的话速度还不如多复制一份 - blob.AsSpan().CopyTo(tensorMemoryOwner.Memory.Span); - return new DenseTensor(tensorMemoryOwner.Memory[..nCols], [1, 1, 32, 384]); + blob.AsSpan().CopyTo(tensorMemoryOwnser.Memory.Span); + return new DenseTensor(tensorMemoryOwnser.Memory[..nCols], [1, 1, 32, 384]); } /// @@ -180,119 +180,6 @@ public static class OcrUtils }; } - /// - /// 从标签列表构建字符串→索引字典,供 Rec 模糊匹配使用。 - /// 索引从1开始(0为CTC空白符),空格字符为 labels.Count+1。 - /// - /// 识别模型的标签列表 - /// 各标签的字符长度集合(降序排列,用于从长到短贪心匹配) - public static IReadOnlyDictionary CreateLabelDict( - IReadOnlyList labels, out int[] labelLengths) - { - var dict = new Dictionary(); - var lengths = new HashSet(); - for (var i = 0; i < labels.Count; i++) - { - if (labels[i] == " ") continue; - var len = labels[i].Length; - if (len > 0) lengths.Add(len); - dict[labels[i]] = i + 1; - } - // 空格字符对应索引 labels.Count + 1 - dict[" "] = labels.Count + 1; - lengths.Add(1); - // 降序:先尝试更长的标签 - labelLengths = lengths.OrderByDescending(x => x).ToArray(); - return dict; - } - - /// - /// 根据额外权重字典,创建与标签列表等长的权重数组(用于加权推理分数)。 - /// 未指定权重的标签默认为 1.0。 - /// - public static float[] CreateWeights( - Dictionary extraWeights, IReadOnlyDictionary labelDict, int labelCount) - { - var result = new float[labelCount + 2]; - Array.Fill(result, 1.0f); - foreach (var (key, value) in extraWeights) - { - if (!labelDict.TryGetValue(key, out var index)) continue; - if (index >= 0 && index < result.Length) - { - result[index] = value; - } - } - return result; - } - - /// - /// 将目标字符串映射为标签索引序列。 - /// 使用贪心从长到短匹配,无法映射的字符会被跳过。 - /// - /// 目标字符串 - /// 标签→索引字典(由 CreateLabelDict 生成) - /// 标签长度集合,降序排列(由 CreateLabelDict 生成) - public static int[] MapStringToLabelIndices( - string target, - IReadOnlyDictionary labelDict, - int[] labelLengths) - { - var chars = target.ToCharArray(); - var targetIndices = new int[chars.Length]; - Array.Fill(targetIndices, -1); - var index = 0; - while (index < chars.Length) - { - var found = false; - foreach (var labelLength in labelLengths) - { - if (index + labelLength > chars.Length) continue; - var subStr = new string(chars, index, labelLength); - if (!labelDict.TryGetValue(subStr, out var labelIndex)) continue; - targetIndices[index] = labelIndex; - index += labelLength; - found = true; - break; - } - if (!found) index++; - } - - return targetIndices.Where(x => x != -1).ToArray(); - } - - /// - /// 动态规划最大子序列匹配。 - /// 在 result 序列中找到 target 的最大置信度子序列匹配,返回归一化分数 (0~1)。 - /// - /// OCR 输出的 (labelIndex, confidence) 序列 - /// 目标标签索引序列 - /// 归一化分母(通常为 target.Length,得到每个目标字符的平均置信度) - public static double GetMaxScoreDp((int, float)[] result, int[] target, int availableCount) - { - if (target.Length == 0 || availableCount <= 0) return 0; - - var dp = new double[target.Length + 1]; - dp[0] = 0; - for (var j = 1; j <= target.Length; j++) - dp[j] = -255d; // 不可达 - - foreach (var (index, confidence) in result) - { - // 逆序更新,避免同一 result 元素被多次使用 - for (var j = target.Length; j >= 1; j--) - { - if (index != target[j - 1]) continue; - if (!(dp[j - 1] > -200)) continue; // 前序不可达 - var newSum = dp[j - 1] + confidence; - if (newSum > dp[j]) dp[j] = newSum; - } - } - - if (dp[target.Length] <= -200) return 0; // 无法完整匹配 - return dp[target.Length] / availableCount; - } - public static Mat Tensor2Mat(Tensor tensor) { var dimensions = tensor.Dimensions; diff --git a/BetterGenshinImpact/Core/Recognition/OCR/IOcrMatchService.cs b/BetterGenshinImpact/Core/Recognition/OCR/IOcrMatchService.cs deleted file mode 100644 index a76f2fca..00000000 --- a/BetterGenshinImpact/Core/Recognition/OCR/IOcrMatchService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using OpenCvSharp; - -namespace BetterGenshinImpact.Core.Recognition.OCR; - -/// -/// 基于 DP 模糊匹配的 OCR 服务接口,返回匹配置信度分数 (0~1)。 -/// 独立于 IOcrService,仅由支持模糊匹配的引擎实现。 -/// -public interface IOcrMatchService -{ - /// - /// 使用检测器定位文字区域后,对每个区域进行模糊匹配,返回最高置信度 (0~1)。 - /// - /// 输入图像(推荐三通道 BGR) - /// 目标字符串 - /// 匹配置信度,0 表示完全不匹配,1 表示完全匹配 - double OcrMatch(Mat mat, string target); - - /// - /// 不使用检测器,直接对整张图像进行模糊匹配,返回置信度 (0~1)。 - /// - /// 输入图像(推荐三通道 BGR) - /// 目标字符串 - /// 匹配置信度,0 表示完全不匹配,1 表示完全匹配 - double OcrMatchDirect(Mat mat, string target); -} diff --git a/BetterGenshinImpact/Core/Recognition/OCR/OcrFactory.cs b/BetterGenshinImpact/Core/Recognition/OCR/OcrFactory.cs index 7e6b49d7..6ff3cdaf 100644 --- a/BetterGenshinImpact/Core/Recognition/OCR/OcrFactory.cs +++ b/BetterGenshinImpact/Core/Recognition/OCR/OcrFactory.cs @@ -1,6 +1,5 @@ using System; using System.Globalization; -using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.Core.Config; using BetterGenshinImpact.Core.Recognition.OCR.Paddle; @@ -19,27 +18,7 @@ public class OcrFactory : IDisposable public static IOcrService Paddle => App.ServiceProvider.GetRequiredService().PaddleOcr; private IOcrService PaddleOcr => _paddleOcrService ??= Create(OcrEngineTypes.Paddle); - /// - /// 获取支持模糊匹配的 OCR 服务。 - /// 若引擎原生支持 IOcrMatchService 则直接返回,否则回退到普通 OCR + 字符串相似度。 - /// 访问此属性会触发 Paddle 引擎的懒加载。 - /// - public static IOcrMatchService PaddleMatch - { - get - { - var factory = App.ServiceProvider.GetRequiredService(); - var service = factory.PaddleOcr; - if (service is IOcrMatchService matchService) - return matchService; - var fallback = new OcrMatchFallbackService(service); - return Interlocked.CompareExchange(ref factory._paddleOcrMatchFallback, fallback, null) - ?? fallback; - } - } - private IOcrService? _paddleOcrService; - private IOcrMatchService? _paddleOcrMatchFallback; private readonly ILogger _logger; private readonly OtherConfig.Ocr _config; @@ -108,33 +87,34 @@ public class OcrFactory : IDisposable private PaddleOcrService CreatePaddleOcrInstance() { - var allowDuplicateChar = _config.AllowDuplicateChar; - var threshold = (float)_config.PaddleOcrThreshold; - var factory = App.ServiceProvider.GetRequiredService(); return _config.PaddleOcrModelConfig switch { PaddleOcrModelConfig.V4Auto => - new PaddleOcrService(factory, + new PaddleOcrService(App.ServiceProvider.GetRequiredService(), PaddleOcrService.PaddleOcrModelType.FromCultureInfoV4(GetCultureInfo()) ?? - PaddleOcrService.PaddleOcrModelType.V4, - allowDuplicateChar, threshold), + PaddleOcrService.PaddleOcrModelType.V4), PaddleOcrModelConfig.V5Auto => - new PaddleOcrService(factory, + new PaddleOcrService(App.ServiceProvider.GetRequiredService(), PaddleOcrService.PaddleOcrModelType.FromCultureInfo(GetCultureInfo()) ?? - PaddleOcrService.PaddleOcrModelType.V5, - allowDuplicateChar, threshold), + PaddleOcrService.PaddleOcrModelType.V5), PaddleOcrModelConfig.V5 => - new PaddleOcrService(factory, PaddleOcrService.PaddleOcrModelType.V5, allowDuplicateChar, threshold), + new PaddleOcrService(App.ServiceProvider.GetRequiredService(), + PaddleOcrService.PaddleOcrModelType.V5), PaddleOcrModelConfig.V4 => - new PaddleOcrService(factory, PaddleOcrService.PaddleOcrModelType.V4, allowDuplicateChar, threshold), + new PaddleOcrService(App.ServiceProvider.GetRequiredService(), + PaddleOcrService.PaddleOcrModelType.V4), PaddleOcrModelConfig.V4En => - new PaddleOcrService(factory, PaddleOcrService.PaddleOcrModelType.V4En, allowDuplicateChar, threshold), + new PaddleOcrService(App.ServiceProvider.GetRequiredService(), + PaddleOcrService.PaddleOcrModelType.V4En), PaddleOcrModelConfig.V5Korean => - new PaddleOcrService(factory, PaddleOcrService.PaddleOcrModelType.V5Korean, allowDuplicateChar, threshold), + new PaddleOcrService(App.ServiceProvider.GetRequiredService(), + PaddleOcrService.PaddleOcrModelType.V5Korean), PaddleOcrModelConfig.V5Latin => - new PaddleOcrService(factory, PaddleOcrService.PaddleOcrModelType.V5Latin, allowDuplicateChar, threshold), + new PaddleOcrService(App.ServiceProvider.GetRequiredService(), + PaddleOcrService.PaddleOcrModelType.V5Latin), PaddleOcrModelConfig.V5Eslav => - new PaddleOcrService(factory, PaddleOcrService.PaddleOcrModelType.V5Eslav, allowDuplicateChar, threshold), + new PaddleOcrService(App.ServiceProvider.GetRequiredService(), + PaddleOcrService.PaddleOcrModelType.V5Eslav), _ => throw new ArgumentOutOfRangeException(nameof(_config.PaddleOcrModelConfig), _config.PaddleOcrModelConfig, "不支持的 Paddle OCR 模型配置") }; @@ -143,7 +123,6 @@ public class OcrFactory : IDisposable public Task Unload() { - _paddleOcrMatchFallback = null; if (_paddleOcrService is not IDisposable disposable) { _paddleOcrService = null; diff --git a/BetterGenshinImpact/Core/Recognition/OCR/OcrMatchFallbackService.cs b/BetterGenshinImpact/Core/Recognition/OCR/OcrMatchFallbackService.cs deleted file mode 100644 index 9d38d39d..00000000 --- a/BetterGenshinImpact/Core/Recognition/OCR/OcrMatchFallbackService.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Diagnostics; -using OpenCvSharp; - -namespace BetterGenshinImpact.Core.Recognition.OCR; - -/// -/// 当 OCR 引擎不支持 IOcrMatchService 时的回退实现。 -/// 使用普通 OCR 识别文字后,通过字符串相似度进行匹配。 -/// -public class OcrMatchFallbackService : IOcrMatchService -{ - private readonly IOcrService _ocrService; - - public OcrMatchFallbackService(IOcrService ocrService) - { - _ocrService = ocrService; - } - - public double OcrMatch(Mat mat, string target) - { - var startTime = Stopwatch.GetTimestamp(); - var ocrResult = _ocrService.OcrResult(mat); - var score = ComputeBestTextSimilarity(ocrResult, target); - var time = Stopwatch.GetElapsedTime(startTime); - Debug.WriteLine($"OcrMatchFallback 耗时 {time.TotalMilliseconds}ms 目标: {target} 分数: {score:F4}"); - return score; - } - - public double OcrMatchDirect(Mat mat, string target) - { - var startTime = Stopwatch.GetTimestamp(); - var text = _ocrService.OcrWithoutDetector(mat); - var score = ComputeTextSimilarity(text, target); - var time = Stopwatch.GetElapsedTime(startTime); - Debug.WriteLine($"OcrMatchDirectFallback 耗时 {time.TotalMilliseconds}ms 目标: {target} 分数: {score:F4}"); - return score; - } - - /// - /// 在 OCR 结果的所有区域中找到与目标字符串最相似的分数。 - /// - private static double ComputeBestTextSimilarity(OcrResult ocrResult, string target) - { - double bestScore = 0; - foreach (var region in ocrResult.Regions) - { - var score = ComputeTextSimilarity(region.Text, target); - if (score > bestScore) bestScore = score; - if (score >= 1.0) break; - } - - return bestScore; - } - - /// - /// 计算两个字符串的相似度 (0~1)。 - /// 优先检查子串包含关系,否则使用编辑距离计算。 - /// - public static double ComputeTextSimilarity(string text, string target) - { - if (string.IsNullOrEmpty(target)) return 1.0; - if (string.IsNullOrEmpty(text)) return 0.0; - if (text.Contains(target, StringComparison.OrdinalIgnoreCase)) return 1.0; - if (target.Contains(text, StringComparison.OrdinalIgnoreCase)) return (double)text.Length / target.Length; - - var distance = LevenshteinDistance(text, target); - var maxLen = Math.Max(text.Length, target.Length); - return 1.0 - (double)distance / maxLen; - } - - /// - /// 计算两个字符串之间的编辑距离(Levenshtein Distance)。 - /// - public static int LevenshteinDistance(string s, string t) - { - var sLen = s.Length; - var tLen = t.Length; - var prev = new int[tLen + 1]; - var curr = new int[tLen + 1]; - - for (var j = 0; j <= tLen; j++) - prev[j] = j; - - for (var i = 1; i <= sLen; i++) - { - curr[0] = i; - for (var j = 1; j <= tLen; j++) - { - var cost = s[i - 1] == t[j - 1] ? 0 : 1; - curr[j] = Math.Min(Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); - } - - (prev, curr) = (curr, prev); - } - - return prev[tLen]; - } -} diff --git a/BetterGenshinImpact/Core/Recognition/OCR/Paddle/Det.cs b/BetterGenshinImpact/Core/Recognition/OCR/Paddle/Det.cs index b49a4ca2..6c2c25db 100644 --- a/BetterGenshinImpact/Core/Recognition/OCR/Paddle/Det.cs +++ b/BetterGenshinImpact/Core/Recognition/OCR/Paddle/Det.cs @@ -30,7 +30,15 @@ public class Det(BgiOnnxModel model, OcrVersionConfig config, BgiOnnxFactory bgi /// Gets or sets the ratio for enlarging text boxes during post-processing. public float UnclipRatio { get; set; } = 2.0f; - + + ~Det() + { + lock (_session) + { + _session.Dispose(); + } + } + public void Dispose() { lock (_session) diff --git a/BetterGenshinImpact/Core/Recognition/OCR/Paddle/PaddleOcrService.cs b/BetterGenshinImpact/Core/Recognition/OCR/Paddle/PaddleOcrService.cs index 01f29392..dee1aef5 100644 --- a/BetterGenshinImpact/Core/Recognition/OCR/Paddle/PaddleOcrService.cs +++ b/BetterGenshinImpact/Core/Recognition/OCR/Paddle/PaddleOcrService.cs @@ -16,7 +16,7 @@ using Size = OpenCvSharp.Size; namespace BetterGenshinImpact.Core.Recognition.OCR.Paddle; -public class PaddleOcrService : IOcrService, IOcrMatchService, IDisposable +public class PaddleOcrService : IOcrService, IDisposable { /// /// Usage: @@ -104,11 +104,11 @@ public class PaddleOcrService : IOcrService, IOcrMatchService, IDisposable TestImagePath); } - public (Det, Rec) Build(BgiOnnxFactory onnxFactory, bool allowDuplicateChar = false, float threshold = 0.5f) + public (Det, Rec) Build(BgiOnnxFactory onnxFactory) { return ( new Det(DetectionModel, DetectionVersion, onnxFactory), - new Rec(RecognitionModel, RecLabel(), RecognitionVersion, onnxFactory, allowDuplicateChar, threshold: threshold)); + new Rec(RecognitionModel, RecLabel(), RecognitionVersion, onnxFactory)); } public static readonly PaddleOcrModelType V4 = Create( @@ -240,10 +240,9 @@ public class PaddleOcrService : IOcrService, IOcrMatchService, IDisposable } } - public PaddleOcrService(BgiOnnxFactory bgiOnnxFactory, PaddleOcrModelType modelType, - bool allowDuplicateChar = false, float threshold = 0.5f) + public PaddleOcrService(BgiOnnxFactory bgiOnnxFactory, PaddleOcrModelType modelType) { - var (modelsDet, modelsRec) = modelType.Build(bgiOnnxFactory, allowDuplicateChar, threshold); + var (modelsDet, modelsRec) = modelType.Build(bgiOnnxFactory); _localDetModel = modelsDet; _localRecModel = modelsRec; @@ -269,8 +268,13 @@ public class PaddleOcrService : IOcrService, IOcrMatchService, IDisposable /// public OcrResult OcrResult(Mat mat) { - using var converted = ConvertBgrIfNeeded(mat); - return _OcrResult(converted ?? mat); + if (mat.Channels() == 4) + { + using var mat3 = mat.CvtColor(ColorConversionCodes.BGRA2BGR); + return _OcrResult(mat3); + } + + return _OcrResult(mat); } /// @@ -335,61 +339,6 @@ public class PaddleOcrService : IOcrService, IOcrMatchService, IDisposable Math.Clamp(rect.Bottom, 0, size.Height)); } - /// - /// 若输入为 BGRA 则转换为 BGR,否则返回 null。 - /// 调用方需在使用后 Dispose 返回的 Mat(若非 null)。 - /// - private static Mat? ConvertBgrIfNeeded(Mat mat) - { - return mat.Channels() == 4 ? mat.CvtColor(ColorConversionCodes.BGRA2BGR) : null; - } - - /// - /// 使用检测器定位文字区域后,对每个区域进行 DP 模糊匹配,返回最高置信度 (0~1)。 - /// - public double OcrMatch(Mat mat, string target) - { - var startTime = Stopwatch.GetTimestamp(); - - using var src = ConvertBgrIfNeeded(mat); - var bgr = src ?? mat; - - var rects = _localDetModel.Run(bgr); - Mat[] mats = rects.Select(rect => - { - var roi = bgr[GetCropedRect(rect.BoundingRect(), bgr.Size())]; - return roi; - }).ToArray(); - - try - { - var score = _localRecModel.RunMatch(mats, target); - var time = Stopwatch.GetElapsedTime(startTime); - Debug.WriteLine($"PaddleOcrMatch 耗时 {time.TotalMilliseconds}ms 目标: {target} 分数: {score:F4}"); - return score; - } - finally - { - foreach (var m in mats) m.Dispose(); - } - } - - /// - /// 不使用检测器,直接对整张图像进行 DP 模糊匹配,返回置信度 (0~1)。 - /// - public double OcrMatchDirect(Mat mat, string target) - { - var startTime = Stopwatch.GetTimestamp(); - - using var src = ConvertBgrIfNeeded(mat); - var bgr = src ?? mat; - - var score = _localRecModel.RunMatch([bgr], target); - var time = Stopwatch.GetElapsedTime(startTime); - Debug.WriteLine($"PaddleOcrMatchDirect 耗时 {time.TotalMilliseconds}ms 目标: {target} 分数: {score:F4}"); - return score; - } - public void Dispose() { _localDetModel.Dispose(); diff --git a/BetterGenshinImpact/Core/Recognition/OCR/Paddle/Rec.cs b/BetterGenshinImpact/Core/Recognition/OCR/Paddle/Rec.cs index 68d2a1d2..e6641b7c 100644 --- a/BetterGenshinImpact/Core/Recognition/OCR/Paddle/Rec.cs +++ b/BetterGenshinImpact/Core/Recognition/OCR/Paddle/Rec.cs @@ -7,66 +7,22 @@ using System.Text; using BetterGenshinImpact.Core.Recognition.OCR.Engine; using BetterGenshinImpact.Core.Recognition.OCR.Engine.data; using BetterGenshinImpact.Core.Recognition.ONNX; -using BetterGenshinImpact.Helpers; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; namespace BetterGenshinImpact.Core.Recognition.OCR.Paddle; -/// -/// OCR 识别器,支持标准文字识别和基于动态规划的模糊匹配。 -/// 模糊匹配将目标字符串与模型原始输出序列做子序列匹配,返回 0~1 的置信度分数, -/// 比先识别再字符串匹配更能容忍 OCR 噪声。 -/// -public class Rec : IDisposable +public class Rec( + BgiOnnxModel model, + IReadOnlyList labels, + OcrVersionConfig config, + BgiOnnxFactory bgiOnnxFactory) + : IDisposable { - private readonly InferenceSession _session; - private readonly IReadOnlyList _labels; - private readonly OcrVersionConfig _config; - private readonly bool _allowDuplicateChar; - private readonly float _threshold; + private readonly InferenceSession _session = bgiOnnxFactory.CreateInferenceSession(model, true); - // 模糊匹配相关字段 - - /// 标签长度集合(降序),用于从长到短贪心匹配目标字符串 - private readonly int[] _labelLengths; - - /// 标签字符串→索引字典,索引从1开始(0为CTC空白符) - private readonly IReadOnlyDictionary _labelDict; - - /// 按标签索引的权重数组,用于加权推理分数;为 null 时不加权 - private readonly float[]? _weights; - - /// 目标字符串→标签索引序列的 LRU 缓存,加速重复查询 - private readonly CacheHelper.LruCache _targetCache = new(128); - - /// - /// ONNX 推理输出的命名张量结构,替代匿名元组 (int[], float[])。 - /// - private readonly record struct TensorResult(int Batch, int TimeSteps, int LabelCount, float[] Data); - - public Rec( - BgiOnnxModel model, - IReadOnlyList labels, - OcrVersionConfig config, - BgiOnnxFactory bgiOnnxFactory, - bool allowDuplicateChar = false, - Dictionary? extraWeights = null, - float threshold = 0.5f) - { - _session = bgiOnnxFactory.CreateInferenceSession(model, true); - _labels = labels; - _config = config; - _allowDuplicateChar = allowDuplicateChar; - _threshold = threshold; - - _labelDict = OcrUtils.CreateLabelDict(labels, out var labelLengths); - _labelLengths = labelLengths; - _weights = extraWeights is { Count: > 0 } - ? OcrUtils.CreateWeights(extraWeights, _labelDict, labels.Count) - : null; - } + // _labels = File.ReadAllLines(labelFilePath); public void Dispose() { @@ -77,14 +33,42 @@ public class Rec : IDisposable GC.SuppressFinalize(this); } + + ~Rec() + { + lock (_session) + { + _session.Dispose(); + } + } + /// - /// 对多张图像按批次执行 OCR 识别。 + /// Run OCR recognition on multiple images in batches. /// + /// Array of images for OCR recognition. + /// Size of the batch to run OCR recognition on. + /// Array of instances corresponding to OCR recognition results of the images. public OcrRecognizerResult[] Run(Mat[] srcs, int batchSize = 0) - => RunBatch(srcs, RunMulti, batchSize); + { + if (srcs.Length == 0) return []; + + var chooseBatchSize = batchSize != 0 ? batchSize : Math.Min(8, Environment.ProcessorCount); + + return srcs + .Select((x, i) => (mat: x, i)) + .OrderBy(x => x.mat.Width) + .Chunk(chooseBatchSize) + .Select(x => (result: RunMulti(x.Select(x1 => x1.mat).ToArray()), ids: x.Select(x1 => x1.i).ToArray())) + .SelectMany(x => x.result.Zip(x.ids, (result, i) => (result, i))) + .OrderBy(x => x.i) + .Select(x => x.result) + .ToArray(); + } public OcrRecognizerResult Run(Mat src) - => RunMulti([src]).Single(); + { + return RunMulti([src]).Single(); + } private OcrRecognizerResult[] RunMulti(Mat[] srcs) { @@ -97,173 +81,17 @@ public class Rec : IDisposable throw new ArgumentException($"src[{i}] size should not be 0, wrong input picture provided?"); } - var resultTensors = RunInference(srcs); - - return resultTensors.SelectMany(tensor => - { - GCHandle dataHandle = default; - try - { - dataHandle = GCHandle.Alloc(tensor.Data, GCHandleType.Pinned); - var dataPtr = dataHandle.AddrOfPinnedObject(); - - return Enumerable.Range(0, tensor.Batch) - .Select(i => - { - StringBuilder sb = new(); - var lastIndex = 0; - float score = 0; - var maxIdx = new int[2]; - using var fullMat = Mat.FromPixelData(tensor.TimeSteps, tensor.LabelCount, - MatType.CV_32FC1, - dataPtr + i * tensor.TimeSteps * tensor.LabelCount * sizeof(float)); - for (var n = 0; n < tensor.TimeSteps; ++n) - { - using var row = fullMat.Row(n); - row.MinMaxIdx(out _, out var maxVal, [], maxIdx); - - if (maxIdx[1] > 0 && maxVal >= _threshold && (_allowDuplicateChar || !(n > 0 && maxIdx[1] == lastIndex))) - { - score += (float)maxVal; - sb.Append(OcrUtils.GetLabelByIndex(maxIdx[1], _labels)); - } - - lastIndex = maxIdx[1]; - } - - var text = sb.ToString(); - return new OcrRecognizerResult(text, text.Length > 0 ? score / text.Length : 0); - }) - .ToArray(); - } - finally - { - if (dataHandle.IsAllocated) dataHandle.Free(); - } - }).ToArray(); - } - - /// - /// 将目标字符串转换为标签索引序列,利用 LRU 缓存加速重复查询。 - /// 无法映射到标签的字符会被跳过。 - /// - public int[] GetTarget(string target) - { - if (_targetCache.TryGet(target, out var cached) && cached is not null) - return cached; - - var result = OcrUtils.MapStringToLabelIndices(target, _labelDict, _labelLengths); - _targetCache.Set(target, result); - return result; - } - - /// - /// 对一批图像执行模糊匹配,返回与目标字符串的最大平均置信度 (0~1)。 - /// - /// 待匹配图像数组 - /// 目标字符串 - /// 每批推理图像数,0表示自动 - public double RunMatch(Mat[] srcs, string target, int batchSize = 0) - { - if (srcs.Length == 0) return 0; - var targetIndexes = GetTarget(target); - if (targetIndexes.Length == 0) return 0; - - var charLevelResults = RunBatch(srcs, - mats => ProcessForMatch(RunInference(mats), targetIndexes), batchSize); - - return GetMaxScoreFlat(charLevelResults, targetIndexes); - } - - /// - /// 从 ONNX 原始输出张量中提取目标字符在每个时间步的置信度。 - /// - /// 与 RunMulti(标准 OCR)不同,此方法不做 argmax(MinMaxIdx), - /// 而是按目标字符的 label 索引直接查找对应位置的原始置信度。 - /// 这样即使目标字符不是某个时间步的最高置信度候选,DP 仍然能拿到其实际分数进行匹配。 - /// - /// - /// RunInference 返回的 (shape, data) 张量数组 - /// 目标字符串映射后的 label 索引序列 - /// 每张图像对应一个 (labelIndex, confidence) 数组,供 DP 匹配使用 - private (int, float)[][] ProcessForMatch(TensorResult[] resultTensors, int[] targetIndexes) - { - // 目标字符去重(排除 CTC 空白符 index=0) - var targetSet = new HashSet(targetIndexes); - targetSet.Remove(0); - - return resultTensors.Select(tensor => - { - var chars = new List<(int, float)>(); - for (var n = 0; n < tensor.TimeSteps * tensor.Batch; n++) - { - // 直接按索引查找目标字符的置信度,而非对整行取 argmax - var rowOffset = n * tensor.LabelCount; - foreach (var labelIdx in targetSet) - { - if (labelIdx >= tensor.LabelCount) continue; - var raw = tensor.Data[rowOffset + labelIdx]; - var confidence = _weights is not null - ? raw * _weights[labelIdx] - : raw; - if (confidence > _threshold) - chars.Add((labelIdx, confidence)); - } - } - return chars.ToArray(); - }).ToArray(); - } - - /// - /// 将多张图像的字符级别结果展平后,计算与 target 的最大匹配分数。 - /// 分母使用 target.Length,得到的是每个目标字符的平均置信度 (0~1)。 - /// - private static double GetMaxScoreFlat((int, float)[][] result, int[] target) - { - var flatResult = result.SelectMany(x => x).ToArray(); - return OcrUtils.GetMaxScoreDp(flatResult, target, target.Length); - } - - /// - /// 通用批处理:按宽度排序、分批推理、恢复原始顺序 - /// - private T[] RunBatch(Mat[] srcs, Func process, int batchSize = 0) - { - if (srcs.Length == 0) return []; - - var chooseBatchSize = batchSize != 0 ? batchSize : Math.Min(8, Environment.ProcessorCount); - - return srcs - .Select((x, i) => (mat: x, i)) - .OrderBy(x => x.mat.Width) - .Chunk(chooseBatchSize) - .Select(chunk => - { - var mats = chunk.Select(x => x.mat).ToArray(); - var result = process(mats); - return (result, ids: chunk.Select(x => x.i).ToArray()); - }) - .SelectMany(x => x.result.Zip(x.ids, (r, i) => (r, i))) - .OrderBy(x => x.i) - .Select(x => x.r) - .ToArray(); - } - - /// - /// 执行 ONNX 推理,返回每张图像的原始 (shape, data) 张量 - /// - private TensorResult[] RunInference(Mat[] srcs) - { - var modelHeight = _config.Shape.Height; + var modelHeight = config.Shape.Height; var maxWidth = (int)Math.Ceiling(srcs.Max(src => { var size = src.Size(); return 1.0 * size.Width / size.Height * modelHeight; })); List> owners = []; + (int[], float[])[] resultTensors; try { - return srcs + resultTensors = srcs // .AsParallel() .Select(src => { @@ -283,10 +111,12 @@ public class Rec : IDisposable { owners.Add(owner); } + return result; } finally { + // Only dispose Mats created in this scope if (channel3 != null && !ReferenceEquals(channel3, src)) { channel3.Dispose(); @@ -294,31 +124,75 @@ public class Rec : IDisposable } }) .Select(inputTensor => - { - lock (_session) { - // 多线程推理会出现问题,加锁解决。 - using IDisposableReadOnlyCollection results = _session.Run([ - NamedOnnxValue.CreateFromTensor(_session.InputNames[0], inputTensor) - ]); - var output = results[0]; - if (output.ElementType is not TensorElementType.Float) - throw new Exception($"Unexpected output tensor type: {output.ElementType}"); + lock (_session) + { + // 多线程推理会出现问题,加锁解决。 + using IDisposableReadOnlyCollection results = _session.Run([ + NamedOnnxValue.CreateFromTensor(_session.InputNames[0], inputTensor) + ]); + var output = results[0]; + if (output.ElementType is not TensorElementType.Float) + throw new Exception($"Unexpected output tensor type: {output.ElementType}"); - if (output.ValueType is not OnnxValueType.ONNX_TYPE_TENSOR) - throw new Exception($"Unexpected output tensor value type: {output.ValueType}"); - var tensor = output.AsTensor(); - // 因为一个已知bug,tensor中内存在dml下使用完后会被释放掉,锁之外的代码会报错 - var dims = tensor.Dimensions; - return new TensorResult(dims[0], dims[1], dims[2], tensor.ToArray()); + if (output.ValueType is not OnnxValueType.ONNX_TYPE_TENSOR) + throw new Exception($"Unexpected output tensor value type: {output.ValueType}"); + var tensor = output.AsTensor(); + // 因为一个已知bug,tensor中内存在dml下使用完后会被释放掉,锁之外的代码会报错 + return (tensor.Dimensions.ToArray(), tensor.ToArray()); + } } - }).ToArray(); + ).ToArray(); } finally { owners.ForEach(x => { x.Dispose(); }); } + + return resultTensors.SelectMany(resultTensor => + { + var resultArray = resultTensor.Item2; + var resultShape = resultTensor.Item1; + GCHandle dataHandle = default; + try + { + dataHandle = GCHandle.Alloc(resultArray, GCHandleType.Pinned); + var dataPtr = dataHandle.AddrOfPinnedObject(); + var labelCount = resultShape[2]; + var charCount = resultShape[1]; + + return Enumerable.Range(0, resultShape[0]) + .Select(i => + { + StringBuilder sb = new(); + var lastIndex = 0; + float score = 0; + for (var n = 0; n < charCount; ++n) + { + using var mat = Mat.FromPixelData(1, labelCount, MatType.CV_32FC1, + dataPtr + (n + i * charCount) * labelCount * sizeof(float)); + var maxIdx = new int[2]; + mat.MinMaxIdx(out _, out var maxVal, [], maxIdx); + + if (maxIdx[1] > 0 && !(n > 0 && maxIdx[1] == lastIndex)) + { + score += (float)maxVal; + sb.Append(OcrUtils.GetLabelByIndex(maxIdx[1], labels)); + } + + lastIndex = maxIdx[1]; + } + + return new OcrRecognizerResult(sb.ToString(), score / sb.Length); + }) + .ToArray(); + } + finally + { + dataHandle.Free(); + } + }).ToArray(); } - public string GetConfigName => _config.Name; -} + public string GetConfigName => config.Name; +} \ No newline at end of file diff --git a/BetterGenshinImpact/Core/Recognition/ONNX/SVTR/PickTextInference.cs b/BetterGenshinImpact/Core/Recognition/ONNX/SVTR/PickTextInference.cs index ded28ffc..70b22c21 100644 --- a/BetterGenshinImpact/Core/Recognition/ONNX/SVTR/PickTextInference.cs +++ b/BetterGenshinImpact/Core/Recognition/ONNX/SVTR/PickTextInference.cs @@ -1,4 +1,4 @@ -using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Core.Config; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; @@ -8,9 +8,9 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Text; -using System.Text.Json; using BetterGenshinImpact.Core.Recognition.OCR.Engine; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; namespace BetterGenshinImpact.Core.Recognition.ONNX.SVTR; @@ -31,7 +31,7 @@ public class PickTextInference : ITextInference if (!File.Exists(wordJsonPath)) throw new FileNotFoundException("Yap字典文件不存在", wordJsonPath); var json = File.ReadAllText(wordJsonPath); - _wordDictionary = JsonSerializer.Deserialize>(json) ?? + _wordDictionary = JsonConvert.DeserializeObject>(json) ?? throw new Exception("index_2_word.json deserialize failed"); } @@ -116,4 +116,4 @@ public class PickTextInference : ITextInference return new DenseTensor(memory, [1, 1, 32, 384]); } -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/Core/Recognition/ONNX/SVTR/TextInferenceFactory.cs b/BetterGenshinImpact/Core/Recognition/ONNX/SVTR/TextInferenceFactory.cs index d4606e45..e6e9700f 100644 --- a/BetterGenshinImpact/Core/Recognition/ONNX/SVTR/TextInferenceFactory.cs +++ b/BetterGenshinImpact/Core/Recognition/ONNX/SVTR/TextInferenceFactory.cs @@ -6,7 +6,7 @@ namespace BetterGenshinImpact.Core.Recognition.ONNX.SVTR; public class TextInferenceFactory { - public static ITextInference Pick { get; } = Create(OcrEngineTypes.YapModel); + public static readonly Lazy Pick = new(() => Create(OcrEngineTypes.YapModel)); public static ITextInference Create(OcrEngineTypes type) { diff --git a/BetterGenshinImpact/Core/Script/Dependence/AutoPathingScript.cs b/BetterGenshinImpact/Core/Script/Dependence/AutoPathingScript.cs index 9dd15fde..ba5d76b3 100644 --- a/BetterGenshinImpact/Core/Script/Dependence/AutoPathingScript.cs +++ b/BetterGenshinImpact/Core/Script/Dependence/AutoPathingScript.cs @@ -1,4 +1,4 @@ -using System; +using System; using BetterGenshinImpact.GameTask.AutoPathing; using BetterGenshinImpact.GameTask.AutoPathing.Model; using System.Threading.Tasks; @@ -12,11 +12,13 @@ public class AutoPathingScript { private object? _config = null; private string _rootPath; + private readonly LimitedFile _autoPathingFile; public AutoPathingScript(string rootPath, object? config) { _config = config; _rootPath = rootPath; + _autoPathingFile = new LimitedFile(Global.Absolute(@"User\AutoPathing")); } public async Task Run(string json) @@ -59,7 +61,40 @@ public class AutoPathingScript /// 在 `\User\AutoPathing` 目录下获取文件 public async Task RunFileFromUser(string path) { - var json = await new LimitedFile(Global.Absolute(@"User\AutoPathing")).ReadText(path); + var json = await AutoPathingFile.ReadText(path); await Run(json); } + + /// + /// 判断 AutoPathing 目录下的路径是否存在 + /// + /// 相对于 User\AutoPathing 的路径 + /// 存在返回 true,否则返回 false + public bool IsExists(string subPath) => AutoPathingFile.IsExists(subPath); + + /// + /// 判断 AutoPathing 目录下的路径是否为文件 + /// + /// 相对于 User\AutoPathing 的路径 + /// 是文件返回 true,否则返回 false + public bool IsFile(string subPath) => AutoPathingFile.IsFile(subPath); + + /// + /// 判断 AutoPathing 目录下的路径是否为文件夹 + /// + /// 相对于 User\AutoPathing 的路径 + /// 是文件夹返回 true,否则返回 false + public bool IsFolder(string subPath) => AutoPathingFile.IsFolder(subPath); + + /// + /// 读取 AutoPathing 目录下指定文件夹的内容(非递归方式) + /// + /// 相对于 User\AutoPathing 的子目录路径,默认为相对根目录 + /// 文件夹内所有文件和文件夹的相对路径数组,出错时返回空数组 + public string[] ReadPathSync(string subPath = "./") => AutoPathingFile.ReadPathSync(subPath); + + /// + /// LimitedFile 实例,用于操作 AutoPathing 目录 + /// + private LimitedFile AutoPathingFile => _autoPathingFile; } \ No newline at end of file diff --git a/BetterGenshinImpact/Core/Script/Dependence/CustomHostFunctions.cs b/BetterGenshinImpact/Core/Script/Dependence/CustomHostFunctions.cs new file mode 100644 index 00000000..be572296 --- /dev/null +++ b/BetterGenshinImpact/Core/Script/Dependence/CustomHostFunctions.cs @@ -0,0 +1,34 @@ +using Microsoft.ClearScript; +using System; +using System.Reflection; + +namespace BetterGenshinImpact.Core.Script.Dependence; + +public class CustomHostFunctions : HostFunctions +{ + /// + /// 创建指定维度的交错数组变量 + /// + /// 数组元素类型 + /// 数组维度 + /// 交错数组变量 + public object NewVarOfArr(int dimensions) + { + try + { + Type arrayType = typeof(T); + for (int i = 0; i < dimensions; i++) + { + arrayType = arrayType.MakeArrayType(); + } + + MethodInfo newVarMethod = typeof(HostFunctions).GetMethod(nameof(newVar))!; + MethodInfo genericMethod = newVarMethod.MakeGenericMethod(arrayType); + return genericMethod.Invoke(this, new object?[] { null })!; + } + catch (Exception ex) + { + throw new InvalidOperationException($"创建维度为 {dimensions} 的数组失败: {ex.Message}", ex); + } + } +} diff --git a/BetterGenshinImpact/Core/Script/Dependence/Dispatcher.cs b/BetterGenshinImpact/Core/Script/Dependence/Dispatcher.cs index dd7e7bc6..b4a2aca6 100644 --- a/BetterGenshinImpact/Core/Script/Dependence/Dispatcher.cs +++ b/BetterGenshinImpact/Core/Script/Dependence/Dispatcher.cs @@ -4,6 +4,7 @@ using BetterGenshinImpact.GameTask; using BetterGenshinImpact.GameTask.AutoDomain; using BetterGenshinImpact.GameTask.AutoEat; using BetterGenshinImpact.GameTask.AutoFishing; +using BetterGenshinImpact.GameTask.AutoCook; using BetterGenshinImpact.GameTask.AutoGeniusInvokation; using BetterGenshinImpact.GameTask.AutoPathing.Handler; using BetterGenshinImpact.GameTask.AutoWood; @@ -19,6 +20,8 @@ using System.Dynamic; using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.GameTask.AutoFight; +using BetterGenshinImpact.GameTask.AutoLeyLineOutcrop; +using BetterGenshinImpact.GameTask.AutoStygianOnslaught; namespace BetterGenshinImpact.Core.Script.Dependence; @@ -193,6 +196,9 @@ public class Dispatcher await new AutoFishingTask(AutoFishingTaskParam.BuildFromSoloTaskConfig(soloTask.Config)).Start( cancellationToken); return null; + case "AutoCook": + await new AutoCookTask().Start(cancellationToken); + return null; case "AutoEat": { string? foodName = soloTask.Config == null ? null : ScriptObjectConverter.GetValue((ScriptObject)soloTask.Config, "foodName", (string?)null); @@ -339,4 +345,39 @@ public class Dispatcher CancellationToken cancellationToken = customCt ?? CancellationContext.Instance.Cts.Token; await new AutoFightTask(param).Start(cancellationToken); } + + /// + /// 运行自动地脉花任务 + /// + /// 自动地脉花任务参数 + /// 自定义取消令牌 + /// + public async Task RunAutoLeyLineOutcropTask(AutoLeyLineOutcropParam param, CancellationToken? customCt = null) + { + if (param == null) + { + throw new ArgumentNullException(nameof(param), "自动地脉花任务参数不能为空"); + } + + CancellationToken cancellationToken = customCt ?? CancellationContext.Instance.Cts.Token; + await new AutoLeyLineOutcropTask(param).Start(cancellationToken); + } + + + /// + /// 运行自动幽境危战任务 + /// + /// 自动幽境危战任务参数 + /// 自定义取消令牌 + /// + public async Task RunAutoStygianOnslaughtTask(AutoStygianOnslaughtParam param, CancellationToken? customCt = null) + { + if (param == null) + { + throw new ArgumentNullException(nameof(param), "自动幽境危战任务参数不能为空"); + } + + CancellationToken cancellationToken = customCt ?? CancellationContext.Instance.Cts.Token; + await new AutoStygianOnslaughtTask(param).Start(cancellationToken); + } } diff --git a/BetterGenshinImpact/Core/Script/Dependence/LimitedFile.cs b/BetterGenshinImpact/Core/Script/Dependence/LimitedFile.cs index 7a17e681..bbf5f715 100644 --- a/BetterGenshinImpact/Core/Script/Dependence/LimitedFile.cs +++ b/BetterGenshinImpact/Core/Script/Dependence/LimitedFile.cs @@ -1,5 +1,7 @@ using BetterGenshinImpact.Core.Script.Utils; +using BetterGenshinImpact.Core.Config; using System; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using OpenCvSharp; @@ -72,6 +74,44 @@ public class LimitedFile(string rootPath) } } + /// + /// 判断指定路径是否为文件 + /// + /// 文件路径(相对于根目录) + /// 如果是文件则返回 true,否则返回 false + public bool IsFile(string path) + { + try + { + string normalizedPath = NormalizePath(path); + return File.Exists(normalizedPath); + } + catch (Exception ex) + { + TaskControl.Logger.LogError("IsFile 异常: {Message}", ex.Message); + return false; + } + } + + /// + /// 判断指定的文件或目录是否存在 + /// + /// 文件或目录路径(相对于根目录) + /// 如果存在返回 true,否则返回 false + public bool IsExists(string path) + { + try + { + string normalizedPath = NormalizePath(path); + return File.Exists(normalizedPath) || Directory.Exists(normalizedPath); + } + catch (Exception ex) + { + TaskControl.Logger.LogError("IsExists 异常: {Message}", ex.Message); + return false; + } + } + /// /// Normalize and validate a path. /// @@ -447,4 +487,51 @@ public class LimitedFile(string rootPath) return true; } + + /// + /// 重命名文件或文件夹(相对于根目录) + /// + /// 原路径 + /// 新路径 + /// 是否重命名成功 + public bool RenamePathSync(string oldPath, string newPath) + { + try + { + // 标准化路径 + oldPath = NormalizePath(oldPath); + newPath = NormalizePath(newPath); + + // 检查原路径是否存在 + if (!File.Exists(oldPath) && !Directory.Exists(oldPath)) + { + TaskControl.Logger.LogError("RenamePathSync 异常: 原路径不存在 {Path}", oldPath); + return false; + } + + //验证扩展名合法性 + if (File.Exists(oldPath) && !IsValid(newPath)) + { + TaskControl.Logger.LogError("RenamePathSync 异常: 新文件路径不合法 {Path}", newPath); + return false; + } + + // 确保目标目录存在 + string? directoryPath = Path.GetDirectoryName(newPath); + if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + // 执行重命名 + Directory.Move(oldPath, newPath); + + return true; + } + catch (Exception ex) + { + TaskControl.Logger.LogError("RenamePathSync 异常: {Message}", ex.Message); + return false; + } + } } diff --git a/BetterGenshinImpact/Core/Script/EngineExtend.cs b/BetterGenshinImpact/Core/Script/EngineExtend.cs index 6bcd472f..e5c487f6 100644 --- a/BetterGenshinImpact/Core/Script/EngineExtend.cs +++ b/BetterGenshinImpact/Core/Script/EngineExtend.cs @@ -13,7 +13,9 @@ using BetterGenshinImpact.Core.Script.Utils; using BetterGenshinImpact.GameTask.AutoDomain; using BetterGenshinImpact.GameTask.AutoFight; using BetterGenshinImpact.GameTask.AutoFight.Model; +using BetterGenshinImpact.GameTask.AutoLeyLineOutcrop; using BetterGenshinImpact.GameTask.AutoSkip; +using BetterGenshinImpact.GameTask.AutoStygianOnslaught; namespace BetterGenshinImpact.Core.Script; @@ -73,6 +75,8 @@ public class EngineExtend engine.AddHostType("AutoDomainParam", typeof(AutoDomainParam)); engine.AddHostType("AutoFightParam", typeof(AutoFightParam)); + engine.AddHostType("AutoLeyLineOutcropParam", typeof(AutoLeyLineOutcropParam)); + engine.AddHostType("AutoStygianOnslaughtParam", typeof(AutoStygianOnslaughtParam)); //鼠标回调 engine.AddHostType("KeyMouseHook", typeof(KeyMouseHook)); // 添加C#的类型 @@ -83,6 +87,7 @@ public class EngineExtend engine.AddHostType("BvLocator", typeof(BvLocator)); engine.AddHostType("BvImage", typeof(BvImage)); + engine.AddHostObject("host", new CustomHostFunctions()); // 导入 JavaScript 模块 // https://microsoft.github.io/ClearScript/2023/01/24/module-interop.html diff --git a/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs b/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs index db054e0d..e324837b 100644 --- a/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs +++ b/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs @@ -56,6 +56,12 @@ public class ScriptRepoUpdater : Singleton /// public event EventHandler? AutoUpdateStateChanged; + /// + /// 命令行启动时并行执行的自动更新 Task。 + /// StartGameTask 结束后会 await 此 Task,确保更新完成后再执行任务。 + /// + public Task? CommandLineAutoUpdateTask { get; set; } + // 仓储位置 public static readonly string ReposPath = Global.Absolute("Repos"); @@ -280,13 +286,26 @@ public class ScriptRepoUpdater : Singleton return (0, 0); } - // 展开所有订阅路径,直接全部更新 + // 展开所有订阅路径 var expandedPaths = ExpandTopLevelPaths(subscribedPaths, repoPath); + // 过滤掉仓库中已不存在的路径(幽灵订阅),避免删除用户文件后检出空内容 + var validPaths = FilterExistingPaths(expandedPaths, repoPath); + + // 清理订阅文件中的幽灵项:直接对原始订阅路径做过滤 + if (validPaths.Count < expandedPaths.Count) + { + var cleaned = FilterExistingPaths(subscribedPaths, repoPath); + if (cleaned.Count < subscribedPaths.Count) + { + SetSubscribedPathsForCurrentRepo(cleaned); + } + } + int successCount = 0; int failCount = 0; - foreach (var path in expandedPaths) + foreach (var path in validPaths) { try { @@ -412,6 +431,43 @@ public class ScriptRepoUpdater : Singleton return result; } + /// + /// 过滤掉仓库中已不存在的路径,防止幽灵订阅导致误删用户文件。 + /// + private List FilterExistingPaths(List paths, string repoPath) + { + bool isGitRepo = IsGitRepository(repoPath); + + if (isGitRepo) + { + using var repo = new Repository(repoPath); + if (repo.Head.Tip == null) return paths; + var repoTree = GetRepoSubdirectoryTree(repo); + + return paths.Where(path => + { + var parts = path.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + var currentTree = repoTree; + foreach (var part in parts) + { + var entry = currentTree[part]; + if (entry == null) return false; + if (entry.TargetType == TreeEntryTargetType.Tree) + currentTree = (Tree)entry.Target; + } + return true; + }).ToList(); + } + else + { + return paths.Where(path => + { + var fullPath = Path.Combine(repoPath, path); + return Directory.Exists(fullPath) || File.Exists(fullPath); + }).ToList(); + } + } + /// /// 静默更新中央仓库(用于自动更新订阅脚本前同步最新仓库内容)。 /// 注意:此方法设计为在 _repoWriteLock 持有期间调用, @@ -2414,73 +2470,8 @@ public class ScriptRepoUpdater : Singleton if (oldPaths.Count == 0) return; - // 默认归入当前仓库 - var repoFolderName = GetCurrentRepoFolderName(); - - // 如果存在多个仓库,尝试按 repo.json 分配路径 - if (Directory.Exists(ReposPath)) - { - var repoDirs = Directory.GetDirectories(ReposPath) - .Where(d => !Path.GetFileName(d).Equals("Temp", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (repoDirs.Count > 1) - { - var repoPathSets = new Dictionary>(); - foreach (var repoDir in repoDirs) - { - var repoJsonFile = Directory.GetFiles(repoDir, "repo.json", SearchOption.AllDirectories).FirstOrDefault(); - if (string.IsNullOrEmpty(repoJsonFile)) continue; - try - { - var json = File.ReadAllText(repoJsonFile); - var jsonObj = JObject.Parse(json); - if (jsonObj["indexes"] is JArray indexes) - { - var pathSet = new HashSet(); - CollectAllPathsFromIndexes(indexes, "", pathSet); - repoPathSets[Path.GetFileName(repoDir)] = pathSet; - } - } - catch { /* ignore */ } - } - - if (repoPathSets.Count > 1) - { - // 按仓库聚合后批量写入 - var repoSubscriptions = new Dictionary>(); - foreach (var path in oldPaths) - { - var targetRepo = repoFolderName; // 默认归入当前仓库 - foreach (var (repoName, pathSet) in repoPathSets) - { - if (pathSet.Contains(path)) - { - targetRepo = repoName; - break; - } - } - - if (!repoSubscriptions.ContainsKey(targetRepo)) - repoSubscriptions[targetRepo] = new List(); - repoSubscriptions[targetRepo].Add(path); - } - - foreach (var (repoName, paths) in repoSubscriptions) - { - WriteSubscriptionFile(GetSubscriptionFilePath(repoName), paths); - } - - // 清空配置属性,框架自动保存 - scriptConfig.SubscribedScriptPaths = new List(); - _logger.LogInformation("已完成订阅路径迁移到独立文件(多仓库分配)"); - return; - } - } - } - - // 单仓库:直接写入 - WriteSubscriptionFile(GetSubscriptionFilePath(repoFolderName), new List(oldPaths)); + // 全部归入当前仓库,幽灵路径由后续 UpdateAllSubscribedScriptsCore 统一清理 + WriteSubscriptionFile(GetSubscriptionFilePath(GetCurrentRepoFolderName()), [.. oldPaths]); // 清空配置属性,框架自动保存 scriptConfig.SubscribedScriptPaths = new List(); @@ -2492,30 +2483,6 @@ public class ScriptRepoUpdater : Singleton } } - /// - /// 递归收集 indexes 中所有路径(用于迁移时匹配) - /// - private static void CollectAllPathsFromIndexes(JArray nodes, string currentPath, HashSet result) - { - foreach (var node in nodes) - { - if (node is JObject nodeObj) - { - var name = nodeObj["name"]?.ToString(); - if (!string.IsNullOrEmpty(name)) - { - var fullPath = string.IsNullOrEmpty(currentPath) ? name : $"{currentPath}/{name}"; - result.Add(fullPath); - - if (nodeObj["children"] is JArray children) - { - CollectAllPathsFromIndexes(children, fullPath, result); - } - } - } - } - } - // 更新订阅脚本路径列表,移除无效路径(仅处理当前仓库的订阅) public void UpdateSubscribedScriptPaths() { diff --git a/BetterGenshinImpact/GameTask/AutoCook/AutoCookConfig.cs b/BetterGenshinImpact/GameTask/AutoCook/AutoCookConfig.cs index 502300ae..d79b901c 100644 --- a/BetterGenshinImpact/GameTask/AutoCook/AutoCookConfig.cs +++ b/BetterGenshinImpact/GameTask/AutoCook/AutoCookConfig.cs @@ -1,17 +1,14 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; using System; namespace BetterGenshinImpact.GameTask.AutoCook; -/// -///自动烹饪配置 -/// [Serializable] public partial class AutoCookConfig : ObservableObject { - /// - /// 触发器是否启用 - /// [ObservableProperty] - private bool _enabled = false; + private int _checkIntervalMs = 10; + + [ObservableProperty] + private bool _stopTaskWhenRecoverButtonDetected = true; } diff --git a/BetterGenshinImpact/GameTask/AutoCook/AutoCookTask.cs b/BetterGenshinImpact/GameTask/AutoCook/AutoCookTask.cs new file mode 100644 index 00000000..1bef8b45 --- /dev/null +++ b/BetterGenshinImpact/GameTask/AutoCook/AutoCookTask.cs @@ -0,0 +1,173 @@ +using BetterGenshinImpact.Core.Simulator; +using BetterGenshinImpact.GameTask.Common.Element.Assets; +using BetterGenshinImpact.GameTask.Model.Area; +using Microsoft.Extensions.Logging; +using OpenCvSharp; +using System; +using System.Threading; +using System.Threading.Tasks; +using BetterGenshinImpact.GameTask.Common.BgiVision; +using Vanara.PInvoke; +using static BetterGenshinImpact.GameTask.Common.TaskControl; + +namespace BetterGenshinImpact.GameTask.AutoCook; + +public class AutoCookTask : ISoloTask +{ + private readonly ILogger _logger = App.GetLogger(); + private const int UiCheckIntervalMs = 400; + private const int PeakMinCount = 600; // 最小仙跳墙 700 多 + private const int PeakTolerance = 20; + private const int PeakStableFrameCount = 3; + private const int TriggerDropCount = 300; // 正常是 400多 + private static readonly Rect CookColorRect1080P = new(600, 660, 730, 190); + private static readonly Scalar TargetCookColor = new(255, 192, 64); + + public string Name => "自动烹饪"; + + public async Task Start(CancellationToken ct) + { + var assetScale = TaskContext.Instance().SystemInfo.AssetScale; + var autoCookConfig = TaskContext.Instance().Config.AutoCookConfig; + var checkIntervalMs = Math.Max(1, autoCookConfig.CheckIntervalMs); + var stopTaskWhenRecoverButtonDetected = autoCookConfig.StopTaskWhenRecoverButtonDetected; + var peakMinCount = (int)(PeakMinCount * assetScale); + var triggerDropCount = (int)(TriggerDropCount * assetScale); + var cookColorRect = ScaleRect(CookColorRect1080P, assetScale); + _logger.LogInformation("自动烹饪任务启动"); + var lastUiCheckTime = DateTime.MinValue; + var inCookUi = false; + int? peakColorCount = null; + int? peakCandidate = null; + var peakCandidateStableFrames = 0; + + while (!ct.IsCancellationRequested) + { + using var captureRegion = CaptureToRectArea(); + var now = DateTime.UtcNow; + if (!inCookUi || (now - lastUiCheckTime).TotalMilliseconds >= UiCheckIntervalMs) + { + var currentInCookUi = IsInCookUi(captureRegion); + if (currentInCookUi != inCookUi) + { + ResetPeakState(ref peakColorCount, ref peakCandidate, ref peakCandidateStableFrames); + } + + inCookUi = currentInCookUi; + lastUiCheckTime = now; + if (!inCookUi) + { + ResetPeakState(ref peakColorCount, ref peakCandidate, ref peakCandidateStableFrames); + } + else + { + if (stopTaskWhenRecoverButtonDetected) + { + var e = captureRegion.Find(ElementAssets.Instance.BtnWhiteRecover); + if (e.IsExist()) + { + _logger.LogInformation("自动烹饪:{Text}", "检测到自动烹饪按钮,结束任务"); + return; + } + } + + if (Bv.ClickWhiteConfirmButton(captureRegion)) + { + ResetPeakState(ref peakColorCount, ref peakCandidate, ref peakCandidateStableFrames); + _logger.LogInformation("自动烹饪:{Text}", "自动点击确认"); + } + } + } + + if (inCookUi) + { + var currentColorCount = CountTargetColor(captureRegion, cookColorRect); + if (peakColorCount.HasValue) + { + if (currentColorCount <= peakColorCount.Value - triggerDropCount) + { + Simulation.SendInput.Keyboard.KeyPress(User32.VK.VK_SPACE); + _logger.LogInformation("自动烹饪:{Text}", $"烹饪条像素数量较峰值下降超过{triggerDropCount},按下空格。峰值:{peakColorCount.Value} 当前:{currentColorCount}"); + ResetPeakState(ref peakColorCount, ref peakCandidate, ref peakCandidateStableFrames); + } + } + else if (TryBuildPeak(currentColorCount, peakMinCount, ref peakCandidate, ref peakCandidateStableFrames, out var builtPeak)) + { + peakColorCount = builtPeak; + _logger.LogInformation("自动烹饪:{Text}", $"识别到完美烹饪条峰值像素数:{builtPeak}"); + } + } + + await Delay(checkIntervalMs, ct); + } + } + + private static void ResetPeakState(ref int? peakColorCount, ref int? peakCandidate, ref int peakCandidateStableFrames) + { + peakColorCount = null; + peakCandidate = null; + peakCandidateStableFrames = 0; + } + + private static bool TryBuildPeak(int currentColorCount, int peakMinCount, ref int? peakCandidate, ref int peakCandidateStableFrames, out int builtPeak) + { + builtPeak = 0; + if (currentColorCount <= peakMinCount) + { + peakCandidate = null; + peakCandidateStableFrames = 0; + return false; + } + + if (!peakCandidate.HasValue) + { + peakCandidate = currentColorCount; + peakCandidateStableFrames = 1; + return false; + } + + if (Math.Abs(currentColorCount - peakCandidate.Value) <= PeakTolerance) + { + peakCandidate = Math.Max(peakCandidate.Value, currentColorCount); + peakCandidateStableFrames++; + if (peakCandidateStableFrames >= PeakStableFrameCount && peakCandidate.Value > peakMinCount) + { + builtPeak = peakCandidate.Value; + peakCandidate = null; + peakCandidateStableFrames = 0; + return true; + } + + return false; + } + + peakCandidate = currentColorCount; + peakCandidateStableFrames = 1; + return false; + } + + private static Rect ScaleRect(Rect rect, double scale) + { + return new Rect( + (int)(rect.X * scale), + (int)(rect.Y * scale), + (int)(rect.Width * scale), + (int)(rect.Height * scale)); + } + + private bool IsInCookUi(ImageRegion captureRegion) + { + using var cookIcon = captureRegion.Find(ElementAssets.Instance.UiLeftTopCookIcon); + return cookIcon.IsExist(); + } + + private int CountTargetColor(ImageRegion captureRegion, Rect cookColorRect) + { + using var crop = captureRegion.DeriveCrop(cookColorRect); + using var rgb = new Mat(); + using var mask = new Mat(); + Cv2.CvtColor(crop.SrcMat, rgb, ColorConversionCodes.BGR2RGB); + Cv2.InRange(rgb, TargetCookColor, TargetCookColor, mask); + return Cv2.CountNonZero(mask); + } +} \ No newline at end of file diff --git a/BetterGenshinImpact/GameTask/AutoCook/AutoCookTrigger.cs b/BetterGenshinImpact/GameTask/AutoCook/AutoCookTrigger.cs deleted file mode 100644 index 9079787b..00000000 --- a/BetterGenshinImpact/GameTask/AutoCook/AutoCookTrigger.cs +++ /dev/null @@ -1,54 +0,0 @@ -using BetterGenshinImpact.Core.Recognition.OpenCv; -using BetterGenshinImpact.GameTask.Common.Element.Assets; -using Microsoft.Extensions.Logging; -using OpenCvSharp; -using System.Linq; - -namespace BetterGenshinImpact.GameTask.AutoCook; - -public class AutoCookTrigger : ITaskTrigger -{ - private readonly ILogger _logger = App.GetLogger(); - - public string Name => "自动烹饪"; - public bool IsEnabled { get; set; } - public int Priority => 50; - public bool IsExclusive { get; set; } - - public void Init() - { - IsEnabled = TaskContext.Instance().Config.AutoCookConfig.Enabled; - IsExclusive = false; - } - - public void OnCapture(CaptureContent content) - { - // 判断是否处于烹饪界面 - IsExclusive = false; - content.CaptureRectArea.Find(ElementAssets.Instance.UiLeftTopCookIcon, _ => - { - IsExclusive = true; - var captureRect = TaskContext.Instance().SystemInfo.ScaleMax1080PCaptureRect; - using var region = content.CaptureRectArea.DeriveCrop(0, captureRect.Height / 2, captureRect.Width, captureRect.Height / 2); - var perfectBarRects = ContoursHelper.FindSpecifyColorRects(region.SrcMat, new Scalar(255, 192, 64), 0, 8); - if (perfectBarRects.Count >= 2) - { - // 点击烹饪按钮 - var btnList = ContoursHelper.FindSpecifyColorRects(region.SrcMat, new Scalar(255, 255, 192), 12, 12); - if (btnList.Count >= 1) - { - if (btnList.Count > 1) - { - _logger.LogWarning("自动烹饪:{Text}", "识别到多个结束烹饪按钮"); - btnList = [.. btnList.OrderByDescending(r => r.Width)]; - } - var btn = btnList[0]; - var x = btn.X + btn.Width / 2; - var y = btn.Y + btn.Height / 2; - region.ClickTo(x, y); - _logger.LogInformation("自动烹饪:{Text}", "点击结束按钮"); - } - } - }); - } -} diff --git a/BetterGenshinImpact/GameTask/AutoDomain/AutoDomainTask.cs b/BetterGenshinImpact/GameTask/AutoDomain/AutoDomainTask.cs index 41e1dce6..c18f3135 100644 --- a/BetterGenshinImpact/GameTask/AutoDomain/AutoDomainTask.cs +++ b/BetterGenshinImpact/GameTask/AutoDomain/AutoDomainTask.cs @@ -95,7 +95,7 @@ public class AutoDomainTask : ISoloTask this.clickanywheretocloseLocalizedString = stringLocalizer.WithCultureGet(cultureInfo, "点击任意位置关闭"); this.matchingChallengeString = stringLocalizer.WithCultureGet(cultureInfo, "匹配挑战"); this.rapidformationString = stringLocalizer.WithCultureGet(cultureInfo, "快速编队"); - this.limitedFullyString = stringLocalizer.WithCultureGet(cultureInfo, "限时全开"); + this.limitedFullyString = stringLocalizer.WithCultureGet(cultureInfo, "限时全部开放"); this.limitedFullyAllString = stringLocalizer.WithCultureGet(cultureInfo, "限时开放"); } @@ -208,8 +208,6 @@ public class AutoDomainTask : ISoloTask Logger.LogInformation("体力耗尽或者设置轮次已达标,结束自动秘境"); break; } - - Notify.Event(NotificationEvent.DomainReward).Success("自动秘境奖励领取"); } } @@ -382,7 +380,7 @@ public class AutoDomainTask : ISoloTask using var limitedFullyStringRa = CaptureToRectArea(); var limitedFullyStringRaocrList = limitedFullyStringRa.FindMulti(RecognitionObject.Ocr(0, 0, limitedFullyStringRa.Width * 0.5, - limitedFullyStringRa.Height)); + limitedFullyStringRa.Height * 0.5)); var limitedFullyStringRaocrListdone = limitedFullyStringRaocrList.LastOrDefault(t => Regex.IsMatch(t.Text, this.limitedFullyString) || Regex.IsMatch(t.Text, this.limitedFullyAllString)); // 检测是否为限时全开秘境 @@ -1204,6 +1202,8 @@ public class AutoDomainTask : ISoloTask // 如果没有选择树脂的提示,说明只有原粹树脂 // 继续向下执行 } + + Notify.Event(NotificationEvent.DomainReward).Success("自动秘境奖励领取"); Sleep(1000, _ct); diff --git a/BetterGenshinImpact/GameTask/AutoDomain/Model/ResinUseRecord.cs b/BetterGenshinImpact/GameTask/AutoDomain/Model/ResinUseRecord.cs index 0edd13b0..b00c489a 100644 --- a/BetterGenshinImpact/GameTask/AutoDomain/Model/ResinUseRecord.cs +++ b/BetterGenshinImpact/GameTask/AutoDomain/Model/ResinUseRecord.cs @@ -62,7 +62,7 @@ public class ResinUseRecord return list; } - public static List BuildFromDomainParam(AutoStygianOnslaughtConfig taskParam) + public static List BuildFromDomainParam(AutoStygianOnslaughtParam taskParam) { List list = []; if (taskParam.SpecifyResinUse) diff --git a/BetterGenshinImpact/GameTask/AutoFight/Assets/combat_avatar.json b/BetterGenshinImpact/GameTask/AutoFight/Assets/combat_avatar.json index a5c84a79..dc28df7b 100644 --- a/BetterGenshinImpact/GameTask/AutoFight/Assets/combat_avatar.json +++ b/BetterGenshinImpact/GameTask/AutoFight/Assets/combat_avatar.json @@ -2049,5 +2049,31 @@ "nameEn": "Illuga", "skillCD": 15, "weapon": "10" + }, + { + "alias": [ + "法尔伽", + "Varka", + "团长", + "法尔加" + ], + "burstCD": 16, + "id": "10000128", + "name": "法尔伽", + "nameEn": "Varka", + "skillCD": 16, + "weapon": "10" + }, + { + "alias": [ + "莉奈娅", + "Linnea" + ], + "burstCD": 15, + "id": "10000130", + "name": "莉奈娅", + "nameEn": "Linnea", + "skillCD": 18, + "weapon": "10" } ] \ No newline at end of file diff --git a/BetterGenshinImpact/GameTask/AutoFight/AutoFightSeek.cs b/BetterGenshinImpact/GameTask/AutoFight/AutoFightSeek.cs index 322787fd..b6036852 100644 --- a/BetterGenshinImpact/GameTask/AutoFight/AutoFightSeek.cs +++ b/BetterGenshinImpact/GameTask/AutoFight/AutoFightSeek.cs @@ -461,7 +461,7 @@ namespace BetterGenshinImpact.GameTask.AutoFight { while (attempt < retryCount) { - if (guardianAvatar.TrySwitch(10, false)) + if (guardianAvatar.TrySwitch(10)) { guardianAvatar.ManualSkillCd = -1; if (await AvatarSkillAsync(Logger, guardianAvatar, false, 1, ct)) @@ -550,7 +550,7 @@ namespace BetterGenshinImpact.GameTask.AutoFight Logger.LogInformation("优先第 {text} 盾奶位 {GuardianAvatar} 元素爆发状态:{attempt},尝试释放", guardianAvatarName, guardianAvatar.Name, "就绪"); - if (guardianAvatar.TrySwitch(8, false)) + if (guardianAvatar.TrySwitch(8)) { Simulation.SendInput.SimulateAction(GIActions.ElementalBurst); Sleep(500, ct); diff --git a/BetterGenshinImpact/GameTask/AutoFight/AutoFightTask.cs b/BetterGenshinImpact/GameTask/AutoFight/AutoFightTask.cs index a6ffa6d6..80ededa9 100644 --- a/BetterGenshinImpact/GameTask/AutoFight/AutoFightTask.cs +++ b/BetterGenshinImpact/GameTask/AutoFight/AutoFightTask.cs @@ -418,7 +418,7 @@ public class AutoFightTask : ISoloTask fightEndFlag = await CheckFightFinish(delayTime, detectDelayTime); } #endregion - + command.Execute(combatScenes, lastCommand); //统计战斗人次 if (i == combatCommands.Count - 1 || command.Name != combatCommands[i + 1].Name) @@ -426,6 +426,13 @@ public class AutoFightTask : ISoloTask countFight++; } + #region check动作触发战斗结束检测 + if (command.Method == Method.Check) + { + fightEndFlag = await CheckFightFinish(delayTime, detectDelayTime); + } + #endregion + lastFightName = command.Name; if (!fightEndFlag && _taskParam is { FightFinishDetectEnabled: true }) { diff --git a/BetterGenshinImpact/GameTask/AutoFight/Model/Avatar.cs b/BetterGenshinImpact/GameTask/AutoFight/Model/Avatar.cs index 15536f45..3181b6b6 100644 --- a/BetterGenshinImpact/GameTask/AutoFight/Model/Avatar.cs +++ b/BetterGenshinImpact/GameTask/AutoFight/Model/Avatar.cs @@ -175,8 +175,9 @@ public class Avatar /// private static bool SwimmingConfirm(Region region) { - using var mask = OpenCvCommonHelper.Threshold(region.ToImageRegion().DeriveCrop(1819, 1025, 9, 11).SrcMat, - new Scalar(242, 223, 39),new Scalar(255, 233, 44)); + using var imageRegion = region.ToImageRegion(); + using var cropped = imageRegion.DeriveCrop(1819, 1025, 9, 11); + using var mask = OpenCvCommonHelper.Threshold(cropped.SrcMat, new Scalar(242, 223, 39), new Scalar(255, 233, 44)); using var labels = new Mat(); using var stats = new Mat(); using var centroids = new Mat(); @@ -238,7 +239,7 @@ public class Avatar /// /// /// - public bool TrySwitch(int tryTimes = 4, bool needLog = true) + public bool TrySwitch(int tryTimes = 4) { var context = new AvatarActiveCheckContext(); for (var i = 0; i < tryTimes; i++) @@ -254,13 +255,16 @@ public class Avatar // 切换成功 if (CombatScenes.GetActiveAvatarIndex(region, context) == Index) { - // if (needLog && i > 0) - // { - // Logger.LogInformation("成功切换角色:{Name}", Name); - // } - return true; } + else + { + if (i == tryTimes - 1) + { + Logger.LogWarning("切换角色失败,最后一次尝试,当前角色编号:{CurrentIndex},期望角色编号:{ExpectedIndex}", CombatScenes.GetActiveAvatarIndex(region, context), Index); + region.SrcMat.SaveImage($"log/{Name}_切换失败.png"); + } + } SimulateSwitchAction(Index); @@ -637,7 +641,7 @@ public class Avatar /// public void Ready() { - Sleep(200, Ct); + Sleep(10, Ct); for (int i = 0; i < 20; i++) { diff --git a/BetterGenshinImpact/GameTask/AutoFight/Script/CombatCommand.cs b/BetterGenshinImpact/GameTask/AutoFight/Script/CombatCommand.cs index 7ca24110..a2aaa8f6 100644 --- a/BetterGenshinImpact/GameTask/AutoFight/Script/CombatCommand.cs +++ b/BetterGenshinImpact/GameTask/AutoFight/Script/CombatCommand.cs @@ -108,7 +108,8 @@ public class CombatCommand && Method != Method.KeyDown && Method != Method.KeyUp && Method != Method.KeyPress - && Method != Method.Scroll) + && Method != Method.Scroll + && Method != Method.Ready) { avatar.Switch(); } @@ -202,6 +203,10 @@ public class CombatCommand { avatar.Ready(); } + else if (Method == Method.Check) + { + // check动作在AutoFightTask主循环中处理,此处不做任何操作 + } else if (Method == Method.Aim) { throw new NotImplementedException(); diff --git a/BetterGenshinImpact/GameTask/AutoFight/Script/Method.cs b/BetterGenshinImpact/GameTask/AutoFight/Script/Method.cs index fbcb2fa0..74c1d868 100644 --- a/BetterGenshinImpact/GameTask/AutoFight/Script/Method.cs +++ b/BetterGenshinImpact/GameTask/AutoFight/Script/Method.cs @@ -13,6 +13,7 @@ public class Method public static readonly Method Charge = new(["charge", "重击"]); public static readonly Method Wait = new(["wait", "after", "等待"]); public static readonly Method Ready = new(["ready", "完成"]); + public static readonly Method Check = new(["check", "检测"]); public static readonly Method Walk = new(["walk", "行走"]); public static readonly Method W = new(["w"]); @@ -45,6 +46,7 @@ public class Method yield return Charge; yield return Wait; yield return Ready; + yield return Check; yield return Walk; yield return W; diff --git a/BetterGenshinImpact/GameTask/AutoFishing/Behaviours.cs b/BetterGenshinImpact/GameTask/AutoFishing/Behaviours.cs index d6d32e60..9390199e 100644 --- a/BetterGenshinImpact/GameTask/AutoFishing/Behaviours.cs +++ b/BetterGenshinImpact/GameTask/AutoFishing/Behaviours.cs @@ -151,7 +151,7 @@ namespace BetterGenshinImpact.GameTask.AutoFishing // 寻找鱼饵 var boxAndBaits = FindBait(imageRegion); - ; + foreach ((Rect box, string? predName) in boxAndBaits) { if (predName == blackboard.selectedBait.GetDescription()) @@ -201,6 +201,7 @@ namespace BetterGenshinImpact.GameTask.AutoFishing } else { + blackboard.Sleep(200); return BehaviourStatus.Running; } } diff --git a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Assets/tcg_character_card.json b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Assets/tcg_character_card.json index 2c9ee916..4ea6be57 100644 --- a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Assets/tcg_character_card.json +++ b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Assets/tcg_character_card.json @@ -1001,7 +1001,7 @@ "nameEn": "escoffier", "type": "character", "name": "爱可菲", - "hp": 11, + "hp": 10, "energy": 2, "element": "冰元素", "weapon": "长柄武器", @@ -1073,6 +1073,154 @@ } ] }, + { + "id": 1116, + "nameEn": "skirk", + "type": "character", + "name": "丝柯克", + "hp": 10, + "energy": 0, + "element": "冰元素", + "weapon": "单手剑", + "skills": [ + { + "nameEn": "havoc_sunder", + "name": "极恶技·断", + "skillTag": [ + "普通攻击" + ], + "cost": [ + { + "id": 1101, + "nameEn": "cryo", + "type": "冰元素", + "count": 1 + }, + { + "id": 1109, + "nameEn": "unaligned_element", + "type": "无色元素", + "count": 2 + } + ] + }, + { + "nameEn": "havoc_warp", + "name": "极恶技·闪", + "skillTag": [ + "元素战技" + ], + "cost": [ + { + "id": 1101, + "nameEn": "cryo", + "type": "冰元素", + "count": 2 + } + ] + }, + { + "nameEn": "havoc_ruin", + "name": "极恶技·灭", + "skillTag": [ + "元素爆发" + ], + "cost": [ + { + "id": 1101, + "nameEn": "cryo", + "type": "冰元素", + "count": 3 + } + ] + }, + { + "nameEn": "reason_beyond_reason", + "name": "理外之理", + "skillTag": [ + "被动技能" + ], + "cost": [] + } + ] + }, + { + "id": 1117, + "nameEn": "mika", + "type": "character", + "name": "米卡", + "hp": 10, + "energy": 2, + "element": "冰元素", + "weapon": "长柄武器", + "skills": [ + { + "nameEn": "spear_of_favonius_arrows_passage", + "name": "西风枪术·镝传", + "skillTag": [ + "普通攻击" + ], + "cost": [ + { + "id": 1101, + "nameEn": "cryo", + "type": "冰元素", + "count": 1 + }, + { + "id": 1109, + "nameEn": "unaligned_element", + "type": "无色元素", + "count": 2 + } + ] + }, + { + "nameEn": "starfrost_swirl", + "name": "星霜的流旋", + "skillTag": [ + "元素战技" + ], + "cost": [ + { + "id": 1101, + "nameEn": "cryo", + "type": "冰元素", + "count": 3 + } + ] + }, + { + "nameEn": "skyfeather_song", + "name": "苍翎的颂愿", + "skillTag": [ + "元素爆发" + ], + "cost": [ + { + "id": 1101, + "nameEn": "cryo", + "type": "冰元素", + "count": 3 + }, + { + "id": 1110, + "nameEn": "energy", + "type": "充能", + "count": 2 + } + ] + }, + { + "nameEn": "suppressive_barrage", + "name": "速射牵制", + "skillTag": [ + "被动技能" + ], + "cost": [] + } + ] + }, { "id": 1201, "nameEn": "barbara", @@ -1646,7 +1794,7 @@ "nameEn": "yelan", "type": "character", "name": "夜兰", - "hp": 10, + "hp": 11, "energy": 3, "element": "水元素", "weapon": "弓", @@ -4299,6 +4447,83 @@ } ] }, + { + "id": 1417, + "nameEn": "ineffa", + "type": "character", + "name": "伊涅芙", + "hp": 10, + "energy": 2, + "element": "雷元素", + "weapon": "长柄武器", + "skills": [ + { + "nameEn": "cyclonic_duster", + "name": "除尘旋刃", + "skillTag": [ + "普通攻击" + ], + "cost": [ + { + "id": 1104, + "nameEn": "electro", + "type": "雷元素", + "count": 1 + }, + { + "id": 1109, + "nameEn": "unaligned_element", + "type": "无色元素", + "count": 2 + } + ] + }, + { + "nameEn": "cleaning_mode_carrier_frequency", + "name": "涤净模式·稳态载频", + "skillTag": [ + "元素战技" + ], + "cost": [ + { + "id": 1104, + "nameEn": "electro", + "type": "雷元素", + "count": 3 + } + ] + }, + { + "nameEn": "supreme_instruction_cyclonic_exterminator", + "name": "至高律令·全域扫灭", + "skillTag": [ + "元素爆发" + ], + "cost": [ + { + "id": 1104, + "nameEn": "electro", + "type": "雷元素", + "count": 3 + }, + { + "id": 1110, + "nameEn": "energy", + "type": "充能", + "count": 2 + } + ] + }, + { + "nameEn": "moonsign_benediction_assemblage_hub", + "name": "月兆祝赐·象拟中继", + "skillTag": [ + "被动技能" + ], + "cost": [] + } + ] + }, { "id": 1501, "nameEn": "sucrose", @@ -7388,7 +7613,7 @@ "nameEn": "alldevouring_narwhal", "type": "character", "name": "吞星之鲸", - "hp": 5, + "hp": 6, "energy": 2, "element": "水元素", "weapon": "其他武器", @@ -7534,7 +7759,7 @@ "nameEn": "hydro_tulpa", "type": "character", "name": "水形幻人", - "hp": 11, + "hp": 10, "energy": 3, "element": "水元素", "weapon": "其他武器", @@ -7688,7 +7913,7 @@ "nameEn": "fatui_pyro_agent", "type": "character", "name": "愚人众·火之债务处理人", - "hp": 9, + "hp": 11, "energy": 2, "element": "火元素", "weapon": "其他武器", @@ -8068,6 +8293,83 @@ } ] }, + { + "id": 2306, + "nameEn": "goldflame_qucusaur_tyrant", + "type": "character", + "name": "金焰绒翼龙暴君", + "hp": 11, + "energy": 2, + "element": "火元素", + "weapon": "其他武器", + "skills": [ + { + "nameEn": "wingcleave", + "name": "翼斩", + "skillTag": [ + "普通攻击" + ], + "cost": [ + { + "id": 1103, + "nameEn": "pyro", + "type": "火元素", + "count": 1 + }, + { + "id": 1109, + "nameEn": "unaligned_element", + "type": "无色元素", + "count": 2 + } + ] + }, + { + "nameEn": "rising_scorchwind", + "name": "升腾炽风", + "skillTag": [ + "元素战技" + ], + "cost": [ + { + "id": 1103, + "nameEn": "pyro", + "type": "火元素", + "count": 3 + } + ] + }, + { + "nameEn": "goldflame_detonation", + "name": "金焰爆轰", + "skillTag": [ + "元素爆发" + ], + "cost": [ + { + "id": 1103, + "nameEn": "pyro", + "type": "火元素", + "count": 3 + }, + { + "id": 1110, + "nameEn": "energy", + "type": "充能", + "count": 2 + } + ] + }, + { + "nameEn": "ancient_bloodline", + "name": "古老者的血脉", + "skillTag": [ + "被动技能" + ], + "cost": [] + } + ] + }, { "id": 2401, "nameEn": "electro_hypostasis", @@ -8772,7 +9074,7 @@ "nameEn": "stonehide_lawachurl", "type": "character", "name": "丘丘岩盔王", - "hp": 8, + "hp": 10, "energy": 2, "element": "岩元素", "weapon": "其他武器", @@ -9072,7 +9374,7 @@ "nameEn": "jadeplume_terrorshroom", "type": "character", "name": "翠翎恐蕈", - "hp": 10, + "hp": 12, "energy": 2, "element": "草元素", "weapon": "其他武器", @@ -9374,76 +9676,5 @@ "cost": [] } ] - }, - { - "id": 6605, - "nameEn": "skirk", - "type": "character", - "name": "丝柯克", - "hp": 10, - "energy": 0, - "element": "冰元素", - "weapon": "单手剑", - "skills": [ - { - "nameEn": "havoc_sunder", - "name": "极恶技·断", - "skillTag": [ - "普通攻击" - ], - "cost": [ - { - "id": 1101, - "nameEn": "cryo", - "type": "冰元素", - "count": 1 - }, - { - "id": 1109, - "nameEn": "unaligned_element", - "type": "无色元素", - "count": 2 - } - ] - }, - { - "nameEn": "havoc_warp", - "name": "极恶技·闪", - "skillTag": [ - "元素战技" - ], - "cost": [ - { - "id": 1101, - "nameEn": "cryo", - "type": "冰元素", - "count": 2 - } - ] - }, - { - "nameEn": "havoc_extinction", - "name": "极恶技·尽", - "skillTag": [ - "元素爆发" - ], - "cost": [ - { - "id": 1101, - "nameEn": "cryo", - "type": "冰元素", - "count": 1 - } - ] - }, - { - "nameEn": "reason_beyond_reason", - "name": "理外之理", - "skillTag": [ - "被动技能" - ], - "cost": [] - } - ] } ] \ No newline at end of file diff --git a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/GeniusInvokationControl.cs b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/GeniusInvokationControl.cs index 684cccdf..facb4c19 100644 --- a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/GeniusInvokationControl.cs +++ b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/GeniusInvokationControl.cs @@ -564,6 +564,75 @@ public class GeniusInvokationControl m.LeftButtonUp(); } + /// + /// 月之五伊涅芙会导致手牌不可调和,根据传入的手牌数量,从左往右尝试 + /// + private bool ActionPhaseElementalTuningAlternatives(int currentCardCount) + { + var rect = TaskContext.Instance().SystemInfo.CaptureAreaRect; + var info = TaskContext.Instance().SystemInfo; + var m = Simulation.SendInput.Mouse; + + var startY = rect.Y + rect.Height - 50; + var endX = rect.X + rect.Width - 50; + var endY = rect.Y + rect.Height / 2d; + + // 手牌数量对应的起点(中心)和间距 + var table = new Dictionary + { + {10, (570.0, 120.0)}, + {9, (570.0, 130.0)}, + {8, (600.0, 145.0)}, + {7, (630.0, 160.0)}, + {6, (620.0, 200.0)}, + {5, (720.0, 200.0)}, + {4, (820.0, 200.0)}, + {3, (920.0, 200.0)}, + {2, (1020.0, 200.0)}, + {1, (1120.0, 200.0)} + }; + + if (!table.ContainsKey(currentCardCount)) + { + _logger.LogWarning("未找到手牌数量对应的起始位置配置: {Count}", currentCardCount); + return false; + } + + var (startX, spacing) = table[currentCardCount]; + + // 先点中间位置,确保牌是展开状态 + ClickExtension.Click(rect.X + rect.Width / 2d, rect.Y + rect.Height - 50); + Sleep(1500); + + // 从左往右尝试 + for (int idx = 0; idx < currentCardCount; idx++) + { + var x = rect.X + startX * info.AssetScale + idx * spacing * info.AssetScale; + + ClickExtension.Click(x, startY); + Sleep(500); + + m.LeftButtonDown(); + Sleep(100); + m = ClickExtension.Move(endX, endY); + Sleep(100); + m.LeftButtonUp(); + + // 等待动画并确认 + Sleep(1200); + if (ActionPhaseElementalTuningConfirm()) + { + return true; + } + + _logger.LogWarning("烧牌位置尝试失败,手牌数:{Count},位置索引:{Idx}", currentCardCount, idx); + ClickGameWindowCenter(); // 复位 + Sleep(1000); + } + + return false; + } + /// /// 烧牌确认(元素调和按钮) /// @@ -692,16 +761,13 @@ public class GeniusInvokationControl for (var i = 0; i < needSpecifyElementDiceCount; i++) { _logger.LogInformation("- 烧第{Count}张牌", i + 1); - ActionPhaseElementalTuning(duel.CurrentCardCount); - Sleep(1200); - var res = ActionPhaseElementalTuningConfirm(); - if (res == false) + + // 尝试对当前手牌从左往右依次进行烧牌 + var tuned = ActionPhaseElementalTuningAlternatives(duel.CurrentCardCount); + if (!tuned) { - _logger.LogWarning("烧牌失败,重试"); - i--; - ClickGameWindowCenter(); // 复位 - Sleep(1000); - continue; + _logger.LogWarning("所有手牌尝试烧牌均失败,取消释放技能"); + return false; } Sleep(1000); // 烧牌动画 diff --git a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Model/ActionCommand.cs b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Model/ActionCommand.cs index b64a28f9..6ee87be4 100644 --- a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Model/ActionCommand.cs +++ b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Model/ActionCommand.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace BetterGenshinImpact.GameTask.AutoGeniusInvokation.Model { @@ -15,6 +15,11 @@ namespace BetterGenshinImpact.GameTask.AutoGeniusInvokation.Model /// 目标编号(技能编号,从右往左) /// public int TargetIndex { get; set; } + + /// + /// 灵活改变骰子的数量(因为在不同的牌局中或者角色技能中会发生骰子实际需要的数量增加或减少) + /// + public int DiceDelta { get; set; } = 0; public override string? ToString() { @@ -22,11 +27,11 @@ namespace BetterGenshinImpact.GameTask.AutoGeniusInvokation.Model { if (string.IsNullOrEmpty(Character.Skills[TargetIndex].Name)) { - return $"【{Character.Name}】使用【技能{TargetIndex}】"; + return $"【{Character.Name}】使用【技能{TargetIndex}】{(DiceDelta != 0 ? $"(骰子{(DiceDelta > 0 ? "增加" : "减少")}{Math.Abs(DiceDelta)})" : "")}"; } else { - return $"【{Character.Name}】使用【{Character.Skills[TargetIndex].Name}】"; + return $"【{Character.Name}】使用【{Character.Skills[TargetIndex].Name}】{(DiceDelta != 0 ? $"(骰子{(DiceDelta > 0 ? "增加" : "减少")}{Math.Abs(DiceDelta)})" : "")}"; } } else if (Action == ActionEnum.SwitchLater) diff --git a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Model/Duel.cs b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Model/Duel.cs index 0c9a12ab..197fde4a 100644 --- a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Model/Duel.cs +++ b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/Model/Duel.cs @@ -203,7 +203,7 @@ public class Duel } // 2. 判断使用技能 - if (actionCommand.GetAllDiceUseCount() > CurrentDiceCount) + if (actionCommand.GetAllDiceUseCount() + actionCommand.DiceDelta > CurrentDiceCount)// 判断条件加上 DiceDelta 骰子变化 { _logger.LogInformation("骰子不足以进行下一步:{Action}", actionCommand); break; @@ -213,7 +213,9 @@ public class Duel bool useSkillRes = actionCommand.UseSkill(this); if (useSkillRes) { - CurrentDiceCount -= actionCommand.GetAllDiceUseCount(); + int realCost = actionCommand.GetAllDiceUseCount() + actionCommand.DiceDelta; + realCost = Math.Max(realCost, 0); + CurrentDiceCount -= realCost; alreadyExecutedActionIndex.Add(i); alreadyExecutedActionCommand.Add(actionCommand); _logger.LogInformation("→指令执行完成:{Action}", actionCommand); @@ -344,7 +346,7 @@ public class Duel } // 2. 判断使用技能 - actionUseDiceSum += actionCommand.GetAllDiceUseCount(); + actionUseDiceSum += Math.Max(actionCommand.GetAllDiceUseCount() + actionCommand.DiceDelta, 0); if (actionUseDiceSum > CurrentDiceCount) { break; diff --git a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/ScriptParser.cs b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/ScriptParser.cs index 0ba12071..6397228a 100644 --- a/BetterGenshinImpact/GameTask/AutoGeniusInvokation/ScriptParser.cs +++ b/BetterGenshinImpact/GameTask/AutoGeniusInvokation/ScriptParser.cs @@ -60,7 +60,7 @@ public class ScriptParser MyAssert(duel.Characters[3] != null, "角色未定义"); string[] actionParts = line.Split(" ", StringSplitOptions.RemoveEmptyEntries); - MyAssert(actionParts.Length == 3, "策略中的行动命令解析错误"); + MyAssert(actionParts.Length >= 3 && actionParts.Length <= 4, "策略中的行动命令解析错误"); MyAssert(actionParts[1] == "使用", "策略中的行动命令解析错误"); var actionCommand = new ActionCommand(); @@ -83,6 +83,26 @@ public class ScriptParser int skillNum = int.Parse(RegexHelper.ExcludeNumberRegex().Replace(actionParts[2], "")); MyAssert(skillNum < 5, "策略中的行动命令解析错误:技能编号错误"); actionCommand.TargetIndex = skillNum; + + // 解析骰子增减 + actionCommand.DiceDelta = 0; + if (actionParts.Length >= 4) + { + if (actionParts[3].StartsWith("骰子增加")) + { + int delta = int.Parse(RegexHelper.ExcludeNumberRegex().Replace(actionParts[3], "")); + actionCommand.DiceDelta = delta; + } + else if (actionParts[3].StartsWith("骰子减少")) + { + int delta = int.Parse(RegexHelper.ExcludeNumberRegex().Replace(actionParts[3], "")); + actionCommand.DiceDelta = -delta; + } + else + { + MyAssert(false, $"策略中的行动命令解析错误:骰子增减参数格式不正确(应为 骰子增加N 或 骰子减少N ),实际:{actionParts[3]}"); + } + } duel.ActionCommandQueue.Add(actionCommand); } else diff --git a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/icon/handbook_track_action_left.png b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/icon/handbook_track_action_left.png new file mode 100644 index 00000000..6cd7be92 Binary files /dev/null and b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/icon/handbook_track_action_left.png differ diff --git a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/pathing/挪德卡莱3-月矩力试验设计局-2.json b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/pathing/挪德卡莱3-月矩力试验设计局-2.json index 7dbee52f..626404cc 100644 --- a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/pathing/挪德卡莱3-月矩力试验设计局-2.json +++ b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/pathing/挪德卡莱3-月矩力试验设计局-2.json @@ -1,16 +1,21 @@ -{ +{ "info": { - "authors": [], + "authors": [ + { + "links": "", + "name": "ddaodan" + } + ], "bgi_version": "0.45.0", "description": "", "enable_monster_loot_split": false, - "last_modified_time": 1760586516234, + "last_modified_time": 1775105055739, "map_match_method": "", "map_name": "Teyvat", "name": "挪德卡莱3-月矩力试验设计局-2", "tags": [], "type": "collect", - "version": "1.0" + "version": "1.1" }, "positions": [ { @@ -19,17 +24,17 @@ "id": 1, "move_mode": "walk", "type": "teleport", - "x": 9375.3896484375, - "y": 3150.5361328125 + "x": 9375.0771484375, + "y": 3150.26611328125 }, { "action": "", "action_params": "", "id": 2, - "move_mode": "walk", - "type": "target", - "x": 9379.1904296875, - "y": 3148.973876953125 + "move_mode": "dash", + "type": "path", + "x": 9377.501953125, + "y": 3145.7734375 }, { "action": "", @@ -37,26 +42,35 @@ "id": 3, "move_mode": "jump", "type": "path", - "x": 9384.3466796875, - "y": 3152.6689453125 + "x": 9380.072265625, + "y": 3141.0322265625 }, { "action": "", "action_params": "", "id": 4, - "move_mode": "jump", + "move_mode": "walk", "type": "path", - "x": 9392.90234375, - "y": 3154.693359375 + "x": 9385.978515625, + "y": 3134.896484375 }, { "action": "", "action_params": "", "id": 5, + "move_mode": "jump", + "type": "path", + "x": 9396.578125, + "y": 3136.884765625 + }, + { + "action": "", + "action_params": "", + "id": 6, "move_mode": "dash", "type": "target", - "x": 9424.498046875, - "y": 3135.7734375 + "x": 9423.1064453125, + "y": 3135.43310546875 } ] } \ No newline at end of file diff --git a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/pathing/挪德卡莱3-月矩力试验设计局-3.json b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/pathing/挪德卡莱3-月矩力试验设计局-3.json index 0a9ad7af..a30b7744 100644 --- a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/pathing/挪德卡莱3-月矩力试验设计局-3.json +++ b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/Assets/pathing/挪德卡莱3-月矩力试验设计局-3.json @@ -1,16 +1,21 @@ -{ +{ "info": { - "authors": [], + "authors": [ + { + "links": "", + "name": "ddaodan" + } + ], "bgi_version": "0.45.0", "description": "", "enable_monster_loot_split": false, - "last_modified_time": 1758088156049, + "last_modified_time": 1775105252724, "map_match_method": "", "map_name": "Teyvat", "name": "挪德卡莱3-月矩力试验设计局-3", "tags": [], "type": "collect", - "version": "1.0" + "version": "1.1" }, "positions": [ { @@ -19,14 +24,23 @@ "id": 1, "move_mode": "dash", "type": "path", - "x": 9463.578125, - "y": 3170.335205078125 + "x": 9463.2578125, + "y": 3147.61328125 }, { "action": "", "action_params": "", "id": 2, "move_mode": "dash", + "type": "path", + "x": 9471.43359375, + "y": 3168.97900390625 + }, + { + "action": "", + "action_params": "", + "id": 3, + "move_mode": "dash", "type": "target", "x": 9461.056640625, "y": 3200.0498046875 diff --git a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropConfig.cs b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropConfig.cs index 88219585..5f1c8c5d 100644 --- a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropConfig.cs +++ b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropConfig.cs @@ -1,11 +1,17 @@ using CommunityToolkit.Mvvm.ComponentModel; using System; +using System.ComponentModel; namespace BetterGenshinImpact.GameTask.AutoLeyLineOutcrop; [Serializable] public partial class AutoLeyLineOutcropConfig : ObservableObject { + public AutoLeyLineOutcropConfig() + { + AttachFightConfigEvents(_fightConfig); + } + [ObservableProperty] private string _leyLineOutcropType = "启示之花"; @@ -44,4 +50,43 @@ public partial class AutoLeyLineOutcropConfig : ObservableObject [ObservableProperty] private bool _isGoToSynthesizer = false; + + /// + /// 是否在领取地脉花奖励后扫描周围掉落物光柱。 + /// + [ObservableProperty] + private bool _scanDropsAfterRewardEnabled = false; + + /// + /// 领取奖励后扫描掉落物的最长时长,单位为秒。 + /// + [ObservableProperty] + private int _scanDropsAfterRewardSeconds = 12; + + [ObservableProperty] + private AutoLeyLineOutcropFightConfig _fightConfig = new(); + + partial void OnFightConfigChanged(AutoLeyLineOutcropFightConfig value) + { + AttachFightConfigEvents(value); + OnPropertyChanged(nameof(FightConfig)); + } + + private void AttachFightConfigEvents(AutoLeyLineOutcropFightConfig? config) + { + if (config == null) + { + return; + } + + config.PropertyChanged -= OnFightConfigPropertyChanged; + config.PropertyChanged += OnFightConfigPropertyChanged; + config.FinishDetectConfig.PropertyChanged -= OnFightConfigPropertyChanged; + config.FinishDetectConfig.PropertyChanged += OnFightConfigPropertyChanged; + } + + private void OnFightConfigPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + OnPropertyChanged(nameof(FightConfig)); + } } diff --git a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropFightConfig.cs b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropFightConfig.cs new file mode 100644 index 00000000..decf6025 --- /dev/null +++ b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropFightConfig.cs @@ -0,0 +1,120 @@ +using BetterGenshinImpact.GameTask.AutoFight; +using CommunityToolkit.Mvvm.ComponentModel; +using System; + +namespace BetterGenshinImpact.GameTask.AutoLeyLineOutcrop; + +[Serializable] +public partial class AutoLeyLineOutcropFightConfig : ObservableObject +{ + [ObservableProperty] private string _strategyName = ""; + + /// + /// 英文逗号分割,强制指定队伍角色。 + /// + [ObservableProperty] private string _teamNames = ""; + + /// + /// 检测战斗结束。 + /// + [ObservableProperty] private bool _fightFinishDetectEnabled = true; + + /// + /// 根据技能CD优化出招人员。 + /// + [ObservableProperty] private string _actionSchedulerByCd = ""; + + [Serializable] + public partial class FightFinishDetectConfig : ObservableObject + { + [ObservableProperty] private string _battleEndProgressBarColor = ""; + [ObservableProperty] private string _battleEndProgressBarColorTolerance = ""; + [ObservableProperty] private bool _fastCheckEnabled = false; + [ObservableProperty] private bool _rotateFindEnemyEnabled = false; + [ObservableProperty] private string _fastCheckParams = ""; + [ObservableProperty] private string _checkEndDelay = ""; + [ObservableProperty] private string _beforeDetectDelay = ""; + [ObservableProperty] private int _rotaryFactor = 10; + [ObservableProperty] private bool _isFirstCheck = false; + [ObservableProperty] private bool _checkBeforeBurst = false; + } + + [ObservableProperty] private FightFinishDetectConfig _finishDetectConfig = new(); + [ObservableProperty] private string _guardianAvatar = string.Empty; + [ObservableProperty] private bool _guardianCombatSkip = false; + [ObservableProperty] private bool _guardianAvatarHold = false; + [ObservableProperty] private bool _burstEnabled = false; + [ObservableProperty] private bool _swimmingEnabled = false; + [ObservableProperty] private bool _kazuhaPickupEnabled = true; + [ObservableProperty] private bool _qinDoublePickUp = false; + [ObservableProperty] private int _timeout = 120; + + public void CopyFromAutoFightConfig(AutoFightConfig source) + { + StrategyName = source.StrategyName; + TeamNames = source.TeamNames; + FightFinishDetectEnabled = source.FightFinishDetectEnabled; + ActionSchedulerByCd = source.ActionSchedulerByCd; + GuardianAvatar = source.GuardianAvatar; + GuardianCombatSkip = source.GuardianCombatSkip; + GuardianAvatarHold = source.GuardianAvatarHold; + BurstEnabled = source.BurstEnabled; + SwimmingEnabled = source.SwimmingEnabled; + KazuhaPickupEnabled = source.KazuhaPickupEnabled; + QinDoublePickUp = source.QinDoublePickUp; + Timeout = source.Timeout; + + FinishDetectConfig.BattleEndProgressBarColor = source.FinishDetectConfig.BattleEndProgressBarColor; + FinishDetectConfig.BattleEndProgressBarColorTolerance = source.FinishDetectConfig.BattleEndProgressBarColorTolerance; + FinishDetectConfig.FastCheckEnabled = source.FinishDetectConfig.FastCheckEnabled; + FinishDetectConfig.RotateFindEnemyEnabled = source.FinishDetectConfig.RotateFindEnemyEnabled; + FinishDetectConfig.FastCheckParams = source.FinishDetectConfig.FastCheckParams; + FinishDetectConfig.CheckEndDelay = source.FinishDetectConfig.CheckEndDelay; + FinishDetectConfig.BeforeDetectDelay = source.FinishDetectConfig.BeforeDetectDelay; + FinishDetectConfig.RotaryFactor = source.FinishDetectConfig.RotaryFactor; + FinishDetectConfig.IsFirstCheck = source.FinishDetectConfig.IsFirstCheck; + FinishDetectConfig.CheckBeforeBurst = source.FinishDetectConfig.CheckBeforeBurst; + } + + public AutoFightConfig ToAutoFightConfig() + { + var config = new AutoFightConfig + { + StrategyName = StrategyName, + TeamNames = TeamNames, + FightFinishDetectEnabled = FightFinishDetectEnabled, + ActionSchedulerByCd = ActionSchedulerByCd, + GuardianAvatar = GuardianAvatar, + GuardianCombatSkip = GuardianCombatSkip, + GuardianAvatarHold = GuardianAvatarHold, + BurstEnabled = BurstEnabled, + SwimmingEnabled = SwimmingEnabled, + Timeout = Timeout, + + // 地脉花战斗不启用战斗后拾取逻辑。 + PickDropsAfterFightEnabled = false, + PickDropsAfterFightSeconds = 0, + KazuhaPickupEnabled = false, + QinDoublePickUp = false, + OnlyPickEliteDropsMode = "DisableAutoPickupForNonElite", + KazuhaPartyName = string.Empty, + BattleThresholdForLoot = null + }; + + config.FinishDetectConfig = new AutoFightConfig.FightFinishDetectConfig + { + BattleEndProgressBarColor = FinishDetectConfig.BattleEndProgressBarColor, + BattleEndProgressBarColorTolerance = FinishDetectConfig.BattleEndProgressBarColorTolerance, + FastCheckEnabled = FinishDetectConfig.FastCheckEnabled, + RotateFindEnemyEnabled = FinishDetectConfig.RotateFindEnemyEnabled, + FastCheckParams = FinishDetectConfig.FastCheckParams, + CheckEndDelay = FinishDetectConfig.CheckEndDelay, + BeforeDetectDelay = FinishDetectConfig.BeforeDetectDelay, + RotaryFactor = FinishDetectConfig.RotaryFactor, + IsFirstCheck = FinishDetectConfig.IsFirstCheck, + CheckBeforeBurst = FinishDetectConfig.CheckBeforeBurst + }; + + return config; + } +} diff --git a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropParam.cs b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropParam.cs new file mode 100644 index 00000000..6fc76269 --- /dev/null +++ b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropParam.cs @@ -0,0 +1,84 @@ +using BetterGenshinImpact.GameTask.Model; + +namespace BetterGenshinImpact.GameTask.AutoLeyLineOutcrop; + +public class AutoLeyLineOutcropParam:BaseTaskParam +{ + + // 刷取次数 + public int Count { get; set; } + // 地区 + public string Country { get; set; } + //地脉花类型 + public string LeyLineOutcropType { get; set; } + // 开启取小值模式 + public bool OpenModeCountMin { get; set; } + // 是否开启树脂耗尽模式 + public bool IsResinExhaustionMode { get; set; } + //是否使用冒险之证寻找地脉花 + public bool UseAdventurerHandbook { get; set; } + //好感队名称 + public string FriendshipTeam { get; set; } + //战斗的队伍名称 + public string Team { get; set; } + //战斗超时时间 + public int Timeout { get; set; } + //地脉花独立战斗配置 + public AutoLeyLineOutcropFightConfig FightConfig { get; set; } = new(); + //是否前往合成台合成浓缩树脂 + public bool IsGoToSynthesizer { get; set; } + //是否使用脆弱树脂 + public bool UseFragileResin { get; set; } + //是否使用须臾树脂 + public bool UseTransientResin { get; set; } + //通过BGI通知系统发送详细通知 + public bool IsNotification { get; set; } + /// + /// 是否在领取奖励后扫描掉落物光柱。 + /// + public bool ScanDropsAfterRewardEnabled { get; set; } + + /// + /// 领取奖励后扫描掉落物光柱的最长时长,单位为秒。 + /// + public int ScanDropsAfterRewardSeconds { get; set; } + + public void SetDefault() + { + var config = TaskContext.Instance().Config.AutoLeyLineOutcropConfig; + SetAutoLeyLineOutcropConfig(config); + } + + public void SetAutoLeyLineOutcropConfig(AutoLeyLineOutcropConfig config) + { + OpenModeCountMin= config.OpenModeCountMin; + IsResinExhaustionMode= config.IsResinExhaustionMode; + UseAdventurerHandbook= config.UseAdventurerHandbook; + FriendshipTeam= config.FriendshipTeam; + Team= config.Team; + Timeout= config.Timeout; + FightConfig = config.FightConfig ?? new AutoLeyLineOutcropFightConfig(); + IsGoToSynthesizer=config.IsGoToSynthesizer; + UseFragileResin= config.UseFragileResin; + UseTransientResin= config.UseTransientResin; + IsNotification= config.IsNotification; + ScanDropsAfterRewardEnabled = config.ScanDropsAfterRewardEnabled; + ScanDropsAfterRewardSeconds = config.ScanDropsAfterRewardSeconds; + Count = config.Count; + Country = config.Country; + LeyLineOutcropType = config.LeyLineOutcropType; + } + + public AutoLeyLineOutcropParam() : base(null, null) + { + SetDefault(); + } + + public AutoLeyLineOutcropParam(int count, string country, string leyLineOutcropType) : base(null, null) + { + SetDefault(); + this.Count = count; + this.Country = country; + this.LeyLineOutcropType = leyLineOutcropType; + } +} diff --git a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropTask.cs b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropTask.cs index 3fe2f271..99e8288e 100644 --- a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropTask.cs +++ b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropTask.cs @@ -5,10 +5,15 @@ using BetterGenshinImpact.Core.Config; using BetterGenshinImpact.Core.Script; using BetterGenshinImpact.Core.Simulator; using BetterGenshinImpact.Core.Simulator.Extensions; +using BetterGenshinImpact.GameTask.AutoDomain; using BetterGenshinImpact.GameTask.AutoPathing; +using BetterGenshinImpact.GameTask.AutoPathing.Handler; using BetterGenshinImpact.GameTask.AutoPathing.Model; using BetterGenshinImpact.GameTask.AutoTrackPath; using BetterGenshinImpact.GameTask.AutoFight; +using BetterGenshinImpact.GameTask.AutoPick.Assets; +using BetterGenshinImpact.GameTask.AutoFight.Model; +using BetterGenshinImpact.GameTask.AutoFight.Script; using BetterGenshinImpact.GameTask.Common; using BetterGenshinImpact.GameTask.Common.BgiVision; using BetterGenshinImpact.GameTask.Common.Job; @@ -17,6 +22,7 @@ using BetterGenshinImpact.GameTask; using BetterGenshinImpact.GameTask.Model; using BetterGenshinImpact.GameTask.Model.Area; using BetterGenshinImpact.Service.Notification; +using BetterGenshinImpact.View.Drawable; using Microsoft.Extensions.Logging; using OpenCvSharp; using System; @@ -31,13 +37,14 @@ using System.Threading.Tasks; using BetterGenshinImpact.GameTask.AutoGeniusInvokation.Exception; using Vanara.PInvoke; using static BetterGenshinImpact.GameTask.Common.TaskControl; +using BetterGenshinImpact.View; namespace BetterGenshinImpact.GameTask.AutoLeyLineOutcrop; public class AutoLeyLineOutcropTask : ISoloTask { private readonly ILogger _logger = App.GetLogger(); - private readonly AutoLeyLineOutcropConfig _config; + private readonly AutoLeyLineOutcropParam _taskParam; private readonly bool _oneDragonMode; private TpTask _tpTask = null!; private readonly ReturnMainUiTask _returnMainUiTask = new(); @@ -61,6 +68,7 @@ public class AutoLeyLineOutcropTask : ISoloTask private RecognitionObject? _paimonMenuRo; private RecognitionObject? _boxIconRo; private RecognitionObject? _mapSettingButtonRo; + private RecognitionObject? _handbookTrackActionRo; private RecognitionObject? _ocrRo1; private RecognitionObject? _ocrRo2; private RecognitionObject? _ocrRo3; @@ -70,12 +78,22 @@ public class AutoLeyLineOutcropTask : ISoloTask private const int MaxRecheckCount = 3; private const int MaxConsecutiveFailures = 5; + private const string OcrFlowOverlayKey = "AutoLeyLineOutcrop.OcrFlow"; + private const string OcrFightOverlayKey = "AutoLeyLineOutcrop.OcrFight"; + private const int OcrOverlayRenderLeadMs = 300; + private static readonly Rect HandbookTrackActionButtonRoi = new(ScaleTo1080(1120), ScaleTo1080(680), ScaleTo1080(700), ScaleTo1080(320)); + private static readonly System.Drawing.Pen OcrOverlayPen = new(System.Drawing.Color.Lime, 2); + private static readonly object PickLock = new(); + private bool _overlayDisplayTemporarilyEnabled; + private bool _overlayDisplayOriginalValue; + private DateTime _lastMaskBringTopTime = DateTime.MinValue; + private bool _friendshipTeamSwitched; public string Name => "自动地脉花"; - public AutoLeyLineOutcropTask(AutoLeyLineOutcropConfig config, bool oneDragonMode = false) + public AutoLeyLineOutcropTask(AutoLeyLineOutcropParam taskParam, bool oneDragonMode = false) { - _config = config; + _taskParam = taskParam; _oneDragonMode = oneDragonMode; } @@ -86,6 +104,7 @@ public class AutoLeyLineOutcropTask : ISoloTask try { Initialize(); + EnsureMaskOverlayVisible(); var runTimesValue = await HandleResinExhaustionMode(); if (runTimesValue <= 0) { @@ -95,7 +114,7 @@ public class AutoLeyLineOutcropTask : ISoloTask await PrepareForLeyLineRun(); await RunLeyLineChallenges(); - if (_config.IsResinExhaustionMode) + if (_taskParam.IsResinExhaustionMode) { await RecheckResinAndContinue(); } @@ -108,7 +127,7 @@ public class AutoLeyLineOutcropTask : ISoloTask { _logger.LogDebug(e, "自动地脉花执行失败"); _logger.LogError("自动地脉花执行失败:" + e.Message); - if (_config.IsNotification) + if (_taskParam.IsNotification) { Notify.Event("AutoLeyLineOutcrop").Error($"任务失败: {e.Message}"); } @@ -119,16 +138,24 @@ public class AutoLeyLineOutcropTask : ISoloTask { try { - await EnsureExitRewardPage(); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "地脉花结束后尝试退出奖励界面失败"); - } + try + { + await EnsureExitRewardPage(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "地脉花结束后尝试退出奖励界面失败"); + } - if (!_marksStatus) + if (!_marksStatus) + { + await OpenCustomMarks(); + } + } + finally { - await OpenCustomMarks(); + ClearOcrOverlayKeys(); + RestoreMaskOverlayVisible(); } } } @@ -144,29 +171,45 @@ public class AutoLeyLineOutcropTask : ISoloTask private void ValidateSettings() { - if (string.IsNullOrWhiteSpace(_config.LeyLineOutcropType)) + _taskParam.FightConfig ??= new AutoLeyLineOutcropFightConfig(); + + if (string.IsNullOrWhiteSpace(_taskParam.LeyLineOutcropType)) { throw new Exception("地脉花类型未选择"); } - if (_config.LeyLineOutcropType != "启示之花" && _config.LeyLineOutcropType != "藏金之花") + if (_taskParam.LeyLineOutcropType != "启示之花" && _taskParam.LeyLineOutcropType != "藏金之花") { throw new Exception("地脉花类型无效,请重新选择"); } - if (string.IsNullOrWhiteSpace(_config.Country)) + if (string.IsNullOrWhiteSpace(_taskParam.Country)) { throw new Exception("国家未配置"); } - if (!string.IsNullOrWhiteSpace(_config.FriendshipTeam) && string.IsNullOrWhiteSpace(_config.Team)) + if (!string.IsNullOrWhiteSpace(_taskParam.FriendshipTeam) && string.IsNullOrWhiteSpace(_taskParam.Team)) { throw new Exception("配置好感队时必须配置战斗队伍"); } - if (_config.Count < 1) + if (_taskParam.Count < 1) { - _config.Count = 1; + _taskParam.Count = 1; + } + + if (string.IsNullOrWhiteSpace(_taskParam.FightConfig.StrategyName) && _taskParam.Timeout > 0) + { + _taskParam.FightConfig.Timeout = _taskParam.Timeout; + } + + if (_taskParam.FightConfig.Timeout <= 0) + { + _taskParam.FightConfig.Timeout = _taskParam.Timeout > 0 ? _taskParam.Timeout : 120; + } + else + { + _taskParam.Timeout = _taskParam.FightConfig.Timeout; } } @@ -193,6 +236,7 @@ public class AutoLeyLineOutcropTask : ISoloTask _paimonMenuRo = BuildTemplate("Assets/icon/paimon_menu.png", new Rect(0, 0, ScaleTo1080(640), ScaleTo1080(216))); _boxIconRo = BuildTemplate("Assets/icon/box.png"); _mapSettingButtonRo = BuildTemplate("Assets/icon/map_setting_button.bmp"); + _handbookTrackActionRo = BuildTemplate("Assets/icon/handbook_track_action_left.png", HandbookTrackActionButtonRoi, 0.72); _ocrRo1 = RecognitionObject.Ocr(ScaleTo1080(800), ScaleTo1080(200), ScaleTo1080(300), ScaleTo1080(100)); _ocrRo2 = RecognitionObject.Ocr(ScaleTo1080(0), ScaleTo1080(200), ScaleTo1080(300), ScaleTo1080(300)); @@ -242,9 +286,9 @@ public class AutoLeyLineOutcropTask : ISoloTask private async Task HandleResinExhaustionMode() { - if (!_config.IsResinExhaustionMode) + if (!_taskParam.IsResinExhaustionMode) { - return _config.Count; + return _taskParam.Count; } var result = await CalCountByResin(); @@ -253,16 +297,16 @@ public class AutoLeyLineOutcropTask : ISoloTask return 0; } - if (_config.OpenModeCountMin) + if (_taskParam.OpenModeCountMin) { - _config.Count = Math.Min(result.Count, _config.Count); + _taskParam.Count = Math.Min(result.Count, _taskParam.Count); } else { - _config.Count = result.Count; + _taskParam.Count = result.Count; } - if (_config.IsNotification) + if (_taskParam.IsNotification) { var text = "树脂耗尽模式统计结果:\n" + @@ -274,7 +318,7 @@ public class AutoLeyLineOutcropTask : ISoloTask Notify.Event("AutoLeyLineOutcrop").Send(text); } - return _config.Count; + return _taskParam.Count; } private async Task PrepareForLeyLineRun() @@ -286,13 +330,12 @@ public class AutoLeyLineOutcropTask : ISoloTask await _tpTask.TpToStatueOfTheSeven(); } - if (!string.IsNullOrWhiteSpace(_config.Team)) + if (!string.IsNullOrWhiteSpace(_taskParam.Team)) { - _switchPartyTask ??= new SwitchPartyTask(); - await _switchPartyTask.Start(_config.Team, _ct); + await TrySwitchPartyAndSync(_taskParam.Team); } - if (_config.UseAdventurerHandbook) + if (_taskParam.UseAdventurerHandbook) { // The config flag means "do NOT use handbook"; close custom marks for manual navigation. await CloseCustomMarks(); @@ -303,17 +346,17 @@ public class AutoLeyLineOutcropTask : ISoloTask private async Task RunLeyLineChallenges() { - while (_currentRunTimes < _config.Count) + while (_currentRunTimes < _taskParam.Count) { - if (!_config.UseAdventurerHandbook) + if (!_taskParam.UseAdventurerHandbook) { // Handbook flow: open the book and track a ley line target. - await FindLeyLineOutcropByBook(_config.Country, _config.LeyLineOutcropType); + await FindLeyLineOutcropByBook(_taskParam.Country, _taskParam.LeyLineOutcropType); } else { // Manual flow: detect the ley line on the big map. - await FindLeyLineOutcrop(_config.Country, _config.LeyLineOutcropType); + await FindLeyLineOutcrop(_taskParam.Country, _taskParam.LeyLineOutcropType); } var foundStrategy = await ExecuteMatchingStrategy(); @@ -332,7 +375,7 @@ public class AutoLeyLineOutcropTask : ISoloTask throw new Exception("地脉花策略配置缺失"); } - if (!_configData.LeyLinePositions.TryGetValue(_config.Country, out var positions)) + if (!_configData.LeyLinePositions.TryGetValue(_taskParam.Country, out var positions)) { return false; } @@ -379,13 +422,13 @@ public class AutoLeyLineOutcropTask : ISoloTask await ExecutePath(optimal); _currentRunTimes++; - if (_currentRunTimes >= _config.Count) + if (_currentRunTimes >= _taskParam.Count) { return; } var currentNode = targetNode; - while (currentNode.Next.Count > 0 && _currentRunTimes < _config.Count) + while (currentNode.Next.Count > 0 && _currentRunTimes < _taskParam.Count) { if (currentNode.Next.Count == 1) { @@ -415,7 +458,7 @@ public class AutoLeyLineOutcropTask : ISoloTask await _returnMainUiTask.Start(_ct); await _tpTask.OpenBigMapUi(); - var found = await LocateLeyLineOutcrop(_config.LeyLineOutcropType); + var found = await LocateLeyLineOutcrop(_taskParam.LeyLineOutcropType); await _returnMainUiTask.Start(_ct); if (!found) @@ -634,8 +677,10 @@ public class AutoLeyLineOutcropTask : ISoloTask } var lastRoute = path.Routes.Last(); - var targetRoute = lastRoute.Replace("Assets/pathing/", "Assets/pathing/target/").Replace("-rerun", ""); - await ProcessLeyLineOutcrop(_config.Timeout, targetRoute); + var targetRoute = BuildTargetRoute(lastRoute); + var rerunRoute = BuildRerunRoute(lastRoute); + var fromTeleportStart = "teleport".Equals(path.StartNode.Type, StringComparison.OrdinalIgnoreCase); + await ProcessLeyLineOutcrop(_taskParam.FightConfig.Timeout, targetRoute, rerunRoute, fromTeleportStart); var rewardSuccess = await AttemptReward(); if (!rewardSuccess) @@ -643,9 +688,39 @@ public class AutoLeyLineOutcropTask : ISoloTask throw new Exception("无法领取奖励"); } + await TryScanDropsAfterReward(); _consecutiveFailureCount = 0; } + private static string BuildTargetRoute(string routePath) + { + return routePath + .Replace("assets/pathing/", "assets/pathing/target/", StringComparison.OrdinalIgnoreCase) + .Replace("-rerun", "", StringComparison.OrdinalIgnoreCase); + } + + private static string BuildRerunRoute(string routePath) + { + var rerunRoute = routePath + .Replace("assets/pathing/target/", "assets/pathing/rerun/", StringComparison.OrdinalIgnoreCase) + .Replace("assets/pathing/", "assets/pathing/rerun/", StringComparison.OrdinalIgnoreCase); + + if (!rerunRoute.Contains("-rerun", StringComparison.OrdinalIgnoreCase)) + { + rerunRoute = rerunRoute.Replace(".json", "-rerun.json", StringComparison.OrdinalIgnoreCase); + } + + return rerunRoute; + } + + private bool PathingFileExists(string routePath) + { + var workDir = Global.Absolute(@"GameTask\AutoLeyLineOutcrop"); + var localPath = routePath.Replace("/", Path.DirectorySeparatorChar.ToString()); + var fullPath = Path.Combine(workDir, localPath); + return File.Exists(fullPath); + } + private async Task RunPathingFile(string routePath) { await _returnMainUiTask.Start(_ct); @@ -656,9 +731,17 @@ public class AutoLeyLineOutcropTask : ISoloTask var task = PathingTask.BuildFromFilePath(fullPath) ?? throw new Exception("路径文件解析失败"); var executor = new PathExecutor(_ct); + executor.PartyConfig = BuildLeyLinePathingPartyConfig(); await executor.Pathing(task); } + private static PathingPartyConfig BuildLeyLinePathingPartyConfig() + { + var partyConfig = PathingPartyConfig.BuildDefault(); + partyConfig.SkipPartySwitch = true; + return partyConfig; + } + private async Task LoadNodeData() { if (_nodeData != null) @@ -762,7 +845,7 @@ public class AutoLeyLineOutcropTask : ISoloTask } await EnsureExitRewardPage(); - if (_config.UseAdventurerHandbook) + if (_taskParam.UseAdventurerHandbook) { _logger.LogWarning("寻找地脉花失败:当前已勾选“不使用冒险之证寻路”,可尝试关闭该选项后重试!"); throw new Exception("寻找地脉花失败:未在地图上识别到地脉花图标。当前已勾选“不使用冒险之证寻路”,可尝试关闭该选项后重试!"); @@ -801,13 +884,13 @@ public class AutoLeyLineOutcropTask : ISoloTask private void HandleNoStrategyFound() { _logger.LogError("未找到对应的地脉花策略"); - if (_config.IsNotification) + if (_taskParam.IsNotification) { Notify.Event("AutoLeyLineOutcrop").Error("未找到对应的地脉花策略"); } } - private async Task ProcessLeyLineOutcrop(int timeoutSeconds, string targetPath, int retries = 0) + private async Task ProcessLeyLineOutcrop(int timeoutSeconds, string targetPath, string rerunPath, bool fromTeleportStart, int retries = 0) { const int maxRetries = 3; if (retries >= maxRetries) @@ -819,18 +902,45 @@ public class AutoLeyLineOutcropTask : ISoloTask await Delay(500, _ct); _logger.LogDebug("检测地脉花交互状态,重试次数: {Retries}/{MaxRetries}", retries + 1, maxRetries); using var capture = CaptureToRectArea(); + using var ocrOverlayScope = DrawOcrOverlayScope(capture, OcrFlowOverlayKey, _ocrRo2!.RegionOfInterest, _ocrRo3!.RegionOfInterest); + await WaitOcrOverlayRenderTick(); + string result1Text; + string result2Text; var result1 = FindSafe(capture, _ocrRo2!); var result2 = FindSafe(capture, _ocrRo3!); - _logger.LogDebug("OCR结果: result1='{Text1}', result2='{Text2}'", result1.Text, result2.Text); + result1Text = result1.Text; + result2Text = result2.Text; + _logger.LogDebug("OCR结果: result1='{Text1}', result2='{Text2}'", result1Text, result2Text); - if (result2.Text.Contains("之花", StringComparison.Ordinal)) + if (IsLeyLineRewardReadyState(capture, result1Text, result2Text)) { - _logger.LogDebug("识别到地脉之花入口"); + _logger.LogDebug("识别到地脉花领奖状态"); await SwitchToFriendshipTeamIfNeeded(); return true; } - if (result2.Text.Contains("溢口", StringComparison.Ordinal)) + if (ContainsLeyLineFlowerText(result2Text)) + { + _logger.LogDebug("识别到地脉之花入口,尝试接触"); + Simulation.SendInput.SimulateAction(GIActions.PickUpOrInteract); + await Delay(800, _ct); + + using var postInteractCapture = CaptureToRectArea(); + result1 = FindSafe(postInteractCapture, _ocrRo2!); + result2 = FindSafe(postInteractCapture, _ocrRo3!); + result1Text = result1.Text; + result2Text = result2.Text; + _logger.LogDebug("接触后OCR结果: result1='{Text1}', result2='{Text2}'", result1Text, result2Text); + + if (IsLeyLineRewardReadyState(postInteractCapture, result1Text, result2Text)) + { + _logger.LogDebug("接触后识别到地脉花领奖状态"); + await SwitchToFriendshipTeamIfNeeded(); + return true; + } + } + + if (result2Text.Contains("溢口", StringComparison.Ordinal)) { _logger.LogDebug("识别到溢口提示,尝试交互"); Simulation.SendInput.SimulateAction(GIActions.PickUpOrInteract); @@ -838,11 +948,22 @@ public class AutoLeyLineOutcropTask : ISoloTask Simulation.SendInput.SimulateAction(GIActions.PickUpOrInteract); await Delay(500, _ct); } - else if (!ContainsFightText(result1.Text)) + else if (!ContainsFightText(result1Text)) { - _logger.LogDebug("未识别到战斗提示,执行路径: {Path}", targetPath); - await RunPathingFile(targetPath); - return await ProcessLeyLineOutcrop(timeoutSeconds, targetPath, retries + 1); + var recoverPath = retries == 0 + ? targetPath + : fromTeleportStart + ? targetPath + : rerunPath; + if (!PathingFileExists(recoverPath)) + { + _logger.LogWarning("纠偏路径不存在,回退target路径: {Path}", recoverPath); + recoverPath = targetPath; + } + + _logger.LogDebug("未识别到战斗提示,执行纠偏路径: {Path}", recoverPath); + await RunPathingFile(recoverPath); + return await ProcessLeyLineOutcrop(timeoutSeconds, targetPath, rerunPath, fromTeleportStart, retries + 1); } var fightResult = await AutoFight(timeoutSeconds); @@ -851,17 +972,189 @@ public class AutoLeyLineOutcropTask : ISoloTask await EnsureExitRewardPage(); if (await ProcessResurrect()) { - return await ProcessLeyLineOutcrop(timeoutSeconds, targetPath, retries + 1); + return await ProcessLeyLineOutcrop(timeoutSeconds, targetPath, rerunPath, fromTeleportStart, retries + 1); } throw new Exception("战斗失败"); } + await TryCollectDropsAfterFight(); await SwitchToFriendshipTeamIfNeeded(); await AutoNavigateToReward(); return true; } + private async Task TryCollectDropsAfterFight() + { + if (!_taskParam.FightConfig.KazuhaPickupEnabled) + { + return; + } + + try + { + var combatScenes = await RunnerContext.Instance.GetCombatScenes(_ct); + if (combatScenes == null) + { + _logger.LogWarning("战后聚集拾取:队伍识别失败,跳过"); + return; + } + + var kazuha = combatScenes.SelectAvatar("枫原万叶"); + if (kazuha != null) + { + await TryKazuhaCollect(kazuha); + return; + } + + var qin = combatScenes.SelectAvatar("琴"); + if (qin != null) + { + await TryQinCollect(combatScenes, qin); + return; + } + + _logger.LogDebug("战后聚集拾取:当前队伍无万叶/琴,跳过"); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "战后聚集拾取异常"); + } + finally + { + Simulation.ReleaseAllKey(); + } + } + + /// + /// 在地脉花奖励领取完成后,短时间扫描周围掉落物光柱并尝试靠近拾取。 + /// + private async Task TryScanDropsAfterReward() + { + if (!_taskParam.ScanDropsAfterRewardEnabled) + { + return; + } + + const int maxScanSeconds = 60; + var scanSeconds = Math.Clamp(_taskParam.ScanDropsAfterRewardSeconds, 0, maxScanSeconds); + if (scanSeconds <= 0) + { + return; + } + + var autoFightConfig = TaskContext.Instance().Config.AutoFightConfig; + var originalSeconds = autoFightConfig.PickDropsAfterFightSeconds; + + try + { + autoFightConfig.PickDropsAfterFightSeconds = scanSeconds; + _logger.LogInformation("领取奖励后开始扫描掉落物光柱,时长 {Seconds} 秒", scanSeconds); + await new ScanPickTask().Start(_ct); + } + catch (Exception ex) when (ex is OperationCanceledException or TaskCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "领取奖励后扫描掉落物光柱异常"); + } + finally + { + autoFightConfig.PickDropsAfterFightSeconds = originalSeconds; + Simulation.ReleaseAllKey(); + } + } + + private async Task TryKazuhaCollect(Avatar kazuha) + { + _logger.LogInformation("战后聚集拾取:开始使用枫原万叶执行长E拾取"); + await Delay(200, _ct); + if (!kazuha.TrySwitch(10)) + { + _logger.LogWarning("战后聚集拾取:切换到万叶失败,跳过"); + return; + } + + _logger.LogInformation("战后聚集拾取:万叶已切换,等待元素战技CD"); + await kazuha.WaitSkillCd(_ct); + kazuha.UseSkill(true); + await Delay(50, _ct); + Simulation.SendInput.SimulateAction(GIActions.NormalAttack); + await Delay(1500, _ct); + kazuha.AfterUseSkill(); + _logger.LogInformation("战后聚集拾取:万叶长E聚集动作执行完成"); + } + + private async Task TryQinCollect(CombatScenes combatScenes, Avatar qin) + { + _logger.LogInformation("战后聚集拾取:使用琴-长E拾取掉落物"); + var find = _taskParam.FightConfig.QinDoublePickUp; + await Delay(150, _ct); + if (qin.TrySwitch(10)) + { + var actionsToUse = PickUpCollectHandler.PickUpActions + .Where(action => action.StartsWith("琴-长E ", StringComparison.OrdinalIgnoreCase)) + .Select(action => action.Replace("琴-长E", "琴", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + foreach (var actionStr in actionsToUse) + { + var pickUpAction = CombatScriptParser.ParseContext(actionStr); + for (var i = 0; i < 2; i++) + { + await qin.WaitSkillCd(_ct); + foreach (var command in pickUpAction.CombatCommands) + { + command.Execute(combatScenes); + Task.Run(() => + { + if (Monitor.TryEnter(PickLock)) + { + try + { + if (find) + { + using var imagePick = CaptureToRectArea(); + if (imagePick.Find(AutoPickAssets.Instance.PickRo).IsExist()) + { + find = false; + } + } + } + finally + { + Monitor.Exit(PickLock); + } + } + }); + } + + if (!find) + { + break; + } + + if (i == 0) + { + _logger.LogInformation("战后聚集拾取:尝试再次执行琴-长E拾取"); + qin.AfterUseSkill(); + } + else + { + break; + } + } + + Simulation.ReleaseAllKey(); + } + } + else + { + _logger.LogWarning("战后聚集拾取:切换到琴失败,跳过"); + } + } + private Region FindSafe(ImageRegion capture, RecognitionObject ro) { var roi = ro.RegionOfInterest; @@ -889,6 +1182,7 @@ public class AutoLeyLineOutcropTask : ISoloTask private async Task AutoFight(int timeoutSeconds) { var fightCts = CancellationTokenSource.CreateLinkedTokenSource(_ct); + using var autoFightConfigScope = UseLeyLineAutoFightConfigScope(); // Ley line uses OCR-based finish detection; disable auto-fight finish detect. var fightTask = StartAutoFightWithoutFinishDetect(fightCts.Token); var fightResult = await RecognizeTextInRegion(timeoutSeconds * 1000); @@ -915,7 +1209,7 @@ public class AutoLeyLineOutcropTask : ISoloTask private Task StartAutoFightWithoutFinishDetect(CancellationToken ct) { - var autoFightConfig = TaskContext.Instance().Config.AutoFightConfig; + var autoFightConfig = BuildLeyLineAutoFightConfig(); var strategyPath = BuildAutoFightStrategyPath(autoFightConfig); var taskParam = new AutoFightParam(strategyPath, autoFightConfig) { @@ -925,9 +1219,39 @@ public class AutoLeyLineOutcropTask : ISoloTask // Avoid false finish signals for ley line fights. taskParam.FinishDetectConfig.FastCheckEnabled = false; taskParam.FinishDetectConfig.RotateFindEnemyEnabled = false; + taskParam.PickDropsAfterFightEnabled = false; + taskParam.KazuhaPickupEnabled = false; + taskParam.QinDoublePickUp = false; + taskParam.OnlyPickEliteDropsMode = "DisableAutoPickupForNonElite"; return new AutoFightTask(taskParam).Start(ct); } + private IDisposable UseLeyLineAutoFightConfigScope() + { + var allConfig = TaskContext.Instance().Config; + var original = allConfig.AutoFightConfig; + allConfig.AutoFightConfig = BuildLeyLineAutoFightConfig(); + return new AutoFightConfigScope(allConfig, original); + } + + private AutoFightConfig BuildLeyLineAutoFightConfig() + { + var globalAutoFightConfig = TaskContext.Instance().Config.AutoFightConfig; + var fightConfig = _taskParam.FightConfig; + if (string.IsNullOrWhiteSpace(fightConfig.StrategyName)) + { + fightConfig.CopyFromAutoFightConfig(globalAutoFightConfig); + } + + if (fightConfig.Timeout <= 0) + { + fightConfig.Timeout = _taskParam.Timeout > 0 ? _taskParam.Timeout : Math.Max(globalAutoFightConfig.Timeout, 1); + } + + _taskParam.Timeout = fightConfig.Timeout; + return fightConfig.ToAutoFightConfig(); + } + private static string BuildAutoFightStrategyPath(AutoFightConfig config) { var path = Global.Absolute(@"User\AutoFight\" + config.StrategyName + ".txt"); @@ -954,8 +1278,13 @@ public class AutoLeyLineOutcropTask : ISoloTask while ((DateTime.UtcNow - start).TotalMilliseconds < timeoutMs) { using var capture = CaptureToRectArea(); + using var ocrOverlayScope = DrawOcrOverlayScope(capture, OcrFightOverlayKey, _ocrRo1!.RegionOfInterest, _ocrRo2!.RegionOfInterest); + await WaitOcrOverlayRenderTick(); + string text; + bool foundText; var result = capture.Find(_ocrRo1!); - var text = result.Text; + text = result.Text; + foundText = RecognizeFightText(capture); if (successKeywords.Any(text.Contains)) { @@ -969,7 +1298,6 @@ public class AutoLeyLineOutcropTask : ISoloTask return false; } - var foundText = RecognizeFightText(capture); if (!foundText) { noTextCount++; @@ -996,8 +1324,19 @@ public class AutoLeyLineOutcropTask : ISoloTask return ContainsFightText(text); } + private bool IsLeyLineRewardReadyState(ImageRegion capture, string result1Text, string result2Text) + { + if (ContainsFightText(result1Text)) + { + return false; + } + + return ContainsRewardPromptActionText(result2Text) || HasRewardPrompt(capture); + } + private static bool ContainsFightText(string text) { + text = NormalizeLeyLineOcrText(text); var keywords = new[] { "打倒", "所有", "敌人" }; return keywords.Any(text.Contains); } @@ -1186,33 +1525,55 @@ public class AutoLeyLineOutcropTask : ISoloTask private async Task SwitchToFriendshipTeamIfNeeded() { - if (string.IsNullOrWhiteSpace(_config.FriendshipTeam)) + if (string.IsNullOrWhiteSpace(_taskParam.FriendshipTeam)) { + _friendshipTeamSwitched = false; return; } Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyUp); try { - _switchPartyTask ??= new SwitchPartyTask(); - await _switchPartyTask.Start(_config.FriendshipTeam, _ct); + _friendshipTeamSwitched = await TrySwitchPartyAndSync(_taskParam.FriendshipTeam); + if (!_friendshipTeamSwitched) + { + _logger.LogWarning("切换好感队失败,保持当前队伍"); + } } catch (Exception ex) { + _friendshipTeamSwitched = false; _logger.LogWarning(ex, "切换好感队失败!"); } } private async Task SwitchBackToCombatTeam() { - if (string.IsNullOrWhiteSpace(_config.Team)) + if (!_friendshipTeamSwitched || string.IsNullOrWhiteSpace(_taskParam.Team)) { return; } Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyUp); + await TrySwitchPartyAndSync(_taskParam.Team); + _friendshipTeamSwitched = false; + } + + private async Task TrySwitchPartyAndSync(string partyName) + { + if (string.IsNullOrWhiteSpace(partyName)) + { + return false; + } + _switchPartyTask ??= new SwitchPartyTask(); - await _switchPartyTask.Start(_config.Team, _ct); + var success = await _switchPartyTask.Start(partyName, _ct); + if (success) + { + RunnerContext.Instance.PartyName = partyName; + } + + return success; } private async Task AttemptReward(int retryCount = 0) @@ -1233,28 +1594,20 @@ public class AutoLeyLineOutcropTask : ISoloTask return await AttemptReward(retryCount + 1); } - var isOriginalResinEmpty = await CheckOriginalResinEmpty(); - var sortedButtons = FindAndSortUseButtons(); - if (sortedButtons.Count == 0) + if (!await TryUseRewardResin()) { await EnsureExitRewardPage(); return false; } - var resinChoice = await AnalyzeResinOptions(sortedButtons, isOriginalResinEmpty); - if (resinChoice == null) - { - await EnsureExitRewardPage(); - return false; - } - - resinChoice.Value.Click(); - await Delay(1000, _ct); - - if (!string.IsNullOrWhiteSpace(_config.FriendshipTeam)) + if (_friendshipTeamSwitched) { await SwitchBackToCombatTeam(); } + else if (!string.IsNullOrWhiteSpace(_taskParam.FriendshipTeam)) + { + _logger.LogDebug("本次未成功切换到好感队,跳过切回战斗队"); + } await Delay(1200, _ct); await EnsureExitRewardPage(); @@ -1264,124 +1617,303 @@ public class AutoLeyLineOutcropTask : ISoloTask private async Task VerifyRewardPage() { using var capture = CaptureToRectArea(); - var roi = new Rect(0, 0, capture.Width, capture.Height / 2); - var list = capture.FindMulti(RecognitionObject.Ocr(roi)); - foreach (var res in list) + return HasRewardPrompt(capture); + } + + private IDisposable DrawOcrOverlayScope(ImageRegion capture, string key, params Rect[] rois) + { + var drawList = new List(rois.Length); + foreach (var roi in rois) { - var text = res.Text; - if (text.Contains("激活地脉之花", StringComparison.Ordinal) || text.Contains("选择激活方式", StringComparison.Ordinal)) + var clamped = roi.ClampTo(capture.Width, capture.Height); + if (clamped.Width <= 0 || clamped.Height <= 0) { - return true; + continue; } + + drawList.Add(capture.ToRectDrawable(clamped, key, OcrOverlayPen)); + } + + var drawContent = VisionContext.Instance().DrawContent; + drawContent.PutOrRemoveRectList(key, drawList.Count > 0 ? drawList : null); + RefreshMaskWindowForOverlay(); + return new OcrOverlayScope(drawContent, key, RefreshMaskWindowForOverlay); + } + + private void ClearOcrOverlayKeys() + { + var drawContent = VisionContext.Instance().DrawContent; + drawContent.RemoveRect(OcrFlowOverlayKey); + drawContent.RemoveRect(OcrFightOverlayKey); + drawContent.PutOrRemoveTextList(OcrFlowOverlayKey, null); + drawContent.PutOrRemoveTextList(OcrFightOverlayKey, null); + RefreshMaskWindowForOverlay(); + } + + private async Task WaitOcrOverlayRenderTick() + { + await Task.Yield(); + await Task.Delay(OcrOverlayRenderLeadMs, _ct); + } + + private void EnsureMaskOverlayVisible() + { + var config = TaskContext.Instance().Config.MaskWindowConfig; + _overlayDisplayOriginalValue = config.DisplayRecognitionResultsOnMask; + if (!config.DisplayRecognitionResultsOnMask) + { + config.DisplayRecognitionResultsOnMask = true; + _overlayDisplayTemporarilyEnabled = true; + } + + var maskWindow = MaskWindow.InstanceNullable(); + if (maskWindow != null) + { + maskWindow.Invoke(() => + { + maskWindow.Topmost = true; + if (!maskWindow.IsVisible) + { + maskWindow.Show(); + } + + maskWindow.BringToTop(); + }); + } + } + + private void RestoreMaskOverlayVisible() + { + if (!_overlayDisplayTemporarilyEnabled) + { + return; + } + + TaskContext.Instance().Config.MaskWindowConfig.DisplayRecognitionResultsOnMask = _overlayDisplayOriginalValue; + _overlayDisplayTemporarilyEnabled = false; + } + + private void RefreshMaskWindowForOverlay() + { + var maskWindow = MaskWindow.InstanceNullable(); + if (maskWindow == null) + { + return; + } + + var now = DateTime.UtcNow; + var shouldBringTop = now - _lastMaskBringTopTime > TimeSpan.FromSeconds(1); + if (shouldBringTop) + { + _lastMaskBringTopTime = now; + } + + maskWindow.Invoke(() => + { + maskWindow.Topmost = true; + if (!maskWindow.IsVisible) + { + maskWindow.Show(); + } + + if (shouldBringTop) + { + maskWindow.BringToTop(); + } + + maskWindow.Refresh(); + }); + } + + private async Task TryUseRewardResin() + { + for (var attempt = 0; attempt < 2; attempt++) + { + if (await TryUseRewardResinOnce()) + { + await Delay(1000, _ct); + if (!await VerifyRewardPage()) + { + return true; + } + + _logger.LogDebug("树脂点击后奖励页面仍存在,第{Attempt}次后准备重试", attempt + 1); + } + else + { + _logger.LogDebug("奖励页面未成功选择树脂,第{Attempt}次后准备重试", attempt + 1); + } + + await Delay(300, _ct); } return false; } - private async Task CheckOriginalResinEmpty() + private async Task TryUseRewardResinOnce() { - using var capture = CaptureToRectArea(); - var list = capture.FindMulti(_ocrRoThis); - foreach (var res in list) + await ActivateRewardPrompt(); + + var promptRegions = CaptureRewardPromptRegions(); + if (promptRegions.Count == 0) { - if (res.Text.Contains("补充", StringComparison.Ordinal)) + _logger.LogDebug("奖励页面未识别到树脂弹窗内容"); + return false; + } + + var lineTexts = BuildPromptTextLines(promptRegions); + var isOriginalResinEmpty = lineTexts.Any(text => text.Contains("补充", StringComparison.Ordinal)); + var hasDoubleReward = lineTexts.Any(text => text.Contains("双倍", StringComparison.Ordinal) + || text.Contains("2倍产出", StringComparison.Ordinal) + || text.Contains("2倍", StringComparison.Ordinal)); + var originalResinLines = lineTexts.Where(text => text.Contains("原粹", StringComparison.Ordinal)).ToList(); + var hasOriginal20 = !isOriginalResinEmpty && originalResinLines.Any(text => text.Contains("20", StringComparison.Ordinal)); + var hasOriginal40 = !isOriginalResinEmpty && originalResinLines.Any(text => text.Contains("40", StringComparison.Ordinal)); + var hasCondensed = lineTexts.Any(text => text.Contains("浓缩", StringComparison.Ordinal)); + var hasTransient = lineTexts.Any(text => text.Contains("须臾", StringComparison.Ordinal)); + var hasFragile = lineTexts.Any(text => text.Contains("脆弱", StringComparison.Ordinal)); + + // 双倍奖励下优先切到 40 树脂,避免误用 20 树脂。 + if (hasDoubleReward && hasOriginal20 && !hasOriginal40) + { + if (await TrySwitch20To40Resin()) { - return true; + await Delay(300, _ct); + promptRegions = CaptureRewardPromptRegions(); + if (promptRegions.Count == 0) + { + return false; + } + + lineTexts = BuildPromptTextLines(promptRegions); + isOriginalResinEmpty = lineTexts.Any(text => text.Contains("补充", StringComparison.Ordinal)); + hasDoubleReward = lineTexts.Any(text => text.Contains("双倍", StringComparison.Ordinal) + || text.Contains("2倍产出", StringComparison.Ordinal) + || text.Contains("2倍", StringComparison.Ordinal)); + originalResinLines = lineTexts.Where(text => text.Contains("原粹", StringComparison.Ordinal)).ToList(); + hasOriginal20 = !isOriginalResinEmpty && originalResinLines.Any(text => text.Contains("20", StringComparison.Ordinal)); + hasOriginal40 = !isOriginalResinEmpty && originalResinLines.Any(text => text.Contains("40", StringComparison.Ordinal)); + hasCondensed = lineTexts.Any(text => text.Contains("浓缩", StringComparison.Ordinal)); + hasTransient = lineTexts.Any(text => text.Contains("须臾", StringComparison.Ordinal)); + hasFragile = lineTexts.Any(text => text.Contains("脆弱", StringComparison.Ordinal)); } } - return false; - } - - private List FindAndSortUseButtons() - { - using var capture = CaptureToRectArea(); - var list = capture.FindMulti(_ocrRoThis); - var buttons = new List(); - - foreach (var res in list) - { - var text = res.Text.Trim(); - if (text == "使用") - { - var centerX = res.X + res.Width / 2; - var centerY = res.Y + res.Height / 2; - buttons.Add(new UseButton(centerX, centerY, res.Y)); - } - } - - return buttons.OrderBy(b => b.SortKey).ToList(); - } - - private async Task AnalyzeResinOptions(List sortedButtons, bool isOriginalResinEmpty) - { - using var capture = CaptureToRectArea(); - var list = capture.FindMulti(_ocrRoThis); - var texts = list.Select(r => new { r.Text, r.Y }).ToList(); - - var hasDoubleReward = texts.Any(t => t.Text.Contains("双倍", StringComparison.Ordinal) || t.Text.Contains("2倍产出", StringComparison.Ordinal) || t.Text.Contains("2倍", StringComparison.Ordinal)); - var hasOriginal20 = !isOriginalResinEmpty && texts.Any(t => t.Text.Contains("20", StringComparison.Ordinal) && t.Text.Contains("原粹", StringComparison.Ordinal)); - var hasOriginal40 = !isOriginalResinEmpty && texts.Any(t => t.Text.Contains("40", StringComparison.Ordinal) && t.Text.Contains("原粹", StringComparison.Ordinal)); - var hasCondensed = texts.Any(t => t.Text.Contains("浓缩", StringComparison.Ordinal)); - var hasTransient = texts.Any(t => t.Text.Contains("须臾", StringComparison.Ordinal)); - var hasFragile = texts.Any(t => t.Text.Contains("脆弱", StringComparison.Ordinal)); - - if (isOriginalResinEmpty) - { - if (hasCondensed && sortedButtons.Count >= 1) - { - return sortedButtons[0]; - } - - if (hasTransient && _config.UseTransientResin && sortedButtons.Count >= 1) - { - return sortedButtons[0]; - } - - if (hasFragile && _config.UseFragileResin && sortedButtons.Count >= 1) - { - return sortedButtons[0]; - } - - return null; - } - + var candidates = new List(); if (hasDoubleReward && (hasOriginal20 || hasOriginal40)) { - if (hasOriginal20 && !hasOriginal40) + candidates.Add("原粹树脂"); + } + else if (isOriginalResinEmpty) + { + if (hasCondensed) { - await TrySwitch20To40Resin(); + candidates.Add("浓缩树脂"); } - return sortedButtons.FirstOrDefault(); - } - - if (hasCondensed && sortedButtons.Count >= 2) - { - return sortedButtons[1]; - } - - if (hasTransient && _config.UseTransientResin && sortedButtons.Count >= 2) - { - return sortedButtons[1]; - } - - if (hasOriginal20 || hasOriginal40) - { - if (hasOriginal20 && !hasOriginal40) + if (hasTransient && _taskParam.UseTransientResin) { - await TrySwitch20To40Resin(); + candidates.Add("须臾树脂"); } - return sortedButtons.FirstOrDefault(); + if (hasFragile && _taskParam.UseFragileResin) + { + candidates.Add("脆弱树脂"); + } } - - if (hasFragile && _config.UseFragileResin && sortedButtons.Count >= 2) + else { - return sortedButtons[1]; + if (hasCondensed) + { + candidates.Add("浓缩树脂"); + } + + if (hasTransient && _taskParam.UseTransientResin) + { + candidates.Add("须臾树脂"); + } + + if (hasOriginal20 || hasOriginal40) + { + candidates.Add("原粹树脂"); + } + + if (hasFragile && _taskParam.UseFragileResin) + { + candidates.Add("脆弱树脂"); + } } - return sortedButtons.FirstOrDefault(); + foreach (var resinName in candidates.Distinct(StringComparer.Ordinal)) + { + if (await TryPressRewardResin(promptRegions, resinName)) + { + return true; + } + } + + _logger.LogDebug("奖励页面树脂识别结果未匹配成功,ocr={Texts}", string.Join(" | ", lineTexts)); + return false; + } + + private async Task ActivateRewardPrompt() + { + using var capture = CaptureToRectArea(); + var titleRoi = GetRewardPromptTitleRoi(capture); + var titleRegion = CaptureRewardPromptTitleRegion(capture, titleRoi); + + // 对齐自动秘境的处理,先点一次标题区域激活弹窗,再点树脂使用按钮。 + Simulation.SendInput.Mouse.LeftButtonUp(); + await Delay(60, _ct); + + if (titleRegion != null) + { + titleRegion.Click(); + _logger.LogDebug("奖励页面已点击标题激活弹窗:text={Text}, x={X}, y={Y}", titleRegion.Text, titleRegion.X, titleRegion.Y); + } + else + { + capture.Derive(titleRoi).Click(); + _logger.LogDebug("奖励页面标题 OCR 被拆分,回退点击标题区域中心激活弹窗"); + } + + await Delay(800, _ct); + } + + private Region? CaptureRewardPromptTitleRegion(ImageRegion capture, Rect titleRoi) + { + var titleRegions = capture.FindMulti(RecognitionObject.Ocr(titleRoi)); + var mergedLines = MergeTextRegionsByLine(capture, titleRegions); + return mergedLines.FirstOrDefault(r => IsRewardPromptTitleText(r.Text)); + } + + private static bool IsRewardPromptTitleText(string text) + { + text = NormalizeLeyLineOcrText(text); + return text.Contains("激活地脉之花", StringComparison.Ordinal) + || text.Contains("选择激活方式", StringComparison.Ordinal) + || text.Contains("地脉之花", StringComparison.Ordinal); + } + + private List CaptureRewardPromptRegions() + { + using var capture = CaptureToRectArea(); + return capture.FindMulti(RecognitionObject.Ocr(GetRewardPromptContentRoi(capture))); + } + + private async Task TryPressRewardResin(List promptRegions, string resinName) + { + // 某些链路会残留左键按下状态,先显式抬起一次再点使用按钮。 + Simulation.SendInput.Mouse.LeftButtonUp(); + await Delay(60, _ct); + + var (success, _) = AutoDomainTask.PressUseResin(promptRegions, resinName); + if (success) + { + _logger.LogDebug("奖励页面已尝试使用树脂:{ResinName}", resinName); + } + + return success; } private async Task TrySwitch20To40Resin() @@ -1398,8 +1930,133 @@ public class AutoLeyLineOutcropTask : ISoloTask await Delay(800, _ct); using var check = CaptureToRectArea(); - var list = check.FindMulti(_ocrRoThis); - return list.Any(r => r.Text.Contains("40", StringComparison.Ordinal) && r.Text.Contains("原粹", StringComparison.Ordinal)); + var lineTexts = BuildPromptTextLines(check.FindMulti(_ocrRoThis)); + return lineTexts.Any(text => text.Contains("40", StringComparison.Ordinal) && text.Contains("原粹", StringComparison.Ordinal)); + } + + private static Rect GetRewardPromptTitleRoi(ImageRegion capture) + { + return new Rect(capture.Width / 4, capture.Height * 3 / 20, capture.Width / 2, capture.Height / 4); + } + + private static Rect GetRewardPromptContentRoi(ImageRegion capture) + { + return new Rect(capture.Width / 4, capture.Height / 5, capture.Width / 2, capture.Height * 3 / 5); + } + + private static List BuildPromptTextLines(IEnumerable regions) + { + return GroupPromptRegionsByLine(regions) + .Select(line => NormalizeLeyLineOcrText(string.Concat(line.OrderBy(r => r.X).Select(r => r.Text.Trim())))) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToList(); + } + + private bool HasRewardPrompt(ImageRegion capture) + { + var titleRoi = GetRewardPromptTitleRoi(capture); + if (CaptureRewardPromptTitleRegion(capture, titleRoi) != null) + { + return true; + } + + var promptRegions = capture.FindMulti(RecognitionObject.Ocr(GetRewardPromptContentRoi(capture))); + var lineTexts = BuildPromptTextLines(promptRegions); + return lineTexts.Any(ContainsRewardPromptContentText); + } + + private static string NormalizeLeyLineOcrText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + return text + .Replace("脈", "脉", StringComparison.Ordinal) + .Replace("觸", "触", StringComparison.Ordinal) + .Replace("樹", "树", StringComparison.Ordinal) + .Replace("選", "选", StringComparison.Ordinal) + .Replace("擇", "择", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Trim(); + } + + private static bool ContainsLeyLineFlowerText(string text) + { + text = NormalizeLeyLineOcrText(text); + return text.Contains("地脉之花", StringComparison.Ordinal) + || (text.Contains("地脉", StringComparison.Ordinal) && text.Contains("之花", StringComparison.Ordinal)); + } + + private static bool ContainsRewardPromptActionText(string text) + { + text = NormalizeLeyLineOcrText(text); + return text.Contains("使用", StringComparison.Ordinal); + } + + private static bool ContainsRewardPromptContentText(string text) + { + text = NormalizeLeyLineOcrText(text); + return text.Contains("原粹树脂", StringComparison.Ordinal) + || text.Contains("浓缩树脂", StringComparison.Ordinal) + || text.Contains("须臾树脂", StringComparison.Ordinal) + || text.Contains("脆弱树脂", StringComparison.Ordinal) + || text.Contains("激活地脉之花", StringComparison.Ordinal) + || text.Contains("选择激活方式", StringComparison.Ordinal) + || (text.Contains("树脂", StringComparison.Ordinal) && text.Contains("使用", StringComparison.Ordinal)) + || text.Contains("补充", StringComparison.Ordinal); + } + + private static List MergeTextRegionsByLine(Region owner, IEnumerable regions) + { + var merged = new List(); + foreach (var line in GroupPromptRegionsByLine(regions)) + { + var orderedLine = line.OrderBy(r => r.X).ToList(); + var left = orderedLine.Min(r => r.X); + var top = orderedLine.Min(r => r.Y); + var right = orderedLine.Max(r => r.Right); + var bottom = orderedLine.Max(r => r.Bottom); + var mergedRegion = owner.Derive(left, top, right - left, bottom - top); + mergedRegion.Text = string.Concat(orderedLine.Select(r => r.Text.Trim())); + merged.Add(mergedRegion); + } + + return merged.OrderBy(r => r.Y).ThenBy(r => r.X).ToList(); + } + + private static List> GroupPromptRegionsByLine(IEnumerable regions) + { + var ordered = regions + .Where(r => !string.IsNullOrWhiteSpace(r.Text)) + .OrderBy(r => r.Y) + .ThenBy(r => r.X) + .ToList(); + + var lines = new List>(); + foreach (var region in ordered) + { + var line = lines.FirstOrDefault(candidate => IsSamePromptLine(candidate[0], region)); + if (line == null) + { + lines.Add(new List { region }); + } + else + { + line.Add(region); + } + } + + return lines; + } + + private static bool IsSamePromptLine(Region first, Region second) + { + var firstCenter = first.Y + first.Height / 2.0; + var secondCenter = second.Y + second.Height / 2.0; + var tolerance = Math.Max(first.Height, second.Height) * 0.6; + return Math.Abs(firstCenter - secondCenter) <= tolerance; } private async Task EnsureExitRewardPage() @@ -1469,6 +2126,35 @@ public class AutoLeyLineOutcropTask : ISoloTask } private async Task FindLeyLineOutcropByBook(string country, string type) + { + await OpenLeyLineOutcropCountryInHandbook(country, type); + + for (var retry = 0; retry < 3; retry++) + { + if (await TryOpenBigMapFromHandbook()) + { + break; + } + + if (retry < 2) + { + _logger.LogDebug("通过冒险之证打开大地图失败,重新打开冒险之证,第{Retry}次", retry + 1); + await OpenLeyLineOutcropCountryInHandbook(country, type); + } + else + { + throw new Exception("大地图打开失败"); + } + } + + var center = _tpTask.GetBigMapCenterPoint(MapTypes.Teyvat.ToString()); + _leyLineX = center.X; + _leyLineY = center.Y; + + await CancelTrackingInMap(); + } + + private async Task OpenLeyLineOutcropCountryInHandbook(string country, string type) { await _returnMainUiTask.Start(_ct); await Delay(1000, _ct); @@ -1497,36 +2183,6 @@ public class AutoLeyLineOutcropTask : ISoloTask await Delay(1000, _ct); await FindAndClickCountry(country); - await FindAndCancelTrackingInBook(); - - for (var retry = 0; retry < 3; retry++) - { - await Delay(1000, _ct); - GameCaptureRegion.GameRegion1080PPosClick(1500, 850); - await Delay(2500, _ct); - - if (await CheckBigMapOpened()) - { - break; - } - - if (retry < 2) - { - await _returnMainUiTask.Start(_ct); - await FindAndClickCountry(country); - await FindAndCancelTrackingInBook(); - } - else - { - throw new Exception("大地图打开失败"); - } - } - - var center = _tpTask.GetBigMapCenterPoint(MapTypes.Teyvat.ToString()); - _leyLineX = center.X; - _leyLineY = center.Y; - - await CancelTrackingInMap(); } private async Task CheckBigMapOpened() @@ -1554,13 +2210,46 @@ public class AutoLeyLineOutcropTask : ISoloTask target.Click(); } - private async Task FindAndCancelTrackingInBook() + private async Task TryOpenBigMapFromHandbook() { + for (var attempt = 0; attempt < 2; attempt++) + { + var button = FindHandbookTrackActionButtonByIcon(); + if (button == null) + { + break; + } + + button.Click(); + _logger.LogDebug("通过图标识别点击冒险之证底部操作按钮,第{Attempt}次", attempt + 1); + await Delay(1500, _ct); + if (await CheckBigMapOpened()) + { + return true; + } + } + + GameCaptureRegion.GameRegion1080PPosClick(1500, 850); + _logger.LogDebug("图标未命中或点击后未打开大地图,回退固定坐标点击"); + await Delay(2500, _ct); + return await CheckBigMapOpened(); + } + + private Region? FindHandbookTrackActionButtonByIcon() + { + if (_handbookTrackActionRo == null) + { + return null; + } + using var capture = CaptureToRectArea(); - var list = capture.FindMulti(_ocrRoThis); - var stop = list.FirstOrDefault(r => r.Text.Contains("停止", StringComparison.Ordinal)); - stop?.Click(); - await Delay(1000, _ct); + var button = capture.Find(_handbookTrackActionRo); + if (!button.IsExist()) + { + return null; + } + + return button; } private async Task CancelTrackingInMap() @@ -1590,9 +2279,9 @@ public class AutoLeyLineOutcropTask : ISoloTask private async Task RecheckResinAndContinue() { _recheckCount++; - if (_config.OpenModeCountMin) + if (_taskParam.OpenModeCountMin) { - if (_currentRunTimes >= _config.Count) + if (_currentRunTimes >= _taskParam.Count) { return; } @@ -1615,7 +2304,7 @@ public class AutoLeyLineOutcropTask : ISoloTask } _currentRunTimes = 0; - _config.Count = result.Count; + _taskParam.Count = result.Count; await RunLeyLineChallenges(); await RecheckResinAndContinue(); } @@ -1632,8 +2321,8 @@ public class AutoLeyLineOutcropTask : ISoloTask } var condensedTimes = counts.CondensedResinCount; - var transientTimes = _config.UseTransientResin ? counts.TransientResinCount : 0; - var fragileTimes = _config.UseFragileResin ? counts.FragileResinCount : 0; + var transientTimes = _taskParam.UseTransientResin ? counts.TransientResinCount : 0; + var fragileTimes = _taskParam.UseFragileResin ? counts.FragileResinCount : 0; return new ResinCountResult { @@ -1659,7 +2348,7 @@ public class AutoLeyLineOutcropTask : ISoloTask CondensedResinCount = await CountCondensedResin() }; - if (_config.UseTransientResin || _config.UseFragileResin) + if (_taskParam.UseTransientResin || _taskParam.UseFragileResin) { await OpenReplenishResinUi(); await Delay(1500, _ct); @@ -1940,22 +2629,38 @@ public class AutoLeyLineOutcropTask : ISoloTask public int FragileResinTimes { get; set; } } - private readonly struct UseButton + private sealed class OcrOverlayScope(DrawContent drawContent, string key, Action refreshAction) : IDisposable { - public int X { get; } - public int Y { get; } - public int SortKey { get; } + private bool _disposed; - public UseButton(int x, int y, int sortKey) + public void Dispose() { - X = x; - Y = y; - SortKey = sortKey; - } + if (_disposed) + { + return; + } - public void Click() - { - GameCaptureRegion.GameRegion1080PPosClick(X, Y); + _disposed = true; + drawContent.RemoveRect(key); + drawContent.PutOrRemoveTextList(key, null); + refreshAction(); } } -} \ No newline at end of file + + private sealed class AutoFightConfigScope(AllConfig allConfig, AutoFightConfig originalConfig) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + allConfig.AutoFightConfig = originalConfig; + } + } + +} diff --git a/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs b/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs index af480387..b6ef4dbf 100644 --- a/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs +++ b/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs @@ -188,7 +188,19 @@ public class PathExecutor { if (CurWaypoints.Item1 > 0) { - await Delay(1000, ct); + var prevWaypoints = waypointsList[CurWaypoints.Item1 - 1]; + var prevWaypoint = prevWaypoints[prevWaypoints.Count - 1]; + if (prevWaypoint.Type == WaypointType.Teleport.Code + || prevWaypoint.Action == ActionEnum.Fight.Code + || prevWaypoint.Action == ActionEnum.NahidaCollect.Code + || prevWaypoint.Action == ActionEnum.PickAround.Code) + { + // No delay + } + else + { + await Delay(1000, ct); + } } await HandleTeleportWaypoint(waypoint); } @@ -318,6 +330,11 @@ public class PathExecutor await TpStatueOfTheSeven(); } + if (PartyConfig.SkipPartySwitch) + { + return true; + } + var pRaList = ra.FindMulti(AutoFightAssets.Instance.PRa); // 判断是否联机 if (pRaList.Count > 0) { @@ -448,9 +465,11 @@ public class PathExecutor // 没有强制配置的情况下,使用地图追踪内的条件配置 // 必须放在这里,因为要通过队伍识别来得到最终结果 var pathingConditionConfig = TaskContext.Instance().Config.PathingConditionConfig; + var skipPartySwitch = PartyConfig.SkipPartySwitch; if (PartyConfig is { Enabled: false }) { PartyConfig = pathingConditionConfig.BuildPartyConfigByCondition(_combatScenes); + PartyConfig.SkipPartySwitch = skipPartySwitch; } // 校验角色是否存在 @@ -1374,4 +1393,4 @@ public class PathExecutor throw new HandledException("达成结束条件,结束地图追踪"); } } -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/GameTask/AutoPick/AutoPickTrigger.cs b/BetterGenshinImpact/GameTask/AutoPick/AutoPickTrigger.cs index 82915c1f..1490ce30 100644 --- a/BetterGenshinImpact/GameTask/AutoPick/AutoPickTrigger.cs +++ b/BetterGenshinImpact/GameTask/AutoPick/AutoPickTrigger.cs @@ -25,7 +25,6 @@ namespace BetterGenshinImpact.GameTask.AutoPick; public partial class AutoPickTrigger : ITaskTrigger { private readonly ILogger _logger = App.GetLogger(); - private readonly ITextInference _pickTextInference = TextInferenceFactory.Pick; public string Name => "自动拾取"; public bool IsEnabled { get; set; } @@ -54,47 +53,6 @@ public partial class AutoPickTrigger : ITaskTrigger // 外部配置 private AutoPickExternalConfig? _externalConfig; - /// - /// 上一帧 OCR 结果 - /// - private string? _lastOcrText; - - /// - /// 连续相同 OCR 结果的帧数 - /// - private int _sameOcrFrameCount; - - /// - /// 判定为稳定所需的最小连续帧数 - /// - private const int OcrStableFrameThreshold = 2; - - /// - /// OCR 帧稳定确认 - /// - private bool IsOcrTextStable(string text) - { - if (text == _lastOcrText) - { - _sameOcrFrameCount++; - } - else - { - _lastOcrText = text; - _sameOcrFrameCount = 1; - } - - if (_sameOcrFrameCount >= OcrStableFrameThreshold) - { - // 重置 - _lastOcrText = null; - _sameOcrFrameCount = 0; - return true; - } - - return false; - } - public AutoPickTrigger() { _autoPickAssets = AutoPickAssets.Instance; @@ -317,7 +275,7 @@ public partial class AutoPickTrigger : ITaskTrigger if (config.OcrEngine == nameof(PickOcrEngineEnum.Yap)) { var textMat = new Mat(content.CaptureRectArea.CacheGreyMat, textRect); - text = _pickTextInference.Inference(textMat); + text = TextInferenceFactory.Pick.Value.Inference(textMat); } else { @@ -356,43 +314,44 @@ public partial class AutoPickTrigger : ITaskTrigger } speedTimer.Record("文字识别"); - if (!string.IsNullOrEmpty(text)) { // 处理OCR识别结果,清理无效字符并确保引号配对 text = ProcessOcrText(text); + if (DoNotPick(text)) { return; } + // 单个字符不拾取 if (text.Length <= 1) { return; } - // 连续多帧识别结果一致,进行下一步 - if (!IsOcrTextStable(text)) - { - return; - } + if (config.WhiteListEnabled && _whiteList.Contains(text)) { LogPick(content, text); Simulation.SendInput.Keyboard.KeyPress(AutoPickAssets.Instance.PickVk); return; } + speedTimer.Record("白名单判断"); + if (isExcludeIcon) { //Debug.WriteLine("AutoPickTrigger: 物品图标是聊天气泡,一般是NPC对话,不拾取"); return; } + if (config.BlackListEnabled) { if (_blackList.Contains(text)) { return; } + if (_fuzzyBlackList.Count > 0) { if (_fuzzyBlackList.Any(item => text.Contains(item))) @@ -401,7 +360,9 @@ public partial class AutoPickTrigger : ITaskTrigger } } } + speedTimer.Record("黑名单判断"); + LogPick(content, text); Simulation.SendInput.Keyboard.KeyPress(AutoPickAssets.Instance.PickVk); } @@ -444,6 +405,11 @@ public partial class AutoPickTrigger : ITaskTrigger { return true; } + + if (text.Contains("月谕圣牌")) + { + return true; + } return false; } diff --git a/BetterGenshinImpact/GameTask/AutoSkip/AutoSkipConfig.cs b/BetterGenshinImpact/GameTask/AutoSkip/AutoSkipConfig.cs index 7369b293..b1b689bf 100644 --- a/BetterGenshinImpact/GameTask/AutoSkip/AutoSkipConfig.cs +++ b/BetterGenshinImpact/GameTask/AutoSkip/AutoSkipConfig.cs @@ -124,6 +124,12 @@ public partial class AutoSkipConfig : ObservableObject /// [ObservableProperty] private bool _runBackgroundEnabled = false; + + /// + /// 后台剧情结束后切回游戏前台 + /// + [ObservableProperty] + private bool _bringGameToFrontAfterBackgroundDialogEnabled = false; /// /// 提交物品 @@ -170,4 +176,4 @@ public enum PictureSourceType { TriggerDispatcher, CaptureLoop -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/GameTask/AutoSkip/AutoSkipTrigger.cs b/BetterGenshinImpact/GameTask/AutoSkip/AutoSkipTrigger.cs index 951816f0..14f7c792 100644 --- a/BetterGenshinImpact/GameTask/AutoSkip/AutoSkipTrigger.cs +++ b/BetterGenshinImpact/GameTask/AutoSkip/AutoSkipTrigger.cs @@ -159,6 +159,8 @@ public partial class AutoSkipTrigger : ITaskTrigger private DateTime _prevGetDailyRewardsTime = DateTime.MinValue; private DateTime _prevClickTime = DateTime.MinValue; + private DateTime _prevBringToFrontTime = DateTime.MinValue; + private bool _pendingBringToFront; public void OnCapture(CaptureContent content) { @@ -177,6 +179,15 @@ public partial class AutoSkipTrigger : ITaskTrigger var isPlaying = content.CurrentGameUiCategory == GameUiCategory.Talk || Bv.IsInTalkUi(content.CaptureRectArea); // 播放中 + if (isPlaying && UseBackgroundOperation) + { + _pendingBringToFront = true; + } + else if (!isPlaying) + { + TryBringToFrontAfterBackgroundDialog(); + } + if (!isPlaying && (DateTime.Now - _prevPlayingTime).TotalSeconds <= 5) { // 关闭弹出页 @@ -253,6 +264,31 @@ public partial class AutoSkipTrigger : ITaskTrigger } } + private void TryBringToFrontAfterBackgroundDialog() + { + if (!_config.BringGameToFrontAfterBackgroundDialogEnabled || !_pendingBringToFront) + { + return; + } + + if (SystemControl.IsGenshinImpactActive()) + { + _pendingBringToFront = false; + return; + } + + if ((DateTime.Now - _prevPlayingTime).TotalMilliseconds <= 800 + || (DateTime.Now - _prevBringToFrontTime).TotalSeconds <= 2) + { + return; + } + + _prevBringToFrontTime = DateTime.Now; + SystemControl.ActivateWindow(); + _pendingBringToFront = false; + _logger.LogInformation("自动剧情:后台对话结束,已自动切回游戏前台"); + } + /// /// 黑屏点击判断 /// diff --git a/BetterGenshinImpact/GameTask/AutoStygianOnslaught/AutoStygianOnslaughtParam.cs b/BetterGenshinImpact/GameTask/AutoStygianOnslaught/AutoStygianOnslaughtParam.cs new file mode 100644 index 00000000..3c54d42e --- /dev/null +++ b/BetterGenshinImpact/GameTask/AutoStygianOnslaught/AutoStygianOnslaughtParam.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.GameTask.Model; + +namespace BetterGenshinImpact.GameTask.AutoStygianOnslaught; + +public class AutoStygianOnslaughtParam:BaseTaskParam +{ + + public int BossNum { get; set; } + // 结束后是否自动分解圣遗物 + public bool AutoArtifactSalvage { get; set; } + + // 指定树脂的使用次数 + public bool SpecifyResinUse{ get; set; } + + // 自定义使用树脂优先级 + public List ResinPriorityList{ get; set; }=["浓缩树脂","原粹树脂"]; + // 使用原粹树脂刷取副本次数 + public int OriginalResinUseCount { get; set; } + + //使用浓缩树脂刷取副本次数 + public int CondensedResinUseCount { get; set; } + + // 使用须臾树脂刷取副本次数 + public int TransientResinUseCount { get; set; } + + // 使用脆弱树脂刷取副本次数 + public int FragileResinUseCount { get; set; } + // 指定战斗队伍 + public string FightTeamName { get; set; } + // 战斗脚本包路径 + public string CombatScriptBagPath { get; set; } + public void SetDefault() + { + var config = TaskContext.Instance().Config.AutoStygianOnslaughtConfig; + SetAutoStygianOnslaughtConfig(config); + } + public void SetAutoStygianOnslaughtConfig(AutoStygianOnslaughtConfig config) + { + BossNum = config.BossNum; + AutoArtifactSalvage = config.AutoArtifactSalvage; + SpecifyResinUse = config.SpecifyResinUse; + ResinPriorityList = config.ResinPriorityList == null ? new List { "浓缩树脂", "原粹树脂" }: new List(config.ResinPriorityList); + OriginalResinUseCount = config.OriginalResinUseCount; + CondensedResinUseCount = config.CondensedResinUseCount; + TransientResinUseCount = config.TransientResinUseCount; + FragileResinUseCount = config.FragileResinUseCount; + FightTeamName = config.FightTeamName; + SetCombatStrategyPath(config.StrategyName); + } + public AutoStygianOnslaughtParam() : base(null, null) + { + SetDefault(); + } + public AutoStygianOnslaughtParam(string combatScriptBagPath) : base(null, null) + { + SetDefault(); + CombatScriptBagPath=combatScriptBagPath; + } + public void SetResinPriorityList(params string[] priorities) + { + ResinPriorityList.Clear(); + ResinPriorityList.AddRange(priorities); + } + + + /// + /// 设置战斗策略路径 + /// + /// 策略名称 + public void SetCombatStrategyPath(string? strategyName = null) + { + if (string.IsNullOrEmpty(strategyName)) + { + strategyName = TaskContext.Instance().Config.AutoFightConfig.StrategyName; + } + + if (string.IsNullOrWhiteSpace(strategyName) ||"根据队伍自动选择".Equals(strategyName)) + { + CombatScriptBagPath= Global.Absolute(@"User\AutoFight\"); + return; + } + + CombatScriptBagPath= Global.Absolute(@"User\AutoFight\" + strategyName + ".txt"); + } +} \ No newline at end of file diff --git a/BetterGenshinImpact/GameTask/AutoStygianOnslaught/AutoStygianOnslaughtTask.cs b/BetterGenshinImpact/GameTask/AutoStygianOnslaught/AutoStygianOnslaughtTask.cs index a3482931..757fb69a 100644 --- a/BetterGenshinImpact/GameTask/AutoStygianOnslaught/AutoStygianOnslaughtTask.cs +++ b/BetterGenshinImpact/GameTask/AutoStygianOnslaught/AutoStygianOnslaughtTask.cs @@ -79,12 +79,21 @@ public class AutoStygianOnslaughtTask : StateMachineBase, /// protected override ILogger Logger => TaskControl.Logger; - private readonly AutoStygianOnslaughtConfig _taskParam; + private readonly AutoStygianOnslaughtParam _taskParam; private readonly CombatScriptBag _combatScriptBag; private List _resinPriorityListWhenSpecifyUse; private LowerHeadThenWalkToTask? _lowerHeadThenWalkToTask; + public AutoStygianOnslaughtTask(AutoStygianOnslaughtParam taskParam) + { + AutoFightAssets.DestroyInstance(); + _taskParam = taskParam; + _combatScriptBag = CombatScriptParser.ReadAndParse(taskParam.CombatScriptBagPath); + _resinPriorityListWhenSpecifyUse = ResinUseRecord.BuildFromDomainParam(taskParam); - public AutoStygianOnslaughtTask(AutoStygianOnslaughtConfig taskParam, string path) + // 注册所有状态处理器 + RegisterAllStateHandlers(); + } + public AutoStygianOnslaughtTask(AutoStygianOnslaughtParam taskParam, string path) { AutoFightAssets.DestroyInstance(); _taskParam = taskParam; @@ -252,14 +261,14 @@ public class AutoStygianOnslaughtTask : StateMachineBase, { return ra.Find(ElementAssets.Instance.BtnWhiteCancel).IsExist() && ra.FindMulti(RecognitionObject.Ocr(ra.Width * 0.35, ra.Height * 0.7, ra.Width * 0.3, ra.Height * 0.2)) - .Any(o => o.Text.Contains("返回")); + .Any(o => o.Text.Contains("返回")); } private bool DetectBattleResultLose(ImageRegion ra) { return ra.Find(ElementAssets.Instance.BtnWhiteConfirm).IsExist() && ra.FindMulti(RecognitionObject.Ocr(ra.Width * 0.2, ra.Height * 0.3, ra.Width * 0.6, ra.Height * 0.3)) - .Any(o => o.Text.Contains("挑战失败") || o.Text.Contains("重新挑战")); + .Any(o => o.Text.Contains("挑战失败") || o.Text.Contains("重新挑战")); } // ========== 第三优先级:OCR 检测 ========== @@ -275,13 +284,6 @@ public class AutoStygianOnslaughtTask : StateMachineBase, { var ocrResult = ra.FindMulti(RecognitionObject.Ocr(ra.Width * 0.2, ra.Height * 0.2, ra.Width * 0.6, ra.Height * 0.6)); var found = ocrResult.Any(t => t.Text.Contains("地脉之花")); - - // 调试日志 - var texts = ocrResult.Any() - ? string.Join(", ", ocrResult.Select(o => $"'{o.Text}'")) - : "(无结果)"; - Logger.LogInformation($"DetectLeylineFlowerPrompt: OCR结果=[{texts}], 地脉之花={found}"); - return found; } @@ -293,29 +295,14 @@ public class AutoStygianOnslaughtTask : StateMachineBase, var hasPreview = ocrResult.Any(o => o.Text.Contains("角色预览")); var hasStart = ocrResult.Any(o => o.Text.Contains("开始挑战")); var found = hasPreview && hasStart; - - // 调试日志 - var texts = ocrResult.Any() - ? string.Join(", ", ocrResult.Select(o => $"'{o.Text}'")) - : "(无结果)"; - Logger.LogInformation($"DetectBossSelect: 右侧OCR结果=[{texts}], 角色预览={hasPreview}, 开始挑战={hasStart}"); - return found; } private bool DetectDifficultySelect(ImageRegion ra) { // "单人挑战" 在右下角 - var ocrResult = ra.FindMulti(RecognitionObject.Ocr(ra.Width * 0.5, ra.Height * 0.7, ra.Width * 0.5, ra.Height * 0.3)); - var found = ocrResult.Any(o => o.Text.Contains("单人挑战")); - - // 调试日志 - var texts = ocrResult.Any() - ? string.Join(", ", ocrResult.Select(o => $"'{o.Text}'")) - : "(无结果)"; - Logger.LogInformation($"DetectDifficultySelect: 右下角OCR结果=[{texts}], 包含单人挑战={found}"); - - return found; + return ra.FindMulti(RecognitionObject.Ocr(ra.Width * 0.5, ra.Height * 0.7, ra.Width * 0.5, ra.Height * 0.3)) + .Any(o => o.Text.Contains("单人挑战")); } private bool DetectDomainEntrance(ImageRegion ra) @@ -323,16 +310,8 @@ public class AutoStygianOnslaughtTask : StateMachineBase, // 秘境入口特征:屏幕右侧有"幽境危战"四个字 // 坐标:左上角(1223, 510), 右下角(1376, 566) // 宽度=153, 高度=56 - var ocrResult = ra.FindMulti(RecognitionObject.Ocr(1223, 510, 153, 56)); - var found = ocrResult.Any(o => o.Text.Contains("幽境危战")); - - // 始终输出日志,帮助调试 - var texts = ocrResult.Any() - ? string.Join(", ", ocrResult.Select(o => $"'{o.Text}'")) - : "(无结果)"; - Logger.LogInformation($"DetectDomainEntrance: 区域(1223,510,153,56) OCR结果=[{texts}], 包含幽境危战={found}"); - - return found; + return ra.FindMulti(RecognitionObject.Ocr(1223, 510, 153, 56)) + .Any(o => o.Text.Contains("幽境危战")); } private bool DetectEventMenu(ImageRegion ra) @@ -340,13 +319,13 @@ public class AutoStygianOnslaughtTask : StateMachineBase, // 活动一览位置:左上角(125, 142), 右下角(238, 170) // OCR 参数:(x, y, width, height) return ra.FindMulti(RecognitionObject.Ocr(125, 142, 238 - 125, 170 - 142)) - .Any(o => o.Text.Contains("活动一览")); + .Any(o => o.Text.Contains("活动一览")); } private bool DetectStygianOnslaughtPage(ImageRegion ra) { return ra.FindMulti(RecognitionObject.Ocr(ra.Width * 0.55, ra.Height * 0.3, ra.Width * 0.4, ra.Height * 0.6)) - .Any(o => o.Text.Contains("前往挑战")); + .Any(o => o.Text.Contains("前往挑战")); } #endregion @@ -825,7 +804,13 @@ public class AutoStygianOnslaughtTask : StateMachineBase, private async Task FindAndInteractLeylineFlowerLoop() { - await _lowerHeadThenWalkToTask!.Start(_ct); + // 先看看当前身边是否有F,有的话,直接F + using var ra1 = CaptureToRectArea(); + var text = Bv.FindFKeyText(ra1); + if (string.IsNullOrEmpty(text) || !text.Contains("激活")) + { + await _lowerHeadThenWalkToTask!.Start(_ct); + } await NewRetry.WaitForAction(() => { diff --git a/BetterGenshinImpact/GameTask/AutoTrackPath/Assets/tp.json b/BetterGenshinImpact/GameTask/AutoTrackPath/Assets/tp.json index 53e0b15e..25459c3a 100644 --- a/BetterGenshinImpact/GameTask/AutoTrackPath/Assets/tp.json +++ b/BetterGenshinImpact/GameTask/AutoTrackPath/Assets/tp.json @@ -1,6 +1,6 @@ { "language": "CHS", - "version": "a045b7e5f0685fe93d5d963ee55bb6cb78b81560", + "version": "56882a11588d5e79a1b6b51739f9fb27e9952fd7", "data": [ { "sceneId": 3, @@ -13904,6 +13904,26 @@ 9554.542 ] }, + { + "id": 1865, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山", + "疗养院旧址" + ], + "position": [ + 3877.027, + 438.402, + -531.106 + ], + "tranPosition": [ + 3877, + 437.802, + -527.9 + ] + }, { "id": 1866, "type": "TeleportWaypoint", @@ -13942,6 +13962,44 @@ 9707.631 ] }, + { + "id": 1884, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "明冠山地" + ], + "position": [ + 2877.141, + 266.934, + -318.6989 + ], + "tranPosition": [ + 2874.4412, + 267.68185, + -312.82062 + ] + }, + { + "id": 1885, + "type": "Goddess", + "name": "七天神像-风", + "country": "蒙德", + "areas": [ + "风息山" + ], + "position": [ + 3309.774, + 236.7597, + -77.536 + ], + "tranPosition": [ + 3310.141, + 237.4942, + -82.18768 + ] + }, { "id": 1919, "type": "TrounceDomain", @@ -13967,6 +14025,86 @@ "贤医的假面" ] }, + { + "id": 1922, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山", + "风车镇" + ], + "position": [ + 3085.12, + 223.57, + -153.5 + ], + "tranPosition": [ + 3092.045, + 223.5, + -156.0731 + ] + }, + { + "id": 1923, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山", + "荆夫港" + ], + "position": [ + 3505.681, + 229.5591, + -205.1554 + ], + "tranPosition": [ + 3498.978, + 229.65445, + -202.25732 + ] + }, + { + "id": 1924, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山", + "荆夫港" + ], + "position": [ + 3545.853, + 216.2449, + -293.6843 + ], + "tranPosition": [ + 3541.3232, + 216.2449, + -295.43365 + ] + }, + { + "id": 1925, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山", + "荆夫港" + ], + "position": [ + 3590.147, + 236.6072, + -251.4925 + ], + "tranPosition": [ + 3591.2407, + 236.71031, + -245.17517 + ] + }, { "id": 1953, "type": "TeleportWaypoint", @@ -14047,6 +14185,25 @@ 9920.595 ] }, + { + "id": 1959, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山" + ], + "position": [ + 4106.138, + 108.106, + -223.881 + ], + "tranPosition": [ + 4110.4688, + 107.4543, + -227.65422 + ] + }, { "id": 1960, "type": "TeleportWaypoint", @@ -14066,6 +14223,101 @@ 282.2899, 9669.12 ] + }, + { + "id": 2009, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山" + ], + "position": [ + 2881.072, + 229.189, + -743.8425 + ], + "tranPosition": [ + 2888.099, + 228.76279, + -748.8444 + ] + }, + { + "id": 2033, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山" + ], + "position": [ + 2990.598, + 226.2524, + -461.8188 + ], + "tranPosition": [ + 2989.9167, + 226.4, + -453.02206 + ] + }, + { + "id": 2034, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山" + ], + "position": [ + 3626.978, + 157.5714, + -320.7664 + ], + "tranPosition": [ + 3622.09, + 157.84663, + -317.44318 + ] + }, + { + "id": 2051, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山" + ], + "position": [ + 3603.124, + 244.0258, + -515.0546 + ], + "tranPosition": [ + 3600.2837, + 243.72385, + -509.51843 + ] + }, + { + "id": 2052, + "type": "TeleportWaypoint", + "name": "传送锚点", + "country": "蒙德", + "areas": [ + "风息山" + ], + "position": [ + 3791.413, + 348.205, + -209.356 + ], + "tranPosition": [ + 3788.4019, + 347.94803, + -217.91002 + ] } ] }, diff --git a/BetterGenshinImpact/GameTask/AutoTrackPath/TpTask.cs b/BetterGenshinImpact/GameTask/AutoTrackPath/TpTask.cs index 121c61cd..0cfd2ccf 100644 --- a/BetterGenshinImpact/GameTask/AutoTrackPath/TpTask.cs +++ b/BetterGenshinImpact/GameTask/AutoTrackPath/TpTask.cs @@ -1,4 +1,4 @@ -using BetterGenshinImpact.Core.Recognition; +using BetterGenshinImpact.Core.Recognition; using BetterGenshinImpact.Core.Recognition.OpenCv; using BetterGenshinImpact.Core.Script.Dependence; using BetterGenshinImpact.Core.Simulator; @@ -496,10 +496,54 @@ public class TpTask { mapCenterPoint = GetPositionFromBigMap(mapName); // 初始中心 } - catch (Exception e) + catch (MapPositionNotRecognizedException) { - ++exceptionTimes; - mapCenterPoint = new Point2f(0f, 0f); // 其他恰当的初始值? + Logger.LogDebug("初始中心点识别失败,开启自救策略"); + // 判断当前缩放是否离最佳识别缩放(4.4)较远,如果是,则先调整到最佳视角尝试 + if (_tpConfig.MapZoomEnabled && Math.Abs(currentZoomLevel - DisplayTpPointZoomLevel) > 0.3) + { + await AdjustMapZoomLevel(currentZoomLevel, DisplayTpPointZoomLevel); + currentZoomLevel = DisplayTpPointZoomLevel; + await Delay(300, ct); + + try + { + mapCenterPoint = GetPositionFromBigMap(mapName); + Logger.LogDebug("调整缩放后识别恢复成功"); + } + catch (MapPositionNotRecognizedException) + { + Logger.LogDebug("缩放后依然失败,尝试强制跃迁..."); + await ForceJumpToTargetArea(x, y, mapName); + await Delay(300, ct); + + try + { + mapCenterPoint = GetPositionFromBigMap(mapName); + Logger.LogDebug("强制切换区域后识别恢复成功"); + } + catch (MapPositionNotRecognizedException ex) + { + throw new Exception("所有脱困策略均失效,无法获取初始点", ex); + } + } + } + else + { + Logger.LogDebug("缩放已在最佳区间附近,直接尝试强制跃迁..."); + await ForceJumpToTargetArea(x, y, mapName); + await Delay(300, ct); + + try + { + mapCenterPoint = GetPositionFromBigMap(mapName); + Logger.LogDebug("强制切换区域后识别恢复成功"); + } + catch (MapPositionNotRecognizedException ex) + { + throw new Exception("初始识别失败且切换区域后依然无效", ex); + } + } } var (xOffset, yOffset) = (x - mapCenterPoint.X, y - mapCenterPoint.Y); @@ -556,21 +600,41 @@ public class TpTask int moveSteps = Math.Max((int)moveMouseLength / 10, 3); // 每次移动的步数最小为 3,避免除 0 错误 await MouseMoveMap(moveMouseX, moveMouseY, moveSteps); + + // 推算理论上的移动后坐标 (惯性预测) + Point2f predictedPoint = mapCenterPoint + new Point2f( + (float)(moveMouseX * currentZoomLevel / _tpConfig.MapScaleFactor), + (float)(moveMouseY * currentZoomLevel / _tpConfig.MapScaleFactor)); + try { - exceptionTimes = 0; - mapCenterPoint = GetPositionFromBigMap(mapName); // 随循环更新的地图中心 - } - catch (Exception) - { - if (++exceptionTimes > 2) + var newCenterPoint = GetPositionFromBigMap(mapName); // 随循环更新的地图中心 + + // 计算识别坐标与预测坐标的偏差 + double jumpDistance = Math.Sqrt(Math.Pow(newCenterPoint.X - predictedPoint.X, 2) + Math.Pow(newCenterPoint.Y - predictedPoint.Y, 2)); + double expectedMoveLen = Math.Sqrt(moveMouseX * moveMouseX + moveMouseY * moveMouseY) * currentZoomLevel / _tpConfig.MapScaleFactor; + + // 如果实际识别坐标产生超出物理可能的远距离跳跃 (比如原本只移动了50单位,但是坐标跳跃了300单位以上) + // 则判定为低特征区域产生的误识别(假阳性),抛出异常进入下面的盲走抓取逻辑 + if (jumpDistance > Math.Max(200, expectedMoveLen * 2)) { - throw new Exception("多次中心点识别失败,重新传送"); + Logger.LogDebug("坐标异常跳跃({dist:0.0}),判定为误识别", jumpDistance); + throw new MapPositionNotRecognizedException("中心点识别坐标异常跳跃"); } - Logger.LogWarning("中心点识别失败,预测移动的距离"); - mapCenterPoint += new Point2f((float)(moveMouseX * currentZoomLevel / _tpConfig.MapScaleFactor), - (float)(moveMouseY * currentZoomLevel / _tpConfig.MapScaleFactor)); + mapCenterPoint = newCenterPoint; + exceptionTimes = 0; + } + catch (MapPositionNotRecognizedException) + { + exceptionTimes++; + if (exceptionTimes > 5) + { + throw new Exception("多次中心点识别失败或异常,惯性推算失效,重新传送"); + } + + Logger.LogDebug("进入盲走推算 (跳过次数: {times})", exceptionTimes); + mapCenterPoint = predictedPoint; } (xOffset, yOffset) = (x - mapCenterPoint.X, y - mapCenterPoint.Y); @@ -743,7 +807,15 @@ public class TpTask using var mapScaleButtonRa = ra.Find(QuickTeleportAssets.Instance.MapScaleButtonRo); if (mapScaleButtonRa.IsExist()) { - rect = MapManager.GetMap(mapName, _mapMatchingMethod).GetBigMapRect(ra.CacheGreyMat); + try + { + rect = MapManager.GetMap(mapName, _mapMatchingMethod).GetBigMapRect(ra.CacheGreyMat); + } + catch (Exception) + { + rect = default; // 发生异常视为识别失败 + } + if (rect == default) { // 滚轮调整后再次识别 @@ -781,10 +853,19 @@ public class TpTask using var mapScaleButtonRa = ra.Find(QuickTeleportAssets.Instance.MapScaleButtonRo); if (mapScaleButtonRa.IsExist()) { - var p = MapManager.GetMap(mapName, _mapMatchingMethod).GetBigMapPosition(ra.CacheGreyMat); + Point2f p; + try + { + p = MapManager.GetMap(mapName, _mapMatchingMethod).GetBigMapPosition(ra.CacheGreyMat); + } + catch (Exception ex) + { + throw new MapPositionNotRecognizedException("大地图特征点匹配引发异常:" + ex.Message, ex); + } + if (p.IsEmpty()) { - throw new InvalidOperationException("识别大地图位置失败"); + throw new MapPositionNotRecognizedException("大地图特征点匹配识别位置失败"); } Debug.WriteLine("识别大地图在全地图位置:" + p); @@ -803,6 +884,36 @@ public class TpTask } } + /// + /// 当无法获取当前位置时,直接根据目标坐标强制计算并跃迁到对应区域的地图 + /// + private async Task ForceJumpToTargetArea(double x, double y, string mapName) + { + if (mapName == MapTypes.Teyvat.ToString()) + { + string targetCountry = "当前位置"; + double minDistance = double.MaxValue; + foreach (var (country, position) in MapLazyAssets.Instance.CountryPositions) + { + var distance = Math.Sqrt(Math.Pow(position[0] - x, 2) + Math.Pow(position[1] - y, 2)); + if (distance < minDistance) + { + minDistance = distance; + targetCountry = country; + } + } + + if (targetCountry != "当前位置") + { + await SwitchArea(targetCountry); + } + } + else + { + await SwitchArea(MapTypesExtensions.ParseFromName(mapName).GetDescription()); + } + } + /// /// 获取最接近的N个传送点坐标和所处区域 /// @@ -1040,3 +1151,9 @@ public class TpTask return (-5 * s) + 6; } } + +public class MapPositionNotRecognizedException : Exception +{ + public MapPositionNotRecognizedException(string message) : base(message) { } + public MapPositionNotRecognizedException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/BetterGenshinImpact/GameTask/ChatUiHotkeyGuard.cs b/BetterGenshinImpact/GameTask/ChatUiHotkeyGuard.cs new file mode 100644 index 00000000..382ceb57 --- /dev/null +++ b/BetterGenshinImpact/GameTask/ChatUiHotkeyGuard.cs @@ -0,0 +1,120 @@ +using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.GameTask.Common.BgiVision; +using System; +using System.Windows.Forms; + +namespace BetterGenshinImpact.GameTask; + +public static class ChatUiHotkeyGuard +{ + private const int StableFrameThreshold = 2; + private static readonly TimeSpan ChatKeyPrimeDuration = TimeSpan.FromMilliseconds(280); + private static readonly object Locker = new(); + + private static ChatUiState _chatUiState = ChatUiState.Closed; + private static int _enterFrameCount; + private static int _exitFrameCount; + private static DateTime _chatKeyPrimeUntilUtc = DateTime.MinValue; + + public static void UpdateVisualState(ChatUiDetectionResult detectionResult) + { + var visualState = detectionResult.State; + + lock (Locker) + { + if (visualState == ChatUiState.Closed) + { + _enterFrameCount = 0; + if (_chatUiState == ChatUiState.Closed) + { + _exitFrameCount = 0; + } + else if (++_exitFrameCount >= StableFrameThreshold) + { + SetState(ChatUiState.Closed); + _exitFrameCount = 0; + } + } + else + { + _exitFrameCount = 0; + _chatKeyPrimeUntilUtc = DateTime.MinValue; + if (_chatUiState == ChatUiState.Closed) + { + if (++_enterFrameCount >= StableFrameThreshold) + { + SetState(visualState); + _enterFrameCount = 0; + } + } + else + { + _enterFrameCount = 0; + SetState(visualState); + } + } + + if (_chatKeyPrimeUntilUtc <= DateTime.UtcNow) + { + _chatKeyPrimeUntilUtc = DateTime.MinValue; + } + } + } + + public static void PrimeFromChatKey(Keys keyCode) + { + if (keyCode != TaskContext.Instance().Config.KeyBindingsConfig.OpenChatScreen.ToWinFormKeys()) + { + return; + } + + lock (Locker) + { + if (_chatUiState != ChatUiState.Closed) + { + return; + } + + _chatKeyPrimeUntilUtc = DateTime.UtcNow + ChatKeyPrimeDuration; + } + } + + public static bool ShouldBlockHotkey(string? configPropertyName) + { + if (string.Equals(configPropertyName, nameof(HotKeyConfig.BgiEnabledHotkey), StringComparison.Ordinal)) + { + return false; + } + + lock (Locker) + { + if (_chatUiState != ChatUiState.Closed) + { + return true; + } + + return _chatKeyPrimeUntilUtc > DateTime.UtcNow; + } + } + + public static void Reset() + { + lock (Locker) + { + _chatUiState = ChatUiState.Closed; + _enterFrameCount = 0; + _exitFrameCount = 0; + _chatKeyPrimeUntilUtc = DateTime.MinValue; + } + } + + private static void SetState(ChatUiState nextState) + { + if (_chatUiState == nextState) + { + return; + } + + _chatUiState = nextState; + } +} diff --git a/BetterGenshinImpact/GameTask/Common/BgiVision/BvChatUi.cs b/BetterGenshinImpact/GameTask/Common/BgiVision/BvChatUi.cs new file mode 100644 index 00000000..08178352 --- /dev/null +++ b/BetterGenshinImpact/GameTask/Common/BgiVision/BvChatUi.cs @@ -0,0 +1,237 @@ +using BetterGenshinImpact.Core.Recognition.OpenCv; +using BetterGenshinImpact.GameTask.Common.Element.Assets; +using BetterGenshinImpact.GameTask.Model.Area; +using OpenCvSharp; +using System; +using System.Collections.Generic; + +namespace BetterGenshinImpact.GameTask.Common.BgiVision; + +public enum ChatUiState +{ + Closed, + PanelOpen, + InputOpen +} + +public readonly record struct ChatUiDetectionResult( + ChatUiState State, + bool HasBackButton, + bool HasMoreButton, + bool HasAddConversationButton, + int BottomCircleCount, + bool HasSendButton) +{ + public bool HasInputControls => BottomCircleCount >= 2 || HasSendButton; + + public string ToDebugSummary() + { + return $"back={HasBackButton}, more={HasMoreButton}, add={HasAddConversationButton}, circles={BottomCircleCount}, send={HasSendButton}"; + } +} + +public static partial class Bv +{ + public static ChatUiDetectionResult DetectChatUi(ImageRegion region) + { + using var backButton = region.Find(ElementAssets.Instance.ChatBackButtonRo); + var hasBackButton = backButton.IsExist(); + var hasMoreButton = HasChatMoreButton(region); + var hasAddConversationButton = HasChatAddConversationButton(region); + var bottomCircleCount = CountChatBottomCircleButtons(region); + var hasSendButton = HasChatSendButton(region); + var hasInputControls = bottomCircleCount >= 2 || hasSendButton; + + if (!hasBackButton || !hasAddConversationButton) + { + return new ChatUiDetectionResult( + ChatUiState.Closed, + hasBackButton, + hasMoreButton, + hasAddConversationButton, + bottomCircleCount, + hasSendButton); + } + + if (hasInputControls) + { + return new ChatUiDetectionResult( + ChatUiState.InputOpen, + hasBackButton, + hasMoreButton, + hasAddConversationButton, + bottomCircleCount, + hasSendButton); + } + + var state = hasMoreButton ? ChatUiState.PanelOpen : ChatUiState.Closed; + return new ChatUiDetectionResult( + state, + hasBackButton, + hasMoreButton, + hasAddConversationButton, + bottomCircleCount, + hasSendButton); + } + + public static ChatUiState DetectChatUiState(ImageRegion region) + { + return DetectChatUi(region).State; + } + + private static bool HasChatMoreButton(ImageRegion region) + { + var scale = GetChatUiScale(region); + using var roi = region.DeriveCrop(region.Width - (int)Math.Round(280 * scale), 0, (int)Math.Round(250 * scale), (int)Math.Round(140 * scale)); + return HasEllipsisDots(roi.SrcMat, scale, detectDarkDots: true) || HasEllipsisDots(roi.SrcMat, scale, detectDarkDots: false); + } + + private static bool HasChatAddConversationButton(ImageRegion region) + { + var scale = GetChatUiScale(region); + using var roi = region.DeriveCrop(0, region.Height - (int)Math.Round(260 * scale), (int)Math.Round(320 * scale), (int)Math.Round(260 * scale)); + return HasBrightRoundedButton(roi.SrcMat, scale, minWidth: 28, maxWidth: 92, minHeight: 28, maxHeight: 92, minAspect: 0.72, maxAspect: 1.28); + } + + private static int CountChatBottomCircleButtons(ImageRegion region) + { + var scale = GetChatUiScale(region); + using var roi = region.DeriveCrop((int)Math.Round(620 * scale), region.Height - (int)Math.Round(220 * scale), (int)Math.Round(760 * scale), (int)Math.Round(180 * scale)); + return CountBrightRoundedButtons(roi.SrcMat, scale, minWidth: 26, maxWidth: 92, minHeight: 26, maxHeight: 92, minAspect: 0.72, maxAspect: 1.28); + } + + private static bool HasChatSendButton(ImageRegion region) + { + var scale = GetChatUiScale(region); + using var roi = region.DeriveCrop((int)Math.Round(820 * scale), region.Height - (int)Math.Round(220 * scale), (int)Math.Round(500 * scale), (int)Math.Round(180 * scale)); + return HasBrightRoundedButton(roi.SrcMat, scale, minWidth: 90, maxWidth: 260, minHeight: 26, maxHeight: 92, minAspect: 1.45, maxAspect: 5.5); + } + + private static bool HasBrightRoundedButton(Mat src, double scale, int minWidth, int maxWidth, int minHeight, int maxHeight, double minAspect, double maxAspect) + { + return CountBrightRoundedButtons(src, scale, minWidth, maxWidth, minHeight, maxHeight, minAspect, maxAspect) > 0; + } + + private static int CountBrightRoundedButtons(Mat src, double scale, int minWidth, int maxWidth, int minHeight, int maxHeight, double minAspect, double maxAspect) + { + using var mask = OpenCvCommonHelper.Threshold(src, new Scalar(180, 165, 135), new Scalar(255, 255, 255)); + using var kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(ToKernelSize(7 * scale), ToKernelSize(7 * scale))); + Cv2.MorphologyEx(mask, mask, MorphTypes.Close, kernel); + Cv2.FindContours(mask, out var contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple); + + var scaledMinWidth = Math.Max(8, (int)Math.Round(minWidth * scale)); + var scaledMaxWidth = Math.Max(scaledMinWidth + 1, (int)Math.Round(maxWidth * scale)); + var scaledMinHeight = Math.Max(8, (int)Math.Round(minHeight * scale)); + var scaledMaxHeight = Math.Max(scaledMinHeight + 1, (int)Math.Round(maxHeight * scale)); + var minArea = scaledMinWidth * scaledMinHeight * 0.35; + var matches = 0; + + foreach (var contour in contours) + { + var rect = Cv2.BoundingRect(contour); + if (rect.Width < scaledMinWidth || rect.Height < scaledMinHeight || rect.Width > scaledMaxWidth || rect.Height > scaledMaxHeight) + { + continue; + } + + var aspect = rect.Width / (double)Math.Max(rect.Height, 1); + if (aspect < minAspect || aspect > maxAspect) + { + continue; + } + + var contourArea = Cv2.ContourArea(contour); + if (contourArea < minArea) + { + continue; + } + + var fillRatio = contourArea / Math.Max(1d, rect.Width * rect.Height); + if (fillRatio < 0.48) + { + continue; + } + + matches++; + } + + return matches; + } + + private static bool HasEllipsisDots(Mat src, double scale, bool detectDarkDots) + { + using var gray = src.CvtColor(ColorConversionCodes.BGR2GRAY); + using var mask = new Mat(); + Cv2.Threshold(gray, mask, detectDarkDots ? 115 : 210, 255, detectDarkDots ? ThresholdTypes.BinaryInv : ThresholdTypes.Binary); + + using var kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(ToKernelSize(3 * scale), ToKernelSize(3 * scale))); + Cv2.MorphologyEx(mask, mask, MorphTypes.Open, kernel); + Cv2.FindContours(mask, out var contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple); + + var minDot = Math.Max(3, (int)Math.Round(4 * scale)); + var maxDot = Math.Max(minDot + 1, (int)Math.Round(22 * scale)); + var maxYOffset = Math.Max(4, (int)Math.Round(10 * scale)); + var minGap = Math.Max(2, (int)Math.Round(3 * scale)); + var maxGap = Math.Max(minGap + 1, (int)Math.Round(36 * scale)); + + var dots = new List<(Rect Rect, Point Center)>(); + foreach (var contour in contours) + { + var rect = Cv2.BoundingRect(contour); + if (rect.Width < minDot || rect.Height < minDot || rect.Width > maxDot || rect.Height > maxDot) + { + continue; + } + + var aspect = rect.Width / (double)Math.Max(rect.Height, 1); + if (aspect < 0.55 || aspect > 1.8) + { + continue; + } + + dots.Add((rect, new Point(rect.X + rect.Width / 2, rect.Y + rect.Height / 2))); + } + + if (dots.Count < 3) + { + return false; + } + + dots.Sort((a, b) => a.Center.X.CompareTo(b.Center.X)); + for (var i = 0; i <= dots.Count - 3; i++) + { + var first = dots[i]; + var second = dots[i + 1]; + var third = dots[i + 2]; + + if (Math.Abs(first.Center.Y - second.Center.Y) > maxYOffset || + Math.Abs(second.Center.Y - third.Center.Y) > maxYOffset || + Math.Abs(first.Center.Y - third.Center.Y) > maxYOffset) + { + continue; + } + + var gap1 = second.Center.X - first.Center.X; + var gap2 = third.Center.X - second.Center.X; + if (gap1 < minGap || gap2 < minGap || gap1 > maxGap || gap2 > maxGap) + { + continue; + } + + return true; + } + + return false; + } + + private static double GetChatUiScale(ImageRegion region) + { + return region.Width / 1920d; + } + + private static int ToKernelSize(double size) + { + var rounded = Math.Max(1, (int)Math.Round(size)); + return rounded % 2 == 0 ? rounded + 1 : rounded; + } +} diff --git a/BetterGenshinImpact/GameTask/Common/Element/Assets/1920x1080/btn_white_recover.png b/BetterGenshinImpact/GameTask/Common/Element/Assets/1920x1080/btn_white_recover.png new file mode 100644 index 00000000..036f4d6d Binary files /dev/null and b/BetterGenshinImpact/GameTask/Common/Element/Assets/1920x1080/btn_white_recover.png differ diff --git a/BetterGenshinImpact/GameTask/Common/Element/Assets/ElementAssets.cs b/BetterGenshinImpact/GameTask/Common/Element/Assets/ElementAssets.cs index 76f2e3fe..217304b5 100644 --- a/BetterGenshinImpact/GameTask/Common/Element/Assets/ElementAssets.cs +++ b/BetterGenshinImpact/GameTask/Common/Element/Assets/ElementAssets.cs @@ -13,6 +13,7 @@ public class ElementAssets : BaseAssets public RecognitionObject BtnWhiteConfirm; public RecognitionObject BtnWhiteCancel; + public RecognitionObject BtnWhiteRecover; public RecognitionObject BtnBlackConfirm; public RecognitionObject BtnBlackCancel; public RecognitionObject BtnBackTeyvat; @@ -31,6 +32,7 @@ public class ElementAssets : BaseAssets public RecognitionObject XKey; public RecognitionObject FriendChat; + public RecognitionObject ChatBackButtonRo; public RecognitionObject PartyBtnChooseView; public RecognitionObject PartyBtnDelete; @@ -140,6 +142,15 @@ public class ElementAssets : BaseAssets TemplateImageMat = GameTaskManager.LoadAssetImage(@"Common\Element", "btn_white_cancel.png", systemInfo), Use3Channels = true }.InitTemplate(); + BtnWhiteRecover = new RecognitionObject + { + Name = "BtnWhiteRecover", + RecognitionType = RecognitionTypes.TemplateMatch, + TemplateImageMat = GameTaskManager.LoadAssetImage(@"Common\Element", "btn_white_recover.png", systemInfo), + Use3Channels = true, + RegionOfInterest = new Rect((int)(580 * AssetScale), (int)(950 * AssetScale), (int)(90 * AssetScale), (int)(95 * AssetScale)), + // Threshold = 0.95 + }.InitTemplate(); BtnBlackConfirm = new RecognitionObject { Name = "BtnBlackConfirm", @@ -264,7 +275,16 @@ public class ElementAssets : BaseAssets DrawOnWindow = false }.InitTemplate(); - // 队伍切换 + // 聊天 UI 识别 + ChatBackButtonRo = new RecognitionObject + { + Name = "ChatBackButton", + RecognitionType = RecognitionTypes.TemplateMatch, + TemplateImageMat = GameTaskManager.LoadAssetImage(@"UseRedeemCode", "esc_return_button.png", systemInfo), + RegionOfInterest = new Rect(0, 0, (int)(220 * AssetScale), (int)(160 * AssetScale)), + Threshold = 0.72, + DrawOnWindow = false + }.InitTemplate(); PartyBtnChooseView = new RecognitionObject { Name = "PartyBtnChooseView", @@ -773,4 +793,4 @@ public class ElementAssets : BaseAssets // DrawOnWindow = true }.InitTemplate(); } -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/GameTask/Common/Element/Assets/Json/冒险家协会_蒙德.json b/BetterGenshinImpact/GameTask/Common/Element/Assets/Json/冒险家协会_蒙德.json index 97759bfb..fbeae4ca 100644 --- a/BetterGenshinImpact/GameTask/Common/Element/Assets/Json/冒险家协会_蒙德.json +++ b/BetterGenshinImpact/GameTask/Common/Element/Assets/Json/冒险家协会_蒙德.json @@ -34,11 +34,11 @@ }, { "id": 4, - "x": -913.5625, - "y": 2233.5625, + "x": -913.51, + "y": 2232.67, "action": "", "move_mode": "walk", - "type": "path" + "type": "target" } ] -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/GameTask/Common/Element/Assets/MapAssets.cs b/BetterGenshinImpact/GameTask/Common/Element/Assets/MapAssets.cs index 3c34b36c..1e6d768a 100644 --- a/BetterGenshinImpact/GameTask/Common/Element/Assets/MapAssets.cs +++ b/BetterGenshinImpact/GameTask/Common/Element/Assets/MapAssets.cs @@ -14,6 +14,9 @@ namespace BetterGenshinImpact.GameTask.Common.Element.Assets; public class MapAssets : BaseAssets { public Rect MimiMapRect { get; } + + public static Rect MimiMapRect1080P = new Rect(62, 19,212,212); + public MapAssets() { diff --git a/BetterGenshinImpact/GameTask/Common/Job/CheckRewardsTask.cs b/BetterGenshinImpact/GameTask/Common/Job/CheckRewardsTask.cs index f7d218bd..6b5b91f9 100644 --- a/BetterGenshinImpact/GameTask/Common/Job/CheckRewardsTask.cs +++ b/BetterGenshinImpact/GameTask/Common/Job/CheckRewardsTask.cs @@ -78,6 +78,8 @@ public class CheckRewardsTask Logger.LogWarning("检查每日奖励结果:{Msg},请手动检查!", "未领取"); Notify.Event(NotificationEvent.DailyReward).Error("检查到每日奖励未领取,请手动查看!"); } + await Delay(200, ct); + await new ReturnMainUiTask().Start(ct); } catch (Exception e) { diff --git a/BetterGenshinImpact/GameTask/Common/Job/ClaimBattlePassRewardsTask.cs b/BetterGenshinImpact/GameTask/Common/Job/ClaimBattlePassRewardsTask.cs index 75413de7..e33e4c0c 100644 --- a/BetterGenshinImpact/GameTask/Common/Job/ClaimBattlePassRewardsTask.cs +++ b/BetterGenshinImpact/GameTask/Common/Job/ClaimBattlePassRewardsTask.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; @@ -7,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.Core.Recognition; using BetterGenshinImpact.Core.Simulator.Extensions; +using BetterGenshinImpact.GameTask.Common.BgiVision; using BetterGenshinImpact.GameTask.Common.Element.Assets; using BetterGenshinImpact.GameTask.Model.Area; using BetterGenshinImpact.Helpers; @@ -27,6 +27,8 @@ public class ClaimBattlePassRewardsTask private readonly string[] claimAllLocalizedStrings; + private bool _manualSelectionReminderLogged; + public ClaimBattlePassRewardsTask() { IStringLocalizer stringLocalizer = App.GetService>() ?? throw new NullReferenceException(); @@ -54,18 +56,13 @@ public class ClaimBattlePassRewardsTask await Delay(200, ct); TaskContext.Instance().PostMessageSimulator.SimulateAction(GIActions.OpenBattlePassScreen); // F4 开纪行 - // 领取战令1 - await Delay(1000, ct); - await ClaimAll(ct); - - - // 领取点数 + // 先领取纪行点数,避免一进入纪行就因可选奖励弹窗阻塞后续流程 await Delay(1000, ct); GameCaptureRegion.GameRegion1080PPosClick(960, 45); // 点中间 await Delay(500, ct); await ClaimAll(ct); - // 领取战令2 + // 最后再回到奖励页领取,若存在手动选择奖励则仅记录日志提醒 await Delay(2500, ct); // 等待升级动画 // 还可能存在领取到原石的情况 if (CaptureToRectArea().Find(ElementAssets.Instance.PrimogemRo).IsExist()) @@ -89,13 +86,18 @@ public class ClaimBattlePassRewardsTask using var ra = CaptureToRectArea(); var ocrList = ra.FindMulti(RecognitionObject.Ocr(ra.ToRect().CutRightBottom(0.3, 0.2))); var wt = ocrList.FirstOrDefault(txt => this.claimAllLocalizedStrings.Any(i => Regex.IsMatch(txt.Text, i))); - Debug.WriteLine(this.claimAllLocalizedStrings); if (wt != null) { wt.Click(); Logger.LogInformation("纪行:{Text}", "一键领取"); await Delay(1000, ct); using var ra2 = CaptureToRectArea(); + if (IsManualSelectionDialog(ra2)) + { + LogManualSelectionReminder(); + return true; + } + if (ra2.Find(ElementAssets.Instance.PrimogemRo).IsExist()) { TaskContext.Instance().PostMessageSimulator.KeyPress(User32.VK.VK_ESCAPE); @@ -109,4 +111,39 @@ public class ClaimBattlePassRewardsTask return false; } } -} \ No newline at end of file + + private static bool IsManualSelectionDialog(ImageRegion region) + { + if (Bv.IsInPromptDialog(region)) + { + return true; + } + + var hasCancelButton = HasDialogButton(region, ElementAssets.Instance.BtnBlackCancel) + || HasDialogButton(region, ElementAssets.Instance.BtnWhiteCancel); + if (!hasCancelButton) + { + return false; + } + + return HasDialogButton(region, ElementAssets.Instance.BtnBlackConfirm) + || HasDialogButton(region, ElementAssets.Instance.BtnWhiteConfirm); + } + + private static bool HasDialogButton(ImageRegion region, RecognitionObject recognitionObject) + { + using var buttonRegion = region.Find(recognitionObject); + return buttonRegion.IsExist(); + } + + private void LogManualSelectionReminder() + { + if (_manualSelectionReminderLogged) + { + return; + } + + _manualSelectionReminderLogged = true; + Logger.LogWarning("纪行:检测到需手动选择的奖励,请手动处理"); + } +} diff --git a/BetterGenshinImpact/GameTask/Common/Job/GoToSereniteaPotTask.cs b/BetterGenshinImpact/GameTask/Common/Job/GoToSereniteaPotTask.cs index 6299a9f0..228d4e4f 100644 --- a/BetterGenshinImpact/GameTask/Common/Job/GoToSereniteaPotTask.cs +++ b/BetterGenshinImpact/GameTask/Common/Job/GoToSereniteaPotTask.cs @@ -591,7 +591,7 @@ internal class GoToSereniteaPotTask await Delay(1000, ct); } - var quitOption = await _chooseTalkOptionTask.SingleSelectText(this.ayuanByeString, ct); + var quitOption = await _chooseTalkOptionTask.SingleSelectText(this.ayuanByeString, ct, skipTimes: 20); if (quitOption != TalkOptionRes.FoundAndClick) { if (!Bv.IsInMainUi(CaptureToRectArea())) diff --git a/BetterGenshinImpact/GameTask/Common/Job/SwitchPartyTask.cs b/BetterGenshinImpact/GameTask/Common/Job/SwitchPartyTask.cs index e7b0d730..8d1d9cd9 100644 --- a/BetterGenshinImpact/GameTask/Common/Job/SwitchPartyTask.cs +++ b/BetterGenshinImpact/GameTask/Common/Job/SwitchPartyTask.cs @@ -1,5 +1,4 @@ using BetterGenshinImpact.Core.Recognition; -using BetterGenshinImpact.Core.Recognition.OCR; using BetterGenshinImpact.Core.Simulator; using BetterGenshinImpact.Core.Simulator.Extensions; using BetterGenshinImpact.GameTask.Common.BgiVision; @@ -10,7 +9,6 @@ using BetterGenshinImpact.View.Drawable; using Microsoft.Extensions.Logging; using OpenCvSharp; using System; -using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading; @@ -30,17 +28,56 @@ public class SwitchPartyTask public async Task Start(string partyName, CancellationToken ct) { - var useOcrMatch = TaskContext.Instance().Config.OtherConfig.OcrConfig.UseOcrMatchForPartySwitch; + bool isInPartyViewUi = false; Logger.LogInformation("尝试切换至队伍: {Name}", partyName); using var ra1 = CaptureToRectArea(); - // 确保进入队伍配置界面 - bool isInPartyViewUi = false; if (!Bv.IsInPartyViewUi(ra1)) { isInPartyViewUi = true; - await EnsurePartyViewOpen(ra1, ct); + // 如果不在主界面,则返回主界面 + if (!Bv.IsInMainUi(ra1)) + { + await _returnMainUiTask.Start(ct); + await Delay(200, ct); + using var raAfterMain = CaptureToRectArea(); + if (!Bv.IsInMainUi(raAfterMain)) + { + throw new InvalidOperationException("未能返回主界面"); + } + } + + // 尝试打开队伍配置页面 + const int maxAttempts = 2; + bool isOpened = false; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + Simulation.SendInput.SimulateAction(GIActions.OpenPartySetupScreen); + + // 考虑加载时间 2s,共检查 4.2s,如果失败则抛出异常 + + for (int i = 0; i < 7; i++) // 检查 7 次 + { + await Delay(600, ct); + using var raCheck = CaptureToRectArea(); + if (Bv.IsInPartyViewUi(raCheck)) + { + isOpened = true; + break; + } + } + + if (isOpened) + { + break; // 页面已打开,跳出循环 + } + } + + if (!isOpened) + { + throw new PartySetupFailedException("未能打开队伍配置界面"); + } } await Delay(500, ct); @@ -48,15 +85,33 @@ public class SwitchPartyTask using var ra = CaptureToRectArea(); var partyViewBtn = ra.Find(ElementAssets.Instance.PartyBtnChooseView); - if (!partyViewBtn.IsExist()) + // OCR 当前队伍名称(无法单字,中间禁止空格) + var currTeamName = ra.Find(new RecognitionObject { - Logger.LogWarning("未找到队伍选择按钮,无法判断当前队伍"); - throw new PartySetupFailedException("未找到队伍选择按钮"); + RecognitionType = RecognitionTypes.Ocr, + RegionOfInterest = new Rect(partyViewBtn.Right, partyViewBtn.Top, (int)(350 * _assetScale), + partyViewBtn.Height) + }).Text; + + var tempName = currTeamName + .Replace("\"", "") // 移除所有双引号(核心新增,解决日志里的""问题) + .Replace("\r\n", "") // 清理Windows换行符 + .Replace("\r", ""); // 先清理所有双引号,避免引号干扰后续处理 + + // 核心逻辑:找到第一个换行符(\n)的位置,截断并删除换行+后面所有字符 + int firstNewLineIndex = tempName.IndexOf('\n'); + if (firstNewLineIndex != -1) // 存在换行符,截取到换行符前 + { + tempName = tempName.Substring(0, firstNewLineIndex); } + + // 最后统一去首尾所有空白(空格、制表符、回车符\r等),得到纯净队伍名 + currTeamName = tempName.Trim(); - // 检查当前队伍是否已是目标 - if (IsCurrentTeamMatch(ra, partyViewBtn, partyName, useOcrMatch)) + Logger.LogInformation("切换队伍,当前队伍名称: {Text},使用正则表达式规则进行模糊匹配", currTeamName); + if (Regex.IsMatch(currTeamName, partyName)) { + Logger.LogInformation("当前队伍[{Name}]即为目标队伍,无需切换", currTeamName); if (isInPartyViewUi) { Simulation.SendInput.Keyboard.KeyPress(User32.VK.VK_ESCAPE); @@ -67,76 +122,101 @@ public class SwitchPartyTask return true; } - // 打开队伍选择页面 - var partyDeleteBtn = await OpenPartyChoosePage(partyViewBtn, ct); - await ScrollToTop(ct); + var menu = await NewRetry.WaitForElementAppear( + ElementAssets.Instance.PartyBtnDelete, + () => partyViewBtn.Click(),// 点击队伍选择按钮 + ct, + 4, + 500 + ); + if (!menu) + { + throw new PartySetupFailedException("未能打开队伍选择页面"); + } - // 逐页查找目标队伍 - Rect regionOfInterest = new(0, (int)(80 * _assetScale), partyDeleteBtn.Right, partyDeleteBtn.Top - (int)(80 * _assetScale)); - var recognitionObject = new RecognitionObject + ImageRegion? switchRa = null; + Region? partyDeleteBtn = null; + using (var ocrRa = CaptureToRectArea()) + { + var openPartyChooseSuccess = await NewRetry.WaitForAction(() => + { + switchRa = ocrRa; + partyDeleteBtn = switchRa.Find(ElementAssets.Instance.PartyBtnDelete); + return partyDeleteBtn.IsExist(); + }, ct, 5); + + if (!openPartyChooseSuccess || switchRa == null || partyDeleteBtn == null) + { + throw new PartySetupFailedException("未能打开队伍配置界面"); + } + } + + // 点击到最上方 + await Task.Delay(50, ct); + GameCaptureRegion.GameRegion1080PPosClick(700, 125); + await Task.Delay(50, ct); + Simulation.SendInput.Mouse.LeftButtonDown(); + await Task.Delay(450, ct); + Simulation.SendInput.Mouse.LeftButtonUp(); + await Task.Delay(100, ct); + + Rect regionOfInterest = new Rect(0, (int)(80 * _assetScale), partyDeleteBtn.Right, partyDeleteBtn.Top - (int)(80 * _assetScale)); + RecognitionObject recognitionObject = new RecognitionObject { RecognitionType = RecognitionTypes.Ocr, RegionOfInterest = regionOfInterest, DrawOnWindow = true, Name = "队伍名称", - DrawOnWindowPen = System.Drawing.Pens.White + DrawOnWindowPen= System.Drawing.Pens.White }; - + // 逐页查找 try { - for (var i = 0; i < 16; i++) // 6.0版本最多20个队伍 + for (var i = 0; i < 16; i++) // 6.0版本最多20个队伍 { using var page = CaptureToRectArea(); - var nameList = page.FindMulti(recognitionObject); - if (nameList == null || nameList.Count <= 0) + var partySwitchNameRaList = page.FindMulti(recognitionObject); + + if (partySwitchNameRaList == null || partySwitchNameRaList.Count <= 0) { Logger.LogInformation("管理队伍界面文字识别失败"); break; } - // 在当前页查找匹配 - var (match, score) = FindMatchInPage(page, nameList, partyName, useOcrMatch); - if (match != null) + // 当前页存在则直接点击 + foreach (var textRegion in partySwitchNameRaList) { - page.ClickTo(match.Right + match.Width, match.Bottom); - await Delay(200, ct); - if (useOcrMatch) - Logger.LogInformation("切换队伍成功: {Text}(匹配分数: {Score:F4})", match.Text, score); - else - Logger.LogInformation("切换队伍成功: {Text}", match.Text); - await ConfirmParty(page, ct, isInPartyViewUi); - RunnerContext.Instance.ClearCombatScenes(); - return true; + if (Regex.IsMatch(textRegion.Text, partyName)) + { + page.ClickTo(textRegion.Right + textRegion.Width, textRegion.Bottom); + await Delay(200, ct); + Logger.LogInformation("切换队伍成功: {Text}", textRegion.Text); + await ConfirmParty(page, ct, isInPartyViewUi); + + RunnerContext.Instance.ClearCombatScenes(); + return true; + } } - // 判断是否已遍历所有队伍 - var lowest = nameList - .Where(r => r.X > 35 * _assetScale && r.X < 100 * _assetScale) - .OrderBy(r => r.Y) - .LastOrDefault(); - if (lowest == null) - { - Logger.LogInformation("未找到符合坐标范围的队伍名称,跳过翻页判断"); - continue; - } + Region lowest = partySwitchNameRaList.Where(r => r.X > 35 * _assetScale && r.X < 100 * _assetScale).OrderBy(r => r.Y).Last(); lowest.DrawSelf("底部的队伍"); - if (lowest.Y < 777 * _assetScale) // 如果最底下是空队伍则不会有队伍名,以此判断是否已遍历完成 + if (lowest.Y < 777 * _assetScale) // 如果最底下是空队伍则不会有队伍名,以此判断是否已遍历完成 { Logger.LogInformation("已抵达最后一个队伍"); break; } - // 翻页 + // 点击下一页 if (i == 0) { - // 首次点一下第一个,防止第五个被点击过 + // #ebe4d8 首次点一下第一个,防止第五个被点击过 page.ClickTo(600 * _assetScale, 200 * _assetScale); - await Task.Delay(300, ct); + await Task.Delay(300, ct); // 等待动画 } - page.ClickTo(regionOfInterest.X + regionOfInterest.Width / 2, lowest.Bottom); + page.ClickTo(regionOfInterest.X + regionOfInterest.Width / 2, lowest.Bottom); // 点击最下方队伍下移 await Delay(400, ct); } } @@ -147,181 +227,14 @@ public class SwitchPartyTask // 未找到 Logger.LogError("未找到队伍: {Name},返回主界面", partyName); - Logger.LogInformation(useOcrMatch - ? "如果找不到设定的队伍名,有可能是文字识别效果不佳,请尝试调整 OcrMatch 模糊匹配阈值" - : "如果找不到设定的队伍名,有可能是文字识别效果不佳,请尝试正则表达式"); + Logger.LogInformation("如果找不到设定的队伍名,有可能是文字识别效果不佳,请尝试正则表达式"); await _returnMainUiTask.Start(ct); return false; } - /// - /// 确保队伍配置界面已打开。如果不在主界面则先返回主界面,然后打开队伍配置。 - /// - private async Task EnsurePartyViewOpen(ImageRegion currentScreen, CancellationToken ct) - { - if (!Bv.IsInMainUi(currentScreen)) - { - await _returnMainUiTask.Start(ct); - await Delay(200, ct); - using var raMain = CaptureToRectArea(); - if (!Bv.IsInMainUi(raMain)) - throw new InvalidOperationException("未能返回主界面"); - } - - const int maxAttempts = 2; - for (int attempt = 1; attempt <= maxAttempts; attempt++) - { - Simulation.SendInput.SimulateAction(GIActions.OpenPartySetupScreen); - for (int i = 0; i < 7; i++) // 考虑加载时间 2s,共检查 4.2s - { - await Delay(600, ct); - using var raCheck = CaptureToRectArea(); - if (Bv.IsInPartyViewUi(raCheck)) return; - } - } - - throw new PartySetupFailedException("未能打开队伍配置界面"); - } - - /// - /// 检查当前队伍名称是否匹配目标 - /// - private bool IsCurrentTeamMatch(ImageRegion ra, Region partyViewBtn, string partyName, bool useOcrMatch) - { - var roi = new Rect(partyViewBtn.Right, partyViewBtn.Top, (int)(350 * _assetScale), partyViewBtn.Height); - - if (useOcrMatch) - { - var matchService = OcrFactory.PaddleMatch; - var threshold = TaskContext.Instance().Config.OtherConfig.OcrConfig.OcrMatchDefaultThreshold; - using var region = ra.DeriveCrop(roi); - var score = matchService.OcrMatch(region.SrcMat, partyName); - Logger.LogInformation("切换队伍,当前队伍 OcrMatch 分数: {Score:F4},判断阈值: {Threshold}", score, threshold); - if (score >= threshold) - { - Logger.LogInformation("当前队伍即为目标队伍(匹配分数: {Score:F4}),无需切换", score); - return true; - } - - return false; - } - - var text = CleanOcrText(ra.Find(new RecognitionObject - { - RecognitionType = RecognitionTypes.Ocr, - RegionOfInterest = roi - }).Text); - Logger.LogInformation("切换队伍,当前队伍名称: {Text},使用正则表达式规则进行模糊匹配", text); - if (Regex.IsMatch(text, partyName)) - { - Logger.LogInformation("当前队伍[{Name}]即为目标队伍,无需切换", text); - return true; - } - - return false; - } - - /// - /// 在当前页的文字区域列表中查找匹配目标的队伍 - /// - private (Region? match, double score) FindMatchInPage( - ImageRegion page, List textRegions, string partyName, bool useOcrMatch) - { - if (useOcrMatch) - { - var matchService = OcrFactory.PaddleMatch; - var threshold = TaskContext.Instance().Config.OtherConfig.OcrConfig.OcrMatchDefaultThreshold; - Region? bestMatch = null; - double bestScore = 0; - var imgW = page.SrcMat.Width; - var imgH = page.SrcMat.Height; - foreach (var region in textRegions) - { - var cx = Math.Max(0, region.X); - var cy = Math.Max(0, region.Y); - var cw = Math.Min(region.Width, imgW - cx); - var ch = Math.Min(region.Height, imgH - cy); - if (cw <= 0 || ch <= 0) - continue; - - using var cropped = page.DeriveCrop(cx, cy, cw, ch); - var score = matchService.OcrMatchDirect(cropped.SrcMat, partyName); - if (score >= threshold && score > bestScore) - { - bestScore = score; - bestMatch = region; - } - } - - return (bestMatch, bestScore); - } - - foreach (var region in textRegions) - { - if (Regex.IsMatch(region.Text, partyName)) - return (region, 0); - } - - return (null, 0); - } - - /// - /// 打开队伍选择页面(点击选择按钮并等待加载) - /// - private static async Task OpenPartyChoosePage(Region partyViewBtn, CancellationToken ct) - { - var menu = await NewRetry.WaitForElementAppear( - ElementAssets.Instance.PartyBtnDelete, - () => partyViewBtn.Click(), - ct, 4, 500); - if (!menu) - throw new PartySetupFailedException("未能打开队伍选择页面"); - - Region? partyDeleteBtn = null; - var success = await NewRetry.WaitForAction(() => - { - using var ocrRa = CaptureToRectArea(); - partyDeleteBtn = ocrRa.Find(ElementAssets.Instance.PartyBtnDelete); - return partyDeleteBtn.IsExist(); - }, ct, 5); - - if (!success || partyDeleteBtn == null) - throw new PartySetupFailedException("未能打开队伍配置界面"); - - return partyDeleteBtn; - } - - /// - /// 滚动列表到最上方 - /// - private static async Task ScrollToTop(CancellationToken ct) - { - await Task.Delay(50, ct); - GameCaptureRegion.GameRegion1080PPosClick(700, 125); - await Task.Delay(50, ct); - Simulation.SendInput.Mouse.LeftButtonDown(); - await Task.Delay(450, ct); - Simulation.SendInput.Mouse.LeftButtonUp(); - await Task.Delay(100, ct); - } - - /// - /// 清理 OCR 识别结果中的干扰字符 - /// - private static string CleanOcrText(string? text) - { - if (string.IsNullOrEmpty(text)) - return string.Empty; - var cleaned = text.Replace("\"", "").Replace("\r\n", "").Replace("\r", ""); - var newLineIndex = cleaned.IndexOf('\n'); - if (newLineIndex != -1) - cleaned = cleaned[..newLineIndex]; - return cleaned.Trim(); - } - private async Task ConfirmParty(ImageRegion page, CancellationToken ct, bool isInPartyViewUi = false) { - Bv.ClickWhiteConfirmButton(page.DeriveCrop(0, page.Height / 4, page.Width / 4, page.Height - page.Height / 4)); + var r1 = Bv.ClickWhiteConfirmButton(page.DeriveCrop(0, page.Height / 4, page.Width / 4, page.Height - page.Height / 4)); var partyChooseUiClosed = await NewRetry.WaitForAction(() => { using var ra2 = CaptureToRectArea(); @@ -331,10 +244,9 @@ public class SwitchPartyTask { throw new PartySetupFailedException("选择队伍失败,等待队伍切换超时!"); } - await Delay(200, ct); using var ra = CaptureToRectArea(); - Bv.ClickWhiteConfirmButton(ra.DeriveCrop(page.Width - page.Width / 4, page.Height / 4, page.Width / 4, page.Height - page.Height / 4)); + var r2 = Bv.ClickWhiteConfirmButton(ra.DeriveCrop(page.Width - page.Width / 4, page.Height / 4, page.Width / 4, page.Height - page.Height / 4)); await Delay(500, ct); if (isInPartyViewUi) await _returnMainUiTask.Start(ct); } diff --git a/BetterGenshinImpact/GameTask/Common/TaskControl.cs b/BetterGenshinImpact/GameTask/Common/TaskControl.cs index 98bcef59..f582bce0 100644 --- a/BetterGenshinImpact/GameTask/Common/TaskControl.cs +++ b/BetterGenshinImpact/GameTask/Common/TaskControl.cs @@ -116,7 +116,8 @@ public class TaskControl } else { - Logger.LogInformation("当前获取焦点的窗口不是原神,尝试恢复窗口"); + var name = SystemControl.GetActiveByProcess(); + Logger.LogInformation("当前获取焦点的窗口为: {Name},不是原神,尝试恢复窗口", name); SystemControl.FocusWindow(TaskContext.Instance().GameHandle); } diff --git a/BetterGenshinImpact/GameTask/GameLoading/GameLoading.cs b/BetterGenshinImpact/GameTask/GameLoading/GameLoading.cs index 219ad711..699fcef8 100644 --- a/BetterGenshinImpact/GameTask/GameLoading/GameLoading.cs +++ b/BetterGenshinImpact/GameTask/GameLoading/GameLoading.cs @@ -1,6 +1,8 @@ using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Core.Recognition; using BetterGenshinImpact.GameTask.GameLoading.Assets; using System; +using System.Collections.Generic; using System.Diagnostics; using BetterGenshinImpact.GameTask.Common; using BetterGenshinImpact.GameTask.Common.BgiVision; @@ -35,6 +37,7 @@ public class GameLoadingTrigger : ITaskTrigger public bool IsBackgroundRunning => true; private readonly GameLoadingAssets _assets; + private readonly ElementAssets _elementAssets; private readonly GenshinStartConfig _config = TaskContext.Instance().Config.GenshinStartConfig; private static ILogger _logger = App.GetLogger(); @@ -56,11 +59,15 @@ public class GameLoadingTrigger : ITaskTrigger private bool biliLoginClicked = false; private (double x1080, double y1080)? lastAgreementClickPos = null; + private DateTime _prevAgePromptOcrTime = DateTime.MinValue; + private bool _agePromptTextMatched = false; + private List _latestLoadingOcrRegions = []; public GameLoadingTrigger() { GameLoadingAssets.DestroyInstance(); _assets = GameLoadingAssets.Instance; + _elementAssets = ElementAssets.Instance; } public void InnerSetEnabled(bool enabled) @@ -150,7 +157,7 @@ public class GameLoadingTrigger : ITaskTrigger } else { - TaskControl.Logger.LogWarning("没有检测到 Starward 协议注册,请查看帮助文档!"); + // TaskControl.Logger.LogWarning("没有检测到 Starward 协议注册,请查看帮助文档!"); return false; } } @@ -247,6 +254,7 @@ public class GameLoadingTrigger : ITaskTrigger InnerSetEnabled(false); return; } + // 成功进入游戏判断 if (Bv.IsInMainUi(content.CaptureRectArea) || Bv.IsInAnyClosableUi(content.CaptureRectArea) || Bv.IsInDomain(content.CaptureRectArea)) { @@ -255,6 +263,25 @@ public class GameLoadingTrigger : ITaskTrigger return; } + if ((DateTime.Now - _prevAgePromptOcrTime).TotalMilliseconds >= 1000) + { + _prevAgePromptOcrTime = DateTime.Now; + _latestLoadingOcrRegions = content.CaptureRectArea.FindMulti(RecognitionObject.OcrThis); + if (_latestLoadingOcrRegions.Any(region => + region.Text.Contains("适龄") || region.Text.Contains("监护"))) + { + // 适龄提示窗口自动关闭 + var agePopup = content.CaptureRectArea.Find(_elementAssets.BtnWhiteConfirm); + if (!agePopup.IsEmpty()) + { + agePopup.Click(); + _logger.LogInformation("检测到适龄提示,自动点击确认"); + } + } + } + + + // B服判断 if (!IsBiliJudged) { @@ -432,4 +459,4 @@ public class GameLoadingTrigger : ITaskTrigger return (bHWnd, windowType); } -}; \ No newline at end of file +}; diff --git a/BetterGenshinImpact/GameTask/GameTaskManager.cs b/BetterGenshinImpact/GameTask/GameTaskManager.cs index baff5531..674e6add 100644 --- a/BetterGenshinImpact/GameTask/GameTaskManager.cs +++ b/BetterGenshinImpact/GameTask/GameTaskManager.cs @@ -1,4 +1,4 @@ -using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Core.Config; using BetterGenshinImpact.Core.Recognition.OpenCv; using BetterGenshinImpact.Core.Script.Dependence.Model.TimerConfig; using BetterGenshinImpact.GameTask.AutoFight.Assets; @@ -47,7 +47,6 @@ internal class GameTaskManager TriggerDictionary.TryAdd("QuickTeleport", new QuickTeleport.QuickTeleportTrigger()); TriggerDictionary.TryAdd("AutoSkip", new AutoSkip.AutoSkipTrigger()); TriggerDictionary.TryAdd("AutoFish", new AutoFishing.AutoFishingTrigger()); - TriggerDictionary.TryAdd("AutoCook", new AutoCook.AutoCookTrigger()); TriggerDictionary.TryAdd("AutoEat", new AutoEat.AutoEatTrigger()); TriggerDictionary.TryAdd("MapMask", new MapMaskTrigger()); TriggerDictionary.TryAdd("SkillCd", new SkillCdTrigger()); @@ -123,7 +122,6 @@ internal class GameTaskManager TriggerDictionary.GetValueOrDefault("AutoFish")?.Init(); TriggerDictionary.GetValueOrDefault("QuickTeleport")?.Init(); // TriggerDictionary.GetValueOrDefault("GameLoading")?.Init(); - TriggerDictionary.GetValueOrDefault("AutoCook")?.Init(); TriggerDictionary.GetValueOrDefault("AutoEat")?.Init(); TriggerDictionary.GetValueOrDefault("MapMask")?.Init(); TriggerDictionary.GetValueOrDefault("SkillCd")?.Init(); diff --git a/BetterGenshinImpact/GameTask/MapMask/MapMaskConfig.cs b/BetterGenshinImpact/GameTask/MapMask/MapMaskConfig.cs index af3fa94f..eb1dbced 100644 --- a/BetterGenshinImpact/GameTask/MapMask/MapMaskConfig.cs +++ b/BetterGenshinImpact/GameTask/MapMask/MapMaskConfig.cs @@ -10,18 +10,42 @@ namespace BetterGenshinImpact.GameTask.MapMask; [Serializable] public partial class MapMaskConfig : ObservableObject { + public const string HoYoLabLanguageEnUs = "en-us"; + public const string HoYoLabLanguagePtPt = "pt-pt"; + public const string HoYoLabLanguageEsEs = "es-es"; + /// /// 是否启用 /// [ObservableProperty] - private bool _enabled = true; + private bool _enabled = false; + + /// + /// 小地图遮罩是否启用 + /// + [ObservableProperty] + private bool _miniMapMaskEnabled = false; + + /// + /// 自动记录路径功能是否启用 + /// + [ObservableProperty] + private bool _pathAutoRecordEnabled = false; private MapPointApiProvider _mapPointApiProvider = MapPointApiProvider.MihoyoMap; + private string _hoYoLabLanguage = HoYoLabLanguageEnUs; + [JsonConverter(typeof(JsonStringEnumConverter))] public MapPointApiProvider MapPointApiProvider { get => _mapPointApiProvider; set => SetProperty(ref _mapPointApiProvider, value); } + + public string HoYoLabLanguage + { + get => _hoYoLabLanguage; + set => SetProperty(ref _hoYoLabLanguage, value); + } } diff --git a/BetterGenshinImpact/GameTask/MapMask/MapMaskTrigger.cs b/BetterGenshinImpact/GameTask/MapMask/MapMaskTrigger.cs index cd9a7f23..f8eba132 100644 --- a/BetterGenshinImpact/GameTask/MapMask/MapMaskTrigger.cs +++ b/BetterGenshinImpact/GameTask/MapMask/MapMaskTrigger.cs @@ -1,14 +1,20 @@ using System; -using System.Windows; +using System.Threading; +using System.Threading.Tasks; using BetterGenshinImpact.Core.Recognition.OpenCv; +using BetterGenshinImpact.GameTask.AutoPathing; using BetterGenshinImpact.GameTask.Common.BgiVision; +using BetterGenshinImpact.GameTask.Common.Element.Assets; using BetterGenshinImpact.GameTask.Common.Map.Maps; using BetterGenshinImpact.GameTask.Common.Map.Maps.Base; using BetterGenshinImpact.GameTask.Common.Map.Maps.Layer; +using BetterGenshinImpact.GameTask.Model.Area; using BetterGenshinImpact.Helpers; using BetterGenshinImpact.View; using BetterGenshinImpact.ViewModel; using Microsoft.Extensions.Logging; +using OpenCvSharp; +using Rect = System.Windows.Rect; namespace BetterGenshinImpact.GameTask.MapMask; @@ -23,8 +29,8 @@ public class MapMaskTrigger : ITaskTrigger public bool IsEnabled { get; set; } public int Priority => 1; // 低优先级 public bool IsExclusive => false; - - public GameUiCategory SupportedGameUiCategory => GameUiCategory.BigMap; + + public GameUiCategory SupportedGameUiCategory => GameUiCategory.Unknown; private readonly MapMaskConfig _config = TaskContext.Instance().Config.MapMaskConfig; private readonly string _mapMatchingMethod = TaskContext.Instance().Config.PathingConditionConfig.MapMatchingMethod; @@ -38,29 +44,77 @@ public class MapMaskTrigger : ITaskTrigger private ISceneMap _teyvatMap => MapManager.GetMap(MapTypes.Teyvat, _mapMatchingMethod); private OpenCvSharp.Rect _prevRect = default; + private readonly object _prevRectLock = new(); private const int RectDebounceThreshold = 3; + private readonly NavigationInstance _navigationInstance = new(); + + private sealed class PendingUiUpdate + { + public bool? IsInBigMapUi { get; init; } + public Rect? BigMapViewport { get; init; } + public Rect? MiniMapViewport { get; init; } + } + + private PendingUiUpdate? _pendingUiUpdate; + private int _uiApplyScheduled; + + private sealed class ComputeWorkItem : IDisposable + { + public required string MapMatchingMethod { get; init; } + public Mat? Mat { get; set; } + + public void Dispose() + { + Mat?.Dispose(); + Mat = null; + } + } + + private ComputeWorkItem? _pendingBigMapCompute; + private int _bigMapWorkerRunning; + private ComputeWorkItem? _pendingMiniMapCompute; + private int _miniMapWorkerRunning; + + /// + /// 初始化触发器状态,并在关闭时同步隐藏遮罩UI + /// public void Init() { IsEnabled = _config.Enabled; - + // 关闭时隐藏UI if (!IsEnabled) { - UIDispatcherHelper.Invoke(() => + var pendingBigMapCompute = Interlocked.Exchange(ref _pendingBigMapCompute, null); + pendingBigMapCompute?.Dispose(); + var pendingMiniMapCompute = Interlocked.Exchange(ref _pendingMiniMapCompute, null); + pendingMiniMapCompute?.Dispose(); + + Interlocked.Exchange(ref _pendingUiUpdate, null); + + UIDispatcherHelper.BeginInvoke(() => { if (MaskWindow.InstanceNullable() != null) { - if (MaskWindow.Instance().DataContext is MaskWindowViewModel vm) + var window = MaskWindow.Instance(); + if (window.DataContext is MaskWindowViewModel vm) { vm.IsInBigMapUi = false; } + + window.PointsCanvasControl.UpdateViewport(0, 0, 0, 0); + window.MiniMapPointsCanvasControl.UpdateViewport(0, 0, 0, 0); } }); } } + /// + /// 接收每帧截图内容并驱动大地图/小地图的异步定位与UI更新 + /// + /// 捕获到的画面内容 public void OnCapture(CaptureContent content) { if ((DateTime.Now - _prevExecute).TotalMilliseconds <= 50) @@ -74,13 +128,8 @@ public class MapMaskTrigger : ITaskTrigger { var region = content.CaptureRectArea; var inBigMapUi = content.CurrentGameUiCategory == GameUiCategory.BigMap || Bv.IsInBigMapUi(region); - UIDispatcherHelper.Invoke(() => - { - if (MaskWindow.Instance().DataContext is MaskWindowViewModel vm) - { - vm.IsInBigMapUi = inBigMapUi; - } - }); + var mapMatchingMethod = TaskContext.Instance().Config.PathingConditionConfig.MapMatchingMethod; + PendingUiUpdate? update = null; if (inBigMapUi) { @@ -99,43 +148,298 @@ public class MapMaskTrigger : ITaskTrigger if (_stableCount == 0) { - var rect256 = BigMapTeyvat256Layer.GetInstance((SceneBaseMap)_teyvatMap).GetBigMapRect(region.CacheGreyMat, _prevRect); - if (rect256 != default) + var greyMat = region.CacheGreyMat.Clone(); + EnqueueBigMapCompute(new ComputeWorkItem { - // 过大或过小的区域不处理 - if (rect256 is { Width: < 50, Height: < 40 } || rect256 is { Width: > 3000, Height: > 1800 }) - { - _prevRect = default; - return; - } - - - // if (_prevRect != default) - // { - // var dx = Math.Abs(rect256.X - _prevRect.X); - // var dy = Math.Abs(rect256.Y - _prevRect.Y); - // if (dx <= RectDebounceThreshold && dy <= RectDebounceThreshold) - // { - // return; - // } - // } - - _prevRect = rect256; - } - - const int s = TeyvatMap.BigMap256ScaleTo2048; // 相对2048做8倍缩放 - var rect2048 = new Rect(rect256.X * s, rect256.Y * s, rect256.Width * s, rect256.Height * s); - UIDispatcherHelper.Invoke(() => { MaskWindow.Instance().PointsCanvasControl.UpdateViewport(rect2048.X, rect2048.Y, rect2048.Width, rect2048.Height); }); + MapMatchingMethod = mapMatchingMethod, + Mat = greyMat + }); } } else { - _prevRect = default; + // 主界面上展示小地图 + if (_config.MiniMapMaskEnabled) + { + if (Bv.IsInMainUi(region)) + { + var srcMat = region.SrcMat.Clone(); + EnqueueMiniMapCompute(new ComputeWorkItem + { + MapMatchingMethod = mapMatchingMethod, + Mat = srcMat + }); + + // 自动记录路径 + if (_config.PathAutoRecordEnabled) + { + // ... + } + } + else + { + update = new PendingUiUpdate { MiniMapViewport = new Rect(0, 0, 0, 0) }; + } + } + + lock (_prevRectLock) + { + _prevRect = default; + } } + + update = update == null + ? new PendingUiUpdate { IsInBigMapUi = inBigMapUi } + : new PendingUiUpdate + { + IsInBigMapUi = inBigMapUi, + BigMapViewport = update.BigMapViewport, + MiniMapViewport = update.MiniMapViewport + }; + + QueueUiUpdate(update); } catch (Exception e) { _logger.LogDebug(e, "实时地图定位时发生异常"); } } -} \ No newline at end of file + + /// + /// 入队大地图定位计算,仅保留正在执行与最新任务 + /// + /// 计算任务 + private void EnqueueBigMapCompute(ComputeWorkItem workItem) + { + var previous = Interlocked.Exchange(ref _pendingBigMapCompute, workItem); + previous?.Dispose(); + + if (Interlocked.Exchange(ref _bigMapWorkerRunning, 1) == 0) + { + _ = Task.Run(BigMapWorkerLoop); + } + } + + /// + /// 入队小地图定位计算,仅保留正在执行与最新任务 + /// + /// 计算任务 + private void EnqueueMiniMapCompute(ComputeWorkItem workItem) + { + var previous = Interlocked.Exchange(ref _pendingMiniMapCompute, workItem); + previous?.Dispose(); + + if (Interlocked.Exchange(ref _miniMapWorkerRunning, 1) == 0) + { + _ = Task.Run(MiniMapWorkerLoop); + } + } + + /// + /// 大地图计算工作线程循环 + /// + private void BigMapWorkerLoop() + { + while (true) + { + var workItem = Interlocked.Exchange(ref _pendingBigMapCompute, null); + if (workItem == null) + { + Interlocked.Exchange(ref _bigMapWorkerRunning, 0); + if (Volatile.Read(ref _pendingBigMapCompute) != null && Interlocked.Exchange(ref _bigMapWorkerRunning, 1) == 0) + { + continue; + } + + return; + } + + try + { + ProcessBigMapCompute(workItem); + } + catch (Exception e) + { + _logger.LogDebug(e, "地图遮罩异步计算时发生异常"); + } + finally + { + workItem.Dispose(); + } + } + } + + /// + /// 小地图计算工作线程循环 + /// + private void MiniMapWorkerLoop() + { + while (true) + { + var workItem = Interlocked.Exchange(ref _pendingMiniMapCompute, null); + if (workItem == null) + { + Interlocked.Exchange(ref _miniMapWorkerRunning, 0); + if (Volatile.Read(ref _pendingMiniMapCompute) != null && Interlocked.Exchange(ref _miniMapWorkerRunning, 1) == 0) + { + continue; + } + + return; + } + + try + { + ProcessMiniMapCompute(workItem); + } + catch (Exception e) + { + _logger.LogDebug(e, "地图遮罩异步计算时发生异常"); + } + finally + { + workItem.Dispose(); + } + } + } + + /// + /// 执行大地图定位计算并产出UI更新 + /// + /// 计算任务 + private void ProcessBigMapCompute(ComputeWorkItem workItem) + { + if (workItem.Mat == null) + { + return; + } + + OpenCvSharp.Rect prevRect; + lock (_prevRectLock) + { + prevRect = _prevRect; + } + + var sceneMap = (SceneBaseMap)MapManager.GetMap(MapTypes.Teyvat, workItem.MapMatchingMethod); + var rect256 = BigMapTeyvat256Layer.GetInstance(sceneMap).GetBigMapRect(workItem.Mat, prevRect); + if (rect256 != default) + { + if (rect256 is { Width: < 50, Height: < 40 } || rect256 is { Width: > 3000, Height: > 1800 }) + { + lock (_prevRectLock) + { + _prevRect = default; + } + return; + } + + lock (_prevRectLock) + { + _prevRect = rect256; + } + } + + const int s = TeyvatMap.BigMap256ScaleTo2048; + var rect2048 = new Rect(rect256.X * s, rect256.Y * s, rect256.Width * s, rect256.Height * s); + QueueUiUpdate(new PendingUiUpdate { BigMapViewport = rect2048 }); + } + + /// + /// 执行小地图定位计算并产出UI更新 + /// + /// 计算任务 + private void ProcessMiniMapCompute(ComputeWorkItem workItem) + { + if (workItem.Mat == null) + { + return; + } + + using var imageRegion = new ImageRegion(workItem.Mat, 0, 0); + workItem.Mat = null; + + var miniPoint = _navigationInstance.GetPositionStable(imageRegion, nameof(MapTypes.Teyvat), workItem.MapMatchingMethod); + if (miniPoint != default) + { + double viewportSize = MapAssets.MimiMapRect1080P.Width / 3.0 * 10; + QueueUiUpdate(new PendingUiUpdate + { + MiniMapViewport = new Rect( + miniPoint.X - viewportSize / 2.0, + miniPoint.Y - viewportSize / 2.0, + viewportSize, + viewportSize) + }); + } + else + { + QueueUiUpdate(new PendingUiUpdate { MiniMapViewport = new Rect(0, 0, 0, 0) }); + } + } + + /// + /// 合并并异步投递UI更新 + /// + /// 待应用的UI更新 + private void QueueUiUpdate(PendingUiUpdate update) + { + Interlocked.Exchange(ref _pendingUiUpdate, update); + TryScheduleUiApply(); + } + + /// + /// 确保仅有一个UI更新调度在队列中 + /// + private void TryScheduleUiApply() + { + if (Interlocked.Exchange(ref _uiApplyScheduled, 1) == 0) + { + UIDispatcherHelper.BeginInvoke(ApplyPendingUiUpdate); + } + } + + /// + /// 在UI线程应用合并后的更新 + /// + private void ApplyPendingUiUpdate() + { + var update = Interlocked.Exchange(ref _pendingUiUpdate, null); + if (update != null) + { + var window = MaskWindow.Instance(); + if (!_config.Enabled) + { + if (window.DataContext is MaskWindowViewModel vmWhenDisabled) + { + vmWhenDisabled.IsInBigMapUi = false; + } + + window.PointsCanvasControl.UpdateViewport(0, 0, 0, 0); + window.MiniMapPointsCanvasControl.UpdateViewport(0, 0, 0, 0); + Interlocked.Exchange(ref _uiApplyScheduled, 0); + return; + } + + if (update.IsInBigMapUi is { } isInBigMapUi && window.DataContext is MaskWindowViewModel vm) + { + vm.IsInBigMapUi = isInBigMapUi; + } + + if (update.BigMapViewport is { } bigMapViewport) + { + window.PointsCanvasControl.UpdateViewport(bigMapViewport.X, bigMapViewport.Y, bigMapViewport.Width, bigMapViewport.Height); + } + + if (update.MiniMapViewport is { } miniMapViewport) + { + window.MiniMapPointsCanvasControl.UpdateViewport(miniMapViewport.X, miniMapViewport.Y, miniMapViewport.Width, miniMapViewport.Height); + } + } + + Interlocked.Exchange(ref _uiApplyScheduled, 0); + if (Volatile.Read(ref _pendingUiUpdate) != null) + { + TryScheduleUiApply(); + } + } +} diff --git a/BetterGenshinImpact/GameTask/MapMask/MapPointApiProvider.cs b/BetterGenshinImpact/GameTask/MapMask/MapPointApiProvider.cs index a32af914..bb9c8caa 100644 --- a/BetterGenshinImpact/GameTask/MapMask/MapPointApiProvider.cs +++ b/BetterGenshinImpact/GameTask/MapMask/MapPointApiProvider.cs @@ -3,5 +3,6 @@ namespace BetterGenshinImpact.GameTask.MapMask; public enum MapPointApiProvider { MihoyoMap = 0, - KongyingTavern = 1 + KongyingTavern = 1, + HoYoLab = 2 } diff --git a/BetterGenshinImpact/GameTask/QuickSereniteaPot/QuickSereniteaPotTask.cs b/BetterGenshinImpact/GameTask/QuickSereniteaPot/QuickSereniteaPotTask.cs index 8d1e647f..814b10b6 100644 --- a/BetterGenshinImpact/GameTask/QuickSereniteaPot/QuickSereniteaPotTask.cs +++ b/BetterGenshinImpact/GameTask/QuickSereniteaPot/QuickSereniteaPotTask.cs @@ -1,4 +1,4 @@ -using BetterGenshinImpact.Core.Simulator; +using BetterGenshinImpact.Core.Simulator; using BetterGenshinImpact.Core.Simulator.Extensions; using BetterGenshinImpact.GameTask.AutoGeniusInvokation.Exception; using BetterGenshinImpact.GameTask.Common; @@ -103,22 +103,26 @@ public class QuickSereniteaPotTask } } } - // 校验F交互是否是 进入[尘歌壶] - bool canIn = Bv.FindF(TaskControl.CaptureToRectArea(), "进入","尘歌壶"); + // 校验F交互是否是 进入/离开[尘歌壶] + var capture = TaskControl.CaptureToRectArea(); + bool isEnter = Bv.FindF(capture, "进入", "尘歌壶"); + bool isLeave = Bv.FindF(capture, "离开", "尘歌壶"); - if (canIn) { - TaskControl.Logger.LogInformation("快速进入尘歌壶:识别到 进入尘歌壶"); - // 按F进入 + if (isEnter || isLeave) { + string action = isEnter ? "进入" : "离开"; + TaskControl.Logger.LogInformation($"快速进出尘歌壶:识别到 {action}尘歌壶"); + + // 按F触发交互 Simulation.SendInput.SimulateAction(GIActions.PickUpOrInteract); - TaskControl.Logger.LogInformation("快速进入尘歌壶:F进入尘歌壶"); + TaskControl.Logger.LogInformation($"快速进出尘歌壶:F{action}尘歌壶"); TaskControl.CheckAndSleep(200); - // 点击进入尘歌壶 + // 点击进入/离开尘歌壶 // 如果不是联机状态,此时玩家应已进入传送界面,本次点击不会影响实际功能 GameCaptureRegion.GameRegion1080PPosClick(1010, 760); } else { - TaskControl.Logger.LogInformation("快速进入尘歌壶:未识别到 进入尘歌壶"); + TaskControl.Logger.LogInformation("快速进出尘歌壶:未识别到 进入或离开尘歌壶"); } } catch (Exception e) diff --git a/BetterGenshinImpact/GameTask/SystemControl.cs b/BetterGenshinImpact/GameTask/SystemControl.cs index d87b8f50..43539eb0 100644 --- a/BetterGenshinImpact/GameTask/SystemControl.cs +++ b/BetterGenshinImpact/GameTask/SystemControl.cs @@ -13,7 +13,8 @@ public class SystemControl { public static nint FindGenshinImpactHandle() { - return FindHandleByProcessName("YuanShen", "GenshinImpact", "Genshin Impact Cloud Game", "Genshin Impact Cloud"); + var processNames = TaskContext.Instance().GetGenshinGameProcessNameList(); + return FindHandleByProcessName(processNames.ToArray()); } public static async Task StartFromLocalAsync(string path) @@ -69,7 +70,13 @@ public class SystemControl public static bool IsGenshinImpactActiveByProcess() { var name = GetActiveProcessName(); - return name is "YuanShen" or "yuanshen" or "GenshinImpact" or "Genshin Impact Cloud Game"; + if (string.IsNullOrEmpty(name)) + { + return false; + } + + var processNames = TaskContext.Instance().GetGenshinGameProcessNameList(); + return processNames.Any(p => string.Equals(p, name, StringComparison.OrdinalIgnoreCase)); } public static string GetActiveByProcess() @@ -83,6 +90,11 @@ public class SystemControl return hWnd == TaskContext.Instance().GameHandle; } + public static bool IsGenshinImpactMinimized() + { + return User32.IsIconic(TaskContext.Instance().GameHandle); + } + public static nint GetForegroundWindowHandle() { return (nint)User32.GetForegroundWindow(); @@ -312,10 +324,11 @@ public class SystemControl { try { - // 尝试通过进程名称查找原神进程 - var processes = Process.GetProcessesByName("YuanShen") - .Concat(Process.GetProcessesByName("GenshinImpact")) - .Concat(Process.GetProcessesByName("Genshin Impact Cloud Game")) + var processNames = TaskContext.Instance().GetGenshinGameProcessNameList(); + var processes = processNames + .SelectMany(Process.GetProcessesByName) + .GroupBy(p => p.Id) + .Select(g => g.First()) .ToArray(); if (processes.Length > 0) diff --git a/BetterGenshinImpact/GameTask/TaskContext.cs b/BetterGenshinImpact/GameTask/TaskContext.cs index ddd16d5a..ba346ea4 100644 --- a/BetterGenshinImpact/GameTask/TaskContext.cs +++ b/BetterGenshinImpact/GameTask/TaskContext.cs @@ -5,6 +5,8 @@ using BetterGenshinImpact.Genshin.Settings; using BetterGenshinImpact.Helpers; using BetterGenshinImpact.Service; using System; +using System.Collections.Generic; +using System.IO; using System.Threading; using BetterGenshinImpact.Core.Script.Group; @@ -74,5 +76,36 @@ namespace BetterGenshinImpact.GameTask /// 注意 IsInitialized = false 时,这个值就会被设置 /// public DateTime LinkedStartGenshinTime { get; set; } = DateTime.MinValue; + + public List GetGenshinGameProcessNameList() + { + if (IsInitialized) + { + return [SystemInfo.GameProcessName]; + } + else + { + List list = ["YuanShen", "GenshinImpact", "Genshin Impact Cloud Game", "Genshin Impact Cloud"]; + try + { + var installPath = Config.GenshinStartConfig.InstallPath; + if (!string.IsNullOrEmpty(installPath)) + { + var customName = Path.GetFileNameWithoutExtension(installPath); + if (!string.IsNullOrEmpty(customName) && !list.Contains(customName)) + { + // list.Insert(0, customName); // 将用户自定义的进程名放在列表前面,优先匹配 + list.Add(customName); + } + } + } + catch + { + /* ignore */ + } + + return list; + } + } } -} +} \ No newline at end of file diff --git a/BetterGenshinImpact/GameTask/TaskTriggerDispatcher.cs b/BetterGenshinImpact/GameTask/TaskTriggerDispatcher.cs index f274c93a..4702c1e0 100644 --- a/BetterGenshinImpact/GameTask/TaskTriggerDispatcher.cs +++ b/BetterGenshinImpact/GameTask/TaskTriggerDispatcher.cs @@ -1,4 +1,4 @@ -using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Core.Config; using BetterGenshinImpact.GameTask.Common; using BetterGenshinImpact.Helpers; using BetterGenshinImpact.View; @@ -125,6 +125,7 @@ namespace BetterGenshinImpact.GameTask public void Start(IntPtr hWnd, CaptureModes mode, int interval = 50) { // 初始化截图器 + ChatUiHotkeyGuard.Reset(); GameCapture = GameCaptureFactory.Create(mode); // 激活窗口 保证后面能够正常获取窗口信息 SystemControl.ActivateWindow(hWnd); @@ -167,6 +168,7 @@ namespace BetterGenshinImpact.GameTask public void Stop() { _timer.Stop(); + ChatUiHotkeyGuard.Reset(); GameCapture?.Stop(); _gameRect = RECT.Empty; _prevGameActive = false; @@ -198,6 +200,8 @@ namespace BetterGenshinImpact.GameTask { _timer.Stop(); } + + ChatUiHotkeyGuard.Reset(); } public void Dispose() @@ -221,6 +225,7 @@ namespace BetterGenshinImpact.GameTask var maskWindow = MaskWindow.Instance(); if (GameCapture == null || !GameCapture.IsCapturing) { + ChatUiHotkeyGuard.Reset(); if (!TaskContext.Instance().SystemInfo.GameProcess.HasExited) { _logger.LogError("截图器未初始化!"); @@ -235,6 +240,14 @@ namespace BetterGenshinImpact.GameTask maskWindow.Invoke(maskWindow.HideSelf); return; } + + // 如果是最小化状态,直接不进行截图 + if (SystemControl.IsGenshinImpactMinimized()) + { + ChatUiHotkeyGuard.Reset(); + PictureInPictureService.Hide(); + return; + } // 检查游戏是否在前台 var hasBackgroundTriggerToRun = false; @@ -246,6 +259,7 @@ namespace BetterGenshinImpact.GameTask var active = SystemControl.IsGenshinImpactActive(); if (!active) { + ChatUiHotkeyGuard.Reset(); // 检查游戏是否已结束 if (TaskContext.Instance().SystemInfo.GameProcess.HasExited) { @@ -259,14 +273,11 @@ namespace BetterGenshinImpact.GameTask Debug.WriteLine("游戏窗口不在前台, 不再进行截屏"); } - if (!TaskContext.Instance().Config.MaskWindowConfig.UseSubform) + var pName = SystemControl.GetActiveProcessName(); + if (pName != "Idle" && pName != "BetterGI" && pName != "YuanShen" && pName != "GenshinImpact" && pName != "Genshin Impact Cloud Game") { - var pName = SystemControl.GetActiveProcessName(); - if (pName != "Idle" && pName != "BetterGI" && pName != "YuanShen" && pName != "GenshinImpact" && pName != "Genshin Impact Cloud Game") - { - // Debug.WriteLine(pName + ":hide mask window"); - maskWindow.Invoke(() => { maskWindow.HideSelf(); }); - } + // Debug.WriteLine(pName + ":hide mask window"); + maskWindow.Invoke(() => { maskWindow.HideSelf(); }); } _prevGameActive = active; @@ -302,23 +313,20 @@ namespace BetterGenshinImpact.GameTask else { PictureInPictureService.Hide(resetManual: true); - if (!TaskContext.Instance().Config.MaskWindowConfig.UseSubform) + // if (!_prevGameActive) + // { + maskWindow.Invoke(() => { - // if (!_prevGameActive) - // { - maskWindow.Invoke(() => + if (maskWindow.IsExist()) { - if (maskWindow.IsExist()) + maskWindow.Show(); + if (!_prevGameActive) { - maskWindow.Show(); - if (!_prevGameActive) - { - maskWindow.BringToTop(); - } + maskWindow.BringToTop(); } - }); - // } - } + } + }); + // } _prevGameActive = active; // // 移动游戏窗口的时候同步遮罩窗口的位置,此时不进行捕获 @@ -328,7 +336,8 @@ namespace BetterGenshinImpact.GameTask } } - if (_triggers == null || !_triggers.Exists(t => t.IsEnabled)) + var hasEnabledTriggers = _triggers != null && _triggers.Exists(t => t.IsEnabled); + if (!hasEnabledTriggers && !active) { // Debug.WriteLine("没有可用的触发器且不处于仅截屏状态, 不再进行截屏"); return; @@ -358,7 +367,13 @@ namespace BetterGenshinImpact.GameTask } // 循环执行所有触发器 有独占状态的触发器的时候只执行独占触发器 - var content = new CaptureContent(bitmap, _frameIndex, _timer.Interval); + using var content = new CaptureContent(bitmap, _frameIndex, _timer.Interval); + ChatUiHotkeyGuard.UpdateVisualState(Bv.DetectChatUi(content.CaptureRectArea)); + + if (!hasEnabledTriggers) + { + return; + } lock (_triggerListLocker) { @@ -404,7 +419,6 @@ namespace BetterGenshinImpact.GameTask } speedTimer.DebugPrint(); - content.Dispose(); } finally { @@ -528,4 +542,4 @@ namespace BetterGenshinImpact.GameTask } } } -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/Genshin/Settings2/GameSettingsChecker.cs b/BetterGenshinImpact/Genshin/Settings2/GameSettingsChecker.cs index 9faf8098..fbebed02 100644 --- a/BetterGenshinImpact/Genshin/Settings2/GameSettingsChecker.cs +++ b/BetterGenshinImpact/Genshin/Settings2/GameSettingsChecker.cs @@ -1,4 +1,4 @@ -using System; +using System; using BetterGenshinImpact.GameTask.Common; using BetterGenshinImpact.Genshin.Settings; using Microsoft.Extensions.Logging; @@ -37,6 +37,11 @@ public class GameSettingsChecker TaskControl.Logger.LogError("检测到游戏亮度非默认值,将会影响功能正常使用,请在原神 游戏设置——图像——亮度 中恢复默认亮度!"); } + if (settings.MiniMapConfig != 1) + { + TaskControl.Logger.LogWarning("检测到游戏小地图锁定配置不是【锁定方向】,无法正常使用地图追踪功能。请在原神 游戏设置——其他——小地图锁定 中调整为【锁定方向】!"); + } + if (inputSettings.MouseSenseIndex != 2 || inputSettings.MouseSenseIndexY != 2 || inputSettings.MouseFocusSenseIndex != 2 @@ -59,4 +64,4 @@ public class GameSettingsChecker TaskControl.Logger.LogDebug(e, "获取原神游戏设置失败"); } } -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/Genshin/Settings2/GenshinGameSettings.cs b/BetterGenshinImpact/Genshin/Settings2/GenshinGameSettings.cs index e154d362..7b27dd08 100644 --- a/BetterGenshinImpact/Genshin/Settings2/GenshinGameSettings.cs +++ b/BetterGenshinImpact/Genshin/Settings2/GenshinGameSettings.cs @@ -53,7 +53,7 @@ public class GenshinGameSettings public string GlobalPerfData { get; set; } // 全局性能数据 [JsonProperty("miniMapConfig")] - public int MiniMapConfig { get; set; } // 小地图配置 + public int MiniMapConfig { get; set; } // 小地图锁定 0:锁定玩家视角 1:锁定方向 [JsonProperty("enableCameraSlope")] public bool EnableCameraSlope { get; set; } // 启用相机坡度 diff --git a/BetterGenshinImpact/Helpers/CacheHelper.cs b/BetterGenshinImpact/Helpers/CacheHelper.cs deleted file mode 100644 index 10a06804..00000000 --- a/BetterGenshinImpact/Helpers/CacheHelper.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace BetterGenshinImpact.Helpers; - -public abstract class CacheHelper -{ - public class LruCache where TKey : notnull where TValue : class - { - private readonly int _capacity; - private readonly TimeSpan? _expireAfter; - private readonly Dictionary> _cacheMap; - private readonly LinkedList<(TKey Key, TValue Value, DateTime ExpireAt)> _lruList; - private readonly object _lock = new(); - - public LruCache(int capacity, TimeSpan? expireAfter = null) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); - _capacity = capacity; - _expireAfter = expireAfter; - _cacheMap = new Dictionary>(); - _lruList = []; - } - - public bool TryGet(TKey key, out TValue? value) - { - lock (_lock) - { - if (_cacheMap.TryGetValue(key, out var node)) - { - if (_expireAfter.HasValue && DateTime.UtcNow > node.Value.ExpireAt) - { - _lruList.Remove(node); - _cacheMap.Remove(key); - value = null; - return false; - } - _lruList.Remove(node); - _lruList.AddFirst(node); - value = node.Value.Value; - return true; - } - value = null; - return false; - } - } - - public void Set(TKey key, TValue value) - { - lock (_lock) - { - var expireAt = _expireAfter.HasValue ? DateTime.UtcNow.Add(_expireAfter.Value) : default; - - if (_cacheMap.TryGetValue(key, out var node)) - { - node.Value = (key, value, expireAt); - _lruList.Remove(node); - _lruList.AddFirst(node); - } - else - { - if (_cacheMap.Count >= _capacity) - { - var lru = _lruList.Last; - if (lru != null) - { - _cacheMap.Remove(lru.Value.Key); - _lruList.RemoveLast(); - } - } - var newNode = new LinkedListNode<(TKey, TValue, DateTime)>((key, value, expireAt)); - _lruList.AddFirst(newNode); - _cacheMap[key] = newNode; - } - } - } - - public bool Remove(TKey key) - { - lock (_lock) - { - if (!_cacheMap.TryGetValue(key, out var node)) return false; - _lruList.Remove(node); - _cacheMap.Remove(key); - return true; - } - } - - public int Count - { - get { lock (_lock) { return _cacheMap.Count; } } - } - - public void Clear() - { - lock (_lock) - { - _cacheMap.Clear(); - _lruList.Clear(); - } - } - } -} diff --git a/BetterGenshinImpact/Helpers/CommandLineOptions.cs b/BetterGenshinImpact/Helpers/CommandLineOptions.cs new file mode 100644 index 00000000..b48c40cc --- /dev/null +++ b/BetterGenshinImpact/Helpers/CommandLineOptions.cs @@ -0,0 +1,96 @@ +using System; +using System.Linq; + +namespace BetterGenshinImpact.Helpers; + +/// +/// 命令行参数统一解析,启动时解析一次,各处查询解析结果。 +/// +public class CommandLineOptions +{ + private static CommandLineOptions? _instance; + + public static CommandLineOptions Instance => _instance ??= Parse(Environment.GetCommandLineArgs()); + + public CommandLineAction Action { get; } + + /// + /// startOneDragon 时可选的配置名称(第 3 个参数) + /// + public string? OneDragonConfigName { get; } + + /// + /// --startGroups / --TaskProgress 时传入的组名列表(第 3 个参数起) + /// + public string[] GroupNames { get; } = []; + + /// + /// 是否有命令行任务参数(startOneDragon / --startGroups / --TaskProgress / start) + /// + public bool HasTaskArgs => Action != CommandLineAction.None; + + /// + /// 是否是需要 StartGameTask 自行处理游戏启动的命令 + /// (一条龙、配置组、任务进度由各自流程中的 StartGameTask 启动游戏) + /// + public bool ShouldDeferGameStart => Action is CommandLineAction.StartOneDragon + or CommandLineAction.StartGroups + or CommandLineAction.TaskProgress; + + private CommandLineOptions(CommandLineAction action, string? oneDragonConfigName = null, string[]? groupNames = null) + { + Action = action; + OneDragonConfigName = oneDragonConfigName; + GroupNames = groupNames ?? []; + } + + internal static CommandLineOptions Parse(string[] args) + { + if (args.Length <= 1) + return new CommandLineOptions(CommandLineAction.None); + + var arg1 = args[1].Trim(); + var extra = args.Skip(2).Select(x => x.Trim()).ToArray(); + + if (arg1.Contains("startOneDragon", StringComparison.OrdinalIgnoreCase)) + { + return new CommandLineOptions(CommandLineAction.StartOneDragon, + oneDragonConfigName: extra.Length > 0 ? extra[0] : null); + } + + if (arg1.Equals("--startGroups", StringComparison.OrdinalIgnoreCase)) + { + return new CommandLineOptions(CommandLineAction.StartGroups, groupNames: extra); + } + + if (arg1.Equals("--TaskProgress", StringComparison.OrdinalIgnoreCase)) + { + return new CommandLineOptions(CommandLineAction.TaskProgress, groupNames: extra); + } + + if (arg1.Contains("start", StringComparison.OrdinalIgnoreCase)) + { + return new CommandLineOptions(CommandLineAction.Start); + } + + return new CommandLineOptions(CommandLineAction.None); + } +} + +public enum CommandLineAction +{ + /// 双击启动,无命令行参数 + None, + + /// 纯 "start" — 仅启动截图器 + Start, + + /// startOneDragon — 启动一条龙 + StartOneDragon, + + /// --startGroups — 启动调度组 + StartGroups, + + /// --TaskProgress — 启动任务进度 + TaskProgress, +} diff --git a/BetterGenshinImpact/Helpers/DpiAwareness/DpiAwarenessController.cs b/BetterGenshinImpact/Helpers/DpiAwareness/DpiAwarenessController.cs index 604fdbfe..233a838c 100644 --- a/BetterGenshinImpact/Helpers/DpiAwareness/DpiAwarenessController.cs +++ b/BetterGenshinImpact/Helpers/DpiAwareness/DpiAwarenessController.cs @@ -40,8 +40,7 @@ internal class DpiAwarenessController } else{ SHCore - .SetProcessDpiAwareness(SHCore.PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE) - .ThrowIfFailed(); + .SetProcessDpiAwareness(SHCore.PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE); } } diff --git a/BetterGenshinImpact/Helpers/DpiHelper.cs b/BetterGenshinImpact/Helpers/DpiHelper.cs index 7abd2afb..58226bf7 100644 --- a/BetterGenshinImpact/Helpers/DpiHelper.cs +++ b/BetterGenshinImpact/Helpers/DpiHelper.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Windows; using System.Windows.Interop; +using BetterGenshinImpact.GameTask; using Vanara.PInvoke; namespace BetterGenshinImpact.Helpers; @@ -18,7 +19,16 @@ public class DpiHelper if (Environment.OSVersion.Version >= new Version(6, 3) && UIDispatcherHelper.MainWindow != null) { - HWND hWnd = new WindowInteropHelper(Application.Current?.MainWindow).Handle; + HWND hWnd = HWND.NULL; + if (TaskContext.Instance().IsInitialized) + { + hWnd = TaskContext.Instance().GameHandle; + } + else + { + hWnd = new WindowInteropHelper(Application.Current?.MainWindow).Handle; + } + HMONITOR hMonitor = User32.MonitorFromWindow(hWnd, User32.MonitorFlags.MONITOR_DEFAULTTONEAREST); SHCore.GetDpiForMonitor(hMonitor, SHCore.MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out _, out uint dpiY); return dpiY / 96f; diff --git a/BetterGenshinImpact/Helpers/Ui/WindowHelper.cs b/BetterGenshinImpact/Helpers/Ui/WindowHelper.cs index d7655fd2..0b859e93 100644 --- a/BetterGenshinImpact/Helpers/Ui/WindowHelper.cs +++ b/BetterGenshinImpact/Helpers/Ui/WindowHelper.cs @@ -1,4 +1,6 @@ -using System.Diagnostics; +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; using System.Windows.Media; using BetterGenshinImpact.Core.Config; using BetterGenshinImpact.GameTask; @@ -9,6 +11,8 @@ namespace BetterGenshinImpact.Helpers.Ui; public class WindowHelper { + private const uint DesktopCompositionDisabledHResult = 0x80263001; + public static void TryApplySystemBackdrop(System.Windows.Window window) { var themeType = TaskContext.Instance().Config.CommonConfig.CurrentThemeType; @@ -37,6 +41,22 @@ public class WindowHelper /// 要应用主题的窗口 /// 主题类型 public static void ApplyThemeToWindow(System.Windows.Window window, ThemeType themeType) + { + try + { + ApplyThemeCore(window, themeType); + } + catch (COMException ex) when ((uint)ex.HResult == DesktopCompositionDisabledHResult) + { + ApplyFallbackTheme(window, themeType); + } + catch + { + ApplyFallbackTheme(window, themeType); + } + } + + private static void ApplyThemeCore(System.Windows.Window window, ThemeType themeType) { switch (themeType) { @@ -76,4 +96,21 @@ public class WindowHelper break; } } -} \ No newline at end of file + + private static void ApplyFallbackTheme(System.Windows.Window window, ThemeType themeType) + { + window.Background = new SolidColorBrush(GetFallbackBackgroundColor(themeType)); + WindowBackdrop.ApplyBackdrop(window, WindowBackdropType.None); + } + + private static Color GetFallbackBackgroundColor(ThemeType themeType) + { + return themeType switch + { + ThemeType.LightNone => Color.FromArgb(255, 243, 243, 243), + ThemeType.LightMica => Color.FromArgb(255, 243, 243, 243), + ThemeType.LightAcrylic => Color.FromArgb(255, 243, 243, 243), + _ => Color.FromArgb(255, 32, 32, 32) + }; + } +} diff --git a/BetterGenshinImpact/Model/HotKeySettingModel.cs b/BetterGenshinImpact/Model/HotKeySettingModel.cs index f8519ec1..ce134e22 100644 --- a/BetterGenshinImpact/Model/HotKeySettingModel.cs +++ b/BetterGenshinImpact/Model/HotKeySettingModel.cs @@ -1,4 +1,7 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.GameTask; +using BetterGenshinImpact.GameTask.AutoFight; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Fischless.HotkeyCapture; using System; @@ -108,7 +111,8 @@ public partial class HotKeySettingModel : ObservableObject { MouseMonitorHook = new MouseHook { - IsHold = IsHold + IsHold = IsHold, + ConfigPropertyName = ConfigPropertyName }; if (OnKeyPressAction != null) @@ -138,7 +142,8 @@ public partial class HotKeySettingModel : ObservableObject } KeyboardMonitorHook = new KeyboardHook { - IsHold = IsHold + IsHold = IsHold, + ConfigPropertyName = ConfigPropertyName }; if (OnKeyPressAction != null) { @@ -169,19 +174,48 @@ public partial class HotKeySettingModel : ObservableObject private void OnKeyPressed(object? sender, KeyPressedEventArgs e) { + if (ShouldBlockGlobalRegister()) + { + return; + } + OnKeyPressAction?.Invoke(sender, e); } private void OnKeyDown(object? sender, KeyPressedEventArgs e) { + if (ShouldBlockGlobalRegister()) + { + return; + } + OnKeyDownAction?.Invoke(sender, e); } private void OnKeyUp(object? sender, KeyPressedEventArgs e) { + if (ShouldBlockGlobalRegister()) + { + ResetBlockedKeyUpState(); + return; + } + OnKeyUpAction?.Invoke(sender, e); } + private bool ShouldBlockGlobalRegister() + { + return HotKeyType == HotKeyTypeEnum.GlobalRegister && ChatUiHotkeyGuard.ShouldBlockHotkey(ConfigPropertyName); + } + + private void ResetBlockedKeyUpState() + { + if (string.Equals(ConfigPropertyName, nameof(HotKeyConfig.OneKeyFightHotkey), StringComparison.Ordinal)) + { + OneKeyFightTask.Instance.KeyUp(); + } + } + public void UnRegisterHotKey() { GlobalRegisterHook?.Dispose(); diff --git a/BetterGenshinImpact/Model/KeyboardHook.cs b/BetterGenshinImpact/Model/KeyboardHook.cs index 9021255b..5c0dc5c9 100644 --- a/BetterGenshinImpact/Model/KeyboardHook.cs +++ b/BetterGenshinImpact/Model/KeyboardHook.cs @@ -2,6 +2,7 @@ using Fischless.HotkeyCapture; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using Vanara.PInvoke; @@ -24,6 +25,8 @@ public class KeyboardHook public bool IsPressed { get; set; } + public string ConfigPropertyName { get; set; } = string.Empty; + /// /// 注意长按的时候会一直触发KeyDown /// @@ -38,6 +41,11 @@ public class KeyboardHook if (e.KeyCode == BindKey) { + if (ChatUiHotkeyGuard.ShouldBlockHotkey(ConfigPropertyName)) + { + return; + } + IsPressed = true; KeyDownEvent?.Invoke(this, new KeyPressedEventArgs(User32.HotKeyModifiers.MOD_NONE, e.KeyCode)); if (IsHold) @@ -65,6 +73,12 @@ public class KeyboardHook { while (IsPressed && KeyPressedEvent != null) { + if (ChatUiHotkeyGuard.ShouldBlockHotkey(ConfigPropertyName)) + { + Thread.Sleep(10); + continue; + } + KeyPressedEvent?.Invoke(this, new KeyPressedEventArgs(User32.HotKeyModifiers.MOD_NONE, e.KeyCode)); } } @@ -75,7 +89,7 @@ public class KeyboardHook if (e.KeyCode == BindKey) { IsPressed = false; - if (SystemControl.IsGenshinImpactActive()) + if (SystemControl.IsGenshinImpactActive() && !ChatUiHotkeyGuard.ShouldBlockHotkey(ConfigPropertyName)) { KeyUpEvent?.Invoke(this, new KeyPressedEventArgs(User32.HotKeyModifiers.MOD_NONE, e.KeyCode)); } diff --git a/BetterGenshinImpact/Model/MaskMap/MaskMapPoint.cs b/BetterGenshinImpact/Model/MaskMap/MaskMapPoint.cs index 773439b1..76b95352 100644 --- a/BetterGenshinImpact/Model/MaskMap/MaskMapPoint.cs +++ b/BetterGenshinImpact/Model/MaskMap/MaskMapPoint.cs @@ -19,12 +19,12 @@ public class MaskMapPoint public double GameY { get; set; } /// - /// 游戏图像地图的坐标 X + /// 2048级别游戏图像地图的坐标 X /// public double ImageX { get; set; } /// - /// 游戏图像地图的坐标 Y + /// 2048级别游戏图像地图的坐标 Y /// public double ImageY { get; set; } diff --git a/BetterGenshinImpact/Model/MouseHook.cs b/BetterGenshinImpact/Model/MouseHook.cs index f9b336ec..d5e110d2 100644 --- a/BetterGenshinImpact/Model/MouseHook.cs +++ b/BetterGenshinImpact/Model/MouseHook.cs @@ -3,6 +3,7 @@ using Fischless.HotkeyCapture; using Gma.System.MouseKeyHook; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using Vanara.PInvoke; @@ -25,6 +26,8 @@ public class MouseHook public bool IsPressed { get; set; } + public string ConfigPropertyName { get; set; } = string.Empty; + public void MouseDown(object? sender, MouseEventExtArgs e) { if (!SystemControl.IsGenshinImpactActive()) @@ -34,6 +37,11 @@ public class MouseHook if (e.Button != MouseButtons.Left && e.Button != MouseButtons.None && e.Button == BindMouse) { + if (ChatUiHotkeyGuard.ShouldBlockHotkey(ConfigPropertyName)) + { + return; + } + IsPressed = true; MouseDownEvent?.Invoke(this, new KeyPressedEventArgs(User32.HotKeyModifiers.MOD_NONE, Keys.None)); if (IsHold) @@ -58,6 +66,12 @@ public class MouseHook { while (IsPressed) { + if (ChatUiHotkeyGuard.ShouldBlockHotkey(ConfigPropertyName)) + { + Thread.Sleep(10); + continue; + } + MousePressed?.Invoke(this, new KeyPressedEventArgs(User32.HotKeyModifiers.MOD_NONE, Keys.None)); } } @@ -68,7 +82,7 @@ public class MouseHook if (e.Button != MouseButtons.Left && e.Button != MouseButtons.None && e.Button == BindMouse) { IsPressed = false; - if (SystemControl.IsGenshinImpactActive()) + if (SystemControl.IsGenshinImpactActive() && !ChatUiHotkeyGuard.ShouldBlockHotkey(ConfigPropertyName)) { MouseUpEvent?.Invoke(this, new KeyPressedEventArgs(User32.HotKeyModifiers.MOD_NONE, Keys.None)); } diff --git a/BetterGenshinImpact/Model/OneDragonTaskItem.cs b/BetterGenshinImpact/Model/OneDragonTaskItem.cs index 23cb792a..0dd568f1 100644 --- a/BetterGenshinImpact/Model/OneDragonTaskItem.cs +++ b/BetterGenshinImpact/Model/OneDragonTaskItem.cs @@ -127,7 +127,9 @@ public partial class OneDragonTaskItem : ObservableObject return; } - await new AutoStygianOnslaughtTask(TaskContext.Instance().Config.AutoStygianOnslaughtConfig, path).Start(CancellationContext.Instance.Cts.Token); + AutoStygianOnslaughtParam param = new AutoStygianOnslaughtParam(); + param.SetAutoStygianOnslaughtConfig(TaskContext.Instance().Config.AutoStygianOnslaughtConfig); + await new AutoStygianOnslaughtTask(param, path).Start(CancellationContext.Instance.Cts.Token); }; break; case "领取每日奖励": @@ -171,7 +173,10 @@ public partial class OneDragonTaskItem : ObservableObject { taskConfig.Count = config.LeyLineRunCount; } - await new AutoLeyLineOutcropTask(taskConfig, config.LeyLineOneDragonMode) + + AutoLeyLineOutcropParam param = new AutoLeyLineOutcropParam(); + param.SetAutoLeyLineOutcropConfig(taskConfig); + await new AutoLeyLineOutcropTask(param, config.LeyLineOneDragonMode) .Start(CancellationContext.Instance.Cts.Token); } finally diff --git a/BetterGenshinImpact/Service/ApplicationHostService.cs b/BetterGenshinImpact/Service/ApplicationHostService.cs index a05c9c60..5baa95b4 100644 --- a/BetterGenshinImpact/Service/ApplicationHostService.cs +++ b/BetterGenshinImpact/Service/ApplicationHostService.cs @@ -9,8 +9,7 @@ using System.Threading.Tasks; using System.Windows; using BetterGenshinImpact.Core.Script; using BetterGenshinImpact.GameTask; -using BetterGenshinImpact.GameTask.Common; -using Microsoft.Extensions.Logging; +using BetterGenshinImpact.Helpers; using Wpf.Ui; namespace BetterGenshinImpact.Service; @@ -49,66 +48,56 @@ public class ApplicationHostService(IServiceProvider serviceProvider) : IHostedS { _navigationWindow = (serviceProvider.GetService(typeof(INavigationWindow)) as INavigationWindow)!; _navigationWindow!.ShowWindow(); - // - var args = Environment.GetCommandLineArgs(); - if (args.Length > 1) + var cmdOptions = CommandLineOptions.Instance; + + if (cmdOptions.HasTaskArgs) { - //无论如何,先跳到主页,否则在通过参数的任务在执行完之前,不会加载快捷键 _ = _navigationWindow.Navigate(typeof(HomePage)); - // 命令行启动时,先等待自动更新订阅脚本完成,再运行配置组/一条龙 - // (正常双击启动在 MainWindowViewModel.OnLoaded 中以 fire-and-forget 方式调用) + // 命令行启动时,并行更新订阅脚本(不阻塞游戏启动和导航) + // StartGameTask 会在游戏进入主界面后等待此 Task 完成,再开始执行任务 var scriptConfig = TaskContext.Instance().Config.ScriptConfig; if (scriptConfig.AutoUpdateBeforeCommandLineRun) { - await Task.Run(() => ScriptRepoUpdater.Instance.AutoUpdateSubscribedScripts()); + ScriptRepoUpdater.Instance.CommandLineAutoUpdateTask = + Task.Run(() => ScriptRepoUpdater.Instance.AutoUpdateSubscribedScripts()); } - if (args[1].Contains("startOneDragon", StringComparison.InvariantCultureIgnoreCase)) + switch (cmdOptions.Action) { + case CommandLineAction.StartOneDragon: + // 通过命令行参数启动「一条龙」 => 跳转到一条龙配置页。 + _ = _navigationWindow.Navigate(typeof(OneDragonFlowPage)); + // 后续代码在 OneDragonFlowViewModel / OnLoaded 中。 + break; - // 通过命令行参数启动「一条龙」 => 跳转到一条龙配置页。 - _ = _navigationWindow.Navigate(typeof(OneDragonFlowPage)); - // 后续代码在 OneDragonFlowViewModel / OnLoaded 中。 - } - else if (args[1].Trim().Equals("--startGroups", StringComparison.InvariantCultureIgnoreCase)) - { - // 通过命令行参数启动「调度组」 => 跳转到调度器配置页。 - _ = _navigationWindow.Navigate(typeof(ScriptControlPage)); - if (args.Length > 2) - { - // 获取调度组 - var names = args.Skip(2).ToArray().Select(x => x.Trim()).ToArray(); - // 启动调度器 - var scheduler = App.GetService(); - scheduler?.OnStartMultiScriptGroupWithNamesAsync(names); - } - }else if (args[1].Trim().Equals("--TaskProgress", StringComparison.InvariantCultureIgnoreCase)) - { + case CommandLineAction.StartGroups: + // 通过命令行参数启动「调度组」 => 跳转到调度器配置页。 + _ = _navigationWindow.Navigate(typeof(ScriptControlPage)); + if (cmdOptions.GroupNames.Length > 0) + { + var scheduler = App.GetService(); + scheduler?.OnStartMultiScriptGroupWithNamesAsync(cmdOptions.GroupNames); + } + break; - // 通过命令行参数启动「调度组」 => 跳转到调度器配置页。 - _ = _navigationWindow.Navigate(typeof(ScriptControlPage)); - if (args.Length > 1) - { - // 获取调度组 - var names = args.Skip(2).ToArray().Select(x => x.Trim()).ToArray(); - // 启动调度器 - var scheduler = App.GetService(); - scheduler?.OnStartMultiScriptTaskProgressAsync(names); - } - } - else if (args[1].Contains("start")) - { - // 通过命令行参数打开「启动页开关」 => 跳转到主页。 - _ = _navigationWindow.Navigate(typeof(HomePage)); - // 后续代码在 HomePageViewModel / OnLoaded 中。 - } - else - { - // 其它命令行参数 => 跳转到主页。 - _ = _navigationWindow.Navigate(typeof(HomePage)); + case CommandLineAction.TaskProgress: + // 通过命令行参数启动「任务进度」 => 跳转到调度器配置页。 + _ = _navigationWindow.Navigate(typeof(ScriptControlPage)); + if (cmdOptions.GroupNames.Length > 0) + { + var scheduler = App.GetService(); + scheduler?.OnStartMultiScriptTaskProgressAsync(cmdOptions.GroupNames); + } + break; + + case CommandLineAction.Start: + // 通过命令行参数打开「启动页开关」 => 跳转到主页。 + _ = _navigationWindow.Navigate(typeof(HomePage)); + // 后续代码在 HomePageViewModel / OnLoaded 中。 + break; } } else diff --git a/BetterGenshinImpact/Service/ConfigService.cs b/BetterGenshinImpact/Service/ConfigService.cs index 1996dc8e..8aed9afa 100644 --- a/BetterGenshinImpact/Service/ConfigService.cs +++ b/BetterGenshinImpact/Service/ConfigService.cs @@ -1,11 +1,13 @@ -using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Core.Config; using BetterGenshinImpact.Service.Interface; +using BetterGenshinImpact.View.Windows; using OpenCvSharp; using System; using System.IO; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; +using Application = System.Windows.Application; namespace BetterGenshinImpact.Service; @@ -13,6 +15,8 @@ public class ConfigService : IConfigService { private readonly object _locker = new(); // 只有UI线程会调用这个方法,lock好像意义不大,而且浪费了下面的读写锁hhh private readonly ReaderWriterLockSlim _rwLock = new(); + private const string ConfigRelativePath = @"User/config.json"; + private const string BackupFolderName = "backup"; public static readonly JsonSerializerOptions JsonOptions = new() { @@ -61,9 +65,9 @@ public class ConfigService : IConfigService public AllConfig Read() { _rwLock.EnterReadLock(); + var filePath = Global.Absolute(ConfigRelativePath); try { - var filePath = Global.Absolute(@"User/config.json"); if (!File.Exists(filePath)) { return new AllConfig(); @@ -83,6 +87,8 @@ public class ConfigService : IConfigService { Console.WriteLine(e.Message); Console.WriteLine(e.StackTrace); + BackupConfigFile(filePath); + ShowConfigExceptionDialog("读取", e); return new AllConfig(); } finally @@ -94,6 +100,7 @@ public class ConfigService : IConfigService public void Write(AllConfig config) { _rwLock.EnterWriteLock(); + var file = Global.Absolute(ConfigRelativePath); try { var path = Global.Absolute("User"); @@ -102,19 +109,62 @@ public class ConfigService : IConfigService Directory.CreateDirectory(path); } - var file = Path.Combine(path, "config.json"); File.WriteAllText(file, JsonSerializer.Serialize(config, JsonOptions)); } catch (Exception e) { Console.WriteLine(e.Message); Console.WriteLine(e.StackTrace); + ShowConfigExceptionDialog("写入", e); } finally { _rwLock.ExitWriteLock(); } } + + private static void BackupConfigFile(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + return; + } + + var directoryPath = Path.GetDirectoryName(filePath); + if (string.IsNullOrWhiteSpace(directoryPath)) + { + return; + } + + var backupDirectory = Path.Combine(directoryPath, BackupFolderName); + Directory.CreateDirectory(backupDirectory); + + var backupFileName = $"config_{DateTime.Now:yyyyMMdd_HHmmss_fff}.json.bak"; + var backupFilePath = Path.Combine(backupDirectory, backupFileName); + File.Copy(filePath, backupFilePath, false); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + Console.WriteLine(ex.StackTrace); + } + } + + private static void ShowConfigExceptionDialog(string operation, Exception exception) + { + var current = Application.Current; + if (current?.Dispatcher == null) + { + return; + } + + var coreException = exception.GetBaseException(); + var coreStack = string.IsNullOrWhiteSpace(coreException.StackTrace) ? "无可用堆栈信息" : coreException.StackTrace; + var message = $"配置文件{operation}失败\n错误:{coreException.Message}\n堆栈:\n{coreStack}"; + _ = ThemedMessageBox.ErrorAsync(message, "配置文件异常"); + } } public class OpenCvRectJsonConverter : JsonConverter diff --git a/BetterGenshinImpact/Service/HoYoLabMapApiService.cs b/BetterGenshinImpact/Service/HoYoLabMapApiService.cs new file mode 100644 index 00000000..da7bc724 --- /dev/null +++ b/BetterGenshinImpact/Service/HoYoLabMapApiService.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using BetterGenshinImpact.Helpers.Http; +using BetterGenshinImpact.GameTask; +using BetterGenshinImpact.GameTask.MapMask; +using BetterGenshinImpact.Service.Interface; +using BetterGenshinImpact.Service.Model.MihoyoMap.Requests; +using BetterGenshinImpact.Service.Model.MihoyoMap.Responses; +using Newtonsoft.Json; + +namespace BetterGenshinImpact.Service; + +public class HoYoLabMapApiService : IHoYoLabMapApiService +{ + private readonly HttpClient _httpClient; + private const string TreeEndpoint = "https://sg-public-api-static.hoyolab.com/common/map_user/ys_obc/v2/map/label/tree"; + private const string ListEndpoint = "https://sg-public-api-static.hoyolab.com/common/map_user/ys_obc/v3/map/point/list"; + private const string InfoEndpoint = "https://sg-public-api-static.hoyolab.com/common/map_user/ys_obc/v1/map/point/info"; + private const string DefaultLang = MapMaskConfig.HoYoLabLanguageEnUs; + + public HoYoLabMapApiService() + { + _httpClient = HttpClientFactory.GetCommonSendClient(); + } + + private static HttpRequestMessage CreateRequest(HttpMethod method, string url) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); + request.Headers.Referrer = new Uri("https://act.hoyolab.com/"); + return request; + } + + private static T DeserializeRequired(string json) + { + var result = JsonConvert.DeserializeObject(json); + if (result == null) + { + throw new JsonException($"Failed to deserialize {typeof(T).Name}. The API returned an empty or invalid JSON."); + } + + return result; + } + + public static string NormalizeLanguage(string? lang) + { + var normalized = (lang ?? string.Empty).Trim().Replace('_', '-').Replace(' ', '-').ToLowerInvariant(); + return normalized switch + { + MapMaskConfig.HoYoLabLanguagePtPt => MapMaskConfig.HoYoLabLanguagePtPt, + MapMaskConfig.HoYoLabLanguageEsEs => MapMaskConfig.HoYoLabLanguageEsEs, + MapMaskConfig.HoYoLabLanguageEnUs => MapMaskConfig.HoYoLabLanguageEnUs, + _ => DefaultLang + }; + } + + private static string GetCurrentLanguage() + { + var lang = TaskContext.Instance().Config.MapMaskConfig.HoYoLabLanguage; + return NormalizeLanguage(lang); + } + + public async Task> GetLabelTreeAsync(LabelTreeRequest request, CancellationToken ct = default) + { + var lang = GetCurrentLanguage(); + var url = $"{TreeEndpoint}?map_id={request.MapId}&app_sn={Uri.EscapeDataString(request.AppSn)}&lang={lang}"; + using var httpRequest = CreateRequest(HttpMethod.Get, url); + using var resp = await _httpClient.SendAsync(httpRequest, ct); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(ct); + return DeserializeRequired>(json); + } + + public async Task> GetPointInfoAsync(PointInfoRequest request, CancellationToken ct = default) + { + var lang = GetCurrentLanguage(); + var url = $"{InfoEndpoint}?map_id={request.MapId}&point_id={request.PointId}&app_sn={Uri.EscapeDataString(request.AppSn)}&lang={lang}"; + using var httpRequest = CreateRequest(HttpMethod.Get, url); + httpRequest.Headers.Add("x-rpc-map_version", "4.5"); + using var resp = await _httpClient.SendAsync(httpRequest, ct); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(ct); + return DeserializeRequired>(json); + } + + public async Task> GetPointListAsync(PointListRequest request, CancellationToken ct = default) + { + var lang = GetCurrentLanguage(); + var labelIds = request.LabelIds != null && request.LabelIds.Count > 0 + ? string.Join(",", request.LabelIds.Select(x => x.ToString())) + : string.Empty; + var url = $"{ListEndpoint}?map_id={request.MapId}&app_sn={Uri.EscapeDataString(request.AppSn)}&lang={lang}&label_ids={Uri.EscapeDataString(labelIds)}"; + using var httpRequest = CreateRequest(HttpMethod.Get, url); + using var resp = await _httpClient.SendAsync(httpRequest, ct); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(ct); + return DeserializeRequired>(json); + } +} \ No newline at end of file diff --git a/BetterGenshinImpact/Service/Interface/IHoYoLabMapApiService.cs b/BetterGenshinImpact/Service/Interface/IHoYoLabMapApiService.cs new file mode 100644 index 00000000..7485cf4e --- /dev/null +++ b/BetterGenshinImpact/Service/Interface/IHoYoLabMapApiService.cs @@ -0,0 +1,5 @@ +namespace BetterGenshinImpact.Service.Interface; + +public interface IHoYoLabMapApiService : IMihoyoMapApiService +{ +} \ No newline at end of file diff --git a/BetterGenshinImpact/Service/MaskMapPointService.cs b/BetterGenshinImpact/Service/MaskMapPointService.cs index 2043b653..9578079e 100644 --- a/BetterGenshinImpact/Service/MaskMapPointService.cs +++ b/BetterGenshinImpact/Service/MaskMapPointService.cs @@ -44,17 +44,20 @@ public sealed class MaskMapPointService : IMaskMapPointService private readonly ILogger _logger; private readonly IAppCache _cache; + private readonly IHoYoLabMapApiService _hoyolabMapApi; private readonly IMihoyoMapApiService _mihoyoMapApi; private readonly IKongyingTavernApiService _kongyingTavernApi; public MaskMapPointService( ILogger logger, IAppCache cache, + IHoYoLabMapApiService hoyolabMapApi, IMihoyoMapApiService mihoyoMapApi, IKongyingTavernApiService kongyingTavernApi) { _logger = logger; _cache = cache; + _hoyolabMapApi = hoyolabMapApi; _mihoyoMapApi = mihoyoMapApi; _kongyingTavernApi = kongyingTavernApi; } @@ -91,12 +94,17 @@ public sealed class MaskMapPointService : IMaskMapPointService return TaskContext.Instance().Config.MapMaskConfig.MapPointApiProvider; } + private IMihoyoMapApiService GetMihoyoCompatibleApi() + { + return GetProvider() == MapPointApiProvider.HoYoLab ? _hoyolabMapApi : _mihoyoMapApi; + } + private async Task> GetMihoyoLabelCategoriesAsync(CancellationToken ct) { ApiResponse? resp = null; try { - resp = await _mihoyoMapApi.GetLabelTreeAsync(new LabelTreeRequest(), ct); + resp = await GetMihoyoCompatibleApi().GetLabelTreeAsync(new LabelTreeRequest(), ct); } catch (Exception ex) { @@ -230,18 +238,24 @@ public sealed class MaskMapPointService : IMaskMapPointService private Task> GetMihoyoPointListCacheAsync(IReadOnlyList parentLabelIds, CancellationToken ct) { var labelIds = parentLabelIds?.Distinct().OrderBy(x => x).ToArray() ?? Array.Empty(); - var key = $"mihoyo-map:point-list:2:ys_obc:zh-cn:{string.Join(",", labelIds)}"; + var provider = GetProvider(); + var providerKey = provider == MapPointApiProvider.HoYoLab ? "hoyolab" : "mihoyo-map"; + var langSegment = provider == MapPointApiProvider.HoYoLab + ? $":lang:{HoYoLabMapApiService.NormalizeLanguage(TaskContext.Instance().Config.MapMaskConfig.HoYoLabLanguage)}" + : string.Empty; + var key = $"{providerKey}:point-list:2:ys_obc{langSegment}:{string.Join(",", labelIds)}"; var request = new PointListRequest { LabelIds = labelIds.ToList() }; + var api = GetMihoyoCompatibleApi(); return _cache.GetOrAddAsync( key, async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; - return await _mihoyoMapApi.GetPointListAsync(request, CancellationToken.None); + return await api.GetPointListAsync(request, CancellationToken.None); }) .WaitAsync(ct); } @@ -255,7 +269,7 @@ public sealed class MaskMapPointService : IMaskMapPointService try { - var resp = await _mihoyoMapApi.GetPointInfoAsync(new PointInfoRequest { PointId = pointId }, ct); + var resp = await GetMihoyoCompatibleApi().GetPointInfoAsync(new PointInfoRequest { PointId = pointId }, ct); if (resp.Retcode != 0 || resp.Data == null) { return new MaskMapPointInfo { Text = $"查询失败: {resp.Retcode} {resp.Message}" }; diff --git a/BetterGenshinImpact/Service/Notification/Model/Enum/NotificationEvent.cs b/BetterGenshinImpact/Service/Notification/Model/Enum/NotificationEvent.cs index ec1a82de..7201e894 100644 --- a/BetterGenshinImpact/Service/Notification/Model/Enum/NotificationEvent.cs +++ b/BetterGenshinImpact/Service/Notification/Model/Enum/NotificationEvent.cs @@ -1,7 +1,23 @@ -namespace BetterGenshinImpact.Service.Notification.Model.Enum; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace BetterGenshinImpact.Service.Notification.Model.Enum; public class NotificationEvent(string code, string msg) { + private static readonly Lazy> AllEvents = new(() => + typeof(NotificationEvent) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(field => field.FieldType == typeof(NotificationEvent)) + .OrderBy(field => field.MetadataToken) + .Select(field => field.GetValue(null)) + .OfType() + .GroupBy(notificationEvent => notificationEvent.Code, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray()); + public static readonly NotificationEvent Test = new("notify.test", "测试通知"); public static readonly NotificationEvent DomainReward = new("domain.reward", "自动秘境奖励"); public static readonly NotificationEvent DomainStart = new("domain.start", "自动秘境启动"); @@ -24,7 +40,12 @@ public class NotificationEvent(string code, string msg) public static readonly NotificationEvent AutoEatStart = new("autoeat.start", "自动吃药启动"); public static readonly NotificationEvent AutoEatEnd = new("autoeat.end", "自动吃药结束"); public static readonly NotificationEvent AutoEatInfo = new("autoeat.info", "自动吃药信息"); - + + public static IReadOnlyList GetAll() + { + return AllEvents.Value; + } + public string Code { get; private set; } = code; public string Msg { get; private set; } = msg; } diff --git a/BetterGenshinImpact/Service/Notification/NotificationConfig.cs b/BetterGenshinImpact/Service/Notification/NotificationConfig.cs index 0bd1a7e6..c70948b8 100644 --- a/BetterGenshinImpact/Service/Notification/NotificationConfig.cs +++ b/BetterGenshinImpact/Service/Notification/NotificationConfig.cs @@ -268,7 +268,7 @@ public partial class NotificationConfig : ObservableObject /// /// Discord Webhook头像地址 - /// Default url from https://bettergi.com/ + /// Default url from https://www.bettergi.com/ /// [ObservableProperty] private string _discordWebhookAvatarUrl = "https://img.alicdn.com/imgextra/i2/2042484851/O1CN01LQfLIG1lhoEZwz1Gt_!!2042484851.png"; diff --git a/BetterGenshinImpact/Service/Notification/NotificationEventSubscriptionHelper.cs b/BetterGenshinImpact/Service/Notification/NotificationEventSubscriptionHelper.cs new file mode 100644 index 00000000..50cbe7fc --- /dev/null +++ b/BetterGenshinImpact/Service/Notification/NotificationEventSubscriptionHelper.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +namespace BetterGenshinImpact.Service.Notification; + +public static class NotificationEventSubscriptionHelper +{ + public static IReadOnlyList ParseEventCodes(string? subscribeEventStr) + { + if (string.IsNullOrWhiteSpace(subscribeEventStr)) + { + return Array.Empty(); + } + + var eventCodes = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var eventCode in subscribeEventStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (string.IsNullOrWhiteSpace(eventCode)) + { + continue; + } + + if (!seen.Add(eventCode)) + { + continue; + } + + eventCodes.Add(eventCode); + } + + return eventCodes; + } + + public static string NormalizeEventCodes(IEnumerable? eventCodes) + { + if (eventCodes == null) + { + return string.Empty; + } + + var normalizedEventCodes = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var eventCode in eventCodes) + { + if (string.IsNullOrWhiteSpace(eventCode)) + { + continue; + } + + var trimmedCode = eventCode.Trim(); + if (!seen.Add(trimmedCode)) + { + continue; + } + + normalizedEventCodes.Add(trimmedCode); + } + + return string.Join(',', normalizedEventCodes); + } + + public static bool ShouldSendNotification(string? subscribeEventStr, string? eventCode) + { + if (string.IsNullOrWhiteSpace(subscribeEventStr)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(eventCode)) + { + return false; + } + + foreach (var subscribeEventCode in ParseEventCodes(subscribeEventStr)) + { + if (string.Equals(subscribeEventCode, eventCode, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/BetterGenshinImpact/Service/Notification/NotificationService.cs b/BetterGenshinImpact/Service/Notification/NotificationService.cs index 6f887c84..1fbcaae2 100644 --- a/BetterGenshinImpact/Service/Notification/NotificationService.cs +++ b/BetterGenshinImpact/Service/Notification/NotificationService.cs @@ -424,10 +424,9 @@ public class NotificationService : IHostedService, IDisposable /// private bool ShouldSendNotification(string eventCode) { - var subscribeEventStr = _notificationConfig?.NotificationEventSubscribe; - if (string.IsNullOrEmpty(subscribeEventStr)) return true; - - return subscribeEventStr.Contains(eventCode); + return NotificationEventSubscriptionHelper.ShouldSendNotification( + _notificationConfig?.NotificationEventSubscribe, + eventCode); } /// @@ -476,4 +475,4 @@ public class NotificationService : IHostedService, IDisposable } }); } -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/Service/ScriptService.cs b/BetterGenshinImpact/Service/ScriptService.cs index ec6ac093..f30fd8eb 100644 --- a/BetterGenshinImpact/Service/ScriptService.cs +++ b/BetterGenshinImpact/Service/ScriptService.cs @@ -420,7 +420,7 @@ public partial class ScriptService : IScriptService // 还原定时器 - TaskTriggerDispatcher.Instance().SetTriggers(GameTaskManager.LoadInitialTriggers()); + // TaskTriggerDispatcher.Instance().SetTriggers(GameTaskManager.LoadInitialTriggers()); if (!string.IsNullOrEmpty(groupName)&&!RunnerContext.Instance.IsPreExecution) { @@ -621,5 +621,13 @@ public partial class ScriptService : IScriptService }); } } + + // 等待命令行启动时并行执行的自动更新完成(如果有) + var pendingUpdate = ScriptRepoUpdater.Instance.CommandLineAutoUpdateTask; + if (pendingUpdate != null) + { + await pendingUpdate; + ScriptRepoUpdater.Instance.CommandLineAutoUpdateTask = null; + } } } diff --git a/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs b/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs index 48db465f..768a8192 100644 --- a/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs +++ b/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs @@ -47,6 +47,11 @@ public sealed class SupabaseMissingTranslationReporter : IMissingTranslationRepo return false; } + if (ShouldSkipReporting(language, sourceInfo)) + { + return false; + } + return _channel.Writer.TryWrite( new MissingTranslationEvent( language, @@ -153,6 +158,8 @@ public sealed class SupabaseMissingTranslationReporter : IMissingTranslationRepo var payload = JsonSerializer.Serialize( batch.Select(r => new SupabaseMissingRowSnake(r.Language, r.Key, r.Source, r.SourceInfo)), SupabaseJsonOptions); + Debug.WriteLine( + $"[MissingTranslation][Supabase][Payload] batch={batch.Count}{Environment.NewLine}{FormatBatchKeysForDebug(batch, 50, 4000)}"); using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { @@ -217,6 +224,25 @@ public sealed class SupabaseMissingTranslationReporter : IMissingTranslationRepo return text.Substring(0, maxLength) + "...(truncated)"; } + private static string FormatBatchKeysForDebug(IReadOnlyList batch, int maxItems, int maxLength) + { + if (batch.Count == 0) + { + return string.Empty; + } + + var lines = batch + .Take(maxItems) + .Select((row, index) => $"{index + 1}. [{row.Language}] {row.Key}"); + var text = string.Join(Environment.NewLine, lines); + if (batch.Count > maxItems) + { + text += $"{Environment.NewLine}... and {batch.Count - maxItems} more"; + } + + return TruncateForLog(text, maxLength); + } + private static TranslationSourceInfo NormalizeSourceInfo(TranslationSourceInfo? sourceInfo) { if (sourceInfo == null) @@ -396,4 +422,29 @@ public sealed class SupabaseMissingTranslationReporter : IMissingTranslationRepo { return ((int)source).ToString(CultureInfo.InvariantCulture); } + + private static bool ShouldSkipReporting(string language, TranslationSourceInfo? sourceInfo) + { + // 中文语言不采集 + if (language.StartsWith("zh", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // 动态命名控件不采集 + if (!string.IsNullOrWhiteSpace(sourceInfo?.ElementName) + && sourceInfo.ElementName.StartsWith("dynamic", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Snackbar 弹出提示不采集 + if (string.Equals(sourceInfo?.ElementType, "Wpf.Ui.Controls.Snackbar", StringComparison.Ordinal)) + { + return true; + } + + return false; + } } + diff --git a/BetterGenshinImpact/Service/UpdateService.cs b/BetterGenshinImpact/Service/UpdateService.cs index 5434e288..75e0bf4f 100644 --- a/BetterGenshinImpact/Service/UpdateService.cs +++ b/BetterGenshinImpact/Service/UpdateService.cs @@ -30,7 +30,7 @@ public class UpdateService : IUpdateService private readonly IConfigService _configService; private const string NoticeUrl = "https://hui-config.oss-cn-hangzhou.aliyuncs.com/bgi/notice.json"; - private const string DownloadPageUrl = "https://bettergi.com/download.html"; + private const string DownloadPageUrl = "https://www.bettergi.com/download.html"; public AllConfig Config { get; set; } diff --git a/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs b/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs index ccf443d9..e82ff0ba 100644 --- a/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs +++ b/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs @@ -341,7 +341,12 @@ namespace BetterGenshinImpact.View.Behavior { continue; } - + + if (IsAutoTranslateExplicitlyDisabled(current)) + { + continue; + } + if (IsInGridViewRowPresenter(current)) { continue; @@ -881,6 +886,16 @@ namespace BetterGenshinImpact.View.Behavior return false; } + + private static bool IsAutoTranslateExplicitlyDisabled(DependencyObject obj) + { + return obj switch + { + FrameworkElement fe => fe.ReadLocalValue(EnableAutoTranslateProperty) is bool enable && !enable, + FrameworkContentElement fce => fce.ReadLocalValue(EnableAutoTranslateProperty) is bool enable && !enable, + _ => false + }; + } } } } diff --git a/BetterGenshinImpact/View/Controls/CascadeSelector.xaml b/BetterGenshinImpact/View/Controls/CascadeSelector.xaml index c0b874bb..cd41177c 100644 --- a/BetterGenshinImpact/View/Controls/CascadeSelector.xaml +++ b/BetterGenshinImpact/View/Controls/CascadeSelector.xaml @@ -44,7 +44,9 @@ PlacementTarget="{Binding ElementName=MainToggle}" StaysOpen="False" AllowsTransparency="True" - PopupAnimation="Slide"> + PopupAnimation="Slide" + Opened="MainPopup_Opened" + Closed="MainPopup_Closed"> + MaxWidth="600" + PreviewMouseWheel="PopupBorder_PreviewMouseWheel"> @@ -70,7 +73,8 @@ ItemsSource="{Binding FirstLevelOptions, ElementName=Root}" BorderThickness="0" SelectionChanged="FirstLevelListView_SelectionChanged" - ScrollViewer.VerticalScrollBarVisibility="Auto"> + ScrollViewer.VerticalScrollBarVisibility="Auto" + ScrollViewer.CanContentScroll="False"> @@ -86,7 +90,8 @@ ItemsSource="{Binding SecondLevelOptions, ElementName=Root}" BorderThickness="0" SelectionChanged="SecondLevelListView_SelectionChanged" - ScrollViewer.VerticalScrollBarVisibility="Auto"> + ScrollViewer.VerticalScrollBarVisibility="Auto" + ScrollViewer.CanContentScroll="False"> diff --git a/BetterGenshinImpact/View/Controls/CascadeSelector.xaml.cs b/BetterGenshinImpact/View/Controls/CascadeSelector.xaml.cs index de66604e..343949f1 100644 --- a/BetterGenshinImpact/View/Controls/CascadeSelector.xaml.cs +++ b/BetterGenshinImpact/View/Controls/CascadeSelector.xaml.cs @@ -1,8 +1,10 @@ +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Windows; using System.Windows.Controls; +using System.Windows.Input; using System.Windows.Media; namespace BetterGenshinImpact.View.Controls; @@ -21,6 +23,7 @@ public partial class CascadeSelector : UserControl { InitializeComponent(); Loaded += OnLoaded; + Unloaded += OnUnloaded; } private void OnLoaded(object sender, RoutedEventArgs e) @@ -29,6 +32,14 @@ public partial class CascadeSelector : UserControl UpdatePopupWidth(); } + /// + /// 控件卸载时清理事件处理器,防止内存泄漏 + /// + private void OnUnloaded(object sender, RoutedEventArgs e) + { + RemoveWindowMouseWheelHandler(); + } + public Dictionary>? CascadeOptions { get { return (Dictionary>?)GetValue(CascadeOptionsProperty); } @@ -277,4 +288,112 @@ public partial class CascadeSelector : UserControl } } } + + /// + /// Popup 打开时添加全局滚轮事件拦截 + /// + private void MainPopup_Opened(object sender, EventArgs e) + { + var window = Window.GetWindow(this); + if (window != null) + { + window.PreviewMouseWheel -= Window_PreviewMouseWheel; + window.PreviewMouseWheel += Window_PreviewMouseWheel; + } + } + + /// + /// Popup 关闭时移除全局滚轮事件拦截 + /// + private void MainPopup_Closed(object sender, EventArgs e) + { + RemoveWindowMouseWheelHandler(); + } + + /// + /// 移除窗口级滚轮事件处理器 + /// + private void RemoveWindowMouseWheelHandler() + { + var window = Window.GetWindow(this); + if (window != null) + { + window.PreviewMouseWheel -= Window_PreviewMouseWheel; + } + } + + /// + /// 全局滚轮事件处理,当 Popup 打开时拦截所有滚轮事件 + /// + private void Window_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (MainPopup.IsOpen) + { + e.Handled = true; + + var scrollViewer1 = FindScrollViewer(FirstLevelListView); + var scrollViewer2 = FindScrollViewer(SecondLevelListView); + + if (scrollViewer1 != null && scrollViewer1.IsMouseOver) + { + scrollViewer1.ScrollToVerticalOffset(scrollViewer1.VerticalOffset - e.Delta / 2.0); + return; + } + + if (scrollViewer2 != null && scrollViewer2.IsMouseOver) + { + scrollViewer2.ScrollToVerticalOffset(scrollViewer2.VerticalOffset - e.Delta / 2.0); + return; + } + } + } + + /// + /// 处理 Popup 内的鼠标滚轮事件,防止滚动穿透到外部页面 + /// + private void PopupBorder_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + e.Handled = true; + + var scrollViewer1 = FindScrollViewer(FirstLevelListView); + var scrollViewer2 = FindScrollViewer(SecondLevelListView); + + if (scrollViewer1 != null && scrollViewer1.IsMouseOver) + { + scrollViewer1.ScrollToVerticalOffset(scrollViewer1.VerticalOffset - e.Delta / 2.0); + return; + } + + if (scrollViewer2 != null && scrollViewer2.IsMouseOver) + { + scrollViewer2.ScrollToVerticalOffset(scrollViewer2.VerticalOffset - e.Delta / 2.0); + return; + } + } + + /// + /// 在视觉树中查找 ScrollViewer + /// + private ScrollViewer? FindScrollViewer(DependencyObject parent) + { + if (parent == null) return null; + + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + + if (child is ScrollViewer scrollViewer) + { + return scrollViewer; + } + + var result = FindScrollViewer(child); + if (result != null) + { + return result; + } + } + + return null; + } } diff --git a/BetterGenshinImpact/View/Controls/DomainSelector.xaml b/BetterGenshinImpact/View/Controls/DomainSelector.xaml index a20b284a..28043acf 100644 --- a/BetterGenshinImpact/View/Controls/DomainSelector.xaml +++ b/BetterGenshinImpact/View/Controls/DomainSelector.xaml @@ -38,12 +38,16 @@ - - + + Width="350" + PreviewMouseWheel="PopupBorder_PreviewMouseWheel"> @@ -63,10 +68,12 @@ - + ScrollViewer.VerticalScrollBarVisibility="Auto" + ScrollViewer.CanContentScroll="False"> @@ -78,13 +85,14 @@ - + ScrollViewer.VerticalScrollBarVisibility="Auto" + ScrollViewer.CanContentScroll="False"> diff --git a/BetterGenshinImpact/View/Controls/DomainSelector.xaml.cs b/BetterGenshinImpact/View/Controls/DomainSelector.xaml.cs index cbfb54a3..322a6d4a 100644 --- a/BetterGenshinImpact/View/Controls/DomainSelector.xaml.cs +++ b/BetterGenshinImpact/View/Controls/DomainSelector.xaml.cs @@ -1,9 +1,12 @@ +using System; using BetterGenshinImpact.GameTask.AutoTrackPath.Model; using BetterGenshinImpact.GameTask.Common.Element.Assets; using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; namespace BetterGenshinImpact.View.Controls; @@ -13,6 +16,15 @@ public partial class DomainSelector : UserControl { InitializeComponent(); Countries = MapLazyAssets.Instance.CountryToDomains.Keys.Reverse().ToList(); + Unloaded += OnUnloaded; + } + + /// + /// 控件卸载时清理事件处理器,防止内存泄漏 + /// + private void OnUnloaded(object sender, RoutedEventArgs e) + { + RemoveWindowMouseWheelHandler(); } public List Countries @@ -95,4 +107,112 @@ public partial class DomainSelector : UserControl MainToggle.IsChecked = false; } } + + /// + /// Popup 打开时添加全局滚轮事件拦截 + /// + private void MainPopup_Opened(object sender, EventArgs e) + { + var window = Window.GetWindow(this); + if (window != null) + { + window.PreviewMouseWheel -= Window_PreviewMouseWheel; + window.PreviewMouseWheel += Window_PreviewMouseWheel; + } + } + + /// + /// Popup 关闭时移除全局滚轮事件拦截 + /// + private void MainPopup_Closed(object sender, EventArgs e) + { + RemoveWindowMouseWheelHandler(); + } + + /// + /// 移除窗口级滚轮事件处理器 + /// + private void RemoveWindowMouseWheelHandler() + { + var window = Window.GetWindow(this); + if (window != null) + { + window.PreviewMouseWheel -= Window_PreviewMouseWheel; + } + } + + /// + /// 全局滚轮事件处理,当 Popup 打开时拦截所有滚轮事件 + /// + private void Window_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (MainPopup.IsOpen) + { + e.Handled = true; + + var scrollViewer1 = FindScrollViewer(CountriesListView); + var scrollViewer2 = FindScrollViewer(DomainsListView); + + if (scrollViewer1 != null && scrollViewer1.IsMouseOver) + { + scrollViewer1.ScrollToVerticalOffset(scrollViewer1.VerticalOffset - e.Delta / 2.0); + return; + } + + if (scrollViewer2 != null && scrollViewer2.IsMouseOver) + { + scrollViewer2.ScrollToVerticalOffset(scrollViewer2.VerticalOffset - e.Delta / 2.0); + return; + } + } + } + + /// + /// 处理 Popup 内的鼠标滚轮事件,防止滚动穿透到外部页面 + /// + private void PopupBorder_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + e.Handled = true; + + var scrollViewer1 = FindScrollViewer(CountriesListView); + var scrollViewer2 = FindScrollViewer(DomainsListView); + + if (scrollViewer1 != null && scrollViewer1.IsMouseOver) + { + scrollViewer1.ScrollToVerticalOffset(scrollViewer1.VerticalOffset - e.Delta / 2.0); + return; + } + + if (scrollViewer2 != null && scrollViewer2.IsMouseOver) + { + scrollViewer2.ScrollToVerticalOffset(scrollViewer2.VerticalOffset - e.Delta / 2.0); + return; + } + } + + /// + /// 在视觉树中查找 ScrollViewer + /// + private ScrollViewer? FindScrollViewer(DependencyObject parent) + { + if (parent == null) return null; + + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + + if (child is ScrollViewer scrollViewer) + { + return scrollViewer; + } + + var result = FindScrollViewer(child); + if (result != null) + { + return result; + } + } + + return null; + } } diff --git a/BetterGenshinImpact/View/Controls/MiniMapPointsCanvas.cs b/BetterGenshinImpact/View/Controls/MiniMapPointsCanvas.cs new file mode 100644 index 00000000..a7ded614 --- /dev/null +++ b/BetterGenshinImpact/View/Controls/MiniMapPointsCanvas.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Media; +using BetterGenshinImpact.Model.MaskMap; +using BetterGenshinImpact.ViewModel; + +namespace BetterGenshinImpact.View.Controls; + +public sealed class MiniMapPointsCanvas : FrameworkElement +{ + public static readonly DependencyProperty PointsSourceProperty = + DependencyProperty.Register( + nameof(PointsSource), + typeof(ObservableCollection), + typeof(MiniMapPointsCanvas), + new PropertyMetadata(null, OnPointsSourceChanged)); + + public static readonly DependencyProperty LabelsSourceProperty = + DependencyProperty.Register( + nameof(LabelsSource), + typeof(IEnumerable), + typeof(MiniMapPointsCanvas), + new PropertyMetadata(null, OnLabelsSourceChanged)); + + private readonly VisualCollection _children; + private readonly DrawingVisual _drawingVisual; + private readonly Dictionary _colorBrushCache; + private int _refreshQueued; + + private ObservableCollection? _points; + private List _allPoints = new(); + private Dictionary _labelMap = new(); + private Rect _viewportRect = Rect.Empty; + + public ObservableCollection? PointsSource + { + get => (ObservableCollection?)GetValue(PointsSourceProperty); + set => SetValue(PointsSourceProperty, value); + } + + public IEnumerable? LabelsSource + { + get => (IEnumerable?)GetValue(LabelsSourceProperty); + set => SetValue(LabelsSourceProperty, value); + } + + public MiniMapPointsCanvas() + { + _children = new VisualCollection(this); + _drawingVisual = new DrawingVisual(); + _children.Add(_drawingVisual); + _colorBrushCache = new Dictionary(); + + IsHitTestVisible = false; + + MapIconImageCache.ImageUpdated += PointImageCacheManagerOnImageUpdated; + } + + private static void OnPointsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var canvas = (MiniMapPointsCanvas)d; + canvas.UpdatePoints(e.NewValue as ObservableCollection); + } + + private static void OnLabelsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var canvas = (MiniMapPointsCanvas)d; + canvas.UpdateLabels(e.NewValue as IEnumerable); + } + + protected override void OnVisualParentChanged(DependencyObject oldParent) + { + base.OnVisualParentChanged(oldParent); + if (VisualParent == null) + { + MapIconImageCache.ImageUpdated -= PointImageCacheManagerOnImageUpdated; + } + } + + private void PointImageCacheManagerOnImageUpdated(object? sender, string e) + { + if (Interlocked.Exchange(ref _refreshQueued, 1) != 0) + { + return; + } + + Dispatcher.BeginInvoke(() => + { + Interlocked.Exchange(ref _refreshQueued, 0); + Refresh(); + }, System.Windows.Threading.DispatcherPriority.Background); + } + + protected override int VisualChildrenCount => _children.Count; + + protected override Visual GetVisualChild(int index) + { + if (index < 0 || index >= _children.Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + return _children[index]; + } + + private void OnPointsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (MaskMapPoint point in e.OldItems) + { + UnsubscribePoint(point); + } + } + + if (e.NewItems != null) + { + foreach (MaskMapPoint point in e.NewItems) + { + SubscribePoint(point); + } + } + + _allPoints = _points?.ToList() ?? new List(); + Refresh(); + } + + private void SubscribePoint(MaskMapPoint point) + { + if (point is INotifyPropertyChanged notifyPoint) + { + notifyPoint.PropertyChanged += OnPointPropertyChanged; + } + } + + private void UnsubscribePoint(MaskMapPoint point) + { + if (point is INotifyPropertyChanged notifyPoint) + { + notifyPoint.PropertyChanged -= OnPointPropertyChanged; + } + } + + private void OnPointPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + Refresh(); + } + + private void RenderPoints() + { + using var dc = _drawingVisual.RenderOpen(); + if (_allPoints.Count == 0 || _viewportRect.IsEmpty || _viewportRect.Width == 0) + { + return; + } + + var aw = ActualWidth; + var ah = ActualHeight; + if (aw <= 0 || ah <= 0) + { + return; + } + + var side = Math.Min(aw, ah); + if (side <= 0) + { + return; + } + + var clipRect = new Rect((aw - side) / 2.0, (ah - side) / 2.0, side, side); + var clip = new EllipseGeometry(clipRect); + dc.PushClip(clip); + + var expandedViewport = _viewportRect; + expandedViewport.Inflate(MaskMapPointStatic.Width, MaskMapPointStatic.Height); + + var scaleX = side / _viewportRect.Width; + var scaleY = side / _viewportRect.Height; + + var pointSide = Math.Max(8, Math.Min(16, side / 12.0)); + + foreach (var point in _allPoints) + { + if (!expandedViewport.Contains(point.ImageX, point.ImageY)) + { + continue; + } + + var localX = clipRect.X + (point.ImageX - _viewportRect.X) * scaleX; + var localY = clipRect.Y + (point.ImageY - _viewportRect.Y) * scaleY; + DrawPoint(dc, point, localX, localY, pointSide, pointSide); + } + + dc.Pop(); + } + + private void DrawPoint(DrawingContext dc, MaskMapPoint point, double centerX, double centerY, double width, double height) + { + var radius = width / 2.0; + const double strokeThickness = 2.0; + + var circleCenter = new Point(centerX, centerY); + + var fillBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#323947")); + fillBrush.Freeze(); + + var borderBrush = new SolidColorBrush(Color.FromRgb(0xD3, 0xBC, 0x8E)); + borderBrush.Freeze(); + + var borderPen = new Pen(borderBrush, strokeThickness); + borderPen.Freeze(); + + var shadowBrush = new SolidColorBrush(Color.FromArgb(30, 0, 0, 0)); + shadowBrush.Freeze(); + + var shadowOffset = new Point(2, 2); + + var shadowCircleGeometry = new EllipseGeometry( + new Point(circleCenter.X + shadowOffset.X, circleCenter.Y + shadowOffset.Y), + radius, radius); + dc.DrawGeometry(shadowBrush, null, shadowCircleGeometry); + + var circleGeometry = new EllipseGeometry(circleCenter, radius, radius); + dc.DrawGeometry(fillBrush, borderPen, circleGeometry); + + if (_labelMap.TryGetValue(point.LabelId, out var label)) + { + var image = MapIconImageCache.TryGet(label.IconUrl); + if (image != null) + { + var imageRect = new Rect(circleCenter.X - radius, circleCenter.Y - radius, width, height); + dc.PushClip(circleGeometry); + dc.DrawImage(image, imageRect); + dc.Pop(); + } + else + { + _ = MapIconImageCache.GetAsync(label.IconUrl, CancellationToken.None); + + var brush = GetColorBrush(label); + dc.DrawEllipse(brush, null, new Point(centerX, centerY), width / 2.0, height / 2.0); + } + } + else + { + var brush = new SolidColorBrush(GenerateRandomColor(point.Id)); + brush.Freeze(); + dc.DrawEllipse(brush, null, new Point(centerX, centerY), width / 2.0, height / 2.0); + } + } + + private Brush GetColorBrush(MaskMapPointLabel label) + { + if (_colorBrushCache.TryGetValue(label.LabelId, out var cachedBrush)) + { + return cachedBrush; + } + + Color color; + if (label.Color.HasValue) + { + var c = label.Color.Value; + color = Color.FromArgb(c.A, c.R, c.G, c.B); + } + else + { + color = GenerateRandomColor(label.LabelId); + } + + var brush = new SolidColorBrush(color); + brush.Freeze(); + _colorBrushCache[label.LabelId] = brush; + return brush; + } + + private static Color GenerateRandomColor(string seed) + { + var hash = seed?.GetHashCode() ?? 0; + var random = new Random(hash); + return Color.FromRgb( + (byte)random.Next(80, 256), + (byte)random.Next(80, 256), + (byte)random.Next(80, 256)); + } + + public void UpdatePoints(ObservableCollection? points) + { + if (_points != null) + { + _points.CollectionChanged -= OnPointsCollectionChanged; + foreach (var point in _points) + { + UnsubscribePoint(point); + } + } + + _points = points; + + if (_points != null) + { + _points.CollectionChanged += OnPointsCollectionChanged; + foreach (var point in _points) + { + SubscribePoint(point); + } + + _allPoints = _points.ToList(); + } + else + { + _allPoints.Clear(); + } + + Refresh(); + } + + public void UpdateLabels(IEnumerable? labels) + { + if (labels != null) + { + _labelMap = labels.ToDictionary(l => l.LabelId, l => l); + _colorBrushCache.Clear(); + } + else + { + _labelMap.Clear(); + _colorBrushCache.Clear(); + } + + Refresh(); + } + + public void UpdateViewport(double x, double y, double width, double height) + { + var newRect = new Rect(x, y, width, height); + if (newRect.Equals(_viewportRect)) + { + return; + } + + _viewportRect = newRect; + Refresh(); + } + + public void Refresh() + { + RenderPoints(); + } +} diff --git a/BetterGenshinImpact/View/Controls/PointsCanvas.cs b/BetterGenshinImpact/View/Controls/PointsCanvas.cs index f82ed17f..721e62b9 100644 --- a/BetterGenshinImpact/View/Controls/PointsCanvas.cs +++ b/BetterGenshinImpact/View/Controls/PointsCanvas.cs @@ -28,7 +28,7 @@ public class PointsCanvas : FrameworkElement private int _refreshQueued; // 私有字段 - private ObservableCollection _points; + private ObservableCollection? _points; private List _allPoints = new(); private Dictionary _labelMap = new(); private Rect _viewportRect = Rect.Empty; @@ -37,6 +37,20 @@ public class PointsCanvas : FrameworkElement #region 依赖属性 + public static readonly DependencyProperty PointsSourceProperty = + DependencyProperty.Register( + nameof(PointsSource), + typeof(ObservableCollection), + typeof(PointsCanvas), + new PropertyMetadata(null, OnPointsSourceChanged)); + + public static readonly DependencyProperty LabelsSourceProperty = + DependencyProperty.Register( + nameof(LabelsSource), + typeof(IEnumerable), + typeof(PointsCanvas), + new PropertyMetadata(null, OnLabelsSourceChanged)); + public static readonly DependencyProperty PointClickCommandProperty = DependencyProperty.Register( nameof(PointClickCommand), @@ -67,6 +81,18 @@ public class PointsCanvas : FrameworkElement set => SetValue(PointClickCommandProperty, value); } + public ObservableCollection? PointsSource + { + get => (ObservableCollection?)GetValue(PointsSourceProperty); + set => SetValue(PointsSourceProperty, value); + } + + public IEnumerable? LabelsSource + { + get => (IEnumerable?)GetValue(LabelsSourceProperty); + set => SetValue(LabelsSourceProperty, value); + } + /// /// 右键点击命令 /// @@ -87,6 +113,18 @@ public class PointsCanvas : FrameworkElement #endregion + private static void OnPointsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var canvas = (PointsCanvas)d; + canvas.UpdatePoints(e.NewValue as ObservableCollection); + } + + private static void OnLabelsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var canvas = (PointsCanvas)d; + canvas.UpdateLabels(e.NewValue as IEnumerable); + } + public PointsCanvas() { _children = new VisualCollection(this); @@ -508,7 +546,7 @@ public class PointsCanvas : FrameworkElement /// /// 更新点位数据 /// - public void UpdatePoints(ObservableCollection points) + public void UpdatePoints(ObservableCollection? points) { // 取消订阅旧集合 if (_points != null) @@ -541,7 +579,7 @@ public class PointsCanvas : FrameworkElement /// /// 更新标签数据 /// - public void UpdateLabels(IEnumerable labels) + public void UpdateLabels(IEnumerable? labels) { if (labels != null) { diff --git a/BetterGenshinImpact/View/Converters/AdaptiveUniformGridColumnsConverter.cs b/BetterGenshinImpact/View/Converters/AdaptiveUniformGridColumnsConverter.cs new file mode 100644 index 00000000..1b922abd --- /dev/null +++ b/BetterGenshinImpact/View/Converters/AdaptiveUniformGridColumnsConverter.cs @@ -0,0 +1,47 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace BetterGenshinImpact.View.Converters; + +[ValueConversion(typeof(double), typeof(int))] +public sealed class AdaptiveUniformGridColumnsConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not double width || double.IsNaN(width) || width <= 0) + { + return 1; + } + + var minimumColumnWidth = 280d; + var maxColumns = 4; + + if (parameter is string parameterText && !string.IsNullOrWhiteSpace(parameterText)) + { + var parts = parameterText.Split([',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length > 0 && + double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedMinimumWidth) && + parsedMinimumWidth > 0) + { + minimumColumnWidth = parsedMinimumWidth; + } + + if (parts.Length > 1 && + int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMaxColumns) && + parsedMaxColumns > 0) + { + maxColumns = parsedMaxColumns; + } + } + + var columns = Math.Max(1, (int)Math.Floor(width / minimumColumnWidth)); + return Math.Min(columns, maxColumns); + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/BetterGenshinImpact/View/MaskWindow.xaml b/BetterGenshinImpact/View/MaskWindow.xaml index 5701c577..dee41961 100644 --- a/BetterGenshinImpact/View/MaskWindow.xaml +++ b/BetterGenshinImpact/View/MaskWindow.xaml @@ -86,6 +86,37 @@ Grid.Column="0" Grid.ColumnSpan="6" ClipToBounds="True"> + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -744,6 +777,7 @@ + @@ -760,14 +794,26 @@ SelectedItem="{Binding SelectedMapPointApiProviderOption, Mode=TwoWay}" DisplayMemberPath="DisplayName" /> + + - + - { - if (_viewModel != null) - { - PointsCanvasControl.UpdateLabels(_viewModel.MapPointLabels); - } - }); - } - - if (e.PropertyName == nameof(MaskWindowViewModel.MapPoints)) - { - Dispatcher.Invoke(() => - { - if (_viewModel != null) - { - PointsCanvasControl.UpdatePoints(_viewModel.MapPoints); - } - }); - } } private void UpdateClickThroughState() @@ -515,96 +462,98 @@ public partial class MaskWindow : Window return; } - // 先有上方判断的原因是,有可能Render的时候,配置还未初始化 - if (!TaskContext.Instance().Config.MaskWindowConfig.DisplayRecognitionResultsOnMask) + var displayRecognitionResults = TaskContext.Instance().Config.MaskWindowConfig.DisplayRecognitionResultsOnMask; + if (!displayRecognitionResults) { return; } - foreach (var kv in VisionContext.Instance().DrawContent.RectList) + if (displayRecognitionResults) { - foreach (var drawable in kv.Value) + foreach (var kv in VisionContext.Instance().DrawContent.RectList) { - if (!drawable.IsEmpty) + foreach (var drawable in kv.Value) { - drawingContext.DrawRectangle(Brushes.Transparent, - new Pen(new SolidColorBrush(drawable.Pen.Color.ToWindowsColor()), drawable.Pen.Width), - drawable.Rect); + if (!drawable.IsEmpty) + { + drawingContext.DrawRectangle( + Brushes.Transparent, + new Pen(new SolidColorBrush(drawable.Pen.Color.ToWindowsColor()), drawable.Pen.Width), + drawable.Rect); + } } } - } - foreach (var kv in VisionContext.Instance().DrawContent.LineList) - { - foreach (var drawable in kv.Value) + foreach (var kv in VisionContext.Instance().DrawContent.LineList) { - drawingContext.DrawLine(new Pen(new SolidColorBrush(drawable.Pen.Color.ToWindowsColor()), drawable.Pen.Width), drawable.P1, drawable.P2); - } - } - - foreach (var kv in VisionContext.Instance().DrawContent.TextList) - { - bool isSkillCd = kv.Key == "SkillCdText"; - var systemInfo = TaskContext.Instance().SystemInfo; - // 使用不封顶的物理比例进行 UI 大小缩放 - var scaleTo1080 = systemInfo.ScaleTo1080PRatio; - - foreach (var drawable in kv.Value) - { - if (!drawable.IsEmpty) + foreach (var drawable in kv.Value) { - var pixelsPerDip = VisualTreeHelper.GetDpi(this).PixelsPerDip; - var renderPoint = new Point(drawable.Point.X / pixelsPerDip, drawable.Point.Y / pixelsPerDip); + drawingContext.DrawLine(new Pen(new SolidColorBrush(drawable.Pen.Color.ToWindowsColor()), drawable.Pen.Width), drawable.P1, drawable.P2); + } + } - if (isSkillCd) + foreach (var kv in VisionContext.Instance().DrawContent.TextList) + { + bool isSkillCd = kv.Key == "SkillCdText"; + var systemInfo = TaskContext.Instance().SystemInfo; + var scaleTo1080 = systemInfo.ScaleTo1080PRatio; + + foreach (var drawable in kv.Value) + { + if (!drawable.IsEmpty) { - // 自定义缩放 - var skillConfigScale = TaskContext.Instance().Config.SkillCdConfig.Scale; - double scaledFontSize = (26 * scaleTo1080 * skillConfigScale) / pixelsPerDip; - var mediumTypeface = new Typeface(_fgiTypeface.FontFamily, _fgiTypeface.Style, FontWeights.Medium, _fgiTypeface.Stretch); - bool isZeroCd = - double.TryParse(drawable.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var cdValue) - && Math.Abs(cdValue) < 0.8; + var pixelsPerDip = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var renderPoint = new Point(drawable.Point.X / pixelsPerDip, drawable.Point.Y / pixelsPerDip); - // 从配置读取颜色 - var skillConfig = TaskContext.Instance().Config.SkillCdConfig; - string textColorStr = isZeroCd ? skillConfig.TextReadyColor : skillConfig.TextNormalColor; - string bgColorStr = isZeroCd ? skillConfig.BackgroundReadyColor : skillConfig.BackgroundNormalColor; + if (isSkillCd) + { + var skillConfigScale = TaskContext.Instance().Config.SkillCdConfig.Scale; + double scaledFontSize = (26 * scaleTo1080 * skillConfigScale) / pixelsPerDip; + var mediumTypeface = new Typeface(_fgiTypeface.FontFamily, _fgiTypeface.Style, FontWeights.Medium, _fgiTypeface.Stretch); + bool isZeroCd = + double.TryParse(drawable.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var cdValue) + && Math.Abs(cdValue) < 0.8; - Color textColor = ParseColor(textColorStr) ?? (isZeroCd ? Color.FromRgb(93, 204, 23) : Color.FromRgb(218, 74, 35)); - Color bgColor = ParseColor(bgColorStr) ?? Colors.White; + var skillConfig = TaskContext.Instance().Config.SkillCdConfig; + string textColorStr = isZeroCd ? skillConfig.TextReadyColor : skillConfig.TextNormalColor; + string bgColorStr = isZeroCd ? skillConfig.BackgroundReadyColor : skillConfig.BackgroundNormalColor; - Brush textBrush = new SolidColorBrush(textColor); - Brush bgBrush = new SolidColorBrush(bgColor); + Color textColor = ParseColor(textColorStr) ?? (isZeroCd ? Color.FromRgb(93, 204, 23) : Color.FromRgb(218, 74, 35)); + Color bgColor = ParseColor(bgColorStr) ?? Colors.White; - var formattedText = new FormattedText( - drawable.Text, - CultureInfo.GetCultureInfo("zh-cn"), - FlowDirection.LeftToRight, - mediumTypeface, - scaledFontSize, - textBrush, - pixelsPerDip); + Brush textBrush = new SolidColorBrush(textColor); + Brush bgBrush = new SolidColorBrush(bgColor); - double px = (6 * scaleTo1080 * skillConfigScale) / pixelsPerDip; - double py = (2 * scaleTo1080 * skillConfigScale) / pixelsPerDip; - double radius = (5 * scaleTo1080 * skillConfigScale) / pixelsPerDip; - var bgRect = new Rect(renderPoint.X - px, renderPoint.Y - py, formattedText.Width + px * 2, formattedText.Height + py * 2); - drawingContext.DrawRoundedRectangle(bgBrush, null, bgRect, radius, radius); - drawingContext.DrawText(formattedText, renderPoint); - } - else - { - double defaultFontSize = (36 * scaleTo1080) / pixelsPerDip; - drawingContext.DrawText(new FormattedText(drawable.Text, - CultureInfo.GetCultureInfo("zh-cn"), - FlowDirection.LeftToRight, - _typeface, - defaultFontSize, Brushes.Black, pixelsPerDip), renderPoint); + var formattedText = new FormattedText( + drawable.Text, + CultureInfo.GetCultureInfo("zh-cn"), + FlowDirection.LeftToRight, + mediumTypeface, + scaledFontSize, + textBrush, + pixelsPerDip); + + double px = (6 * scaleTo1080 * skillConfigScale) / pixelsPerDip; + double py = (2 * scaleTo1080 * skillConfigScale) / pixelsPerDip; + double radius = (5 * scaleTo1080 * skillConfigScale) / pixelsPerDip; + var bgRect = new Rect(renderPoint.X - px, renderPoint.Y - py, formattedText.Width + px * 2, formattedText.Height + py * 2); + drawingContext.DrawRoundedRectangle(bgBrush, null, bgRect, radius, radius); + drawingContext.DrawText(formattedText, renderPoint); + } + else + { + double defaultFontSize = (36 * scaleTo1080) / pixelsPerDip; + drawingContext.DrawText(new FormattedText(drawable.Text, + CultureInfo.GetCultureInfo("zh-cn"), + FlowDirection.LeftToRight, + _typeface, + defaultFontSize, Brushes.Black, pixelsPerDip), renderPoint); + } } } } } + } catch (Exception e) { diff --git a/BetterGenshinImpact/View/Pages/CommonSettingsPage.xaml b/BetterGenshinImpact/View/Pages/CommonSettingsPage.xaml index 578bb8a4..37990a85 100644 --- a/BetterGenshinImpact/View/Pages/CommonSettingsPage.xaml +++ b/BetterGenshinImpact/View/Pages/CommonSettingsPage.xaml @@ -21,8 +21,8 @@ - - + + @@ -434,48 +434,21 @@ Width="200" Margin="0,0,12,0" VerticalAlignment="Center" - IsSnapToTickEnabled="True" - Maximum="1" Minimum="0" + Maximum="1" TickFrequency="0.1" + IsSnapToTickEnabled="True" Value="{Binding Config.MaskWindowConfig.TextOpacity, Mode=TwoWay}" /> - - - @@ -1064,7 +1037,7 @@ - + - + @@ -1238,10 +1212,10 @@ Grid.Column="1" MinWidth="100" Margin="0,0,36,0" - DisplayMemberPath="Item2" ItemsSource="{Binding ServerTimeZones}" SelectedValue="{Binding Config.OtherConfig.ServerTimeZoneOffset}" - SelectedValuePath="Item1" /> + SelectedValuePath="Item1" + DisplayMemberPath="Item2" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/BetterGenshinImpact/View/Pages/JsListPage.xaml b/BetterGenshinImpact/View/Pages/JsListPage.xaml index 5c0faffc..4664e033 100644 --- a/BetterGenshinImpact/View/Pages/JsListPage.xaml +++ b/BetterGenshinImpact/View/Pages/JsListPage.xaml @@ -1,4 +1,4 @@ - @@ -186,4 +188,4 @@ - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Pages/NotificationSettingsPage.xaml b/BetterGenshinImpact/View/Pages/NotificationSettingsPage.xaml index 9b167784..1744fcb0 100644 --- a/BetterGenshinImpact/View/Pages/NotificationSettingsPage.xaml +++ b/BetterGenshinImpact/View/Pages/NotificationSettingsPage.xaml @@ -102,33 +102,59 @@ Margin="0,0,36,0" IsChecked="{Binding Config.NotificationConfig.JsNotificationEnabled, Mode=TwoWay}" /> - - - - - - - - - - - + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + @@ -2092,4 +2118,4 @@ - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Pages/TaskSettingsPage.xaml b/BetterGenshinImpact/View/Pages/TaskSettingsPage.xaml index 88407f4f..2c098915 100644 --- a/BetterGenshinImpact/View/Pages/TaskSettingsPage.xaml +++ b/BetterGenshinImpact/View/Pages/TaskSettingsPage.xaml @@ -1,4 +1,4 @@ - --> - - - - - - - - - - - - - - - - 可以自动演奏单个,也可以全自动完成整个专辑 - - - 点击查看使用教程 - - - - - - - - - - - - - - - - - - 进入演奏界面使用,下落模式必须选择垂落模式 - - - 点击查看使用教程 - - - - - - - - - - - - - - - - 进入专辑界面使用,自动演奏未完成乐曲 - - - 点击查看使用教程 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -2476,6 +2320,194 @@ ItemsSource="{Binding Source={x:Static pages:TaskSettingsPageViewModel.LeyLineOutcropCountryList}}" SelectedItem="{Binding Config.AutoLeyLineOutcropConfig.Country, Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2696,7 +2728,7 @@ Maximum="9999" Minimum="1" ValidationMode="InvalidInputOverwritten" - Value="{Binding Config.AutoLeyLineOutcropConfig.Timeout, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + Value="{Binding Config.AutoLeyLineOutcropConfig.FightConfig.Timeout, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> @@ -2752,9 +2784,10 @@ - - - + + + @@ -2768,23 +2801,179 @@ - 自动使用输入的兑换码 + 可以自动演奏单个,也可以全自动完成整个专辑 - + + 点击查看使用教程 + + + + + + + + + + + + + + + + + 进入演奏界面使用,下落模式必须选择垂落模式 - + + 点击查看使用教程 + + + + + + + + + + + + + + + + 进入专辑界面使用,自动演奏未完成乐曲 - + + 点击查看使用教程 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + EnableCommand="{Binding SwitchAutoCookCommand}" + EnableContent="{Binding SwitchAutoCookButtonText}" + IsChecked="{Binding SwitchAutoCookEnabled}" /> @@ -2800,23 +2989,52 @@ + + + + + + + + + + + + + + IsChecked="{Binding Config.AutoCookConfig.StopTaskWhenRecoverButtonDetected, Mode=TwoWay}" /> - - + + @@ -3044,6 +3262,69 @@ + + + + + + + + + + + + + + + + 自动使用输入的兑换码 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -975,16 +1000,20 @@ - - + + - - + + + + + + 在遮罩窗口中显示大地图位置与标点信息 + - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - 手动烹饪时,自动在完美状态下结束烹饪(不用的时候请关闭) - - - - - -