// 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.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; 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.Osu.Objects; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; namespace osu.Game.Rulesets.Osu.Edit { public class OsuHitObjectComposer : HitObjectComposer { public OsuHitObjectComposer(Ruleset ruleset) : base(ruleset) { } protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableOsuEditRuleset(ruleset, beatmap, mods); protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { new HitCircleCompositionTool(), new SliderCompositionTool(), new SpinnerCompositionTool() }; private readonly Bindable distanceSnapToggle = new Bindable(); protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] { new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) }); private BindableList selectedHitObjects; private Bindable placementObject; [BackgroundDependencyLoader] private void load() { LayerBelowRuleset.AddRange(new Drawable[] { new PlayfieldBorder { RelativeSizeAxes = Axes.Both, PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }, distanceSnapGridContainer = new Container { RelativeSizeAxes = Axes.Both } }); selectedHitObjects = EditorBeatmap.SelectedHitObjects.GetBoundCopy(); selectedHitObjects.CollectionChanged += (_, __) => updateDistanceSnapGrid(); placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); placementObject.ValueChanged += _ => updateDistanceSnapGrid(); distanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); // we may be entering the screen with a selection already active updateDistanceSnapGrid(); } protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) => new OsuBlueprintContainer(hitObjects); private DistanceSnapGrid distanceSnapGrid; private Container distanceSnapGridContainer; private readonly Cached distanceSnapGridCache = new Cached(); private double? lastDistanceSnapGridTime; protected override void Update() { base.Update(); if (!(BlueprintContainer.CurrentTool is SelectTool)) { if (EditorClock.CurrentTime != lastDistanceSnapGridTime) { distanceSnapGridCache.Invalidate(); lastDistanceSnapGridTime = EditorClock.CurrentTime; } if (!distanceSnapGridCache.IsValid) updateDistanceSnapGrid(); } } public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) { if (snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) return snapResult; // will be null if distance snap is disabled or not feasible for the current time value. if (distanceSnapGrid == null) return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); } private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) { // check other on-screen objects for snapping/stacking var blueprints = BlueprintContainer.SelectionBlueprints.AliveChildren; var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); float snapRadius = playfield.GamefieldToScreenSpace(new Vector2(OsuHitObject.OBJECT_RADIUS / 5)).X - playfield.GamefieldToScreenSpace(Vector2.Zero).X; foreach (var b in blueprints) { if (b.IsSelected) continue; var hitObject = (OsuHitObject)b.HitObject; Vector2? snap = checkSnap(hitObject.Position); if (snap == null && hitObject.Position != hitObject.EndPosition) snap = checkSnap(hitObject.EndPosition); if (snap != null) { // only return distance portion, since time is not really valid snapResult = new SnapResult(snap.Value, null, playfield); return true; } Vector2? checkSnap(Vector2 checkPos) { Vector2 checkScreenPos = playfield.GamefieldToScreenSpace(checkPos); if (Vector2.Distance(checkScreenPos, screenSpacePosition) < snapRadius) return checkScreenPos; return null; } } snapResult = null; return false; } private void updateDistanceSnapGrid() { distanceSnapGridContainer.Clear(); distanceSnapGridCache.Invalidate(); distanceSnapGrid = null; if (distanceSnapToggle.Value != TernaryState.True) return; switch (BlueprintContainer.CurrentTool) { case SelectTool _: if (!EditorBeatmap.SelectedHitObjects.Any()) return; distanceSnapGrid = createDistanceSnapGrid(EditorBeatmap.SelectedHitObjects); break; default: if (!CursorInPlacementArea) return; distanceSnapGrid = createDistanceSnapGrid(Enumerable.Empty()); break; } if (distanceSnapGrid != null) { distanceSnapGridContainer.Add(distanceSnapGrid); distanceSnapGridCache.Validate(); } } private DistanceSnapGrid createDistanceSnapGrid(IEnumerable selectedHitObjects) { if (BlueprintContainer.CurrentTool is SpinnerCompositionTool) return null; var objects = selectedHitObjects.ToList(); if (objects.Count == 0) // use accurate time value to give more instantaneous feedback to the user. return createGrid(h => h.StartTime <= EditorClock.CurrentTimeAccurate); double minTime = objects.Min(h => h.StartTime); return createGrid(h => h.StartTime < minTime, objects.Count + 1); } /// /// Creates a grid from the last matching a predicate to a target . /// /// A predicate that matches s where the grid can start from. /// Only the last matching the predicate is used. /// An offset from the selected via at which the grid should stop. /// The from a selected to a target . private OsuDistanceSnapGrid createGrid(Func sourceSelector, int targetOffset = 1) { if (targetOffset < 1) throw new ArgumentOutOfRangeException(nameof(targetOffset)); int sourceIndex = -1; for (int i = 0; i < EditorBeatmap.HitObjects.Count; i++) { if (!sourceSelector(EditorBeatmap.HitObjects[i])) break; sourceIndex = i; } if (sourceIndex == -1) return null; HitObject sourceObject = EditorBeatmap.HitObjects[sourceIndex]; int targetIndex = sourceIndex + targetOffset; HitObject targetObject = null; // Keep advancing the target object while its start time falls before the end time of the source object while (true) { if (targetIndex >= EditorBeatmap.HitObjects.Count) break; if (EditorBeatmap.HitObjects[targetIndex].StartTime >= sourceObject.GetEndTime()) { targetObject = EditorBeatmap.HitObjects[targetIndex]; break; } targetIndex++; } if (sourceObject is Spinner) return null; return new OsuDistanceSnapGrid((OsuHitObject)sourceObject, (OsuHitObject)targetObject); } } }