Merge branch 'main' into d-v3

# Conflicts:
#	BetterGenshinImpact/BetterGenshinImpact.csproj
This commit is contained in:
辉鸭蛋
2026-05-18 00:49:07 +08:00
19 changed files with 529 additions and 79 deletions

View File

@@ -88,7 +88,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.RichTextBoxEx.Wpf" Version="1.1.0.1" />
<!-- <PackageReference Include="supabase-csharp" Version="0.16.2" />-->
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.7" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="System.IO.Hashing" Version="9.0.4" />
<PackageReference Include="TorchSharp" Version="0.105.0" />
@@ -96,10 +96,10 @@
<PackageReference Include="Vanara.PInvoke.SHCore" Version="4.1.3" />
<PackageReference Include="Vanara.PInvoke.User32" Version="4.1.3" />
<PackageReference Include="XamlAnimatedGif" Version="2.3.1" />
<PackageReference Include="WPF-UI" Version="4.2.0" />
<PackageReference Include="WPF-UI.DependencyInjection" Version="4.2.0" />
<PackageReference Include="WPF-UI.Tray" Version="4.2.0" />
<PackageReference Include="WPF-UI.Violeta" Version="4.2.0.10" />
<PackageReference Include="WPF-UI" Version="4.3.0" />
<PackageReference Include="WPF-UI.DependencyInjection" Version="4.3.0" />
<PackageReference Include="WPF-UI.Tray" Version="4.3.0" />
<PackageReference Include="WPF-UI.Violeta" Version="4.3.0.0" />
<PackageReference Include="gong-wpf-dragdrop" Version="3.2.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="YoloSharp" Version="6.0.3" />

View File

@@ -21,10 +21,12 @@ public class BvLocator
{
private static readonly ILogger Logger = App.GetLogger<BvLocator>();
private readonly CancellationToken _cancellationToken;
private int? _timeout;
private int? _retryInterval;
public RecognitionObject RecognitionObject { get; }
public Action<List<Region>>? RetryAction { get; set; }
public Func<List<Region>, Task>? RetryAction { get; set; }
public static int DefaultTimeout { get; set; } = 10000;
@@ -98,17 +100,21 @@ public class BvLocator
public async Task<List<Region>> 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<Region> 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<List<Region>>? action)
{
RetryAction = action;
if (action == null)
{
RetryAction = null;
}
else
{
RetryAction = (results) =>
{
action(results);
return Task.CompletedTask;
};
}
return this;
}
/// <summary>
/// 设置超时时间(毫秒)
/// </summary>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns></returns>
public BvLocator WithTimeout(int timeout)
{
if (timeout <= 0)
{
throw new ArgumentOutOfRangeException(nameof(timeout), "timeout 必须大于 0");
}
_timeout = timeout;
return this;
}
/// <summary>
/// 设置重试间隔(毫秒)
/// </summary>
/// <param name="retryInterval">重试间隔(毫秒)</param>
/// <returns></returns>
public BvLocator WithRetryInterval(int retryInterval)
{
if (retryInterval <= 0)
{
throw new ArgumentOutOfRangeException(nameof(retryInterval), "retryInterval 必须大于 0");
}
_retryInterval = retryInterval;
return this;
}
/// <summary>
/// 为 JavaScript 提供的动态参数重载
/// 解决 ClearScript 无法将 JS 函数隐式转换为 Action 委托的问题
/// 支持同步和异步 JS 函数
/// </summary>
/// <param name="action">JS 回调函数</param>
/// <returns></returns>
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;
}
}
}

View File

@@ -88,6 +88,7 @@ public class AutoPathingScript
/// <summary>
/// 读取 AutoPathing 目录下指定文件夹的内容(非递归方式)
/// 目录不存在时返回空数组,不会自动创建目录
/// </summary>
/// <param name="subPath">相对于 User\AutoPathing 的子目录路径,默认为相对根目录</param>
/// <returns>文件夹内所有文件和文件夹的相对路径数组,出错时返回空数组</returns>

