feat(地图遮罩): 新增小地图遮罩显示与自动记录路径功能

- 添加小地图遮罩显示功能,在游戏主界面显示玩家位置及附近点位
- 新增自动记录路径功能开关配置
- 实现 MiniMapPointsCanvas 控件用于小地图点位渲染
- 扩展 MapMaskTrigger 以支持小地图遮罩和路径记录逻辑
This commit is contained in:
辉鸭蛋
2026-02-24 01:44:44 +08:00
parent 847bc8970e
commit c9cd8fcd5f
6 changed files with 429 additions and 5 deletions

View File

@@ -15,6 +15,18 @@ public partial class MapMaskConfig : ObservableObject
/// </summary>
[ObservableProperty]
private bool _enabled = true;
/// <summary>
/// 小地图遮罩是否启用
/// </summary>
[ObservableProperty]
private bool _miniMapMaskEnabled = true;
/// <summary>
/// 自动记录路径功能是否启用
/// </summary>
[ObservableProperty]
private bool _pathAutoRecordEnabled = true;
private MapPointApiProvider _mapPointApiProvider = MapPointApiProvider.MihoyoMap;

View File

@@ -1,7 +1,8 @@
using System;
using System.Windows;
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;
@@ -9,6 +10,8 @@ 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,7 +26,7 @@ public class MapMaskTrigger : ITaskTrigger
public bool IsEnabled { get; set; }
public int Priority => 1; // 低优先级
public bool IsExclusive => false;
public GameUiCategory SupportedGameUiCategory => GameUiCategory.BigMap;
private readonly MapMaskConfig _config = TaskContext.Instance().Config.MapMaskConfig;
@@ -40,11 +43,13 @@ public class MapMaskTrigger : ITaskTrigger
private OpenCvSharp.Rect _prevRect = default;
private const int RectDebounceThreshold = 3;
private readonly NavigationInstance _navigationInstance = new();
public void Init()
{
IsEnabled = _config.Enabled;
// 关闭时隐藏UI
if (!IsEnabled)
{
@@ -108,8 +113,8 @@ public class MapMaskTrigger : ITaskTrigger
_prevRect = default;
return;
}
// if (_prevRect != default)
// {
// var dx = Math.Abs(rect256.X - _prevRect.X);
@@ -130,6 +135,27 @@ public class MapMaskTrigger : ITaskTrigger
}
else
{
if ((_config.MiniMapMaskEnabled || _config.PathAutoRecordEnabled) && Bv.IsInMainUi(region))
{
// 主界面上展示小地图
if (_config.MiniMapMaskEnabled)
{
var miniPoint = _navigationInstance.GetPositionStable(region, nameof(MapTypes.Teyvat), TaskContext.Instance().Config.PathingConditionConfig.MapMatchingMethod);
if (miniPoint != default)
{
UIDispatcherHelper.Invoke(() => { MaskWindow.Instance().MiniMapPointsCanvasControl.UpdateViewport(miniPoint.X, miniPoint.Y, 300, 300); });
}
// 自动记录路径
if (_config.PathAutoRecordEnabled)
{
// ...
}
}
}
_prevRect = default;
}
}

View File

@@ -0,0 +1,319 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
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
{
private readonly VisualCollection _children;
private readonly DrawingVisual _drawingVisual;
private readonly Dictionary<string, Brush> _colorBrushCache;
private int _refreshQueued;
private ObservableCollection<MaskMapPoint>? _points;
private List<MaskMapPoint> _allPoints = new();
private Dictionary<string, MaskMapPointLabel> _labelMap = new();
private Rect _viewportRect = Rect.Empty;
public MiniMapPointsCanvas()
{
_children = new VisualCollection(this);
_drawingVisual = new DrawingVisual();
_children.Add(_drawingVisual);
_colorBrushCache = new Dictionary<string, Brush>();
IsHitTestVisible = false;
MapIconImageCache.ImageUpdated += PointImageCacheManagerOnImageUpdated;
}
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<MaskMapPoint>();
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)
{
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<MaskMapPoint> 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<MaskMapPointLabel> 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();
}
}

View File

