using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Xml.Linq; using Barotrauma.Extensions; using Barotrauma.Items.Components; using Microsoft.Xna.Framework; namespace Barotrauma { internal readonly struct InventorySlotItem { public readonly int Slot; public readonly Item Item; public InventorySlotItem(int slot, Item item) { Slot = slot; Item = item; } public void Deconstruct(out int slot, out Item item) { slot = Slot; item = Item; } } internal abstract partial class Command { public abstract LocalizedString GetDescription(); } /// /// A command for setting and reverting a MapEntity rectangle /// /// /// internal class TransformCommand : Command { private readonly List Receivers; private readonly List NewData; private readonly List OldData; private readonly bool Resized; /// /// A command for setting and reverting a MapEntity rectangle /// /// Entities whose rectangle has been altered /// The new rectangle that is or will be applied to the map entity /// Old rectangle the map entity had before /// If the transform was resized or not /// /// All lists should be equal in length, for every receiver there should be a corresponding entry at the same position in newData and oldData. /// public TransformCommand(List receivers, List newData, List oldData, bool resized) { Receivers = receivers; NewData = newData; OldData = oldData; Resized = resized; } public override void Execute() => SetRects(NewData); public override void UnExecute() => SetRects(OldData); public override void Cleanup() { NewData.Clear(); OldData.Clear(); Receivers.Clear(); } private void SetRects(IReadOnlyList rects) { if (Receivers.Count != rects.Count) { DebugConsole.ThrowError($"Receivers.Count did not match Rects.Count ({Receivers.Count} vs {rects.Count})."); return; } for (int i = 0; i < rects.Count; i++) { MapEntity entity = Receivers[i].GetReplacementOrThis(); Rectangle Rect = rects[i]; Vector2 diff = Rect.Location.ToVector2() - entity.Rect.Location.ToVector2(); entity.Move(diff); entity.Rect = Rect; } } public override LocalizedString GetDescription() { if (Resized) { return TextManager.GetWithVariable("Undo.ResizedItem", "[item]", Receivers.FirstOrDefault()?.Name); } return Receivers.Count > 1 ? TextManager.GetWithVariable("Undo.MovedItemsMultiple", "[count]", Receivers.Count.ToString()) : TextManager.GetWithVariable("Undo.MovedItem", "[item]", Receivers.FirstOrDefault()?.Name); } } /// /// A command that removes and unremoves map entities /// /// /// /// internal class AddOrDeleteCommand : Command { private readonly Dictionary PreviousInventories = new Dictionary(); public readonly List Receivers; private readonly List CloneList; private readonly bool WasDeleted; private readonly List ContainedItemsCommand = new List(); // We need to 'snapshot' the state of the circuit box and the best way to do that is to save it to XML. private readonly List CircuitBoxData = new List(); /// /// Creates a command where all entities share the same state. /// /// Entities that were deleted or added /// Whether or not all entities are or are going to be deleted /// Ignore item inventories when set to false, workaround for pasting public AddOrDeleteCommand(List receivers, bool wasDeleted, bool handleInventoryBehavior = true) { Debug.Assert(receivers.Count > 0, "Command has 0 receivers"); WasDeleted = wasDeleted; Receivers = new List(receivers); try { foreach (MapEntity receiver in receivers) { if (receiver is Item { ParentInventory: not null } it) { PreviousInventories.Add(new InventorySlotItem(it.ParentInventory.FindIndex(it), it), it.ParentInventory); } } List clonedTargets = MapEntity.Clone(receivers); List itemsToDelete = new List(); foreach (MapEntity receiver in Receivers) { if (receiver is not Item it) { continue; } foreach (var cb in it.GetComponents()) { CircuitBoxData.Add(cb.Save(new XElement("root"))); } foreach (ItemContainer component in it.GetComponents()) { if (component.Inventory == null) { continue; } itemsToDelete.AddRange(component.Inventory.AllItems.Where(static item => !item.Removed)); } } if (itemsToDelete.Any() && handleInventoryBehavior) { ContainedItemsCommand.Add(new AddOrDeleteCommand(itemsToDelete, wasDeleted)); if (wasDeleted) { foreach (MapEntity item in itemsToDelete) { if (item != null && !item.Removed) { item.Remove(); } } } } foreach (MapEntity clone in clonedTargets) { clone.ShallowRemove(); if (clone is Item it) { foreach (ItemContainer container in it.GetComponents()) { container.Inventory?.DeleteAllItems(); } } } CloneList = clonedTargets; } // This should never happen except if we decide to make a new type of MapEntity that isn't finished yet catch (Exception e) { Receivers = new List(); CloneList = new List(); DebugConsole.ThrowError("Could not store object", e); } } public override void Execute() => Process(true); public override void UnExecute() => Process(false); private void Process(bool redo) { var items = DeleteUndelete(redo); foreach (var cmd in ContainedItemsCommand) { cmd.Process(redo); } ApplyCircuitBoxDataIfAny(items); } /// /// We need to manually copy over the circuit box data because of how the undo handles inventory items. /// The undo system recursively deletes inventory items and creates a separate command for each one. /// This causes the circuit box to lose its internal inventory when it's cloned and then restored and make it /// unable to load the state from XML. /// /// The workaround to this is to ignore the XML that is being loaded when the item is created and instead /// save the XML into the command and then load it back after the undo system has restored the items which /// is what this function does. /// private void ApplyCircuitBoxDataIfAny(ImmutableArray items) { int cbIndex = 0; foreach (var newItem in items) { foreach (ItemComponent component in newItem.Components) { if (component is not CircuitBox cb) { continue; } if (cbIndex < 0 || cbIndex >= CircuitBoxData.Count) { DebugConsole.ThrowError("Unable to restore wiring in circuit box, index out of range."); continue; } var cbData = CircuitBoxData[cbIndex]; cbIndex++; cb.LoadFromXML(new ContentXElement(null, cbData)); } } } public override void Cleanup() { foreach (MapEntity entity in CloneList) { if (!entity.Removed) { entity.Remove(); } } CloneList?.Clear(); Receivers.Clear(); PreviousInventories?.Clear(); ContainedItemsCommand?.ForEach(static cmd => cmd.Cleanup()); CircuitBoxData.Clear(); } private ImmutableArray DeleteUndelete(bool redo) { bool wasDeleted = WasDeleted; // We are redoing instead of undoing, flip the behavior if (redo) { wasDeleted = !wasDeleted; } // collect newly created items so we can update their circuit boxes if any var builder = ImmutableArray.CreateBuilder(); if (wasDeleted) { Debug.Assert(Receivers.All(static entity => entity.GetReplacementOrThis().Removed), "Tried to redo a deletion but some items were not deleted"); List clones = MapEntity.Clone(CloneList); int length = Math.Min(Receivers.Count, clones.Count); for (int i = 0; i < length; i++) { MapEntity clone = clones[i], receiver = Receivers[i]; if (receiver.GetReplacementOrThis() is Item item && clone is Item cloneItem) { builder.Add(cloneItem); foreach (ItemComponent ic in item.Components) { int index = item.GetComponentIndex(ic); ItemComponent component = cloneItem.Components.ElementAtOrDefault(index); switch (component) { case null: continue; case ItemContainer { Inventory: not null } newContainer when ic is ItemContainer { Inventory: not null } itemContainer: itemContainer.Inventory.GetReplacementOrThis().ReplacedBy = newContainer.Inventory; goto default; default: ic.GetReplacementOrThis().ReplacedBy = component; break; } } } receiver.GetReplacementOrThis().ReplacedBy = clone; } for (int i = 0; i < length; i++) { MapEntity clone = clones[i], receiver = Receivers[i]; if (clone is Item it) { foreach (var (slotRef, inventory) in PreviousInventories) { if (slotRef.Item == receiver) { inventory.GetReplacementOrThis().TryPutItem(it, slotRef.Slot, false, false, null, createNetworkEvent: false); } } } } foreach (MapEntity clone in clones) { clone.Submarine = Submarine.MainSub; } return builder.ToImmutable(); } else { foreach (MapEntity t in Receivers) { MapEntity receiver = t.GetReplacementOrThis(); if (!receiver.Removed) { receiver.Remove(); } } return builder.ToImmutable(); } } public void MergeInto(AddOrDeleteCommand master) { master.Receivers.AddRange(Receivers); master.CloneList.AddRange(CloneList); master.ContainedItemsCommand.AddRange(ContainedItemsCommand); foreach (var (slot, item) in PreviousInventories) { master.PreviousInventories.Add(slot, item); } } public override LocalizedString GetDescription() { if (WasDeleted) { return Receivers.Count > 1 ? TextManager.GetWithVariable("Undo.RemovedItemsMultiple", "[count]", Receivers.Count.ToString()) : TextManager.GetWithVariable("Undo.RemovedItem", "[item]", Receivers.FirstOrDefault()?.Name ?? "null"); } return Receivers.Count > 1 ? TextManager.GetWithVariable("Undo.AddedItemsMultiple", "[count]", Receivers.Count.ToString()) : TextManager.GetWithVariable("Undo.AddedItem", "[item]", Receivers.FirstOrDefault()?.Name ?? "null"); } } /// /// A command that places or drops items out of inventories /// /// /// internal class InventoryPlaceCommand : Command { private readonly Inventory Inventory; private readonly List Receivers; private readonly bool wasDropped; public InventoryPlaceCommand(Inventory inventory, List items, bool dropped) { Inventory = inventory; Receivers = items.Select(item => new InventorySlotItem(inventory.FindIndex(item), item)).ToList(); wasDropped = dropped; } public override void Execute() => ContainUncontain(false); public override void UnExecute() => ContainUncontain(true); public override void Cleanup() { Receivers.Clear(); } private void ContainUncontain(bool drop) { // flip the behavior if the item was dropped instead of inserted if (wasDropped) { drop = !drop; } foreach (var (slot, receiver) in Receivers) { Item item = (Item) receiver.GetReplacementOrThis(); if (drop) { item.Drop(null, createNetworkEvent: false); } else { Inventory.GetReplacementOrThis().TryPutItem(item, slot, false, false, null, createNetworkEvent: false); } } } public override LocalizedString GetDescription() { if (wasDropped) { return TextManager.GetWithVariable("Undo.DroppedItem", "[item]", Receivers.FirstOrDefault().Item.Name); } string container = "[ERROR]"; if (Inventory.Owner is Item item) { container = item.Name; } return Receivers.Count > 1 ? TextManager.GetWithVariables("Undo.ContainedItemsMultiple", ("[count]", Receivers.Count.ToString()), ("[container]", container)) : TextManager.GetWithVariables("Undo.ContainedItem", ("[item]", Receivers.FirstOrDefault().Item.Name), ("[container]", container)); } } /// /// A command that sets item properties /// internal class PropertyCommand : Command { private Dictionary> OldProperties; private readonly List Receivers; private readonly Identifier PropertyName; private readonly object NewProperties; private string sanitizedProperty; public readonly int PropertyCount; /// /// A command that sets item properties /// /// Affected entities /// Real property name, not all lowercase /// /// public PropertyCommand(List receivers, Identifier propertyName, object newData, Dictionary> oldData) { Receivers = receivers; PropertyName = propertyName; OldProperties = oldData; NewProperties = newData; PropertyCount = receivers.Count; SanitizeProperty(); } public PropertyCommand(ISerializableEntity receiver, Identifier propertyName, object newData, object oldData) { Receivers = new List { receiver }; PropertyName = propertyName; OldProperties = new Dictionary> { { oldData, Receivers } }; NewProperties = newData; PropertyCount = 1; SanitizeProperty(); } public bool MergeInto(PropertyCommand master) { if (!master.Receivers.SequenceEqual(Receivers)) { return false; } master.OldProperties = OldProperties; return true; } private void SanitizeProperty() { sanitizedProperty = NewProperties switch { float f => f.FormatSingleDecimal(), Point point => XMLExtensions.PointToString(point), Vector2 vector2 => vector2.FormatZeroDecimal(), Vector3 vector3 => vector3.FormatSingleDecimal(), Vector4 vector4 => vector4.FormatSingleDecimal(), Color color => XMLExtensions.ColorToString(color), Rectangle rectangle => XMLExtensions.RectToString(rectangle), _ => NewProperties.ToString() }; } public override void Execute() => SetProperties(false); public override void UnExecute() => SetProperties(true); public override void Cleanup() { Receivers.Clear(); OldProperties.Clear(); } private void SetProperties(bool undo) { foreach (ISerializableEntity t in Receivers) { ISerializableEntity receiver; switch (t) { case MapEntity me when me.GetReplacementOrThis() is ISerializableEntity sEntity: receiver = sEntity; break; case ItemComponent ic when ic.GetReplacementOrThis() is ISerializableEntity sItemComponent: receiver = sItemComponent; break; default: receiver = t; break; } object data = NewProperties; if (undo) { foreach (var (key, value) in OldProperties) { if (value.Contains(t)) { data = key; } } } if (receiver.SerializableProperties != null) { Dictionary props = receiver.SerializableProperties; if (props.TryGetValue(PropertyName, out SerializableProperty prop)) { prop.TrySetValue(receiver, data); // Update the editing hud if (MapEntity.EditingHUD == null || (MapEntity.EditingHUD.UserData != receiver && (receiver is ItemComponent ic && MapEntity.EditingHUD.UserData != ic.Item))) { continue; } GUIListBox list = MapEntity.EditingHUD.GetChild(); if (list == null) { continue; } IEnumerable editors = list.Content.FindChildren(comp => comp is SerializableEntityEditor).Cast(); SerializableEntityEditor.LockEditing = true; foreach (SerializableEntityEditor editor in editors) { if (editor.UserData == receiver && editor.Fields.TryGetValue(PropertyName, out GUIComponent[] _)) { editor.UpdateValue(prop, data); } } SerializableEntityEditor.LockEditing = false; } } } } public override LocalizedString GetDescription() { return Receivers.Count > 1 ? TextManager.GetWithVariables("Undo.ChangedPropertyMultiple", ("[property]", PropertyName.Value), ("[count]", Receivers.Count.ToString()), ("[value]", sanitizedProperty)) : TextManager.GetWithVariables("Undo.ChangedProperty", ("[property]", PropertyName.Value), ("[item]", Receivers.FirstOrDefault()?.Name), ("[value]", sanitizedProperty)); } } /// /// A command that moves items around in inventories /// /// /// internal class InventoryMoveCommand : Command { private readonly Inventory oldInventory; private readonly Inventory newInventory; private readonly int oldSlot; private readonly int newSlot; private readonly Item targetItem; public InventoryMoveCommand(Inventory oldInventory, Inventory newInventory, Item item, int oldSlot, int newSlot) { this.newInventory = newInventory; this.oldInventory = oldInventory; this.oldSlot = oldSlot; this.newSlot = newSlot; targetItem = item; } public override void Execute() { if (targetItem.GetReplacementOrThis() is Item item) { newInventory?.GetReplacementOrThis().TryPutItem(item, newSlot, true, false, null, createNetworkEvent: false); } } public override void UnExecute() { if (targetItem.GetReplacementOrThis() is Item item) { oldInventory?.GetReplacementOrThis().TryPutItem(item, oldSlot, true, false, null, createNetworkEvent: false); } } public override void Cleanup() { } public override LocalizedString GetDescription() { return TextManager.GetWithVariable("Undo.MovedItem", "[item]", targetItem.Name); } } /// /// A command for applying changes from the . /// internal class TransformToolCommand : Command { private readonly Dictionary originalData; public float? ScaleMult, RotationRad; public readonly Vector2 Pivot; private readonly Vector2 wirePivot; public float MinScale, MaxScale; public TransformToolCommand(Dictionary data, Vector2 pivot) { originalData = data; Pivot = pivot; wirePivot = Pivot - Submarine.MainSub.HiddenSubPosition; MinScale = 0.01f / Math.Max(data.Values.Min(data => data.Scale), 0.01f); MaxScale = 10f / Math.Min(data.Values.Max(data => data.Scale), 10f); } public override void Execute() => UpdateTransforms(RotationRad ?? 0f, ScaleMult ?? 1f); public override void UnExecute() => UpdateTransforms(0f, 1f); public override void Cleanup() => originalData.Clear(); private void UpdateTransforms(float rotationRad, float scaleMult) { foreach ((MapEntity receiver, SubEditorScreen.TransformData data) in originalData) { if (RotationRad.HasValue && receiver is Item { Prefab.AllowRotatingInEditor: true } or Structure { Prefab.AllowRotatingInEditor: true }) { int rotationDir = receiver is Structure && receiver.FlippedX ^ receiver.FlippedY ? -1 : 1; float newRotation = MathHelper.ToDegrees(data.RotationRad + rotationRad * rotationDir); switch (receiver) { case Item item: item.Rotation = newRotation; break; case Structure structure: structure.Rotation = newRotation; break; } data.TurretLimits?.ForEach(pair => pair.Key.RotationLimits = pair.Value + new Vector2(MathHelper.ToDegrees(rotationRad))); } if (ScaleMult.HasValue) { receiver.Scale = data.Scale * scaleMult; if (receiver.ResizeVertical || receiver.ResizeHorizontal) { if (receiver.ResizeVertical) { receiver.RectHeight = (int)(data.Rect.Height * scaleMult); } if (receiver.ResizeHorizontal) { receiver.RectWidth = (int)(data.Rect.Width * scaleMult); } if (receiver is Structure structure && data.TexOffset.HasValue) { structure.TextureOffset = data.TexOffset.Value * scaleMult; } } data.TextScales?.ForEach(pair => pair.Key.TextScale = pair.Value * scaleMult); data.LightRanges?.ForEach(pair => pair.Key.Range = pair.Value * scaleMult); data.Wires?.ForEach(pair => pair.Key.Width = pair.Value.Width * scaleMult); } Vector2 newEntityPos = MathUtils.RotatePoint((data.Pos - Pivot) * scaleMult, -rotationRad) + Pivot; receiver.Move(newEntityPos - receiver.DrawPosition); data.Wires?.ForEach(pair => pair.Key.SetNodes(pair.Value.Nodes.Select(TransformWireNode))); Vector2 TransformWireNode(Vector2 node) => MathUtils.RotatePoint((node - wirePivot) * scaleMult, -rotationRad) + wirePivot; } } public override LocalizedString GetDescription() => originalData.Count > 1 ? TextManager.GetWithVariable("Undo.ChangedTransformMultiple", "[amount]", originalData.Count.ToString()) : TextManager.GetWithVariable("Undo.ChangedTransform", "[item]", originalData.First().Key.Name); } }