Add Ehwrj clean-room live map
Some checks failed
build / build-test-publish (push) Has been cancelled

This commit is contained in:
2026-06-02 22:49:24 +09:00
parent c93ab38cbd
commit cba5243ce4
71 changed files with 5990 additions and 9 deletions

34
src/Ehwrj.App/App.axaml Normal file
View File

@@ -0,0 +1,34 @@
<Application x:Class="Ehwrj.App.App"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Styles>
<FluentTheme />
<Style Selector="Window">
<Setter Property="FontFamily" Value="Inter, Segoe UI, Arial" />
</Style>
<Style Selector="TextBlock">
<Setter Property="Foreground" Value="#E6EDF3" />
</Style>
<Style Selector="Button">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="14,6" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="#4CC9A7" />
<Setter Property="Foreground" Value="#07110F" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button.secondary">
<Setter Property="Background" Value="#2A3642" />
<Setter Property="Foreground" Value="#E6EDF3" />
</Style>
<Style Selector="Slider">
<Setter Property="Margin" Value="0,4,0,12" />
</Style>
<Style Selector="CheckBox">
<Setter Property="Foreground" Value="#E6EDF3" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
</Application.Styles>
</Application>

View File

@@ -0,0 +1,24 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace Ehwrj.App;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Ehwrj.App</RootNamespace>
<AssemblyName>Ehwrj</AssemblyName>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.7" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.7" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.7" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Ehwrj.Core/Ehwrj.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,30 @@
using System.Runtime.InteropServices;
namespace Ehwrj.App.Infrastructure;
internal static class Win32
{
private const int GwlExStyle = -20;
private const int WsExTransparent = 0x00000020;
private const int WsExToolWindow = 0x00000080;
private const int WsExLayered = 0x00080000;
private const int WsExNoActivate = 0x08000000;
public static void MakeOverlayClickThrough(IntPtr hwnd)
{
if (!OperatingSystem.IsWindows())
{
return;
}
var style = GetWindowLongPtr(hwnd, GwlExStyle);
style |= WsExTransparent | WsExToolWindow | WsExLayered | WsExNoActivate;
_ = SetWindowLongPtr(hwnd, GwlExStyle, style);
}
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)]
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)]
private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
}

View File

@@ -0,0 +1,227 @@
<Window x:Class="Ehwrj.App.MainWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:rendering="using:Ehwrj.App.Rendering"
Title="Ehwrj"
Width="1280"
Height="780"
MinWidth="980"
MinHeight="640"
Background="#0B0F14"
WindowStartupLocation="CenterScreen">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="#121A22" Padding="24">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Ehwrj" FontSize="32" FontWeight="SemiBold" />
<TextBlock Text="{Binding Ui.Subtitle}" Foreground="#93A4B3" Margin="0,4,0,18" />
<TextBlock Text="{Binding Ui.Language}" FontSize="13" Foreground="#93A4B3" />
<ComboBox ItemsSource="{Binding LanguageOptions}"
SelectedItem="{Binding SelectedLanguageOption}"
Margin="0,4,0,20">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<Button Content="{Binding Ui.Start}" Command="{Binding StartCommand}" Margin="0,0,8,0" />
<Button Content="{Binding Ui.Stop}" Classes="secondary" Command="{Binding StopCommand}" />
</StackPanel>
<TextBlock Text="{Binding Ui.Connection}" FontSize="13" Foreground="#93A4B3" />
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" Margin="0,4,0,18" />
<Expander Header="{Binding Ui.Diagnostics}" IsExpanded="False" Margin="0,0,0,6">
<ItemsControl ItemsSource="{Binding EndpointHealthEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" Foreground="#C9D3DD" FontSize="12" Margin="0,0,0,6" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
<Expander Header="{Binding Ui.Map}" IsExpanded="True" Margin="0,0,0,6">
<StackPanel>
<CheckBox Content="{Binding Ui.ShowLabels}" IsChecked="{Binding Settings.Map.ShowLabels}" />
<CheckBox Content="{Binding Ui.ShowAircraftMach}" IsChecked="{Binding Settings.Map.ShowAircraftMach}" />
<CheckBox Content="{Binding Ui.FollowPlayer}" IsChecked="{Binding Settings.Map.FollowPlayer}" />
<CheckBox Content="{Binding Ui.RotateWithPlayer}" IsChecked="{Binding Settings.Map.RotateWithPlayer}" />
<CheckBox Content="{Binding Ui.ShowRangeRings}" IsChecked="{Binding Settings.Map.ShowRangeRings}" />
<CheckBox Content="{Binding Ui.ShowBattleLog}" IsChecked="{Binding Settings.Map.ShowBattleLog}" />
<CheckBox Content="{Binding Ui.DeliveryTracker}" IsChecked="{Binding Settings.Map.ShowDeliveryTracker}" />
<TextBlock Text="{Binding Ui.DeliveryTrackerAngle}" Foreground="#93A4B3" />
<Slider Minimum="0.1" Maximum="1" Value="{Binding Settings.Map.DeliveryTrackerAngle}" />
</StackPanel>
</Expander>
<CheckBox Content="{Binding Ui.ShowOverlay}" IsChecked="{Binding IsOverlayEnabled}" />
<CheckBox Content="{Binding Ui.ShowMinimap}" IsChecked="{Binding Settings.Overlay.ShowMiniMap}" />
<CheckBox Content="{Binding Ui.ShowMachLabels}" IsChecked="{Binding Settings.Overlay.ShowMach}" />
<CheckBox Content="{Binding Ui.ShowSpotRadar}" IsChecked="{Binding Settings.Overlay.ShowSpotRadar}" />
<TextBlock Text="{Binding Ui.OverlaySize}" Foreground="#93A4B3" />
<Slider Minimum="160" Maximum="2000" Value="{Binding Settings.Overlay.Size}" />
<TextBlock Text="{Binding Ui.TopOffset}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="500" Value="{Binding Settings.Overlay.Top}" />
<TextBlock Text="{Binding Ui.RightOffset}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="2000" Value="{Binding Settings.Overlay.Right}" />
<TextBlock Text="{Binding Ui.Zoom}" Foreground="#93A4B3" />
<Slider Minimum="25" Maximum="400" Value="{Binding Settings.Overlay.ZoomPercent}" />
<Expander Header="{Binding Ui.Minimap}" IsExpanded="True" Margin="0,6,0,0">
<StackPanel>
<TextBlock Text="{Binding Ui.AircraftScale}" Foreground="#93A4B3" />
<Slider Minimum="50" Maximum="200" Value="{Binding Settings.Overlay.MiniMapAircraftScale}" />
<TextBlock Text="{Binding Ui.MinimumMach}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="2" TickFrequency="0.1" Value="{Binding Settings.Overlay.MiniMapMinimumMach}" />
</StackPanel>
</Expander>
<Expander Header="{Binding Ui.SpotRadar}" IsExpanded="True" Margin="0,4,0,0">
<StackPanel>
<CheckBox Content="{Binding Ui.ShowDistance}" IsChecked="{Binding Settings.Overlay.SpotShowDistance}" />
<CheckBox Content="{Binding Ui.ShowMach}" IsChecked="{Binding Settings.Overlay.SpotShowMach}" />
<CheckBox Content="{Binding Ui.ShowClosureSpeed}" IsChecked="{Binding Settings.Overlay.SpotShowRelativeSpeed}" />
<TextBlock Text="{Binding Ui.RadarRangeKm}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="30" Value="{Binding Settings.Overlay.SpotDetectDistanceKm}" />
<TextBlock Text="{Binding Ui.RadarSpread}" Foreground="#93A4B3" />
<Slider Minimum="40" Maximum="600" Value="{Binding Settings.Overlay.SpotDistance}" />
<TextBlock Text="{Binding Ui.MinimumMach}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="2" TickFrequency="0.1" Value="{Binding Settings.Overlay.SpotMinimumMach}" />
<TextBlock Text="{Binding Ui.MarkerOpacity}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="100" Value="{Binding Settings.Overlay.SpotOpacity}" />
<TextBlock Text="{Binding Ui.FontOpacity}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="100" Value="{Binding Settings.Overlay.SpotFontOpacity}" />
<TextBlock Text="{Binding Ui.VerticalScale}" Foreground="#93A4B3" />
<Slider Minimum="40" Maximum="140" Value="{Binding Settings.Overlay.SpotVerticalScale}" />
<TextBlock Text="{Binding Ui.VerticalOffset}" Foreground="#93A4B3" />
<Slider Minimum="-500" Maximum="500" Value="{Binding Settings.Overlay.SpotVerticalOffset}" />
<TextBlock Text="{Binding Ui.ArrowScale}" Foreground="#93A4B3" />
<Slider Minimum="50" Maximum="200" Value="{Binding Settings.Overlay.SpotArrowScale}" />
<TextBlock Text="{Binding Ui.ArrowOutline}" Foreground="#93A4B3" />
<Slider Minimum="0" Maximum="10" Value="{Binding Settings.Overlay.SpotOutlineWidth}" />
<TextBlock Text="{Binding Ui.DistanceFontSize}" Foreground="#93A4B3" />
<Slider Minimum="10" Maximum="60" Value="{Binding Settings.Overlay.DistanceFontSize}" />
<TextBlock Text="{Binding Ui.MachFontSize}" Foreground="#93A4B3" />
<Slider Minimum="10" Maximum="60" Value="{Binding Settings.Overlay.MachFontSize}" />
<TextBlock Text="{Binding Ui.ClosureFontSize}" Foreground="#93A4B3" />
<Slider Minimum="10" Maximum="60" Value="{Binding Settings.Overlay.RelativeFontSize}" />
<TextBlock Text="{Binding Ui.ArrowColor}" Foreground="#93A4B3" />
<TextBox Text="{Binding Settings.Overlay.SpotArrowColor}" Watermark="#ff1e1e" Margin="0,4,0,10" />
<TextBlock Text="{Binding Ui.DistanceColor}" Foreground="#93A4B3" />
<TextBox Text="{Binding Settings.Overlay.DistanceTextColor}" Watermark="#ff1e1e" Margin="0,4,0,10" />
<TextBlock Text="{Binding Ui.MachColor}" Foreground="#93A4B3" />
<TextBox Text="{Binding Settings.Overlay.MachTextColor}" Watermark="#57c7f2" Margin="0,4,0,10" />
<TextBlock Text="{Binding Ui.ClosureColor}" Foreground="#93A4B3" />
<TextBox Text="{Binding Settings.Overlay.RelativeTextColor}" Watermark="#19f24f" Margin="0,4,0,10" />
</StackPanel>
</Expander>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Button Content="{Binding Ui.Save}" Command="{Binding SaveCommand}" Margin="0,0,8,0" />
<Button Content="{Binding Ui.Reset}" Classes="secondary" Command="{Binding ResetSettingsCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
<Grid Grid.Column="1" Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="{Binding Ui.LiveMap}" FontSize="24" FontWeight="SemiBold" />
<TextBlock Text="{Binding Ui.LocalApiScope}" Foreground="#93A4B3" />
<TextBlock Text="{Binding PlayerInfo}" Foreground="#C9D3DD" Margin="0,6,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Bottom" Spacing="16">
<TextBlock Text="{Binding AllySummary}" Foreground="#4CC9A7" />
<TextBlock Text="{Binding EnemySummary}" Foreground="#FF5252" />
<TextBlock Text="{Binding BattleTimerText}" Foreground="#F6C85F" />
<TextBlock Text="{Binding SnapshotSummary}" Foreground="#93A4B3" />
</StackPanel>
</Grid>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="280" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="#071014" ClipToBounds="True">
<rendering:MapCanvas Snapshot="{Binding Snapshot}"
MapSettings="{Binding Settings.Map}"
OverlaySettings="{Binding Settings.Overlay}"
Ui="{Binding Ui}" />
</Border>
<Border Grid.Column="1" Background="#121A22" Padding="16" Margin="18,0,0,0" IsVisible="{Binding Settings.Map.ShowBattleLog}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Text="{Binding Ui.BattleLog}" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding BattleLogEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" Foreground="#C9D3DD" Margin="0,0,0,8" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
</Grid>
<Grid Grid.Row="2" Margin="0,18,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding ProcessStatus}" Foreground="#93A4B3" />
<TextBlock Grid.Column="1" Text="{Binding LastUpdatedText}" Foreground="#93A4B3" />
</Grid>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,74 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Ehwrj.App.Services;
using Ehwrj.App.ViewModels;
using Ehwrj.Core.Services;
namespace Ehwrj.App;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
private OverlayWindow? _overlayWindow;
public MainWindow()
{
InitializeComponent();
var settingsStore = new SettingsStore();
var appSettings = settingsStore.Load();
var client = new WarThunderClient();
var processProbe = new ProcessProbe();
var service = new LiveMapService(client, processProbe);
_viewModel = new MainViewModel(service, settingsStore, appSettings);
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
_viewModel.Settings.Overlay.PropertyChanged += OnOverlaySettingsChanged;
DataContext = _viewModel;
Opened += OnOpened;
Closing += OnClosing;
}
private void OnOpened(object? sender, EventArgs e)
{
_viewModel.StartCommand.Execute(null);
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MainViewModel.IsOverlayEnabled))
{
UpdateOverlayVisibility();
}
}
private void OnOverlaySettingsChanged(object? sender, PropertyChangedEventArgs e)
{
_overlayWindow?.ApplyBounds();
}
private void UpdateOverlayVisibility()
{
if (_viewModel.IsOverlayEnabled)
{
_overlayWindow ??= new OverlayWindow(_viewModel.Settings.Overlay)
{
DataContext = _viewModel
};
_overlayWindow.Show();
_overlayWindow.ApplyBounds();
}
else
{
_overlayWindow?.Hide();
}
}
private void OnClosing(object? sender, WindowClosingEventArgs e)
{
_overlayWindow?.Close();
_viewModel.Dispose();
}
}

