RQ Overlay: оверлей заработка и статистики для Royal Quest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Сборка
|
||||
bin/
|
||||
obj/
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vs/
|
||||
*.user
|
||||
*.DotSettings.user
|
||||
3
RQOverlay.slnx
Normal file
3
RQOverlay.slnx
Normal file
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="src/RQOverlay/RQOverlay.csproj" />
|
||||
</Solution>
|
||||
374
src/RQOverlay/App.xaml
Normal file
374
src/RQOverlay/App.xaml
Normal file
@@ -0,0 +1,374 @@
|
||||
<Application x:Class="RQOverlay.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
ShutdownMode="OnMainWindowClose">
|
||||
<!--
|
||||
Общие стили. Все цвета, шрифты, скругления и тени — токены темы (Themes/*.xaml),
|
||||
поэтому здесь они берутся через DynamicResource: тема меняется на лету.
|
||||
Первый словарь в MergedDictionaries — текущая тема, его подменяет ThemeManager.
|
||||
-->
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Fluent.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- ================= Кнопка-иконка ================= -->
|
||||
<Style x:Key="IconButton" TargetType="Button">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource Icons}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDim}" />
|
||||
<Setter Property="Width" Value="25" />
|
||||
<Setter Property="Height" Value="26" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bg" Background="Transparent" CornerRadius="{DynamicResource RadiusS}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource Hover}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource Pressed}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ================= Обычная кнопка ================= -->
|
||||
<Style x:Key="FlatButton" TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceAlt}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource Ui}" />
|
||||
<Setter Property="Padding" Value="14,6" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bg" Background="{TemplateBinding Background}" CornerRadius="{DynamicResource RadiusM}"
|
||||
Padding="{TemplateBinding Padding}" BorderBrush="{DynamicResource CardStroke}" BorderThickness="1">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource SurfaceHover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource Pressed}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.4" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ================= Скроллбар ================= -->
|
||||
<Style x:Key="ThumbStyle" TargetType="Thumb">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border Background="{DynamicResource Thumb}" CornerRadius="{DynamicResource RadiusS}" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Width" Value="6" />
|
||||
<Setter Property="MinWidth" Value="6" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Track x:Name="PART_Track" IsDirectionReversed="True">
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource ThumbStyle}" />
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter Property="Width" Value="Auto" />
|
||||
<Setter Property="Height" Value="6" />
|
||||
<Setter Property="MinHeight" Value="6" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Track x:Name="PART_Track">
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource ThumbStyle}" />
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ================= Поле ввода ================= -->
|
||||
<Style x:Key="DarkBox" TargetType="TextBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceAlt}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardStroke}" />
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource Ui}" />
|
||||
<Setter Property="Padding" Value="8,0" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Grid>
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="{DynamicResource RadiusM}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<!-- Подчёркивание фокуса, как у полей WinUI -->
|
||||
<Border x:Name="Focus" Height="2" VerticalAlignment="Bottom" Background="{DynamicResource Accent}"
|
||||
CornerRadius="0,0,2,2" Margin="1,0" Visibility="Collapsed" />
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Focus" Property="Visibility" Value="Visible" />
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Surface}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ================= Флажок ================= -->
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Border x:Name="Box" Width="16" Height="16" CornerRadius="{DynamicResource RadiusS}" BorderThickness="1"
|
||||
BorderBrush="{DynamicResource TextFaint}" Background="Transparent" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="Mark" Text="" FontFamily="{DynamicResource Icons}" FontSize="10"
|
||||
Foreground="{DynamicResource OnAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="Hidden" />
|
||||
</Border>
|
||||
<ContentPresenter Margin="8,0,0,0" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource TextDim}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Box" Property="Background" Value="{DynamicResource Accent}" />
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter TargetName="Mark" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ================= Переключатель ================= -->
|
||||
<Style x:Key="Switch" TargetType="CheckBox">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<Border x:Name="Track" Width="40" Height="20" CornerRadius="{DynamicResource RadiusPill}" Background="Transparent"
|
||||
BorderBrush="{DynamicResource TextFaint}" BorderThickness="1">
|
||||
<Border x:Name="Knob" Width="12" Height="12" CornerRadius="{DynamicResource RadiusPill}"
|
||||
Background="{DynamicResource TextDim}" HorizontalAlignment="Left" Margin="4,0,0,0" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Track" Property="Background" Value="{DynamicResource Accent}" />
|
||||
<Setter TargetName="Track" Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter TargetName="Knob" Property="Background" Value="{DynamicResource OnAccent}" />
|
||||
<Setter TargetName="Knob" Property="HorizontalAlignment" Value="Right" />
|
||||
<Setter TargetName="Knob" Property="Margin" Value="0,0,4,0" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ================= Сегментированный выбор ================= -->
|
||||
<Style x:Key="Segment" TargetType="RadioButton">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDim}" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RadioButton">
|
||||
<Border x:Name="Bg" Background="Transparent" CornerRadius="{DynamicResource RadiusS}" Padding="12,4">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource SegmentOn}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SegmentOnText}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ================= Карточка и подписи ================= -->
|
||||
<Style x:Key="Card" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceAlt}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardStroke}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource RadiusL}" />
|
||||
<Setter Property="Padding" Value="16,12" />
|
||||
</Style>
|
||||
|
||||
<!-- Мелкая подпись раздела: «ДОБЫЧА», «СЕССИЯ» -->
|
||||
<Style x:Key="Caption" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource CaptionFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!-- Заголовок раздела -->
|
||||
<Style x:Key="HeadingText" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource Heading}" />
|
||||
<Setter Property="FontWeight" Value="{DynamicResource HeadingWeight}" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!-- Числа: моноширинные цифры -->
|
||||
<Style x:Key="Num" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource NumFont}" />
|
||||
<Setter Property="Typography.NumeralAlignment" Value="Tabular" />
|
||||
</Style>
|
||||
|
||||
<!-- Крупное число: итоги и карточки -->
|
||||
<Style x:Key="BigNum" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BigNumFont}" />
|
||||
<Setter Property="FontWeight" Value="{DynamicResource BigNumWeight}" />
|
||||
<Setter Property="Typography.NumeralAlignment" Value="Tabular" />
|
||||
</Style>
|
||||
|
||||
<!-- ================= Ячейки таблиц ================= -->
|
||||
<Style x:Key="CellEditor" TargetType="TextBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceHover}" />
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource NumFont}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,2" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter Property="Padding" Value="4,0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style x:Key="CellText" TargetType="TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Padding" Value="10,0" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
</Style>
|
||||
<Style x:Key="CellNum" TargetType="TextBlock" BasedOn="{StaticResource CellText}">
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource NumFont}" />
|
||||
<Setter Property="Typography.NumeralAlignment" Value="Tabular" />
|
||||
</Style>
|
||||
<Style x:Key="CellDim" TargetType="TextBlock" BasedOn="{StaticResource CellText}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDim}" />
|
||||
</Style>
|
||||
<Style x:Key="CellNumDim" TargetType="TextBlock" BasedOn="{StaticResource CellNum}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDim}" />
|
||||
</Style>
|
||||
|
||||
<!-- ================= Таблица ================= -->
|
||||
<Style x:Key="DarkGrid" TargetType="DataGrid">
|
||||
<Setter Property="AutoGenerateColumns" Value="False" />
|
||||
<Setter Property="CanUserAddRows" Value="False" />
|
||||
<Setter Property="CanUserDeleteRows" Value="False" />
|
||||
<Setter Property="CanUserResizeRows" Value="False" />
|
||||
<Setter Property="HeadersVisibility" Value="Column" />
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource GridLine}" />
|
||||
<Setter Property="SelectionMode" Value="Single" />
|
||||
<Setter Property="SelectionUnit" Value="FullRow" />
|
||||
<Setter Property="RowHeight" Value="32" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource Ui}" />
|
||||
<Setter Property="RowBackground" Value="Transparent" />
|
||||
<Setter Property="AlternatingRowBackground" Value="{DynamicResource RowAlt}" />
|
||||
<Setter Property="ColumnHeaderStyle">
|
||||
<Setter.Value>
|
||||
<Style TargetType="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="{DynamicResource HeaderBg}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFaint}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource CaptionFont}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="10,8" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Divider}" />
|
||||
</Style>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Setter Property="RowStyle">
|
||||
<Setter.Value>
|
||||
<Style TargetType="DataGridRow">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource Hover}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Setter Property="CellStyle">
|
||||
<Setter.Value>
|
||||
<Style TargetType="DataGridCell">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="DataGridCell">
|
||||
<Border Background="{TemplateBinding Background}">
|
||||
<ContentPresenter VerticalAlignment="Stretch" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource Selection}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
68
src/RQOverlay/App.xaml.cs
Normal file
68
src/RQOverlay/App.xaml.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using RQOverlay.Core;
|
||||
using RQOverlay.Plugins;
|
||||
using RQOverlay.Views;
|
||||
|
||||
namespace RQOverlay;
|
||||
|
||||
public partial class App : Application, IPluginHost
|
||||
{
|
||||
readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public PriceBook Prices { get; } = new();
|
||||
public PluginManager Plugins { get; private set; } = null!;
|
||||
public SessionViewModel Session { get; private set; } = null!;
|
||||
public OverlaySettings Settings { get; } = OverlaySettings.Load();
|
||||
|
||||
public string DataDir => AppPaths.Dir;
|
||||
|
||||
public static new App Current => (App)Application.Current;
|
||||
|
||||
public void OpenPrices(bool unpricedOnly) => AppWindow.ShowSingle(AppWindow.Page.Prices, unpricedOnly);
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
ThemeManager.Apply(Settings.Theme);
|
||||
|
||||
Prices.Load();
|
||||
Prices.UseAuction = Settings.UseAuction;
|
||||
Plugins = new PluginManager(this, Settings);
|
||||
Plugins.Load();
|
||||
Session = new SessionViewModel(Prices, Plugins);
|
||||
|
||||
var window = new MainWindow { DataContext = Session };
|
||||
MainWindow = window;
|
||||
window.Show();
|
||||
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||
timer.Tick += (_, _) => Session.Tick();
|
||||
timer.Start();
|
||||
|
||||
// Сканирование памяти — в фоне, результаты — в UI-поток.
|
||||
var watcher = new LogWatcher();
|
||||
watcher.StatusChanged += (connected, status) => Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
Session.Connected = connected;
|
||||
Session.Status = status;
|
||||
});
|
||||
|
||||
// --history: засчитать и то, что накопилось в логе до запуска оверлея.
|
||||
bool countHistory = e.Args.Contains("--history");
|
||||
watcher.EntriesReceived += (entries, initial) =>
|
||||
Dispatcher.BeginInvoke(() => Session.Apply(entries, initial, countHistory));
|
||||
|
||||
new Thread(() => watcher.Run(_cts.Token)) { IsBackground = true, Name = "LogWatcher" }.Start();
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_cts.Cancel();
|
||||
Plugins.Shutdown();
|
||||
Prices.Save();
|
||||
Settings.Save();
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
BIN
src/RQOverlay/Assets/RQOverlay.ico
Normal file
BIN
src/RQOverlay/Assets/RQOverlay.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
52
src/RQOverlay/Converters.cs
Normal file
52
src/RQOverlay/Converters.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace RQOverlay;
|
||||
|
||||
/// <summary>bool -> Visibility. ConverterParameter="!" инвертирует.</summary>
|
||||
public sealed class BoolToVis : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type t, object? p, CultureInfo c)
|
||||
{
|
||||
bool v = value is true;
|
||||
if (p as string == "!") v = !v;
|
||||
return v ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type t, object? p, CultureInfo c) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Панель-полоска: делит ширину между детьми пропорционально их весу
|
||||
/// (присоединённое свойство Weight). Для полоски долей дохода.
|
||||
/// </summary>
|
||||
public sealed class ShareStrip : Panel
|
||||
{
|
||||
public static readonly DependencyProperty WeightProperty = DependencyProperty.RegisterAttached(
|
||||
"Weight", typeof(double), typeof(ShareStrip),
|
||||
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsParentArrange));
|
||||
|
||||
public static double GetWeight(DependencyObject o) => (double)o.GetValue(WeightProperty);
|
||||
public static void SetWeight(DependencyObject o, double v) => o.SetValue(WeightProperty, v);
|
||||
|
||||
protected override Size MeasureOverride(Size available)
|
||||
{
|
||||
foreach (UIElement child in InternalChildren) child.Measure(available);
|
||||
return new Size(0, 0); // растягиваемся на всё, что дадут
|
||||
}
|
||||
|
||||
protected override Size ArrangeOverride(Size final)
|
||||
{
|
||||
double total = InternalChildren.Cast<UIElement>().Sum(c => Math.Max(0, GetWeight(c)));
|
||||
double x = 0;
|
||||
foreach (UIElement child in InternalChildren)
|
||||
{
|
||||
double w = total > 0 ? final.Width * Math.Max(0, GetWeight(child)) / total : 0;
|
||||
child.Arrange(new Rect(x, 0, w, final.Height));
|
||||
x += w;
|
||||
}
|
||||
return final;
|
||||
}
|
||||
}
|
||||
41
src/RQOverlay/Core/Config.cs
Normal file
41
src/RQOverlay/Core/Config.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace RQOverlay.Core;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// НАСТРОЙКИ — правим только здесь
|
||||
// ---------------------------------------------------------------------
|
||||
public static class Config
|
||||
{
|
||||
/// <summary>Имя процесса БЕЗ расширения .exe</summary>
|
||||
public const string ProcessName = "rqmain";
|
||||
|
||||
/// <summary>
|
||||
/// Сигнатура для поиска. Должна быть максимально уникальной:
|
||||
/// чем длиннее и специфичнее — тем меньше мусора и быстрее скан.
|
||||
/// </summary>
|
||||
public const string Signature = "<TR style='color:";
|
||||
|
||||
/// <summary>Пауза между опросами памяти, мс.</summary>
|
||||
public const int PollIntervalMs = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Раз в сколько опросов делать полный обход памяти.
|
||||
/// Лог при росте переезжает на новое место, а старая копия остаётся —
|
||||
/// без полного скана мы бы вечно читали устаревшую копию.
|
||||
/// </summary>
|
||||
public const int FullScanEvery = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница пользовательского адресного пространства.
|
||||
/// x64 -> 0x7FFFFFFFFFFF, x86 -> 0x7FFFFFFF
|
||||
/// </summary>
|
||||
public const ulong MaxUserAddress = 0x7FFFFFFFFFFF;
|
||||
|
||||
/// <summary>Размер куска при чтении большого региона (1 МБ).</summary>
|
||||
public const int ChunkSize = 1 << 20;
|
||||
|
||||
/// <summary>
|
||||
/// Максимальное расстояние от сигнатуры до границы строки, в байтах.
|
||||
/// Весь лог — одна длинная строка, поэтому запас большой.
|
||||
/// </summary>
|
||||
public const int MaxEntryBytes = 256 * 1024;
|
||||
}
|
||||
66
src/RQOverlay/Core/LogParser.cs
Normal file
66
src/RQOverlay/Core/LogParser.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace RQOverlay.Core;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ПАРСЕР HTML-ЛОГА
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <param name="Html">Тело записи с исходной разметкой — нужно, чтобы достать
|
||||
/// выделенные названия предметов (<U style='color:#FFFFFF'>...</U>).</param>
|
||||
/// <param name="Color">Цвет записи из разметки, например "#FF0000".</param>
|
||||
public record LogEntry(string Time, string Text, string Color, string Html);
|
||||
|
||||
public static class LogParser
|
||||
{
|
||||
// Разбираем конструкции вида:
|
||||
// <TR style='color:#FF0000' valign=top title='9/23 15:58:07'><TD colspan=2>текст
|
||||
// Тело заканчивается на следующем <TR или на следующем <TD colspan: в памяти
|
||||
// встречаются обрывки старых буферов, где у записи потерялось начало с <TR —
|
||||
// без второго условия такая сирота приклеилась бы к предыдущей записи.
|
||||
static readonly Regex EntryRegex = new(
|
||||
@"<TR\s+style='color:(?<color>#[0-9A-Fa-f]{6})'[^>]*title='(?<time>[^']*)'>" +
|
||||
@"(?<body>(?:<TD[^>]*>)?.*?)(?=<TR\s|<TD\s+colspan|$)",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
// Незакрытый тег в конце — запись обрезана посреди разметки.
|
||||
static readonly Regex TagRegex = new("<[^>]*(?:>|$)", RegexOptions.Compiled);
|
||||
|
||||
public static IEnumerable<LogEntry> Parse(string raw)
|
||||
{
|
||||
foreach (Match m in EntryRegex.Matches(raw))
|
||||
{
|
||||
string html = m.Groups["body"].Value;
|
||||
if (IsGarbage(html)) continue;
|
||||
|
||||
string text = StripTags(html);
|
||||
if (text.Length == 0) continue;
|
||||
|
||||
yield return new LogEntry(
|
||||
m.Groups["time"].Value,
|
||||
text,
|
||||
m.Groups["color"].Value,
|
||||
html);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Обрывок перезаписанного буфера: битый UTF-8 (U+FFFD) или управляющие символы.
|
||||
/// Настоящие записи лога — чистый текст с переводами строк.
|
||||
/// </summary>
|
||||
public static bool IsGarbage(string s)
|
||||
{
|
||||
foreach (char c in s)
|
||||
if (c == '<27>' || (c < 0x20 && c != '\n' && c != '\r' && c != '\t'))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Снимает HTML-теги, декодирует сущности и схлопывает пробелы.</summary>
|
||||
public static string StripTags(string html)
|
||||
{
|
||||
string text = TagRegex.Replace(html, " ");
|
||||
text = System.Net.WebUtility.HtmlDecode(text);
|
||||
return Regex.Replace(text, @"\s+", " ").Trim();
|
||||
}
|
||||
}
|
||||
104
src/RQOverlay/Core/LogWatcher.cs
Normal file
104
src/RQOverlay/Core/LogWatcher.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RQOverlay.Core;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// НАБЛЮДАТЕЛЬ ЗА ЛОГОМ
|
||||
// Ждёт процесс игры, периодически сканирует память и отдаёт только
|
||||
// новые записи. Переживает перезапуск игры.
|
||||
// ---------------------------------------------------------------------
|
||||
public sealed class LogWatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Новые записи. Второй аргумент — true для самого первого снимка:
|
||||
/// это история, накопленная до запуска программы.
|
||||
/// Вызывается из фонового потока.
|
||||
/// </summary>
|
||||
public event Action<IReadOnlyList<LogEntry>, bool>? EntriesReceived;
|
||||
|
||||
/// <summary>Подключены ли к игре. Вызывается из фонового потока.</summary>
|
||||
public event Action<bool, string>? StatusChanged;
|
||||
|
||||
public void Run(CancellationToken ct)
|
||||
{
|
||||
bool first = true;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var proc = Process.GetProcessesByName(Config.ProcessName).FirstOrDefault();
|
||||
if (proc is null)
|
||||
{
|
||||
StatusChanged?.Invoke(false, "Ожидание игры…");
|
||||
ct.WaitHandle.WaitOne(2000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ключи уже показанных записей. У каждого процесса игры свой лог,
|
||||
// поэтому при перезапуске начинаем с чистого листа.
|
||||
var seen = new HashSet<string>();
|
||||
// Тексты уже показанных записей по времени — для отсева повреждённых копий (см. IsDamaged).
|
||||
var textsByTime = new Dictionary<string, List<string>>();
|
||||
|
||||
try
|
||||
{
|
||||
using var scanner = new MemoryScanner(proc.Id);
|
||||
StatusChanged?.Invoke(true, $"Подключено (PID {proc.Id})");
|
||||
|
||||
while (!proc.HasExited && !ct.IsCancellationRequested)
|
||||
{
|
||||
var fresh = new List<LogEntry>();
|
||||
|
||||
foreach (var raw in scanner.FindAll(Config.Signature))
|
||||
{
|
||||
// Ключ: время + текст + порядковый номер такой записи внутри копии.
|
||||
// Номер нужен, чтобы два одинаковых события в одну секунду
|
||||
// (например, два одинаковых предмета) не схлопнулись в одно.
|
||||
// В памяти лежит несколько копий лога — у всех одинаковые ключи.
|
||||
var counts = new Dictionary<string, int>();
|
||||
var entries = LogParser.Parse(raw).ToList();
|
||||
var inCopy = entries.Select(e => e.Time + "|" + e.Text).ToHashSet();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
string key = entry.Time + "|" + entry.Text;
|
||||
int n = counts[key] = counts.GetValueOrDefault(key) + 1;
|
||||
if (seen.Contains(key + "|" + n)) continue;
|
||||
if (IsDamaged(entry, textsByTime, inCopy)) continue;
|
||||
|
||||
seen.Add(key + "|" + n);
|
||||
if (!textsByTime.TryGetValue(entry.Time, out var texts))
|
||||
textsByTime[entry.Time] = texts = new List<string>();
|
||||
texts.Add(entry.Text);
|
||||
fresh.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (fresh.Count > 0 || first)
|
||||
EntriesReceived?.Invoke(fresh, first);
|
||||
first = false;
|
||||
|
||||
ct.WaitHandle.WaitOne(Config.PollIntervalMs);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusChanged?.Invoke(false, "Ошибка: " + ex.Message);
|
||||
ct.WaitHandle.WaitOne(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Повреждённая копия записи. При смене локации игра перевыделяет буфер лога,
|
||||
/// и в памяти остаются копии, где запись обрезана или к ней приклеились остатки
|
||||
/// старого буфера: "...Получено золото: 6 ." -> "...Получено золото: 6 . 12".
|
||||
/// Признак: это начало или продолжение уже показанной записи с тем же временем,
|
||||
/// а самой той записи в этой копии нет. Настоящая похожая запись
|
||||
/// ("Роса" -> "Роса (3шт.)" в одну секунду) лежит в живом логе рядом с исходной.
|
||||
/// </summary>
|
||||
static bool IsDamaged(LogEntry entry, Dictionary<string, List<string>> textsByTime, HashSet<string> inCopy) =>
|
||||
textsByTime.TryGetValue(entry.Time, out var texts)
|
||||
&& texts.Any(s => s.Length != entry.Text.Length
|
||||
&& (s.StartsWith(entry.Text, StringComparison.Ordinal)
|
||||
|| entry.Text.StartsWith(s, StringComparison.Ordinal))
|
||||
&& !inCopy.Contains(entry.Time + "|" + s));
|
||||
}
|
||||
161
src/RQOverlay/Core/MemoryScanner.cs
Normal file
161
src/RQOverlay/Core/MemoryScanner.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace RQOverlay.Core;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// СКАНЕР ПАМЯТИ
|
||||
// ---------------------------------------------------------------------
|
||||
public sealed class MemoryScanner : IDisposable
|
||||
{
|
||||
readonly IntPtr _handle;
|
||||
|
||||
// "Горячие" регионы: те, где в прошлый раз нашлись данные.
|
||||
// Обычно проверяем только их, полный обход — раз в Config.FullScanEvery опросов.
|
||||
List<(ulong Base, ulong Size)> _hot = new();
|
||||
int _pollsSinceFull;
|
||||
|
||||
public MemoryScanner(int pid)
|
||||
{
|
||||
_handle = Native.OpenProcess(
|
||||
Native.PROCESS_VM_READ | Native.PROCESS_QUERY_INFORMATION,
|
||||
false, pid);
|
||||
|
||||
if (_handle == IntPtr.Zero)
|
||||
throw new InvalidOperationException(
|
||||
"OpenProcess не удался (код " + Marshal.GetLastWin32Error() +
|
||||
"). Попробуйте запустить от администратора.");
|
||||
}
|
||||
|
||||
/// <summary>Возвращает все найденные строки, содержащие сигнатуру.</summary>
|
||||
public IEnumerable<string> FindAll(string signature)
|
||||
{
|
||||
byte[] pattern = Encoding.UTF8.GetBytes(signature); // игра хранит лог в UTF-8
|
||||
|
||||
bool full = _hot.Count == 0 || ++_pollsSinceFull >= Config.FullScanEvery;
|
||||
if (full) _pollsSinceFull = 0;
|
||||
|
||||
var regions = full ? EnumerateRegions() : _hot;
|
||||
var found = new List<(ulong, ulong)>();
|
||||
|
||||
foreach (var (baseAddr, size) in regions)
|
||||
{
|
||||
bool any = false;
|
||||
foreach (var s in ScanRegion(baseAddr, size, pattern))
|
||||
{
|
||||
any = true;
|
||||
yield return s;
|
||||
}
|
||||
if (any) found.Add((baseAddr, size));
|
||||
}
|
||||
|
||||
// Если горячие регионы протухли, список опустеет и следующий опрос будет полным.
|
||||
_hot = found;
|
||||
}
|
||||
|
||||
/// <summary>Обход адресного пространства: отбираем читаемые регионы кучи.</summary>
|
||||
IEnumerable<(ulong Base, ulong Size)> EnumerateRegions()
|
||||
{
|
||||
ulong addr = 0;
|
||||
int mbiSize = Marshal.SizeOf<MemoryBasicInformation>();
|
||||
|
||||
while (addr < Config.MaxUserAddress)
|
||||
{
|
||||
if (Native.VirtualQueryEx(_handle, (IntPtr)addr, out var mbi, (IntPtr)mbiSize) == 0)
|
||||
break;
|
||||
|
||||
bool committed = mbi.State == Native.MEM_COMMIT;
|
||||
|
||||
// PAGE_GUARD читать нельзя — обращение вызовет исключение в цели.
|
||||
bool guarded = (mbi.Protect & Native.PAGE_GUARD) != 0
|
||||
|| (mbi.Protect & Native.PAGE_NOACCESS) != 0;
|
||||
|
||||
bool writable = mbi.Protect == Native.PAGE_READWRITE
|
||||
|| mbi.Protect == Native.PAGE_WRITECOPY
|
||||
|| mbi.Protect == Native.PAGE_EXECUTE_READWRITE;
|
||||
|
||||
// MEM_PRIVATE = куча/стек. Отсекает образы DLL и файловые маппинги,
|
||||
// где динамического текста лога быть не может.
|
||||
bool isHeap = mbi.Type == Native.MEM_PRIVATE;
|
||||
|
||||
if (committed && !guarded && writable && isHeap && mbi.RegionSize > 0)
|
||||
yield return (mbi.BaseAddress, mbi.RegionSize);
|
||||
|
||||
ulong next = mbi.BaseAddress + mbi.RegionSize;
|
||||
if (next <= addr) break; // защита от зацикливания
|
||||
addr = next;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Читает регион кусками и ищет в них сигнатуру.</summary>
|
||||
IEnumerable<string> ScanRegion(ulong baseAddr, ulong size, byte[] pattern)
|
||||
{
|
||||
// Перехлёст нужен, чтобы не потерять совпадение на границе кусков
|
||||
// и чтобы хвост строки целиком попал в буфер.
|
||||
int overlap = pattern.Length + Config.MaxEntryBytes;
|
||||
int step = Config.ChunkSize - overlap;
|
||||
if (step <= 0) step = Config.ChunkSize;
|
||||
|
||||
for (ulong offset = 0; offset < size; offset += (ulong)step)
|
||||
{
|
||||
int len = (int)Math.Min((ulong)Config.ChunkSize, size - offset);
|
||||
byte[] buffer = new byte[len];
|
||||
|
||||
// Регион мог быть освобождён между VirtualQueryEx и чтением —
|
||||
// это нормально, просто пропускаем.
|
||||
if (!Native.ReadProcessMemory(_handle, (IntPtr)(baseAddr + offset),
|
||||
buffer, (IntPtr)len, out IntPtr read))
|
||||
continue;
|
||||
|
||||
int actual = (int)read;
|
||||
if (actual < pattern.Length) continue;
|
||||
|
||||
int pos = 0;
|
||||
while ((pos = IndexOf(buffer, pattern, pos, actual)) >= 0)
|
||||
{
|
||||
// Весь лог — одна строка со множеством <TR>. Отдаём её один раз
|
||||
// и прыгаем за её конец, а не к следующему <TR> внутри неё.
|
||||
var (text, end) = ExtractUtf8String(buffer, pos, actual);
|
||||
if (text.Length > 0) yield return text;
|
||||
pos = Math.Max(end, pos + pattern.Length);
|
||||
}
|
||||
|
||||
if (len < Config.ChunkSize) break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Поиск подмассива (векторизованный через Span).</summary>
|
||||
static int IndexOf(byte[] haystack, byte[] needle, int from, int limit)
|
||||
{
|
||||
if (from >= limit) return -1;
|
||||
int i = haystack.AsSpan(from, limit - from).IndexOf(needle);
|
||||
return i < 0 ? -1 : from + i;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Расширяет позицию попадания до границ UTF-8 строки.
|
||||
/// Граница — нулевой байт (терминатор). Возвращает текст и смещение его конца.
|
||||
/// </summary>
|
||||
static (string Text, int End) ExtractUtf8String(byte[] buffer, int hit, int limit)
|
||||
{
|
||||
int start = hit;
|
||||
int minStart = Math.Max(0, hit - Config.MaxEntryBytes);
|
||||
while (start - 1 >= minStart && buffer[start - 1] != 0)
|
||||
start--;
|
||||
|
||||
int end = hit;
|
||||
int maxEnd = Math.Min(limit, hit + Config.MaxEntryBytes);
|
||||
while (end < maxEnd && buffer[end] != 0)
|
||||
end++;
|
||||
|
||||
int length = end - start;
|
||||
if (length <= 0) return (string.Empty, end);
|
||||
|
||||
return (Encoding.UTF8.GetString(buffer, start, length), end);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_handle != IntPtr.Zero) Native.CloseHandle(_handle);
|
||||
}
|
||||
}
|
||||
52
src/RQOverlay/Core/Native.cs
Normal file
52
src/RQOverlay/Core/Native.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RQOverlay.Core;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// WINAPI
|
||||
// ---------------------------------------------------------------------
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct MemoryBasicInformation
|
||||
{
|
||||
public ulong BaseAddress;
|
||||
public ulong AllocationBase;
|
||||
public uint AllocationProtect;
|
||||
public uint Alignment1;
|
||||
public ulong RegionSize;
|
||||
public uint State;
|
||||
public uint Protect;
|
||||
public uint Type;
|
||||
public uint Alignment2;
|
||||
}
|
||||
|
||||
static class Native
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr OpenProcess(int access, bool inheritHandle, int processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool ReadProcessMemory(
|
||||
IntPtr handle, IntPtr address, byte[] buffer, IntPtr size, out IntPtr bytesRead);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern int VirtualQueryEx(
|
||||
IntPtr handle, IntPtr address, out MemoryBasicInformation mbi, IntPtr length);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
// Права доступа
|
||||
public const int PROCESS_VM_READ = 0x0010;
|
||||
public const int PROCESS_QUERY_INFORMATION = 0x0400;
|
||||
|
||||
// Состояние региона
|
||||
public const uint MEM_COMMIT = 0x1000;
|
||||
public const uint MEM_PRIVATE = 0x20000;
|
||||
|
||||
// Защита страниц
|
||||
public const uint PAGE_NOACCESS = 0x01;
|
||||
public const uint PAGE_READWRITE = 0x04;
|
||||
public const uint PAGE_WRITECOPY = 0x08;
|
||||
public const uint PAGE_EXECUTE_READWRITE = 0x40;
|
||||
public const uint PAGE_GUARD = 0x100;
|
||||
}
|
||||
163
src/RQOverlay/GameEvents.cs
Normal file
163
src/RQOverlay/GameEvents.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using RQOverlay.Core;
|
||||
|
||||
namespace RQOverlay;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Игровые события, разобранные из записей лога.
|
||||
// Одна запись может дать несколько событий: "Росинка погибает. Получено опыта: 2."
|
||||
// -> MobKilled + ExpGained.
|
||||
// ---------------------------------------------------------------------
|
||||
public abstract record GameEvent;
|
||||
public sealed record GoldPickedUp(long Amount) : GameEvent; // Поднято 473 золота
|
||||
public sealed record QuestGold(long Amount) : GameEvent; // ...Получено золото: 6 .
|
||||
public sealed record ItemLooted(string Name, int Qty) : GameEvent; // Получен предмет: <U>Роса</U>
|
||||
public sealed record ItemSold(string Name, int Qty, long Total) : GameEvent; // Продано 6 <U>Сухой лист</U> за 120
|
||||
public sealed record ExpGained(long Amount) : GameEvent; // Получено опыта: 3
|
||||
public sealed record MobKilled(string Name) : GameEvent; // Луговая Жужа погибает.
|
||||
public sealed record DamageDealt(string Target, long Amount) : GameEvent; // Вы нанесли 62 урона. Цель: Росинка
|
||||
public sealed record DamageTaken(string Source, long Amount) : GameEvent; // Луговая Жужа наносит вам 24 урона.
|
||||
public sealed record LevelUp(int Level) : GameEvent; // Вы достигли 4 уровня!
|
||||
|
||||
public static class GameEvents
|
||||
{
|
||||
static readonly Regex GoldPickupRx = new(@"^Поднято\s+(\d+)\s+золот", RegexOptions.Compiled);
|
||||
static readonly Regex QuestGoldRx = new(@"Получено\s+золото:\s*(\d+)", RegexOptions.Compiled);
|
||||
static readonly Regex ExpRx = new(@"Получено\s+опыта:\s*(\d+)", RegexOptions.Compiled);
|
||||
static readonly Regex KillRx = new(@"^(?<name>.+?)\s+погибает\b", RegexOptions.Compiled);
|
||||
static readonly Regex DealtRx = new(@"^Вы\s+нанесли\s+(?<n>\d+)\s+урона\.\s*Цель:\s*(?<name>.+?)\.?$", RegexOptions.Compiled);
|
||||
static readonly Regex TakenRx = new(@"^(?<name>.+?)\s+наносит\s+вам\s+(?<n>\d+)\s+урона", RegexOptions.Compiled);
|
||||
static readonly Regex LevelRx = new(@"^Вы\s+достигли\s+(\d+)\s+уровня", RegexOptions.Compiled);
|
||||
static readonly Regex LootRx = new(@"^Получен[ыо]?\s+предмет", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
static readonly Regex LootPlainRx = new(@"^Получен[ыо]?\s+предметы?:\s*(.+?)\.?$", RegexOptions.Compiled);
|
||||
static readonly Regex SaleRx = new(@"^Продан[оы]?\b", RegexOptions.Compiled);
|
||||
static readonly Regex SalePlainRx = new(@"^Продан[оы]?\s+(?:(?<qty>\d+)\s+)?(?<name>.+?)\s+за\s+\d+", RegexOptions.Compiled);
|
||||
static readonly Regex SaleTotalRx = new(@"\bза\s+(\d+)", RegexOptions.Compiled);
|
||||
|
||||
// Предметы, с которыми идёт взаимодействие, игра выделяет тегом <U style='color:#FFFFFF'>.
|
||||
// Цвет не проверяем: у редких предметов он может быть другим.
|
||||
static readonly Regex HighlightRx = new(@"(?<before>[^<>]*)<U\b[^>]*>(?<name>.*?)</U>(?<after>[^<]*)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
// Количество перед названием — так пишет игра: "Продано 6 <U>Сухой лист</U> за 120".
|
||||
static readonly Regex QtyBeforeRx = new(@"(\d+)\s*(?:шт\.?)?\s*$",RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
// Количество после названия — на случай других сообщений: "Роса x3", "Роса (3)", "Роса 3 шт."
|
||||
static readonly Regex QtyAfterRx = new(@"^\s*(?:[xх×]\s*(\d+)|\(\s*(\d+)\s*(?:шт\.?)?\s*\)|(\d+)\s*шт)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public static IReadOnlyList<GameEvent> Parse(LogEntry e)
|
||||
{
|
||||
var list = new List<GameEvent>();
|
||||
string text = e.Text;
|
||||
|
||||
if (GoldPickupRx.Match(text) is { Success: true } g)
|
||||
{
|
||||
list.Add(new GoldPickedUp(long.Parse(g.Groups[1].Value)));
|
||||
return list;
|
||||
}
|
||||
|
||||
if (DealtRx.Match(text) is { Success: true } d)
|
||||
{
|
||||
list.Add(new DamageDealt(d.Groups["name"].Value.Trim(), long.Parse(d.Groups["n"].Value)));
|
||||
return list;
|
||||
}
|
||||
|
||||
if (TakenRx.Match(text) is { Success: true } t)
|
||||
{
|
||||
list.Add(new DamageTaken(t.Groups["name"].Value.Trim(), long.Parse(t.Groups["n"].Value)));
|
||||
return list;
|
||||
}
|
||||
|
||||
if (KillRx.Match(text) is { Success: true } k)
|
||||
list.Add(new MobKilled(k.Groups["name"].Value.Trim()));
|
||||
|
||||
// Опыт бывает и за убийство, и за задание — в одной записи с золотом:
|
||||
// "Задание "..." выполнено. Получено опыта: 9. Получено золото: 6 ."
|
||||
if (ExpRx.Match(text) is { Success: true } x)
|
||||
list.Add(new ExpGained(long.Parse(x.Groups[1].Value)));
|
||||
|
||||
if (QuestGoldRx.Match(text) is { Success: true } q)
|
||||
list.Add(new QuestGold(long.Parse(q.Groups[1].Value)));
|
||||
|
||||
if (LevelRx.Match(text) is { Success: true } lv)
|
||||
list.Add(new LevelUp(int.Parse(lv.Groups[1].Value)));
|
||||
|
||||
if (LootRx.IsMatch(text))
|
||||
{
|
||||
var items = Highlights(e.Html).ToList();
|
||||
if (items.Count == 0 && LootPlainRx.Match(text) is { Success: true } p)
|
||||
items.Add(SplitQty(p.Groups[1].Value));
|
||||
|
||||
foreach (var (name, qty) in items.Where(i => IsValidItemName(i.Name)))
|
||||
list.Add(new ItemLooted(name, qty));
|
||||
}
|
||||
else if (SaleRx.IsMatch(text) && SaleTotalRx.Match(text) is { Success: true } total)
|
||||
{
|
||||
var item = Highlights(e.Html).FirstOrDefault();
|
||||
if (item.Name is null && SalePlainRx.Match(text) is { Success: true } p)
|
||||
item = p.Groups["qty"].Success
|
||||
? (p.Groups["name"].Value.Trim(), Math.Max(1, int.Parse(p.Groups["qty"].Value)))
|
||||
: SplitQty(p.Groups["name"].Value);
|
||||
|
||||
if (item.Name is not null && IsValidItemName(item.Name))
|
||||
list.Add(new ItemSold(item.Name, item.Qty, long.Parse(total.Groups[1].Value)));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Время записи: "9/23 16:14:33" (месяц/день, без года).</summary>
|
||||
public static DateTime? ParseTime(string time)
|
||||
{
|
||||
if (!DateTime.TryParseExact(time, "M/d HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var t))
|
||||
return null;
|
||||
// Год игра не пишет. Запись "из будущего" — это прошлогодний декабрь.
|
||||
var now = DateTime.Now;
|
||||
var result = new DateTime(now.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second);
|
||||
return result > now.AddDays(1) ? result.AddYears(-1) : result;
|
||||
}
|
||||
|
||||
static IEnumerable<(string Name, int Qty)> Highlights(string html)
|
||||
{
|
||||
foreach (Match m in HighlightRx.Matches(html))
|
||||
{
|
||||
string name = LogParser.StripTags(m.Groups["name"].Value);
|
||||
if (!IsValidItemName(name)) continue;
|
||||
var before = QtyBeforeRx.Match(m.Groups["before"].Value);
|
||||
int qty = before.Success && int.TryParse(before.Groups[1].Value, out int n) && n > 0
|
||||
? n
|
||||
: ParseQty(m.Groups["after"].Value);
|
||||
yield return (name, qty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Похоже ли на название предмета. Отсекает обрывки разметки и мусор из памяти
|
||||
/// вроде "<U style='color:<3A><><EFBFBD>".
|
||||
/// </summary>
|
||||
public static bool IsValidItemName(string name) =>
|
||||
name.Length is > 0 and <= 80
|
||||
&& name.IndexOfAny(['<', '>', '\'', '=']) < 0
|
||||
&& !LogParser.IsGarbage(name);
|
||||
|
||||
static int ParseQty(string after)
|
||||
{
|
||||
var m = QtyAfterRx.Match(after);
|
||||
if (!m.Success) return 1;
|
||||
string v = m.Groups[1].Success ? m.Groups[1].Value
|
||||
: m.Groups[2].Success ? m.Groups[2].Value
|
||||
: m.Groups[3].Value;
|
||||
return int.TryParse(v, out int n) && n > 0 ? n : 1;
|
||||
}
|
||||
|
||||
/// <summary>"Роса x3" -> ("Роса", 3) для записей без выделения.</summary>
|
||||
static (string Name, int Qty) SplitQty(string s)
|
||||
{
|
||||
var m = Regex.Match(s, @"^(?<name>.+?)\s*(?<rest>(?:[xх×]\s*\d+|\(\s*\d+[^)]*\)|\d+\s*шт\.?))$", RegexOptions.IgnoreCase);
|
||||
return m.Success
|
||||
? (m.Groups["name"].Value.Trim(), ParseQty(m.Groups["rest"].Value))
|
||||
: (s.Trim(), 1);
|
||||
}
|
||||
}
|
||||
166
src/RQOverlay/MainWindow.xaml
Normal file
166
src/RQOverlay/MainWindow.xaml
Normal file
@@ -0,0 +1,166 @@
|
||||
<Window x:Class="RQOverlay.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:RQOverlay"
|
||||
xmlns:p="clr-namespace:RQOverlay.Plugins"
|
||||
Title="RQ Overlay"
|
||||
Width="316" SizeToContent="Height"
|
||||
WindowStyle="None" AllowsTransparency="True" Background="Transparent"
|
||||
ResizeMode="NoResize" Topmost="True" ShowInTaskbar="False"
|
||||
FontFamily="{DynamicResource Ui}" FontSize="13" Foreground="{DynamicResource Text}"
|
||||
TextOptions.TextFormattingMode="Display"
|
||||
UseLayoutRounding="True">
|
||||
|
||||
<Window.Resources>
|
||||
<local:BoolToVis x:Key="Vis" />
|
||||
</Window.Resources>
|
||||
|
||||
<!-- Внешний отступ — место под тень -->
|
||||
<Border Margin="12" CornerRadius="{DynamicResource RadiusPanel}" Background="{DynamicResource Panel}"
|
||||
BorderBrush="{DynamicResource PanelBorder}" BorderThickness="1" Effect="{DynamicResource PanelShadow}">
|
||||
<StackPanel>
|
||||
|
||||
<!-- ============ Заголовок (за него таскаем окно) ============ -->
|
||||
<Grid x:Name="Header" Background="Transparent" Margin="14,7,6,0"
|
||||
MouseLeftButtonDown="Header_MouseLeftButtonDown">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" ToolTip="{Binding Status}" ClipToBounds="True">
|
||||
<Ellipse Width="6" Height="6" Margin="0,1,8,0" VerticalAlignment="Center">
|
||||
<Ellipse.Style>
|
||||
<Style TargetType="Ellipse">
|
||||
<Setter Property="Fill" Value="{DynamicResource TextFaint}" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Connected}" Value="True">
|
||||
<Setter Property="Fill" Value="{DynamicResource Ok}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Ellipse.Style>
|
||||
</Ellipse>
|
||||
<TextBlock Text="{DynamicResource Brand}" FontFamily="{DynamicResource Heading}" FontSize="12.5"
|
||||
Foreground="{DynamicResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Elapsed}" Style="{StaticResource Num}" FontSize="12"
|
||||
Foreground="{DynamicResource TextFaint}" VerticalAlignment="Center" Margin="10,0,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource IconButton}" Click="Collapse_Click" ToolTip="Свернуть / развернуть список">
|
||||
<TextBlock FontSize="10">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Text" Value="" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ListExpanded}" Value="False">
|
||||
<Setter Property="Text" Value="" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Button>
|
||||
<Button Style="{StaticResource IconButton}" Click="Reset_Click" Content="" ToolTip="Новая сессия (сбросить счётчики)" />
|
||||
<Button Style="{StaticResource IconButton}" Click="App_Click" Content="" ToolTip="Окно программы: сессия, мобы, цены" />
|
||||
<Button Style="{StaticResource IconButton}" Click="Plugins_Click" Content="" ToolTip="Настройки: тема и плагины" />
|
||||
<Button Style="{StaticResource IconButton}" Click="Lock_Click" Content=""
|
||||
ToolTip="Пропускать клики в игру.
Вернуть управление: Ctrl+Shift+O" />
|
||||
<Button Style="{StaticResource IconButton}" Click="Close_Click" Content="" FontSize="9" ToolTip="Закрыть" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ============ Итог в золоте (если включён хоть один «золотой» плагин) ============ -->
|
||||
<StackPanel Visibility="{Binding HasGold, Converter={StaticResource Vis}}">
|
||||
<Grid Margin="18,10,18,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Ellipse Width="20" Height="20" Fill="{DynamicResource CoinFill}" Stroke="{DynamicResource CoinStroke}"
|
||||
StrokeThickness="1" VerticalAlignment="Center" Margin="0,2,12,0" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Total}" Style="{StaticResource BigNum}"
|
||||
FontSize="32" Foreground="{DynamicResource Gold}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding PerHour}" Style="{StaticResource Num}" FontSize="12"
|
||||
Foreground="{DynamicResource TextFaint}" Margin="50,-2,18,0" />
|
||||
|
||||
<!-- Полоска: доли источников дохода. Колонки строятся по золотым показателям. -->
|
||||
<Border Margin="18,12,18,0" Height="3" CornerRadius="{DynamicResource RadiusS}" Background="{DynamicResource Track}"
|
||||
ClipToBounds="True">
|
||||
<ItemsControl ItemsSource="{Binding GoldMetrics}"
|
||||
Visibility="{Binding HasIncome, Converter={StaticResource Vis}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<local:ShareStrip />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="local:ShareStrip.Weight" Value="{Binding Share}" />
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="p:Metric">
|
||||
<Rectangle Fill="{Binding Color}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ Показатели плагинов ============ -->
|
||||
<ItemsControl ItemsSource="{Binding Metrics}" Margin="18,10,18,0">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="p:Metric">
|
||||
<Grid Height="23">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="74" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Ellipse Width="6" Height="6" Margin="0,0,10,0" Fill="{Binding Color}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding Label}" Foreground="{DynamicResource TextDim}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding ValueText}" Style="{StaticResource Num}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Text="{Binding RateText}" Style="{StaticResource Num}" FontSize="11.5"
|
||||
Foreground="{DynamicResource TextFaint}" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Margin="18,6,18,0" Foreground="{DynamicResource TextFaint}" TextWrapping="Wrap"
|
||||
Text="Все плагины выключены — включите нужные в настройках.">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Metrics.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<!-- ============ Блоки плагинов (список добычи и т.п.) ============ -->
|
||||
<ItemsControl ItemsSource="{Binding OverlayViews}"
|
||||
Visibility="{Binding ListExpanded, Converter={StaticResource Vis}}" />
|
||||
|
||||
<TextBlock Margin="18,8,18,0" FontSize="11.5" Foreground="{DynamicResource TextFaint}" TextWrapping="Wrap"
|
||||
Text="{Binding Status}"
|
||||
Visibility="{Binding Connected, Converter={StaticResource Vis}, ConverterParameter=!}" />
|
||||
|
||||
<TextBlock Margin="18,8,18,0" FontSize="11.5" Foreground="{DynamicResource TextFaint}" TextWrapping="Wrap"
|
||||
Visibility="{Binding ClickThrough, Converter={StaticResource Vis}}">
|
||||
<Run FontFamily="{DynamicResource Icons}" Text="" /> Клики проходят в игру · Ctrl+Shift+O
|
||||
</TextBlock>
|
||||
|
||||
<Border Height="14" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
131
src/RQOverlay/MainWindow.xaml.cs
Normal file
131
src/RQOverlay/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
|
||||
namespace RQOverlay;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
const int HotkeyId = 0x454E; // любое число, уникальное в пределах окна
|
||||
IntPtr _hwnd;
|
||||
|
||||
SessionViewModel Vm => (SessionViewModel)DataContext;
|
||||
OverlaySettings Settings => App.Current.Settings;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
Vm.ListExpanded = Settings.ListExpanded;
|
||||
Opacity = Math.Clamp(Settings.Opacity, 0.3, 1.0);
|
||||
RestorePosition();
|
||||
};
|
||||
LocationChanged += (_, _) => { Settings.Left = Left; Settings.Top = Top; };
|
||||
MouseWheel += OnMouseWheel;
|
||||
}
|
||||
|
||||
protected override void OnSourceInitialized(EventArgs e)
|
||||
{
|
||||
base.OnSourceInitialized(e);
|
||||
_hwnd = new WindowInteropHelper(this).Handle;
|
||||
|
||||
// TOOLWINDOW — нет в Alt+Tab; NOACTIVATE — клик по оверлею не отбирает фокус у игры.
|
||||
SetExStyle(WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, true);
|
||||
|
||||
HwndSource.FromHwnd(_hwnd).AddHook(WndProc);
|
||||
RegisterHotKey(_hwnd, HotkeyId, MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT, (uint)KeyInterop.VirtualKeyFromKey(Key.O));
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
UnregisterHotKey(_hwnd, HotkeyId);
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == WM_HOTKEY && wParam.ToInt32() == HotkeyId)
|
||||
{
|
||||
SetClickThrough(!Vm.ClickThrough);
|
||||
handled = true;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
void SetClickThrough(bool on)
|
||||
{
|
||||
SetExStyle(WS_EX_TRANSPARENT, on);
|
||||
Vm.ClickThrough = on;
|
||||
}
|
||||
|
||||
void RestorePosition()
|
||||
{
|
||||
var screen = SystemParameters.WorkArea;
|
||||
double left = Settings.Left ?? screen.Right - ActualWidth - 16;
|
||||
double top = Settings.Top ?? screen.Top + 120;
|
||||
|
||||
// Если монитор, на котором стоял оверлей, отключили — возвращаем на видимую область.
|
||||
var virt = new Rect(SystemParameters.VirtualScreenLeft, SystemParameters.VirtualScreenTop,
|
||||
SystemParameters.VirtualScreenWidth, SystemParameters.VirtualScreenHeight);
|
||||
if (!virt.IntersectsWith(new Rect(left, top, ActualWidth, 60)))
|
||||
{
|
||||
left = screen.Right - ActualWidth - 16;
|
||||
top = screen.Top + 120;
|
||||
}
|
||||
Left = left;
|
||||
Top = top;
|
||||
}
|
||||
|
||||
// ---- обработчики ----
|
||||
|
||||
void Header_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ButtonState == MouseButtonState.Pressed) DragMove();
|
||||
}
|
||||
|
||||
/// <summary>Ctrl + колесо — прозрачность оверлея.</summary>
|
||||
void OnMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return;
|
||||
Opacity = Math.Clamp(Opacity + (e.Delta > 0 ? 0.05 : -0.05), 0.3, 1.0);
|
||||
Settings.Opacity = Opacity;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
void Collapse_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Vm.ListExpanded = !Vm.ListExpanded;
|
||||
Settings.ListExpanded = Vm.ListExpanded;
|
||||
}
|
||||
|
||||
void Reset_Click(object sender, RoutedEventArgs e) => Vm.Reset();
|
||||
void App_Click(object sender, RoutedEventArgs e) => Views.AppWindow.ShowSingle();
|
||||
void Plugins_Click(object sender, RoutedEventArgs e) => Views.AppWindow.ShowSingle(Views.AppWindow.Page.Plugins);
|
||||
void Lock_Click(object sender, RoutedEventArgs e) => SetClickThrough(true);
|
||||
void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
// ---- WinAPI ----
|
||||
|
||||
void SetExStyle(int flags, bool on)
|
||||
{
|
||||
int style = GetWindowLong(_hwnd, GWL_EXSTYLE);
|
||||
style = on ? style | flags : style & ~flags;
|
||||
SetWindowLong(_hwnd, GWL_EXSTYLE, style);
|
||||
}
|
||||
|
||||
const int GWL_EXSTYLE = -20;
|
||||
const int WS_EX_TRANSPARENT = 0x00000020;
|
||||
const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||
const int WS_EX_NOACTIVATE = 0x08000000;
|
||||
const int WM_HOTKEY = 0x0312;
|
||||
const uint MOD_CONTROL = 0x0002;
|
||||
const uint MOD_SHIFT = 0x0004;
|
||||
const uint MOD_NOREPEAT = 0x4000;
|
||||
|
||||
[DllImport("user32.dll")] static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||
[DllImport("user32.dll")] static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
[DllImport("user32.dll")] static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
[DllImport("user32.dll")] static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
}
|
||||
45
src/RQOverlay/OverlaySettings.cs
Normal file
45
src/RQOverlay/OverlaySettings.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RQOverlay;
|
||||
|
||||
/// <summary>Положение и вид оверлея. Хранится в %AppData%\RQ Overlay\overlay.json.</summary>
|
||||
public sealed class OverlaySettings
|
||||
{
|
||||
static readonly string FilePath = Path.Combine(AppPaths.Dir, "overlay.json");
|
||||
|
||||
public double? Left { get; set; }
|
||||
public double? Top { get; set; }
|
||||
public bool ListExpanded { get; set; } = true;
|
||||
public double Opacity { get; set; } = 1.0;
|
||||
|
||||
/// <summary>Включён ли плагин, по его Id. Нет записи — включён.</summary>
|
||||
public Dictionary<string, bool> Plugins { get; set; } = new();
|
||||
|
||||
/// <summary>Считать заработок по цене аукциона (если указана), иначе только НПС.</summary>
|
||||
public bool UseAuction { get; set; } = true;
|
||||
|
||||
/// <summary>Тема оформления: Fluent, Terminal или Claude.</summary>
|
||||
public string Theme { get; set; } = "Fluent";
|
||||
|
||||
public static OverlaySettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
return JsonSerializer.Deserialize<OverlaySettings>(File.ReadAllText(FilePath)) ?? new();
|
||||
}
|
||||
catch { /* битые настройки — берём по умолчанию */ }
|
||||
return new();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AppPaths.Dir);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
catch { /* не критично */ }
|
||||
}
|
||||
}
|
||||
53
src/RQOverlay/Plugins/Builtin/CounterPlugins.cs
Normal file
53
src/RQOverlay/Plugins/Builtin/CounterPlugins.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using RQOverlay.Core;
|
||||
|
||||
namespace RQOverlay.Plugins.Builtin;
|
||||
|
||||
/// <summary>Плагин с одним показателем, который суммирует события одного типа.</summary>
|
||||
public abstract class CounterPlugin<TEvent> : IPlugin where TEvent : GameEvent
|
||||
{
|
||||
readonly Metric _metric;
|
||||
|
||||
protected CounterPlugin(string label, string color, bool isGold) =>
|
||||
_metric = new Metric(label, color, isGold);
|
||||
|
||||
public abstract string Id { get; }
|
||||
public abstract string Name { get; }
|
||||
public abstract string Description { get; }
|
||||
public IReadOnlyList<Metric> Metrics => [_metric];
|
||||
|
||||
public void Initialize(IPluginHost host) { }
|
||||
|
||||
public void OnEntry(LogEntry entry, IReadOnlyList<GameEvent> events, bool isLive)
|
||||
{
|
||||
foreach (var e in events.OfType<TEvent>())
|
||||
_metric.Value += Amount(e);
|
||||
}
|
||||
|
||||
public void ResetSession() => _metric.Value = 0;
|
||||
|
||||
protected abstract long Amount(TEvent e);
|
||||
}
|
||||
|
||||
public sealed class GroundGoldPlugin() : CounterPlugin<GoldPickedUp>("Золото с земли", "#FFD25A", isGold: true)
|
||||
{
|
||||
public override string Id => "gold.ground";
|
||||
public override string Name => "Золото с земли";
|
||||
public override string Description => "«Поднято N золота»: золото, выпавшее с мобов.";
|
||||
protected override long Amount(GoldPickedUp e) => e.Amount;
|
||||
}
|
||||
|
||||
public sealed class QuestGoldPlugin() : CounterPlugin<QuestGold>("Задания", "#B99CFF", isGold: true)
|
||||
{
|
||||
public override string Id => "gold.quests";
|
||||
public override string Name => "Золото за задания";
|
||||
public override string Description => "«Получено золото: N» при сдаче задания.";
|
||||
protected override long Amount(QuestGold e) => e.Amount;
|
||||
}
|
||||
|
||||
public sealed class ExpPlugin() : CounterPlugin<ExpGained>("Опыт", "#5BD68A", isGold: false)
|
||||
{
|
||||
public override string Id => "exp";
|
||||
public override string Name => "Опыт";
|
||||
public override string Description => "Весь полученный опыт (за мобов и задания) и опыт в час. Удобно сравнивать локации.";
|
||||
protected override long Amount(ExpGained e) => e.Amount;
|
||||
}
|
||||
199
src/RQOverlay/Plugins/Builtin/LootPlugin.cs
Normal file
199
src/RQOverlay/Plugins/Builtin/LootPlugin.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using RQOverlay.Core;
|
||||
|
||||
namespace RQOverlay.Plugins.Builtin;
|
||||
|
||||
/// <summary>
|
||||
/// Предмет, выпавший за сессию. Количество в заработке можно поправить вручную
|
||||
/// или совсем исключить предмет (например, он пойдёт на крафт, а не на продажу).
|
||||
/// </summary>
|
||||
public sealed class LootRow : Observable
|
||||
{
|
||||
public LootRow(PriceItem item) => Item = item;
|
||||
|
||||
/// <summary>Запись справочника. Может смениться, если её удалили в окне цен.</summary>
|
||||
public PriceItem Item { get; internal set; }
|
||||
public string Name => Item.Name;
|
||||
|
||||
int _dropped;
|
||||
public int Dropped { get => _dropped; internal set { if (Set(ref _dropped, value)) Changed(); } }
|
||||
|
||||
int? _countedOverride;
|
||||
/// <summary>Ручная поправка количества. null — считать всё выпавшее.</summary>
|
||||
public int? CountedOverride
|
||||
{
|
||||
get => _countedOverride;
|
||||
set { if (Set(ref _countedOverride, value)) Changed(); }
|
||||
}
|
||||
|
||||
bool _excluded;
|
||||
/// <summary>Не учитывать в заработке (крафт, оставить себе и т.п.).</summary>
|
||||
public bool Excluded
|
||||
{
|
||||
get => _excluded;
|
||||
set { if (Set(ref _excluded, value)) { Changed(); Raise(nameof(InIncome)); } }
|
||||
}
|
||||
|
||||
/// <summary>Для галочки «в заработок» в таблице.</summary>
|
||||
public bool InIncome { get => !_excluded; set => Excluded = !value; }
|
||||
|
||||
public bool IsQuest => Item.IsQuest;
|
||||
|
||||
/// <summary>Сколько штук идёт в заработок.</summary>
|
||||
public int Counted => _excluded || Item.IsQuest ? 0 : Math.Max(0, _countedOverride ?? _dropped);
|
||||
|
||||
public string CountedText
|
||||
{
|
||||
get => _countedOverride?.ToString() ?? _dropped.ToString();
|
||||
set
|
||||
{
|
||||
string s = (value ?? "").Trim();
|
||||
if (s.Length == 0) CountedOverride = null;
|
||||
else if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) && n >= 0)
|
||||
CountedOverride = n == _dropped ? null : n;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsCorrected => _countedOverride.HasValue;
|
||||
|
||||
public decimal? Value => Counted == 0 ? 0 : Item.EffectivePrice * Counted;
|
||||
public bool HasPrice => Counted == 0 || Item.EffectivePrice.HasValue;
|
||||
|
||||
public string QtyText => "×" + _dropped;
|
||||
|
||||
/// <summary>Что показать в колонке стоимости оверлея.</summary>
|
||||
public string ValueText =>
|
||||
Item.IsQuest ? "квест"
|
||||
: _excluded ? "не в счёт"
|
||||
: Value is { } v ? Fmt.Gold(v)
|
||||
: "?";
|
||||
|
||||
/// <summary>Строка не влияет на заработок — показываем приглушённо.</summary>
|
||||
public bool IsMuted => Item.IsQuest || _excluded;
|
||||
|
||||
public string UnitPriceText => Item.EffectivePrice is { } p ? p.ToString("0.##", CultureInfo.CurrentCulture) : "";
|
||||
|
||||
/// <summary>Сообщить плагину, что заработок надо пересчитать.</summary>
|
||||
internal event Action? ValueChanged;
|
||||
|
||||
void Changed()
|
||||
{
|
||||
Refresh();
|
||||
ValueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
foreach (var p in new[]
|
||||
{
|
||||
nameof(Item), nameof(Name), nameof(Dropped), nameof(Counted), nameof(CountedText), nameof(IsCorrected),
|
||||
nameof(Value), nameof(HasPrice), nameof(QtyText), nameof(ValueText), nameof(IsMuted),
|
||||
nameof(IsQuest), nameof(UnitPriceText),
|
||||
})
|
||||
Raise(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Стоимость выпавших предметов по справочнику цен + список добычи в оверлее.</summary>
|
||||
public sealed class LootPlugin : Observable, IPlugin
|
||||
{
|
||||
readonly Metric _metric = new("Предметы", "#7FD1FF", isGold: true);
|
||||
IPluginHost _host = null!;
|
||||
bool _recalcQueued;
|
||||
|
||||
public string Id => "loot";
|
||||
public string Name => "Предметы";
|
||||
public string Description => "Стоимость выпавших предметов по справочнику цен, список добычи в оверлее и таблица с ручной корректировкой во вкладке «Сессия».";
|
||||
public IReadOnlyList<Metric> Metrics => [_metric];
|
||||
|
||||
public ObservableCollection<LootRow> Loot { get; } = new();
|
||||
|
||||
int _unpriced;
|
||||
public bool HasLoot => Loot.Count > 0;
|
||||
public bool HasUnpriced => _unpriced > 0;
|
||||
public string UnpricedText => $"{_unpriced} {Fmt.Plural(_unpriced, "предмет", "предмета", "предметов")} без цены";
|
||||
|
||||
public int TotalDropped => Loot.Sum(r => r.Dropped);
|
||||
public string ValueText => Fmt.Gold(_metric.Value);
|
||||
|
||||
public void Initialize(IPluginHost host)
|
||||
{
|
||||
_host = host;
|
||||
_host.Prices.Changed += Recalc;
|
||||
}
|
||||
|
||||
public void OnEntry(LogEntry entry, IReadOnlyList<GameEvent> events, bool isLive)
|
||||
{
|
||||
bool any = false;
|
||||
foreach (var l in events.OfType<ItemLooted>())
|
||||
{
|
||||
var row = Loot.FirstOrDefault(r => r.Name.Equals(l.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (row is null)
|
||||
{
|
||||
row = new LootRow(_host.Prices.Get(l.Name));
|
||||
row.ValueChanged += QueueRecalc;
|
||||
Loot.Add(row);
|
||||
}
|
||||
row.Dropped += l.Qty;
|
||||
any = true;
|
||||
}
|
||||
if (any) Recalc();
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
foreach (var row in Loot) row.ValueChanged -= QueueRecalc;
|
||||
Loot.Clear();
|
||||
Recalc();
|
||||
}
|
||||
|
||||
public FrameworkElement CreateOverlayView() => new Views.LootOverlayView { DataContext = this };
|
||||
public FrameworkElement CreateSessionView() => new Views.LootSessionView { DataContext = this };
|
||||
|
||||
public void OpenPrices() => _host.OpenPrices(unpricedOnly: true);
|
||||
|
||||
/// <summary>Правки в таблице приходят пачкой свойств — пересчитываем один раз.</summary>
|
||||
void QueueRecalc()
|
||||
{
|
||||
if (Application.Current is null) { Recalc(); return; } // вне WPF-приложения (тесты)
|
||||
if (_recalcQueued) return;
|
||||
_recalcQueued = true;
|
||||
Application.Current.Dispatcher.BeginInvoke(() => { _recalcQueued = false; Recalc(); });
|
||||
}
|
||||
|
||||
void Recalc()
|
||||
{
|
||||
decimal value = 0;
|
||||
_unpriced = 0;
|
||||
foreach (var row in Loot)
|
||||
{
|
||||
// Запись могли удалить из справочника — берём актуальную.
|
||||
var item = _host.Prices.Get(row.Name);
|
||||
if (!ReferenceEquals(item, row.Item)) row.Item = item;
|
||||
|
||||
row.Refresh();
|
||||
if (row.Value is { } v) value += v;
|
||||
else _unpriced++;
|
||||
}
|
||||
_metric.Value = value;
|
||||
|
||||
// В оверлее: самое ценное сверху, без цены — ниже, квестовые и исключённые — в конце.
|
||||
var sorted = Loot
|
||||
.OrderBy(r => r.IsMuted)
|
||||
.ThenByDescending(r => r.HasPrice)
|
||||
.ThenByDescending(r => r.Value ?? 0)
|
||||
.ThenBy(r => r.Name)
|
||||
.ToList();
|
||||
for (int i = 0; i < sorted.Count; i++)
|
||||
{
|
||||
int at = Loot.IndexOf(sorted[i]);
|
||||
if (at != i) Loot.Move(at, i);
|
||||
}
|
||||
|
||||
Raise(nameof(HasLoot)); Raise(nameof(HasUnpriced)); Raise(nameof(UnpricedText));
|
||||
Raise(nameof(TotalDropped)); Raise(nameof(ValueText));
|
||||
}
|
||||
}
|
||||
181
src/RQOverlay/Plugins/Builtin/MobStatsPlugin.cs
Normal file
181
src/RQOverlay/Plugins/Builtin/MobStatsPlugin.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using RQOverlay.Core;
|
||||
|
||||
namespace RQOverlay.Plugins.Builtin;
|
||||
|
||||
/// <summary>Один вид мобов: убийства и опыт за сессию и за всё время работы с оверлеем.</summary>
|
||||
public sealed class MobStat : Observable
|
||||
{
|
||||
public MobStat(string name) => Name = name;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
int _sessionKills, _totalKills;
|
||||
long _sessionXp, _totalXp;
|
||||
|
||||
public int SessionKills { get => _sessionKills; set => Set(ref _sessionKills, value); }
|
||||
public long SessionXp { get => _sessionXp; set => Set(ref _sessionXp, value); }
|
||||
public int TotalKills { get => _totalKills; set => Set(ref _totalKills, value); }
|
||||
public long TotalXp { get => _totalXp; set => Set(ref _totalXp, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Статистика по убитым мобам: сколько убито и сколько опыта принесли.
|
||||
/// Добычу мобам намеренно не приписываем: когда рядом умирает несколько
|
||||
/// разных мобов, по логу не понять, с кого что упало.
|
||||
/// </summary>
|
||||
public sealed class MobStatsPlugin : Observable, IPlugin
|
||||
{
|
||||
static readonly TimeSpan SaveEvery = TimeSpan.FromSeconds(15);
|
||||
|
||||
readonly Metric _kills = new("Убито мобов", "#FF7A7A", isGold: false);
|
||||
readonly Dictionary<string, MobStat> _byName = new();
|
||||
IPluginHost _host = null!;
|
||||
string _file = "";
|
||||
DateTime _lastSave = DateTime.MinValue;
|
||||
|
||||
public string Id => "mobs";
|
||||
public string Name => "Мобы";
|
||||
public string Description => "Сколько мобов каждого вида убито и сколько опыта они дали — за сессию и за всё время. Вкладка «Мобы» в окне программы.";
|
||||
public IReadOnlyList<Metric> Metrics => [_kills];
|
||||
|
||||
public ObservableCollection<MobStat> Rows { get; } = new();
|
||||
|
||||
public string SessionSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
int kills = Rows.Sum(r => r.SessionKills);
|
||||
return $"{kills} {Fmt.Plural(kills, "убийство", "убийства", "убийств")} · опыт {Fmt.Gold(Rows.Sum(r => r.SessionXp))}";
|
||||
}
|
||||
}
|
||||
|
||||
public string TotalSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
int kills = Rows.Sum(r => r.TotalKills);
|
||||
return $"{kills} {Fmt.Plural(kills, "убийство", "убийства", "убийств")} · опыт {Fmt.Gold(Rows.Sum(r => r.TotalXp))}";
|
||||
}
|
||||
}
|
||||
|
||||
public void Initialize(IPluginHost host)
|
||||
{
|
||||
_host = host;
|
||||
_file = Path.Combine(host.DataDir, "mobs.json");
|
||||
Load();
|
||||
}
|
||||
|
||||
public void OnEntry(LogEntry entry, IReadOnlyList<GameEvent> events, bool isLive)
|
||||
{
|
||||
foreach (var k in events.OfType<MobKilled>())
|
||||
{
|
||||
// Опыт за убийство приходит в той же записи.
|
||||
long xp = events.OfType<ExpGained>().Sum(x => x.Amount);
|
||||
var stat = Get(k.Name);
|
||||
|
||||
stat.SessionKills++;
|
||||
stat.SessionXp += xp;
|
||||
_kills.Value++;
|
||||
|
||||
// "Всё время" — только живые записи, иначе при повторе сессии они посчитаются дважды.
|
||||
if (isLive)
|
||||
{
|
||||
stat.TotalKills++;
|
||||
stat.TotalXp += xp;
|
||||
if (DateTime.Now - _lastSave > SaveEvery) Save();
|
||||
}
|
||||
RaiseSummaries();
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
foreach (var s in Rows)
|
||||
{
|
||||
s.SessionKills = 0;
|
||||
s.SessionXp = 0;
|
||||
}
|
||||
_kills.Value = 0;
|
||||
RaiseSummaries();
|
||||
}
|
||||
|
||||
public void ClearAllTime()
|
||||
{
|
||||
foreach (var s in Rows)
|
||||
{
|
||||
s.TotalKills = 0;
|
||||
s.TotalXp = 0;
|
||||
}
|
||||
// Мобы без убийств в этой сессии больше не нужны.
|
||||
foreach (var s in Rows.Where(s => s.SessionKills == 0).ToList())
|
||||
{
|
||||
Rows.Remove(s);
|
||||
_byName.Remove(s.Name);
|
||||
}
|
||||
Save();
|
||||
RaiseSummaries();
|
||||
}
|
||||
|
||||
public FrameworkElement CreatePageView() => new Views.MobStatsView { DataContext = this };
|
||||
public FrameworkElement CreateSessionView() => new Views.MobsSessionView { DataContext = this };
|
||||
|
||||
public void Shutdown() => Save();
|
||||
|
||||
MobStat Get(string name)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var stat))
|
||||
{
|
||||
_byName[name] = stat = new MobStat(name);
|
||||
Rows.Add(stat);
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
void RaiseSummaries()
|
||||
{
|
||||
Raise(nameof(SessionSummary));
|
||||
Raise(nameof(TotalSummary));
|
||||
}
|
||||
|
||||
// ---- хранение "всего времени" в %AppData%\RQ Overlay\mobs.json ----
|
||||
|
||||
sealed record Dto(string Name, int Kills, long Xp);
|
||||
|
||||
void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_file)) return;
|
||||
var list = JsonSerializer.Deserialize<List<Dto>>(File.ReadAllText(_file)) ?? new();
|
||||
foreach (var d in list.Where(d => !string.IsNullOrEmpty(d.Name)))
|
||||
{
|
||||
var stat = Get(d.Name);
|
||||
stat.TotalKills = d.Kills;
|
||||
stat.TotalXp = d.Xp;
|
||||
}
|
||||
}
|
||||
catch { /* битый файл — начинаем заново */ }
|
||||
}
|
||||
|
||||
void Save()
|
||||
{
|
||||
_lastSave = DateTime.Now;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_host.DataDir);
|
||||
var data = Rows.Where(s => s.TotalKills > 0).Select(s => new Dto(s.Name, s.TotalKills, s.TotalXp));
|
||||
string tmp = _file + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
}));
|
||||
File.Move(tmp, _file, overwrite: true);
|
||||
}
|
||||
catch { /* попробуем в следующий раз */ }
|
||||
}
|
||||
}
|
||||
96
src/RQOverlay/Plugins/IPlugin.cs
Normal file
96
src/RQOverlay/Plugins/IPlugin.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using RQOverlay.Core;
|
||||
|
||||
namespace RQOverlay.Plugins;
|
||||
|
||||
// =====================================================================
|
||||
// КОНТРАКТ ПЛАГИНА
|
||||
//
|
||||
// Встроенные плагины и плагины из папки plugins\*.dll реализуют один
|
||||
// и тот же интерфейс. Внешний плагин — это библиотека net10.0-windows
|
||||
// с UseWPF, ссылающаяся на RQOverlay.dll, в которой есть публичный
|
||||
// класс с конструктором без параметров, реализующий IPlugin.
|
||||
// =====================================================================
|
||||
public interface IPlugin
|
||||
{
|
||||
/// <summary>Уникальный идентификатор — ключ в настройках включения.</summary>
|
||||
string Id { get; }
|
||||
string Name { get; }
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>Показатели для оверлея. Хост сам считает для них темп в час.</summary>
|
||||
IReadOnlyList<Metric> Metrics { get; }
|
||||
|
||||
void Initialize(IPluginHost host);
|
||||
|
||||
/// <summary>
|
||||
/// Запись лога текущей сессии.
|
||||
/// isLive = false для записей, которые плагин видит повторно: история лога
|
||||
/// (запуск с --history) и повтор сессии при включении плагина. Всё, что
|
||||
/// копится дольше одной сессии, стоит считать только при isLive = true.
|
||||
/// </summary>
|
||||
void OnEntry(LogEntry entry, IReadOnlyList<GameEvent> events, bool isLive);
|
||||
|
||||
/// <summary>Новая сессия — обнулить сессионные счётчики.</summary>
|
||||
void ResetSession();
|
||||
|
||||
/// <summary>Дополнительный блок в оверлее под показателями (необязательно).</summary>
|
||||
FrameworkElement? CreateOverlayView() => null;
|
||||
|
||||
/// <summary>Своя вкладка в окне программы (необязательно).</summary>
|
||||
FrameworkElement? CreatePageView() => null;
|
||||
|
||||
/// <summary>Раздел на вкладке «Сессия» в окне программы (необязательно).</summary>
|
||||
FrameworkElement? CreateSessionView() => null;
|
||||
|
||||
/// <summary>Выход из программы — сохранить данные.</summary>
|
||||
void Shutdown() { }
|
||||
}
|
||||
|
||||
/// <summary>Что хост предоставляет плагинам.</summary>
|
||||
public interface IPluginHost
|
||||
{
|
||||
PriceBook Prices { get; }
|
||||
|
||||
/// <summary>Папка для файлов плагина (%AppData%\RQ Overlay).</summary>
|
||||
string DataDir { get; }
|
||||
|
||||
/// <summary>Открыть окно программы на вкладке цен.</summary>
|
||||
void OpenPrices(bool unpricedOnly);
|
||||
}
|
||||
|
||||
/// <summary>Один показатель в оверлее: подпись, значение и темп в час.</summary>
|
||||
public sealed class Metric : Observable
|
||||
{
|
||||
/// <param name="color">Цвет точки, например "#FFD25A".</param>
|
||||
/// <param name="isGold">Значение в золоте — входит в общий итог и полоску долей.</param>
|
||||
public Metric(string label, string color, bool isGold)
|
||||
{
|
||||
Label = label;
|
||||
IsGold = isGold;
|
||||
var brush = (Brush)new BrushConverter().ConvertFromString(color)!;
|
||||
brush.Freeze();
|
||||
Color = brush;
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
public Brush Color { get; }
|
||||
public bool IsGold { get; }
|
||||
|
||||
decimal _value;
|
||||
public decimal Value
|
||||
{
|
||||
get => _value;
|
||||
set { if (Set(ref _value, value)) { Raise(nameof(ValueText)); Raise(nameof(Share)); } }
|
||||
}
|
||||
|
||||
public string ValueText => Fmt.Gold(_value);
|
||||
public double Share => (double)Math.Max(0, _value);
|
||||
|
||||
string _rateText = "—";
|
||||
public string RateText { get => _rateText; private set => Set(ref _rateText, value); }
|
||||
|
||||
public void UpdateRate(double hours) =>
|
||||
RateText = hours < 1.0 / 60 ? "—" : Fmt.Gold(_value / (decimal)hours) + "/ч";
|
||||
}
|
||||
148
src/RQOverlay/Plugins/PluginManager.cs
Normal file
148
src/RQOverlay/Plugins/PluginManager.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using RQOverlay.Core;
|
||||
using RQOverlay.Plugins.Builtin;
|
||||
|
||||
namespace RQOverlay.Plugins;
|
||||
|
||||
/// <summary>Плагин + его состояние включения.</summary>
|
||||
public sealed class PluginSlot : Observable
|
||||
{
|
||||
readonly PluginManager _owner;
|
||||
|
||||
public PluginSlot(IPlugin plugin, string source, PluginManager owner)
|
||||
{
|
||||
Plugin = plugin;
|
||||
Source = source;
|
||||
_owner = owner;
|
||||
}
|
||||
|
||||
public IPlugin Plugin { get; }
|
||||
public string Name => Plugin.Name;
|
||||
public string Description => Plugin.Description;
|
||||
public string Source { get; }
|
||||
|
||||
bool _enabled;
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _enabled;
|
||||
set { if (Set(ref _enabled, value)) _owner.OnToggled(this); }
|
||||
}
|
||||
|
||||
internal void SetInitial(bool enabled) => _enabled = enabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Загружает плагины, раздаёт им записи лога и помнит, какие включены.
|
||||
/// Выключенный плагин записи не получает. При включении ему повторяется
|
||||
/// вся текущая сессия, поэтому данные не теряются.
|
||||
/// </summary>
|
||||
public sealed class PluginManager
|
||||
{
|
||||
readonly OverlaySettings _settings;
|
||||
readonly IPluginHost _host;
|
||||
|
||||
// Записи текущей сессии — для повтора включённому плагину.
|
||||
readonly List<(LogEntry Entry, IReadOnlyList<GameEvent> Events)> _session = new();
|
||||
|
||||
public ObservableCollection<PluginSlot> Slots { get; } = new();
|
||||
|
||||
/// <summary>Какой-то плагин включили или выключили.</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
public PluginManager(IPluginHost host, OverlaySettings settings)
|
||||
{
|
||||
_host = host;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public IEnumerable<IPlugin> Enabled => Slots.Where(s => s.IsEnabled).Select(s => s.Plugin);
|
||||
|
||||
public void Load()
|
||||
{
|
||||
Register(new LootPlugin(), "встроенный");
|
||||
Register(new GroundGoldPlugin(), "встроенный");
|
||||
Register(new QuestGoldPlugin(), "встроенный");
|
||||
Register(new ExpPlugin(), "встроенный");
|
||||
Register(new MobStatsPlugin(), "встроенный");
|
||||
|
||||
foreach (var (plugin, file) in LoadExternal())
|
||||
Register(plugin, file);
|
||||
}
|
||||
|
||||
void Register(IPlugin plugin, string source)
|
||||
{
|
||||
if (Slots.Any(s => s.Plugin.Id == plugin.Id)) return; // дубль Id — берём первый
|
||||
|
||||
try { plugin.Initialize(_host); }
|
||||
catch { return; } // сломанный плагин не должен ронять программу
|
||||
|
||||
var slot = new PluginSlot(plugin, source, this);
|
||||
slot.SetInitial(_settings.Plugins.GetValueOrDefault(plugin.Id, true));
|
||||
Slots.Add(slot);
|
||||
}
|
||||
|
||||
/// <summary>Внешние плагины: plugins\*.dll рядом с exe.</summary>
|
||||
static IEnumerable<(IPlugin, string)> LoadExternal()
|
||||
{
|
||||
string dir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
if (!Directory.Exists(dir)) yield break;
|
||||
|
||||
foreach (var file in Directory.GetFiles(dir, "*.dll"))
|
||||
{
|
||||
Type[] types;
|
||||
try { types = Assembly.LoadFrom(file).GetExportedTypes(); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var type in types.Where(t => typeof(IPlugin).IsAssignableFrom(t) && t is { IsAbstract: false, IsInterface: false }))
|
||||
{
|
||||
IPlugin? plugin = null;
|
||||
try { plugin = (IPlugin?)Activator.CreateInstance(type); }
|
||||
catch { /* нет конструктора без параметров или он упал */ }
|
||||
if (plugin != null) yield return (plugin, Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispatch(LogEntry entry, IReadOnlyList<GameEvent> events, bool isLive)
|
||||
{
|
||||
_session.Add((entry, events));
|
||||
foreach (var plugin in Enabled)
|
||||
Safe(() => plugin.OnEntry(entry, events, isLive));
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
_session.Clear();
|
||||
foreach (var slot in Slots)
|
||||
Safe(slot.Plugin.ResetSession);
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
foreach (var slot in Slots)
|
||||
Safe(slot.Plugin.Shutdown);
|
||||
}
|
||||
|
||||
internal void OnToggled(PluginSlot slot)
|
||||
{
|
||||
_settings.Plugins[slot.Plugin.Id] = slot.IsEnabled;
|
||||
_settings.Save();
|
||||
|
||||
if (slot.IsEnabled)
|
||||
{
|
||||
// Догоняем сессию, которую плагин пропустил, пока был выключен.
|
||||
Safe(slot.Plugin.ResetSession);
|
||||
foreach (var (entry, events) in _session)
|
||||
Safe(() => slot.Plugin.OnEntry(entry, events, isLive: false));
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
static void Safe(Action a)
|
||||
{
|
||||
try { a(); }
|
||||
catch { /* ошибка в плагине не должна останавливать остальные */ }
|
||||
}
|
||||
}
|
||||
326
src/RQOverlay/PriceBook.cs
Normal file
326
src/RQOverlay/PriceBook.cs
Normal file
@@ -0,0 +1,326 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RQOverlay;
|
||||
|
||||
public abstract class Observable : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected void Raise([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
|
||||
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
Raise(name);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public enum PriceSource { None, Sale, Manual }
|
||||
|
||||
/// <summary>Цены одного предмета: у НПС и на аукционе.</summary>
|
||||
public sealed class PriceItem : Observable
|
||||
{
|
||||
readonly PriceBook _book;
|
||||
|
||||
public PriceItem(string name, PriceBook book)
|
||||
{
|
||||
Name = name;
|
||||
_book = book;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
// ---- цена у НПС: подхватывается из продаж, можно задать вручную ----
|
||||
|
||||
decimal? _npcPrice;
|
||||
public decimal? NpcPrice
|
||||
{
|
||||
get => _npcPrice;
|
||||
set { if (Set(ref _npcPrice, value)) { Raise(nameof(NpcPriceText)); NotifyEffective(); } }
|
||||
}
|
||||
|
||||
PriceSource _npcSource;
|
||||
public PriceSource NpcSource
|
||||
{
|
||||
get => _npcSource;
|
||||
set { if (Set(ref _npcSource, value)) Raise(nameof(IsLocked)); }
|
||||
}
|
||||
|
||||
/// <summary>Ручной ввод цены НПС. Любая правка закрепляет цену от автообновления.</summary>
|
||||
public string NpcPriceText
|
||||
{
|
||||
get => Format(_npcPrice);
|
||||
set
|
||||
{
|
||||
if (!TryParse(value, out var p)) return;
|
||||
NpcPrice = p;
|
||||
NpcSource = p.HasValue ? PriceSource.Manual : PriceSource.None;
|
||||
Updated = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Закреплённую вручную цену продажи у торговца не перезаписывают.</summary>
|
||||
public bool IsLocked
|
||||
{
|
||||
get => _npcSource == PriceSource.Manual;
|
||||
set
|
||||
{
|
||||
if (value == IsLocked) return;
|
||||
NpcSource = value ? PriceSource.Manual : (_npcPrice.HasValue ? PriceSource.Sale : PriceSource.None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- цена на аукционе: только вручную ----
|
||||
|
||||
decimal? _auctionPrice;
|
||||
public decimal? AuctionPrice
|
||||
{
|
||||
get => _auctionPrice;
|
||||
set { if (Set(ref _auctionPrice, value)) { Raise(nameof(AuctionPriceText)); NotifyEffective(); } }
|
||||
}
|
||||
|
||||
public string AuctionPriceText
|
||||
{
|
||||
get => Format(_auctionPrice);
|
||||
set
|
||||
{
|
||||
if (!TryParse(value, out var p)) return;
|
||||
AuctionPrice = p;
|
||||
Updated = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- квестовый предмет: в заработок не идёт ----
|
||||
|
||||
bool _isQuest;
|
||||
public bool IsQuest
|
||||
{
|
||||
get => _isQuest;
|
||||
set { if (Set(ref _isQuest, value)) NotifyEffective(); }
|
||||
}
|
||||
|
||||
DateTime? _updated;
|
||||
public DateTime? Updated
|
||||
{
|
||||
get => _updated;
|
||||
set { if (Set(ref _updated, value)) Raise(nameof(UpdatedText)); }
|
||||
}
|
||||
|
||||
public string UpdatedText => _updated?.ToString("dd.MM HH:mm") ?? "";
|
||||
|
||||
// ---- цена, по которой считается заработок ----
|
||||
|
||||
/// <summary>Аукцион (если выбран режим и цена указана), иначе НПС. У квестовых — нет цены.</summary>
|
||||
public decimal? EffectivePrice =>
|
||||
_isQuest ? null : (_book.UseAuction && _auctionPrice.HasValue ? _auctionPrice : _npcPrice);
|
||||
|
||||
/// <summary>Откуда взята цена: для подсказок в таблицах.</summary>
|
||||
public string EffectiveSource =>
|
||||
_isQuest ? "квест"
|
||||
: _book.UseAuction && _auctionPrice.HasValue ? "аукцион"
|
||||
: _npcPrice.HasValue ? "НПС"
|
||||
: "";
|
||||
|
||||
/// <summary>Нужно указать цену: не квестовый и ни одной подходящей цены.</summary>
|
||||
public bool NeedsPrice => !_isQuest && EffectivePrice is null;
|
||||
|
||||
internal void NotifyEffective()
|
||||
{
|
||||
Raise(nameof(EffectivePrice)); Raise(nameof(EffectiveSource)); Raise(nameof(NeedsPrice));
|
||||
}
|
||||
|
||||
static string Format(decimal? v) => v?.ToString("0.##", CultureInfo.CurrentCulture) ?? "";
|
||||
|
||||
/// <summary>Пустая строка — сброс цены (null). Мусор — false, значение не меняем.</summary>
|
||||
static bool TryParse(string? text, out decimal? price)
|
||||
{
|
||||
string s = (text ?? "").Trim().Replace(" ", "").Replace(" ", "").Replace(',', '.');
|
||||
if (s.Length == 0) { price = null; return true; }
|
||||
if (decimal.TryParse(s, NumberStyles.Number, CultureInfo.InvariantCulture, out var p) && p >= 0)
|
||||
{
|
||||
price = p;
|
||||
return true;
|
||||
}
|
||||
price = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Справочник цен. Хранится в %AppData%\RQ Overlay\prices.json.</summary>
|
||||
public sealed class PriceBook
|
||||
{
|
||||
static readonly string FilePath = Path.Combine(AppPaths.Dir, "prices.json");
|
||||
|
||||
readonly Dictionary<string, PriceItem> _byName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ObservableCollection<PriceItem> Items { get; } = new();
|
||||
|
||||
/// <summary>Любая цена или отметка изменилась (для пересчёта итогов).</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
bool _useAuction = true;
|
||||
/// <summary>Считать заработок по цене аукциона, если она указана. Иначе — только НПС.</summary>
|
||||
public bool UseAuction
|
||||
{
|
||||
get => _useAuction;
|
||||
set
|
||||
{
|
||||
if (_useAuction == value) return;
|
||||
_useAuction = value;
|
||||
foreach (var item in Items) item.NotifyEffective();
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public PriceItem Get(string name)
|
||||
{
|
||||
if (_byName.TryGetValue(name, out var item)) return item;
|
||||
|
||||
item = new PriceItem(name, this);
|
||||
Add(item);
|
||||
Save();
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>Цена предмета для заработка — без создания записи в справочнике.</summary>
|
||||
public decimal? PriceOf(string name) => _byName.TryGetValue(name, out var item) ? item.EffectivePrice : null;
|
||||
|
||||
/// <summary>Цена НПС из сообщения о продаже торговцу.</summary>
|
||||
public void LearnFromSale(string name, int qty, long total)
|
||||
{
|
||||
var item = Get(name);
|
||||
if (item.IsLocked) return;
|
||||
|
||||
decimal price = Math.Round((decimal)total / Math.Max(1, qty), 2);
|
||||
if (item.NpcPrice == price && item.NpcSource == PriceSource.Sale) return; // та же продажа из истории лога
|
||||
|
||||
item.NpcPrice = price;
|
||||
item.NpcSource = PriceSource.Sale;
|
||||
item.Updated = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>Удалить запись. Если предмет выпадал в этой сессии, он появится снова — уже без цен.</summary>
|
||||
public void Remove(PriceItem item)
|
||||
{
|
||||
_byName.Remove(item.Name);
|
||||
Items.Remove(item);
|
||||
item.PropertyChanged -= OnItemChanged;
|
||||
Save();
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
void Add(PriceItem item)
|
||||
{
|
||||
_byName[item.Name] = item;
|
||||
Items.Add(item);
|
||||
item.PropertyChanged += OnItemChanged;
|
||||
}
|
||||
|
||||
void OnItemChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(PriceItem.NpcPrice) or nameof(PriceItem.NpcSource)
|
||||
or nameof(PriceItem.AuctionPrice) or nameof(PriceItem.IsQuest))
|
||||
{
|
||||
Save();
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- хранение ----
|
||||
|
||||
// Price/Source — поля старого формата (до появления цены аукциона), читаем для совместимости.
|
||||
sealed class Dto
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public decimal? NpcPrice { get; set; }
|
||||
public PriceSource? NpcSource { get; set; }
|
||||
public decimal? AuctionPrice { get; set; }
|
||||
public bool IsQuest { get; set; }
|
||||
public DateTime? Updated { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
public PriceSource? Source { get; set; }
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath)) return;
|
||||
var list = JsonSerializer.Deserialize<List<Dto>>(File.ReadAllText(FilePath)) ?? new();
|
||||
|
||||
// Мусор из памяти, попавший в справочник до появления проверки названий, — выбрасываем.
|
||||
int dropped = list.RemoveAll(d => !GameEvents.IsValidItemName(d.Name));
|
||||
|
||||
foreach (var d in list.OrderBy(d => d.Name))
|
||||
{
|
||||
if (_byName.ContainsKey(d.Name)) continue;
|
||||
Add(new PriceItem(d.Name, this)
|
||||
{
|
||||
NpcPrice = d.NpcPrice ?? d.Price,
|
||||
NpcSource = d.NpcSource ?? d.Source ?? PriceSource.None,
|
||||
AuctionPrice = d.AuctionPrice,
|
||||
IsQuest = d.IsQuest,
|
||||
Updated = d.Updated,
|
||||
});
|
||||
}
|
||||
if (dropped > 0) Save();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Битый файл не должен ронять оверлей — начнём с пустого справочника.
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AppPaths.Dir);
|
||||
var list = Items.Select(i => new
|
||||
{
|
||||
i.Name, i.NpcPrice, i.NpcSource, i.AuctionPrice, i.IsQuest, i.Updated,
|
||||
}).ToList();
|
||||
string tmp = FilePath + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(list, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
}));
|
||||
File.Move(tmp, FilePath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Не удалось сохранить (файл занят и т.п.) — попробуем при следующем изменении.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class AppPaths
|
||||
{
|
||||
public static readonly string Dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RQ Overlay");
|
||||
|
||||
static AppPaths()
|
||||
{
|
||||
// Раньше программа называлась Enj — переносим старые данные на новое место.
|
||||
var legacy = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Enj");
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(legacy) && !Directory.Exists(Dir))
|
||||
Directory.Move(legacy, Dir);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Не получилось — начнём с чистой папки.
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/RQOverlay/RQOverlay.csproj
Normal file
33
src/RQOverlay/RQOverlay.csproj
Normal file
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>RQOverlay</RootNamespace>
|
||||
<AssemblyName>RQOverlay</AssemblyName>
|
||||
<ApplicationIcon>Assets\RQOverlay.ico</ApplicationIcon>
|
||||
|
||||
<!-- Сведения о файле (Свойства → Подробно) -->
|
||||
<Product>RQ Overlay</Product>
|
||||
<AssemblyTitle>RQ Overlay</AssemblyTitle>
|
||||
<Description>Оверлей заработка и статистики для Royal Quest</Description>
|
||||
<Version>1.0.0</Version>
|
||||
<Copyright>© 2026</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Сборка в один exe: dotnet publish -c Release (см. параметры ниже) -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>none</DebugType>
|
||||
<SatelliteResourceLanguages>ru;en</SatelliteResourceLanguages>
|
||||
<PublishDir>$(MSBuildThisFileDirectory)..\..\dist\</PublishDir>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
189
src/RQOverlay/SessionViewModel.cs
Normal file
189
src/RQOverlay/SessionViewModel.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using RQOverlay.Core;
|
||||
using RQOverlay.Plugins;
|
||||
|
||||
namespace RQOverlay;
|
||||
|
||||
/// <summary>Состояние текущей сессии фарма: время, показатели включённых плагинов, итог.</summary>
|
||||
public sealed class SessionViewModel : Observable
|
||||
{
|
||||
readonly PriceBook _prices;
|
||||
readonly PluginManager _plugins;
|
||||
DateTime _start = DateTime.Now;
|
||||
|
||||
public SessionViewModel(PriceBook prices, PluginManager plugins)
|
||||
{
|
||||
_prices = prices;
|
||||
_plugins = plugins;
|
||||
_plugins.Changed += Rebuild;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ручная корректировка заработка в золоте (например, минус потраченное на зелья).
|
||||
/// Показывается в оверлее отдельной строкой, только когда не ноль.
|
||||
/// </summary>
|
||||
public Metric Adjustment { get; } = new("Корректировка", "#9AA3B5", isGold: true);
|
||||
|
||||
public string AdjustmentText
|
||||
{
|
||||
get => Adjustment.Value == 0 ? "" : Adjustment.Value.ToString("0.##", CultureInfo.CurrentCulture);
|
||||
set
|
||||
{
|
||||
string s = (value ?? "").Trim().Replace(" ", "").Replace(" ", "").Replace(',', '.');
|
||||
decimal v;
|
||||
if (s.Length == 0 || s == "-") v = 0;
|
||||
else if (!decimal.TryParse(s, NumberStyles.Number, CultureInfo.InvariantCulture, out v)) return;
|
||||
|
||||
bool wasShown = Adjustment.Value != 0;
|
||||
Adjustment.Value = v;
|
||||
Raise();
|
||||
if (wasShown != (v != 0)) Rebuild();
|
||||
else RaiseTotals();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Показатели всех включённых плагинов, по порядку.</summary>
|
||||
public ObservableCollection<Metric> Metrics { get; } = new();
|
||||
|
||||
/// <summary>Золотые показатели — для полоски долей.</summary>
|
||||
public ObservableCollection<Metric> GoldMetrics { get; } = new();
|
||||
|
||||
/// <summary>Дополнительные блоки плагинов в оверлее (список добычи и т.п.).</summary>
|
||||
public ObservableCollection<FrameworkElement> OverlayViews { get; } = new();
|
||||
|
||||
decimal TotalGold => GoldMetrics.Sum(m => m.Value);
|
||||
double Hours => (DateTime.Now - _start).TotalHours;
|
||||
|
||||
public bool HasGold => GoldMetrics.Count > 0;
|
||||
public bool HasIncome => TotalGold > 0;
|
||||
public string Total => Fmt.Gold(TotalGold);
|
||||
|
||||
public string PerHour =>
|
||||
// Первую минуту темп скачет слишком сильно, чтобы что-то значить.
|
||||
Hours < 1.0 / 60 ? "— / ч" : Fmt.Gold(TotalGold / (decimal)Hours) + " / ч";
|
||||
|
||||
public string Elapsed
|
||||
{
|
||||
get
|
||||
{
|
||||
var t = DateTime.Now - _start;
|
||||
return $"{(int)t.TotalHours}:{t.Minutes:00}:{t.Seconds:00}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Оверлей пропускает клики мыши в игру.</summary>
|
||||
bool _clickThrough;
|
||||
public bool ClickThrough { get => _clickThrough; set => Set(ref _clickThrough, value); }
|
||||
|
||||
bool _listExpanded = true;
|
||||
public bool ListExpanded { get => _listExpanded; set => Set(ref _listExpanded, value); }
|
||||
|
||||
bool _connected;
|
||||
public bool Connected { get => _connected; set => Set(ref _connected, value); }
|
||||
|
||||
string _status = "Запуск…";
|
||||
public string Status { get => _status; set => Set(ref _status, value); }
|
||||
|
||||
/// <summary>Раз в секунду: таймер и темп.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
double hours = Hours;
|
||||
foreach (var m in Metrics) m.UpdateRate(hours);
|
||||
Raise(nameof(Elapsed));
|
||||
Raise(nameof(PerHour));
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_start = DateTime.Now;
|
||||
_plugins.ResetSession();
|
||||
AdjustmentText = "";
|
||||
Tick();
|
||||
}
|
||||
|
||||
/// <param name="initial">Первый снимок — история до запуска оверлея.</param>
|
||||
/// <param name="countHistory">Засчитывать ли историю в сессию (флаг --history).</param>
|
||||
public void Apply(IEnumerable<LogEntry> entries, bool initial, bool countHistory)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var events = GameEvents.Parse(entry);
|
||||
if (events.Count == 0) continue;
|
||||
|
||||
// Цены учим из любых продаж, в том числе из истории лога.
|
||||
foreach (var s in events.OfType<ItemSold>())
|
||||
_prices.LearnFromSale(s.Name, s.Qty, s.Total);
|
||||
|
||||
if (!initial)
|
||||
_plugins.Dispatch(entry, events, isLive: true);
|
||||
else if (countHistory)
|
||||
_plugins.Dispatch(entry, events, isLive: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Набор включённых плагинов изменился — пересобираем оверлей.</summary>
|
||||
void Rebuild()
|
||||
{
|
||||
foreach (var m in Metrics) m.PropertyChanged -= OnMetricChanged;
|
||||
Metrics.Clear();
|
||||
GoldMetrics.Clear();
|
||||
OverlayViews.Clear();
|
||||
|
||||
foreach (var plugin in _plugins.Enabled)
|
||||
{
|
||||
foreach (var m in plugin.Metrics)
|
||||
{
|
||||
Metrics.Add(m);
|
||||
if (m.IsGold) GoldMetrics.Add(m);
|
||||
m.PropertyChanged += OnMetricChanged;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (plugin.CreateOverlayView() is { } view) OverlayViews.Add(view);
|
||||
}
|
||||
catch { /* сломанный блок плагина просто не показываем */ }
|
||||
}
|
||||
|
||||
if (Adjustment.Value != 0)
|
||||
{
|
||||
Metrics.Add(Adjustment);
|
||||
GoldMetrics.Add(Adjustment);
|
||||
Adjustment.PropertyChanged += OnMetricChanged;
|
||||
}
|
||||
|
||||
Tick();
|
||||
RaiseTotals();
|
||||
Raise(nameof(HasGold));
|
||||
}
|
||||
|
||||
void OnMetricChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(Metric.Value) && sender is Metric { IsGold: true })
|
||||
RaiseTotals();
|
||||
}
|
||||
|
||||
void RaiseTotals()
|
||||
{
|
||||
Raise(nameof(Total)); Raise(nameof(PerHour)); Raise(nameof(HasIncome));
|
||||
}
|
||||
}
|
||||
|
||||
public static class Fmt
|
||||
{
|
||||
static readonly NumberFormatInfo Nfi = new() { NumberGroupSeparator = " ", NumberDecimalSeparator = "," };
|
||||
|
||||
public static string Gold(decimal v) => Math.Round(v).ToString("#,0", Nfi);
|
||||
public static string Gold(long v) => v.ToString("#,0", Nfi);
|
||||
|
||||
public static string Plural(int n, string one, string few, string many)
|
||||
{
|
||||
int m10 = n % 10, m100 = n % 100;
|
||||
if (m10 == 1 && m100 != 11) return one;
|
||||
if (m10 is >= 2 and <= 4 && m100 is < 12 or > 14) return few;
|
||||
return many;
|
||||
}
|
||||
}
|
||||
89
src/RQOverlay/ThemeManager.cs
Normal file
89
src/RQOverlay/ThemeManager.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace RQOverlay;
|
||||
|
||||
/// <summary>Описание темы для выбора в настройках.</summary>
|
||||
public sealed record ThemeInfo(string Id, string Name, string Description, string[] Swatches, string FontFamily);
|
||||
|
||||
/// <summary>
|
||||
/// Переключение тем на лету: первый словарь в Application.Resources.MergedDictionaries —
|
||||
/// токены текущей темы; всё остальное ссылается на них через DynamicResource.
|
||||
/// </summary>
|
||||
public static class ThemeManager
|
||||
{
|
||||
public static readonly ThemeInfo[] Themes =
|
||||
[
|
||||
new("Fluent", "Fluent", "Как Windows 11: слои Mica, навигация слева, акцент-пилюля.",
|
||||
["#202020", "#2D2D2D", "#60CDFF", "#FFD25A"], "Segoe UI Variable Display, Segoe UI"),
|
||||
new("Terminal", "Terminal", "Консоль: моноширинный шрифт, прямые углы, [ меню ] сверху.",
|
||||
["#0B0D0C", "#26302B", "#7EE787", "#E3B341"], "Cascadia Mono, Consolas"),
|
||||
new("Claude", "Claude", "Тёплый и строгий: засечки, терракотовый акцент, много воздуха.",
|
||||
["#1F1E1D", "#2B2A28", "#D97757", "#E6B566"], "Georgia, Cambria"),
|
||||
];
|
||||
|
||||
public static string Current { get; private set; } = "Fluent";
|
||||
|
||||
/// <summary>Тема сменилась — окнам пора перекрасить системный заголовок.</summary>
|
||||
public static event Action? Changed;
|
||||
|
||||
public static void Apply(string id)
|
||||
{
|
||||
var theme = Themes.FirstOrDefault(t => t.Id.Equals(id, StringComparison.OrdinalIgnoreCase)) ?? Themes[0];
|
||||
var dict = new ResourceDictionary
|
||||
{
|
||||
// Относительный URI — не зависит от имени сборки.
|
||||
Source = new Uri($"/Themes/{theme.Id}.xaml", UriKind.Relative),
|
||||
};
|
||||
|
||||
var merged = Application.Current.Resources.MergedDictionaries;
|
||||
if (merged.Count > 0) merged[0] = dict;
|
||||
else merged.Add(dict);
|
||||
|
||||
Current = theme.Id;
|
||||
foreach (Window w in Application.Current.Windows) ApplyChrome(w);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Тёмный системный заголовок и (Windows 11) его цвет под фон темы —
|
||||
/// чтобы заголовок не выбивался из окна.
|
||||
/// </summary>
|
||||
public static void ApplyChrome(Window window)
|
||||
{
|
||||
var hwnd = new WindowInteropHelper(window).Handle;
|
||||
if (hwnd == IntPtr.Zero) return;
|
||||
|
||||
int on = 1;
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref on, sizeof(int));
|
||||
|
||||
if (Application.Current.TryFindResource("Bg") is SolidColorBrush bg &&
|
||||
Application.Current.TryFindResource("TextDim") is SolidColorBrush text &&
|
||||
Application.Current.TryFindResource("Divider") is SolidColorBrush border)
|
||||
{
|
||||
int caption = ToColorRef(bg.Color), textColor = ToColorRef(text.Color), borderColor = ToColorRef(Flatten(border.Color, bg.Color));
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_CAPTION_COLOR, ref caption, sizeof(int));
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_TEXT_COLOR, ref textColor, sizeof(int));
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, ref borderColor, sizeof(int));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Полупрозрачный цвет поверх фона → непрозрачный (DWM альфу не понимает).</summary>
|
||||
static Color Flatten(Color c, Color bg)
|
||||
{
|
||||
double a = c.A / 255.0;
|
||||
return Color.FromRgb((byte)(c.R * a + bg.R * (1 - a)), (byte)(c.G * a + bg.G * (1 - a)), (byte)(c.B * a + bg.B * (1 - a)));
|
||||
}
|
||||
|
||||
static int ToColorRef(Color c) => c.R | (c.G << 8) | (c.B << 16);
|
||||
|
||||
const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
|
||||
const int DWMWA_BORDER_COLOR = 34;
|
||||
const int DWMWA_CAPTION_COLOR = 35;
|
||||
const int DWMWA_TEXT_COLOR = 36;
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
|
||||
}
|
||||
123
src/RQOverlay/Themes/Claude.xaml
Normal file
123
src/RQOverlay/Themes/Claude.xaml
Normal file
@@ -0,0 +1,123 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
|
||||
<!--
|
||||
CLAUDE — тёплый строгий минимализм.
|
||||
Тёплые нейтральные серые, терракотовый акцент, засечки в заголовках и крупных числах,
|
||||
много воздуха, тонкие рамки, спокойная боковая панель без акцентных полос.
|
||||
-->
|
||||
|
||||
<sys:String x:Key="ThemeName">Claude</sys:String>
|
||||
<sys:String x:Key="Brand">RQ Overlay</sys:String>
|
||||
<x:Static x:Key="CaptionPrefix" Member="sys:String.Empty" />
|
||||
|
||||
<!-- Поверхности -->
|
||||
<SolidColorBrush x:Key="Bg" Color="#1F1E1D" />
|
||||
<SolidColorBrush x:Key="Surface" Color="#262624" />
|
||||
<SolidColorBrush x:Key="SurfaceAlt" Color="#2B2A28" />
|
||||
<SolidColorBrush x:Key="SurfaceHover" Color="#33322F" />
|
||||
<SolidColorBrush x:Key="CardStroke" Color="#3A3935" />
|
||||
<SolidColorBrush x:Key="Divider" Color="#3A3935" />
|
||||
<SolidColorBrush x:Key="GridLine" Color="#302F2C" />
|
||||
<SolidColorBrush x:Key="HeaderBg" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="RowAlt" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="Hover" Color="#0DF5F4EE" />
|
||||
<SolidColorBrush x:Key="Pressed" Color="#14F5F4EE" />
|
||||
<SolidColorBrush x:Key="Selection" Color="#26D97757" />
|
||||
<SolidColorBrush x:Key="Thumb" Color="#55524B" />
|
||||
<SolidColorBrush x:Key="Track" Color="#33322F" />
|
||||
|
||||
<!-- Текст -->
|
||||
<SolidColorBrush x:Key="Text" Color="#F5F4EE" />
|
||||
<SolidColorBrush x:Key="TextDim" Color="#BFBDB3" />
|
||||
<SolidColorBrush x:Key="TextFaint" Color="#8C8A82" />
|
||||
|
||||
<!-- Акценты -->
|
||||
<SolidColorBrush x:Key="Accent" Color="#D97757" />
|
||||
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="SegmentOn" Color="#3A3935" />
|
||||
<SolidColorBrush x:Key="SegmentOnText" Color="#F5F4EE" />
|
||||
<SolidColorBrush x:Key="Gold" Color="#E6B566" />
|
||||
<SolidColorBrush x:Key="Loot" Color="#9CB4D8" />
|
||||
<SolidColorBrush x:Key="Ok" Color="#9DBE8A" />
|
||||
<SolidColorBrush x:Key="Warn" Color="#E0936B" />
|
||||
<SolidColorBrush x:Key="WarnBg" Color="#1AD97757" />
|
||||
<SolidColorBrush x:Key="WarnBgHover" Color="#2ED97757" />
|
||||
|
||||
<!-- Оверлей -->
|
||||
<SolidColorBrush x:Key="Panel" Color="#F0262624" />
|
||||
<SolidColorBrush x:Key="PanelBorder" Color="#3F3E39" />
|
||||
<DropShadowEffect x:Key="PanelShadow" BlurRadius="28" ShadowDepth="6" Opacity="0.45" Color="#0A0908" />
|
||||
<RadialGradientBrush x:Key="CoinFill" GradientOrigin="0.35,0.3" Center="0.4,0.35" RadiusX="0.7" RadiusY="0.7">
|
||||
<GradientStop Color="#F6DDA8" Offset="0" />
|
||||
<GradientStop Color="#E6B566" Offset="0.5" />
|
||||
<GradientStop Color="#A7783A" Offset="1" />
|
||||
</RadialGradientBrush>
|
||||
<SolidColorBrush x:Key="CoinStroke" Color="#7D5A2C" />
|
||||
|
||||
<!-- Шрифты: засечки для заголовков и крупных чисел, спокойный гротеск для остального -->
|
||||
<FontFamily x:Key="Ui">Segoe UI Variable Text, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="Heading">Georgia, Cambria</FontFamily>
|
||||
<FontFamily x:Key="CaptionFont">Segoe UI Variable Small, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="NumFont">Segoe UI Variable Text, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="BigNumFont">Georgia, Cambria</FontFamily>
|
||||
<FontFamily x:Key="Icons">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
|
||||
<FontWeight x:Key="HeadingWeight">Normal</FontWeight>
|
||||
<FontWeight x:Key="BigNumWeight">Normal</FontWeight>
|
||||
|
||||
<!-- Геометрия: мягче, чем Fluent -->
|
||||
<CornerRadius x:Key="RadiusS">6</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusM">8</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusL">12</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusPanel">14</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusPill">10</CornerRadius>
|
||||
|
||||
<!-- ============ Навигация: спокойная боковая панель ============ -->
|
||||
<Style TargetType="TabControl">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<Grid Background="{DynamicResource Surface}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="220" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Background="{DynamicResource Bg}" BorderBrush="{DynamicResource Divider}" BorderThickness="0,0,1,0">
|
||||
<StackPanel Margin="12,18,12,0">
|
||||
<TextBlock Text="{DynamicResource Brand}" FontFamily="{DynamicResource Heading}" FontSize="22"
|
||||
Foreground="{DynamicResource Text}" Margin="12,0,0,22" />
|
||||
<StackPanel IsItemsHost="True" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ContentPresenter Grid.Column="1" ContentSource="SelectedContent" Margin="36,28,28,22" />
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource Ui}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="Bg" Background="Transparent" CornerRadius="8" Padding="12,8" Margin="0,0,0,2">
|
||||
<ContentPresenter x:Name="Hdr" ContentSource="Header" TextElement.Foreground="{DynamicResource TextDim}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource Hover}" />
|
||||
<Setter TargetName="Hdr" Property="TextElement.Foreground" Value="{DynamicResource Text}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource SurfaceHover}" />
|
||||
<Setter TargetName="Hdr" Property="TextElement.Foreground" Value="{DynamicResource Text}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
130
src/RQOverlay/Themes/Fluent.xaml
Normal file
130
src/RQOverlay/Themes/Fluent.xaml
Normal file
@@ -0,0 +1,130 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
|
||||
<!--
|
||||
FLUENT — тёмная тема в духе WinUI 11.
|
||||
Слои как у Mica: подложка окна → поверхность контента со скруглённым углом → карточки.
|
||||
Навигация слева, выбранный пункт отмечен акцентной «пилюлей».
|
||||
-->
|
||||
|
||||
<sys:String x:Key="ThemeName">Fluent</sys:String>
|
||||
<sys:String x:Key="Brand">RQ Overlay</sys:String>
|
||||
<x:Static x:Key="CaptionPrefix" Member="sys:String.Empty" />
|
||||
|
||||
<!-- Поверхности -->
|
||||
<SolidColorBrush x:Key="Bg" Color="#202020" />
|
||||
<SolidColorBrush x:Key="Surface" Color="#272727" />
|
||||
<SolidColorBrush x:Key="SurfaceAlt" Color="#2D2D2D" />
|
||||
<SolidColorBrush x:Key="SurfaceHover" Color="#353535" />
|
||||
<SolidColorBrush x:Key="CardStroke" Color="#14FFFFFF" />
|
||||
<SolidColorBrush x:Key="Divider" Color="#1AFFFFFF" />
|
||||
<SolidColorBrush x:Key="GridLine" Color="#0DFFFFFF" />
|
||||
<SolidColorBrush x:Key="HeaderBg" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="RowAlt" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="Hover" Color="#0FFFFFFF" />
|
||||
<SolidColorBrush x:Key="Pressed" Color="#0AFFFFFF" />
|
||||
<SolidColorBrush x:Key="Selection" Color="#2260CDFF" />
|
||||
<SolidColorBrush x:Key="Thumb" Color="#5AFFFFFF" />
|
||||
<SolidColorBrush x:Key="Track" Color="#1AFFFFFF" />
|
||||
|
||||
<!-- Текст -->
|
||||
<SolidColorBrush x:Key="Text" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="TextDim" Color="#C8C8C8" />
|
||||
<SolidColorBrush x:Key="TextFaint" Color="#8B8B8B" />
|
||||
|
||||
<!-- Акценты -->
|
||||
<SolidColorBrush x:Key="Accent" Color="#60CDFF" />
|
||||
<SolidColorBrush x:Key="OnAccent" Color="#003550" />
|
||||
<SolidColorBrush x:Key="SegmentOn" Color="#3A3A3A" />
|
||||
<SolidColorBrush x:Key="SegmentOnText" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="Gold" Color="#FFD25A" />
|
||||
<SolidColorBrush x:Key="Loot" Color="#60CDFF" />
|
||||
<SolidColorBrush x:Key="Ok" Color="#6CCB5F" />
|
||||
<SolidColorBrush x:Key="Warn" Color="#FCB93B" />
|
||||
<SolidColorBrush x:Key="WarnBg" Color="#1FFCB93B" />
|
||||
<SolidColorBrush x:Key="WarnBgHover" Color="#33FCB93B" />
|
||||
|
||||
<!-- Оверлей -->
|
||||
<SolidColorBrush x:Key="Panel" Color="#EB202020" />
|
||||
<SolidColorBrush x:Key="PanelBorder" Color="#1FFFFFFF" />
|
||||
<DropShadowEffect x:Key="PanelShadow" BlurRadius="24" ShadowDepth="4" Opacity="0.5" Color="Black" />
|
||||
<RadialGradientBrush x:Key="CoinFill" GradientOrigin="0.35,0.3" Center="0.4,0.35" RadiusX="0.7" RadiusY="0.7">
|
||||
<GradientStop Color="#FFF3B8" Offset="0" />
|
||||
<GradientStop Color="#FFD25A" Offset="0.45" />
|
||||
<GradientStop Color="#B8862A" Offset="1" />
|
||||
</RadialGradientBrush>
|
||||
<SolidColorBrush x:Key="CoinStroke" Color="#8A6420" />
|
||||
|
||||
<!-- Шрифты -->
|
||||
<FontFamily x:Key="Ui">Segoe UI Variable Text, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="Heading">Segoe UI Variable Display, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="CaptionFont">Segoe UI Variable Small, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="NumFont">Segoe UI Variable Text, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="BigNumFont">Segoe UI Variable Display, Segoe UI</FontFamily>
|
||||
<FontFamily x:Key="Icons">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
|
||||
<FontWeight x:Key="HeadingWeight">SemiBold</FontWeight>
|
||||
<FontWeight x:Key="BigNumWeight">SemiBold</FontWeight>
|
||||
|
||||
<!-- Геометрия -->
|
||||
<CornerRadius x:Key="RadiusS">4</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusM">4</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusL">8</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusPanel">8</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusPill">10</CornerRadius>
|
||||
|
||||
<!-- ============ Навигация: вкладки слева, как NavigationView ============ -->
|
||||
<Style TargetType="TabControl">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<Grid Background="{DynamicResource Bg}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="210" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="6,10,6,0">
|
||||
<TextBlock Text="{DynamicResource Brand}" FontFamily="{DynamicResource Heading}" FontSize="14"
|
||||
FontWeight="SemiBold" Foreground="{DynamicResource Text}" Margin="14,4,0,16" />
|
||||
<StackPanel IsItemsHost="True" />
|
||||
</StackPanel>
|
||||
<!-- Слой контента: чуть светлее подложки, скруглён верхний левый угол -->
|
||||
<Border Grid.Column="1" Background="{DynamicResource Surface}" CornerRadius="8,0,0,0"
|
||||
BorderBrush="{DynamicResource CardStroke}" BorderThickness="1,1,0,0">
|
||||
<ContentPresenter ContentSource="SelectedContent" Margin="28,22,20,18" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource Ui}" />
|
||||
<Setter Property="FontSize" Value="13.5" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Grid Height="36" Margin="0,0,0,4">
|
||||
<Border x:Name="Bg" Background="Transparent" CornerRadius="4" />
|
||||
<Border x:Name="Pill" Width="3" Height="16" CornerRadius="2" HorizontalAlignment="Left"
|
||||
Background="{DynamicResource Accent}" Visibility="Collapsed" />
|
||||
<ContentPresenter x:Name="Hdr" ContentSource="Header" VerticalAlignment="Center" Margin="16,0,8,0"
|
||||
TextElement.Foreground="{DynamicResource TextDim}" />
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource Hover}" />
|
||||
<Setter TargetName="Hdr" Property="TextElement.Foreground" Value="{DynamicResource Text}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource Hover}" />
|
||||
<Setter TargetName="Pill" Property="Visibility" Value="Visible" />
|
||||
<Setter TargetName="Hdr" Property="TextElement.Foreground" Value="{DynamicResource Text}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
118
src/RQOverlay/Themes/Terminal.xaml
Normal file
118
src/RQOverlay/Themes/Terminal.xaml
Normal file
@@ -0,0 +1,118 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
|
||||
<!--
|
||||
TERMINAL — консольный минимализм.
|
||||
Моноширинный шрифт везде, прямые углы, тонкие рамки вместо заливок,
|
||||
вкладки как [ пункты ] меню с инверсией выбранного, фосфорно-зелёный акцент.
|
||||
-->
|
||||
|
||||
<sys:String x:Key="ThemeName">Terminal</sys:String>
|
||||
<sys:String x:Key="Brand">rq $</sys:String>
|
||||
<sys:String x:Key="CaptionPrefix">// </sys:String>
|
||||
|
||||
<!-- Поверхности: почти всё — один чёрный, структура держится на рамках -->
|
||||
<SolidColorBrush x:Key="Bg" Color="#0B0D0C" />
|
||||
<SolidColorBrush x:Key="Surface" Color="#0B0D0C" />
|
||||
<SolidColorBrush x:Key="SurfaceAlt" Color="#0F1211" />
|
||||
<SolidColorBrush x:Key="SurfaceHover" Color="#161A18" />
|
||||
<SolidColorBrush x:Key="CardStroke" Color="#26302B" />
|
||||
<SolidColorBrush x:Key="Divider" Color="#26302B" />
|
||||
<SolidColorBrush x:Key="GridLine" Color="#161C19" />
|
||||
<SolidColorBrush x:Key="HeaderBg" Color="#0F1211" />
|
||||
<SolidColorBrush x:Key="RowAlt" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="Hover" Color="#14A6F0B8" />
|
||||
<SolidColorBrush x:Key="Pressed" Color="#22A6F0B8" />
|
||||
<SolidColorBrush x:Key="Selection" Color="#227EE787" />
|
||||
<SolidColorBrush x:Key="Thumb" Color="#3A4A42" />
|
||||
<SolidColorBrush x:Key="Track" Color="#1A221E" />
|
||||
|
||||
<!-- Текст -->
|
||||
<SolidColorBrush x:Key="Text" Color="#D7E0DA" />
|
||||
<SolidColorBrush x:Key="TextDim" Color="#8A9A91" />
|
||||
<SolidColorBrush x:Key="TextFaint" Color="#56655D" />
|
||||
|
||||
<!-- Акценты -->
|
||||
<SolidColorBrush x:Key="Accent" Color="#7EE787" />
|
||||
<SolidColorBrush x:Key="OnAccent" Color="#0B0D0C" />
|
||||
<SolidColorBrush x:Key="SegmentOn" Color="#7EE787" />
|
||||
<SolidColorBrush x:Key="SegmentOnText" Color="#0B0D0C" />
|
||||
<SolidColorBrush x:Key="Gold" Color="#E3B341" />
|
||||
<SolidColorBrush x:Key="Loot" Color="#79C0FF" />
|
||||
<SolidColorBrush x:Key="Ok" Color="#7EE787" />
|
||||
<SolidColorBrush x:Key="Warn" Color="#F0883E" />
|
||||
<SolidColorBrush x:Key="WarnBg" Color="#14F0883E" />
|
||||
<SolidColorBrush x:Key="WarnBgHover" Color="#28F0883E" />
|
||||
|
||||
<!-- Оверлей: без тени, чёткая рамка -->
|
||||
<SolidColorBrush x:Key="Panel" Color="#F00B0D0C" />
|
||||
<SolidColorBrush x:Key="PanelBorder" Color="#2F3B35" />
|
||||
<DropShadowEffect x:Key="PanelShadow" BlurRadius="0" ShadowDepth="0" Opacity="0" />
|
||||
<SolidColorBrush x:Key="CoinFill" Color="#E3B341" />
|
||||
<SolidColorBrush x:Key="CoinStroke" Color="#E3B341" />
|
||||
|
||||
<!-- Шрифты: только моноширинный -->
|
||||
<FontFamily x:Key="Ui">Cascadia Mono, Consolas</FontFamily>
|
||||
<FontFamily x:Key="Heading">Cascadia Mono, Consolas</FontFamily>
|
||||
<FontFamily x:Key="CaptionFont">Cascadia Mono, Consolas</FontFamily>
|
||||
<FontFamily x:Key="NumFont">Cascadia Mono, Consolas</FontFamily>
|
||||
<FontFamily x:Key="BigNumFont">Cascadia Mono, Consolas</FontFamily>
|
||||
<FontFamily x:Key="Icons">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
|
||||
<FontWeight x:Key="HeadingWeight">Bold</FontWeight>
|
||||
<FontWeight x:Key="BigNumWeight">Bold</FontWeight>
|
||||
|
||||
<!-- Геометрия: прямые углы -->
|
||||
<CornerRadius x:Key="RadiusS">0</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusM">0</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusL">0</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusPanel">0</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusPill">0</CornerRadius>
|
||||
|
||||
<!-- ============ Навигация: строка меню сверху ============ -->
|
||||
<Style TargetType="TabControl">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<DockPanel Background="{DynamicResource Bg}">
|
||||
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource Divider}" BorderThickness="0,0,0,1" Padding="16,10">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{DynamicResource Brand}" FontFamily="{DynamicResource Ui}" FontWeight="Bold"
|
||||
Foreground="{DynamicResource Accent}" VerticalAlignment="Center" Margin="0,0,20,0" />
|
||||
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ContentPresenter ContentSource="SelectedContent" Margin="20,18" />
|
||||
</DockPanel>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource Ui}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="Bg" Background="Transparent" Padding="6,2" Margin="0,0,6,0">
|
||||
<StackPanel x:Name="Hdr" Orientation="Horizontal" TextElement.Foreground="{DynamicResource TextDim}">
|
||||
<TextBlock Text="[ " />
|
||||
<ContentPresenter ContentSource="Header" />
|
||||
<TextBlock Text=" ]" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Hdr" Property="TextElement.Foreground" Value="{DynamicResource Text}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource Accent}" />
|
||||
<Setter TargetName="Hdr" Property="TextElement.Foreground" Value="{DynamicResource OnAccent}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
11
src/RQOverlay/Views/AppWindow.xaml
Normal file
11
src/RQOverlay/Views/AppWindow.xaml
Normal file
@@ -0,0 +1,11 @@
|
||||
<Window x:Class="RQOverlay.Views.AppWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="RQ Overlay" Width="1040" Height="720" MinWidth="640" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource Bg}" Foreground="{DynamicResource Text}"
|
||||
FontFamily="{DynamicResource Ui}" FontSize="13"
|
||||
TextOptions.TextFormattingMode="Display" UseLayoutRounding="True">
|
||||
<!-- Обычное окно программы: не поверх всех, есть в панели задач. -->
|
||||
<TabControl x:Name="Tabs" />
|
||||
</Window>
|
||||
86
src/RQOverlay/Views/AppWindow.xaml.cs
Normal file
86
src/RQOverlay/Views/AppWindow.xaml.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
|
||||
namespace RQOverlay.Views;
|
||||
|
||||
public partial class AppWindow : Window
|
||||
{
|
||||
static AppWindow? _instance;
|
||||
|
||||
public enum Page { Session, Prices, Plugins }
|
||||
|
||||
readonly PricesView _prices = new();
|
||||
readonly TabItem _sessionTab;
|
||||
readonly TabItem _pricesTab;
|
||||
readonly TabItem _pluginsTab;
|
||||
|
||||
/// <summary>Окно программы существует в одном экземпляре — повторный вызов его активирует.</summary>
|
||||
public static void ShowSingle(Page page = Page.Session, bool unpricedOnly = false)
|
||||
{
|
||||
_instance ??= new AppWindow();
|
||||
var w = _instance;
|
||||
|
||||
w.Tabs.SelectedItem = page switch
|
||||
{
|
||||
Page.Prices => w._pricesTab,
|
||||
Page.Plugins => w._pluginsTab,
|
||||
_ => w._sessionTab,
|
||||
};
|
||||
if (page == Page.Prices) w._prices.OnlyUnpricedFilter = unpricedOnly;
|
||||
|
||||
w.Show();
|
||||
if (w.WindowState == WindowState.Minimized) w.WindowState = WindowState.Normal;
|
||||
w.Activate();
|
||||
}
|
||||
|
||||
AppWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var manager = App.Current.Plugins;
|
||||
_sessionTab = new TabItem { Header = "Сессия", Content = new SessionView() };
|
||||
Tabs.Items.Add(_sessionTab);
|
||||
BuildPluginTabs();
|
||||
|
||||
_pricesTab = new TabItem { Header = "Цены", Content = _prices };
|
||||
_pluginsTab = new TabItem { Header = "Настройки", Content = new PluginsView { DataContext = manager } };
|
||||
Tabs.Items.Add(_pricesTab);
|
||||
Tabs.Items.Add(_pluginsTab);
|
||||
|
||||
// Вкладки плагинов появляются и пропадают вместе с включением плагина.
|
||||
manager.Changed += OnPluginsChanged;
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
manager.Changed -= OnPluginsChanged;
|
||||
_instance = null;
|
||||
};
|
||||
|
||||
// Системный заголовок в цвет темы (ThemeManager перекрашивает его и при смене темы).
|
||||
SourceInitialized += (_, _) => ThemeManager.ApplyChrome(this);
|
||||
}
|
||||
|
||||
void OnPluginsChanged()
|
||||
{
|
||||
var selected = Tabs.SelectedItem;
|
||||
foreach (var tab in Tabs.Items.OfType<TabItem>().Where(t => t.Tag is Plugins.IPlugin).ToList())
|
||||
Tabs.Items.Remove(tab);
|
||||
BuildPluginTabs();
|
||||
Tabs.SelectedItem = Tabs.Items.Contains(selected) ? selected : Tabs.Items[0];
|
||||
}
|
||||
|
||||
void BuildPluginTabs()
|
||||
{
|
||||
int at = Tabs.Items.IndexOf(_sessionTab) + 1;
|
||||
foreach (var plugin in App.Current.Plugins.Enabled)
|
||||
{
|
||||
FrameworkElement? page;
|
||||
try { page = plugin.CreatePageView(); }
|
||||
catch { page = null; }
|
||||
if (page is null) continue;
|
||||
|
||||
Tabs.Items.Insert(at++, new TabItem { Header = plugin.Name, Content = page, Tag = plugin });
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/RQOverlay/Views/LootOverlayView.xaml
Normal file
79
src/RQOverlay/Views/LootOverlayView.xaml
Normal file
@@ -0,0 +1,79 @@
|
||||
<UserControl x:Class="RQOverlay.Views.LootOverlayView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:RQOverlay"
|
||||
xmlns:b="clr-namespace:RQOverlay.Plugins.Builtin">
|
||||
<!-- Блок плагина «Предметы» в оверлее: список добычи + предупреждение о предметах без цены.
|
||||
DataContext — LootPlugin. -->
|
||||
<UserControl.Resources>
|
||||
<local:BoolToVis x:Key="Vis" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<StackPanel>
|
||||
<Rectangle Height="1" Fill="{DynamicResource Divider}" Margin="18,12,18,10" />
|
||||
|
||||
<TextBlock Style="{StaticResource Caption}" Margin="18,0,18,4"><Run Text="{DynamicResource CaptionPrefix}" /><Run Text="ДОБЫЧА" /></TextBlock>
|
||||
|
||||
<TextBlock Text="Пока ничего не поднято" Foreground="{DynamicResource TextFaint}" Margin="18,4,18,4"
|
||||
Visibility="{Binding HasLoot, Converter={StaticResource Vis}, ConverterParameter=!}" />
|
||||
|
||||
<ScrollViewer MaxHeight="220" VerticalScrollBarVisibility="Auto" Margin="12,0,8,0"
|
||||
Visibility="{Binding HasLoot, Converter={StaticResource Vis}}">
|
||||
<ItemsControl ItemsSource="{Binding Loot}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="b:LootRow">
|
||||
<Border x:Name="Row" Padding="6,3" CornerRadius="{DynamicResource RadiusM}" Background="Transparent" Margin="0,0,4,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="68" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis" ToolTip="{Binding Name}" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding QtyText}" Style="{StaticResource Num}"
|
||||
Foreground="{DynamicResource TextFaint}" Margin="8,0,0,0" />
|
||||
<TextBlock x:Name="Val" Grid.Column="2" Text="{Binding ValueText}" Style="{StaticResource Num}"
|
||||
HorizontalAlignment="Right" Foreground="{DynamicResource Text}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding HasPrice}" Value="False">
|
||||
<Setter TargetName="Val" Property="Foreground" Value="{DynamicResource Warn}" />
|
||||
<Setter TargetName="Val" Property="FontWeight" Value="SemiBold" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsMuted}" Value="True">
|
||||
<Setter TargetName="Val" Property="Foreground" Value="{DynamicResource TextFaint}" />
|
||||
<Setter TargetName="Val" Property="FontWeight" Value="Normal" />
|
||||
<Setter TargetName="Val" Property="FontSize" Value="11.5" />
|
||||
</DataTrigger>
|
||||
<Trigger SourceName="Row" Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Row" Property="Background" Value="{DynamicResource Hover}" />
|
||||
</Trigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Button Margin="12,10,12,0" Click="Unpriced_Click" Cursor="Hand" Focusable="False"
|
||||
Visibility="{Binding HasUnpriced, Converter={StaticResource Vis}}">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bg" Background="{DynamicResource WarnBg}" CornerRadius="{DynamicResource RadiusM}" Padding="8,5">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="" FontFamily="{DynamicResource Icons}" Foreground="{DynamicResource Warn}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<TextBlock Text="{Binding UnpricedText}" Foreground="{DynamicResource Warn}" />
|
||||
<TextBlock Text=" — указать" Foreground="{DynamicResource TextDim}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource WarnBgHover}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
12
src/RQOverlay/Views/LootOverlayView.xaml.cs
Normal file
12
src/RQOverlay/Views/LootOverlayView.xaml.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using RQOverlay.Plugins.Builtin;
|
||||
|
||||
namespace RQOverlay.Views;
|
||||
|
||||
public partial class LootOverlayView : UserControl
|
||||
{
|
||||
public LootOverlayView() => InitializeComponent();
|
||||
|
||||
void Unpriced_Click(object sender, RoutedEventArgs e) => ((LootPlugin)DataContext).OpenPrices();
|
||||
}
|
||||
111
src/RQOverlay/Views/LootSessionView.xaml
Normal file
111
src/RQOverlay/Views/LootSessionView.xaml
Normal file
@@ -0,0 +1,111 @@
|
||||
<UserControl x:Class="RQOverlay.Views.LootSessionView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- Раздел плагина «Предметы» на вкладке «Сессия». DataContext — LootPlugin. -->
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Выпавшие предметы" Style="{StaticResource HeadingText}" />
|
||||
<TextBlock Foreground="{DynamicResource TextDim}" TextWrapping="Wrap" Margin="0,2,0,0"
|
||||
Text="«Учтено» — сколько штук идёт в заработок (можно исправить). Снимите «В заработок», если предмет пойдёт на крафт. Отметка «Квест» сохраняется для предмета навсегда." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Top" Margin="16,0,0,0">
|
||||
<TextBlock Text="{Binding TotalDropped, StringFormat='Выпало: {0}'}" Foreground="{DynamicResource TextDim}" Margin="0,0,14,0" />
|
||||
<TextBlock Text="{Binding ValueText, StringFormat='В заработок: {0}'}" Foreground="{DynamicResource Gold}" FontWeight="SemiBold" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border CornerRadius="{DynamicResource RadiusL}" BorderBrush="{DynamicResource Divider}" BorderThickness="1" ClipToBounds="True">
|
||||
<DataGrid x:Name="LootGrid" Style="{StaticResource DarkGrid}" MinHeight="80"
|
||||
PreviewMouseWheel="Grid_PreviewMouseWheel">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Предмет" Binding="{Binding Name}" IsReadOnly="True" Width="*" MinWidth="160"
|
||||
ElementStyle="{StaticResource CellText}" />
|
||||
<DataGridTextColumn Header="Выпало" Binding="{Binding Dropped}" IsReadOnly="True" Width="70"
|
||||
ElementStyle="{StaticResource CellNum}" />
|
||||
|
||||
<DataGridTemplateColumn Header="Учтено" Width="80" SortMemberPath="Counted">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock x:Name="T" Text="{Binding CountedText}" Style="{StaticResource CellNum}" />
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCorrected}" Value="True">
|
||||
<Setter TargetName="T" Property="Foreground" Value="{DynamicResource Accent}" />
|
||||
<Setter TargetName="T" Property="FontWeight" Value="SemiBold" />
|
||||
<Setter TargetName="T" Property="ToolTip" Value="Исправлено вручную. Очистите поле, чтобы вернуть выпавшее количество." />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsMuted}" Value="True">
|
||||
<Setter TargetName="T" Property="Foreground" Value="{DynamicResource TextFaint}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
<DataGridTemplateColumn.CellEditingTemplate>
|
||||
<DataTemplate>
|
||||
<TextBox Text="{Binding CountedText, UpdateSourceTrigger=LostFocus}"
|
||||
Style="{StaticResource CellEditor}" Loaded="Editor_Loaded" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellEditingTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="Цена/шт" Width="110" SortMemberPath="Item.EffectivePrice" IsReadOnly="True">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="8,0">
|
||||
<TextBlock Text="{Binding Item.EffectiveSource}" Foreground="{DynamicResource TextFaint}"
|
||||
FontSize="11" VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<TextBlock Text="{Binding UnitPriceText}" Typography.NumeralAlignment="Tabular" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="Сумма" Width="90" SortMemberPath="Value" IsReadOnly="True">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock x:Name="V" Text="{Binding ValueText}" Style="{StaticResource CellNum}"
|
||||
Foreground="{DynamicResource Gold}" FontWeight="SemiBold" />
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding HasPrice}" Value="False">
|
||||
<Setter TargetName="V" Property="Foreground" Value="{DynamicResource Warn}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsMuted}" Value="True">
|
||||
<Setter TargetName="V" Property="Foreground" Value="{DynamicResource TextFaint}" />
|
||||
<Setter TargetName="V" Property="FontWeight" Value="Normal" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="В заработок" Width="96" SortMemberPath="InIncome">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding InIncome, UpdateSourceTrigger=PropertyChanged}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
ToolTip="Снимите, если предмет не будет продаваться (крафт и т.п.)" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="Квест" Width="64" SortMemberPath="IsQuest">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Item.IsQuest, UpdateSourceTrigger=PropertyChanged}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
ToolTip="Квестовый предмет — никогда не идёт в заработок" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Margin="0,6,0,0" Foreground="{DynamicResource TextFaint}"
|
||||
Text="F2 или двойной клик по «Учтено» — изменить количество. Цены — на вкладке «Цены»." />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
43
src/RQOverlay/Views/LootSessionView.xaml.cs
Normal file
43
src/RQOverlay/Views/LootSessionView.xaml.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace RQOverlay.Views;
|
||||
|
||||
public partial class LootSessionView : UserControl
|
||||
{
|
||||
public LootSessionView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Своё представление, отсортированное по имени: список в оверлее пересортировывается
|
||||
// по стоимости, и без этого строки прыгали бы прямо во время правки.
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (DataContext is not Plugins.Builtin.LootPlugin plugin) return;
|
||||
var view = new System.Windows.Data.ListCollectionView(plugin.Loot);
|
||||
view.SortDescriptions.Add(new System.ComponentModel.SortDescription(nameof(Plugins.Builtin.LootRow.Name),
|
||||
System.ComponentModel.ListSortDirection.Ascending));
|
||||
LootGrid.ItemsSource = view;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Сразу выделяем текст в редакторе — чтобы новое число просто вбивалось поверх.</summary>
|
||||
void Editor_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var box = (TextBox)sender;
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Таблица стоит внутри прокручиваемой страницы и растягивается по содержимому,
|
||||
/// поэтому колесо мыши передаём странице.
|
||||
/// </summary>
|
||||
void Grid_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
var copy = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta) { RoutedEvent = MouseWheelEvent, Source = sender };
|
||||
((UIElement)((FrameworkElement)sender).Parent).RaiseEvent(copy);
|
||||
}
|
||||
}
|
||||
63
src/RQOverlay/Views/MobStatsView.xaml
Normal file
63
src/RQOverlay/Views/MobStatsView.xaml
Normal file
@@ -0,0 +1,63 @@
|
||||
<UserControl x:Class="RQOverlay.Views.MobStatsView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- DataContext — MobStatsPlugin -->
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Сводка -->
|
||||
<Grid Margin="0,0,0,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Style="{StaticResource Card}" Padding="14,8" Margin="0,0,10,0">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource Caption}"><Run Text="{DynamicResource CaptionPrefix}" /><Run Text="СЕССИЯ" /></TextBlock>
|
||||
<TextBlock Text="{Binding SessionSummary}" FontSize="14" Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Background="{DynamicResource SurfaceAlt}" CornerRadius="{DynamicResource RadiusL}" Padding="14,8">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource Caption}"><Run Text="{DynamicResource CaptionPrefix}" /><Run Text="ВСЁ ВРЕМЯ" /></TextBlock>
|
||||
<TextBlock Text="{Binding TotalSummary}" FontSize="14" Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button Grid.Column="3" Style="{StaticResource FlatButton}" Content="Очистить «всё время»"
|
||||
VerticalAlignment="Center" Click="Clear_Click" />
|
||||
</Grid>
|
||||
|
||||
<!-- Таблица мобов -->
|
||||
<Border Grid.Row="1" CornerRadius="{DynamicResource RadiusL}" BorderBrush="{DynamicResource Divider}" BorderThickness="1" ClipToBounds="True">
|
||||
<DataGrid x:Name="MobGrid" Style="{StaticResource DarkGrid}" IsReadOnly="True" ItemsSource="{Binding Rows}">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Моб" Binding="{Binding Name}" Width="*" MinWidth="160"
|
||||
ElementStyle="{StaticResource CellText}" />
|
||||
<DataGridTextColumn Header="Убито · сессия" Binding="{Binding SessionKills}" Width="120"
|
||||
ElementStyle="{StaticResource CellNum}" />
|
||||
<DataGridTemplateColumn Header="Опыт · сессия" Width="120" SortMemberPath="SessionXp">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding SessionXp}" Style="{StaticResource CellNum}"
|
||||
Foreground="{DynamicResource Ok}" FontWeight="SemiBold" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Убито · всего" Binding="{Binding TotalKills}" Width="120"
|
||||
ElementStyle="{StaticResource CellNumDim}" />
|
||||
<DataGridTextColumn Header="Опыт · всего" Binding="{Binding TotalXp}" Width="120"
|
||||
ElementStyle="{StaticResource CellNumDim}" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,10,0,0" Foreground="{DynamicResource TextFaint}" TextWrapping="Wrap"
|
||||
Text="«Всё время» копится между запусками, пока работает оверлей. Лог, накопленный в игре до запуска программы, в него не входит." />
|
||||
</Grid>
|
||||
</UserControl>
|
||||
42
src/RQOverlay/Views/MobStatsView.xaml.cs
Normal file
42
src/RQOverlay/Views/MobStatsView.xaml.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using RQOverlay.Plugins.Builtin;
|
||||
|
||||
namespace RQOverlay.Views;
|
||||
|
||||
public partial class MobStatsView : UserControl
|
||||
{
|
||||
public MobStatsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) => ApplyDefaultSort();
|
||||
}
|
||||
|
||||
MobStatsPlugin Plugin => (MobStatsPlugin)DataContext;
|
||||
|
||||
/// <summary>По умолчанию: больше всего убито за сессию, затем за всё время. Пересортировка на лету.</summary>
|
||||
void ApplyDefaultSort()
|
||||
{
|
||||
if (CollectionViewSource.GetDefaultView(MobGrid.ItemsSource) is not ListCollectionView view) return;
|
||||
if (view.SortDescriptions.Count == 0)
|
||||
{
|
||||
view.SortDescriptions.Add(new SortDescription(nameof(MobStat.SessionKills), ListSortDirection.Descending));
|
||||
view.SortDescriptions.Add(new SortDescription(nameof(MobStat.TotalKills), ListSortDirection.Descending));
|
||||
MobGrid.Columns[1].SortDirection = ListSortDirection.Descending;
|
||||
}
|
||||
view.IsLiveSorting = true;
|
||||
foreach (var sd in view.SortDescriptions)
|
||||
if (!view.LiveSortingProperties.Contains(sd.PropertyName))
|
||||
view.LiveSortingProperties.Add(sd.PropertyName);
|
||||
}
|
||||
|
||||
void Clear_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var answer = MessageBox.Show(Window.GetWindow(this)!,
|
||||
"Обнулить статистику мобов за всё время? Данные текущей сессии останутся.",
|
||||
"Очистить статистику", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (answer == MessageBoxResult.Yes) Plugin.ClearAllTime();
|
||||
}
|
||||
}
|
||||
37
src/RQOverlay/Views/MobsSessionView.xaml
Normal file
37
src/RQOverlay/Views/MobsSessionView.xaml
Normal file
@@ -0,0 +1,37 @@
|
||||
<UserControl x:Class="RQOverlay.Views.MobsSessionView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- Раздел плагина «Мобы» на вкладке «Сессия»: кто убит за сессию. DataContext — MobStatsPlugin. -->
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Убитые мобы" Style="{StaticResource HeadingText}" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding SessionSummary}" Foreground="{DynamicResource TextDim}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="Empty" Text="В этой сессии ещё никого не убили" Foreground="{DynamicResource TextFaint}" Margin="0,0,0,4" />
|
||||
|
||||
<Border x:Name="Table" CornerRadius="{DynamicResource RadiusL}" BorderBrush="{DynamicResource Divider}" BorderThickness="1" ClipToBounds="True">
|
||||
<DataGrid x:Name="MobGrid" Style="{StaticResource DarkGrid}" IsReadOnly="True"
|
||||
PreviewMouseWheel="Grid_PreviewMouseWheel">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Моб" Binding="{Binding Name}" Width="*" MinWidth="160"
|
||||
ElementStyle="{StaticResource CellText}" />
|
||||
<DataGridTextColumn Header="Убито" Binding="{Binding SessionKills}" Width="90"
|
||||
ElementStyle="{StaticResource CellNum}" />
|
||||
<DataGridTemplateColumn Header="Опыт" Width="90" SortMemberPath="SessionXp">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding SessionXp}" Style="{StaticResource CellNum}"
|
||||
Foreground="{DynamicResource Ok}" FontWeight="SemiBold" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
51
src/RQOverlay/Views/MobsSessionView.xaml.cs
Normal file
51
src/RQOverlay/Views/MobsSessionView.xaml.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Input;
|
||||
using RQOverlay.Plugins.Builtin;
|
||||
|
||||
namespace RQOverlay.Views;
|
||||
|
||||
public partial class MobsSessionView : UserControl
|
||||
{
|
||||
ListCollectionView? _view;
|
||||
|
||||
public MobsSessionView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Только мобы, убитые в этой сессии; больше всего убийств — сверху. Всё обновляется на лету.
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (DataContext is not MobStatsPlugin plugin) return;
|
||||
|
||||
_view = new ListCollectionView(plugin.Rows) { Filter = o => ((MobStat)o).SessionKills > 0 };
|
||||
_view.SortDescriptions.Add(new SortDescription(nameof(MobStat.SessionKills), ListSortDirection.Descending));
|
||||
_view.IsLiveFiltering = true;
|
||||
_view.LiveFilteringProperties.Add(nameof(MobStat.SessionKills));
|
||||
_view.IsLiveSorting = true;
|
||||
_view.LiveSortingProperties.Add(nameof(MobStat.SessionKills));
|
||||
((INotifyCollectionChanged)_view).CollectionChanged += (_, _) => UpdateEmpty();
|
||||
|
||||
MobGrid.ItemsSource = _view;
|
||||
UpdateEmpty();
|
||||
};
|
||||
}
|
||||
|
||||
void UpdateEmpty()
|
||||
{
|
||||
bool empty = _view is null || _view.Count == 0;
|
||||
Empty.Visibility = empty ? Visibility.Visible : Visibility.Collapsed;
|
||||
Table.Visibility = empty ? Visibility.Collapsed : Visibility.Visible;
|
||||
}
|
||||
|
||||
/// <summary>Таблица стоит внутри прокручиваемой страницы — колесо мыши передаём странице.</summary>
|
||||
void Grid_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
var copy = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta) { RoutedEvent = MouseWheelEvent, Source = sender };
|
||||
((UIElement)((FrameworkElement)sender).Parent).RaiseEvent(copy);
|
||||
}
|
||||
}
|
||||
114
src/RQOverlay/Views/PluginsView.xaml
Normal file
114
src/RQOverlay/Views/PluginsView.xaml
Normal file
@@ -0,0 +1,114 @@
|
||||
<UserControl x:Class="RQOverlay.Views.PluginsView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- Вкладка «Настройки»: тема оформления и плагины. DataContext — PluginManager. -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="0,0,10,0">
|
||||
|
||||
<TextBlock Text="Настройки" Style="{StaticResource HeadingText}" FontSize="24" Margin="0,0,0,18" />
|
||||
|
||||
<!-- ============ Тема ============ -->
|
||||
<TextBlock Style="{StaticResource Caption}" Margin="0,0,0,8">
|
||||
<Run Text="{DynamicResource CaptionPrefix}" /><Run Text="ОФОРМЛЕНИЕ" />
|
||||
</TextBlock>
|
||||
|
||||
<ItemsControl x:Name="ThemeList" Margin="-5,0,-5,26">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Rows="1" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<!-- Карточка темы: превью рисуется её собственной палитрой и шрифтом -->
|
||||
<Border x:Name="Card" Style="{StaticResource Card}" Margin="5,0" Padding="0" Cursor="Hand"
|
||||
BorderThickness="1" MouseLeftButtonUp="Theme_Click" Tag="{Binding Info.Id}">
|
||||
<StackPanel>
|
||||
<Border Height="74" Background="{Binding Swatch0}" CornerRadius="{DynamicResource RadiusL}" Margin="6,6,6,0">
|
||||
<Grid Margin="12,10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="1 287" FontFamily="{Binding Info.FontFamily}" FontSize="22"
|
||||
Foreground="{Binding Swatch3}" />
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" VerticalAlignment="Bottom">
|
||||
<Border Width="36" Height="6" Background="{Binding Swatch2}" Margin="0,0,4,0" />
|
||||
<Border Width="22" Height="6" Background="{Binding Swatch1}" Margin="0,0,4,0" />
|
||||
<Border Width="14" Height="6" Background="{Binding Swatch3}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Margin="14,10,14,12">
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding Info.Name}" FontFamily="{Binding Info.FontFamily}" FontSize="15"
|
||||
Foreground="{DynamicResource Text}" />
|
||||
<TextBlock x:Name="Check" Text="" FontFamily="{DynamicResource Icons}" FontSize="12"
|
||||
Foreground="{DynamicResource Accent}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
Visibility="Collapsed" />
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding Info.Description}" Foreground="{DynamicResource TextFaint}" FontSize="12"
|
||||
TextWrapping="Wrap" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSelected}" Value="True">
|
||||
<Setter TargetName="Card" Property="BorderBrush" Value="{DynamicResource Accent}" />
|
||||
<Setter TargetName="Check" Property="Visibility" Value="Visible" />
|
||||
</DataTrigger>
|
||||
<Trigger SourceName="Card" Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Card" Property="Background" Value="{DynamicResource SurfaceHover}" />
|
||||
</Trigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- ============ Плагины ============ -->
|
||||
<TextBlock Style="{StaticResource Caption}" Margin="0,0,0,4">
|
||||
<Run Text="{DynamicResource CaptionPrefix}" /><Run Text="ПЛАГИНЫ" />
|
||||
</TextBlock>
|
||||
<TextBlock Foreground="{DynamicResource TextDim}" TextWrapping="Wrap" Margin="0,0,0,10"
|
||||
Text="Выключенный плагин пропадает из оверлея и итога. При включении он сразу догоняет текущую сессию." />
|
||||
|
||||
<Border Style="{StaticResource Card}" Padding="0">
|
||||
<ItemsControl ItemsSource="{Binding Slots}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border BorderBrush="{DynamicResource Divider}" BorderThickness="0,0,0,1" Padding="18,14">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Name}" FontSize="14" Foreground="{DynamicResource Text}" />
|
||||
<TextBlock Text="{Binding Source}" FontSize="11.5" Foreground="{DynamicResource TextFaint}"
|
||||
VerticalAlignment="Center" Margin="10,1,0,0" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Description}" Foreground="{DynamicResource TextDim}"
|
||||
TextWrapping="Wrap" Margin="0,3,0,0" />
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Column="1" Style="{StaticResource Switch}" VerticalAlignment="Center" Margin="20,0,0,0"
|
||||
IsChecked="{Binding IsEnabled, UpdateSourceTrigger=PropertyChanged}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Foreground="{DynamicResource TextFaint}" TextWrapping="Wrap" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||
Text="Сторонние плагины: *.dll в папке plugins рядом с программой. Подхватываются при запуске." />
|
||||
<Button Grid.Column="1" Style="{StaticResource FlatButton}" Content="Папка плагинов" Click="OpenPlugins_Click" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
66
src/RQOverlay/Views/PluginsView.xaml.cs
Normal file
66
src/RQOverlay/Views/PluginsView.xaml.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace RQOverlay.Views;
|
||||
|
||||
/// <summary>Тема в списке выбора: описание + готовые кисти для превью.</summary>
|
||||
public sealed class ThemeOption : Observable
|
||||
{
|
||||
public ThemeOption(ThemeInfo info)
|
||||
{
|
||||
Info = info;
|
||||
Brush Make(int i)
|
||||
{
|
||||
var b = (Brush)new BrushConverter().ConvertFromString(info.Swatches[i])!;
|
||||
b.Freeze();
|
||||
return b;
|
||||
}
|
||||
Swatch0 = Make(0); Swatch1 = Make(1); Swatch2 = Make(2); Swatch3 = Make(3);
|
||||
}
|
||||
|
||||
public ThemeInfo Info { get; }
|
||||
public Brush Swatch0 { get; }
|
||||
public Brush Swatch1 { get; }
|
||||
public Brush Swatch2 { get; }
|
||||
public Brush Swatch3 { get; }
|
||||
|
||||
bool _isSelected;
|
||||
public bool IsSelected { get => _isSelected; set => Set(ref _isSelected, value); }
|
||||
}
|
||||
|
||||
public partial class PluginsView : UserControl
|
||||
{
|
||||
readonly List<ThemeOption> _themes = ThemeManager.Themes.Select(t => new ThemeOption(t)).ToList();
|
||||
|
||||
public PluginsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
ThemeList.ItemsSource = _themes;
|
||||
MarkSelected();
|
||||
}
|
||||
|
||||
void MarkSelected()
|
||||
{
|
||||
foreach (var t in _themes) t.IsSelected = t.Info.Id == ThemeManager.Current;
|
||||
}
|
||||
|
||||
void Theme_Click(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: string id }) return;
|
||||
ThemeManager.Apply(id);
|
||||
App.Current.Settings.Theme = ThemeManager.Current;
|
||||
App.Current.Settings.Save();
|
||||
MarkSelected();
|
||||
}
|
||||
|
||||
void OpenPlugins_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string dir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
Directory.CreateDirectory(dir);
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", dir) { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
169
src/RQOverlay/Views/PricesView.xaml
Normal file
169
src/RQOverlay/Views/PricesView.xaml
Normal file
@@ -0,0 +1,169 @@
|
||||
<UserControl x:Class="RQOverlay.Views.PricesView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Foreground="{DynamicResource TextDim}" TextWrapping="Wrap"
|
||||
Text="Цены за штуку. «НПС» подхватывается сама, когда вы продаёте предмет торговцу; если ввести её вручную, она закрепится. «Аукцион» — только вручную. Квестовые предметы в заработок не идут. Всё сохраняется между запусками." />
|
||||
|
||||
<!-- Режим оценки -->
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,12,0,0">
|
||||
<TextBlock Text="Считать заработок по" VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<Border Background="{DynamicResource SurfaceAlt}" CornerRadius="{DynamicResource RadiusM}" Padding="3">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton x:Name="ModeAuction" Style="{StaticResource Segment}" GroupName="Mode"
|
||||
Content="аукциону, если указан, иначе НПС" Checked="Mode_Changed" />
|
||||
<RadioButton x:Name="ModeNpc" Style="{StaticResource Segment}" GroupName="Mode"
|
||||
Content="только НПС" Checked="Mode_Changed" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Поиск и фильтр -->
|
||||
<Grid Grid.Row="2" Margin="0,12,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid>
|
||||
<TextBox x:Name="Search" Style="{StaticResource DarkBox}" TextChanged="Filter_Changed" />
|
||||
<TextBlock Text="Поиск…" Foreground="{DynamicResource TextFaint}" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center" IsHitTestVisible="False">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Text, ElementName=Search}" Value="">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
<CheckBox x:Name="OnlyUnpriced" Grid.Column="1" Margin="14,0,0,0" Content="Только без цены"
|
||||
Checked="Filter_Changed" Unchecked="Filter_Changed" />
|
||||
</Grid>
|
||||
|
||||
<!-- Таблица -->
|
||||
<Border Grid.Row="3" CornerRadius="{DynamicResource RadiusL}" BorderBrush="{DynamicResource Divider}" BorderThickness="1" ClipToBounds="True">
|
||||
<DataGrid x:Name="PriceGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Disabled"
|
||||
PreviewKeyDown="Grid_PreviewKeyDown" SelectionChanged="Grid_SelectionChanged">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Предмет" Binding="{Binding Name}" IsReadOnly="True" Width="*" MinWidth="150"
|
||||
ElementStyle="{StaticResource CellText}" />
|
||||
|
||||
<!-- Цена НПС -->
|
||||
<DataGridTemplateColumn Header="НПС" Width="90" SortMemberPath="NpcPrice">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock x:Name="T" Text="{Binding NpcPriceText}" Style="{StaticResource CellNum}" />
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding NpcPrice}" Value="{x:Null}">
|
||||
<Setter TargetName="T" Property="Text" Value="—" />
|
||||
<Setter TargetName="T" Property="Foreground" Value="{DynamicResource TextFaint}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding EffectiveSource}" Value="НПС">
|
||||
<Setter TargetName="T" Property="Foreground" Value="{DynamicResource Gold}" />
|
||||
<Setter TargetName="T" Property="FontWeight" Value="SemiBold" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
<DataGridTemplateColumn.CellEditingTemplate>
|
||||
<DataTemplate>
|
||||
<TextBox Text="{Binding NpcPriceText, UpdateSourceTrigger=LostFocus}"
|
||||
Style="{StaticResource CellEditor}" Loaded="Editor_Loaded" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellEditingTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="Закреп." Width="64" SortMemberPath="IsLocked">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding IsLocked, UpdateSourceTrigger=PropertyChanged}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
ToolTip="Закреплённую цену НПС не меняют продажи торговцу" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<!-- Цена аукциона -->
|
||||
<DataGridTemplateColumn Header="Аукцион" Width="90" SortMemberPath="AuctionPrice">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock x:Name="T" Text="{Binding AuctionPriceText}" Style="{StaticResource CellNum}" />
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding AuctionPrice}" Value="{x:Null}">
|
||||
<Setter TargetName="T" Property="Text" Value="—" />
|
||||
<Setter TargetName="T" Property="Foreground" Value="{DynamicResource TextFaint}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding EffectiveSource}" Value="аукцион">
|
||||
<Setter TargetName="T" Property="Foreground" Value="{DynamicResource Gold}" />
|
||||
<Setter TargetName="T" Property="FontWeight" Value="SemiBold" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
<DataGridTemplateColumn.CellEditingTemplate>
|
||||
<DataTemplate>
|
||||
<TextBox Text="{Binding AuctionPriceText, UpdateSourceTrigger=LostFocus}"
|
||||
Style="{StaticResource CellEditor}" Loaded="Editor_Loaded" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellEditingTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="Квест" Width="58" SortMemberPath="IsQuest">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding IsQuest, UpdateSourceTrigger=PropertyChanged}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
ToolTip="Квестовый предмет — не идёт в заработок" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<!-- Итоговая цена -->
|
||||
<DataGridTemplateColumn Header="В расчёт" Width="92" SortMemberPath="EffectivePrice" IsReadOnly="True">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock x:Name="T" Text="{Binding EffectiveSource}" Style="{StaticResource CellDim}" />
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding NeedsPrice}" Value="True">
|
||||
<Setter TargetName="T" Property="Text" Value="нет цены" />
|
||||
<Setter TargetName="T" Property="Foreground" Value="{DynamicResource Warn}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTextColumn Header="Обновлено" Binding="{Binding UpdatedText}" IsReadOnly="True" Width="88"
|
||||
ElementStyle="{StaticResource CellDim}" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
|
||||
<!-- Низ -->
|
||||
<Grid Grid.Row="4" Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Foreground="{DynamicResource TextDim}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
Margin="0,0,12,0" Text="Двойной клик по цене — изменить. Пустое значение — сбросить." />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button x:Name="DeleteBtn" Style="{StaticResource FlatButton}" Content="Удалить" Click="Delete_Click"
|
||||
Margin="0,0,8,0" IsEnabled="False" />
|
||||
<Button Style="{StaticResource FlatButton}" Content="Открыть папку" Click="OpenFolder_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
96
src/RQOverlay/Views/PricesView.xaml.cs
Normal file
96
src/RQOverlay/Views/PricesView.xaml.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace RQOverlay.Views;
|
||||
|
||||
public partial class PricesView : UserControl
|
||||
{
|
||||
readonly ICollectionView _view;
|
||||
PriceBook Prices => App.Current.Prices;
|
||||
|
||||
public PricesView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_view = new ListCollectionView(Prices.Items);
|
||||
_view.SortDescriptions.Add(new SortDescription(nameof(PriceItem.Name), ListSortDirection.Ascending));
|
||||
_view.Filter = Matches;
|
||||
if (_view is ICollectionViewLiveShaping live)
|
||||
{
|
||||
// Предмет, которому только что задали цену (или отметили квестовым), сам уходит из фильтра.
|
||||
live.IsLiveFiltering = true;
|
||||
live.LiveFilteringProperties.Add(nameof(PriceItem.NeedsPrice));
|
||||
}
|
||||
PriceGrid.ItemsSource = _view;
|
||||
|
||||
if (Prices.UseAuction) ModeAuction.IsChecked = true; else ModeNpc.IsChecked = true;
|
||||
}
|
||||
|
||||
public bool OnlyUnpricedFilter
|
||||
{
|
||||
get => OnlyUnpriced.IsChecked == true;
|
||||
set => OnlyUnpriced.IsChecked = value;
|
||||
}
|
||||
|
||||
bool Matches(object o)
|
||||
{
|
||||
var item = (PriceItem)o;
|
||||
if (OnlyUnpriced.IsChecked == true && !item.NeedsPrice) return false;
|
||||
string q = Search.Text.Trim();
|
||||
return q.Length == 0 || item.Name.Contains(q, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
void Mode_Changed(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_view is null) return; // ещё идёт InitializeComponent
|
||||
Prices.UseAuction = ModeAuction.IsChecked == true;
|
||||
App.Current.Settings.UseAuction = Prices.UseAuction;
|
||||
App.Current.Settings.Save();
|
||||
}
|
||||
|
||||
void Filter_Changed(object sender, RoutedEventArgs e) => _view?.Refresh();
|
||||
|
||||
void Grid_SelectionChanged(object sender, SelectionChangedEventArgs e) =>
|
||||
DeleteBtn.IsEnabled = PriceGrid.SelectedItem is PriceItem;
|
||||
|
||||
/// <summary>Сразу выделяем текст в редакторе — чтобы новая цена просто вбивалась поверх.</summary>
|
||||
void Editor_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var box = (TextBox)sender;
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
|
||||
void Grid_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
// Enter на строке без редактирования — начать ввод цены.
|
||||
if (e.Key == Key.Enter && PriceGrid.SelectedItem is not null && !IsEditing())
|
||||
{
|
||||
PriceGrid.CurrentCell = new DataGridCellInfo(PriceGrid.SelectedItem, PriceGrid.Columns[1]);
|
||||
PriceGrid.BeginEdit();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Delete && !IsEditing())
|
||||
{
|
||||
Delete_Click(sender, e);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsEditing() => Keyboard.FocusedElement is TextBox tb && tb != Search;
|
||||
|
||||
void Delete_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PriceGrid.SelectedItem is PriceItem item) Prices.Remove(item);
|
||||
}
|
||||
|
||||
void OpenFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(AppPaths.Dir);
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", AppPaths.Dir) { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
105
src/RQOverlay/Views/SessionView.xaml
Normal file
105
src/RQOverlay/Views/SessionView.xaml
Normal file
@@ -0,0 +1,105 @@
|
||||
<UserControl x:Class="RQOverlay.Views.SessionView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:RQOverlay"
|
||||
xmlns:p="clr-namespace:RQOverlay.Plugins">
|
||||
<!-- DataContext — SessionViewModel -->
|
||||
<UserControl.Resources>
|
||||
<local:BoolToVis x:Key="Vis" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="0,0,10,0">
|
||||
|
||||
<!-- Заголовок -->
|
||||
<Grid Margin="0,0,0,18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Сессия" Style="{StaticResource HeadingText}" FontSize="24" />
|
||||
<TextBlock Foreground="{DynamicResource TextFaint}" Margin="0,2,0,0">
|
||||
<Run Text="идёт" /> <Run Text="{Binding Elapsed, Mode=OneWay}" FontFamily="{DynamicResource NumFont}" Foreground="{DynamicResource TextDim}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Style="{StaticResource FlatButton}" Content="Новая сессия" Click="Reset_Click"
|
||||
VerticalAlignment="Top" ToolTip="Обнулить счётчики сессии (цены и «всё время» не трогаются)" />
|
||||
</Grid>
|
||||
|
||||
<!-- Итог -->
|
||||
<Border Style="{StaticResource Card}" Padding="22,18" Margin="0,0,0,10"
|
||||
Visibility="{Binding HasGold, Converter={StaticResource Vis}}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource Caption}">
|
||||
<Run Text="{DynamicResource CaptionPrefix}" /><Run Text="ЗАРАБОТОК ЗА СЕССИЮ" />
|
||||
</TextBlock>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<TextBlock Text="{Binding Total}" Style="{StaticResource BigNum}" FontSize="40"
|
||||
Foreground="{DynamicResource Gold}" />
|
||||
<TextBlock Text="золота" Foreground="{DynamicResource TextFaint}" VerticalAlignment="Bottom" Margin="10,0,0,9" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" HorizontalAlignment="Right">
|
||||
<TextBlock Style="{StaticResource Caption}" HorizontalAlignment="Right">
|
||||
<Run Text="{DynamicResource CaptionPrefix}" /><Run Text="ТЕМП" />
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding PerHour}" Style="{StaticResource BigNum}" FontSize="22"
|
||||
Foreground="{DynamicResource Text}" HorizontalAlignment="Right" Margin="0,4,0,4" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Показатели плагинов: ровный ряд карточек -->
|
||||
<ItemsControl ItemsSource="{Binding Metrics}" Margin="-5,0,-5,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Rows="1" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="p:Metric">
|
||||
<Border Style="{StaticResource Card}" Margin="5,0">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="7" Height="7" Fill="{Binding Color}" Margin="0,0,8,0" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" Foreground="{DynamicResource TextDim}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ValueText}" Style="{StaticResource BigNum}" FontSize="24" Margin="0,8,0,0"
|
||||
Foreground="{DynamicResource Text}" />
|
||||
<TextBlock Text="{Binding RateText}" Style="{StaticResource Num}" FontSize="12"
|
||||
Foreground="{DynamicResource TextFaint}" Margin="0,2,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Ручная корректировка -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,24">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="140" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="Корректировка" VerticalAlignment="Center" Margin="0,0,14,0" />
|
||||
<TextBox Grid.Column="1" Style="{StaticResource DarkBox}" HorizontalContentAlignment="Right"
|
||||
FontFamily="{DynamicResource NumFont}"
|
||||
Text="{Binding AdjustmentText, UpdateSourceTrigger=PropertyChanged, Delay=400}" />
|
||||
<TextBlock Grid.Column="2" Foreground="{DynamicResource TextFaint}" TextWrapping="Wrap" VerticalAlignment="Center"
|
||||
Margin="14,0,0,0"
|
||||
Text="Золото, добавляемое к итогу. Со знаком минус — вычитается: зелья, ремонт." />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Разделы плагинов (заполняются из кода) -->
|
||||
<StackPanel x:Name="Sections" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
47
src/RQOverlay/Views/SessionView.xaml.cs
Normal file
47
src/RQOverlay/Views/SessionView.xaml.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace RQOverlay.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Вкладка «Сессия»: сводка по всем показателям, ручная корректировка и разделы плагинов.
|
||||
/// Разделы создаются заново при каждом изменении набора плагинов.
|
||||
/// </summary>
|
||||
public partial class SessionView : UserControl
|
||||
{
|
||||
public SessionView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = App.Current.Session;
|
||||
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
App.Current.Plugins.Changed += BuildSections;
|
||||
BuildSections();
|
||||
};
|
||||
Unloaded += (_, _) => App.Current.Plugins.Changed -= BuildSections;
|
||||
}
|
||||
|
||||
void BuildSections()
|
||||
{
|
||||
Sections.Children.Clear();
|
||||
foreach (var plugin in App.Current.Plugins.Enabled)
|
||||
{
|
||||
FrameworkElement? view;
|
||||
try { view = plugin.CreateSessionView(); }
|
||||
catch { view = null; }
|
||||
if (view is null) continue;
|
||||
|
||||
view.Margin = new Thickness(0, 0, 0, 28);
|
||||
Sections.Children.Add(view);
|
||||
}
|
||||
}
|
||||
|
||||
void Reset_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var answer = MessageBox.Show(Window.GetWindow(this)!,
|
||||
"Начать новую сессию? Счётчики и корректировки сессии обнулятся. Цены и статистика «за всё время» останутся.",
|
||||
"Новая сессия", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (answer == MessageBoxResult.Yes) App.Current.Session.Reset();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user