diff --git a/AnotherReplayReader.csproj b/AnotherReplayReader.csproj index 9fcf718..1ed9371 100644 --- a/AnotherReplayReader.csproj +++ b/AnotherReplayReader.csproj @@ -34,6 +34,7 @@ + diff --git a/Apm/ApmPlotter.cs b/Apm/ApmPlotter.cs new file mode 100644 index 0000000..3c6b1f2 --- /dev/null +++ b/Apm/ApmPlotter.cs @@ -0,0 +1,236 @@ +using AnotherReplayReader.ReplayFile; +using OxyPlot; +using OxyPlot.Axes; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace AnotherReplayReader.Apm +{ + public class ApmPlotterFilterOptions + { + public bool CountUnknowns { get; } + public bool CountAutos { get; } + public bool CountClicks { get; } + + public ApmPlotterFilterOptions(bool countUnknowns, bool countAutos, bool countClicks) + { + CountUnknowns = countUnknowns; + CountAutos = countAutos; + CountClicks = countClicks; + } + + public bool ShouldSkip(byte command) + { + if (!CountUnknowns && ApmPlotter.IsUnknown(command)) + { + return true; + } + if (!CountAutos && ApmPlotter.IsAuto(command)) + { + return true; + } + if (!CountClicks && ApmPlotter.IsClick(command)) + { + return true; + } + return false; + } + } + + internal class ApmPlotter + { + private static readonly ConcurrentDictionary UnknownCommands; + private static readonly ConcurrentDictionary AutoCommands; + private readonly ImmutableArray<(TimeSpan, ImmutableArray)> _commands; + + public Replay Replay { get; } + public ImmutableArray Players { get; } + public ImmutableArray PlayerLifes { get; } + public ImmutableArray StricterPlayerLifes { get; } + public TimeSpan ReplayLength { get; } + + static ApmPlotter() + { + static KeyValuePair Create(byte x) => new(x, default); + UnknownCommands = new(RA3Commands.UnknownCommands.Select(Create)); + AutoCommands = new(RA3Commands.AutoCommands.Select(Create)); + } + + public ApmPlotter(Replay replay) + { + if (replay.Body is not { } body) + { + throw new InvalidOperationException("Replay must be fully parsed!"); + } + + Replay = replay; + + // player list without post Commentator + Players = (replay.Players.Last().PlayerName == Replay.PostCommentator + ? replay.Players.Take(replay.Players.Length - 1) + : replay.Players).ToImmutableArray(); + + // get all commands + IEnumerable Filter(ReplayChunk chunk) + { + foreach (var command in CommandChunk.Parse(chunk)) + { + if (command.PlayerIndex >= Players.Length) + { + Debug.Instance.DebugMessage += $"Unknown command with invalid player index: {command}\r\n"; + continue; + } + yield return command; + } + } + var query = from chunk in body + where chunk.Type is 1 + select (chunk.Time, Filter(chunk).ToImmutableArray()); + _commands = query.ToImmutableArray(); + + var playerLifes = new TimeSpan[Players.Length]; + var threeSeconds = TimeSpan.FromSeconds(3); + var stricterLifes = new TimeSpan[Players.Length]; + foreach (var (time, commands) in _commands) + { + var estimatedTime = time + threeSeconds; + foreach (var command in commands) + { + var commandId = command.CommandId; + var playerIndex = command.PlayerIndex; + if (commandId == 0x21) + { + if (playerLifes[playerIndex] < estimatedTime) + { + playerLifes[playerIndex] = estimatedTime; + } + } + else if (!IsUnknown(commandId) && !IsAuto(commandId)) + { + if (stricterLifes[playerIndex] < estimatedTime) + { + stricterLifes[playerIndex] = estimatedTime; + } + } + } + } + PlayerLifes = playerLifes.ToImmutableArray(); + StricterPlayerLifes = stricterLifes.ToImmutableArray(); + ReplayLength = replay.Footer?.ReplayLength ?? PlayerLifes.Max(); + } + + public DataPoint[][] GetPoints(TimeSpan resolution, ApmPlotterFilterOptions options) + { + var lists = Players + .Select(x => new List { 0 }) + .ToArray(); + var currentTime = TimeSpan.Zero; + var currentIndex = 0; + foreach (var (time, commands) in _commands) + { + while (time >= currentTime + resolution) + { + currentTime += resolution; + ++currentIndex; + } + foreach (var command in commands) + { + if (options.ShouldSkip(command.CommandId)) + { + continue; + } + var list = lists[command.PlayerIndex]; + var gap = currentIndex - list.Count; + if (gap >= 0) + { + list.AddRange(Enumerable.Repeat(0, gap + 1)); + } + ++lists[command.PlayerIndex][currentIndex]; + } + } + var apmMultiplier = 1 / resolution.TotalMinutes; + DataPoint Create(int v, int i) => new(TimeSpanAxis.ToDouble(resolution) * i, + v * apmMultiplier); + return lists.Select(l => l.Select(Create).ToArray()).ToArray(); + } + + public double[] CalculateAverageApm(ApmPlotterFilterOptions options) + { + var apm = new double[Players.Length]; + foreach (var (_, commands) in _commands) + { + foreach (var command in commands) + { + if (options.ShouldSkip(command.CommandId)) + { + continue; + } + ++apm[command.PlayerIndex]; + } + } + for (var i = 0; i < apm.Length; ++i) + { + apm[i] /= PlayerLifes[i].TotalMinutes; + } + return apm; + } + + public static double[] CalculateInstantApm(DataPoint[][] data, TimeSpan begin, TimeSpan end) + { + return data.Select(playerData => + { + return playerData + .SkipWhile(p => TimeSpanAxis.ToTimeSpan(p.X) < begin) + .TakeWhile(p => TimeSpanAxis.ToTimeSpan(p.X) < end) + .Average(p => new double?(p.Y)) ?? double.NaN; + }).ToArray(); + } + + public Dictionary GetCommandCounts(TimeSpan begin, TimeSpan end) + { + var playerCommands = new Dictionary(); + + foreach (var (time, commands) in _commands) + { + if (time < begin) + { + continue; + } + if (time >= end) + { + break; + } + foreach (var command in commands) + { + if (!playerCommands.TryGetValue(command.CommandId, out var commandCount)) + { + commandCount = playerCommands[command.CommandId] = new int[Players.Length]; + } + commandCount[command.PlayerIndex] = commandCount[command.PlayerIndex] + 1; + } + } + return playerCommands; + } + + public static bool IsUnknown(byte commandId) + { + if (UnknownCommands.ContainsKey(commandId)) + { + return true; + } + if (RA3Commands.IsUnknownCommand(commandId)) + { + UnknownCommands.TryAdd(commandId, default); + return true; + } + return false; + } + + public static bool IsAuto(byte commandId) => AutoCommands.ContainsKey(commandId); + + public static bool IsClick(byte commandId) => commandId is 0xF8 or 0xF5; + } +} diff --git a/Apm/DataRow.cs b/Apm/DataRow.cs new file mode 100644 index 0000000..e43273a --- /dev/null +++ b/Apm/DataRow.cs @@ -0,0 +1,146 @@ +using AnotherReplayReader.ReplayFile; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AnotherReplayReader.Apm +{ + internal class DataRow + { + public const string AverageApmRow = "APM(整局游戏)"; + public const string PartialApmRow = "APM(当前时间段)"; + + public string Name { get; } + public bool IsDisabled { 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 _values; + + public DataRow(string name, + IEnumerable values, + bool isDisabled = false) + { + Name = name; + IsDisabled = isDisabled; + + if (values.Count() < 6) + { + values = values.Concat(new string[6 - values.Count()]); + } + _values = values.Select(x => new DataValue(x)).ToArray(); + } + + public static List GetList(ApmPlotter plotter, + ApmPlotterFilterOptions options, + PlayerIdentity identity, + TimeSpan begin, + TimeSpan end, + out int apmRowIndex) + { + // these commands should appear in this order by default + var orderedCommands = new List(); + orderedCommands.AddRange(RA3Commands.UnknownCommands); + orderedCommands.AddRange(RA3Commands.AutoCommands); + orderedCommands.AddRange(new byte[] + { + 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 + { + new("ID", plotter.Players.Select(x => x.PlayerName)) + }; + var isPartial = begin > TimeSpan.Zero || end <= plotter.ReplayLength; + if (isPartial) + { + string GetStatus(Player player, int i) + { + if (player.IsComputer) + { + return "这是 AI"; + } + if (begin < plotter.StricterPlayerLifes[i]) + { + return "存活"; + } + return begin < plotter.PlayerLifes[i] + ? "变身天眼帝国,或双手离开键盘" + : "可能已离开房间"; + } + dataList.Add(new("存活状态(推测)", plotter.Players.Select(GetStatus))); + } + if (plotter.Replay.Type is ReplayType.Lan && identity.IsUsable) + { + string GetIpAndName(Player player) => player.IsComputer + ? "这是 AI" + : identity.QueryRealNameAndIP(player.PlayerIp); + dataList.Add(new("局域网 IP", plotter.Players.Select(GetIpAndName))); + } + apmRowIndex = dataList.Count; + // get commands + var commandCounts = plotter.GetCommandCounts(begin, end); + // add commands in the order specified by the list + foreach (var command in orderedCommands) + { + var counts = commandCounts.TryGetValue(command, out var stored) + ? stored + : new int[plotter.Players.Length]; + if (!isPartial || counts.Any(x => x > 0)) + { + dataList.Add(new(RA3Commands.GetCommandName(command), + counts.Select(x => $"{x}"), + options.ShouldSkip(command))); + } + commandCounts.Remove(command); + } + // add other commands + foreach (var commandCount in commandCounts) + { + dataList.Add(new(RA3Commands.GetCommandName(commandCount.Key), + commandCount.Value.Select(x => $"{x}"), + options.ShouldSkip(commandCount.Key))); + } + + return dataList; + } + } +} diff --git a/Apm/DataValue.cs b/Apm/DataValue.cs new file mode 100644 index 0000000..043484c --- /dev/null +++ b/Apm/DataValue.cs @@ -0,0 +1,43 @@ +using System; + +namespace AnotherReplayReader.Apm +{ + internal class DataValue : IComparable, 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(); + } + } +} diff --git a/ApmWindow.xaml b/ApmWindow.xaml index e892a4f..835ef66 100644 --- a/ApmWindow.xaml +++ b/ApmWindow.xaml @@ -1,63 +1,128 @@  - - -