View File

@@ -145,6 +145,35 @@ public class HtmlMask : IDisposable
/// </summary>
public bool Exists(string id) => HtmlMaskWindow.Exists(id);
/// <summary>
/// 设置窗口的点击穿透模式
/// </summary>
/// <param name="windowId">窗口ID</param>
/// <param name="enabled">true=点击穿透false=可交互</param>
public void SetClickThrough(string windowId, bool enabled)
{
HtmlMaskWindow.SetClickThrough(windowId, enabled);
}
/// <summary>
/// 获取窗口的点击穿透状态
/// </summary>
/// <param name="windowId">窗口ID</param>
/// <returns>true=点击穿透false=可交互</returns>
public bool GetClickThrough(string windowId)
{
return HtmlMaskWindow.GetClickThrough(windowId);
}
/// <summary>
/// 切换窗口的点击穿透模式
/// </summary>
/// <param name="windowId">窗口ID</param>
public void ToggleClickThrough(string windowId)
{
HtmlMaskWindow.ToggleClickThrough(windowId);
}
#endregion
#region

View File

@@ -15,6 +15,7 @@ public class LimitedFile(string rootPath)
{
/// <summary>
/// 读取指定文件夹内所有文件和文件夹的路径(非递归方式)。
/// 目录不存在时返回空数组
/// </summary>
/// <param name="folderPath">文件夹路径(相对于根目录)</param>
/// <returns>文件夹内所有文件和文件夹的路径数组</returns>
@@ -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>();
}
// 获取指定文件夹下的所有文件(非递归)
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<string>();
}
}
/// <summary>
/// 创建指定路径的目录,如果已存在则跳过
/// </summary>
/// <param name="folderPath">文件夹路径(相对于根目录)</param>
/// <returns>是否创建成功或目录已存在</returns>
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;
}
}
/// <summary>
/// 判断指定路径是否为文件夹。
/// </summary>

View File

@@ -0,0 +1,43 @@
using System;
using System.Linq;
using BetterGenshinImpact.Core.Config;
namespace BetterGenshinImpact.Core.Script.Dependence;
/// <summary>
/// 战斗策略文件访问类
/// 提供JS脚本环境访问 User\AutoFight 目录下战斗策略文件的方法
/// </summary>
public class StrategyFile
{
private readonly LimitedFile _strategyFile = new(Global.Absolute(@"User\AutoFight"));
/// <summary>
/// 判断 User\AutoFight 目录下的路径是否为文件夹
/// </summary>
/// <param name="subPath">相对于 User\AutoFight 的路径</param>
/// <returns>是文件夹返回 true否则返回 false</returns>
public bool IsFolder(string subPath) => _strategyFile.IsFolder(subPath);
/// <summary>
/// 判断 User\AutoFight 目录下的路径是否为文件
/// </summary>
/// <param name="subPath">相对于 User\AutoFight 的路径</param>
/// <returns>是文件返回 true否则返回 false</returns>
public bool IsFile(string subPath) => _strategyFile.IsFile(subPath);
/// <summary>
/// 判断 User\AutoFight 目录下的路径是否存在
/// </summary>
/// <param name="subPath">相对于 User\AutoFight 的路径</param>
/// <returns>存在返回 true否则返回 false</returns>
public bool IsExists(string subPath) => _strategyFile.IsExists(subPath);
/// <summary>
/// 读取 User\AutoFight 目录下指定文件夹的内容(非递归方式)
/// 目录不存在时返回空数组,不会自动创建目录
/// </summary>
/// <param name="subPath">相对于 User\AutoFight 的子目录路径,默认为根目录</param>
/// <returns>文件夹内所有文件和文件夹的相对路径数组,出错时返回空数组</returns>
public string[] ReadPathSync(string subPath = "./") => _strategyFile.ReadPathSync(subPath);
}

