mirror of
https://github.com/ppy/osu
synced 2025-03-05 02:49:30 +00:00
Merge pull request #8696 from smoogipoo/editor-undo-redo
Add undo/redo support to the Editor
This commit is contained in:
commit
ef5e88dd82
342
osu.Game.Tests/Editor/LegacyEditorBeatmapPatcherTest.cs
Normal file
342
osu.Game.Tests/Editor/LegacyEditorBeatmapPatcherTest.cs
Normal file
@ -0,0 +1,342 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using NUnit.Framework;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Rulesets.Osu;
|
||||
using osu.Game.Rulesets.Osu.Beatmaps;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Screens.Edit;
|
||||
using osuTK;
|
||||
using Decoder = osu.Game.Beatmaps.Formats.Decoder;
|
||||
|
||||
namespace osu.Game.Tests.Editor
|
||||
{
|
||||
[TestFixture]
|
||||
public class LegacyEditorBeatmapPatcherTest
|
||||
{
|
||||
private LegacyEditorBeatmapPatcher patcher;
|
||||
private EditorBeatmap current;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
patcher = new LegacyEditorBeatmapPatcher(current = new EditorBeatmap(new OsuBeatmap
|
||||
{
|
||||
BeatmapInfo =
|
||||
{
|
||||
Ruleset = new OsuRuleset().RulesetInfo
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestAddHitObject()
|
||||
{
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
new HitCircle { StartTime = 1000 }
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestInsertHitObject()
|
||||
{
|
||||
current.AddRange(new[]
|
||||
{
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 3000 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
(OsuHitObject)current.HitObjects[0],
|
||||
new HitCircle { StartTime = 2000 },
|
||||
(OsuHitObject)current.HitObjects[1],
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDeleteHitObject()
|
||||
{
|
||||
current.AddRange(new[]
|
||||
{
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 2000 },
|
||||
new HitCircle { StartTime = 3000 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
(OsuHitObject)current.HitObjects[0],
|
||||
(OsuHitObject)current.HitObjects[2],
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestChangeStartTime()
|
||||
{
|
||||
current.AddRange(new[]
|
||||
{
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 2000 },
|
||||
new HitCircle { StartTime = 3000 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
new HitCircle { StartTime = 500 },
|
||||
(OsuHitObject)current.HitObjects[1],
|
||||
(OsuHitObject)current.HitObjects[2],
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestChangeSample()
|
||||
{
|
||||
current.AddRange(new[]
|
||||
{
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 2000 },
|
||||
new HitCircle { StartTime = 3000 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
(OsuHitObject)current.HitObjects[0],
|
||||
new HitCircle { StartTime = 2000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } } },
|
||||
(OsuHitObject)current.HitObjects[2],
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestChangeSliderPath()
|
||||
{
|
||||
current.AddRange(new OsuHitObject[]
|
||||
{
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new Slider
|
||||
{
|
||||
StartTime = 2000,
|
||||
Path = new SliderPath(new[]
|
||||
{
|
||||
new PathControlPoint(Vector2.Zero),
|
||||
new PathControlPoint(Vector2.One),
|
||||
new PathControlPoint(new Vector2(2), PathType.Bezier),
|
||||
new PathControlPoint(new Vector2(3)),
|
||||
}, 50)
|
||||
},
|
||||
new HitCircle { StartTime = 3000 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
(OsuHitObject)current.HitObjects[0],
|
||||
new Slider
|
||||
{
|
||||
StartTime = 2000,
|
||||
Path = new SliderPath(new[]
|
||||
{
|
||||
new PathControlPoint(Vector2.Zero, PathType.Bezier),
|
||||
new PathControlPoint(new Vector2(4)),
|
||||
new PathControlPoint(new Vector2(5)),
|
||||
}, 100)
|
||||
},
|
||||
(OsuHitObject)current.HitObjects[2],
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestAddMultipleHitObjects()
|
||||
{
|
||||
current.AddRange(new[]
|
||||
{
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 2000 },
|
||||
new HitCircle { StartTime = 3000 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
new HitCircle { StartTime = 500 },
|
||||
(OsuHitObject)current.HitObjects[0],
|
||||
new HitCircle { StartTime = 1500 },
|
||||
(OsuHitObject)current.HitObjects[1],
|
||||
new HitCircle { StartTime = 2250 },
|
||||
new HitCircle { StartTime = 2500 },
|
||||
(OsuHitObject)current.HitObjects[2],
|
||||
new HitCircle { StartTime = 3500 },
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestDeleteMultipleHitObjects()
|
||||
{
|
||||
current.AddRange(new[]
|
||||
{
|
||||
new HitCircle { StartTime = 500 },
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 1500 },
|
||||
new HitCircle { StartTime = 2000 },
|
||||
new HitCircle { StartTime = 2250 },
|
||||
new HitCircle { StartTime = 2500 },
|
||||
new HitCircle { StartTime = 3000 },
|
||||
new HitCircle { StartTime = 3500 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
(OsuHitObject)current.HitObjects[1],
|
||||
(OsuHitObject)current.HitObjects[3],
|
||||
(OsuHitObject)current.HitObjects[6],
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestChangeSamplesOfMultipleHitObjects()
|
||||
{
|
||||
current.AddRange(new[]
|
||||
{
|
||||
new HitCircle { StartTime = 500 },
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 1500 },
|
||||
new HitCircle { StartTime = 2000 },
|
||||
new HitCircle { StartTime = 2250 },
|
||||
new HitCircle { StartTime = 2500 },
|
||||
new HitCircle { StartTime = 3000 },
|
||||
new HitCircle { StartTime = 3500 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
(OsuHitObject)current.HitObjects[0],
|
||||
new HitCircle { StartTime = 1000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } } },
|
||||
(OsuHitObject)current.HitObjects[2],
|
||||
(OsuHitObject)current.HitObjects[3],
|
||||
new HitCircle { StartTime = 2250, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_WHISTLE } } },
|
||||
(OsuHitObject)current.HitObjects[5],
|
||||
new HitCircle { StartTime = 3000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP } } },
|
||||
(OsuHitObject)current.HitObjects[7],
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestAddAndDeleteHitObjects()
|
||||
{
|
||||
current.AddRange(new[]
|
||||
{
|
||||
new HitCircle { StartTime = 500 },
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 1500 },
|
||||
new HitCircle { StartTime = 2000 },
|
||||
new HitCircle { StartTime = 2250 },
|
||||
new HitCircle { StartTime = 2500 },
|
||||
new HitCircle { StartTime = 3000 },
|
||||
new HitCircle { StartTime = 3500 },
|
||||
});
|
||||
|
||||
var patch = new OsuBeatmap
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
new HitCircle { StartTime = 750 },
|
||||
(OsuHitObject)current.HitObjects[1],
|
||||
(OsuHitObject)current.HitObjects[4],
|
||||
(OsuHitObject)current.HitObjects[5],
|
||||
new HitCircle { StartTime = 2650 },
|
||||
new HitCircle { StartTime = 2750 },
|
||||
new HitCircle { StartTime = 4000 },
|
||||
}
|
||||
};
|
||||
|
||||
runTest(patch);
|
||||
}
|
||||
|
||||
private void runTest(IBeatmap patch)
|
||||
{
|
||||
// Due to the method of testing, "patch" comes in without having been decoded via a beatmap decoder.
|
||||
// This causes issues because the decoder adds various default properties (e.g. new combo on first object, default samples).
|
||||
// To resolve "patch" into a sane state it is encoded and then re-decoded.
|
||||
patch = decode(encode(patch));
|
||||
|
||||
// Apply the patch.
|
||||
patcher.Patch(encode(current), encode(patch));
|
||||
|
||||
// Convert beatmaps to strings for assertion purposes.
|
||||
string currentStr = Encoding.ASCII.GetString(encode(current));
|
||||
string patchStr = Encoding.ASCII.GetString(encode(patch));
|
||||
|
||||
Assert.That(currentStr, Is.EqualTo(patchStr));
|
||||
}
|
||||
|
||||
private byte[] encode(IBeatmap beatmap)
|
||||
{
|
||||
using (var encoded = new MemoryStream())
|
||||
{
|
||||
using (var sw = new StreamWriter(encoded))
|
||||
new LegacyBeatmapEncoder(beatmap).Encode(sw);
|
||||
|
||||
return encoded.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private IBeatmap decode(byte[] state)
|
||||
{
|
||||
using (var stream = new MemoryStream(state))
|
||||
using (var reader = new LineBufferedReader(stream))
|
||||
return Decoder.GetDecoder<Beatmap>(reader).Decode(reader);
|
||||
}
|
||||
}
|
||||
}
|
195
osu.Game.Tests/Visual/Editor/TestSceneEditorChangeStates.cs
Normal file
195
osu.Game.Tests/Visual/Editor/TestSceneEditorChangeStates.cs
Normal file
@ -0,0 +1,195 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Osu;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Screens.Edit;
|
||||
using osu.Game.Screens.Edit.Compose.Components.Timeline;
|
||||
using osuTK.Input;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Editor
|
||||
{
|
||||
public class TestSceneEditorChangeStates : ScreenTestScene
|
||||
{
|
||||
private EditorBeatmap editorBeatmap;
|
||||
|
||||
public override void SetUpSteps()
|
||||
{
|
||||
base.SetUpSteps();
|
||||
|
||||
Screens.Edit.Editor editor = null;
|
||||
|
||||
AddStep("load editor", () =>
|
||||
{
|
||||
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
|
||||
LoadScreen(editor = new Screens.Edit.Editor());
|
||||
});
|
||||
|
||||
AddUntilStep("wait for editor to load", () => editor.ChildrenOfType<HitObjectComposer>().FirstOrDefault()?.IsLoaded == true
|
||||
&& editor.ChildrenOfType<TimelineArea>().FirstOrDefault()?.IsLoaded == true);
|
||||
AddStep("get beatmap", () => editorBeatmap = editor.ChildrenOfType<EditorBeatmap>().Single());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestUndoFromInitialState()
|
||||
{
|
||||
int hitObjectCount = 0;
|
||||
|
||||
AddStep("get initial state", () => hitObjectCount = editorBeatmap.HitObjects.Count);
|
||||
|
||||
addUndoSteps();
|
||||
|
||||
AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRedoFromInitialState()
|
||||
{
|
||||
int hitObjectCount = 0;
|
||||
|
||||
AddStep("get initial state", () => hitObjectCount = editorBeatmap.HitObjects.Count);
|
||||
|
||||
addRedoSteps();
|
||||
|
||||
AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestAddObjectAndUndo()
|
||||
{
|
||||
HitObject addedObject = null;
|
||||
HitObject removedObject = null;
|
||||
HitObject expectedObject = null;
|
||||
|
||||
AddStep("bind removal", () =>
|
||||
{
|
||||
editorBeatmap.HitObjectAdded += h => addedObject = h;
|
||||
editorBeatmap.HitObjectRemoved += h => removedObject = h;
|
||||
});
|
||||
|
||||
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
|
||||
AddAssert("hitobject added", () => addedObject == expectedObject);
|
||||
|
||||
addUndoSteps();
|
||||
AddAssert("hitobject removed", () => removedObject == expectedObject);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestAddObjectThenUndoThenRedo()
|
||||
{
|
||||
HitObject addedObject = null;
|
||||
HitObject removedObject = null;
|
||||
HitObject expectedObject = null;
|
||||
|
||||
AddStep("bind removal", () =>
|
||||
{
|
||||
editorBeatmap.HitObjectAdded += h => addedObject = h;
|
||||
editorBeatmap.HitObjectRemoved += h => removedObject = h;
|
||||
});
|
||||
|
||||
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
|
||||
addUndoSteps();
|
||||
|
||||
AddStep("reset variables", () =>
|
||||
{
|
||||
addedObject = null;
|
||||
removedObject = null;
|
||||
});
|
||||
|
||||
addRedoSteps();
|
||||
AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance)
|
||||
AddAssert("no hitobject removed", () => removedObject == null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRemoveObjectThenUndo()
|
||||
{
|
||||
HitObject addedObject = null;
|
||||
HitObject removedObject = null;
|
||||
HitObject expectedObject = null;
|
||||
|
||||
AddStep("bind removal", () =>
|
||||
{
|
||||
editorBeatmap.HitObjectAdded += h => addedObject = h;
|
||||
editorBeatmap.HitObjectRemoved += h => removedObject = h;
|
||||
});
|
||||
|
||||
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
|
||||
AddStep("remove object", () => editorBeatmap.Remove(expectedObject));
|
||||
AddStep("reset variables", () =>
|
||||
{
|
||||
addedObject = null;
|
||||
removedObject = null;
|
||||
});
|
||||
|
||||
addUndoSteps();
|
||||
AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance)
|
||||
AddAssert("no hitobject removed", () => removedObject == null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRemoveObjectThenUndoThenRedo()
|
||||
{
|
||||
HitObject addedObject = null;
|
||||
HitObject removedObject = null;
|
||||
HitObject expectedObject = null;
|
||||
|
||||
AddStep("bind removal", () =>
|
||||
{
|
||||
editorBeatmap.HitObjectAdded += h => addedObject = h;
|
||||
editorBeatmap.HitObjectRemoved += h => removedObject = h;
|
||||
});
|
||||
|
||||
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
|
||||
AddStep("remove object", () => editorBeatmap.Remove(expectedObject));
|
||||
addUndoSteps();
|
||||
|
||||
AddStep("reset variables", () =>
|
||||
{
|
||||
addedObject = null;
|
||||
removedObject = null;
|
||||
});
|
||||
|
||||
addRedoSteps();
|
||||
AddAssert("hitobject removed", () => removedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance after undo)
|
||||
AddAssert("no hitobject added", () => addedObject == null);
|
||||
}
|
||||
|
||||
private void addUndoSteps()
|
||||
{
|
||||
AddStep("press undo", () =>
|
||||
{
|
||||
InputManager.PressKey(Key.LControl);
|
||||
InputManager.PressKey(Key.Z);
|
||||
});
|
||||
|
||||
AddStep("release keys", () =>
|
||||
{
|
||||
InputManager.ReleaseKey(Key.LControl);
|
||||
InputManager.ReleaseKey(Key.Z);
|
||||
});
|
||||
}
|
||||
|
||||
private void addRedoSteps()
|
||||
{
|
||||
AddStep("press redo", () =>
|
||||
{
|
||||
InputManager.PressKey(Key.LControl);
|
||||
InputManager.PressKey(Key.LShift);
|
||||
InputManager.PressKey(Key.Z);
|
||||
});
|
||||
|
||||
AddStep("release keys", () =>
|
||||
{
|
||||
InputManager.ReleaseKey(Key.LControl);
|
||||
InputManager.ReleaseKey(Key.LShift);
|
||||
InputManager.ReleaseKey(Key.Z);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -62,6 +62,7 @@ namespace osu.Game.Screens.Edit
|
||||
|
||||
private IBeatmap playableBeatmap;
|
||||
private EditorBeatmap editorBeatmap;
|
||||
private EditorChangeHandler changeHandler;
|
||||
|
||||
private DependencyContainer dependencies;
|
||||
|
||||
@ -100,9 +101,11 @@ namespace osu.Game.Screens.Edit
|
||||
}
|
||||
|
||||
AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap));
|
||||
|
||||
dependencies.CacheAs(editorBeatmap);
|
||||
|
||||
changeHandler = new EditorChangeHandler(editorBeatmap);
|
||||
dependencies.CacheAs<IEditorChangeHandler>(changeHandler);
|
||||
|
||||
EditorMenuBar menuBar;
|
||||
|
||||
var fileMenuItems = new List<MenuItem>
|
||||
@ -147,6 +150,14 @@ namespace osu.Game.Screens.Edit
|
||||
new MenuItem("File")
|
||||
{
|
||||
Items = fileMenuItems
|
||||
},
|
||||
new MenuItem("Edit")
|
||||
{
|
||||
Items = new[]
|
||||
{
|
||||
new EditorMenuItem("Undo", MenuItemType.Standard, undo),
|
||||
new EditorMenuItem("Redo", MenuItemType.Standard, redo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -233,6 +244,19 @@ namespace osu.Game.Screens.Edit
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case Key.Z:
|
||||
if (e.ControlPressed)
|
||||
{
|
||||
if (e.ShiftPressed)
|
||||
redo();
|
||||
else
|
||||
undo();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@ -297,6 +321,10 @@ namespace osu.Game.Screens.Edit
|
||||
return base.OnExiting(next);
|
||||
}
|
||||
|
||||
private void undo() => changeHandler.RestoreState(-1);
|
||||
|
||||
private void redo() => changeHandler.RestoreState(1);
|
||||
|
||||
private void resetTrack(bool seekToStart = false)
|
||||
{
|
||||
Beatmap.Value.Track?.Stop();
|
||||
|
@ -120,6 +120,16 @@ namespace osu.Game.Screens.Edit
|
||||
|
||||
private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a collection of <see cref="HitObject"/>s to this <see cref="EditorBeatmap"/>.
|
||||
/// </summary>
|
||||
/// <param name="hitObjects">The <see cref="HitObject"/>s to add.</param>
|
||||
public void AddRange(IEnumerable<HitObject> hitObjects)
|
||||
{
|
||||
foreach (var h in hitObjects)
|
||||
Add(h);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a <see cref="HitObject"/> to this <see cref="EditorBeatmap"/>.
|
||||
/// </summary>
|
||||
@ -141,12 +151,34 @@ namespace osu.Game.Screens.Edit
|
||||
/// Removes a <see cref="HitObject"/> from this <see cref="EditorBeatmap"/>.
|
||||
/// </summary>
|
||||
/// <param name="hitObject">The <see cref="HitObject"/> to add.</param>
|
||||
public void Remove(HitObject hitObject)
|
||||
/// <returns>True if the <see cref="HitObject"/> has been removed, false otherwise.</returns>
|
||||
public bool Remove(HitObject hitObject)
|
||||
{
|
||||
if (!mutableHitObjects.Contains(hitObject))
|
||||
return;
|
||||
int index = FindIndex(hitObject);
|
||||
|
||||
mutableHitObjects.Remove(hitObject);
|
||||
if (index == -1)
|
||||
return false;
|
||||
|
||||
RemoveAt(index);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of a <see cref="HitObject"/> in this <see cref="EditorBeatmap"/>.
|
||||
/// </summary>
|
||||
/// <param name="hitObject">The <see cref="HitObject"/> to search for.</param>
|
||||
/// <returns>The index of <paramref name="hitObject"/>.</returns>
|
||||
public int FindIndex(HitObject hitObject) => mutableHitObjects.IndexOf(hitObject);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a <see cref="HitObject"/> at an index in this <see cref="EditorBeatmap"/>.
|
||||
/// </summary>
|
||||
/// <param name="index">The index of the <see cref="HitObject"/> to remove.</param>
|
||||
public void RemoveAt(int index)
|
||||
{
|
||||
var hitObject = (HitObject)mutableHitObjects[index];
|
||||
|
||||
mutableHitObjects.RemoveAt(index);
|
||||
|
||||
var bindable = startTimeBindables[hitObject];
|
||||
bindable.UnbindAll();
|
||||
|
112
osu.Game/Screens/Edit/EditorChangeHandler.cs
Normal file
112
osu.Game/Screens/Edit/EditorChangeHandler.cs
Normal file
@ -0,0 +1,112 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
|
||||
namespace osu.Game.Screens.Edit
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks changes to the <see cref="Editor"/>.
|
||||
/// </summary>
|
||||
public class EditorChangeHandler : IEditorChangeHandler
|
||||
{
|
||||
private readonly LegacyEditorBeatmapPatcher patcher;
|
||||
|
||||
private readonly List<byte[]> savedStates = new List<byte[]>();
|
||||
|
||||
private int currentState = -1;
|
||||
|
||||
private readonly EditorBeatmap editorBeatmap;
|
||||
private int bulkChangesStarted;
|
||||
private bool isRestoring;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="EditorChangeHandler"/>.
|
||||
/// </summary>
|
||||
/// <param name="editorBeatmap">The <see cref="EditorBeatmap"/> to track the <see cref="HitObject"/>s of.</param>
|
||||
public EditorChangeHandler(EditorBeatmap editorBeatmap)
|
||||
{
|
||||
this.editorBeatmap = editorBeatmap;
|
||||
|
||||
editorBeatmap.HitObjectAdded += hitObjectAdded;
|
||||
editorBeatmap.HitObjectRemoved += hitObjectRemoved;
|
||||
editorBeatmap.HitObjectUpdated += hitObjectUpdated;
|
||||
|
||||
patcher = new LegacyEditorBeatmapPatcher(editorBeatmap);
|
||||
|
||||
// Initial state.
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void hitObjectAdded(HitObject obj) => SaveState();
|
||||
|
||||
private void hitObjectRemoved(HitObject obj) => SaveState();
|
||||
|
||||
private void hitObjectUpdated(HitObject obj) => SaveState();
|
||||
|
||||
public void BeginChange() => bulkChangesStarted++;
|
||||
|
||||
public void EndChange()
|
||||
{
|
||||
if (bulkChangesStarted == 0)
|
||||
throw new InvalidOperationException($"Cannot call {nameof(EndChange)} without a previous call to {nameof(BeginChange)}.");
|
||||
|
||||
if (--bulkChangesStarted == 0)
|
||||
SaveState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the current <see cref="Editor"/> state.
|
||||
/// </summary>
|
||||
public void SaveState()
|
||||
{
|
||||
if (bulkChangesStarted > 0)
|
||||
return;
|
||||
|
||||
if (isRestoring)
|
||||
return;
|
||||
|
||||
if (currentState < savedStates.Count - 1)
|
||||
savedStates.RemoveRange(currentState + 1, savedStates.Count - currentState - 1);
|
||||
|
||||
using (var stream = new MemoryStream())
|
||||
{
|
||||
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
new LegacyBeatmapEncoder(editorBeatmap).Encode(sw);
|
||||
|
||||
savedStates.Add(stream.ToArray());
|
||||
}
|
||||
|
||||
currentState = savedStates.Count - 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores an older or newer state.
|
||||
/// </summary>
|
||||
/// <param name="direction">The direction to restore in. If less than 0, an older state will be used. If greater than 0, a newer state will be used.</param>
|
||||
public void RestoreState(int direction)
|
||||
{
|
||||
if (bulkChangesStarted > 0)
|
||||
return;
|
||||
|
||||
if (savedStates.Count == 0)
|
||||
return;
|
||||
|
||||
int newState = Math.Clamp(currentState + direction, 0, savedStates.Count - 1);
|
||||
if (currentState == newState)
|
||||
return;
|
||||
|
||||
isRestoring = true;
|
||||
|
||||
patcher.Patch(savedStates[currentState], savedStates[newState]);
|
||||
currentState = newState;
|
||||
|
||||
isRestoring = false;
|
||||
}
|
||||
}
|
||||
}
|
33
osu.Game/Screens/Edit/IEditorChangeHandler.cs
Normal file
33
osu.Game/Screens/Edit/IEditorChangeHandler.cs
Normal file
@ -0,0 +1,33 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Game.Rulesets.Objects;
|
||||
|
||||
namespace osu.Game.Screens.Edit
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for a component that manages changes in the <see cref="Editor"/>.
|
||||
/// </summary>
|
||||
public interface IEditorChangeHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Begins a bulk state change event. <see cref="EndChange"/> should be invoked soon after.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This should be invoked when multiple changes to the <see cref="Editor"/> should be bundled together into one state change event.
|
||||
/// When nested invocations are involved, a state change will not occur until an equal number of invocations of <see cref="EndChange"/> are received.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// When a group of <see cref="HitObject"/>s are deleted, a single undo and redo state change should update the state of all <see cref="HitObject"/>.
|
||||
/// </example>
|
||||
void BeginChange();
|
||||
|
||||
/// <summary>
|
||||
/// Ends a bulk state change event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This should be invoked as soon as possible after <see cref="BeginChange"/> to cause a state change.
|
||||
/// </remarks>
|
||||
void EndChange();
|
||||
}
|
||||
}
|
107
osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs
Normal file
107
osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs
Normal file
@ -0,0 +1,107 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using DiffPlex;
|
||||
using osu.Framework.Audio.Track;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.IO;
|
||||
using Decoder = osu.Game.Beatmaps.Formats.Decoder;
|
||||
|
||||
namespace osu.Game.Screens.Edit
|
||||
{
|
||||
/// <summary>
|
||||
/// Patches an <see cref="EditorBeatmap"/> based on the difference between two legacy (.osu) states.
|
||||
/// </summary>
|
||||
public class LegacyEditorBeatmapPatcher
|
||||
{
|
||||
private readonly EditorBeatmap editorBeatmap;
|
||||
|
||||
public LegacyEditorBeatmapPatcher(EditorBeatmap editorBeatmap)
|
||||
{
|
||||
this.editorBeatmap = editorBeatmap;
|
||||
}
|
||||
|
||||
public void Patch(byte[] currentState, byte[] newState)
|
||||
{
|
||||
// Diff the beatmaps
|
||||
var result = new Differ().CreateLineDiffs(readString(currentState), readString(newState), true, false);
|
||||
|
||||
// Find the index of [HitObject] sections. Lines changed prior to this index are ignored.
|
||||
int oldHitObjectsIndex = Array.IndexOf(result.PiecesOld, "[HitObjects]");
|
||||
int newHitObjectsIndex = Array.IndexOf(result.PiecesNew, "[HitObjects]");
|
||||
|
||||
var toRemove = new List<int>();
|
||||
var toAdd = new List<int>();
|
||||
|
||||
foreach (var block in result.DiffBlocks)
|
||||
{
|
||||
// Removed hitobjects
|
||||
for (int i = 0; i < block.DeleteCountA; i++)
|
||||
{
|
||||
int hoIndex = block.DeleteStartA + i - oldHitObjectsIndex - 1;
|
||||
|
||||
if (hoIndex < 0)
|
||||
continue;
|
||||
|
||||
toRemove.Add(hoIndex);
|
||||
}
|
||||
|
||||
// Added hitobjects
|
||||
for (int i = 0; i < block.InsertCountB; i++)
|
||||
{
|
||||
int hoIndex = block.InsertStartB + i - newHitObjectsIndex - 1;
|
||||
|
||||
if (hoIndex < 0)
|
||||
continue;
|
||||
|
||||
toAdd.Add(hoIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Make the removal indices are sorted so that iteration order doesn't get messed up post-removal.
|
||||
toRemove.Sort();
|
||||
|
||||
// Apply the changes.
|
||||
for (int i = toRemove.Count - 1; i >= 0; i--)
|
||||
editorBeatmap.RemoveAt(toRemove[i]);
|
||||
|
||||
if (toAdd.Count > 0)
|
||||
{
|
||||
IBeatmap newBeatmap = readBeatmap(newState);
|
||||
foreach (var i in toAdd)
|
||||
editorBeatmap.Add(newBeatmap.HitObjects[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private string readString(byte[] state) => Encoding.UTF8.GetString(state);
|
||||
|
||||
private IBeatmap readBeatmap(byte[] state)
|
||||
{
|
||||
using (var stream = new MemoryStream(state))
|
||||
using (var reader = new LineBufferedReader(stream, true))
|
||||
return new PassThroughWorkingBeatmap(Decoder.GetDecoder<Beatmap>(reader).Decode(reader)).GetPlayableBeatmap(editorBeatmap.BeatmapInfo.Ruleset);
|
||||
}
|
||||
|
||||
private class PassThroughWorkingBeatmap : WorkingBeatmap
|
||||
{
|
||||
private readonly IBeatmap beatmap;
|
||||
|
||||
public PassThroughWorkingBeatmap(IBeatmap beatmap)
|
||||
: base(beatmap.BeatmapInfo, null)
|
||||
{
|
||||
this.beatmap = beatmap;
|
||||
}
|
||||
|
||||
protected override IBeatmap GetBeatmap() => beatmap;
|
||||
|
||||
protected override Texture GetBackground() => throw new NotImplementedException();
|
||||
|
||||
protected override Track GetTrack() => throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
@ -18,6 +18,7 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Package References">
|
||||
<PackageReference Include="DiffPlex" Version="1.6.1" />
|
||||
<PackageReference Include="Humanizer" Version="2.7.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
|
||||
|
@ -75,6 +75,7 @@
|
||||
</ItemGroup>
|
||||
<!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. -->
|
||||
<ItemGroup Label="Transitive Dependencies">
|
||||
<PackageReference Include="DiffPlex" Version="1.6.1" />
|
||||
<PackageReference Include="Humanizer" Version="2.7.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
|
||||
|
Loading…
Reference in New Issue
Block a user