-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor to command pattern for change handling #30255
base: master
Are you sure you want to change the base?
Conversation
…end/osu into command-pattern-real-2
Implement variant type generic proxies without heap allocations
…ature/command-handler # Conflicts: # osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs # osu.Game.Rulesets.Osu/Edit/Commands/OsuHitObjectCommandProxy.cs # osu.Game/Screens/Edit/Commands/SetPositionCommand.cs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A customary look trough the code for theoretical correctness. I don't actually know how the editor works & I've not tested this code.
As I see it, doing [action, undo, redo] will likely break things on complex actions.
{ | ||
public static class ListCommands | ||
{ | ||
public class Update<T> : PropertyChangeCommand<IList<T>, T> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can't be a PropertyChangeCommand
because the implementation of MergeWithPrevious()
is incorrect.
Example:
IList<T> list;
T first;
T second;
var command1 = new Update(list, 1, first);
var command2 = new Update(list, 2, second);
var merged = command2.MergeWithPrevious(command1);
merged
is command2
, which is incorrect as it'll never do list[1] = first
.
public bool MergeWithPrevious(IEditorCommand previousCommand, [MaybeNullWhen(false)] out IEditorCommand merged) | ||
{ | ||
// Updates are debounced so we only need one update command in a transaction. | ||
if (previousCommand is UpdateHitObjectCommand previousUpdateCommand && previousUpdateCommand.HitObject == HitObject) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this check for Beatmap
as well?
HitObject.SliderVelocityMultiplier = proposedVelocity; | ||
HitObject.Path.ExpectedDistance.Value = proposedDistance; | ||
editorBeatmap?.Update(HitObject); | ||
Proxy.SetSliderVelocityMultiplier(proposedVelocity); | ||
Proxy.Path().SetExpectedDistance(proposedDistance); | ||
editorBeatmap?.AsCommandProxy(commandHandler).Update(HitObject); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure how editorBeatmap.Update(HitObject)
works, but isn't this kinda wrong when undoed?
What the undo does:
editorBeatmap?.Update(HitObject);
HitObject.Path.ExpectedDistance.Value = previousDistance;
HitObject.SliderVelocityMultiplier = previousVelocity;
What it should actually do:
HitObject.Path.ExpectedDistance.Value = previousDistance;
HitObject.SliderVelocityMultiplier = previousVelocity;
editorBeatmap?.Update(HitObject);
@@ -456,15 +462,15 @@ private void removeControlPoints(List<PathControlPoint> toRemove) | |||
// The first control point in the slider must have a type, so take it from the previous "first" one | |||
// Todo: Should be handled within SliderPath itself | |||
if (c == controlPoints[0] && controlPoints.Count > 1 && controlPoints[1].Type == null) | |||
controlPoints[1].Type = controlPoints[0].Type; | |||
new CommandProxy<PathControlPoint>(commandHandler, c).SetType(controlPoints[0].Type); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
new CommandProxy<PathControlPoint>(commandHandler, c).SetType(controlPoints[0].Type); | |
new CommandProxy<PathControlPoint>(commandHandler, controlPoints[1]).SetType(controlPoints[0].Type); |
/// <summary> | ||
/// The X component of <see cref="Position"/>. | ||
/// </summary> | ||
public float X => position.X; | ||
|
||
/// <summary> | ||
/// The Y component of <see cref="Position"/>. | ||
/// </summary> | ||
public float Y => position.Y; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Weird how this class implements IHasMutablePosition
, but these are only getters.
{ | ||
if (command is IMergeableCommand mergeable) | ||
{ | ||
for (int i = 0; i < undoCommands.Count; i++) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Searching trough the whole command list for a merge feels wrong. It could lead to subtle ordering bugs.
Example:
This code does:
// Proxy.A() == initial
Proxy.SetA(first);
BeatmapProxy.Add(newSlider);
Proxy.SetA(second);
Equvalent to
HitObject.A = first;
Beatmap.Add(newSlider);
HitObject.A = second;
undoCommands
would be build up as following:
[SetA(initial)]
[Remove(newSlider), SetA(initial)]
[Remove(newSlider), SetA(initial)]
SetA(first)
merged into the previous command
Undo()
ing this would put the following transaction on the redo stack:
[SetA(second), Add(newSlider)]
Which is equivalent to
HitObject.A = second;
Beatmap.Add(newSlider);
Subtly different from the initial code. In a way that might cause weird breakages.
This is all theoretical and completely untested.
|
||
public Transaction Reverse() | ||
{ | ||
var commands = UndoCommands.Reverse().Select(command => command.CreateUndo()).ToList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling CreateUndo()
like this is probably losing information. The xmldoc mentions that it's a delicate operation that takes the current state of the object into account.
I think it's easier to correctly implement undo and redo if IEditorCommand
was structured slightly differently:
public interface IEditorCommand
{
/// Stores necessary information for an undo operation
/// and applies the command to the current state.
/// This also functions as Redo()
void Apply(); // alt-name: Do()
/// Undoes the action done in Apply()/Do()
void Undo();
}
This would also match the "command pattern" with undo in literature.
Also, for redo, you're doing |
This is a MVP command pattern implementation made in collaboration with @minetoblend.
Commands approach
Commands are created for each individual property that can be modified, without code generators and minimal generics. They have hard references to the objects they modify so they can apply the changes without needing additional context or lookup. In the future we might change this in order to allow serialization.
Mergeable commands compress repeated changes within the same transaction. This results in ~1000x reduction of commands in the history for drag based events.
Commands coverage
Missing coverage
Proxies
To make it easier to use commands with minimal boilerplate we designed proxies. The proxy is a wrapper around an object that exposes custom setter methods which generate commands on use. The proxy setters are all defined in extension methods with generics that look at the wrapped type. This design allows for maximum flexibility so you can also use proxies for inexact types like
T : OsuHitObject, IHasPath
and all the expected methods will be there.We went through a few designs for proxies before ending up at this design. Here's the requirements for proxies we came up with:
Migrating in parts
Migrating things across one by one to the new command system is quite problematic. We attempted to combine the old change handler with the new command system so the old change handler can handle undo on all that doesn't have command coverage yet. This does mean that the change handler is saving states even if you do a transaction with commands, because it needs to have the latest state saved in case you want to undo a change without command coverage next.
The problem is that the old change handler does not play nice with the command system, so it's hard to combine the two systems
When the old change handler modifies a hit object during undo, it creates a new object so all the hard links in the commands in the history no longer point to the correct object.
Making commands serializable wouldnt solve this either because the old change handler can not handle IDs because the legacy file format it uses does not support object IDs. Your IDs will be lost on undo and the links will be broken still.
Best we can do is break up the migration by object type: