RQ Overlay: оверлей заработка и статистики для Royal Quest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 12:46:52 +03:00
commit 7f89dba5f8
43 changed files with 4253 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Сборка
bin/
obj/
dist/
# IDE
.idea/
.vs/
*.user
*.DotSettings.user

3
RQOverlay.slnx Normal file
View File

@@ -0,0 +1,3 @@
<Solution>
<Project Path="src/RQOverlay/RQOverlay.csproj" />
</Solution>

374
src/RQOverlay/App.xaml Normal file
View 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="&#xE73E;" 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
View 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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View 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;
}
}

View 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;
}

View File

@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
namespace RQOverlay.Core;
// ---------------------------------------------------------------------
// ПАРСЕР HTML-ЛОГА
// ---------------------------------------------------------------------
/// <param name="Html">Тело записи с исходной разметкой — нужно, чтобы достать
/// выделенные названия предметов (&lt;U style='color:#FFFFFF'&gt;...&lt;/U&gt;).</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();
}
}

View 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));
}

View 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);
}
}

View 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
View 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>
/// Похоже ли на название предмета. Отсекает обрывки разметки и мусор из памяти
/// вроде "&lt;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);
}
}

View 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="&#xE70E;" />
<Style.Triggers>
<DataTrigger Binding="{Binding ListExpanded}" Value="False">
<Setter Property="Text" Value="&#xE70D;" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
<Button Style="{StaticResource IconButton}" Click="Reset_Click" Content="&#xE72C;" ToolTip="Новая сессия (сбросить счётчики)" />
<Button Style="{StaticResource IconButton}" Click="App_Click" Content="&#xE8A7;" ToolTip="Окно программы: сессия, мобы, цены" />
<Button Style="{StaticResource IconButton}" Click="Plugins_Click" Content="&#xE713;" ToolTip="Настройки: тема и плагины" />
<Button Style="{StaticResource IconButton}" Click="Lock_Click" Content="&#xE72E;"
ToolTip="Пропускать клики в игру.&#x0a;Вернуть управление: Ctrl+Shift+O" />
<Button Style="{StaticResource IconButton}" Click="Close_Click" Content="&#xE8BB;" 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="&#xE72E;" /> Клики проходят в игру · Ctrl+Shift+O
</TextBlock>
<Border Height="14" />
</StackPanel>
</Border>
</Window>

View 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);
}

View 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 { /* не критично */ }
}
}

View 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;
}

View 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));
}
}

View 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 { /* попробуем в следующий раз */ }
}
}

View 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) + "/ч";
}

View 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
View 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
{
// Не получилось — начнём с чистой папки.
}
}
}

View 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>

View 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;
}
}

View 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);
}

View 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>

View 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>

View 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>

View 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>

View 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 });
}
}
}

View 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="&#xE7BA;" 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>

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

View 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>

View 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);
}
}

View 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>

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

View 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>

View 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);
}
}

View 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="&#xE73E;" 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>

View 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 });
}
}

View 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>

View 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 });
}
}

View 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>

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