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 @@
-
+