View File

@@ -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#的类型

View File

@@ -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();
}
}

View File

@@ -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<OneKeyFightTask>
else
{
_cts.Cancel();
Simulation.ReleaseAllKey();
}
}
}
@@ -89,6 +91,7 @@ public class OneKeyFightTask : Singleton<OneKeyFightTask>
if (IsHoldOnMode())
{
_cts?.Cancel();
Simulation.ReleaseAllKey();
}
}
@@ -179,9 +182,9 @@ public class OneKeyFightTask : Singleton<OneKeyFightTask>
// 通用化战斗策略
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);

View File

@@ -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聚集动作执行完成");

View File

@@ -25,7 +25,7 @@ public class PickUpCollectHandler : IActionHandler
/// </summary>
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)," +

View File

@@ -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<BgiOnnxFactory>()
.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;
}
}

View File

@@ -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;
}
/// <summary>
/// 重试执行异步 action直到返回 true 或达到最大重试次数。
/// </summary>
/// <param name="action">判断条件(异步)</param>
/// <param name="ct">取消令牌</param>
/// <param name="retryTimes">最大重试次数</param>
/// <param name="delayMs">每次重试间隔(毫秒)</param>
/// <returns>是否成功</returns>
public static async Task<bool> WaitForAction(Func<Task<bool>> 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;
}
/// <summary>
/// 重试直到某个元素出现,可执行键盘或鼠标操作。
/// </summary>

View File

@@ -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
}
}
/// <summary>
/// 模拟长按指定动作。使用 try/finally 块确保在任务被取消或发生异常时,按键也能安全释放,防止卡键。
/// </summary>
/// <param name="action">需要模拟的游戏动作(如元素战技、普通攻击等)</param>
/// <param name="holdMs">长按持续的时间(毫秒)</param>
/// <param name="ct">用于监控任务取消的取消令牌</param>
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);
}
}
/// <summary>
/// 模拟长按元素战技如万叶长E。包含释放前摇、长按以及释放后的缓冲延时。
/// </summary>
/// <param name="holdMs">元素战技按住的时间(毫秒)</param>
/// <param name="ct">用于监控任务取消的取消令牌</param>
/// <param name="releaseLeftMouseBefore">是否在按下元素战技前先松开鼠标左键,避免输入冲突,默认 true</param>
/// <param name="releaseLeftMouseDelayMs">松开鼠标左键后的缓冲时间(毫秒),默认 10ms</param>
/// <param name="postKeyUpDelayMs">元素战技释放后的缓冲时间(毫秒),默认 50ms</param>
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);
}
/// <summary>
/// 模拟鼠标左键连续点击循环如万叶长E后的下落攻击。双层 try/finally 设计以确保无论在循环的哪个阶段发生取消或异常,鼠标左键都会被强制释放。
/// </summary>
/// <param name="repeatCount">需要循环点击的次数</param>
/// <param name="ct">用于监控任务取消的取消令牌</param>
/// <param name="preUpDelayMs">每次点击前,预先抬起左键后的缓冲延时(毫秒),默认 10ms</param>
/// <param name="downHoldMs">鼠标左键按下的保持时间(毫秒),默认 35ms</param>
/// <param name="postUpDelayMs">每次点击完成后的等待时间(毫秒),默认 50ms</param>
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;
}
}
}

View File

@@ -190,6 +190,7 @@ public class TaskRunner
TaskTriggerDispatcher.Instance().SetTriggers(GameTaskManager.LoadInitialTriggers());
VisionContext.Instance().DrawContent.ClearAll();
HtmlMaskWindow.CloseAll();
}
}

View File

