// Copyright (c) ppy Pty Ltd . 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.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; 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. [Cached(Type = typeof(IPlacementHandler))] public abstract class HitObjectComposer : HitObjectComposer, IPlacementHandler where TObject : HitObject { protected IRulesetConfigManager Config { get; private set; } protected readonly Ruleset Ruleset; // 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; } protected ComposeBlueprintContainer BlueprintContainer { get; private set; } private DrawableEditorRulesetWrapper drawableRulesetWrapper; protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; private InputManager inputManager; private EditorRadioButtonCollection toolboxCollection; private FillFlowContainer togglesCollection; private IBindable hasTiming; protected HitObjectComposer(Ruleset ruleset) { Ruleset = ruleset; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader] private void load() { Config = Dependencies.Get().GetConfigFor(Ruleset); try { drawableRulesetWrapper = new DrawableEditorRulesetWrapper(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() })) { Clock = EditorClock, ProcessCustomClock = false }; } catch (Exception e) { Logger.Error(e, "Could not load beatmap successfully!"); return; } dependencies.CacheAs(Playfield); InternalChildren = new Drawable[] { new Container { Name = "Content", RelativeSizeAxes = Axes.Both, Children = new Drawable[] { // layers below playfield drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer().WithChild(LayerBelowRuleset), drawableRulesetWrapper, // layers above playfield drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer() .WithChild(BlueprintContainer = CreateBlueprintContainer()) } }, new LeftToolboxFlow { 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), }, } } }, }; toolboxCollection.Items = CompositionTools .Prepend(new SelectTool()) .Select(t => new RadioButton(t.Name, () => toolSelected(t), t.CreateIcon)) .ToList(); TernaryStates = CreateTernaryButtons().ToArray(); togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); setSelectTool(); EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } 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(); }); } 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.TernaryStates; /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. /// protected virtual ComposeBlueprintContainer CreateBlueprintContainer() => new ComposeBlueprintContainer(this); /// /// 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 = null) => (DrawableRuleset)ruleset.CreateDrawableRulesetWith(beatmap, mods); #region Tool selection logic protected override bool OnKeyDown(KeyDownEvent e) { if (e.ControlPressed || e.AltPressed || e.SuperPressed) return false; if (checkLeftToggleFromKey(e.Key, out int leftIndex)) { var item = toolboxCollection.Items.ElementAtOrDefault(leftIndex); if (item != null) { if (!item.Selected.Disabled) item.Select(); return true; } } if (checkRightToggleFromKey(e.Key, out int rightIndex)) { var item = togglesCollection.ElementAtOrDefault(rightIndex); if (item is DrawableTernaryButton button) { button.Button.Toggle(); return true; } } return base.OnKeyDown(e); } private bool checkLeftToggleFromKey(Key key, out int index) { if (key < Key.Number1 || key > Key.Number9) { index = -1; return false; } index = key - Key.Number1; return true; } private bool checkRightToggleFromKey(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(); } } private void setSelectTool() => toolboxCollection.Items.First().Select(); private void toolSelected(HitObjectCompositionTool tool) { 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 (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 SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) { var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); double? targetTime = null; 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); } public override float GetBeatSnapDistanceAt(HitObject referenceObject) { return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * referenceObject.DifficultyControlPoint.SliderVelocity / BeatSnapProvider.BeatDivisor); } public override float DurationToDistance(HitObject referenceObject, double duration) { double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceObject)); } public override double DistanceToDuration(HitObject referenceObject, float distance) { double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); return distance / GetBeatSnapDistanceAt(referenceObject) * beatLength; } public override double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) => BeatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; public override float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) { double startTime = referenceObject.StartTime; double actualDuration = startTime + DistanceToDuration(referenceObject, distance); double snappedEndTime = BeatSnapProvider.SnapTime(actualDuration, startTime); double beatLength = BeatSnapProvider.GetBeatLengthAtTime(startTime); // we don't want to exceed the actual duration and snap to a point in the future. // as we are snapping to beat length via SnapTime (which will round-to-nearest), check for snapping in the forward direction and reverse it. if (snappedEndTime > actualDuration + 1) snappedEndTime -= beatLength; return DurationToDistance(referenceObject, snappedEndTime - startTime); } #endregion private class LeftToolboxFlow : ExpandingButtonContainer { public LeftToolboxFlow() : base(80, 200) { RelativeSizeAxes = Axes.Y; Padding = new MarginPadding { Right = 10 }; FillFlow.Spacing = new Vector2(10); } } } /// /// A non-generic definition of a HitObject composer class. /// Generally used to access certain methods without requiring a generic type for . /// [Cached(typeof(HitObjectComposer))] [Cached(typeof(IPositionSnapProvider))] public abstract class HitObjectComposer : CompositeDrawable, IPositionSnapProvider { protected HitObjectComposer() { RelativeSizeAxes = Axes.Both; } /// /// The target ruleset's playfield. /// public abstract Playfield Playfield { 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; } public virtual string ConvertSelectionToString() => string.Empty; #region IPositionSnapProvider public abstract SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); public virtual SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, null); public abstract float GetBeatSnapDistanceAt(HitObject referenceObject); public abstract float DurationToDistance(HitObject referenceObject, double duration); public abstract double DistanceToDuration(HitObject referenceObject, float distance); public abstract double GetSnappedDurationFromDistance(HitObject referenceObject, float distance); public abstract float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance); #endregion } }