Skip to content

Implementing a ruleset editor

Dean Herbert edited this page May 29, 2020 · 4 revisions

Creating an editor for a Ruleset

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.

Create basic structure and editor visual test

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.

Keeping DrawableHitObjects compatible with the editor

  • 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 an OsuHitObject) should be bindables and also allow handling the same flow.

Creating a custom selection handler

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:

Making selection work for non-scrolling ruleset

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

Adding context menu options for current selection

To create context menu items for the current selection, you will need to override GetContextMenuItemsForSelection. It is recommended to use TernaryStateMenuItems 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;
}

Creating composer blueprints

todo

Creating placement tools

todo