View File

@@ -0,0 +1,9 @@
namespace Ehwrj.App.Models;
public sealed class AppSettings
{
public MapSettings Map { get; set; } = new();
public OverlaySettings Overlay { get; set; } = new();
public string Language { get; set; } = "en-US";
public int PollIntervalMs { get; set; } = 500;
}

View File

@@ -0,0 +1,15 @@
namespace Ehwrj.App.Models;
public enum EndpointHealthState
{
Ok,
Warning,
Error,
NotChecked
}
public sealed record EndpointHealth(
string Path,
bool Required,
EndpointHealthState State,
string Detail);

View File

@@ -0,0 +1,3 @@
namespace Ehwrj.App.Models;
public sealed record LanguageOption(string Code, string DisplayName);

View File

@@ -0,0 +1,61 @@
using Avalonia.Media.Imaging;
using Ehwrj.Core.Models;
namespace Ehwrj.App.Models;
public sealed class LiveSnapshot
{
private static readonly IReadOnlyList<EndpointHealth> EmptyEndpointHealth =
[
new("map_info.json", Required: true, EndpointHealthState.NotChecked, "not checked"),
new("map_obj.json", Required: true, EndpointHealthState.NotChecked, "not checked"),
new("map.img", Required: true, EndpointHealthState.NotChecked, "not checked"),
new("state", Required: false, EndpointHealthState.NotChecked, "not checked"),
new("hudmsg", Required: false, EndpointHealthState.NotChecked, "not checked"),
new("gamechat", Required: false, EndpointHealthState.NotChecked, "not checked")
];
public static LiveSnapshot Empty { get; } = new(
MapInfo.Empty,
Array.Empty<MapObject>(),
FlightState.Empty,
Array.Empty<BattleMessage>(),
null,
DateTimeOffset.MinValue,
false,
"Waiting for War Thunder",
EmptyEndpointHealth);
public LiveSnapshot(
MapInfo mapInfo,
IReadOnlyList<MapObject> objects,
FlightState flightState,
IReadOnlyList<BattleMessage> messages,
Bitmap? mapImage,
DateTimeOffset updatedAt,
bool isGameRunning,
string status,
IReadOnlyList<EndpointHealth>? endpointHealth = null)
{
MapInfo = mapInfo;
Objects = objects;
FlightState = flightState;
Messages = messages;
MapImage = mapImage;
UpdatedAt = updatedAt;
IsGameRunning = isGameRunning;
Status = status;
EndpointHealth = endpointHealth ?? EmptyEndpointHealth;
}
public MapInfo MapInfo { get; }
public IReadOnlyList<MapObject> Objects { get; }
public FlightState FlightState { get; }
public IReadOnlyList<BattleMessage> Messages { get; }
public Bitmap? MapImage { get; }
public DateTimeOffset UpdatedAt { get; }
public bool IsGameRunning { get; }
public string Status { get; }
public IReadOnlyList<EndpointHealth> EndpointHealth { get; }
public MapObject? Player => Objects.FirstOrDefault(static o => o.IsPlayer) ?? Objects.FirstOrDefault(static o => o.IsAircraft);
}

View File

@@ -0,0 +1,76 @@
using Ehwrj.App.ViewModels;
namespace Ehwrj.App.Models;
public sealed class MapSettings : ObservableObject
{
private bool _showLabels = true;
private bool _showAircraftMach = true;
private bool _followPlayer = true;
private bool _rotateWithPlayer;
private bool _showRangeRings = true;
private bool _showBattleLog = true;
private bool _showDeliveryTracker = true;
private double _deliveryTrackerAngle = 0.8;
public bool ShowLabels
{
get => _showLabels;
set => SetProperty(ref _showLabels, value);
}
public bool ShowAircraftMach
{
get => _showAircraftMach;
set => SetProperty(ref _showAircraftMach, value);
}
public bool FollowPlayer
{
get => _followPlayer;
set => SetProperty(ref _followPlayer, value);
}
public bool RotateWithPlayer
{
get => _rotateWithPlayer;
set => SetProperty(ref _rotateWithPlayer, value);
}
public bool ShowRangeRings
{
get => _showRangeRings;
set => SetProperty(ref _showRangeRings, value);
}
public bool ShowBattleLog
{
get => _showBattleLog;
set => SetProperty(ref _showBattleLog, value);
}
public bool ShowDeliveryTracker
{
get => _showDeliveryTracker;
set => SetProperty(ref _showDeliveryTracker, value);
}
public double DeliveryTrackerAngle
{
get => _deliveryTrackerAngle;
set => SetProperty(ref _deliveryTrackerAngle, Math.Clamp(value, 0.1, 1.0));
}
public void Reset()
{
ShowLabels = true;
ShowAircraftMach = true;
FollowPlayer = true;
RotateWithPlayer = false;
ShowRangeRings = true;
ShowBattleLog = true;
ShowDeliveryTracker = true;
DeliveryTrackerAngle = 0.8;
}
}

View File

