APM!
This commit is contained in:
@@ -1,174 +1,64 @@
|
||||
using AnotherReplayReader.ReplayFile;
|
||||
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.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace AnotherReplayReader
|
||||
{
|
||||
internal class DataValue : IComparable<DataValue>, IComparable
|
||||
{
|
||||
public int? NumberValue { get; }
|
||||
public string Value { get; }
|
||||
|
||||
public DataValue(string value)
|
||||
{
|
||||
Value = value;
|
||||
if (int.TryParse(value, out var numberValue))
|
||||
{
|
||||
NumberValue = numberValue;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public int CompareTo(DataValue other)
|
||||
{
|
||||
if (NumberValue.HasValue == other.NumberValue.HasValue)
|
||||
{
|
||||
if (!NumberValue.HasValue)
|
||||
{
|
||||
return Value.CompareTo(other.Value);
|
||||
}
|
||||
return NumberValue.Value.CompareTo(other.NumberValue!.Value);
|
||||
}
|
||||
return NumberValue.HasValue ? 1 : -1;
|
||||
}
|
||||
|
||||
public int CompareTo(object obj)
|
||||
{
|
||||
if (obj is DataValue other)
|
||||
{
|
||||
return CompareTo(other);
|
||||
}
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
internal class DataRow
|
||||
{
|
||||
public string Name { get; }
|
||||
public DataValue Player1Value => _values[0];
|
||||
public DataValue Player2Value => _values[1];
|
||||
public DataValue Player3Value => _values[2];
|
||||
public DataValue Player4Value => _values[3];
|
||||
public DataValue Player5Value => _values[4];
|
||||
public DataValue Player6Value => _values[5];
|
||||
|
||||
private readonly IReadOnlyList<DataValue> _values;
|
||||
|
||||
public DataRow(string name, IEnumerable<string> values)
|
||||
{
|
||||
Name = name;
|
||||
|
||||
if (values.Count() < 6)
|
||||
{
|
||||
values = values.Concat(new string[6 - values.Count()]);
|
||||
}
|
||||
_values = values.Select(x => new DataValue(x)).ToArray();
|
||||
}
|
||||
|
||||
public static List<DataRow> GetList(Replay replay, PlayerIdentity identity)
|
||||
{
|
||||
var list = new List<byte>
|
||||
{
|
||||
0x0F,
|
||||
0x5F,
|
||||
0x12,
|
||||
0x1B,
|
||||
0x48,
|
||||
0x52,
|
||||
0xFC,
|
||||
0xFD,
|
||||
0x01,
|
||||
0x21,
|
||||
0x33,
|
||||
0x34,
|
||||
0x35,
|
||||
0x37,
|
||||
0x47,
|
||||
0xF6,
|
||||
0xF9,
|
||||
0xF5,
|
||||
0xF8,
|
||||
0x2A,
|
||||
0xFA,
|
||||
0xFB,
|
||||
0x07,
|
||||
0x08,
|
||||
0x05,
|
||||
0x06,
|
||||
0x09,
|
||||
0x00,
|
||||
0x0A,
|
||||
0x03,
|
||||
0x04,
|
||||
0x28,
|
||||
0x29,
|
||||
0x0D,
|
||||
0x0E,
|
||||
0x15,
|
||||
0x14,
|
||||
0x36,
|
||||
0x16,
|
||||
0x2C,
|
||||
0x1A,
|
||||
0x4E,
|
||||
0xFE,
|
||||
0xFF,
|
||||
0x32,
|
||||
0x2E,
|
||||
0x2F,
|
||||
0x4B,
|
||||
0x4C,
|
||||
0x02,
|
||||
0x0C,
|
||||
0x10,
|
||||
};
|
||||
|
||||
var dataList = new List<DataRow>
|
||||
{
|
||||
new DataRow("ID", replay.Players.Select(x => x.PlayerName))
|
||||
};
|
||||
if (replay.Type == ReplayType.Lan && identity.IsUsable)
|
||||
{
|
||||
dataList.Add(new DataRow("局域网IP", replay.Players.Select(x => identity.QueryRealNameAndIP(x.PlayerIp))));
|
||||
}
|
||||
|
||||
var commandCounts = replay.GetCommandCounts();
|
||||
foreach (var command in list)
|
||||
{
|
||||
var counts = commandCounts.TryGetValue(command, out var stored) ? stored : new int[replay.Players.Count];
|
||||
dataList.Add(new DataRow(RA3Commands.GetCommandName(command), counts.Select(x => $"{x}")));
|
||||
commandCounts.Remove(command);
|
||||
}
|
||||
|
||||
foreach (var commandCount in commandCounts)
|
||||
{
|
||||
dataList.Add(new DataRow(RA3Commands.GetCommandName(commandCount.Key), commandCount.Value.Select(x => $"{x}")));
|
||||
}
|
||||
|
||||
return dataList;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
InitializeApmWindowData(replay);
|
||||
}
|
||||
|
||||
private async void InitializeApmWindowData(Replay replay)
|
||||
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)
|
||||
{
|
||||
@@ -181,21 +71,15 @@ namespace AnotherReplayReader
|
||||
_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
|
||||
{
|
||||
var dataList = await Task.Run(() => DataRow.GetList(replay, _identity));
|
||||
_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;
|
||||
await FilterDataAsyncThrowable(begin, end, updatePlot);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -203,6 +87,109 @@ namespace AnotherReplayReader
|
||||
}
|
||||
}
|
||||
|
||||
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:#.##}";
|
||||
}
|
||||
list.Insert(apmIndex, new(DataRow.PartialApmRow,
|
||||
instantApms.Select(PartialApmToString)));
|
||||
}
|
||||
list.Insert(apmIndex, new(DataRow.AverageApmRow,
|
||||
avg.Select(v => $"{v:#.##}")));
|
||||
|
||||
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);
|
||||
@@ -213,5 +200,207 @@ namespace AnotherReplayReader
|
||||
{
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user