@@ -9,7 +9,9 @@
WindowStartupLocation="Manual"
ShowInTaskbar="False"
Topmost="True">
<Grid>
<wv2:WebView2 x:Name="WebView" DefaultBackgroundColor="Transparent"/>
</Grid>
<Border x:Name="ClickThroughBorder" Background="Transparent">
<Grid>
<wv2:WebView2 x:Name="WebView" DefaultBackgroundColor="Transparent"/>
</Grid>
</Border>
</Window>

View File

@@ -17,7 +17,7 @@ using Microsoft.Web.WebView2.Core;
namespace BetterGenshinImpact.View;
/// <summary>
/// HTML遮罩窗口 - 仅用于显示,不可交互(点击穿透)
/// HTML遮罩窗口
/// </summary>
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();
/// <summary>
/// 窗口唯一标识
/// </summary>
public string MaskId => _id;
/// <summary>
/// 当前是否处于点击穿透模式
/// </summary>
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);
}
/// <summary>
/// 获取窗口实例,不存在则抛出异常
/// </summary>
/// <param name="windowId">窗口ID</param>
/// <returns>窗口实例</returns>
private static HtmlMaskWindow GetWindowOrThrow(string windowId)
{
if (_windows.TryGetValue(windowId, out var window))
return window;
throw new InvalidOperationException($"HTML遮罩窗口不存在或已关闭: {windowId}");
}
/// <summary>
/// 设置指定窗口的点击穿透模式
/// </summary>
/// <param name="windowId">窗口ID</param>
/// <param name="enabled">true=点击穿透false=可交互</param>
public static void SetClickThrough(string windowId, bool enabled)
{
GetWindowOrThrow(windowId).SetClickThroughMode(enabled);
}
/// <summary>
/// 获取指定窗口的点击穿透状态
/// </summary>
/// <param name="windowId">窗口ID</param>
/// <returns>点击穿透状态</returns>
public static bool GetClickThrough(string windowId)
{
return GetWindowOrThrow(windowId).IsClickThrough;
}
/// <summary>
/// 原子切换指定窗口的点击穿透模式
/// </summary>
/// <param name="windowId">窗口ID</param>
public static void ToggleClickThrough(string windowId)
{
GetWindowOrThrow(windowId).ToggleClickThroughCore();
}
/// <summary>
/// 通知窗口刷新待推送的消息
/// </summary>
@@ -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
}
/// <summary>
/// 设置点击穿透
/// 设置点击穿透模式
/// </summary>
private void SetClickThrough()
/// <param name="enabled">true=点击穿透false=可交互</param>
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遮罩恢复游戏焦点失败");
}
}
}
/// <summary>
/// 设置背景透明度
/// </summary>
/// <param name="isInteractive">是否处于交互模式</param>
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);
}
/// <summary>
/// 设置点击穿透模式
/// </summary>
/// <param name="enabled">true=点击穿透false=可交互</param>
public void SetClickThroughMode(bool enabled)
{
Dispatcher.Invoke(() => SetClickThrough(enabled));
}
/// <summary>
/// 切换点击穿透模式
/// </summary>
private void ToggleClickThroughCore()
{
Dispatcher.Invoke(() => SetClickThrough(!_isClickThrough));
}
/// <summary>

View File

@@ -229,7 +229,7 @@ public partial class OneDragonFlowViewModel : ViewModel
[ObservableProperty] private List<string> _domainNameList = ["", ..MapLazyAssets.Instance.DomainNameList];
[ObservableProperty] private List<string> _completionActionList = ["无", "关闭游戏", "关闭游戏和软件", "关机"];
[ObservableProperty] private List<string> _completionActionList = ["无", "关闭游戏", "关闭软件", "关闭游戏和软件", "关机"];
[ObservableProperty] private List<string> _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("重命名配置时失败");
}
}
}
}

View File

@@ -22,7 +22,7 @@
<PackageReference Include="Vanara.Windows.Extensions" Version="4.1.3" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.11.0.20250507" />
<PackageReference Include="OpenCvSharp4.Windows" Version="4.11.0.20250507" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.7" />
</ItemGroup>
</Project>