diff --git a/BetterGenshinImpact/BetterGenshinImpact.csproj b/BetterGenshinImpact/BetterGenshinImpact.csproj index fb32daa1..43e5ea0d 100644 --- a/BetterGenshinImpact/BetterGenshinImpact.csproj +++ b/BetterGenshinImpact/BetterGenshinImpact.csproj @@ -88,7 +88,7 @@ - + @@ -96,10 +96,10 @@ - - - - + + + + diff --git a/BetterGenshinImpact/Core/BgiVision/BvLocator.cs b/BetterGenshinImpact/Core/BgiVision/BvLocator.cs index abedab3c..c7a72670 100644 --- a/BetterGenshinImpact/Core/BgiVision/BvLocator.cs +++ b/BetterGenshinImpact/Core/BgiVision/BvLocator.cs @@ -21,10 +21,12 @@ public class BvLocator { private static readonly ILogger Logger = App.GetLogger(); private readonly CancellationToken _cancellationToken; + private int? _timeout; + private int? _retryInterval; public RecognitionObject RecognitionObject { get; } - public Action>? RetryAction { get; set; } + public Func, Task>? RetryAction { get; set; } public static int DefaultTimeout { get; set; } = 10000; @@ -98,17 +100,21 @@ public class BvLocator public async Task> WaitFor(int? timeout = null) { - var actualTimeout = timeout ?? DefaultTimeout; - var retryCount = actualTimeout / DefaultRetryInterval; + var actualTimeout = timeout ?? _timeout ?? DefaultTimeout; + var actualRetryInterval = _retryInterval ?? DefaultRetryInterval; + var retryCount = Math.Max(1, actualTimeout / actualRetryInterval); List results = []; - var retryRes = await NewRetry.WaitForAction(() => + var retryRes = await NewRetry.WaitForAction(async () => { results = FindAll(); var b = results.Count > 0; - RetryAction?.Invoke(results); + if (!b && RetryAction != null) + { + await RetryAction(results); + } return b; - }, _cancellationToken, retryCount, DefaultRetryInterval); + }, _cancellationToken, retryCount, actualRetryInterval); if (retryRes) { @@ -150,20 +156,21 @@ public class BvLocator public async Task WaitForDisappear(int? timeout = null) { - var actualTimeout = timeout ?? DefaultTimeout; - var retryCount = actualTimeout / DefaultRetryInterval; + var actualTimeout = timeout ?? _timeout ?? DefaultTimeout; + var actualRetryInterval = _retryInterval ?? DefaultRetryInterval; + var retryCount = Math.Max(1, actualTimeout / actualRetryInterval); - var retryRes = await NewRetry.WaitForAction(() => + var retryRes = await NewRetry.WaitForAction(async () => { var results = FindAll(); var b = results.Count == 0; - if (!b) + if (!b && RetryAction != null) { - RetryAction?.Invoke(results); + await RetryAction(results); } return b; - }, _cancellationToken, retryCount, DefaultRetryInterval); + }, _cancellationToken, retryCount, actualRetryInterval); if (!retryRes) { @@ -205,19 +212,77 @@ public class BvLocator public BvLocator WithRetryAction(Action>? action) { - RetryAction = action; + if (action == null) + { + RetryAction = null; + } + else + { + RetryAction = (results) => + { + action(results); + return Task.CompletedTask; + }; + } + return this; + } + + /// + /// 设置超时时间(毫秒) + /// + /// 超时时间(毫秒) + /// + public BvLocator WithTimeout(int timeout) + { + if (timeout <= 0) + { + throw new ArgumentOutOfRangeException(nameof(timeout), "timeout 必须大于 0"); + } + _timeout = timeout; + return this; + } + + /// + /// 设置重试间隔(毫秒) + /// + /// 重试间隔(毫秒) + /// + public BvLocator WithRetryInterval(int retryInterval) + { + if (retryInterval <= 0) + { + throw new ArgumentOutOfRangeException(nameof(retryInterval), "retryInterval 必须大于 0"); + } + _retryInterval = retryInterval; return this; } /// /// 为 JavaScript 提供的动态参数重载 /// 解决 ClearScript 无法将 JS 函数隐式转换为 Action 委托的问题 + /// 支持同步和异步 JS 函数 /// /// JS 回调函数 /// public BvLocator WithRetryAction(dynamic action) { - RetryAction = (results) => action(results); + if (action == null) + { + RetryAction = null; + } + else + { + RetryAction = async (results) => + { + var taskResult = action(results); + // 如果 JS 返回的是 Promise/Task,等待其完成 + if (taskResult is Task task) + { + await task; + } + }; + } return this; } -} \ No newline at end of file + +} diff --git a/BetterGenshinImpact/Core/Script/Dependence/AutoPathingScript.cs b/BetterGenshinImpact/Core/Script/Dependence/AutoPathingScript.cs index ba5d76b3..03969b8d 100644 --- a/BetterGenshinImpact/Core/Script/Dependence/AutoPathingScript.cs +++ b/BetterGenshinImpact/Core/Script/Dependence/AutoPathingScript.cs @@ -88,6 +88,7 @@ public class AutoPathingScript /// /// 读取 AutoPathing 目录下指定文件夹的内容(非递归方式) + /// 目录不存在时返回空数组,不会自动创建目录 /// /// 相对于 User\AutoPathing 的子目录路径,默认为相对根目录 /// 文件夹内所有文件和文件夹的相对路径数组,出错时返回空数组 diff --git a/BetterGenshinImpact/Core/Script/Dependence/HtmlMask.cs b/BetterGenshinImpact/Core/Script/Dependence/HtmlMask.cs index 36991565..a01020e9 100644 --- a/BetterGenshinImpact/Core/Script/Dependence/HtmlMask.cs +++ b/BetterGenshinImpact/Core/Script/Dependence/HtmlMask.cs @@ -145,6 +145,35 @@ public class HtmlMask : IDisposable /// public bool Exists(string id) => HtmlMaskWindow.Exists(id); + /// + /// 设置窗口的点击穿透模式 + /// + /// 窗口ID + /// true=点击穿透,false=可交互 + public void SetClickThrough(string windowId, bool enabled) + { + HtmlMaskWindow.SetClickThrough(windowId, enabled); + } + + /// + /// 获取窗口的点击穿透状态 + /// + /// 窗口ID + /// true=点击穿透,false=可交互 + public bool GetClickThrough(string windowId) + { + return HtmlMaskWindow.GetClickThrough(windowId); + } + + /// + /// 切换窗口的点击穿透模式 + /// + /// 窗口ID + public void ToggleClickThrough(string windowId) + { + HtmlMaskWindow.ToggleClickThrough(windowId); + } + #endregion #region 消息通信 diff --git a/BetterGenshinImpact/Core/Script/Dependence/LimitedFile.cs b/BetterGenshinImpact/Core/Script/Dependence/LimitedFile.cs index bbf5f715..479902d3 100644 --- a/BetterGenshinImpact/Core/Script/Dependence/LimitedFile.cs +++ b/BetterGenshinImpact/Core/Script/Dependence/LimitedFile.cs @@ -15,6 +15,7 @@ public class LimitedFile(string rootPath) { /// /// 读取指定文件夹内所有文件和文件夹的路径(非递归方式)。 + /// 目录不存在时返回空数组 /// /// 文件夹路径(相对于根目录) /// 文件夹内所有文件和文件夹的路径数组 @@ -23,19 +24,19 @@ public class LimitedFile(string rootPath) try { // 对传入的文件夹路径进行标准化 - string normalizedFolderPath = NormalizePath(folderPath); + string fullPath = NormalizePath(folderPath); - // 确保目录存在 - if (!Directory.Exists(normalizedFolderPath)) + if (!Directory.Exists(fullPath)) { - Directory.CreateDirectory(normalizedFolderPath); + TaskControl.Logger.LogError("ReadPathSync 目录不存在: {Path}", fullPath); + return Array.Empty(); } // 获取指定文件夹下的所有文件(非递归) - string[] files = Directory.GetFiles(normalizedFolderPath, "*", SearchOption.TopDirectoryOnly); + string[] files = Directory.GetFiles(fullPath, "*", SearchOption.TopDirectoryOnly); // 获取指定文件夹下的所有子文件夹(非递归) - string[] directories = Directory.GetDirectories(normalizedFolderPath, "*", SearchOption.TopDirectoryOnly); + string[] directories = Directory.GetDirectories(fullPath, "*", SearchOption.TopDirectoryOnly); // 合并文件和文件夹路径 string[] combined = files.Concat(directories).ToArray(); @@ -45,12 +46,33 @@ public class LimitedFile(string rootPath) } catch (Exception ex) { - // 记录异常并返回空数组 TaskControl.Logger.LogError("ReadPathSync 异常: {Message}", ex.Message); return Array.Empty(); } } + /// + /// 创建指定路径的目录,如果已存在则跳过 + /// + /// 文件夹路径(相对于根目录) + /// 是否创建成功或目录已存在 + public bool CreateDirectory(string folderPath) + { + try + { + // 对传入的文件夹路径进行标准化 + string fullPath = NormalizePath(folderPath); + // 如果目录不存在,自动创建 + if (!Directory.Exists(fullPath)){Directory.CreateDirectory(fullPath);} + return true; + } + catch (Exception ex) + { + TaskControl.Logger.LogError("CreateDir 异常: {Message}", ex.Message); + return false; + } + } + /// /// 判断指定路径是否为文件夹。 /// diff --git a/BetterGenshinImpact/Core/Script/Dependence/StrategyFile.cs b/BetterGenshinImpact/Core/Script/Dependence/StrategyFile.cs new file mode 100644 index 00000000..db4f7c0b --- /dev/null +++ b/BetterGenshinImpact/Core/Script/Dependence/StrategyFile.cs @@ -0,0 +1,43 @@ +using System; +using System.Linq; +using BetterGenshinImpact.Core.Config; + +namespace BetterGenshinImpact.Core.Script.Dependence; + +/// +/// 战斗策略文件访问类 +/// 提供JS脚本环境访问 User\AutoFight 目录下战斗策略文件的方法 +/// +public class StrategyFile +{ + private readonly LimitedFile _strategyFile = new(Global.Absolute(@"User\AutoFight")); + + /// + /// 判断 User\AutoFight 目录下的路径是否为文件夹 + /// + /// 相对于 User\AutoFight 的路径 + /// 是文件夹返回 true,否则返回 false + public bool IsFolder(string subPath) => _strategyFile.IsFolder(subPath); + + /// + /// 判断 User\AutoFight 目录下的路径是否为文件 + /// + /// 相对于 User\AutoFight 的路径 + /// 是文件返回 true,否则返回 false + public bool IsFile(string subPath) => _strategyFile.IsFile(subPath); + + /// + /// 判断 User\AutoFight 目录下的路径是否存在 + /// + /// 相对于 User\AutoFight 的路径 + /// 存在返回 true,否则返回 false + public bool IsExists(string subPath) => _strategyFile.IsExists(subPath); + + /// + /// 读取 User\AutoFight 目录下指定文件夹的内容(非递归方式) + /// 目录不存在时返回空数组,不会自动创建目录 + /// + /// 相对于 User\AutoFight 的子目录路径,默认为根目录 + /// 文件夹内所有文件和文件夹的相对路径数组,出错时返回空数组 + public string[] ReadPathSync(string subPath = "./") => _strategyFile.ReadPathSync(subPath); +} diff --git a/BetterGenshinImpact/Core/Script/EngineExtend.cs b/BetterGenshinImpact/Core/Script/EngineExtend.cs index afb1a46d..0cf8f3d1 100644 --- a/BetterGenshinImpact/Core/Script/EngineExtend.cs +++ b/BetterGenshinImpact/Core/Script/EngineExtend.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using BetterGenshinImpact.Core.Script.Dependence; using BetterGenshinImpact.Core.Script.Dependence.Model; @@ -77,6 +77,7 @@ public class EngineExtend engine.AddHostType("AutoFightParam", typeof(AutoFightParam)); engine.AddHostType("AutoLeyLineOutcropParam", typeof(AutoLeyLineOutcropParam)); engine.AddHostType("AutoStygianOnslaughtParam", typeof(AutoStygianOnslaughtParam)); + engine.AddHostObject("strategyFile", new StrategyFile()); //鼠标回调 engine.AddHostType("KeyMouseHook", typeof(KeyMouseHook)); // 添加C#的类型 diff --git a/BetterGenshinImpact/GameTask/AutoFight/AutoFightTask.cs b/BetterGenshinImpact/GameTask/AutoFight/AutoFightTask.cs index 625e271b..a59a3185 100644 --- a/BetterGenshinImpact/GameTask/AutoFight/AutoFightTask.cs +++ b/BetterGenshinImpact/GameTask/AutoFight/AutoFightTask.cs @@ -680,15 +680,20 @@ public class AutoFightTask : ISoloTask await Delay(200, ct); if (picker.TrySwitch(10)) { + // 等待元素战技 CD 就绪 await picker.WaitSkillCd(ct); - picker.UseSkill(true); - await Delay(50, ct); - Simulation.SendInput.SimulateAction(GIActions.NormalAttack); - await Delay(100, ct); - Simulation.SendInput.SimulateAction(GIActions.NormalAttack); - await Delay(100, ct); - Simulation.SendInput.SimulateAction(GIActions.NormalAttack); + + // 调用统一的辅助方法,模拟万叶长按 E 的输入序列: + // 包含释放鼠标左键前摇防卡键 -> E 键 KeyDown -> 延时 800ms -> E 键 KeyUp -> 延时 50ms + await SimulateHoldElementalSkillAsync(800, ct); + + // 调用统一的辅助方法,模拟 6 次鼠标左键连续点击: + // 配合万叶长 E 的滞空特性执行下落攻击,内部包含 try/finally 以保证取消任务时安全释放左键 + await SimulateMouseLeftClickLoopAsync(6, ct); + + // 等待下落攻击和聚怪拾取动作彻底结束 await Delay(1500, ct); + // 截图并更新技能最新冷却时间 picker.AfterUseSkill(); } } diff --git a/BetterGenshinImpact/GameTask/AutoFight/OneKeyFightTask.cs b/BetterGenshinImpact/GameTask/AutoFight/OneKeyFightTask.cs index 88f2864e..dbfa63db 100644 --- a/BetterGenshinImpact/GameTask/AutoFight/OneKeyFightTask.cs +++ b/BetterGenshinImpact/GameTask/AutoFight/OneKeyFightTask.cs @@ -1,4 +1,5 @@ using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Core.Simulator; using BetterGenshinImpact.GameTask.AutoFight.Model; using BetterGenshinImpact.GameTask.AutoFight.Script; using BetterGenshinImpact.Model; @@ -74,6 +75,7 @@ public class OneKeyFightTask : Singleton else { _cts.Cancel(); + Simulation.ReleaseAllKey(); } } } @@ -89,6 +91,7 @@ public class OneKeyFightTask : Singleton if (IsHoldOnMode()) { _cts?.Cancel(); + Simulation.ReleaseAllKey(); } } @@ -179,9 +182,9 @@ public class OneKeyFightTask : Singleton // 通用化战斗策略 foreach (var command in combatCommands) { + if (ct.IsCancellationRequested) break; if (command.ActivatingRound != null && command.ActivatingRound.Count > 0 && !command.ActivatingRound.Contains(round)) { - // 跳过强制首轮指令 continue; } command.Execute(activeAvatar); diff --git a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropTask.cs b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropTask.cs index bf2ba068..396fdffd 100644 --- a/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropTask.cs +++ b/BetterGenshinImpact/GameTask/AutoLeyLineOutcrop/AutoLeyLineOutcropTask.cs @@ -1,4 +1,4 @@ -using BetterGenshinImpact.Core.Recognition; +using BetterGenshinImpact.Core.Recognition; using BetterGenshinImpact.Core.Recognition.OpenCv; using BetterGenshinImpact.Core.Recognition.OCR; using BetterGenshinImpact.Core.Config; @@ -11,6 +11,7 @@ using BetterGenshinImpact.GameTask.AutoPathing.Handler; using BetterGenshinImpact.GameTask.AutoPathing.Model; using BetterGenshinImpact.GameTask.AutoTrackPath; using BetterGenshinImpact.GameTask.AutoFight; +using BetterGenshinImpact.GameTask.AutoFight.Assets; using BetterGenshinImpact.GameTask.AutoPick.Assets; using BetterGenshinImpact.GameTask.AutoFight.Model; using BetterGenshinImpact.GameTask.AutoFight.Script; @@ -1081,15 +1082,34 @@ public class AutoLeyLineOutcropTask : ISoloTask _logger.LogInformation("战后聚集拾取:万叶已切换,等待元素战技CD"); await kazuha.WaitSkillCd(_ct); - kazuha.UseSkill(true); - await Delay(50, _ct); - Simulation.SendInput.SimulateAction(GIActions.NormalAttack); - await Delay(100, _ct); - Simulation.SendInput.SimulateAction(GIActions.NormalAttack); - await Delay(100, _ct); - Simulation.SendInput.SimulateAction(GIActions.NormalAttack); + await SimulateHoldElementalSkillAsync(1000, _ct); + await Delay(200, _ct); + + // 获取游戏画面,进行 OCR 及视觉状态双重验证,以确认长E技能是否真正释放成功 + using (var region = CaptureToRectArea()) + { + // 裁剪技能 CD 区域并做 HSV 颜色过滤,分离出白色的 CD 数字 + using var eRa = region.DeriveCrop(AutoFightAssets.Instance.ECooldownRect); + using var eRaWhite = OpenCvCommonHelper.InRangeHsv(eRa.SrcMat, new Scalar(0, 0, 235), new Scalar(0, 25, 255)); + var text = OcrFactory.Paddle.OcrWithoutDetector(eRaWhite); + + // 如果成功读到了大于 0 的 CD 数值,说明技能已释放 + var hasOcrCd = double.TryParse(text, out var ocrCd) && ocrCd > 0; + // 视觉上判断当前技能图标是否高亮就绪,如果不亮(false)也说明技能释放进入了冷却 + var isVisualReady = Bv.IsSkillReady(region, kazuha.Index, false); + + // 当 OCR 没读出 CD(可能网络卡顿技能没放出来),并且视觉上技能图标依然亮着就绪时,判断为释放失败 + if (!hasOcrCd && isVisualReady) + { + _logger.LogWarning("战后聚集拾取:万叶长E释放确认失败(OCR:{Text}),跳过后续拾取动作", text); + return; + } + + // 更新技能冷却记录,防止干扰后续冷却判断 + kazuha.AfterUseSkill(region); + } + await SimulateMouseLeftClickLoopAsync(6, _ct); await Delay(1500, _ct); - kazuha.AfterUseSkill(); _logger.LogInformation("战后聚集拾取:万叶长E动作完成,等待拾取动作结束"); await Delay(KazuhaPickupPostSkillWaitMs, _ct); _logger.LogInformation("战后聚集拾取:万叶长E聚集动作执行完成"); diff --git a/BetterGenshinImpact/GameTask/AutoPathing/Handler/PickUpCollectHandler.cs b/BetterGenshinImpact/GameTask/AutoPathing/Handler/PickUpCollectHandler.cs index 5328eb7e..c9d8503f 100644 --- a/BetterGenshinImpact/GameTask/AutoPathing/Handler/PickUpCollectHandler.cs +++ b/BetterGenshinImpact/GameTask/AutoPathing/Handler/PickUpCollectHandler.cs @@ -25,7 +25,7 @@ public class PickUpCollectHandler : IActionHandler /// public static readonly string[] PickUpActions = [ - "枫原万叶-长E attack(0.08),keydown(E),wait(1),keyup(E),attack(0.5)", + "枫原万叶-长E attack(0.08),keydown(E),wait(0.8),keyup(E),attack(0.5)", "枫原万叶-短E attack(0.08),keydown(E),wait(0.47),keyup(E),attack(0.5)", "琴-短E wait(0.1),keydown(E),wait(0.4),moveby(1000,0),wait(0.2),moveby(1000,0),wait(0.2),moveby(1000,0),wait(0.2),moveby(1000,-3500),wait(1.8),keyup(E),wait(0.3),click(middle)", "琴-长E wait(0.1),click(middle),keydown(E),click(middle),wait(0.4),moveby(500,0),wait(0.1),moveby(500,0),wait(0.1)," + diff --git a/BetterGenshinImpact/GameTask/Common/Job/LinneaMiningTask.cs b/BetterGenshinImpact/GameTask/Common/Job/LinneaMiningTask.cs index dfbb98cf..30467161 100644 --- a/BetterGenshinImpact/GameTask/Common/Job/LinneaMiningTask.cs +++ b/BetterGenshinImpact/GameTask/Common/Job/LinneaMiningTask.cs @@ -67,12 +67,14 @@ public class LinneaMiningTask private readonly int _scanRounds; private readonly int _mineCount; + private readonly bool _preferRight; private int _debugIndex; public LinneaMiningTask(int scanRounds = DefaultScanRounds, int mineCount = DefaultMineCount) { _scanRounds = scanRounds; _mineCount = mineCount; + _preferRight = scanRounds > 1; _predictor = App.ServiceProvider.GetRequiredService() .CreateYoloPredictor(BgiOnnxModel.BgiMine); ClusterDistanceThreshold = BaseClusterDistance * _widthScale; @@ -359,7 +361,7 @@ public class LinneaMiningTask } } - clusters.Add(new MineralCluster(rect, AreaRatioThreshold)); + clusters.Add(new MineralCluster(rect, AreaRatioThreshold, _preferRight)); } return clusters; @@ -379,14 +381,16 @@ public class MineralCluster public double TargetWidth { get; private set; } public double TargetHeight { get; private set; } - public MineralCluster(Rect firstRect, double areaRatioThreshold = 5) + public MineralCluster(Rect firstRect, double areaRatioThreshold = 5, bool preferRight = true) { AreaRatioThreshold = areaRatioThreshold; + PreferRight = preferRight; Rects.Add(firstRect); RecalculateCenter(); } private readonly double AreaRatioThreshold; + private readonly bool PreferRight; public bool TryAddRect(Rect rect) { @@ -403,19 +407,28 @@ public class MineralCluster CenterX = Rects.Average(r => r.X + r.Width / 2.0); CenterY = Rects.Average(r => r.Y + r.Height / 2.0); - // 按距离质心排序,取最近2个中靠右的 - var candidates = Rects + var sorted = Rects .Select(r => (cx: r.X + r.Width / 2.0, cy: r.Y + r.Height / 2.0, dist: Math.Pow(r.X + r.Width / 2.0 - CenterX, 2) + Math.Pow(r.Y + r.Height / 2.0 - CenterY, 2), w: (double)r.Width, h: (double)r.Height)) .OrderBy(t => t.dist) - .Take(2) - .OrderByDescending(t => t.cx) - .First(); + .ToList(); - TargetX = candidates.cx; - TargetY = candidates.cy; - TargetWidth = candidates.w; - TargetHeight = candidates.h; + (double cx, double cy, double dist, double w, double h) target; + if (PreferRight && sorted.Count >= 2) + { + // 多轮旋转时:取最近2个中靠右的,对冲左转偏移 + target = sorted.Take(2).OrderByDescending(t => t.cx).First(); + } + else + { + // 单轮时:直接选最近的 + target = sorted.First(); + } + + TargetX = target.cx; + TargetY = target.cy; + TargetWidth = target.w; + TargetHeight = target.h; } } diff --git a/BetterGenshinImpact/GameTask/Common/NewRetry.cs b/BetterGenshinImpact/GameTask/Common/NewRetry.cs index e6ddfd2c..8d8ac8bb 100644 --- a/BetterGenshinImpact/GameTask/Common/NewRetry.cs +++ b/BetterGenshinImpact/GameTask/Common/NewRetry.cs @@ -1,4 +1,4 @@ -using BetterGenshinImpact.GameTask.AutoGeniusInvokation.Exception; +using BetterGenshinImpact.GameTask.AutoGeniusInvokation.Exception; using System; using System.Collections.Generic; using System.Linq; @@ -89,6 +89,28 @@ public static class NewRetry return false; } + /// + /// 重试执行异步 action,直到返回 true 或达到最大重试次数。 + /// + /// 判断条件(异步) + /// 取消令牌 + /// 最大重试次数 + /// 每次重试间隔(毫秒) + /// 是否成功 + public static async Task WaitForAction(Func> action, CancellationToken ct, int retryTimes = 10, int delayMs = 1000) + { + for (var i = 0; i < retryTimes; i++) + { + await TaskControl.Delay(delayMs, ct); + if (await action()) + { + return true; + } + } + + return false; + } + /// /// 重试直到某个元素出现,可执行键盘或鼠标操作。 /// diff --git a/BetterGenshinImpact/GameTask/Common/TaskControl.cs b/BetterGenshinImpact/GameTask/Common/TaskControl.cs index f582bce0..ed1b4cc1 100644 --- a/BetterGenshinImpact/GameTask/Common/TaskControl.cs +++ b/BetterGenshinImpact/GameTask/Common/TaskControl.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Drawing; using System.Threading; using System.Threading.Tasks; @@ -9,6 +9,7 @@ using Fischless.GameCapture; using Microsoft.Extensions.Logging; using OpenCvSharp; using Vanara.PInvoke; +using BetterGenshinImpact.Core.Simulator.Extensions; namespace BetterGenshinImpact.GameTask.Common; @@ -184,6 +185,90 @@ public class TaskControl } } + /// + /// 模拟长按指定动作。使用 try/finally 块确保在任务被取消或发生异常时,按键也能安全释放,防止卡键。 + /// + /// 需要模拟的游戏动作(如元素战技、普通攻击等) + /// 长按持续的时间(毫秒) + /// 用于监控任务取消的取消令牌 + public static async Task SimulateHoldActionAsync(GIActions action, int holdMs, CancellationToken ct) + { + try + { + Simulation.SendInput.SimulateAction(action, KeyType.KeyDown); + await Delay(holdMs, ct); + } + finally + { + Simulation.SendInput.SimulateAction(action, KeyType.KeyUp); + } + } + + /// + /// 模拟长按元素战技(如万叶长E)。包含释放前摇、长按以及释放后的缓冲延时。 + /// + /// 元素战技按住的时间(毫秒) + /// 用于监控任务取消的取消令牌 + /// 是否在按下元素战技前先松开鼠标左键,避免输入冲突,默认 true + /// 松开鼠标左键后的缓冲时间(毫秒),默认 10ms + /// 元素战技释放后的缓冲时间(毫秒),默认 50ms + public static async Task SimulateHoldElementalSkillAsync( + int holdMs, + CancellationToken ct, + bool releaseLeftMouseBefore = true, + int releaseLeftMouseDelayMs = 10, + int postKeyUpDelayMs = 50) + { + if (releaseLeftMouseBefore) + { + Simulation.SendInput.Mouse.LeftButtonUp(); + await Delay(releaseLeftMouseDelayMs, ct); + } + + await SimulateHoldActionAsync(GIActions.ElementalSkill, holdMs, ct); + await Delay(postKeyUpDelayMs, ct); + } + + /// + /// 模拟鼠标左键连续点击循环(如万叶长E后的下落攻击)。双层 try/finally 设计以确保无论在循环的哪个阶段发生取消或异常,鼠标左键都会被强制释放。 + /// + /// 需要循环点击的次数 + /// 用于监控任务取消的取消令牌 + /// 每次点击前,预先抬起左键后的缓冲延时(毫秒),默认 10ms + /// 鼠标左键按下的保持时间(毫秒),默认 35ms + /// 每次点击完成后的等待时间(毫秒),默认 50ms + public static async Task SimulateMouseLeftClickLoopAsync( + int repeatCount, + CancellationToken ct, + int preUpDelayMs = 10, + int downHoldMs = 35, + int postUpDelayMs = 50) + { + try + { + for (var i = 0; i < repeatCount; i++) + { + Simulation.SendInput.Mouse.LeftButtonUp(); + await Delay(preUpDelayMs, ct); + Simulation.SendInput.Mouse.LeftButtonDown(); + try + { + await Delay(downHoldMs, ct); + } + finally + { + Simulation.SendInput.Mouse.LeftButtonUp(); + } + + await Delay(postUpDelayMs, ct); + } + } + finally + { + Simulation.SendInput.Mouse.LeftButtonUp(); + } + } + public static Mat CaptureGameImage(IGameCapture? gameCapture) { var image = gameCapture?.Capture(); @@ -225,4 +310,4 @@ public class TaskControl var content = new CaptureContent(image, 0, 0); return content.CaptureRectArea; } -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/GameTask/TaskRunner.cs b/BetterGenshinImpact/GameTask/TaskRunner.cs index d48ab1ac..1e7dd6d0 100644 --- a/BetterGenshinImpact/GameTask/TaskRunner.cs +++ b/BetterGenshinImpact/GameTask/TaskRunner.cs @@ -190,6 +190,7 @@ public class TaskRunner TaskTriggerDispatcher.Instance().SetTriggers(GameTaskManager.LoadInitialTriggers()); VisionContext.Instance().DrawContent.ClearAll(); + HtmlMaskWindow.CloseAll(); } } diff --git a/BetterGenshinImpact/View/HtmlMaskWindow.xaml b/BetterGenshinImpact/View/HtmlMaskWindow.xaml index 7a9c69af..4d6f4b72 100644 --- a/BetterGenshinImpact/View/HtmlMaskWindow.xaml +++ b/BetterGenshinImpact/View/HtmlMaskWindow.xaml @@ -9,7 +9,9 @@ WindowStartupLocation="Manual" ShowInTaskbar="False" Topmost="True"> - - - + + + + + diff --git a/BetterGenshinImpact/View/HtmlMaskWindow.xaml.cs b/BetterGenshinImpact/View/HtmlMaskWindow.xaml.cs index 6037d78f..a9874eef 100644 --- a/BetterGenshinImpact/View/HtmlMaskWindow.xaml.cs +++ b/BetterGenshinImpact/View/HtmlMaskWindow.xaml.cs @@ -17,7 +17,7 @@ using Microsoft.Web.WebView2.Core; namespace BetterGenshinImpact.View; /// -/// HTML遮罩窗口 - 仅用于显示,不可交互(点击穿透) +/// HTML遮罩窗口 /// public partial class HtmlMaskWindow : Window { @@ -29,12 +29,21 @@ public partial class HtmlMaskWindow : Window private readonly string _webView2DataPath; private readonly string _pageUrl; private bool _navigationCompleted; + private bool _styleCaptured; + private int _originalStyle; + private volatile bool _isClickThrough = true; + private readonly System.Windows.Media.SolidColorBrush _backgroundBrush = new(); /// /// 窗口唯一标识 /// public string MaskId => _id; + /// + /// 当前是否处于点击穿透模式 + /// + public bool IsClickThrough => _isClickThrough; + private HtmlMaskWindow(string url, string? id, string workDir) { _id = id ?? Guid.NewGuid().ToString("N"); @@ -42,6 +51,7 @@ public partial class HtmlMaskWindow : Window _webView2DataPath = Path.Combine(workDir, "WebView2Data"); _pageUrl = url; InitializeComponent(); + ClickThroughBorder.Background = _backgroundBrush; Loaded += OnLoaded; InitializeAsync(url); } @@ -156,6 +166,47 @@ public partial class HtmlMaskWindow : Window return _windows.ContainsKey(id); } + /// + /// 获取窗口实例,不存在则抛出异常 + /// + /// 窗口ID + /// 窗口实例 + private static HtmlMaskWindow GetWindowOrThrow(string windowId) + { + if (_windows.TryGetValue(windowId, out var window)) + return window; + throw new InvalidOperationException($"HTML遮罩窗口不存在或已关闭: {windowId}"); + } + + /// + /// 设置指定窗口的点击穿透模式 + /// + /// 窗口ID + /// true=点击穿透,false=可交互 + public static void SetClickThrough(string windowId, bool enabled) + { + GetWindowOrThrow(windowId).SetClickThroughMode(enabled); + } + + /// + /// 获取指定窗口的点击穿透状态 + /// + /// 窗口ID + /// 点击穿透状态 + public static bool GetClickThrough(string windowId) + { + return GetWindowOrThrow(windowId).IsClickThrough; + } + + /// + /// 原子切换指定窗口的点击穿透模式 + /// + /// 窗口ID + public static void ToggleClickThrough(string windowId) + { + GetWindowOrThrow(windowId).ToggleClickThroughCore(); + } + /// /// 通知窗口刷新待推送的消息 /// @@ -164,13 +215,24 @@ public partial class HtmlMaskWindow : Window if (!_windows.TryGetValue(windowId, out var window)) return; window.Dispatcher.BeginInvoke(() => { - // 页面还没加载完,消息留在队列中由 NavigationCompleted 统一推送 - if (!window._navigationCompleted) return; - if (window.WebView.CoreWebView2 == null) return; - HtmlMask.FlushPendingMessages(windowId, json => + try { - window.WebView.CoreWebView2.PostWebMessageAsString(json); - }); + // 页面还没加载完,消息留在队列中由 NavigationCompleted 统一推送 + if (!window._navigationCompleted) return; + if (window.WebView.CoreWebView2 == null) return; + HtmlMask.FlushPendingMessages(windowId, json => + { + window.WebView.CoreWebView2.PostWebMessageAsString(json); + }); + } + catch (ObjectDisposedException) + { + // WebView 已被释放,忽略此消息推送 + } + catch (Exception ex) + { + TaskControl.Logger.LogDebug(ex, "HTML遮罩窗口消息推送异常"); + } }); } @@ -178,7 +240,7 @@ public partial class HtmlMaskWindow : Window private void OnLoaded(object sender, RoutedEventArgs e) { - SetClickThrough(); + SetClickThrough(true); UpdatePosition(); } @@ -387,14 +449,87 @@ public partial class HtmlMaskWindow : Window } /// - /// 设置点击穿透 + /// 设置点击穿透模式 /// - private void SetClickThrough() + /// true=点击穿透,false=可交互 + private void SetClickThrough(bool enabled) { var hwnd = new WindowInteropHelper(this).Handle; - var style = (int)GetWindowLong(hwnd, WindowLongFlags.GWL_EXSTYLE); - User32.SetWindowLong(hwnd, WindowLongFlags.GWL_EXSTYLE, - (IntPtr)(style | (int)User32.WindowStylesEx.WS_EX_TRANSPARENT)); + if (hwnd == IntPtr.Zero) return; + + if (!_styleCaptured) + { + _originalStyle = (int)GetWindowLong(hwnd, WindowLongFlags.GWL_EXSTYLE); + _styleCaptured = true; + } + + int newStyle = enabled + ? (_originalStyle | (int)User32.WindowStylesEx.WS_EX_TRANSPARENT | (int)User32.WindowStylesEx.WS_EX_LAYERED) + : _originalStyle; + + User32.SetWindowLong(hwnd, WindowLongFlags.GWL_EXSTYLE, (IntPtr)newStyle); + _isClickThrough = enabled; + + SetBackgroundOpacity(!enabled); + + if (!enabled) + { + // 禁用穿透:激活遮罩窗口,确保获得键盘和鼠标焦点 + try + { + User32.SetForegroundWindow(hwnd); + User32.BringWindowToTop(hwnd); + Dispatcher.Invoke(() => Activate()); + } + catch (Exception ex) + { + TaskControl.Logger.LogDebug(ex, "HTML遮罩窗口激活失败"); + } + } + else + { + // 开启穿透:将焦点还给游戏窗口 + try + { + var gameHandle = TaskContext.Instance().GameHandle; + if (gameHandle != IntPtr.Zero) + { + SystemControl.FocusWindow(gameHandle); + } + } + catch (Exception ex) + { + TaskControl.Logger.LogDebug(ex, "HTML遮罩恢复游戏焦点失败"); + } + } + } + + /// + /// 设置背景透明度 + /// + /// 是否处于交互模式 + private void SetBackgroundOpacity(bool isInteractive) + { + _backgroundBrush.Color = isInteractive + ? System.Windows.Media.Color.FromArgb(1, 0, 0, 0) + : System.Windows.Media.Color.FromArgb(0, 0, 0, 0); + } + + /// + /// 设置点击穿透模式 + /// + /// true=点击穿透,false=可交互 + public void SetClickThroughMode(bool enabled) + { + Dispatcher.Invoke(() => SetClickThrough(enabled)); + } + + /// + /// 切换点击穿透模式 + /// + private void ToggleClickThroughCore() + { + Dispatcher.Invoke(() => SetClickThrough(!_isClickThrough)); } /// diff --git a/BetterGenshinImpact/ViewModel/Pages/OneDragonFlowViewModel.cs b/BetterGenshinImpact/ViewModel/Pages/OneDragonFlowViewModel.cs index 4cec56c1..1b9024ea 100644 --- a/BetterGenshinImpact/ViewModel/Pages/OneDragonFlowViewModel.cs +++ b/BetterGenshinImpact/ViewModel/Pages/OneDragonFlowViewModel.cs @@ -229,7 +229,7 @@ public partial class OneDragonFlowViewModel : ViewModel [ObservableProperty] private List _domainNameList = ["", ..MapLazyAssets.Instance.DomainNameList]; - [ObservableProperty] private List _completionActionList = ["无", "关闭游戏", "关闭游戏和软件", "关机"]; + [ObservableProperty] private List _completionActionList = ["无", "关闭游戏", "关闭软件", "关闭游戏和软件", "关机"]; [ObservableProperty] private List _sundayEverySelectedValueList = ["","1", "2", "3"]; @@ -650,6 +650,9 @@ public partial class OneDragonFlowViewModel : ViewModel case "关闭游戏": SystemControl.CloseGame(); break; + case "关闭软件": + Application.Current.Dispatcher.Invoke(() => { Application.Current.Shutdown(); }); + break; case "关闭游戏和软件": SystemControl.CloseGame(); Application.Current.Dispatcher.Invoke(() => { Application.Current.Shutdown(); }); @@ -820,4 +823,4 @@ public partial class OneDragonFlowViewModel : ViewModel Toast.Error("重命名配置时失败"); } } -} \ No newline at end of file +} diff --git a/Fischless.GameCapture/Fischless.GameCapture.csproj b/Fischless.GameCapture/Fischless.GameCapture.csproj index 5ce2520b..75032828 100644 --- a/Fischless.GameCapture/Fischless.GameCapture.csproj +++ b/Fischless.GameCapture/Fischless.GameCapture.csproj @@ -22,7 +22,7 @@ - + \ No newline at end of file