@@ -0,0 +1,283 @@
using Ehwrj.App.ViewModels;
namespace Ehwrj.App.Models;
public sealed class OverlaySettings : ObservableObject
{
private bool _showMiniMap = true;
private bool _showMach = true;
private bool _showSpotRadar = true;
private bool _spotShowDistance = true;
private bool _spotShowMach;
private bool _spotShowRelativeSpeed = true;
private double _size = 560;
private double _top = 24;
private double _right = 24;
private double _zoomPercent = 92;
private double _miniMapMinimumMach;
private double _miniMapAircraftScale = 125;
private double _spotOpacity = 70;
private double _spotDistance = 529;
private double _spotVerticalScale = 68;
private double _spotVerticalOffset = 224;
private double _spotArrowScale = 140;
private double _spotOutlineWidth = 2;
private double _spotDetectDistanceKm = 15;
private double _spotMinimumMach = 0.5;
private double _spotFontOpacity = 70;
private double _distanceFontSize = 31;
private double _distanceOutlineWidth = 2;
private double _machFontSize = 27;
private double _machOutlineWidth = 2;
private double _relativeFontSize = 24;
private double _relativeOutlineWidth = 2;
private string _spotArrowColor = "#ff1e1e";
private string _spotTextColor = "#ffffff";
private string _distanceTextColor = "#ff1e1e";
private string _machTextColor = "#57c7f2";
private string _relativeTextColor = "#19f24f";
public bool ShowMiniMap
{
get => _showMiniMap;
set => SetProperty(ref _showMiniMap, value);
}
public bool ShowMach
{
get => _showMach;
set => SetProperty(ref _showMach, value);
}
public bool ShowSpotRadar
{
get => _showSpotRadar;
set => SetProperty(ref _showSpotRadar, value);
}
public bool SpotShowDistance
{
get => _spotShowDistance;
set => SetProperty(ref _spotShowDistance, value);
}
public bool SpotShowMach
{
get => _spotShowMach;
set => SetProperty(ref _spotShowMach, value);
}
public bool SpotShowRelativeSpeed
{
get => _spotShowRelativeSpeed;
set => SetProperty(ref _spotShowRelativeSpeed, value);
}
public double Size
{
get => _size;
set => SetProperty(ref _size, Math.Clamp(value, 160, 2000));
}
public double Top
{
get => _top;
set => SetProperty(ref _top, Math.Clamp(value, 0, 500));
}
public double Right
{
get => _right;
set => SetProperty(ref _right, Math.Clamp(value, 0, 2000));
}
public double ZoomPercent
{
get => _zoomPercent;
set => SetProperty(ref _zoomPercent, Math.Clamp(value, 25, 400));
}
public double MiniMapMinimumMach
{
get => _miniMapMinimumMach;
set => SetProperty(ref _miniMapMinimumMach, Math.Clamp(value, 0, 2));
}
public double MiniMapAircraftScale
{
get => _miniMapAircraftScale;
set => SetProperty(ref _miniMapAircraftScale, Math.Clamp(value, 50, 200));
}
public double SpotOpacity
{
get => _spotOpacity;
set => SetProperty(ref _spotOpacity, Math.Clamp(value, 0, 100));
}
public double SpotDistance
{
get => _spotDistance;
set => SetProperty(ref _spotDistance, Math.Clamp(value, 40, 600));
}
public double SpotVerticalScale
{
get => _spotVerticalScale;
set => SetProperty(ref _spotVerticalScale, Math.Clamp(value, 40, 140));
}
public double SpotVerticalOffset
{
get => _spotVerticalOffset;
set => SetProperty(ref _spotVerticalOffset, Math.Clamp(value, -500, 500));
}
public double SpotArrowScale
{
get => _spotArrowScale;
set => SetProperty(ref _spotArrowScale, Math.Clamp(value, 50, 200));
}
public double SpotOutlineWidth
{
get => _spotOutlineWidth;
set => SetProperty(ref _spotOutlineWidth, Math.Clamp(value, 0, 10));
}
public double SpotDetectDistanceKm
{
get => _spotDetectDistanceKm;
set => SetProperty(ref _spotDetectDistanceKm, Math.Clamp(value, 0, 30));
}
public double SpotMinimumMach
{
get => _spotMinimumMach;
set => SetProperty(ref _spotMinimumMach, Math.Clamp(value, 0, 2));
}
public double SpotFontOpacity
{
get => _spotFontOpacity;
set => SetProperty(ref _spotFontOpacity, Math.Clamp(value, 0, 100));
}
public double DistanceFontSize
{
get => _distanceFontSize;
set => SetProperty(ref _distanceFontSize, Math.Clamp(value, 10, 60));
}
public double DistanceOutlineWidth
{
get => _distanceOutlineWidth;
set => SetProperty(ref _distanceOutlineWidth, Math.Clamp(value, 0, 8));
}
public double MachFontSize
{
get => _machFontSize;
set => SetProperty(ref _machFontSize, Math.Clamp(value, 10, 60));
}
public double MachOutlineWidth
{
get => _machOutlineWidth;
set => SetProperty(ref _machOutlineWidth, Math.Clamp(value, 0, 8));
}
public double RelativeFontSize
{
get => _relativeFontSize;
set => SetProperty(ref _relativeFontSize, Math.Clamp(value, 10, 60));
}
public double RelativeOutlineWidth
{
get => _relativeOutlineWidth;
set => SetProperty(ref _relativeOutlineWidth, Math.Clamp(value, 0, 8));
}
public string SpotArrowColor
{
get => _spotArrowColor;
set => SetProperty(ref _spotArrowColor, NormalizeColor(value, "#ff1e1e"));
}
public string SpotTextColor
{
get => _spotTextColor;
set => SetProperty(ref _spotTextColor, NormalizeColor(value, "#ffffff"));
}
public string DistanceTextColor
{
get => _distanceTextColor;
set => SetProperty(ref _distanceTextColor, NormalizeColor(value, "#ff1e1e"));
}
public string MachTextColor
{
get => _machTextColor;
set => SetProperty(ref _machTextColor, NormalizeColor(value, "#57c7f2"));
}
public string RelativeTextColor
{
get => _relativeTextColor;
set => SetProperty(ref _relativeTextColor, NormalizeColor(value, "#19f24f"));
}
public void Reset()
{
ShowMiniMap = true;
ShowMach = true;
ShowSpotRadar = true;
SpotShowDistance = true;
SpotShowMach = false;
SpotShowRelativeSpeed = true;
Size = 560;
Top = 24;
Right = 24;
ZoomPercent = 92;
MiniMapMinimumMach = 0;
MiniMapAircraftScale = 125;
SpotOpacity = 70;
SpotDistance = 529;
SpotVerticalScale = 68;
SpotVerticalOffset = 224;
SpotArrowScale = 140;
SpotOutlineWidth = 2;
SpotDetectDistanceKm = 15;
SpotMinimumMach = 0.5;
SpotFontOpacity = 70;
DistanceFontSize = 31;
DistanceOutlineWidth = 2;
MachFontSize = 27;
MachOutlineWidth = 2;
RelativeFontSize = 24;
RelativeOutlineWidth = 2;
SpotArrowColor = "#ff1e1e";
SpotTextColor = "#ffffff";
DistanceTextColor = "#ff1e1e";
MachTextColor = "#57c7f2";
RelativeTextColor = "#19f24f";
}
private static string NormalizeColor(string? value, string fallback)
{
if (string.IsNullOrWhiteSpace(value))
{
return fallback;
}
var trimmed = value.Trim();
if (!trimmed.StartsWith('#'))
{
trimmed = $"#{trimmed}";
}
return trimmed.Length is 7 or 9 ? trimmed : fallback;
}
}

View File

