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