@@ -86,6 +86,35 @@
Grid.Column="0"
Grid.ColumnSpan="6"
ClipToBounds="True">
<controls:MiniMapPointsCanvas x:Name="MiniMapPointsCanvasControl"
Visibility="{Binding IsInBigMapUi, Converter={StaticResource BooleanToVisibilityRevertConverter}}"
IsHitTestVisible="False">
<Canvas.Left>
<MultiBinding Converter="{StaticResource OverlayRelativeOrAbsoluteConverter}">
<Binding Path="MiniMapOverlayLeftRatio" />
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="ActualWidth" />
</MultiBinding>
</Canvas.Left>
<Canvas.Top>
<MultiBinding Converter="{StaticResource OverlayRelativeOrAbsoluteConverter}">
<Binding Path="MiniMapOverlayTopRatio" />
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="ActualHeight" />
</MultiBinding>
</Canvas.Top>
<controls:MiniMapPointsCanvas.Width>
<MultiBinding Converter="{StaticResource OverlayRelativeOrAbsoluteConverter}">
<Binding Path="MiniMapOverlaySizeRatio" />
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="ActualHeight" />
</MultiBinding>
</controls:MiniMapPointsCanvas.Width>
<controls:MiniMapPointsCanvas.Height>
<MultiBinding Converter="{StaticResource OverlayRelativeOrAbsoluteConverter}">
<Binding Path="MiniMapOverlaySizeRatio" />
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="ActualHeight" />
</MultiBinding>
</controls:MiniMapPointsCanvas.Height>
</controls:MiniMapPointsCanvas>
<overlay:AdjustableOverlayItem x:Name="StatusWrapper"
Padding="4,2"
Background="#00000000"

View File

@@ -25,6 +25,9 @@ using System.Windows.Media;
using System.Windows.Threading;
using BetterGenshinImpact.Genshin.Settings2;
using BetterGenshinImpact.Model.MaskMap;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging.Messages;
using CvPoint2f = OpenCvSharp.Point2f;
using BetterGenshinImpact.ViewModel;
using BetterGenshinImpact.View.Windows;
using Vanara.PInvoke;
@@ -52,6 +55,7 @@ public partial class MaskWindow : Window
private MaskWindowConfig? _maskWindowConfig;
private MapLabelSearchWindow? _mapLabelSearchWindow;
private CancellationTokenSource? _mapLabelCategorySelectCts;
private CvPoint2f? _miniMapLastPosition;
static MaskWindow()
{
@@ -195,9 +199,33 @@ public partial class MaskWindow : Window
{
PointsCanvasControl.UpdateLabels(_viewModel.MapPointLabels);
PointsCanvasControl.UpdatePoints(_viewModel.MapPoints);
MiniMapPointsCanvasControl.UpdateLabels(_viewModel.MapPointLabels);
MiniMapPointsCanvasControl.UpdatePoints(_viewModel.MapPoints);
}
PointsCanvasControl.ViewportChanged += PointsCanvasControlOnViewportChanged;
WeakReferenceMessenger.Default.Register<PropertyChangedMessage<object>>(this, (sender, msg) =>
{
if (msg.PropertyName != "SendCurrentPosition")
{
return;
}
if (msg.NewValue is not CvPoint2f pos)
{
return;
}
if (pos.X <= 0 || pos.Y <= 0)
{
return;
}
_miniMapLastPosition = pos;
const double viewportSize = 512;
MiniMapPointsCanvasControl.UpdateViewport(pos.X - viewportSize / 2.0, pos.Y - viewportSize / 2.0, viewportSize, viewportSize);
});
}
private void PointsCanvasControlOnViewportChanged(object? sender, EventArgs e)
@@ -215,6 +243,8 @@ public partial class MaskWindow : Window
IsVisibleChanged -= MaskWindowOnIsVisibleChanged;
StateChanged -= MaskWindowOnStateChanged;
WeakReferenceMessenger.Default.Unregister<PropertyChangedMessage<object>>(this);
if (_maskWindowConfig != null)
{
_maskWindowConfig.PropertyChanged -= MaskWindowConfigOnPropertyChanged;
@@ -265,6 +295,7 @@ public partial class MaskWindow : Window
if (_viewModel != null)
{
PointsCanvasControl.UpdateLabels(_viewModel.MapPointLabels);
MiniMapPointsCanvasControl.UpdateLabels(_viewModel.MapPointLabels);
}
});
}
@@ -276,6 +307,7 @@ public partial class MaskWindow : Window
if (_viewModel != null)
{
PointsCanvasControl.UpdatePoints(_viewModel.MapPoints);
MiniMapPointsCanvasControl.UpdatePoints(_viewModel.MapPoints);
}
});
}

View File

@@ -78,6 +78,12 @@ namespace BetterGenshinImpact.ViewModel
[ObservableProperty] private string _mapPointsLoadingText = "正在加载点位...";
public double MiniMapOverlayLeftRatio => 67d / 1920d;
public double MiniMapOverlayTopRatio => 21d / 1080d;
public double MiniMapOverlaySizeRatio => 200d / 1080d;
public sealed record MapPointApiProviderOption(MapPointApiProvider Provider, string DisplayName);
public IReadOnlyList<MapPointApiProviderOption> MapPointApiProviderOptions { get; } =