@@ -0,0 +1,311 @@
namespace Ehwrj.App.Models;
public sealed class UiText
{
private static readonly UiText English = new("en-US")
{
Subtitle = "War Thunder local map companion",
Language = "Language",
Start = "Start",
Stop = "Stop",
Connection = "Connection",
Diagnostics = "Diagnostics",
RequiredEndpoint = "required",
OptionalEndpoint = "optional",
EndpointOk = "ok",
EndpointWarning = "warning",
EndpointError = "error",
EndpointNotChecked = "not checked",
Map = "Map",
ShowLabels = "Show labels",
ShowAircraftMach = "Show aircraft Mach",
FollowPlayer = "Follow player",
RotateWithPlayer = "Rotate with player",
ShowRangeRings = "Show range rings",
ShowBattleLog = "Show battle log",
DeliveryTracker = "Delivery tracker",
DeliveryTrackerAngle = "Delivery tracker angle",
ShowOverlay = "Show overlay",
ShowMinimap = "Show minimap",
ShowMachLabels = "Show Mach labels",
ShowSpotRadar = "Show spot radar",
OverlaySize = "Overlay size",
TopOffset = "Top offset",
RightOffset = "Right offset",
Zoom = "Zoom",
Minimap = "Minimap",
AircraftScale = "Aircraft scale",
MinimumMach = "Minimum Mach",
SpotRadar = "Spot radar",
ShowDistance = "Show distance",
ShowMach = "Show Mach",
ShowClosureSpeed = "Show closure speed",
RadarRangeKm = "Radar range, km",
RadarSpread = "Radar spread",
MarkerOpacity = "Marker opacity",
FontOpacity = "Font opacity",
VerticalScale = "Vertical scale",
VerticalOffset = "Vertical offset",
ArrowScale = "Arrow scale",
ArrowOutline = "Arrow outline",
DistanceFontSize = "Distance font size",
MachFontSize = "Mach font size",
ClosureFontSize = "Closure font size",
ArrowColor = "Arrow color",
DistanceColor = "Distance color",
MachColor = "Mach color",
ClosureColor = "Closure color",
Save = "Save",
Reset = "Reset",
LiveMap = "Live Map",
LocalApiScope = "Reads only the local game API at 127.0.0.1:8111",
BattleLog = "Battle Log",
WaitingForWarThunder = "Waiting for War Thunder",
ConnectedToLocalApi = "Connected to 127.0.0.1:8111",
WaitingForLocalMapApi = "Waiting for local map API",
WarThunderProcessNotDetected = "War Thunder process not detected",
StartWarThunderToEnableLiveData = "Start War Thunder to enable live data",
WaitingForMapImage = "Waiting for map.img",
AcesDetected = "aces.exe detected",
AcesNotDetected = "aces.exe not detected",
NeverUpdated = "Never updated",
PlayerNotDetected = "Player not detected",
Player = "Player",
Allied = "allied",
Enemy = "enemy",
Objects = "objects",
Pos = "pos",
Climb = "climb",
Heading = "hdg",
Updated = "Updated",
MessageEnemy = "Enemy",
MessageAlly = "Ally",
MessageEvent = "Event",
SessionStarted = "Session started",
AircraftVisible = "aircraft visible",
AlliedAircraftChanged = "Allied aircraft count changed",
EnemyAircraftChanged = "Enemy aircraft count changed"
};
private static readonly UiText Korean = new("ko-KR")
{
Subtitle = "War Thunder 로컬 맵 도구",
Language = "언어",
Start = "시작",
Stop = "정지",
Connection = "연결",
Diagnostics = "진단",
RequiredEndpoint = "필수",
OptionalEndpoint = "선택",
EndpointOk = "정상",
EndpointWarning = "경고",
EndpointError = "오류",
EndpointNotChecked = "미확인",
Map = "지도",
ShowLabels = "라벨 표시",
ShowAircraftMach = "항공기 마하 표시",
FollowPlayer = "플레이어 따라가기",
RotateWithPlayer = "플레이어 방향 회전",
ShowRangeRings = "거리 링 표시",
ShowBattleLog = "전투 로그 표시",
DeliveryTracker = "투하 추적기",
DeliveryTrackerAngle = "투하 추적 각도",
ShowOverlay = "오버레이 표시",
ShowMinimap = "미니맵 표시",
ShowMachLabels = "마하 라벨 표시",
ShowSpotRadar = "스팟 레이더 표시",
OverlaySize = "오버레이 크기",
TopOffset = "위쪽 오프셋",
RightOffset = "오른쪽 오프셋",
Zoom = "확대",
Minimap = "미니맵",
AircraftScale = "항공기 크기",
MinimumMach = "최소 마하",
SpotRadar = "스팟 레이더",
ShowDistance = "거리 표시",
ShowMach = "마하 표시",
ShowClosureSpeed = "접근 속도 표시",
RadarRangeKm = "레이더 범위, km",
RadarSpread = "레이더 간격",
MarkerOpacity = "마커 불투명도",
FontOpacity = "글자 불투명도",
VerticalScale = "세로 배율",
VerticalOffset = "세로 오프셋",
ArrowScale = "화살표 크기",
ArrowOutline = "화살표 외곽선",
DistanceFontSize = "거리 글자 크기",
MachFontSize = "마하 글자 크기",
ClosureFontSize = "접근 속도 글자 크기",
ArrowColor = "화살표 색상",
DistanceColor = "거리 색상",
MachColor = "마하 색상",
ClosureColor = "접근 속도 색상",
Save = "저장",
Reset = "초기화",
LiveMap = "라이브 맵",
LocalApiScope = "로컬 게임 API 127.0.0.1:8111만 읽음",
BattleLog = "전투 로그",
WaitingForWarThunder = "War Thunder 대기 중",
ConnectedToLocalApi = "127.0.0.1:8111 연결됨",
WaitingForLocalMapApi = "로컬 맵 API 대기 중",
WarThunderProcessNotDetected = "War Thunder 프로세스 감지 안 됨",
StartWarThunderToEnableLiveData = "실시간 데이터를 보려면 War Thunder를 시작하세요",
WaitingForMapImage = "map.img 대기 중",
AcesDetected = "aces.exe 감지됨",
AcesNotDetected = "aces.exe 감지 안 됨",
NeverUpdated = "아직 갱신 안 됨",
PlayerNotDetected = "플레이어 감지 안 됨",
Player = "플레이어",
Allied = "아군",
Enemy = "적",
Objects = "개 오브젝트",
Pos = "좌표",
Climb = "상승각",
Heading = "방위",
Updated = "갱신",
MessageEnemy = "적",
MessageAlly = "아군",
MessageEvent = "이벤트",
SessionStarted = "세션 시작",
AircraftVisible = "항공기 표시 중",
AlliedAircraftChanged = "아군 항공기 수 변경",
EnemyAircraftChanged = "적 항공기 수 변경"
};
private UiText(string code)
{
Code = code;
}
public string Code { get; }
public string Subtitle { get; init; } = "";
public string Language { get; init; } = "";
public string Start { get; init; } = "";
public string Stop { get; init; } = "";
public string Connection { get; init; } = "";
public string Diagnostics { get; init; } = "";
public string RequiredEndpoint { get; init; } = "";
public string OptionalEndpoint { get; init; } = "";
public string EndpointOk { get; init; } = "";
public string EndpointWarning { get; init; } = "";
public string EndpointError { get; init; } = "";
public string EndpointNotChecked { get; init; } = "";
public string Map { get; init; } = "";
public string ShowLabels { get; init; } = "";
public string ShowAircraftMach { get; init; } = "";
public string FollowPlayer { get; init; } = "";
public string RotateWithPlayer { get; init; } = "";
public string ShowRangeRings { get; init; } = "";
public string ShowBattleLog { get; init; } = "";
public string DeliveryTracker { get; init; } = "";
public string DeliveryTrackerAngle { get; init; } = "";
public string ShowOverlay { get; init; } = "";
public string ShowMinimap { get; init; } = "";
public string ShowMachLabels { get; init; } = "";
public string ShowSpotRadar { get; init; } = "";
public string OverlaySize { get; init; } = "";
public string TopOffset { get; init; } = "";
public string RightOffset { get; init; } = "";
public string Zoom { get; init; } = "";
public string Minimap { get; init; } = "";
public string AircraftScale { get; init; } = "";
public string MinimumMach { get; init; } = "";
public string SpotRadar { get; init; } = "";
public string ShowDistance { get; init; } = "";
public string ShowMach { get; init; } = "";
public string ShowClosureSpeed { get; init; } = "";
public string RadarRangeKm { get; init; } = "";
public string RadarSpread { get; init; } = "";
public string MarkerOpacity { get; init; } = "";
public string FontOpacity { get; init; } = "";
public string VerticalScale { get; init; } = "";
public string VerticalOffset { get; init; } = "";
public string ArrowScale { get; init; } = "";
public string ArrowOutline { get; init; } = "";
public string DistanceFontSize { get; init; } = "";
public string MachFontSize { get; init; } = "";
public string ClosureFontSize { get; init; } = "";
public string ArrowColor { get; init; } = "";
public string DistanceColor { get; init; } = "";
public string MachColor { get; init; } = "";
public string ClosureColor { get; init; } = "";
public string Save { get; init; } = "";
public string Reset { get; init; } = "";
public string LiveMap { get; init; } = "";
public string LocalApiScope { get; init; } = "";
public string BattleLog { get; init; } = "";
public string WaitingForWarThunder { get; init; } = "";
public string ConnectedToLocalApi { get; init; } = "";
public string WaitingForLocalMapApi { get; init; } = "";
public string WarThunderProcessNotDetected { get; init; } = "";
public string StartWarThunderToEnableLiveData { get; init; } = "";
public string WaitingForMapImage { get; init; } = "";
public string AcesDetected { get; init; } = "";
public string AcesNotDetected { get; init; } = "";
public string NeverUpdated { get; init; } = "";
public string PlayerNotDetected { get; init; } = "";
public string Player { get; init; } = "";
public string Allied { get; init; } = "";
public string Enemy { get; init; } = "";
public string Objects { get; init; } = "";
public string Pos { get; init; } = "";
public string Climb { get; init; } = "";
public string Heading { get; init; } = "";
public string Updated { get; init; } = "";
public string MessageEnemy { get; init; } = "";
public string MessageAlly { get; init; } = "";
public string MessageEvent { get; init; } = "";
public string SessionStarted { get; init; } = "";
public string AircraftVisible { get; init; } = "";
public string AlliedAircraftChanged { get; init; } = "";
public string EnemyAircraftChanged { get; init; } = "";
public static IReadOnlyList<LanguageOption> LanguageOptions { get; } =
[
new("en-US", "English"),
new("ko-KR", "한국어")
];
public static UiText For(string? code)
{
return string.Equals(code, Korean.Code, StringComparison.OrdinalIgnoreCase)
? Korean
: English;
}
public string FormatAllySummary(int count) => Code == Korean.Code ? $"{Allied} {count}" : $"{count} {Allied}";
public string FormatEnemySummary(int count) => Code == Korean.Code ? $"{Enemy} {count}" : $"{count} {Enemy}";
public string FormatObjectSummary(int count) => Code == Korean.Code ? $"{count}{Objects}" : $"{count} {Objects}";
public string FormatUpdated(DateTimeOffset updatedAt) => $"{Updated} {updatedAt:HH:mm:ss}";
public string FormatSessionStarted(int ally, int enemy) => $"{SessionStarted}: {ally} {Allied}, {enemy} {Enemy} {AircraftVisible}.";
public string FormatAlliedAircraftChanged(int oldCount, int newCount) => $"{AlliedAircraftChanged}: {oldCount} -> {newCount}.";
public string FormatEnemyAircraftChanged(int oldCount, int newCount) => $"{EnemyAircraftChanged}: {oldCount} -> {newCount}.";
public string FormatEndpointHealth(EndpointHealth health)
{
var scope = health.Required ? RequiredEndpoint : OptionalEndpoint;
var state = health.State switch
{
EndpointHealthState.Ok => EndpointOk,
EndpointHealthState.Warning => EndpointWarning,
EndpointHealthState.Error => EndpointError,
EndpointHealthState.NotChecked => EndpointNotChecked,
_ => health.State.ToString()
};
return $"{health.Path} [{scope}] {state} - {health.Detail}";
}
public string FormatStatus(string status)
{
return status switch
{
"Waiting for War Thunder" => WaitingForWarThunder,
"Connected to 127.0.0.1:8111" => ConnectedToLocalApi,
"War Thunder process not detected" => WarThunderProcessNotDetected,
_ when status.StartsWith("Waiting for local map API: ", StringComparison.Ordinal) =>
$"{WaitingForLocalMapApi}: {status["Waiting for local map API: ".Length..]}",
_ => status
};
}
}

