From fee5cf0efdc1d2484487ab8b7d2faefbc7d145aa Mon Sep 17 00:00:00 2001 From: That-One-Nerd Date: Sun, 31 Aug 2025 23:18:47 -0400 Subject: [PATCH] Completed a large amount of work relating to recording. This was all done in about 4 hours of crunch. I was just in the groove. Recording is pretty much completely working. You can record things in realtime, you can pass that data onto a playback object (haven't written that part yet but I've written the in-between), and you can save recordings to a file, either as a binary file (efficient) or as an XML file (definitely not). There's more I might want to do with the encoding in the future, namely some compression. Like if a value hasn't changed in a while, we don't need to store it every tick. I believe this is called "temporal compression." The XML data actually compresses incredibly well (I guess I shouldn't be surprised; the data is 90% the word "Property"). That might be worth looking into. Anyway, playback coming soon. --- ActionRecorder.sln | 2 +- ActionRecorder/ActionManager.cs | 171 ++++++++++++++++++ ActionRecorder/ActionRecorder.csproj | 8 + ActionRecorder/Class1.cs | 9 - ActionRecorder/ComponentRecorderAttribute.cs | 17 ++ ActionRecorder/ComponentRecorderBase.cs | 24 +++ ActionRecorder/ComponentRecorderInfo.cs | 14 ++ ActionRecorder/FrequencyKind.cs | 9 + ActionRecorder/HelperExtensions.cs | 19 ++ ActionRecorder/ObjectRecorder.cs | 138 ++++++++++++++ ActionRecorder/Recorders/TransformRecorder.cs | 48 +++++ ActionRecorder/RecordingContainer.cs | 45 +++++ ActionRecorder/RecordingInstantBase.cs | 55 ++++++ 13 files changed, 549 insertions(+), 10 deletions(-) create mode 100644 ActionRecorder/ActionManager.cs delete mode 100644 ActionRecorder/Class1.cs create mode 100644 ActionRecorder/ComponentRecorderAttribute.cs create mode 100644 ActionRecorder/ComponentRecorderBase.cs create mode 100644 ActionRecorder/ComponentRecorderInfo.cs create mode 100644 ActionRecorder/FrequencyKind.cs create mode 100644 ActionRecorder/HelperExtensions.cs create mode 100644 ActionRecorder/ObjectRecorder.cs create mode 100644 ActionRecorder/Recorders/TransformRecorder.cs create mode 100644 ActionRecorder/RecordingContainer.cs create mode 100644 ActionRecorder/RecordingInstantBase.cs diff --git a/ActionRecorder.sln b/ActionRecorder.sln index f3b1836..58e00b8 100644 --- a/ActionRecorder.sln +++ b/ActionRecorder.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36212.18 d17.14 +VisualStudioVersion = 17.14.36212.18 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionRecorder", "ActionRecorder\ActionRecorder.csproj", "{ADBD19FD-F099-4422-A96B-D2D3A025C31B}" EndProject diff --git a/ActionRecorder/ActionManager.cs b/ActionRecorder/ActionManager.cs new file mode 100644 index 0000000..db9f86e --- /dev/null +++ b/ActionRecorder/ActionManager.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Xml; +using UnityEngine; +using DisplayNameAttribute = System.ComponentModel.DisplayNameAttribute; + +namespace ActionRecorder +{ + public static class ActionManager + { + private static readonly List recorderTypes; + private static readonly List activeRecorders; + + private static readonly Dictionary registeredIds; + private static readonly Dictionary<(string id, string recordname), RecordingContainer> recordingData; + + static ActionManager() + { + Assembly[] toSearch = new Assembly[] + { + Assembly.GetEntryAssembly(), + Assembly.GetExecutingAssembly() + }; + + recorderTypes = new List(); + foreach (Assembly asm in toSearch) + { + if (asm is null) continue; + foreach (Type t in asm.GetTypes()) + { + if (recorderTypes.Any(x => x.RecorderType == t)) continue; + + ComponentRecorderAttribute att = t.GetCustomAttribute(); + if (att is null || !t.HasBaseType()) continue; + + // Try to get the instant name. If there isn't one, use the name + // of the recorder, minus the word "recorder" + string instantName; + DisplayNameAttribute instantNameAtt = att.instantType.GetCustomAttribute(); + if (instantNameAtt is null) + { + instantName = t.Name.Replace("Recorder", ""); + } + else instantName = instantNameAtt.DisplayName; + + recorderTypes.Add(new ComponentRecorderInfo() + { + ComponentType = att.componentType, + RecorderType = t, + InstantType = att.instantType, + InstantDisplayName = instantName + }); + } + } + + activeRecorders = new List(); + registeredIds = new Dictionary(); + recordingData = new Dictionary<(string, string), RecordingContainer>(); + } + + public static ComponentRecorderInfo GetRecorderInfo(Type recorderType) + { + return recorderTypes.SingleOrDefault(x => x.RecorderType == recorderType); + } + + public static bool IdTaken(string id) => registeredIds.ContainsKey(id); + public static void RegisterController(ObjectRecorder recorder) + { + if (registeredIds.ContainsKey(recorder.Id)) + { + (ObjectRecorder, object) current = registeredIds[recorder.Id]; + current.Item1 = recorder; + registeredIds[recorder.Id] = current; + } + else registeredIds.Add(recorder.Id, (recorder, null)); + + recorder.containers = new RecordingContainer[recorder.ComponentsToRecord.Length]; + for (int i = 0; i < recorder.containers.Length; i++) + { + ComponentRecorderInfo info = GetRecorderInfo(recorder.recorders[i].GetType()); + if (!recordingData.TryGetValue((recorder.Id, info.InstantDisplayName), out RecordingContainer data)) + { + data = new RecordingContainer(info.InstantDisplayName); + recordingData.Add((recorder.Id, info.InstantDisplayName), data); + } + recorder.containers[i] = data; + } + } + + public static bool HasRecorderTypeFor(Component component) + { + return recorderTypes.Any(x => x.ComponentType == component.GetType()); + } + public static ComponentRecorderBase GetRecorderFor(string id, Component component) + { + Type comType = component.GetType(); + + // First check if a recorder with this ID already exists. + ComponentRecorderBase result = activeRecorders.SingleOrDefault(x => + x.Controller.Id == id && + GetComponentTypeOfRecorder(x) == comType); + if (result != null) return result; + + // Otherwise make a new instance. + ComponentRecorderInfo recInfo = recorderTypes.SingleOrDefault(x => x.ComponentType == comType); + if (recInfo is null) return null; + result = (ComponentRecorderBase)Activator.CreateInstance(recInfo.RecorderType); + activeRecorders.Add(result); + + return result; + } + + public static Stream GetRecordingAsBinary(string id) + { + MemoryStream ms = new MemoryStream(); + BinaryWriter writer = new BinaryWriter(ms, Encoding.UTF8, true); + WriteRecordingAsBinary(id, writer); + writer.Close(); + return ms; + } + public static void WriteRecordingAsBinary(string id, BinaryWriter writer) + { + writer.Write(id); + IEnumerable data = from c in recordingData + where c.Key.id == id + select c.Value; + foreach (RecordingContainer container in data) + { + writer.Write(container.DisplayName); + container.EncodeBinary(writer); + } + } + public static string GetRecordingAsXml(string id, bool indent = true) + { + StringBuilder result = new StringBuilder(); + XmlWriterSettings settings = new XmlWriterSettings(); + if (indent) + { + settings.Indent = true; + settings.IndentChars = " "; + } + XmlWriter writer = XmlWriter.Create(result, settings); + WriteRecordingAsXml(id, writer); + writer.Close(); + return result.ToString(); + } + public static void WriteRecordingAsXml(string id, XmlWriter writer) + { + writer.WriteStartElement("Object"); + writer.WriteAttributeString("Identifier", id); + IEnumerable data = from c in recordingData + where c.Key.id == id + select c.Value; + foreach (RecordingContainer container in data) + { + writer.WriteStartElement("Component"); + writer.WriteAttributeString("Identifier", container.DisplayName); + container.EncodeXml(writer); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + + internal static Type GetComponentTypeOfRecorder(ComponentRecorderBase instance) => + recorderTypes.Single(x => x.RecorderType == instance.GetType()).ComponentType; + } +} diff --git a/ActionRecorder/ActionRecorder.csproj b/ActionRecorder/ActionRecorder.csproj index d97526b..fc44079 100644 --- a/ActionRecorder/ActionRecorder.csproj +++ b/ActionRecorder/ActionRecorder.csproj @@ -4,6 +4,14 @@ netstandard2.0 + + embedded + + + + none + + UnityEngine.dll diff --git a/ActionRecorder/Class1.cs b/ActionRecorder/Class1.cs deleted file mode 100644 index b27bb89..0000000 --- a/ActionRecorder/Class1.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace ActionRecorder -{ - public class Class1 - { - - } -} diff --git a/ActionRecorder/ComponentRecorderAttribute.cs b/ActionRecorder/ComponentRecorderAttribute.cs new file mode 100644 index 0000000..81e3591 --- /dev/null +++ b/ActionRecorder/ComponentRecorderAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace ActionRecorder +{ + [AttributeUsage(AttributeTargets.Class)] + public class ComponentRecorderAttribute : Attribute + { + public readonly Type componentType; + public readonly Type instantType; + + public ComponentRecorderAttribute(Type componentType, Type instantType) + { + this.componentType = componentType; + this.instantType = instantType; + } + } +} diff --git a/ActionRecorder/ComponentRecorderBase.cs b/ActionRecorder/ComponentRecorderBase.cs new file mode 100644 index 0000000..d879ba2 --- /dev/null +++ b/ActionRecorder/ComponentRecorderBase.cs @@ -0,0 +1,24 @@ +using System; +using UnityEngine; + +namespace ActionRecorder +{ + public abstract class ComponentRecorderBase + { + public ObjectRecorder Controller { get; internal set; } + public Type ComponentType => ActionManager.GetComponentTypeOfRecorder(this); + + public Component Component { get; internal set; } + + public abstract FrequencyKind RecordFrequency { get; } + + public virtual void Awake() { } + public virtual void Start() { } + + public virtual void OnBeginRecording() { } + public abstract RecordingInstantBase RecordInstant(); + public virtual void OnEndRecording() { } + + // TODO: Return a type for containing recordings. + } +} diff --git a/ActionRecorder/ComponentRecorderInfo.cs b/ActionRecorder/ComponentRecorderInfo.cs new file mode 100644 index 0000000..4f63c1e --- /dev/null +++ b/ActionRecorder/ComponentRecorderInfo.cs @@ -0,0 +1,14 @@ +using System; + +namespace ActionRecorder +{ + public class ComponentRecorderInfo + { + public Type ComponentType { get; internal set; } + public Type RecorderType { get; internal set; } + public Type InstantType { get; internal set; } + public string InstantDisplayName { get; internal set; } + + internal ComponentRecorderInfo() { } + } +} diff --git a/ActionRecorder/FrequencyKind.cs b/ActionRecorder/FrequencyKind.cs new file mode 100644 index 0000000..dee9ca5 --- /dev/null +++ b/ActionRecorder/FrequencyKind.cs @@ -0,0 +1,9 @@ +namespace ActionRecorder +{ + public enum FrequencyKind + { + Manual = 0, + Update = 1, + FixedUpdate = 2 + } +} diff --git a/ActionRecorder/HelperExtensions.cs b/ActionRecorder/HelperExtensions.cs new file mode 100644 index 0000000..c1c39e4 --- /dev/null +++ b/ActionRecorder/HelperExtensions.cs @@ -0,0 +1,19 @@ +using System; + +namespace ActionRecorder +{ + internal static class HelperExtensions + { + public static bool HasBaseType(this Type type, Type baseType) + { + Type activeType = type.BaseType; + while (activeType != null) + { + if (activeType == baseType) return true; + else activeType = activeType.BaseType; + } + return false; + } + public static bool HasBaseType(this Type type) => HasBaseType(type, typeof(T)); + } +} diff --git a/ActionRecorder/ObjectRecorder.cs b/ActionRecorder/ObjectRecorder.cs new file mode 100644 index 0000000..7660852 --- /dev/null +++ b/ActionRecorder/ObjectRecorder.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; +using UnityEngine; + +namespace ActionRecorder +{ + public class ObjectRecorder : MonoBehaviour + { + public bool AutoRecord; + public string Id; + public Component[] ComponentsToRecord; + + public bool Recording { get; private set; } + + internal ComponentRecorderBase[] recorders; + internal RecordingContainer[] containers; + + private void Awake() + { + if (string.IsNullOrEmpty(Id)) + { + Debug.LogWarning("You should always give your recorders a unique identifier!"); + Id = GetRandomId(); + } + else if (ActionManager.IdTaken(Id)) + { + Debug.LogWarning($"A recorder with the ID \"{Id}\" already exists! Please choose a different unique identifier."); + Id = GetRandomId(); + } + + // Go through the list of components to record, ensure they are + // components belonging to this object, and initialize recorders + // for those components. + + List valid = new List(); + foreach (Component c in ComponentsToRecord) + { + if (c is null) continue; + if (c.gameObject != gameObject) + { + Debug.LogWarning($"The component {c} does not belong to this game object and thus cannot be recorded!"); + continue; + } + else if (!ActionManager.HasRecorderTypeFor(c)) + { + Debug.LogError($"No recognized recorder type for {c}! This component cannot be recorded."); + continue; + } + valid.Add(c); + } + ComponentsToRecord = valid.ToArray(); + + recorders = new ComponentRecorderBase[ComponentsToRecord.Length]; + for (int i = 0; i < recorders.Length; i++) + { + ComponentRecorderBase rec = ActionManager.GetRecorderFor(Id, ComponentsToRecord[i]); + rec.Controller = this; + rec.Component = ComponentsToRecord[i]; + recorders[i] = rec; + } + ActionManager.RegisterController(this); + + OnAwake(); + for (int i = 0; i < recorders.Length; i++) recorders[i].Awake(); + } + private void Start() + { + if (AutoRecord) RecordStart(); + + OnStart(); + for (int i = 0; i < recorders.Length; i++) recorders[i].Start(); + } + + protected virtual void OnAwake() { } + protected virtual void OnStart() { } + protected virtual void OnUpdate() { } + protected virtual void OnFixedUpdate() { } + protected virtual void BeginRecording() { } + protected virtual void EndRecording() { } + public event Action OnBeginRecording = delegate { }; + public event Action OnEndRecording = delegate { }; + + private void Update() + { + OnUpdate(); + if (!Recording) return; + + for (int i = 0; i < recorders.Length; i++) + { + ComponentRecorderBase rec = recorders[i]; + if (rec.RecordFrequency != FrequencyKind.Update) continue; + RecordingInstantBase instant = rec.RecordInstant(); + containers[i].Add(instant); + } + } + private void FixedUpdate() + { + OnFixedUpdate(); + if (!Recording) return; + + for (int i = 0; i < recorders.Length; i++) + { + ComponentRecorderBase rec = recorders[i]; + if (rec.RecordFrequency != FrequencyKind.FixedUpdate) continue; + RecordingInstantBase instant = rec.RecordInstant(); + containers[i].Add(instant); + } + } + + public void RecordStart() + { + if (Recording) return; + Recording = true; + BeginRecording(); + OnBeginRecording(); + for (int i = 0; i < recorders.Length; i++) recorders[i].OnBeginRecording(); + } + public void RecordStop() + { + if (!Recording) return; + Recording = false; + EndRecording(); + OnEndRecording(); + for (int i = 0; i < recorders.Length; i++) recorders[i].OnEndRecording(); + } + + private static string GetRandomId() + { + StringBuilder id = new StringBuilder("CHANGEME-"); + System.Random rand = new System.Random(); + for (int i = 0; i < 8; i++) id.Append((char)('A' + rand.Next(26))); + return id.ToString(); + } + } +} diff --git a/ActionRecorder/Recorders/TransformRecorder.cs b/ActionRecorder/Recorders/TransformRecorder.cs new file mode 100644 index 0000000..a83f5d6 --- /dev/null +++ b/ActionRecorder/Recorders/TransformRecorder.cs @@ -0,0 +1,48 @@ +using System.ComponentModel; +using System.IO; +using System.Xml; +using UnityEngine; + +namespace ActionRecorder.Recorders +{ + [ComponentRecorder(typeof(Transform), typeof(TransformInstant))] + public class TransformRecorder : ComponentRecorderBase + { + public override FrequencyKind RecordFrequency { get; } = FrequencyKind.Update; + + new public Transform Component => (Transform)base.Component; + + public override RecordingInstantBase RecordInstant() => new TransformInstant() + { + Time = Time.time, + localPosition = Component.localPosition, + localRotation = Component.localRotation.eulerAngles, + localScale = Component.localScale + }; + + [DisplayName("Transform")] + public class TransformInstant : RecordingInstantBase + { + public Vector3 localPosition, localRotation, localScale; + + public override void EncodeBinary(BinaryWriter writer) + { + writer.Write(localPosition.x); + writer.Write(localPosition.y); + writer.Write(localPosition.z); + writer.Write(localRotation.x); + writer.Write(localRotation.y); + writer.Write(localRotation.z); + writer.Write(localScale.x); + writer.Write(localScale.y); + writer.Write(localScale.z); + } + public override void EncodeXml(XmlWriter writer) + { + WriteProperty(writer, nameof(localPosition), localPosition); + WriteProperty(writer, nameof(localRotation), localRotation); + WriteProperty(writer, nameof(localScale), localScale); + } + } + } +} diff --git a/ActionRecorder/RecordingContainer.cs b/ActionRecorder/RecordingContainer.cs new file mode 100644 index 0000000..9a15b09 --- /dev/null +++ b/ActionRecorder/RecordingContainer.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.IO; +using System.Xml; + +namespace ActionRecorder +{ + public class RecordingContainer + { + public string DisplayName { get; } + + // TODO: Replace this with a more sophisticated method. + // Allocate using powers of 2, implement IList, etc. + public readonly List instants; + + internal RecordingContainer(string name) + { + DisplayName = name; + instants = new List(); + } + + public void Add(RecordingInstantBase instant) + { + instants.Add(instant); + } + + public void EncodeBinary(BinaryWriter writer) + { + foreach (RecordingInstantBase instant in instants) + { + writer.Write(instant.Time); + instant.EncodeBinary(writer); + } + } + public void EncodeXml(XmlWriter writer) + { + foreach (RecordingInstantBase instant in instants) + { + writer.WriteStartElement("Instant"); + writer.WriteAttributeString("Time", instant.Time.ToString()); + instant.EncodeXml(writer); + writer.WriteEndElement(); + } + } + } +} diff --git a/ActionRecorder/RecordingInstantBase.cs b/ActionRecorder/RecordingInstantBase.cs new file mode 100644 index 0000000..5721b33 --- /dev/null +++ b/ActionRecorder/RecordingInstantBase.cs @@ -0,0 +1,55 @@ +using System.IO; +using System.Xml; +using UnityEngine; + +namespace ActionRecorder +{ + public abstract class RecordingInstantBase + { + public float Time { get; set; } + + public abstract void EncodeBinary(BinaryWriter writer); + public abstract void EncodeXml(XmlWriter writer); + + protected void WriteProperty(XmlWriter writer, string name, object value) + { + writer.WriteStartElement("Property"); + writer.WriteAttributeString("Name", name); + writer.WriteValue(value?.ToString() ?? ""); + writer.WriteEndElement(); + } + protected void WriteProperty(XmlWriter writer, string name, Vector2 value) + { + writer.WriteStartElement("Property"); + writer.WriteAttributeString("Name", name); + + WriteProperty(writer, "x", value.x); + WriteProperty(writer, "y", value.y); + + writer.WriteEndElement(); + } + protected void WriteProperty(XmlWriter writer, string name, Vector3 value) + { + writer.WriteStartElement("Property"); + writer.WriteAttributeString("Name", name); + + WriteProperty(writer, "x", value.x); + WriteProperty(writer, "y", value.y); + WriteProperty(writer, "z", value.z); + + writer.WriteEndElement(); + } + protected void WriteProperty(XmlWriter writer, string name, Vector4 value) + { + writer.WriteStartElement("Property"); + writer.WriteAttributeString("Name", name); + + WriteProperty(writer, "x", value.x); + WriteProperty(writer, "y", value.y); + WriteProperty(writer, "z", value.z); + WriteProperty(writer, "w", value.w); + + writer.WriteEndElement(); + } + } +}