Add Ehwrj clean-room live map
Some checks failed
build / build-test-publish (push) Has been cancelled
Some checks failed
build / build-test-publish (push) Has been cancelled
This commit is contained in:
34
src/Ehwrj.App/App.axaml
Normal file
34
src/Ehwrj.App/App.axaml
Normal 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>
|
||||
|
||||
24
src/Ehwrj.App/App.axaml.cs
Normal file
24
src/Ehwrj.App/App.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
20
src/Ehwrj.App/Ehwrj.App.csproj
Normal file
20
src/Ehwrj.App/Ehwrj.App.csproj
Normal 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>
|
||||
30
src/Ehwrj.App/Infrastructure/Win32.cs
Normal file
30
src/Ehwrj.App/Infrastructure/Win32.cs
Normal 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);
|
||||
}
|
||||
227
src/Ehwrj.App/MainWindow.axaml
Normal file
227
src/Ehwrj.App/MainWindow.axaml
Normal 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>
|
||||
74
src/Ehwrj.App/MainWindow.axaml.cs
Normal file
74
src/Ehwrj.App/MainWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
9
src/Ehwrj.App/Models/AppSettings.cs
Normal file
9
src/Ehwrj.App/Models/AppSettings.cs
Normal 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;
|
||||
}
|
||||
15
src/Ehwrj.App/Models/EndpointHealth.cs
Normal file
15
src/Ehwrj.App/Models/EndpointHealth.cs
Normal 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);
|
||||
3
src/Ehwrj.App/Models/LanguageOption.cs
Normal file
3
src/Ehwrj.App/Models/LanguageOption.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Ehwrj.App.Models;
|
||||
|
||||
public sealed record LanguageOption(string Code, string DisplayName);
|
||||
61
src/Ehwrj.App/Models/LiveSnapshot.cs
Normal file
61
src/Ehwrj.App/Models/LiveSnapshot.cs
Normal 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);
|
||||
}
|
||||
76
src/Ehwrj.App/Models/MapSettings.cs
Normal file
76
src/Ehwrj.App/Models/MapSettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
283
src/Ehwrj.App/Models/OverlaySettings.cs
Normal file
283
src/Ehwrj.App/Models/OverlaySettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
311
src/Ehwrj.App/Models/UiText.cs
Normal file
311
src/Ehwrj.App/Models/UiText.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
16
src/Ehwrj.App/OverlayWindow.axaml
Normal file
16
src/Ehwrj.App/OverlayWindow.axaml
Normal 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>
|
||||
45
src/Ehwrj.App/OverlayWindow.axaml.cs
Normal file
45
src/Ehwrj.App/OverlayWindow.axaml.cs
Normal 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
21
src/Ehwrj.App/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
463
src/Ehwrj.App/Rendering/MapCanvas.cs
Normal file
463
src/Ehwrj.App/Rendering/MapCanvas.cs
Normal 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);
|
||||
155
src/Ehwrj.App/Rendering/OverlayCanvas.cs
Normal file
155
src/Ehwrj.App/Rendering/OverlayCanvas.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
215
src/Ehwrj.App/Services/LiveMapService.cs
Normal file
215
src/Ehwrj.App/Services/LiveMapService.cs
Normal 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);
|
||||
45
src/Ehwrj.App/Services/SettingsStore.cs
Normal file
45
src/Ehwrj.App/Services/SettingsStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
274
src/Ehwrj.App/ViewModels/MainViewModel.cs
Normal file
274
src/Ehwrj.App/ViewModels/MainViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
27
src/Ehwrj.App/ViewModels/ObservableObject.cs
Normal file
27
src/Ehwrj.App/ViewModels/ObservableObject.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
33
src/Ehwrj.App/ViewModels/RelayCommand.cs
Normal file
33
src/Ehwrj.App/ViewModels/RelayCommand.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user