View File

@@ -0,0 +1,16 @@
<Window x:Class="Ehwrj.App.OverlayWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:rendering="using:Ehwrj.App.Rendering"
Background="Transparent"
SystemDecorations="None"
ShowInTaskbar="False"
CanResize="False"
Topmost="True"
Width="560"
Height="560"
TransparencyLevelHint="Transparent">
<rendering:OverlayCanvas Snapshot="{Binding Snapshot}"
OverlaySettings="{Binding Settings.Overlay}"
Ui="{Binding Ui}" />
</Window>

View File

@@ -0,0 +1,45 @@
using Avalonia;
using Avalonia.Controls;
using Ehwrj.App.Infrastructure;
using Ehwrj.App.Models;
namespace Ehwrj.App;
public partial class OverlayWindow : Window
{
private readonly OverlaySettings _settings;
public OverlayWindow()
: this(new OverlaySettings())
{
}
public OverlayWindow(OverlaySettings settings)
{
_settings = settings;
InitializeComponent();
Opened += OnOpened;
}
public void ApplyBounds()
{
var size = Math.Max(160, _settings.Size);
Width = size;
Height = size;
var area = Screens.Primary?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
Position = new PixelPoint(
area.X + area.Width - (int)Math.Round(size) - (int)Math.Round(_settings.Right),
area.Y + (int)Math.Round(_settings.Top));
}
private void OnOpened(object? sender, EventArgs e)
{
if (TryGetPlatformHandle()?.Handle is { } handle)
{
Win32.MakeOverlayClickThrough(handle);
}
ApplyBounds();
}
}

21
src/Ehwrj.App/Program.cs Normal file
View File

@@ -0,0 +1,21 @@
using Avalonia;
namespace Ehwrj.App;
internal static class Program
{
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -0,0 +1,463 @@
using System.ComponentModel;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.VisualTree;
using Ehwrj.App.Models;
using Ehwrj.Core.Geometry;
using Ehwrj.Core.Models;
namespace Ehwrj.App.Rendering;
public class MapCanvas : Control
{
private MapSettings? _subscribedMapSettings;
private OverlaySettings? _subscribedOverlaySettings;
public static readonly StyledProperty<LiveSnapshot> SnapshotProperty = AvaloniaProperty.Register<MapCanvas, LiveSnapshot>(
nameof(Snapshot),
LiveSnapshot.Empty);
public static readonly StyledProperty<OverlaySettings> OverlaySettingsProperty = AvaloniaProperty.Register<MapCanvas, OverlaySettings>(
nameof(OverlaySettings),
new OverlaySettings());
public static readonly StyledProperty<MapSettings> MapSettingsProperty = AvaloniaProperty.Register<MapCanvas, MapSettings>(
nameof(MapSettings),
new MapSettings());
public static readonly StyledProperty<UiText> UiProperty = AvaloniaProperty.Register<MapCanvas, UiText>(
nameof(Ui),
UiText.For("en-US"));
public LiveSnapshot Snapshot
{
get => GetValue(SnapshotProperty);
set => SetValue(SnapshotProperty, value);
}
public OverlaySettings OverlaySettings
{
get => GetValue(OverlaySettingsProperty);
set => SetValue(OverlaySettingsProperty, value);
}
public MapSettings MapSettings
{
get => GetValue(MapSettingsProperty);
set => SetValue(MapSettingsProperty, value);
}
public UiText Ui
{
get => GetValue(UiProperty);
set => SetValue(UiProperty, value);
}
static MapCanvas()
{
AffectsRender<MapCanvas>(SnapshotProperty, OverlaySettingsProperty, MapSettingsProperty, UiProperty);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == OverlaySettingsProperty)
{
SubscribeOverlaySettings(change.NewValue as OverlaySettings);
}
else if (change.Property == MapSettingsProperty)
{
SubscribeMapSettings(change.NewValue as MapSettings);
}
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
SubscribeMapSettings(null);
SubscribeOverlaySettings(null);
base.OnDetachedFromVisualTree(e);
}
public override void Render(DrawingContext context)
{
base.Render(context);
var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
context.FillRectangle(new SolidColorBrush(Color.FromRgb(7, 16, 20)), bounds);
var view = CreateView(bounds);
DrawRotatedMap(context, view);
DrawRangeRings(context, view);
DrawDeliveryTracker(context, view);
DrawObjects(context, view, detailed: true);
DrawStatus(context, bounds);
}
private ViewState CreateView(Rect bounds)
{
var imageSize = Snapshot.MapImage?.Size ?? new Size(1, 1);
var rect = Fit(bounds, imageSize.Width, imageSize.Height, 0.96);
var player = Snapshot.Player;
if (MapSettings.FollowPlayer && player is not null)
{
var projected = ProjectPointRaw(player, Snapshot, rect);
if (projected is not null)
{
const double scale = 1.45;
var width = rect.Width * scale;
var height = rect.Height * scale;
var normalizedX = (projected.Value.X - rect.Left) / Math.Max(1, rect.Width);
var normalizedY = (projected.Value.Y - rect.Top) / Math.Max(1, rect.Height);
rect = new Rect(
bounds.Center.X - normalizedX * width,
bounds.Center.Y - normalizedY * height,
width,
height);
}
}
var rotation = MapSettings.RotateWithPlayer && player is not null
? ObjectHeading(player) ?? 0
: 0;
return new ViewState(rect, bounds.Center, rotation);
}
private void DrawRotatedMap(DrawingContext context, ViewState view)
{
if (Math.Abs(view.RotationRadians) < 0.0001)
{
DrawMap(context, view.Rect);
return;
}
using (context.PushTransform(Matrix.CreateRotation(-view.RotationRadians, view.Center)))
{
DrawMap(context, view.Rect);
}
}
private void DrawMap(DrawingContext context, Rect rect)
{
if (Snapshot.MapImage is not null)
{
context.DrawImage(Snapshot.MapImage, new Rect(Snapshot.MapImage.Size), rect);
}
else
{
context.FillRectangle(new SolidColorBrush(Color.FromRgb(10, 26, 32)), rect);
DrawCenteredText(context, Ui.WaitingForMapImage, rect, 18, Colors.White);
}
var gridPen = new Pen(new SolidColorBrush(Color.FromArgb(60, 230, 237, 243)), 1);
for (var i = 1; i < 6; i++)
{
var x = rect.Left + rect.Width * i / 6;
var y = rect.Top + rect.Height * i / 6;
context.DrawLine(gridPen, new Point(x, rect.Top), new Point(x, rect.Bottom));
context.DrawLine(gridPen, new Point(rect.Left, y), new Point(rect.Right, y));
}
context.DrawRectangle(null, new Pen(new SolidColorBrush(Color.FromArgb(110, 230, 237, 243)), 1), rect);
}
protected void DrawObjects(DrawingContext context, ViewState view, bool detailed)
{
foreach (var obj in Snapshot.Objects)
{
var point = ProjectPoint(obj, Snapshot, view);
if (point is null) continue;
var brush = new SolidColorBrush(ObjectColor(obj));
var radius = obj.IsPlayer ? 7 : obj.IsAircraft ? 5 : 4;
context.DrawEllipse(brush, new Pen(Brushes.Black, 1), point.Value, radius, radius);
if (detailed && MapSettings.ShowAircraftMach && obj.Mach.HasValue && obj.IsAircraft)
{
DrawText(context, $"M {obj.Mach.Value:0.0}", new Point(point.Value.X + 9, point.Value.Y - 10), 12, Colors.White);
}
if (detailed && MapSettings.ShowLabels)
{
var label = obj.Name ?? obj.Icon ?? obj.Type;
if (!string.IsNullOrWhiteSpace(label))
{
DrawText(context, label, new Point(point.Value.X + 9, point.Value.Y + 4), 12, Color.FromRgb(220, 229, 238));
}
}
}
}
private void DrawRangeRings(DrawingContext context, ViewState view)
{
if (!MapSettings.ShowRangeRings || Snapshot.Player is null)
{
return;
}
var playerPoint = ProjectPoint(Snapshot.Player, Snapshot, view);
if (playerPoint is null)
{
return;
}
var pen = new Pen(new SolidColorBrush(Color.FromArgb(75, 230, 237, 243)), 1);
var radiusUnit = Math.Min(view.Rect.Width, view.Rect.Height) * 0.1;
for (var i = 1; i <= 3; i++)
{
var radius = radiusUnit * i;
context.DrawEllipse(null, pen, playerPoint.Value, radius, radius);
DrawText(context, $"{i * 10} km", new Point(playerPoint.Value.X + radius + 4, playerPoint.Value.Y - 8), 11, Color.FromArgb(160, 230, 237, 243));
}
}
private void DrawDeliveryTracker(DrawingContext context, ViewState view)
{
if (!MapSettings.ShowDeliveryTracker)
{
return;
}
var friendlyAircraft = Snapshot.Objects.Where(static o =>
o.IsAircraft && o.Kind is MapObjectKind.Ally or MapObjectKind.Squad or MapObjectKind.Player);
var targets = Snapshot.Objects.Where(static o =>
o.IsEnemyBombingPoint || o.Kind == MapObjectKind.Enemy && (o.IsObjective || !o.IsAircraft));
var pen = new Pen(new SolidColorBrush(Color.FromArgb(170, 255, 212, 94)), 2);
foreach (var aircraft in friendlyAircraft)
{
var start = ProjectPoint(aircraft, Snapshot, view);
var forward = HeadingVector(aircraft);
if (start is null || forward is null)
{
continue;
}
foreach (var target in targets)
{
var end = ProjectPoint(target, Snapshot, view);
if (end is null)
{
continue;
}
var dx = end.Value.X - start.Value.X;
var dy = end.Value.Y - start.Value.Y;
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < 1)
{
continue;
}
var dot = (dx * forward.Value.X + dy * forward.Value.Y) / distance;
if (dot < MapSettings.DeliveryTrackerAngle)
{
continue;
}
context.DrawLine(pen, start.Value, end.Value);
}
}
}
private void DrawStatus(DrawingContext context, Rect bounds)
{
var status = Snapshot.IsGameRunning
? Ui.FormatStatus(Snapshot.Status)
: Ui.StartWarThunderToEnableLiveData;
DrawText(context, status, new Point(18, bounds.Bottom - 32), 13, Color.FromRgb(147, 164, 179));
}
protected static Rect Fit(Rect bounds, double imageWidth, double imageHeight, double fill)
{
var targetWidth = bounds.Width * fill;
var targetHeight = bounds.Height * fill;
var scale = Math.Min(targetWidth / Math.Max(1, imageWidth), targetHeight / Math.Max(1, imageHeight));
var width = imageWidth * scale;
var height = imageHeight * scale;
return new Rect(bounds.Left + (bounds.Width - width) / 2, bounds.Top + (bounds.Height - height) / 2, width, height);
}
protected static Point? ProjectPoint(MapObject obj, LiveSnapshot snapshot, ViewState view)
{
var point = ProjectPointRaw(obj, snapshot, view.Rect);
return point.HasValue ? RotateAround(point.Value, view.Center, -view.RotationRadians) : null;
}
protected static Point? ProjectPointRaw(MapObject obj, LiveSnapshot snapshot, Rect rect)
{
var viewport = new MapViewport(rect.Left, rect.Top, rect.Width, rect.Height);
var projected = CoordinateProjector.Project(obj, snapshot.MapInfo, viewport);
return projected.HasValue ? new Point(projected.Value.X, projected.Value.Y) : null;
}
protected static void DrawCenteredText(DrawingContext context, string text, Rect rect, double size, Color color)
{
var formatted = CreateText(text, size, color);
context.DrawText(formatted, new Point(rect.Left + (rect.Width - formatted.Width) / 2, rect.Top + (rect.Height - formatted.Height) / 2));
}
protected static void DrawText(DrawingContext context, string text, Point point, double size, Color color)
{
context.DrawText(CreateText(text, size, color), point);
}
protected static void DrawOutlinedText(
DrawingContext context,
string text,
Point point,
double size,
Color color,
double outlineWidth,
double opacityPercent)
{
var foreground = WithOpacity(color, opacityPercent);
if (outlineWidth > 0)
{
var outline = WithOpacity(Colors.Black, Math.Min(100, opacityPercent + 20));
var offsets = new[]
{
new Point(-outlineWidth, 0),
new Point(outlineWidth, 0),
new Point(0, -outlineWidth),
new Point(0, outlineWidth),
new Point(-outlineWidth, -outlineWidth),
new Point(outlineWidth, outlineWidth)
};
foreach (var offset in offsets)
{
DrawText(context, text, new Point(point.X + offset.X, point.Y + offset.Y), size, outline);
}
}
DrawText(context, text, point, size, foreground);
}
protected static Color ParseColor(string? value, Color fallback)
{
return Color.TryParse(value, out var color) ? color : fallback;
}
protected static Color ObjectColor(MapObject obj)
{
return obj.Kind switch
{
MapObjectKind.Player => Color.FromRgb(246, 200, 95),
MapObjectKind.Ally => Color.FromRgb(76, 201, 167),
MapObjectKind.Squad => Color.FromRgb(120, 156, 255),
MapObjectKind.Enemy => Color.FromRgb(255, 82, 82),
MapObjectKind.Objective => Color.FromRgb(222, 202, 132),
_ => obj.IsAircraft ? Color.FromRgb(255, 82, 82) : Color.FromRgb(222, 202, 132)
};
}
protected static double? ObjectHeading(MapObject obj)
{
if (obj.DirectionX.HasValue && obj.DirectionY.HasValue)
{
return Math.Atan2(obj.DirectionY.Value, obj.DirectionX.Value);
}
return obj.HeadingRadians;
}
protected static Point? HeadingVector(MapObject obj)
{
var heading = ObjectHeading(obj);
if (!heading.HasValue)
{
return null;
}
return new Point(Math.Cos(heading.Value), Math.Sin(heading.Value));
}
private static Point RotateAround(Point point, Point center, double radians)
{
if (Math.Abs(radians) < 0.0001)
{
return point;
}
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);
var dx = point.X - center.X;
var dy = point.Y - center.Y;
return new Point(
center.X + dx * cos - dy * sin,
center.Y + dx * sin + dy * cos);
}
protected static FormattedText CreateText(string text, double size, Color color)
{
return new FormattedText(
text,
CultureInfo.InvariantCulture,
FlowDirection.LeftToRight,
new Typeface("Inter"),
size,
new SolidColorBrush(color));
}
private static Color WithOpacity(Color color, double opacityPercent)
{
var alpha = (byte)Math.Clamp(opacityPercent / 100.0 * 255, 0, 255);
return Color.FromArgb(alpha, color.R, color.G, color.B);
}
private void SubscribeOverlaySettings(OverlaySettings? settings)
{
if (ReferenceEquals(_subscribedOverlaySettings, settings))
{
return;
}
if (_subscribedOverlaySettings is not null)
{
_subscribedOverlaySettings.PropertyChanged -= OnOverlaySettingsChanged;
}
_subscribedOverlaySettings = settings;
if (_subscribedOverlaySettings is not null)
{
_subscribedOverlaySettings.PropertyChanged += OnOverlaySettingsChanged;
}
}
private void SubscribeMapSettings(MapSettings? settings)
{
if (ReferenceEquals(_subscribedMapSettings, settings))
{
return;
}
if (_subscribedMapSettings is not null)
{
_subscribedMapSettings.PropertyChanged -= OnMapSettingsChanged;
}
_subscribedMapSettings = settings;
if (_subscribedMapSettings is not null)
{
_subscribedMapSettings.PropertyChanged += OnMapSettingsChanged;
}
}
private void OnMapSettingsChanged(object? sender, PropertyChangedEventArgs e)
{
InvalidateVisual();
}
private void OnOverlaySettingsChanged(object? sender, PropertyChangedEventArgs e)
{
InvalidateVisual();
}
}
public readonly record struct ViewState(Rect Rect, Point Center, double RotationRadians);

