diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index 842522ae87..bc2626d3d6 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -74,29 +74,35 @@ jobs: mkdir -p $GITHUB_WORKSPACE/master/ mkdir -p $GITHUB_WORKSPACE/pr/ + - name: Get upstream branch # https://akaimo.hatenablog.jp/entry/2020/05/16/101251 + id: upstreambranch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "::set-output name=branchname::$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" + echo "::set-output name=repo::$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" + # Checkout osu - name: Checkout osu (master) uses: actions/checkout@v2 with: - repository: peppy/osu - ref: 'diffcalc-optimisations' path: 'master/osu' - name: Checkout osu (pr) uses: actions/checkout@v2 with: path: 'pr/osu' + repository: ${{ steps.upstreambranch.outputs.repo }} + ref: ${{ steps.upstreambranch.outputs.branchname }} - name: Checkout osu-difficulty-calculator (master) uses: actions/checkout@v2 with: - repository: peppy/osu-difficulty-calculator - ref: 'bypass-attrib-row-insert' + repository: ppy/osu-difficulty-calculator path: 'master/osu-difficulty-calculator' - name: Checkout osu-difficulty-calculator (pr) uses: actions/checkout@v2 with: - repository: peppy/osu-difficulty-calculator - ref: 'bypass-attrib-row-insert' + repository: ppy/osu-difficulty-calculator path: 'pr/osu-difficulty-calculator' - name: Install .NET 5.0.x diff --git a/osu.Android.props b/osu.Android.props index 4859510e6c..8fad10d247 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,7 +52,7 @@ - + diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 9feaa55051..82d76252d2 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. foreach (var hitObject in beatmap.HitObjects - .SelectMany(obj => obj is JuiceStream stream ? stream.NestedHitObjects : new[] { obj }) + .SelectMany(obj => obj is JuiceStream stream ? stream.NestedHitObjects.AsEnumerable() : new[] { obj }) .Cast() .OrderBy(x => x.StartTime)) { diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs index fb58d805a9..e643b82271 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs @@ -149,7 +149,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { lowerBound ??= RandomStart; upperBound ??= TotalColumns; - nextColumn ??= (_ => GetRandomColumn(lowerBound, upperBound)); + nextColumn ??= _ => GetRandomColumn(lowerBound, upperBound); // Check for the initial column if (isValid(initialColumn)) @@ -176,7 +176,19 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy return initialColumn; - bool isValid(int column) => validation?.Invoke(column) != false && !patterns.Any(p => p.ColumnHasObject(column)); + bool isValid(int column) + { + if (validation?.Invoke(column) == false) + return false; + + foreach (var p in patterns) + { + if (p.ColumnHasObject(column)) + return false; + } + + return true; + } } /// diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs index f095a0ffce..828f2ec393 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs @@ -12,46 +12,68 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns /// internal class Pattern { - private readonly List hitObjects = new List(); + private List hitObjects; + private HashSet containedColumns; /// /// All the hit objects contained in this pattern. /// - public IEnumerable HitObjects => hitObjects; + public IEnumerable HitObjects => hitObjects ?? Enumerable.Empty(); /// /// Check whether a column of this patterns contains a hit object. /// /// The column index. /// Whether the column with index contains a hit object. - public bool ColumnHasObject(int column) => hitObjects.Exists(h => h.Column == column); + public bool ColumnHasObject(int column) => containedColumns?.Contains(column) == true; /// /// Amount of columns taken up by hit objects in this pattern. /// - public int ColumnWithObjects => HitObjects.GroupBy(h => h.Column).Count(); + public int ColumnWithObjects => containedColumns?.Count ?? 0; /// /// Adds a hit object to this pattern. /// /// The hit object to add. - public void Add(ManiaHitObject hitObject) => hitObjects.Add(hitObject); + public void Add(ManiaHitObject hitObject) + { + prepareStorage(); + + hitObjects.Add(hitObject); + containedColumns.Add(hitObject.Column); + } /// /// Copies hit object from another pattern to this one. /// /// The other pattern. - public void Add(Pattern other) => hitObjects.AddRange(other.HitObjects); + public void Add(Pattern other) + { + prepareStorage(); + + if (other.hitObjects != null) + { + hitObjects.AddRange(other.hitObjects); + + foreach (var h in other.hitObjects) + containedColumns.Add(h.Column); + } + } /// /// Clears this pattern, removing all hit objects. /// - public void Clear() => hitObjects.Clear(); + public void Clear() + { + hitObjects?.Clear(); + containedColumns?.Clear(); + } - /// - /// Removes a hit object from this pattern. - /// - /// The hit object to remove. - public bool Remove(ManiaHitObject hitObject) => hitObjects.Remove(hitObject); + private void prepareStorage() + { + hitObjects ??= new List(); + containedColumns ??= new HashSet(); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs new file mode 100644 index 0000000000..47b2d3a098 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneOsuEditorGrids : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestGridExclusivity() + { + AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + rectangularGridActive(false); + + AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + rectangularGridActive(true); + + AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + rectangularGridActive(false); + } + + private void rectangularGridActive(bool active) + { + AddStep("choose placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move cursor to (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(new Vector2(1, 1))); + }); + + if (active) + AddAssert("placement blueprint at (0, 0)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(0, 0))); + else + AddAssert("placement blueprint at (1, 1)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(1, 1))); + + AddStep("choose selection tool", () => InputManager.Key(Key.Number1)); + } + + [Test] + public void TestGridSizeToggling() + { + AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); + AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any()); + gridSizeIs(4); + + nextGridSizeIs(8); + nextGridSizeIs(16); + nextGridSizeIs(32); + nextGridSizeIs(4); + } + + private void nextGridSizeIs(int size) + { + AddStep("toggle to next grid size", () => InputManager.Key(Key.G)); + gridSizeIs(size); + } + + private void gridSizeIs(int size) + => AddAssert($"grid size is {size}", () => this.ChildrenOfType().Single().Spacing == new Vector2(size) + && EditorBeatmap.BeatmapInfo.GridSize == size); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index cb3338126c..4c8d0b2ce6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -21,6 +21,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty public class OsuDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.0675; + private double hitWindowGreat; public OsuDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -52,11 +53,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty double starRating = basePerformance > 0.00001 ? Math.Cbrt(1.12) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; - HitWindows hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); - - // Todo: These int casts are temporary to achieve 1:1 results with osu!stable, and should be removed in the future - double hitWindowGreat = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate; double preempt = (int)BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / clockRate; int maxCombo = beatmap.HitObjects.Count; @@ -96,12 +92,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty } } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - new Aim(mods), - new Speed(mods), - new Flashlight(mods) - }; + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + + // Todo: These int casts are temporary to achieve 1:1 results with osu!stable, and should be removed in the future + hitWindowGreat = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate; + + return new Skill[] + { + new Aim(mods), + new Speed(mods, hitWindowGreat), + new Flashlight(mods) + }; + } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index fa6c5c4d9c..8e8f9bc06e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -16,6 +16,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; + /// + /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms to account for simultaneous s. + /// + public double StrainTime { get; private set; } + /// /// Normalized distance from the end position of the previous to the start position of this . /// @@ -32,11 +37,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double? Angle { get; private set; } - /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 50ms. - /// - public readonly double StrainTime; - private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; @@ -48,8 +48,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing setDistances(); - // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure - StrainTime = Math.Max(50, DeltaTime); + // Capped to 25ms to prevent difficulty calculation breaking from simulatenous objects. + StrainTime = Math.Max(DeltaTime, 25); } private void setDistances() diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index f0eb199e5f..9364b11048 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -6,6 +6,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; +using osu.Framework.Utils; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -26,12 +27,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override double DifficultyMultiplier => 1.04; private const double min_speed_bonus = 75; // ~200BPM - private const double max_speed_bonus = 45; // ~330BPM private const double speed_balancing_factor = 40; - public Speed(Mod[] mods) + private readonly double greatWindow; + + public Speed(Mod[] mods, double hitWindowGreat) : base(mods) { + greatWindow = hitWindowGreat; } protected override double StrainValueOf(DifficultyHitObject current) @@ -40,13 +43,25 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills return 0; var osuCurrent = (OsuDifficultyHitObject)current; + var osuPrevious = Previous.Count > 0 ? (OsuDifficultyHitObject)Previous[0] : null; double distance = Math.Min(single_spacing_threshold, osuCurrent.TravelDistance + osuCurrent.JumpDistance); - double deltaTime = Math.Max(max_speed_bonus, current.DeltaTime); + double strainTime = osuCurrent.StrainTime; + + double greatWindowFull = greatWindow * 2; + double speedWindowRatio = strainTime / greatWindowFull; + + // Aim to nerf cheesy rhythms (Very fast consecutive doubles with large deltatimes between) + if (osuPrevious != null && strainTime < greatWindowFull && osuPrevious.StrainTime > strainTime) + strainTime = Interpolation.Lerp(osuPrevious.StrainTime, strainTime, speedWindowRatio); + + // Cap deltatime to the OD 300 hitwindow. + // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. + strainTime /= Math.Clamp((strainTime / greatWindowFull) / 0.93, 0.92, 1); double speedBonus = 1.0; - if (deltaTime < min_speed_bonus) - speedBonus = 1 + Math.Pow((min_speed_bonus - deltaTime) / speed_balancing_factor, 2); + if (strainTime < min_speed_bonus) + speedBonus = 1 + Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); double angleBonus = 1.0; @@ -64,7 +79,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills } } - return (1 + (speedBonus - 1) * 0.75) * angleBonus * (0.95 + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / osuCurrent.StrainTime; + return (1 + (speedBonus - 1) * 0.75) + * angleBonus + * (0.95 + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) + / strainTime; } } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 806b7e6051..1e84ec80e1 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -42,10 +42,12 @@ namespace osu.Game.Rulesets.Osu.Edit }; private readonly Bindable distanceSnapToggle = new Bindable(); + private readonly Bindable rectangularGridSnapToggle = new Bindable(); protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] { - new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }), + new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) }); private BindableList selectedHitObjects; @@ -63,6 +65,10 @@ namespace osu.Game.Rulesets.Osu.Edit PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }, distanceSnapGridContainer = new Container + { + RelativeSizeAxes = Axes.Both + }, + rectangularPositionSnapGrid = new OsuRectangularPositionSnapGrid { RelativeSizeAxes = Axes.Both } @@ -73,7 +79,19 @@ namespace osu.Game.Rulesets.Osu.Edit placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); placementObject.ValueChanged += _ => updateDistanceSnapGrid(); - distanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); + distanceSnapToggle.ValueChanged += _ => + { + updateDistanceSnapGrid(); + + if (distanceSnapToggle.Value == TernaryState.True) + rectangularGridSnapToggle.Value = TernaryState.False; + }; + + rectangularGridSnapToggle.ValueChanged += _ => + { + if (rectangularGridSnapToggle.Value == TernaryState.True) + distanceSnapToggle.Value = TernaryState.False; + }; // we may be entering the screen with a selection already active updateDistanceSnapGrid(); @@ -91,6 +109,8 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly Cached distanceSnapGridCache = new Cached(); private double? lastDistanceSnapGridTime; + private RectangularPositionSnapGrid rectangularPositionSnapGrid; + protected override void Update() { base.Update(); @@ -122,13 +142,19 @@ namespace osu.Game.Rulesets.Osu.Edit if (positionSnap.ScreenSpacePosition != screenSpacePosition) return positionSnap; - // will be null if distance snap is disabled or not feasible for the current time value. - if (distanceSnapGrid == null) - return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + if (distanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + { + (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } - (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + if (rectangularGridSnapToggle.Value == TernaryState.True) + { + Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(screenSpacePosition)); + return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } - return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); } private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..b8ff92bd37 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs @@ -0,0 +1,69 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class OsuRectangularPositionSnapGrid : RectangularPositionSnapGrid, IKeyBindingHandler + { + private static readonly int[] grid_sizes = { 4, 8, 16, 32 }; + + private int currentGridSizeIndex = grid_sizes.Length - 1; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + + public OsuRectangularPositionSnapGrid() + : base(OsuPlayfield.BASE_SIZE / 2) + { + } + + [BackgroundDependencyLoader] + private void load() + { + var gridSizeIndex = Array.IndexOf(grid_sizes, editorBeatmap.BeatmapInfo.GridSize); + if (gridSizeIndex >= 0) + currentGridSizeIndex = gridSizeIndex; + updateSpacing(); + } + + private void nextGridSize() + { + currentGridSizeIndex = (currentGridSizeIndex + 1) % grid_sizes.Length; + updateSpacing(); + } + + private void updateSpacing() + { + int gridSize = grid_sizes[currentGridSizeIndex]; + + editorBeatmap.BeatmapInfo.GridSize = gridSize; + Spacing = new Vector2(gridSize); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.EditorCycleGridDisplayMode: + nextGridSize(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs index fd7bfe7e60..7a071b5a03 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs @@ -13,26 +13,20 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public class LegacyReverseArrow : CompositeDrawable { - private ISkin skin { get; } - [Resolved(canBeNull: true)] private DrawableHitObject drawableHitObject { get; set; } private Drawable proxy; - public LegacyReverseArrow(ISkin skin) - { - this.skin = skin; - } - [BackgroundDependencyLoader] - private void load() + private void load(ISkinSource skinSource) { AutoSizeAxes = Axes.Both; string lookupName = new OsuSkinComponent(OsuSkinComponents.ReverseArrow).LookupName; - InternalChild = skin.GetAnimation(lookupName, true, true) ?? Empty(); + var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null); + InternalChild = skin?.GetAnimation(lookupName, true, true) ?? Empty(); } protected override void LoadComplete() diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index 8a24e36420..ff9f6f0e07 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy case OsuSkinComponents.ReverseArrow: if (hasHitCircle.Value) - return new LegacyReverseArrow(this); + return new LegacyReverseArrow(); return null; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs new file mode 100644 index 0000000000..5d8a6dabd7 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneBlueprintOrdering : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private EditorBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + [Test] + public void TestSelectedObjectHasPriorityWhenOverlapping() + { + var firstSlider = new Slider + { + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2()), + new PathControlPoint(new Vector2(150, -50)), + new PathControlPoint(new Vector2(300, 0)) + }), + Position = new Vector2(0, 100) + }; + var secondSlider = new Slider + { + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2()), + new PathControlPoint(new Vector2(-50, 50)), + new PathControlPoint(new Vector2(-100, 100)) + }), + Position = new Vector2(200, 0) + }; + + AddStep("add overlapping sliders", () => + { + EditorBeatmap.Add(firstSlider); + EditorBeatmap.Add(secondSlider); + }); + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(firstSlider)); + + AddStep("move mouse to common point", () => + { + var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; + InputManager.MoveMouseTo(pos); + }); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + + AddAssert("selection is unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == firstSlider); + } + + [Test] + public void TestOverlappingObjectsWithSameStartTime() + { + AddStep("add overlapping circles", () => + { + EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2)); + EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2 + new Vector2(-10, -20))); + EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2 + new Vector2(10, -20))); + }); + + AddStep("click at centre of playfield", () => + { + var hitObjectContainer = Editor.ChildrenOfType().Single(); + var centre = hitObjectContainer.ToScreenSpace(OsuPlayfield.BASE_SIZE / 2); + InputManager.MoveMouseTo(centre); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("frontmost object selected", () => + { + var hasCombo = Editor.ChildrenOfType().Single(b => b.IsSelected).Item as IHasComboInformation; + return hasCombo?.IndexInCurrentCombo == 0; + }); + } + + [Test] + public void TestPlacementOfConcurrentObjectWithDuration() + { + AddStep("seek to timing point", () => EditorClock.Seek(2170)); + AddStep("add hit circle", () => EditorBeatmap.Add(createHitCircle(2170, Vector2.Zero))); + + AddStep("choose spinner placement tool", () => + { + InputManager.Key(Key.Number4); + var hitObjectContainer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(hitObjectContainer.ScreenSpaceDrawQuad.Centre); + }); + + AddStep("begin placing spinner", () => + { + InputManager.Click(MouseButton.Left); + }); + AddStep("end placing spinner", () => + { + EditorClock.Seek(2500); + InputManager.Click(MouseButton.Right); + }); + + AddAssert("two timeline blueprints present", () => Editor.ChildrenOfType().Count() == 2); + } + + private HitCircle createHitCircle(double startTime, Vector2 position) => new HitCircle + { + StartTime = startTime, + Position = position, + }; + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs deleted file mode 100644 index 976bf93c15..0000000000 --- a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using NUnit.Framework; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Screens.Edit.Compose.Components; -using osu.Game.Tests.Beatmaps; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.Editing -{ - public class TestSceneBlueprintSelection : EditorTestScene - { - protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); - - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); - - private EditorBlueprintContainer blueprintContainer - => Editor.ChildrenOfType().First(); - - [Test] - public void TestSelectedObjectHasPriorityWhenOverlapping() - { - var firstSlider = new Slider - { - Path = new SliderPath(new[] - { - new PathControlPoint(new Vector2()), - new PathControlPoint(new Vector2(150, -50)), - new PathControlPoint(new Vector2(300, 0)) - }), - Position = new Vector2(0, 100) - }; - var secondSlider = new Slider - { - Path = new SliderPath(new[] - { - new PathControlPoint(new Vector2()), - new PathControlPoint(new Vector2(-50, 50)), - new PathControlPoint(new Vector2(-100, 100)) - }), - Position = new Vector2(200, 0) - }; - - AddStep("add overlapping sliders", () => - { - EditorBeatmap.Add(firstSlider); - EditorBeatmap.Add(secondSlider); - }); - AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(firstSlider)); - - AddStep("move mouse to common point", () => - { - var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; - InputManager.MoveMouseTo(pos); - }); - AddStep("right click", () => InputManager.Click(MouseButton.Right)); - - AddAssert("selection is unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == firstSlider); - } - } -} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs similarity index 98% rename from osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs rename to osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs index 4c4a87972f..a2a7b72283 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs @@ -20,14 +20,14 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorSelection : EditorTestScene + public class TestSceneComposerSelection : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); - private EditorBlueprintContainer blueprintContainer - => Editor.ChildrenOfType().First(); + private ComposeBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); private void moveMouseToObject(Func targetFunc) { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..85a98eca47 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs @@ -0,0 +1,104 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneRectangularPositionSnapGrid : OsuManualInputManagerTestScene + { + private Container content; + protected override Container Content => content; + + [BackgroundDependencyLoader] + private void load() + { + base.Content.AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Gray + }, + content = new Container + { + RelativeSizeAxes = Axes.Both + } + }); + } + + private static readonly object[][] test_cases = + { + new object[] { new Vector2(0, 0), new Vector2(10, 10) }, + new object[] { new Vector2(240, 180), new Vector2(10, 15) }, + new object[] { new Vector2(160, 120), new Vector2(30, 20) }, + new object[] { new Vector2(480, 360), new Vector2(100, 100) }, + }; + + [TestCaseSource(nameof(test_cases))] + public void TestRectangularGrid(Vector2 position, Vector2 spacing) + { + RectangularPositionSnapGrid grid = null; + + AddStep("create grid", () => Child = grid = new RectangularPositionSnapGrid(position) + { + RelativeSizeAxes = Axes.Both, + Spacing = spacing + }); + + AddStep("add snapping cursor", () => Add(new SnappingCursorContainer + { + RelativeSizeAxes = Axes.Both, + GetSnapPosition = pos => grid.GetSnappedPosition(grid.ToLocalSpace(pos)) + })); + } + + private class SnappingCursorContainer : CompositeDrawable + { + public Func GetSnapPosition; + + private readonly Drawable cursor; + + public SnappingCursorContainer() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = cursor = new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(50), + Colour = Color4.Red + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updatePosition(GetContainingInputManager().CurrentState.Mouse.Position); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + base.OnMouseMove(e); + + updatePosition(e.ScreenSpaceMousePosition); + return true; + } + + private void updatePosition(Vector2 screenSpacePosition) + { + cursor.Position = GetSnapPosition.Invoke(screenSpacePosition); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs new file mode 100644 index 0000000000..2544b6c2a1 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs @@ -0,0 +1,266 @@ +// 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 NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneTimelineSelection : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private TimelineBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + private void moveMouseToObject(Func targetFunc) + { + AddStep("move mouse to object", () => + { + var pos = blueprintContainer.SelectionBlueprints + .First(s => s.Item == targetFunc()) + .ChildrenOfType() + .First().ScreenSpaceDrawQuad.Centre; + + InputManager.MoveMouseTo(pos); + }); + } + + [Test] + public void TestNudgeSelection() + { + HitCircle[] addedObjects = null; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + })); + + AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + + AddStep("nudge forwards", () => InputManager.Key(Key.K)); + AddAssert("objects moved forwards in time", () => addedObjects[0].StartTime > 100); + + AddStep("nudge backwards", () => InputManager.Key(Key.J)); + AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100); + } + + [Test] + public void TestBasicSelect() + { + var addedObject = new HitCircle { StartTime = 100 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + moveMouseToObject(() => addedObject); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + var addedObject2 = new HitCircle + { + StartTime = 200, + Position = new Vector2(100), + }; + + AddStep("add one more hitobject", () => EditorBeatmap.Add(addedObject2)); + AddAssert("selection unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + moveMouseToObject(() => addedObject2); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject2); + } + + [Test] + public void TestMultiSelect() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObjects[0]); + + AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); + + moveMouseToObject(() => addedObjects[2]); + AddStep("click third", () => InputManager.Click(MouseButton.Left)); + AddAssert("3 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 3 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[2])); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && !EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); + + AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft)); + } + + [Test] + public void TestBasicDeselect() + { + var addedObject = new HitCircle { StartTime = 100 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + moveMouseToObject(() => addedObject); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + AddStep("click away", () => + { + InputManager.MoveMouseTo(Editor.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft + Vector2.One); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("selection lost", () => EditorBeatmap.SelectedHitObjects.Count == 0); + } + + [Test] + public void TestQuickDelete() + { + var addedObject = new HitCircle + { + StartTime = 0, + }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + moveMouseToObject(() => addedObject); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestRangeSelect() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + new HitCircle { StartTime = 500, Position = new Vector2(400) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObjects[1]); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + + moveMouseToObject(() => addedObjects[3]); + AddStep("click fourth", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Skip(1).Take(3)); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(2)); + + AddStep("clear selection", () => EditorBeatmap.SelectedHitObjects.Clear()); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(1)); + + AddStep("hold ctrl", () => InputManager.PressKey(Key.ControlLeft)); + moveMouseToObject(() => addedObjects[2]); + AddStep("click third", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(new[] { addedObjects[0], addedObjects[2] }); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + moveMouseToObject(() => addedObjects[4]); + AddStep("click fifth", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Except(new[] { addedObjects[1] })); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects); + + AddStep("clear selection", () => EditorBeatmap.SelectedHitObjects.Clear()); + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(1)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(2)); + + moveMouseToObject(() => addedObjects[2]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(3)); + + AddStep("release keys", () => + { + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + } + + [Test] + public void TestRangeSelectAfterExternalSelection() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("select all without mouse", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + assertSelectionIs(addedObjects); + + AddStep("hold down shift", () => InputManager.PressKey(Key.ShiftLeft)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second object", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects); + + moveMouseToObject(() => addedObjects[3]); + AddStep("click fourth object", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Skip(1)); + + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + } + + private void assertSelectionIs(IEnumerable hitObjects) + => AddAssert("correct hitobjects selected", () => EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).SequenceEqual(hitObjects)); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index bddc7ab731..04676f656f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -3,10 +3,12 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Rulesets; @@ -39,6 +41,45 @@ namespace osu.Game.Tests.Visual.Gameplay confirmClockRunning(true); } + [Test] + public void TestPauseWithLargeOffset() + { + double lastTime; + bool alwaysGoingForward = true; + + AddStep("force large offset", () => + { + var offset = (BindableDouble)LocalConfig.GetBindable(OsuSetting.AudioOffset); + + // use a large negative offset to avoid triggering a fail from forwards seeking. + offset.MinValue = -5000; + offset.Value = -5000; + }); + + AddStep("add time forward check hook", () => + { + lastTime = double.MinValue; + alwaysGoingForward = true; + + Player.OnUpdate += _ => + { + double currentTime = Player.GameplayClockContainer.CurrentTime; + alwaysGoingForward &= currentTime >= lastTime; + lastTime = currentTime; + }; + }); + + AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); + + pauseAndConfirm(); + + resumeAndConfirm(); + + AddAssert("time didn't go backwards", () => alwaysGoingForward); + + AddStep("reset offset", () => LocalConfig.SetValue(OsuSetting.AudioOffset, 0.0)); + } + [Test] public void TestPauseResume() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs index f5cba2c900..405461eec8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs @@ -24,9 +24,10 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestBasic() { - TestPopupDialog dialog = null; + TestPopupDialog firstDialog = null; + TestPopupDialog secondDialog = null; - AddStep("dialog #1", () => overlay.Push(dialog = new TestPopupDialog + AddStep("dialog #1", () => overlay.Push(firstDialog = new TestPopupDialog { Icon = FontAwesome.Regular.TrashAlt, HeaderText = @"Confirm deletion of", @@ -46,9 +47,9 @@ namespace osu.Game.Tests.Visual.UserInterface }, })); - AddAssert("first dialog displayed", () => overlay.CurrentDialog == dialog); + AddAssert("first dialog displayed", () => overlay.CurrentDialog == firstDialog); - AddStep("dialog #2", () => overlay.Push(dialog = new TestPopupDialog + AddStep("dialog #2", () => overlay.Push(secondDialog = new TestPopupDialog { Icon = FontAwesome.Solid.Cog, HeaderText = @"What do you want to do with", @@ -82,30 +83,33 @@ namespace osu.Game.Tests.Visual.UserInterface }, })); - AddAssert("second dialog displayed", () => overlay.CurrentDialog == dialog); + AddAssert("second dialog displayed", () => overlay.CurrentDialog == secondDialog); + AddAssert("first dialog is not part of hierarchy", () => firstDialog.Parent == null); } [Test] public void TestDismissBeforePush() { + TestPopupDialog testDialog = null; AddStep("dismissed dialog push", () => { - overlay.Push(new TestPopupDialog + overlay.Push(testDialog = new TestPopupDialog { State = { Value = Visibility.Hidden } }); }); AddAssert("no dialog pushed", () => overlay.CurrentDialog == null); + AddAssert("dialog is not part of hierarchy", () => testDialog.Parent == null); } [Test] public void TestDismissBeforePushViaButtonPress() { + TestPopupDialog testDialog = null; AddStep("dismissed dialog push", () => { - TestPopupDialog dialog; - overlay.Push(dialog = new TestPopupDialog + overlay.Push(testDialog = new TestPopupDialog { Buttons = new PopupDialogButton[] { @@ -113,10 +117,11 @@ namespace osu.Game.Tests.Visual.UserInterface }, }); - dialog.PerformOkAction(); + testDialog.PerformOkAction(); }); AddAssert("no dialog pushed", () => overlay.CurrentDialog == null); + AddAssert("dialog is not part of hierarchy", () => testDialog.Parent == null); } private class TestPopupDialog : PopupDialog diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs index 6ae7f7481e..3dd34f6c2f 100644 --- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -13,6 +13,7 @@ using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Framework.Threading; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using SharpCompress.Compressors; @@ -160,7 +161,7 @@ namespace osu.Game.Beatmaps try { - using (var db = new SqliteConnection(storage.GetDatabaseConnectionString("online"))) + using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage))) { db.Open(); diff --git a/osu.Game/Database/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs index d402195f29..94fa967d72 100644 --- a/osu.Game/Database/DatabaseContextFactory.cs +++ b/osu.Game/Database/DatabaseContextFactory.cs @@ -13,7 +13,7 @@ namespace osu.Game.Database { private readonly Storage storage; - private const string database_name = @"client"; + private const string database_name = @"client.db"; private ThreadLocal threadContexts; @@ -139,7 +139,7 @@ namespace osu.Game.Database threadContexts = new ThreadLocal(CreateContext, true); } - protected virtual OsuDbContext CreateContext() => new OsuDbContext(storage.GetDatabaseConnectionString(database_name)) + protected virtual OsuDbContext CreateContext() => new OsuDbContext(CreateDatabaseConnectionString(database_name, storage)) { Database = { AutoTransactionsEnabled = false } }; @@ -152,7 +152,7 @@ namespace osu.Game.Database try { - storage.DeleteDatabase(database_name); + storage.Delete(database_name); } catch { @@ -171,5 +171,7 @@ namespace osu.Game.Database recycleThreadContexts(); } + + public static string CreateDatabaseConnectionString(string filename, Storage storage) => string.Concat("Data Source=", storage.GetFullPath($@"{filename}", true)); } } diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index 42f628a75a..fe88e6f78a 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -274,21 +274,40 @@ namespace osu.Game.Graphics.UserInterface CornerRadius = corner_radius; Height = 40; - Foreground.Children = new Drawable[] + Foreground.Child = new GridContainer { - Text = new OsuSpriteText + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + new Dimension(GridSizeMode.AutoSize), }, - Icon = new SpriteIcon + ColumnDimensions = new[] { - Icon = FontAwesome.Solid.ChevronDown, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding { Right = 5 }, - Size = new Vector2(12), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), }, + Content = new[] + { + new Drawable[] + { + Text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + Truncate = true, + }, + Icon = new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronDown, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Horizontal = 5 }, + Size = new Vector2(12), + }, + } + } }; AddInternal(new HoverClickSounds()); diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs index 5b2549d2ee..b9ccc907d9 100644 --- a/osu.Game/IO/WrappedStorage.cs +++ b/osu.Game/IO/WrappedStorage.cs @@ -70,11 +70,6 @@ namespace osu.Game.IO public override Stream GetStream(string path, FileAccess access = FileAccess.Read, FileMode mode = FileMode.OpenOrCreate) => UnderlyingStorage.GetStream(MutatePath(path), access, mode); - public override string GetDatabaseConnectionString(string name) => - UnderlyingStorage.GetDatabaseConnectionString(MutatePath(name)); - - public override void DeleteDatabase(string name) => UnderlyingStorage.DeleteDatabase(MutatePath(name)); - public override void OpenPathInNativeExplorer(string path) => UnderlyingStorage.OpenPathInNativeExplorer(MutatePath(path)); public override Storage GetStorageForDirectory(string path) diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index f62131e2d7..9fd7caadd0 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -75,6 +75,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.A }, GlobalAction.EditorVerifyMode), new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), + new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), }; public IEnumerable InGameKeyBindings => new[] @@ -284,6 +285,9 @@ namespace osu.Game.Input.Bindings SeekReplayBackward, [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChatFocus))] - ToggleChatFocus + ToggleChatFocus, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridDisplayMode))] + EditorCycleGridDisplayMode } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 14159f0d34..06f1b094bf 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -164,6 +164,11 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorTimingMode => new TranslatableString(getKey(@"editor_timing_mode"), @"Timing mode"); + /// + /// "Cycle grid display mode" + /// + public static LocalisableString EditorCycleGridDisplayMode => new TranslatableString(getKey(@"editor_cycle_grid_display_mode"), @"Cycle grid display mode"); + /// /// "Hold for HUD" /// diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index d5d31343f2..f051e09c08 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -49,6 +49,8 @@ namespace osu.Game.Overlays Show(); } + public override bool IsPresent => dialogContainer.Children.Count > 0; + protected override bool BlockNonPositionalInput => true; private void onDialogOnStateChanged(VisibilityContainer dialog, Visibility v) diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index c4b9e6e1ad..ae0cb895bc 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -3,11 +3,12 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; +using osu.Framework.Extensions.ListExtensions; +using osu.Framework.Lists; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -83,7 +84,7 @@ namespace osu.Game.Rulesets.Objects private readonly List nestedHitObjects = new List(); [JsonIgnore] - public IReadOnlyList NestedHitObjects => nestedHitObjects; + public SlimReadOnlyListWrapper NestedHitObjects => nestedHitObjects.AsSlimReadOnly(); public HitObject() { @@ -91,7 +92,7 @@ namespace osu.Game.Rulesets.Objects { double offset = time.NewValue - time.OldValue; - foreach (var nested in NestedHitObjects) + foreach (var nested in nestedHitObjects) nested.StartTime += offset; }; } @@ -122,11 +123,14 @@ namespace osu.Game.Rulesets.Objects if (this is IHasComboInformation hasCombo) { - foreach (var n in NestedHitObjects.OfType()) + foreach (HitObject hitObject in nestedHitObjects) { - n.ComboIndexBindable.BindTo(hasCombo.ComboIndexBindable); - n.ComboIndexWithOffsetsBindable.BindTo(hasCombo.ComboIndexWithOffsetsBindable); - n.IndexInCurrentComboBindable.BindTo(hasCombo.IndexInCurrentComboBindable); + if (hitObject is IHasComboInformation n) + { + n.ComboIndexBindable.BindTo(hasCombo.ComboIndexBindable); + n.ComboIndexWithOffsetsBindable.BindTo(hasCombo.ComboIndexWithOffsetsBindable); + n.IndexInCurrentComboBindable.BindTo(hasCombo.IndexInCurrentComboBindable); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs index 4078661a26..3fc26fa974 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -1,12 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using osu.Framework.Bindables; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Screens.Edit.Compose.Components { @@ -15,69 +15,62 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public sealed class HitObjectOrderedSelectionContainer : Container> { + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + editorBeatmap.HitObjectUpdated += hitObjectUpdated; + } + + private void hitObjectUpdated(HitObject _) => SortInternal(); + public override void Add(SelectionBlueprint drawable) { + SortInternal(); base.Add(drawable); - bindStartTime(drawable); } public override bool Remove(SelectionBlueprint drawable) { - if (!base.Remove(drawable)) - return false; - - unbindStartTime(drawable); - return true; - } - - public override void Clear(bool disposeChildren) - { - base.Clear(disposeChildren); - unbindAllStartTimes(); - } - - private readonly Dictionary, IBindable> startTimeMap = new Dictionary, IBindable>(); - - private void bindStartTime(SelectionBlueprint blueprint) - { - var bindable = blueprint.Item.StartTimeBindable.GetBoundCopy(); - - bindable.BindValueChanged(_ => - { - if (LoadState >= LoadState.Ready) - SortInternal(); - }); - - startTimeMap[blueprint] = bindable; - } - - private void unbindStartTime(SelectionBlueprint blueprint) - { - startTimeMap[blueprint].UnbindAll(); - startTimeMap.Remove(blueprint); - } - - private void unbindAllStartTimes() - { - foreach (var kvp in startTimeMap) - kvp.Value.UnbindAll(); - startTimeMap.Clear(); + SortInternal(); + return base.Remove(drawable); } protected override int Compare(Drawable x, Drawable y) { - var xObj = (SelectionBlueprint)x; - var yObj = (SelectionBlueprint)y; + var xObj = ((SelectionBlueprint)x).Item; + var yObj = ((SelectionBlueprint)y).Item; // Put earlier blueprints towards the end of the list, so they handle input first - int i = yObj.Item.StartTime.CompareTo(xObj.Item.StartTime); - - if (i != 0) return i; + int result = yObj.StartTime.CompareTo(xObj.StartTime); + if (result != 0) return result; // Fall back to end time if the start time is equal. - i = yObj.Item.GetEndTime().CompareTo(xObj.Item.GetEndTime()); + result = yObj.GetEndTime().CompareTo(xObj.GetEndTime()); + if (result != 0) return result; - return i == 0 ? CompareReverseChildID(y, x) : i; + // As a final fallback, use combo information if available. + if (xObj is IHasComboInformation xHasCombo && yObj is IHasComboInformation yHasCombo) + { + result = yHasCombo.ComboIndex.CompareTo(xHasCombo.ComboIndex); + if (result != 0) return result; + + result = yHasCombo.IndexInCurrentCombo.CompareTo(xHasCombo.IndexInCurrentCombo); + if (result != 0) return result; + } + + return CompareReverseChildID(y, x); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorBeatmap != null) + editorBeatmap.HitObjectUpdated -= hitObjectUpdated; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..95b4b2fe53 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs @@ -0,0 +1,113 @@ +// 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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Layout; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class RectangularPositionSnapGrid : CompositeDrawable + { + /// + /// The position of the origin of this in local coordinates. + /// + public Vector2 StartPosition { get; } + + private Vector2 spacing = Vector2.One; + + /// + /// The spacing between grid lines of this . + /// + public Vector2 Spacing + { + get => spacing; + set + { + if (spacing.X <= 0 || spacing.Y <= 0) + throw new ArgumentException("Grid spacing must be positive."); + + spacing = value; + gridCache.Invalidate(); + } + } + + private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); + + public RectangularPositionSnapGrid(Vector2 startPosition) + { + StartPosition = startPosition; + + AddLayout(gridCache); + } + + protected override void Update() + { + base.Update(); + + if (!gridCache.IsValid) + { + ClearInternal(); + createContent(); + gridCache.Validate(); + } + } + + private void createContent() + { + var drawSize = DrawSize; + + generateGridLines(Direction.Horizontal, StartPosition.Y, 0, -Spacing.Y); + generateGridLines(Direction.Horizontal, StartPosition.Y, drawSize.Y, Spacing.Y); + + generateGridLines(Direction.Vertical, StartPosition.X, 0, -Spacing.X); + generateGridLines(Direction.Vertical, StartPosition.X, drawSize.X, Spacing.X); + } + + private void generateGridLines(Direction direction, float startPosition, float endPosition, float step) + { + int index = 0; + float currentPosition = startPosition; + + while ((endPosition - currentPosition) * Math.Sign(step) > 0) + { + var gridLine = new Box + { + Colour = Colour4.White, + Alpha = index == 0 ? 0.3f : 0.1f, + EdgeSmoothness = new Vector2(0.2f) + }; + + if (direction == Direction.Horizontal) + { + gridLine.RelativeSizeAxes = Axes.X; + gridLine.Height = 1; + gridLine.Y = currentPosition; + } + else + { + gridLine.RelativeSizeAxes = Axes.Y; + gridLine.Width = 1; + gridLine.X = currentPosition; + } + + AddInternal(gridLine); + + index += 1; + currentPosition = startPosition + index * step; + } + } + + public Vector2 GetSnappedPosition(Vector2 original) + { + Vector2 relativeToStart = original - StartPosition; + Vector2 offset = Vector2.Divide(relativeToStart, Spacing); + Vector2 roundedOffset = new Vector2(MathF.Round(offset.X), MathF.Round(offset.Y)); + + return StartPosition + Vector2.Multiply(roundedOffset, Spacing); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 44eb062db8..ee35b6a47c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -191,7 +191,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The blueprint. /// The mouse event responsible for selection. /// Whether a selection was performed. - internal bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) + internal virtual bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { if (e.ShiftPressed && e.Button == MouseButton.Right) { diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs index c43e554b85..845a671e2c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs @@ -1,13 +1,19 @@ // 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.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osuTK; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -62,5 +68,62 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline EditorBeatmap.Update(h); }); } + + /// + /// The "pivot" object, used in range selection mode. + /// When in range selection, the range to select is determined by the pivot object + /// (last existing object interacted with prior to holding down Shift) + /// and by the object clicked last when Shift was pressed. + /// + [CanBeNull] + private HitObject pivot; + + internal override bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) + { + if (e.ShiftPressed && e.Button == MouseButton.Left && pivot != null) + { + handleRangeSelection(blueprint, e.ControlPressed); + return true; + } + + bool result = base.MouseDownSelectionRequested(blueprint, e); + // ensure that the object wasn't removed by the base implementation before making it the new pivot. + if (EditorBeatmap.HitObjects.Contains(blueprint.Item)) + pivot = blueprint.Item; + return result; + } + + /// + /// Handles a request for range selection (triggered when Shift is held down). + /// + /// The blueprint which was clicked in range selection mode. + /// + /// Whether the selection should be cumulative. + /// In cumulative mode, consecutive range selections will shift the pivot (which usually stays fixed for the duration of a range selection) + /// and will never deselect an object that was previously selected. + /// + private void handleRangeSelection(SelectionBlueprint blueprint, bool cumulative) + { + var clickedObject = blueprint.Item; + + Debug.Assert(pivot != null); + + double rangeStart = Math.Min(clickedObject.StartTime, pivot.StartTime); + double rangeEnd = Math.Max(clickedObject.GetEndTime(), pivot.GetEndTime()); + + var newSelection = new HashSet(EditorBeatmap.HitObjects.Where(obj => isInRange(obj, rangeStart, rangeEnd))); + + if (cumulative) + { + pivot = clickedObject; + newSelection.UnionWith(EditorBeatmap.SelectedHitObjects); + } + + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.AddRange(newSelection); + + bool isInRange(HitObject hitObject, double start, double end) + => hitObject.StartTime >= start && hitObject.GetEndTime() <= end; + } } } diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 3fbb55872b..aa46522dec 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -158,10 +158,10 @@ namespace osu.Game.Screens.Play { // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. - platformOffsetClock = new HardwareCorrectionOffsetClock(source) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; + platformOffsetClock = new HardwareCorrectionOffsetClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; // the final usable gameplay clock with user-set offsets applied. - userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); + userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock, pauseFreqAdjust); return masterGameplayClock = new MasterGameplayClock(userOffsetClock); } @@ -216,11 +216,25 @@ namespace osu.Game.Screens.Play { // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. // base implementation already adds offset at 1.0 rate, so we only add the difference from that here. - public override double CurrentTime => base.CurrentTime + Offset * (Rate - 1); + public override double CurrentTime => base.CurrentTime + offsetAdjust; - public HardwareCorrectionOffsetClock(IClock source, bool processSource = true) - : base(source, processSource) + private readonly BindableDouble pauseRateAdjust; + + private double offsetAdjust; + + public HardwareCorrectionOffsetClock(IClock source, BindableDouble pauseRateAdjust) + : base(source) { + this.pauseRateAdjust = pauseRateAdjust; + } + + public override void ProcessFrame() + { + base.ProcessFrame(); + + // changing this during the pause transform effect will cause a potentially large offset to be suddenly applied as we approach zero rate. + if (pauseRateAdjust.Value == 1) + offsetAdjust = Offset * (Rate - 1); } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index e6afbe383a..ba118c5240 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index 51ca381b63..37931d0c38 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,7 +70,7 @@ - + @@ -93,7 +93,7 @@ - +