-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Implementing a ruleset editor
This guide will detail the minimum viable implementation of an editor, allowing a user to place, select and modify hitobjects for an arbitrary ruleset.
We assume that you already have a working, playable implementation of a ruleset, following standard practices.
The examples here will use osu!taiko as an example, so classes will use the Taiko
prefix.
A HitObjectComposer is the main component of an editor implementation. Think of this as a central hub for displaying the ruleset portion of the compose screen and also the class with overridable methods to construct specialised classes for various editor functionalities (specifically CreateBlueprintContainer
and CreateDrawableRuleset
).
Create a new HitObjectComposer
class in your ruleset project:
public class TaikoHitObjectComposer : HitObjectComposer<TaikoHitObject>
{
public TaikoHitObjectComposer(TaikoRuleset ruleset)
: base(ruleset)
{
}
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => Array.Empty<HitObjectCompositionTool>();
}
and update your Ruleset class to point to it:
public class TaikoRuleset : Ruleset, ILegacyRuleset
{
...
public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this);
}
Let's start by making a visual test class for our editor. For sake of simplicity, this guide will create a high level editor test. More targeted tests can be made but require a slight amount of local implementation (see TestSceneHitObjectComposer as one example).
Create a new TestSceneEditor
class in your test project:
[TestFixture]
public class TestSceneEditor : EditorTestScene
{
public TestSceneEditor()
: base(new TaikoRuleset())
{
}
}
Running your visual tests, you should now see the editor in a relatively good visual state. You should also notice that basic object movement in the timeline should work as expected, as long as you are doing all DrawableHitObject
visual update logic in UpdateState
. This is because HitObject
's StartTime
bindable handling is done for you.
- All transforms and state changes should be done in
UpdateState
. This allows internal logic to update visual state without user intervention. - DrawablesHitObjects must respond to changes to bindables. Current bindables are:
- StartTimeBindable (handled for the user)
- SamplesBindable
- DurationBindable (coming soon?)
- Any custom attributes added (for instance the
Position
of anOsuHitObject
) should be bindables and also allow handling the same flow.
The default SelectionHandler
implementation goes a long way to make things work out of the box, but there are some scenarios you will need to create a custom implementation:
If you are a scrolling ruleset, you will also benefit from selection logic automatically working in the main compose area too. If not, you will need to override HandleMovement
To create context menu items for the current selection, you will need to override GetContextMenuItemsForSelection
. It is recommended to use TernaryStateMenuItem
s to correctly represent a selection which has multiple different states, or to hide options which can't feasibly operate on the current selection.
An example of examining and applying the ternary state to the current selection follows:
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint> selection)
{
// validity check — we can only show this menu item if all of the selection is the correct type.
if (selection.All(s => s.HitObject is TaikoHitObject))
{
// pre-cast for simplicity.
var hits = selection.Select(s => s.HitObject).OfType<TaikoHitObject>();
yield return new TernaryStateMenuItem("Strong", action: state =>
{
// applied state will always be true or false — this is after a user change.
foreach (var h in hits)
{
switch (state)
{
case TernaryState.True:
h.IsStrong = true;
break;
case TernaryState.False:
h.IsStrong = false;
break;
}
// Only required if you need to run ApplyDefaults on the HitObject.
// If you are handling the change via bindables this is usually not required.
EditorBeatmap?.UpdateHitObject(h);
}
})
{
// set the initial state using a handle helper function below.
State = { Value = getTernaryState(hits, h => h.IsStrong) }
};
}
}
private TernaryState getTernaryState<T>(IEnumerable<T> selection, Func<T, bool> func)
{
if (selection.Any(func))
return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
return TernaryState.False;
}
todo
todo