View File

@@ -0,0 +1,155 @@
using Avalonia;
using Avalonia.Media;
using Ehwrj.Core.Geometry;
namespace Ehwrj.App.Rendering;
public sealed class OverlayCanvas : MapCanvas
{
public override void Render(DrawingContext context)
{
var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
var settings = OverlaySettings;
if (!settings.ShowMiniMap && !settings.ShowSpotRadar)
{
return;
}
if (settings.ShowMiniMap)
{
DrawMiniMap(context, bounds);
}
if (settings.ShowSpotRadar)
{
DrawSpotRadar(context, bounds);
}
}
private void DrawMiniMap(DrawingContext context, Rect bounds)
{
var alpha = (byte)Math.Clamp(OverlaySettings.SpotOpacity / 100.0 * 220, 0, 220);
context.DrawRectangle(new SolidColorBrush(Color.FromArgb(120, 0, 0, 0)), null, bounds, 8, 8);
if (Snapshot.MapImage is not null)
{
using (context.PushOpacity(0.82))
{
context.DrawImage(Snapshot.MapImage, new Rect(Snapshot.MapImage.Size), bounds);
}
}
var rect = new Rect(0, 0, bounds.Width, bounds.Height);
foreach (var obj in Snapshot.Objects)
{
var point = ProjectPointRaw(obj, Snapshot, rect);
if (point is null) continue;
if (obj.IsAircraft && obj.Mach.GetValueOrDefault() < OverlaySettings.MiniMapMinimumMach) continue;
var color = obj.IsPlayer ? Color.FromRgb(76, 201, 167) : obj.IsAircraft ? Color.FromRgb(255, 30, 30) : Color.FromRgb(240, 220, 150);
color = Color.FromArgb(alpha, color.R, color.G, color.B);
var brush = new SolidColorBrush(color);
var scale = OverlaySettings.MiniMapAircraftScale / 100.0;
var radius = (obj.IsPlayer ? 8 : obj.IsAircraft ? 5 : 3) * scale;
context.DrawEllipse(brush, new Pen(Brushes.Black, 1), point.Value, radius, radius);
if (OverlaySettings.ShowMach && obj.Mach.HasValue)
{
var textColor = ParseColor(OverlaySettings.SpotTextColor, Colors.White);
DrawOutlinedText(context, obj.Mach.Value.ToString("0.0"), new Point(point.Value.X + 8, point.Value.Y - 10), 13, textColor, 1, OverlaySettings.SpotFontOpacity);
}
}
}
private void DrawSpotRadar(DrawingContext context, Rect bounds)
{
var player = Snapshot.Player;
if (player is null) return;
var minSide = Math.Min(bounds.Width, bounds.Height);
var center = new Point(
bounds.Width / 2,
bounds.Height / 2 + OverlaySettings.SpotVerticalOffset * minSide / 2240.0);
var radius = minSide * 0.42 * (OverlaySettings.SpotDistance / 529.0);
radius = Math.Clamp(radius, minSide * 0.08, minSide * 0.48);
var radarPen = new Pen(new SolidColorBrush(Color.FromArgb(90, 255, 255, 255)), 1);
context.DrawEllipse(null, radarPen, center, radius, radius);
context.DrawLine(radarPen, new Point(center.X, center.Y - radius), new Point(center.X, center.Y + radius));
context.DrawLine(radarPen, new Point(center.X - radius, center.Y), new Point(center.X + radius, center.Y));
foreach (var target in Snapshot.Objects.Where(static o => o.IsAircraft && !o.IsPlayer))
{
var distance = CoordinateProjector.ApproximateDistanceKm(player, target);
if (!double.IsFinite(distance) || distance <= 0 || distance > OverlaySettings.SpotDetectDistanceKm) continue;
if (target.Mach.GetValueOrDefault() < OverlaySettings.SpotMinimumMach) continue;
var dx = target.X!.Value - player.X!.Value;
var dy = target.Y!.Value - player.Y!.Value;
var rawDistance = Math.Sqrt(dx * dx + dy * dy);
if (rawDistance <= double.Epsilon) continue;
var normalizedDistance = Math.Clamp(distance / Math.Max(1, OverlaySettings.SpotDetectDistanceKm), 0, 1);
var point = new Point(
center.X + dx / rawDistance * normalizedDistance * radius,
center.Y + dy / rawDistance * normalizedDistance * radius * (OverlaySettings.SpotVerticalScale / 100.0));
var opacity = (byte)Math.Clamp(OverlaySettings.SpotOpacity / 100.0 * 255, 0, 255);
var arrowColor = ParseColor(OverlaySettings.SpotArrowColor, Color.FromRgb(255, 30, 30));
var brush = new SolidColorBrush(Color.FromArgb(opacity, arrowColor.R, arrowColor.G, arrowColor.B));
DrawTriangle(context, point, 12 * OverlaySettings.SpotArrowScale / 100.0, brush, OverlaySettings.SpotOutlineWidth);
var lineY = point.Y - 13;
if (OverlaySettings.SpotShowDistance)
{
DrawOutlinedText(
context,
$"{distance:0.0} km",
new Point(point.X + 14, lineY),
OverlaySettings.DistanceFontSize,
ParseColor(OverlaySettings.DistanceTextColor, Color.FromRgb(255, 30, 30)),
OverlaySettings.DistanceOutlineWidth,
OverlaySettings.SpotFontOpacity);
lineY += OverlaySettings.DistanceFontSize + 2;
}
if (OverlaySettings.SpotShowMach && target.Mach.HasValue)
{
DrawOutlinedText(
context,
$"M {target.Mach.Value:0.0}",
new Point(point.X + 14, lineY),
OverlaySettings.MachFontSize,
ParseColor(OverlaySettings.MachTextColor, Color.FromRgb(87, 199, 242)),
OverlaySettings.MachOutlineWidth,
OverlaySettings.SpotFontOpacity);
lineY += OverlaySettings.MachFontSize + 2;
}
if (OverlaySettings.SpotShowRelativeSpeed && target.ClosureSpeed.HasValue)
{
DrawOutlinedText(
context,
$"{target.ClosureSpeed.Value:+0;-0;0} m/s",
new Point(point.X + 14, lineY),
OverlaySettings.RelativeFontSize,
ParseColor(OverlaySettings.RelativeTextColor, Color.FromRgb(25, 242, 79)),
OverlaySettings.RelativeOutlineWidth,
OverlaySettings.SpotFontOpacity);
}
}
}
private static void DrawTriangle(DrawingContext context, Point center, double size, IBrush brush, double outlineWidth)
{
var geometry = new StreamGeometry();
using (var ctx = geometry.Open())
{
ctx.BeginFigure(new Point(center.X, center.Y - size), true);
ctx.LineTo(new Point(center.X + size * 0.8, center.Y + size * 0.7));
ctx.LineTo(new Point(center.X - size * 0.8, center.Y + size * 0.7));
ctx.EndFigure(true);
}
var outline = outlineWidth > 0 ? new Pen(Brushes.Black, outlineWidth) : null;
context.DrawGeometry(brush, outline, geometry);
}
}

