407 lines
14 KiB
C#
407 lines
14 KiB
C#
using AnotherReplayReader.Apm;
|
|
using AnotherReplayReader.ReplayFile;
|
|
using AnotherReplayReader.Utils;
|
|
using OxyPlot;
|
|
using OxyPlot.Annotations;
|
|
using OxyPlot.Axes;
|
|
using OxyPlot.Legends;
|
|
using OxyPlot.Series;
|
|
using System;
|
|
using System.ComponentModel;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Input;
|
|
|
|
namespace AnotherReplayReader
|
|
{
|
|
/// <summary>
|
|
/// APM.xaml 的交互逻辑
|
|
/// </summary>
|
|
internal partial class ApmWindow : Window
|
|
{
|
|
public static readonly TimeSpan DefaultResolution = TimeSpan.FromSeconds(15);
|
|
private readonly Regex _resolutionRegex = new(@"[^0-9]+");
|
|
private readonly PlayerIdentity _identity;
|
|
private readonly Replay _replay;
|
|
private readonly Task<ApmPlotter> _plotter;
|
|
private readonly ApmWindowPlotController _plotController;
|
|
|
|
public ApmWindowPlotViewModel PlotModel { get; } = new();
|
|
public TimeSpan PlotResolution { get; private set; } = DefaultResolution;
|
|
public bool SkipUnknowns { get; private set; } = true;
|
|
public bool SkipAutos { get; private set; } = true;
|
|
public bool SkipClicks { get; private set; } = false;
|
|
|
|
public ApmWindow(Replay replay, PlayerIdentity identity)
|
|
{
|
|
_identity = identity;
|
|
_replay = replay;
|
|
_plotter = Task.Run(() => new ApmPlotter(replay));
|
|
_plotController = new(this);
|
|
InitializeComponent();
|
|
}
|
|
|
|
public async void FilterData(TimeSpan begin, TimeSpan end, bool updatePlot)
|
|
{
|
|
await FilterDataAsync(begin, end, updatePlot);
|
|
}
|
|
|
|
private async void OnApmWindowLoaded(object sender, RoutedEventArgs e)
|
|
{
|
|
_plot.Controller = _plotController;
|
|
_resolution.Text = PlotResolution.TotalSeconds.ToString();
|
|
_skipUnknowns.IsChecked = SkipUnknowns;
|
|
_skipAutos.IsChecked = SkipAutos;
|
|
_skipClicks.IsChecked = SkipClicks;
|
|
await InitializeApmWindowData();
|
|
}
|
|
|
|
private async Task InitializeApmWindowData()
|
|
{
|
|
if (_identity.IsUsable)
|
|
{
|
|
_setPlayerButton.IsEnabled = true;
|
|
_setPlayerButton.Visibility = Visibility.Visible;
|
|
}
|
|
else
|
|
{
|
|
_setPlayerButton.IsEnabled = false;
|
|
_setPlayerButton.Visibility = Visibility.Hidden;
|
|
}
|
|
|
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
|
_label.Content = "选择表格之后按 Ctrl+C 可以复制内容";
|
|
}
|
|
|
|
private async Task FilterDataAsync(TimeSpan begin, TimeSpan end, bool updatePlot)
|
|
{
|
|
try
|
|
{
|
|
await FilterDataAsyncThrowable(begin, end, updatePlot);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
MessageBox.Show($"加载录像信息失败:\r\n{e}");
|
|
}
|
|
}
|
|
|
|
private async Task FilterDataAsyncThrowable(TimeSpan begin, TimeSpan end, bool updatePlot)
|
|
{
|
|
var resolution = PlotResolution;
|
|
var options = new ApmPlotterFilterOptions(!SkipUnknowns, !SkipAutos, !SkipClicks);
|
|
var (dataList, plotData, replayLength, isPartial) = await Task.Run(async () =>
|
|
{
|
|
var plotter = await _plotter.ConfigureAwait(false);
|
|
var list = DataRow.GetList(plotter, options, _identity, begin, end, out var apmIndex);
|
|
// avg apm
|
|
var avg = plotter.CalculateAverageApm(options);
|
|
// data for plotting and partial apm
|
|
var data = plotter.GetPoints(resolution, options);
|
|
// partial apm
|
|
var isPartial = begin > TimeSpan.Zero || end <= plotter.ReplayLength;
|
|
if (isPartial)
|
|
{
|
|
var instantApms = ApmPlotter.CalculateInstantApm(data, begin, end);
|
|
string PartialApmToString(double v, int i)
|
|
{
|
|
if (plotter.Players[i].IsComputer)
|
|
{
|
|
return "这是 AI";
|
|
}
|
|
return plotter.PlayerLifes[i] < begin
|
|
? "玩家已战败"
|
|
: $"{v:0.##}";
|
|
}
|
|
list.Insert(apmIndex, new(DataRow.PartialApmRow,
|
|
instantApms.Select(PartialApmToString)));
|
|
}
|
|
list.Insert(apmIndex, new(DataRow.AverageApmRow,
|
|
avg.Select(v => $"{v:0.##}")));
|
|
|
|
return (list, data, plotter.ReplayLength, isPartial);
|
|
});
|
|
if (updatePlot)
|
|
{
|
|
BuildPlot(plotData, replayLength);
|
|
}
|
|
|
|
_table.Items.Clear();
|
|
foreach (var row in dataList)
|
|
{
|
|
_table.Items.Add(row);
|
|
}
|
|
|
|
_table.Columns[1].Header = dataList[0].Player1Value;
|
|
_table.Columns[2].Header = dataList[0].Player2Value;
|
|
_table.Columns[3].Header = dataList[0].Player3Value;
|
|
_table.Columns[4].Header = dataList[0].Player4Value;
|
|
_table.Columns[5].Header = dataList[0].Player5Value;
|
|
_table.Columns[6].Header = dataList[0].Player6Value;
|
|
if (isPartial)
|
|
{
|
|
_label.Content = $"{(ShortTimeSpan)begin} 至 {(ShortTimeSpan)end} 之间的 APM 数据";
|
|
_label.FontWeight = System.Windows.FontWeights.Bold;
|
|
}
|
|
else
|
|
{
|
|
_label.Content = "以下是整局游戏的 APM 数据:";
|
|
_label.FontWeight = System.Windows.FontWeights.Normal;
|
|
}
|
|
}
|
|
|
|
private void BuildPlot(DataPoint[][] data, TimeSpan replayLength)
|
|
{
|
|
var model = new PlotModel();
|
|
model.Annotations.Add(new ApmWindowPlotTextAnnotation
|
|
{
|
|
Text = "鼠标左键拖拽选择,滚轮缩放,右键拖拽移动",
|
|
});
|
|
var maxApm = data.SelectMany(x => x).Max(x => x.Y);
|
|
var yaxis = new LinearAxis
|
|
{
|
|
Position = AxisPosition.Right,
|
|
IsZoomEnabled = false,
|
|
AbsoluteMinimum = -maxApm / 10,
|
|
AbsoluteMaximum = maxApm,
|
|
MajorStep = 50,
|
|
};
|
|
var lengthInSeconds = replayLength.TotalSeconds;
|
|
var limit = lengthInSeconds / 10;
|
|
var xaxis = new TimeSpanAxis
|
|
{
|
|
Position = AxisPosition.Bottom,
|
|
AbsoluteMinimum = -limit,
|
|
AbsoluteMaximum = lengthInSeconds + limit,
|
|
};
|
|
model.Axes.Add(yaxis);
|
|
model.Axes.Add(xaxis);
|
|
foreach (var (points, i) in data.Select((v, i) => (v, i)))
|
|
{
|
|
var series = new LineSeries
|
|
{
|
|
Title = _replay.Players[i].PlayerName,
|
|
};
|
|
series.Points.AddRange(points);
|
|
model.Series.Add(series);
|
|
model.Legends.Add(new Legend());
|
|
}
|
|
PlotModel.Model = model;
|
|
}
|
|
|
|
private void OnSetPlayerButtonClick(object sender, RoutedEventArgs e)
|
|
{
|
|
var window1 = new Window1(_identity);
|
|
window1.ShowDialog();
|
|
}
|
|
|
|
private void OnTableMouseDoubleClick(object sender, MouseButtonEventArgs e)
|
|
{
|
|
_table.SelectAll();
|
|
}
|
|
|
|
private int NormalizeResolutionInput(string text)
|
|
{
|
|
if (!int.TryParse(text, out var value))
|
|
{
|
|
value = (int)PlotResolution.TotalSeconds;
|
|
}
|
|
return Math.Min(Math.Max(1, value), 3600);
|
|
}
|
|
|
|
private void OnResolutionPreviewTextInput(object sender, TextCompositionEventArgs e)
|
|
{
|
|
e.Handled = _resolutionRegex.IsMatch(e.Text);
|
|
}
|
|
|
|
private async void OnResolutionTextChanged(object sender, EventArgs e)
|
|
{
|
|
var seconds = NormalizeResolutionInput(_resolution.Text);
|
|
if (Math.Abs(seconds - PlotResolution.TotalSeconds) >= 0.5)
|
|
{
|
|
PlotResolution = TimeSpan.FromSeconds(seconds);
|
|
_resolution.Text = seconds.ToString();
|
|
_plotController.DiscardRectangle();
|
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
|
}
|
|
}
|
|
|
|
private async void OnSkipUnknownsCheckedChanged(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_skipUnknowns.IsChecked != SkipUnknowns)
|
|
{
|
|
SkipUnknowns = _skipUnknowns.IsChecked is true;
|
|
_plotController.DiscardRectangle();
|
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
|
}
|
|
}
|
|
|
|
private async void OnSkipAutosCheckedChanged(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_skipAutos.IsChecked != SkipAutos)
|
|
{
|
|
SkipAutos = _skipAutos.IsChecked is true;
|
|
_plotController.DiscardRectangle();
|
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
|
}
|
|
}
|
|
|
|
private async void OnSkipClicksCheckedChanged(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_skipClicks.IsChecked != SkipClicks)
|
|
{
|
|
SkipClicks = _skipClicks.IsChecked is true;
|
|
_plotController.DiscardRectangle();
|
|
await FilterDataAsync(TimeSpan.MinValue, TimeSpan.MaxValue, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal class ApmWindowPlotViewModel : INotifyPropertyChanged
|
|
{
|
|
private PlotModel _model = new();
|
|
public event PropertyChangedEventHandler? PropertyChanged;
|
|
public PlotModel Model
|
|
{
|
|
get => _model;
|
|
set
|
|
{
|
|
_model = value;
|
|
PropertyChanged?.Invoke(this, new(nameof(Model)));
|
|
}
|
|
}
|
|
}
|
|
|
|
internal class ApmWindowPlotController : PlotController
|
|
{
|
|
private readonly ApmWindow _window;
|
|
private RectangleAnnotation? _current;
|
|
private PlotModel Plot => _window.PlotModel.Model;
|
|
private double Resolution => _window.PlotResolution.TotalSeconds;
|
|
// private readonly Func<OxyMouseDownEventArgs>
|
|
|
|
public ApmWindowPlotController(ApmWindow window)
|
|
{
|
|
_window = window;
|
|
}
|
|
|
|
public void DiscardRectangle()
|
|
{
|
|
_current = null;
|
|
RemoveSelections();
|
|
Plot.InvalidatePlot(false);
|
|
}
|
|
|
|
public override bool HandleMouseDown(IView view, OxyMouseDownEventArgs args)
|
|
{
|
|
TryBeginDragRectagle(args);
|
|
return base.HandleMouseDown(view, args);
|
|
}
|
|
|
|
public override bool HandleMouseMove(IView view, OxyMouseEventArgs args)
|
|
{
|
|
TryDragRectangle(args);
|
|
return base.HandleMouseMove(view, args);
|
|
}
|
|
|
|
public override bool HandleMouseUp(IView view, OxyMouseEventArgs args)
|
|
{
|
|
TryEndDragRectangle();
|
|
return base.HandleMouseUp(view, args);
|
|
}
|
|
|
|
private void RemoveSelections()
|
|
{
|
|
var list = Plot.Annotations
|
|
.Select((x, i) => (Item: x, Index: i))
|
|
.Where(t => t.Item is RectangleAnnotation)
|
|
.Reverse()
|
|
.ToArray();
|
|
foreach (var (_, index) in list)
|
|
{
|
|
Plot.Annotations.RemoveAt(index);
|
|
}
|
|
}
|
|
|
|
private void TryBeginDragRectagle(OxyMouseDownEventArgs args)
|
|
{
|
|
if (args.ChangedButton == OxyMouseButton.Left)
|
|
{
|
|
var x = Plot.Axes[1].InverseTransform(args.Position.X);
|
|
x = Math.Round(x / Resolution) * Resolution;
|
|
RemoveSelections();
|
|
_current = new()
|
|
{
|
|
ClipByYAxis = true,
|
|
Fill = OxyColor.FromArgb(128, 0, 128, 255),
|
|
MinimumY = double.NegativeInfinity,
|
|
MaximumY = double.PositiveInfinity,
|
|
MinimumX = x,
|
|
MaximumX = x
|
|
};
|
|
Plot.Annotations.Add(_current);
|
|
Plot.InvalidatePlot(false);
|
|
}
|
|
}
|
|
|
|
private void TryDragRectangle(OxyMouseEventArgs args)
|
|
{
|
|
if (_current is not null)
|
|
{
|
|
var x = Plot.Axes[1].InverseTransform(args.Position.X);
|
|
var offsetMultiplier = Math.Round((x - _current.MinimumX) / Resolution);
|
|
x = offsetMultiplier * Resolution;
|
|
_current.MaximumX = _current.MinimumX + x;
|
|
if (_current.MaximumX < _current.MinimumX)
|
|
{
|
|
var temp = _current.MinimumX;
|
|
_current.MinimumX = _current.MaximumX;
|
|
_current.MaximumX = temp;
|
|
}
|
|
Plot.InvalidatePlot(false);
|
|
}
|
|
}
|
|
|
|
private void TryEndDragRectangle()
|
|
{
|
|
if (_current is not null)
|
|
{
|
|
if (Math.Abs(_current.MaximumX - _current.MinimumX) < Resolution)
|
|
{
|
|
RemoveSelections();
|
|
Plot.InvalidatePlot(false);
|
|
_window.FilterData(TimeSpan.MinValue, TimeSpan.MaxValue, false);
|
|
}
|
|
else
|
|
{
|
|
_window.FilterData(TimeSpan.FromSeconds(_current.MinimumX),
|
|
TimeSpan.FromSeconds(_current.MaximumX),
|
|
false);
|
|
}
|
|
_current = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
public class ApmWindowPlotTextAnnotation : Annotation
|
|
{
|
|
public string Text { get; set; } = string.Empty;
|
|
public double X { get; set; } = 8;
|
|
public double Y { get; set; } = 8;
|
|
|
|
public override void Render(IRenderContext rc)
|
|
{
|
|
base.Render(rc);
|
|
double pX = PlotModel.PlotArea.Left + X;
|
|
double pY = PlotModel.PlotArea.Top + Y;
|
|
rc.DrawMultilineText(new(pX, pY),
|
|
Text,
|
|
PlotModel.TextColor,
|
|
PlotModel.DefaultFont,
|
|
PlotModel.DefaultFontSize,
|
|
PlotModel.SubtitleFontWeight);
|
|
}
|
|
}
|
|
}
|