mirror of
https://github.com/osukey/osukey.git
synced 2025-06-05 12:57:39 +09: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 IBeatmap playableBeatmap;
|
||||||
private EditorBeatmap editorBeatmap;
|
private EditorBeatmap editorBeatmap;
|
||||||
|
private EditorChangeHandler changeHandler;
|
||||||
|
|
||||||
private DependencyContainer dependencies;
|
private DependencyContainer dependencies;
|
||||||
|
|
||||||
@ -100,9 +101,11 @@ namespace osu.Game.Screens.Edit
|
|||||||
}
|
}
|
||||||
|
|
||||||
AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap));
|
AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap));
|
||||||
|
|
||||||
dependencies.CacheAs(editorBeatmap);
|
dependencies.CacheAs(editorBeatmap);
|
||||||
|
|
||||||
|
changeHandler = new EditorChangeHandler(editorBeatmap);
|
||||||
|
dependencies.CacheAs<IEditorChangeHandler>(changeHandler);
|
||||||
|
|
||||||
EditorMenuBar menuBar;
|
EditorMenuBar menuBar;
|
||||||
|
|
||||||
var fileMenuItems = new List<MenuItem>
|
var fileMenuItems = new List<MenuItem>
|
||||||
@ -147,6 +150,14 @@ namespace osu.Game.Screens.Edit
|
|||||||
new MenuItem("File")
|
new MenuItem("File")
|
||||||
{
|
{
|
||||||
Items = fileMenuItems
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.Z:
|
||||||
|
if (e.ControlPressed)
|
||||||
|
{
|
||||||
|
if (e.ShiftPressed)
|
||||||
|
redo();
|
||||||
|
else
|
||||||
|
undo();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,6 +321,10 @@ namespace osu.Game.Screens.Edit
|
|||||||
return base.OnExiting(next);
|
return base.OnExiting(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void undo() => changeHandler.RestoreState(-1);
|
||||||
|
|
||||||
|
private void redo() => changeHandler.RestoreState(1);
|
||||||
|
|
||||||
private void resetTrack(bool seekToStart = false)
|
private void resetTrack(bool seekToStart = false)
|
||||||
{
|
{
|
||||||
Beatmap.Value.Track?.Stop();
|
Beatmap.Value.Track?.Stop();
|
||||||
|
@ -120,6 +120,16 @@ namespace osu.Game.Screens.Edit
|
|||||||
|
|
||||||
private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects;
|
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>
|
/// <summary>
|
||||||
/// Adds a <see cref="HitObject"/> to this <see cref="EditorBeatmap"/>.
|
/// Adds a <see cref="HitObject"/> to this <see cref="EditorBeatmap"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -141,12 +151,34 @@ namespace osu.Game.Screens.Edit
|
|||||||
/// Removes a <see cref="HitObject"/> from this <see cref="EditorBeatmap"/>.
|
/// Removes a <see cref="HitObject"/> from this <see cref="EditorBeatmap"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="hitObject">The <see cref="HitObject"/> to add.</param>
|
/// <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))
|
int index = FindIndex(hitObject);
|
||||||
return;
|
|
||||||
|
|
||||||
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];
|
var bindable = startTimeBindables[hitObject];
|
||||||
bindable.UnbindAll();
|
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>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Label="Package References">
|
<ItemGroup Label="Package References">
|
||||||
|
<PackageReference Include="DiffPlex" Version="1.6.1" />
|
||||||
<PackageReference Include="Humanizer" Version="2.7.9" />
|
<PackageReference Include="Humanizer" Version="2.7.9" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
|
||||||
|
@ -75,6 +75,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. -->
|
<!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. -->
|
||||||
<ItemGroup Label="Transitive Dependencies">
|
<ItemGroup Label="Transitive Dependencies">
|
||||||
|
<PackageReference Include="DiffPlex" Version="1.6.1" />
|
||||||
<PackageReference Include="Humanizer" Version="2.7.9" />
|
<PackageReference Include="Humanizer" Version="2.7.9" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
|
||||||
|
Loading…
x
Reference in New Issue
Block a user