View File

@@ -0,0 +1,215 @@
using Ehwrj.App.Models;
using Ehwrj.Core.Models;
using Ehwrj.Core.Services;
namespace Ehwrj.App.Services;
public sealed class LiveMapService : IDisposable
{
private static readonly EndpointDescriptor[] Endpoints =
[
new("map_info.json", Required: true),
new("map_obj.json", Required: true),
new("map.img", Required: true),
new("state", Required: false),
new("hudmsg", Required: false),
new("gamechat", Required: false)
];
private readonly IWarThunderClient _client;
private readonly ProcessProbe _processProbe;
private readonly ObjectTracker _tracker = new();
private CancellationTokenSource? _cts;
private Task? _loop;
private byte[]? _lastImageBytes;
private Avalonia.Media.Imaging.Bitmap? _lastImage;
public LiveMapService(IWarThunderClient client, ProcessProbe processProbe)
{
_client = client;
_processProbe = processProbe;
}
public event EventHandler<LiveSnapshot>? SnapshotUpdated;
public bool IsRunning => _loop is { IsCompleted: false };
public void Start(int intervalMs)
{
if (IsRunning) return;
_cts = new CancellationTokenSource();
_loop = Task.Run(() => RunAsync(Math.Max(100, intervalMs), _cts.Token));
}
public void Stop()
{
_cts?.Cancel();
}
private async Task RunAsync(int intervalMs, CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(intervalMs));
while (!cancellationToken.IsCancellationRequested)
{
await PublishOnceAsync(cancellationToken).ConfigureAwait(false);
await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false);
}
}
private async Task PublishOnceAsync(CancellationToken cancellationToken)
{
var gameRunning = _processProbe.IsWarThunderRunning();
var endpointHealth = new List<EndpointHealth>();
try
{
var mapInfoJson = await ReadRequiredAsync("map_info.json", _client.GetMapInfoAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
if (mapInfoJson is null)
{
PublishFailure(gameRunning, endpointHealth, "Waiting for local map API: map_info.json failed");
return;
}
var objectsJson = await ReadRequiredAsync("map_obj.json", _client.GetObjectsAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
if (objectsJson is null)
{
PublishFailure(gameRunning, endpointHealth, "Waiting for local map API: map_obj.json failed");
return;
}
var imageBytes = await ReadRequiredAsync("map.img", _client.GetMapImageAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
if (imageBytes is null)
{
PublishFailure(gameRunning, endpointHealth, "Waiting for local map API: map.img failed");
return;
}
var stateJson = await ReadOptionalAsync("state", _client.TryGetStateAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
var hudJson = await ReadOptionalAsync("hudmsg", _client.TryGetHudMessagesAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
var chatJson = await ReadOptionalAsync("gamechat", _client.TryGetGameChatAsync, endpointHealth, cancellationToken).ConfigureAwait(false);
var mapInfo = MapInfo.FromJson(mapInfoJson);
var flightState = FlightState.FromJson(stateJson);
var objects = MapObject.FromJson(objectsJson, _tracker, flightState);
var messages = BattleMessage.FromJson(hudJson)
.Concat(BattleMessage.FromJson(chatJson))
.DistinctBy(static m => $"{m.Enemy}:{m.Text}", StringComparer.Ordinal)
.ToArray();
var mapImage = GetBitmap(imageBytes);
var snapshot = new LiveSnapshot(mapInfo, objects, flightState, messages, mapImage, DateTimeOffset.Now, gameRunning, "Connected to 127.0.0.1:8111", CompleteEndpointHealth(endpointHealth));
SnapshotUpdated?.Invoke(this, snapshot);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
var status = gameRunning
? $"Waiting for local map API: {ex.Message}"
: "War Thunder process not detected";
PublishFailure(gameRunning, endpointHealth, status);
}
}
private async Task<T?> ReadRequiredAsync<T>(
string path,
Func<CancellationToken, Task<T>> read,
List<EndpointHealth> health,
CancellationToken cancellationToken)
where T : class
{
try
{
var value = await read(cancellationToken).ConfigureAwait(false);
health.Add(new EndpointHealth(path, Required: true, EndpointHealthState.Ok, "ok"));
return value;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
health.Add(new EndpointHealth(path, Required: true, EndpointHealthState.Error, ex.Message));
return null;
}
}
private async Task<string?> ReadOptionalAsync(
string path,
Func<CancellationToken, Task<string?>> read,
List<EndpointHealth> health,
CancellationToken cancellationToken)
{
try
{
var value = await read(cancellationToken).ConfigureAwait(false);
health.Add(value is null
? new EndpointHealth(path, Required: false, EndpointHealthState.Warning, "not available")
: new EndpointHealth(path, Required: false, EndpointHealthState.Ok, "ok"));
return value;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
health.Add(new EndpointHealth(path, Required: false, EndpointHealthState.Warning, ex.Message));
return null;
}
}
private void PublishFailure(bool gameRunning, List<EndpointHealth> endpointHealth, string status)
{
SnapshotUpdated?.Invoke(
this,
new LiveSnapshot(
MapInfo.Empty,
Array.Empty<MapObject>(),
FlightState.Empty,
Array.Empty<BattleMessage>(),
_lastImage,
DateTimeOffset.Now,
gameRunning,
status,
CompleteEndpointHealth(endpointHealth)));
}
private static IReadOnlyList<EndpointHealth> CompleteEndpointHealth(IReadOnlyList<EndpointHealth> health)
{
var completed = new List<EndpointHealth>(health);
foreach (var endpoint in Endpoints)
{
if (completed.Any(h => string.Equals(h.Path, endpoint.Path, StringComparison.Ordinal)))
{
continue;
}
completed.Add(new EndpointHealth(endpoint.Path, endpoint.Required, EndpointHealthState.NotChecked, "not checked"));
}
return completed;
}
private Avalonia.Media.Imaging.Bitmap? GetBitmap(byte[] imageBytes)
{
if (_lastImageBytes is not null && imageBytes.SequenceEqual(_lastImageBytes))
{
return _lastImage;
}
using var stream = new MemoryStream(imageBytes);
var bitmap = new Avalonia.Media.Imaging.Bitmap(stream);
_lastImageBytes = imageBytes;
_lastImage = bitmap;
return bitmap;
}
public void Dispose()
{
Stop();
_cts?.Dispose();
}
}
internal sealed record EndpointDescriptor(string Path, bool Required);

View File

@@ -0,0 +1,45 @@
using System.IO;
using System.Text.Json;
using Ehwrj.App.Models;
namespace Ehwrj.App.Services;
public sealed class SettingsStore
{
private readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true
};
public string SettingsDirectory { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Ehwrj");
public string SettingsPath => Path.Combine(SettingsDirectory, "settings.json");
public AppSettings Load()
{
try
{
if (!File.Exists(SettingsPath))
{
return new AppSettings();
}
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<AppSettings>(json, _jsonOptions) ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
public void Save(AppSettings settings)
{
Directory.CreateDirectory(SettingsDirectory);
var json = JsonSerializer.Serialize(settings, _jsonOptions);
File.WriteAllText(SettingsPath, json);
}
}

View File

@@ -0,0 +1,274 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using Avalonia.Threading;
using Ehwrj.App.Models;
using Ehwrj.App.Services;
using Ehwrj.Core.Models;
namespace Ehwrj.App.ViewModels;
public sealed class MainViewModel : ObservableObject, IDisposable
{
private readonly LiveMapService _service;
private readonly SettingsStore _settingsStore;
private LiveSnapshot _snapshot = LiveSnapshot.Empty;
private bool _isOverlayEnabled;
private int _lastAllyCount = -1;
private int _lastEnemyCount = -1;
private DateTimeOffset? _sessionStartedAt;
private readonly HashSet<string> _seenMessages = new(StringComparer.Ordinal);
public MainViewModel(LiveMapService service, SettingsStore settingsStore, AppSettings settings)
{
_service = service;
_settingsStore = settingsStore;
Settings = settings;
StartCommand = new RelayCommand(Start);
StopCommand = new RelayCommand(Stop);
SaveCommand = new RelayCommand(Save);
ResetSettingsCommand = new RelayCommand(ResetSettings);
_service.SnapshotUpdated += OnSnapshotUpdated;
UpdateEndpointHealth(Snapshot);
}
public AppSettings Settings { get; }
public ICommand StartCommand { get; }
public ICommand StopCommand { get; }
public ICommand SaveCommand { get; }
public ICommand ResetSettingsCommand { get; }
public ObservableCollection<string> BattleLogEntries { get; } = new();
public ObservableCollection<string> EndpointHealthEntries { get; } = new();
public IReadOnlyList<LanguageOption> LanguageOptions => UiText.LanguageOptions;
public UiText Ui => UiText.For(Settings.Language);
public LanguageOption SelectedLanguageOption
{
get => LanguageOptions.FirstOrDefault(o => string.Equals(o.Code, Settings.Language, StringComparison.OrdinalIgnoreCase))
?? LanguageOptions[0];
set
{
if (string.Equals(Settings.Language, value.Code, StringComparison.OrdinalIgnoreCase))
{
return;
}
Settings.Language = value.Code;
OnPropertyChanged();
OnPropertyChanged(nameof(Ui));
NotifyLocalizedProperties();
RebuildBattleLog();
Save();
}
}
public LiveSnapshot Snapshot
{
get => _snapshot;
private set
{
if (SetProperty(ref _snapshot, value))
{
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(ProcessStatus));
OnPropertyChanged(nameof(SnapshotSummary));
OnPropertyChanged(nameof(AllySummary));
OnPropertyChanged(nameof(EnemySummary));
OnPropertyChanged(nameof(BattleTimerText));
OnPropertyChanged(nameof(PlayerInfo));
OnPropertyChanged(nameof(LastUpdatedText));
UpdateEndpointHealth(value);
UpdateBattleLog(value);
}
}
}
public bool IsOverlayEnabled
{
get => _isOverlayEnabled;
set => SetProperty(ref _isOverlayEnabled, value);
}
public string Status => Ui.FormatStatus(Snapshot.Status);
public string ProcessStatus => Snapshot.IsGameRunning ? Ui.AcesDetected : Ui.AcesNotDetected;
public string SnapshotSummary => Ui.FormatObjectSummary(Snapshot.Objects.Count);
public string AllySummary => Ui.FormatAllySummary(AllyCount);
public string EnemySummary => Ui.FormatEnemySummary(EnemyCount);
public string BattleTimerText => _sessionStartedAt.HasValue
? FormatElapsed(DateTimeOffset.Now - _sessionStartedAt.Value)
: "0s";
public string PlayerInfo => FormatPlayerInfo();
public string LastUpdatedText => Snapshot.UpdatedAt == DateTimeOffset.MinValue
? Ui.NeverUpdated
: Ui.FormatUpdated(Snapshot.UpdatedAt);
private int AllyCount => Snapshot.Objects.Count(static o => o.IsAircraft && o.Kind is MapObjectKind.Ally or MapObjectKind.Squad or MapObjectKind.Player);
private int EnemyCount => Snapshot.Objects.Count(static o => o.IsAircraft && o.Kind == MapObjectKind.Enemy);
private void Start()
{
_service.Start(Settings.PollIntervalMs);
}
private void Stop()
{
_service.Stop();
}
private void Save()
{
_settingsStore.Save(Settings);
}
private void ResetSettings()
{
Settings.Map.Reset();
Settings.Overlay.Reset();
Save();
}
private void UpdateBattleLog(LiveSnapshot snapshot)
{
if (snapshot.UpdatedAt == DateTimeOffset.MinValue)
{
return;
}
_sessionStartedAt ??= DateTimeOffset.Now;
var ally = AllyCount;
var enemy = EnemyCount;
foreach (var message in snapshot.Messages)
{
var key = $"{message.Enemy}:{message.Text}";
if (_seenMessages.Add(key))
{
var prefix = message.Enemy switch
{
true => Ui.MessageEnemy,
false => Ui.MessageAlly,
_ => Ui.MessageEvent
};
AddBattleLog($"{prefix}: {message.Text}");
}
}
if (_lastAllyCount < 0 || _lastEnemyCount < 0)
{
AddBattleLog(Ui.FormatSessionStarted(ally, enemy));
}
else
{
if (ally != _lastAllyCount)
{
AddBattleLog(Ui.FormatAlliedAircraftChanged(_lastAllyCount, ally));
}
if (enemy != _lastEnemyCount)
{
AddBattleLog(Ui.FormatEnemyAircraftChanged(_lastEnemyCount, enemy));
}
}
_lastAllyCount = ally;
_lastEnemyCount = enemy;
}
private void AddBattleLog(string text)
{
BattleLogEntries.Add($"{DateTimeOffset.Now:HH:mm:ss} {text}");
while (BattleLogEntries.Count > 120)
{
BattleLogEntries.RemoveAt(0);
}
}
private string FormatPlayerInfo()
{
var player = Snapshot.Player;
if (player is null)
{
return Ui.PlayerNotDetected;
}
var parts = new List<string> { player.Name ?? Ui.Player };
if (player.X.HasValue && player.Y.HasValue)
{
parts.Add($"{Ui.Pos} {player.X.Value:0.000}, {player.Y.Value:0.000}");
}
if (player.Mach.HasValue)
{
parts.Add($"M {player.Mach.Value:0.0}");
}
if (player.ClimbAngleDegrees.HasValue)
{
parts.Add($"{Ui.Climb} {player.ClimbAngleDegrees.Value:+0;-0;0} deg");
}
var speed = Snapshot.FlightState.TrueAirspeedKmh ?? Snapshot.FlightState.IndicatedAirspeedKmh;
if (speed.HasValue)
{
parts.Add($"{speed.Value:0} km/h");
}
if (player.HeadingRadians.HasValue)
{
parts.Add($"{Ui.Heading} {player.HeadingRadians.Value * 180 / Math.PI:0} deg");
}
return string.Join(" | ", parts);
}
private static string FormatElapsed(TimeSpan elapsed)
{
return elapsed.TotalMinutes >= 1
? $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s"
: $"{Math.Max(0, elapsed.Seconds)}s";
}
private void OnSnapshotUpdated(object? sender, LiveSnapshot snapshot)
{
Dispatcher.UIThread.Post(() => Snapshot = snapshot);
}
private void RebuildBattleLog()
{
BattleLogEntries.Clear();
_seenMessages.Clear();
_lastAllyCount = -1;
_lastEnemyCount = -1;
UpdateBattleLog(Snapshot);
UpdateEndpointHealth(Snapshot);
}
private void NotifyLocalizedProperties()
{
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(ProcessStatus));
OnPropertyChanged(nameof(SnapshotSummary));
OnPropertyChanged(nameof(AllySummary));
OnPropertyChanged(nameof(EnemySummary));
OnPropertyChanged(nameof(BattleTimerText));
OnPropertyChanged(nameof(PlayerInfo));
OnPropertyChanged(nameof(LastUpdatedText));
UpdateEndpointHealth(Snapshot);
}
private void UpdateEndpointHealth(LiveSnapshot snapshot)
{
EndpointHealthEntries.Clear();
foreach (var health in snapshot.EndpointHealth)
{
EndpointHealthEntries.Add(Ui.FormatEndpointHealth(health));
}
}
public void Dispose()
{
_service.SnapshotUpdated -= OnSnapshotUpdated;
_settingsStore.Save(Settings);
_service.Dispose();
}
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Ehwrj.App.ViewModels;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,33 @@
using System.Windows.Input;
namespace Ehwrj.App.ViewModels;
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke() ?? true;
}
public void Execute(object? parameter)
{
_execute();
}
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}