// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Overlays; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose.Components; using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Edit { /// /// Top level container for editor compose mode. /// Responsible for providing snapping and generally gluing components together. /// /// The base type of supported objects. public abstract partial class HitObjectComposer : HitObjectComposer, IPlacementHandler where TObject : HitObject { /// /// Whether the playfield should be centered horizontally. Should be disabled for playfields which span the full horizontal width. /// protected virtual bool ApplyHorizontalCentering => true; protected IRulesetConfigManager Config { get; private set; } // Provides `Playfield` private DependencyContainer dependencies; [Resolved] protected EditorClock EditorClock { get; private set; } [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } [Resolved] protected IBeatSnapProvider BeatSnapProvider { get; private set; } [Resolved] private OverlayColourProvider colourProvider { get; set; } public override ComposeBlueprintContainer BlueprintContainer => blueprintContainer; private ComposeBlueprintContainer blueprintContainer; protected ExpandingToolboxContainer LeftToolbox { get; private set; } protected ExpandingToolboxContainer RightToolbox { get; private set; } private DrawableEditorRulesetWrapper drawableRulesetWrapper; protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; protected InputManager InputManager { get; private set; } private Box leftToolboxBackground; private Box rightToolboxBackground; private EditorRadioButtonCollection toolboxCollection; private FillFlowContainer togglesCollection; private FillFlowContainer sampleBankTogglesCollection; private IBindable hasTiming; private Bindable autoSeekOnPlacement; private readonly Bindable composerFocusMode = new Bindable(); [CanBeNull] private RadioButton lastTool; protected DrawableRuleset DrawableRuleset { get; private set; } protected HitObjectComposer(Ruleset ruleset) : base(ruleset) { } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader(true)] private void load(OsuConfigManager config, [CanBeNull] Editor editor) { autoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); if (editor != null) composerFocusMode.BindTo(editor.ComposerFocusMode); Config = Dependencies.Get().GetConfigFor(Ruleset); try { DrawableRuleset = CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() }); drawableRulesetWrapper = new DrawableEditorRulesetWrapper(DrawableRuleset) { Clock = EditorClock, ProcessCustomClock = false }; } catch (Exception e) { Logger.Error(e, "Could not load beatmap successfully!"); return; } if (DrawableRuleset is IDrawableScrollingRuleset scrollingRuleset) dependencies.CacheAs(scrollingRuleset.ScrollingInfo); dependencies.CacheAs(Playfield); InternalChildren = new[] { PlayfieldContentContainer = new Container { Name = "Playfield content", RelativeSizeAxes = Axes.Y, Children = new Drawable[] { // layers below playfield drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer().WithChild(LayerBelowRuleset), drawableRulesetWrapper, // layers above playfield drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer() .WithChild(blueprintContainer = CreateBlueprintContainer()) } }, new Container { RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, Children = new Drawable[] { leftToolboxBackground = new Box { Colour = colourProvider.Background5, RelativeSizeAxes = Axes.Both, }, LeftToolbox = new ExpandingToolboxContainer(TOOLBOX_CONTRACTED_SIZE_LEFT, 200) { Children = new Drawable[] { new EditorToolboxGroup("toolbox (1-9)") { Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } }, new EditorToolboxGroup("toggles (Q~P)") { Child = togglesCollection = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5), }, }, new EditorToolboxGroup("bank (Shift/Alt-Q~R)") { Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5), Children = new Drawable[] { new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Children = new Drawable[] { new ExpandableSpriteText { Text = "Normal", AlwaysPresent = true, AllowMultiline = false, RelativePositionAxes = Axes.X, X = 0.25f, Origin = Anchor.TopCentre, Anchor = Anchor.TopLeft, Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 17), }, new ExpandableSpriteText { Text = "Addition", AlwaysPresent = true, AllowMultiline = false, RelativePositionAxes = Axes.X, X = 0.75f, Origin = Anchor.TopCentre, Anchor = Anchor.TopLeft, Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 17), }, } }, sampleBankTogglesCollection = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5), }, } } }, } }, } }, new Container { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, Children = new Drawable[] { rightToolboxBackground = new Box { Colour = colourProvider.Background5, RelativeSizeAxes = Axes.Both, }, RightToolbox = new ExpandingToolboxContainer(TOOLBOX_CONTRACTED_SIZE_RIGHT, 250) { Child = new EditorToolboxGroup("inspector") { Child = CreateHitObjectInspector() }, } } }, }; toolboxCollection.Items = (CompositionTools.Prepend(new SelectTool())) .Select(t => new HitObjectCompositionToolButton(t, () => toolSelected(t))) .ToList(); foreach (var item in toolboxCollection.Items) { item.Selected.DisabledChanged += isDisabled => { item.TooltipText = isDisabled ? "Add at least one timing point first!" : ((HitObjectCompositionToolButton)item).TooltipText; }; } TernaryStates = CreateTernaryButtons().ToArray(); togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Zip(BlueprintContainer.SampleAdditionBankTernaryStates).Select(b => new SampleBankTernaryButton(b.First, b.Second))); SetSelectTool(); EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } /// /// Houses all content relevant to the playfield. /// /// /// Generally implementations should not be adding to this directly. /// Use or instead. /// protected Container PlayfieldContentContainer { get; private set; } protected override void LoadComplete() { base.LoadComplete(); InputManager = GetContainingInputManager(); hasTiming = EditorBeatmap.HasTiming.GetBoundCopy(); hasTiming.BindValueChanged(timing => { // it's important this is performed before the similar code in EditorRadioButton disables the button. if (!timing.NewValue) SetSelectTool(); }); EditorBeatmap.HasTiming.BindValueChanged(hasTiming => { foreach (var item in toolboxCollection.Items) { item.Selected.Disabled = !hasTiming.NewValue; } }, true); composerFocusMode.BindValueChanged(_ => { // Transforms should be kept in sync with other usages of composer focus mode. if (!composerFocusMode.Value) { leftToolboxBackground.FadeIn(750, Easing.OutQuint); rightToolboxBackground.FadeIn(750, Easing.OutQuint); } else { leftToolboxBackground.Delay(600).FadeTo(0.5f, 4000, Easing.OutQuint); rightToolboxBackground.Delay(600).FadeTo(0.5f, 4000, Easing.OutQuint); } }, true); } protected override void Update() { base.Update(); if (ApplyHorizontalCentering) { PlayfieldContentContainer.Anchor = Anchor.Centre; PlayfieldContentContainer.Origin = Anchor.Centre; // Ensure that the playfield is always centered but also doesn't get cut off by toolboxes. PlayfieldContentContainer.Width = Math.Max(1024, DrawWidth) - TOOLBOX_CONTRACTED_SIZE_RIGHT * 2; PlayfieldContentContainer.X = 0; } else { PlayfieldContentContainer.Anchor = Anchor.CentreLeft; PlayfieldContentContainer.Origin = Anchor.CentreLeft; PlayfieldContentContainer.Width = Math.Max(1024, DrawWidth); PlayfieldContentContainer.X = LeftToolbox.DrawWidth; } composerFocusMode.Value = PlayfieldContentContainer.Contains(InputManager.CurrentState.Mouse.Position) && !LeftToolbox.Contains(InputManager.CurrentState.Mouse.Position) && !RightToolbox.Contains(InputManager.CurrentState.Mouse.Position); } public override Playfield Playfield => drawableRulesetWrapper.Playfield; public override IEnumerable HitObjects => drawableRulesetWrapper.Playfield.AllHitObjects; public override bool CursorInPlacementArea => drawableRulesetWrapper.Playfield.ReceivePositionalInputAt(InputManager.CurrentState.Mouse.Position); /// /// Defines all available composition tools, listed on the left side of the editor screen as button controls. /// This should usually define one tool for each type used in the target ruleset. /// /// /// A "select" tool is automatically added as the first tool. /// protected abstract IReadOnlyList CompositionTools { get; } /// /// A collection of states which will be displayed to the user in the toolbox. /// public TernaryButton[] TernaryStates { get; private set; } /// /// Create all ternary states required to be displayed to the user. /// protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. /// protected virtual ComposeBlueprintContainer CreateBlueprintContainer() => new ComposeBlueprintContainer(this); protected virtual Drawable CreateHitObjectInspector() => new HitObjectInspector(); /// /// Construct a drawable ruleset for the provided ruleset. /// /// /// Can be overridden to add editor-specific logical changes to a 's standard . /// For example, hit animations or judgement logic may be changed to give a better editor user experience. /// /// The ruleset used to construct its drawable counterpart. /// The loaded beatmap. /// The mods to be applied. /// An editor-relevant . protected virtual DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) => (DrawableRuleset)ruleset.CreateDrawableRulesetWith(beatmap, mods); #region Tool selection logic protected override bool OnKeyDown(KeyDownEvent e) { if (e.ControlPressed || e.SuperPressed) return false; if (checkToolboxMappingFromKey(e.Key, out int leftIndex)) { var item = toolboxCollection.Items.ElementAtOrDefault(leftIndex); if (item != null) { if (!item.Selected.Disabled) item.Select(); return true; } } if (checkToggleMappingFromKey(e.Key, out int rightIndex)) { if (e.ShiftPressed || e.AltPressed) { if (sampleBankTogglesCollection.ElementAtOrDefault(rightIndex) is SampleBankTernaryButton sampleBankTernaryButton) { if (e.ShiftPressed) sampleBankTernaryButton.NormalButton.Toggle(); if (e.AltPressed) sampleBankTernaryButton.AdditionsButton.Toggle(); return true; } } else { if (togglesCollection.ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) { button.Button.Toggle(); return true; } } } return base.OnKeyDown(e); } private bool checkToolboxMappingFromKey(Key key, out int index) { if (key < Key.Number1 || key > Key.Number9) { index = -1; return false; } index = key - Key.Number1; return true; } private bool checkToggleMappingFromKey(Key key, out int index) { switch (key) { case Key.Q: index = 0; break; case Key.W: index = 1; break; case Key.E: index = 2; break; case Key.R: index = 3; break; case Key.T: index = 4; break; case Key.Y: index = 5; break; case Key.U: index = 6; break; case Key.I: index = 7; break; case Key.O: index = 8; break; case Key.P: index = 9; break; default: index = -1; break; } return index >= 0; } private void selectionChanged(object sender, NotifyCollectionChangedEventArgs changedArgs) { if (EditorBeatmap.SelectedHitObjects.Any()) { // ensure in selection mode if a selection is made. SetSelectTool(); } } public void SetSelectTool() => toolboxCollection.Items.First().Select(); public void SetLastTool() => (lastTool ?? toolboxCollection.Items.First()).Select(); private void toolSelected(CompositionTool tool) { lastTool = toolboxCollection.Items.OfType().FirstOrDefault(i => i.Tool == BlueprintContainer.CurrentTool); BlueprintContainer.CurrentTool = tool; if (!(tool is SelectTool)) EditorBeatmap.SelectedHitObjects.Clear(); } #endregion #region IPlacementHandler public void BeginPlacement(HitObject hitObject) { EditorBeatmap.PlacementObject.Value = hitObject; } public void EndPlacement(HitObject hitObject, bool commit) { EditorBeatmap.PlacementObject.Value = null; if (commit) { EditorBeatmap.Add(hitObject); if (autoSeekOnPlacement.Value && EditorClock.CurrentTime < hitObject.StartTime) EditorClock.SeekSmoothlyTo(hitObject.StartTime); } } public void Delete(HitObject hitObject) => EditorBeatmap.Remove(hitObject); #endregion #region IPositionSnapProvider /// /// Retrieve the relevant at a specified screen-space position. /// In cases where a ruleset doesn't require custom logic (due to nested playfields, for example) /// this will return the ruleset's main playfield. /// /// The screen-space position to query. /// The most relevant . protected virtual Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => drawableRulesetWrapper.Playfield; public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); double? targetTime = null; if (snapType.HasFlag(SnapType.GlobalGrids)) { if (playfield is ScrollingPlayfield scrollingPlayfield) { targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); // apply beat snapping targetTime = BeatSnapProvider.SnapTime(targetTime.Value); // convert back to screen space screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); } } return new SnapResult(screenSpacePosition, targetTime, playfield); } #endregion } /// /// A non-generic definition of a HitObject composer class. /// Generally used to access certain methods without requiring a generic type for . /// [Cached] public abstract partial class HitObjectComposer : CompositeDrawable, IPositionSnapProvider { public const float TOOLBOX_CONTRACTED_SIZE_LEFT = 60; public const float TOOLBOX_CONTRACTED_SIZE_RIGHT = 120; public readonly Ruleset Ruleset; protected HitObjectComposer(Ruleset ruleset) { Ruleset = ruleset; RelativeSizeAxes = Axes.Both; } /// /// The target ruleset's playfield. /// public abstract Playfield Playfield { get; } public abstract ComposeBlueprintContainer BlueprintContainer { get; } /// /// All s in currently loaded beatmap. /// public abstract IEnumerable HitObjects { get; } /// /// Whether the user's cursor is currently in an area of the that is valid for placement. /// public abstract bool CursorInPlacementArea { get; } /// /// Returns a string representing the current selection. /// The inverse method to . /// public virtual string ConvertSelectionToString() => string.Empty; /// /// Selects objects based on the supplied and . /// The inverse method to . /// /// The time instant to seek to, in milliseconds. /// The ruleset-specific description of objects to select at the given timestamp. public virtual void SelectFromTimestamp(double timestamp, string objectDescription) { } #region IPositionSnapProvider public abstract SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All); #endregion } }