diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..8be6479043 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Normalize all the line endings +32a74f95a5c80a0ed18e693f13a47522099df5c3 diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml index 5b19c3732c..91ca622f55 100644 --- a/.github/ISSUE_TEMPLATE/bug-issue.yml +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -9,7 +9,7 @@ body: Important to note that your issue may have already been reported before. Please check: - Pinned issues, at the top of https://github.com/ppy/osu/issues. - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0). - - And most importantly, search for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful. + - And most importantly, search for your issue both in the [issue listing](https://github.com/ppy/osu/issues) and the [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a). If you find that it already exists, respond with a reaction or add any further information that may be helpful. - type: dropdown attributes: @@ -48,20 +48,28 @@ body: Attaching log files is required for every reported bug. See instructions below on how to find them. + **Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead. + + ### Desktop platforms + If the game has not yet been closed since you found the bug: 1. Head on to game settings and click on "Open osu! folder" 2. Then open the `logs` folder located there - **Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead. - - The default places to find the logs are as follows: + The default places to find the logs on desktop platforms are as follows: - `%AppData%/osu/logs` *on Windows* - `~/.local/share/osu/logs` *on Linux & macOS* - - `Android/data/sh.ppy.osulazer/files/logs` *on Android* - - *On iOS*, they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) If you have selected a custom location for the game files, you can find the `logs` folder there. + ### Mobile platforms + + The places to find the logs on mobile platforms are as follows: + - *On Android*, navigate to `Android/data/sh.ppy.osulazer/files/logs` using a file browser app. + - *On iOS*, connect your device to a PC and copy the `logs` directory from the app's document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) + + --- + After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below. - type: textarea diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAimAssist.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs similarity index 71% rename from osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAimAssist.cs rename to osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs index b8310bc4e7..9b49e60363 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAimAssist.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs @@ -6,18 +6,18 @@ using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModAimAssist : OsuModTestScene + public class TestSceneOsuModMagnetised : OsuModTestScene { [TestCase(0.1f)] [TestCase(0.5f)] [TestCase(1)] - public void TestAimAssist(float strength) + public void TestMagnetised(float strength) { CreateModTest(new ModTestData { - Mod = new OsuModAimAssist + Mod = new OsuModMagnetised { - AssistStrength = { Value = strength }, + AttractionStrength = { Value = strength }, }, PassCondition = () => true, Autoplay = false, diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 983964d639..aaf455e95f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Automation; public override string Description => @"Automatic cursor movement - just follow the rhythm."; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModAimAssist) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised) }; public bool PerformFail() => false; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs index 31179cdf4a..b31ef5d2fd 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModAutoplay : ModAutoplay { - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAimAssist), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray(); public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs index d677ab43d0..5b42772358 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModCinema : ModCinema { - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAimAssist), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray(); public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs similarity index 83% rename from osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs rename to osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs index 1abbd67d8f..ca6e9cfb1d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -16,20 +16,20 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModAimAssist : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + internal class OsuModMagnetised : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset { - public override string Name => "Aim Assist"; - public override string Acronym => "AA"; - public override IconUsage? Icon => FontAwesome.Solid.MousePointer; + public override string Name => "Magnetised"; + public override string Acronym => "MG"; + public override IconUsage? Icon => FontAwesome.Solid.Magnet; public override ModType Type => ModType.Fun; - public override string Description => "No need to chase the circle – the circle chases you!"; + public override string Description => "No need to chase the circles – your cursor is a magnet!"; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) }; private IFrameStableClock gameplayClock; - [SettingSource("Assist strength", "How much this mod will assist you.", 0)] - public BindableFloat AssistStrength { get; } = new BindableFloat(0.5f) + [SettingSource("Attraction strength", "How strong the pull is.", 0)] + public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f) { Precision = 0.05f, MinValue = 0.05f, @@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.Mods private void easeTo(DrawableHitObject hitObject, Vector2 destination) { - double dampLength = Interpolation.Lerp(3000, 40, AssistStrength.Value); + double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value); float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime); float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 7479c3120a..fea9246035 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -4,19 +4,14 @@ #nullable enable using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.Utils; -using osuTK; namespace osu.Game.Rulesets.Osu.Mods { @@ -28,12 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Description => "It never gets boring!"; private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; - private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2; - - /// - /// Number of previous hitobjects to be shifted together when another object is being moved. - /// - private const int preceding_hitobjects_to_shift = 10; private Random? rng; @@ -42,330 +31,33 @@ namespace osu.Game.Rulesets.Osu.Mods if (!(beatmap is OsuBeatmap osuBeatmap)) return; - var hitObjects = osuBeatmap.HitObjects; - Seed.Value ??= RNG.Next(); rng = new Random((int)Seed.Value); - var randomObjects = randomiseObjects(hitObjects); + var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects); - applyRandomisation(hitObjects, randomObjects); - } - - /// - /// Randomise the position of each hit object and return a list of s describing how each hit object should be placed. - /// - /// A list of s to have their positions randomised. - /// A list of s describing how each hit object should be placed. - private List randomiseObjects(IEnumerable hitObjects) - { - Debug.Assert(rng != null, $"{nameof(ApplyToBeatmap)} was not called before randomising objects"); - - var randomObjects = new List(); - RandomObjectInfo? previous = null; float rateOfChangeMultiplier = 0; - foreach (OsuHitObject hitObject in hitObjects) + foreach (var positionInfo in positionInfos) { - var current = new RandomObjectInfo(hitObject); - randomObjects.Add(current); - // rateOfChangeMultiplier only changes every 5 iterations in a combo // to prevent shaky-line-shaped streams - if (hitObject.IndexInCurrentCombo % 5 == 0) + if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0) rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1; - if (previous == null) + if (positionInfo == positionInfos.First()) { - current.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2); - current.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); + positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2); + positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); } else { - current.DistanceFromPrevious = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal); - - // The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object) - // is proportional to the distance between the last and the current hit object - // to allow jumps and prevent too sharp turns during streams. - - // Allow maximum jump angle when jump distance is more than half of playfield diagonal length - current.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, current.DistanceFromPrevious / (playfield_diagonal * 0.5f)); + positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f)); } - - previous = current; } - return randomObjects; - } - - /// - /// Reposition the hit objects according to the information in . - /// - /// The hit objects to be repositioned. - /// A list of describing how each hit object should be placed. - private void applyRandomisation(IReadOnlyList hitObjects, IReadOnlyList randomObjects) - { - RandomObjectInfo? previous = null; - - for (int i = 0; i < hitObjects.Count; i++) - { - var hitObject = hitObjects[i]; - - var current = randomObjects[i]; - - if (hitObject is Spinner) - { - previous = null; - continue; - } - - computeRandomisedPosition(current, previous, i > 1 ? randomObjects[i - 2] : null); - - // Move hit objects back into the playfield if they are outside of it - Vector2 shift = Vector2.Zero; - - switch (hitObject) - { - case HitCircle circle: - shift = clampHitCircleToPlayfield(circle, current); - break; - - case Slider slider: - shift = clampSliderToPlayfield(slider, current); - break; - } - - if (shift != Vector2.Zero) - { - var toBeShifted = new List(); - - for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--) - { - // only shift hit circles - if (!(hitObjects[j] is HitCircle)) break; - - toBeShifted.Add(hitObjects[j]); - } - - if (toBeShifted.Count > 0) - applyDecreasingShift(toBeShifted, shift); - } - - previous = current; - } - } - - /// - /// Compute the randomised position of a hit object while attempting to keep it inside the playfield. - /// - /// The representing the hit object to have the randomised position computed for. - /// The representing the hit object immediately preceding the current one. - /// The representing the hit object immediately preceding the one. - private void computeRandomisedPosition(RandomObjectInfo current, RandomObjectInfo? previous, RandomObjectInfo? beforePrevious) - { - float previousAbsoluteAngle = 0f; - - if (previous != null) - { - Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre; - Vector2 relativePosition = previous.HitObject.Position - earliestPosition; - previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); - } - - float absoluteAngle = previousAbsoluteAngle + current.RelativeAngle; - - var posRelativeToPrev = new Vector2( - current.DistanceFromPrevious * (float)Math.Cos(absoluteAngle), - current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle) - ); - - Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre; - - posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev); - - current.PositionRandomised = lastEndPosition + posRelativeToPrev; - } - - /// - /// Move the randomised position of a hit circle so that it fits inside the playfield. - /// - /// The deviation from the original randomised position in order to fit within the playfield. - private Vector2 clampHitCircleToPlayfield(HitCircle circle, RandomObjectInfo objectInfo) - { - var previousPosition = objectInfo.PositionRandomised; - objectInfo.EndPositionRandomised = objectInfo.PositionRandomised = clampToPlayfieldWithPadding( - objectInfo.PositionRandomised, - (float)circle.Radius - ); - - circle.Position = objectInfo.PositionRandomised; - - return objectInfo.PositionRandomised - previousPosition; - } - - /// - /// Moves the and all necessary nested s into the if they aren't already. - /// - /// The deviation from the original randomised position in order to fit within the playfield. - private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo) - { - var possibleMovementBounds = calculatePossibleMovementBounds(slider); - - var previousPosition = objectInfo.PositionRandomised; - - // Clamp slider position to the placement area - // If the slider is larger than the playfield, force it to stay at the original position - float newX = possibleMovementBounds.Width < 0 - ? objectInfo.PositionOriginal.X - : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right); - - float newY = possibleMovementBounds.Height < 0 - ? objectInfo.PositionOriginal.Y - : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom); - - slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY); - objectInfo.EndPositionRandomised = slider.EndPosition; - - shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal); - - return objectInfo.PositionRandomised - previousPosition; - } - - /// - /// Decreasingly shift a list of s by a specified amount. - /// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount. - /// - /// The list of hit objects to be shifted. - /// The amount to be shifted. - private void applyDecreasingShift(IList hitObjects, Vector2 shift) - { - for (int i = 0; i < hitObjects.Count; i++) - { - var hitObject = hitObjects[i]; - // The first object is shifted by a vector slightly smaller than shift - // The last object is shifted by a vector slightly larger than zero - Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1)); - - hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius); - } - } - - /// - /// Calculates a which contains all of the possible movements of the slider (in relative X/Y coordinates) - /// such that the entire slider is inside the playfield. - /// - /// - /// If the slider is larger than the playfield, the returned may have negative width/height. - /// - private RectangleF calculatePossibleMovementBounds(Slider slider) - { - var pathPositions = new List(); - slider.Path.GetPathToProgress(pathPositions, 0, 1); - - float minX = float.PositiveInfinity; - float maxX = float.NegativeInfinity; - - float minY = float.PositiveInfinity; - float maxY = float.NegativeInfinity; - - // Compute the bounding box of the slider. - foreach (var pos in pathPositions) - { - minX = MathF.Min(minX, pos.X); - maxX = MathF.Max(maxX, pos.X); - - minY = MathF.Min(minY, pos.Y); - maxY = MathF.Max(maxY, pos.Y); - } - - // Take the circle radius into account. - float radius = (float)slider.Radius; - - minX -= radius; - minY -= radius; - - maxX += radius; - maxY += radius; - - // Given the bounding box of the slider (via min/max X/Y), - // the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right), - // and the amount that it can move to the right is WIDTH - maxX. - // Same calculation applies for the Y axis. - float left = -minX; - float right = OsuPlayfield.BASE_SIZE.X - maxX; - float top = -minY; - float bottom = OsuPlayfield.BASE_SIZE.Y - maxY; - - return new RectangleF(left, top, right - left, bottom - top); - } - - /// - /// Shifts all nested s and s by the specified shift. - /// - /// whose nested s and s should be shifted - /// The the 's nested s and s should be shifted by - private void shiftNestedObjects(Slider slider, Vector2 shift) - { - foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat)) - { - if (!(hitObject is OsuHitObject osuHitObject)) - continue; - - osuHitObject.Position += shift; - } - } - - /// - /// Clamp a position to playfield, keeping a specified distance from the edges. - /// - /// The position to be clamped. - /// The minimum distance allowed from playfield edges. - /// The clamped position. - private Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding) - { - return new Vector2( - Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding), - Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding) - ); - } - - private class RandomObjectInfo - { - /// - /// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle. - /// - /// - /// of the first hit object in a beatmap represents the absolute angle from playfield center to the object. - /// - /// - /// If is 0, the player's cursor doesn't need to change its direction of movement when passing - /// the previous object to reach this one. - /// - public float RelativeAngle { get; set; } - - /// - /// The jump distance from the previous hit object to this one. - /// - /// - /// of the first hit object in a beatmap is relative to the playfield center. - /// - public float DistanceFromPrevious { get; set; } - - public Vector2 PositionOriginal { get; } - public Vector2 PositionRandomised { get; set; } - - public Vector2 EndPositionOriginal { get; } - public Vector2 EndPositionRandomised { get; set; } - - public OsuHitObject HitObject { get; } - - public RandomObjectInfo(OsuHitObject hitObject) - { - PositionRandomised = PositionOriginal = hitObject.Position; - EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition; - HitObject = hitObject; - } + osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 9719de441e..6b81efdca6 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer { public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModAimAssist) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised) }).ToArray(); /// /// How early before a hitobject's start time to trigger a hit. diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs index 28c3b069b6..45ce4d555a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Fun; public override string Description => "Everything rotates. EVERYTHING."; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModAimAssist) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised) }; private float theta; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index 40a05400ea..693a5bee0b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Fun; public override string Description => "They just won't stay still..."; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModAimAssist) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised) }; private const int wiggle_duration = 90; // (ms) Higher = fewer wiggles private const int wiggle_strength = 10; // Higher = stronger wiggles diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 47a2618ddd..207e7a4ab0 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -195,7 +195,7 @@ namespace osu.Game.Rulesets.Osu new OsuModApproachDifferent(), new OsuModMuted(), new OsuModNoScope(), - new OsuModAimAssist(), + new OsuModMagnetised(), new ModAdaptiveSpeed() }; diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index 97a4b14a62..da73c2addb 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Utils { - public static class OsuHitObjectGenerationUtils + public static partial class OsuHitObjectGenerationUtils { // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle. // The closer the hit objects draw to the border, the sharper the turn diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs new file mode 100644 index 0000000000..d1bc3b45df --- /dev/null +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs @@ -0,0 +1,340 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osuTK; + +#nullable enable + +namespace osu.Game.Rulesets.Osu.Utils +{ + public static partial class OsuHitObjectGenerationUtils + { + /// + /// Number of previous hitobjects to be shifted together when an object is being moved. + /// + private const int preceding_hitobjects_to_shift = 10; + + private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2; + + /// + /// Generate a list of s containing information for how the given list of + /// s are positioned. + /// + /// A list of s to process. + /// A list of s describing how each hit object is positioned relative to the previous one. + public static List GeneratePositionInfos(IEnumerable hitObjects) + { + var positionInfos = new List(); + Vector2 previousPosition = playfield_centre; + float previousAngle = 0; + + foreach (OsuHitObject hitObject in hitObjects) + { + Vector2 relativePosition = hitObject.Position - previousPosition; + float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); + float relativeAngle = absoluteAngle - previousAngle; + + positionInfos.Add(new ObjectPositionInfo(hitObject) + { + RelativeAngle = relativeAngle, + DistanceFromPrevious = relativePosition.Length + }); + + previousPosition = hitObject.EndPosition; + previousAngle = absoluteAngle; + } + + return positionInfos; + } + + /// + /// Reposition the hit objects according to the information in . + /// + /// Position information for each hit object. + /// The repositioned hit objects. + public static List RepositionHitObjects(IEnumerable objectPositionInfos) + { + List workingObjects = objectPositionInfos.Select(o => new WorkingObject(o)).ToList(); + WorkingObject? previous = null; + + for (int i = 0; i < workingObjects.Count; i++) + { + var current = workingObjects[i]; + var hitObject = current.HitObject; + + if (hitObject is Spinner) + { + previous = null; + continue; + } + + computeModifiedPosition(current, previous, i > 1 ? workingObjects[i - 2] : null); + + // Move hit objects back into the playfield if they are outside of it + Vector2 shift = Vector2.Zero; + + switch (hitObject) + { + case HitCircle _: + shift = clampHitCircleToPlayfield(current); + break; + + case Slider _: + shift = clampSliderToPlayfield(current); + break; + } + + if (shift != Vector2.Zero) + { + var toBeShifted = new List(); + + for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--) + { + // only shift hit circles + if (!(workingObjects[j].HitObject is HitCircle)) break; + + toBeShifted.Add(workingObjects[j].HitObject); + } + + if (toBeShifted.Count > 0) + applyDecreasingShift(toBeShifted, shift); + } + + previous = current; + } + + return workingObjects.Select(p => p.HitObject).ToList(); + } + + /// + /// Compute the modified position of a hit object while attempting to keep it inside the playfield. + /// + /// The representing the hit object to have the modified position computed for. + /// The representing the hit object immediately preceding the current one. + /// The representing the hit object immediately preceding the one. + private static void computeModifiedPosition(WorkingObject current, WorkingObject? previous, WorkingObject? beforePrevious) + { + float previousAbsoluteAngle = 0f; + + if (previous != null) + { + Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre; + Vector2 relativePosition = previous.HitObject.Position - earliestPosition; + previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); + } + + float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle; + + var posRelativeToPrev = new Vector2( + current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle), + current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle) + ); + + Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre; + + posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev); + + current.PositionModified = lastEndPosition + posRelativeToPrev; + } + + /// + /// Move the modified position of a so that it fits inside the playfield. + /// + /// The deviation from the original modified position in order to fit within the playfield. + private static Vector2 clampHitCircleToPlayfield(WorkingObject workingObject) + { + var previousPosition = workingObject.PositionModified; + workingObject.EndPositionModified = workingObject.PositionModified = clampToPlayfieldWithPadding( + workingObject.PositionModified, + (float)workingObject.HitObject.Radius + ); + + workingObject.HitObject.Position = workingObject.PositionModified; + + return workingObject.PositionModified - previousPosition; + } + + /// + /// Moves the and all necessary nested s into the if they aren't already. + /// + /// The deviation from the original modified position in order to fit within the playfield. + private static Vector2 clampSliderToPlayfield(WorkingObject workingObject) + { + var slider = (Slider)workingObject.HitObject; + var possibleMovementBounds = calculatePossibleMovementBounds(slider); + + var previousPosition = workingObject.PositionModified; + + // Clamp slider position to the placement area + // If the slider is larger than the playfield, force it to stay at the original position + float newX = possibleMovementBounds.Width < 0 + ? workingObject.PositionOriginal.X + : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right); + + float newY = possibleMovementBounds.Height < 0 + ? workingObject.PositionOriginal.Y + : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom); + + slider.Position = workingObject.PositionModified = new Vector2(newX, newY); + workingObject.EndPositionModified = slider.EndPosition; + + shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal); + + return workingObject.PositionModified - previousPosition; + } + + /// + /// Decreasingly shift a list of s by a specified amount. + /// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount. + /// + /// The list of hit objects to be shifted. + /// The amount to be shifted. + private static void applyDecreasingShift(IList hitObjects, Vector2 shift) + { + for (int i = 0; i < hitObjects.Count; i++) + { + var hitObject = hitObjects[i]; + // The first object is shifted by a vector slightly smaller than shift + // The last object is shifted by a vector slightly larger than zero + Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1)); + + hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius); + } + } + + /// + /// Calculates a which contains all of the possible movements of the slider (in relative X/Y coordinates) + /// such that the entire slider is inside the playfield. + /// + /// + /// If the slider is larger than the playfield, the returned may have negative width/height. + /// + private static RectangleF calculatePossibleMovementBounds(Slider slider) + { + var pathPositions = new List(); + slider.Path.GetPathToProgress(pathPositions, 0, 1); + + float minX = float.PositiveInfinity; + float maxX = float.NegativeInfinity; + + float minY = float.PositiveInfinity; + float maxY = float.NegativeInfinity; + + // Compute the bounding box of the slider. + foreach (var pos in pathPositions) + { + minX = MathF.Min(minX, pos.X); + maxX = MathF.Max(maxX, pos.X); + + minY = MathF.Min(minY, pos.Y); + maxY = MathF.Max(maxY, pos.Y); + } + + // Take the circle radius into account. + float radius = (float)slider.Radius; + + minX -= radius; + minY -= radius; + + maxX += radius; + maxY += radius; + + // Given the bounding box of the slider (via min/max X/Y), + // the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right), + // and the amount that it can move to the right is WIDTH - maxX. + // Same calculation applies for the Y axis. + float left = -minX; + float right = OsuPlayfield.BASE_SIZE.X - maxX; + float top = -minY; + float bottom = OsuPlayfield.BASE_SIZE.Y - maxY; + + return new RectangleF(left, top, right - left, bottom - top); + } + + /// + /// Shifts all nested s and s by the specified shift. + /// + /// whose nested s and s should be shifted + /// The the 's nested s and s should be shifted by + private static void shiftNestedObjects(Slider slider, Vector2 shift) + { + foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat)) + { + if (!(hitObject is OsuHitObject osuHitObject)) + continue; + + osuHitObject.Position += shift; + } + } + + /// + /// Clamp a position to playfield, keeping a specified distance from the edges. + /// + /// The position to be clamped. + /// The minimum distance allowed from playfield edges. + /// The clamped position. + private static Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding) + { + return new Vector2( + Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding), + Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding) + ); + } + + public class ObjectPositionInfo + { + /// + /// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle. + /// + /// + /// of the first hit object in a beatmap represents the absolute angle from playfield center to the object. + /// + /// + /// If is 0, the player's cursor doesn't need to change its direction of movement when passing + /// the previous object to reach this one. + /// + public float RelativeAngle { get; set; } + + /// + /// The jump distance from the previous hit object to this one. + /// + /// + /// of the first hit object in a beatmap is relative to the playfield center. + /// + public float DistanceFromPrevious { get; set; } + + /// + /// The hit object associated with this . + /// + public OsuHitObject HitObject { get; } + + public ObjectPositionInfo(OsuHitObject hitObject) + { + HitObject = hitObject; + } + } + + private class WorkingObject + { + public Vector2 PositionOriginal { get; } + public Vector2 PositionModified { get; set; } + public Vector2 EndPositionModified { get; set; } + + public ObjectPositionInfo PositionInfo { get; } + public OsuHitObject HitObject => PositionInfo.HitObject; + + public WorkingObject(ObjectPositionInfo positionInfo) + { + PositionInfo = positionInfo; + PositionModified = PositionOriginal = HitObject.Position; + EndPositionModified = HitObject.EndPosition; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs index 064d6f82fd..87d836687f 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs @@ -2,10 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Timing; +using osu.Game.Configuration; using osu.Game.Overlays.Toolbar; using osuTK; using osuTK.Graphics; @@ -15,7 +18,10 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public class TestSceneToolbarClock : OsuManualInputManagerTestScene { + private Bindable clockDisplayMode; + private readonly Container mainContainer; + private readonly ToolbarClock toolbarClock; public TestSceneToolbarClock() { @@ -49,7 +55,7 @@ namespace osu.Game.Tests.Visual.Menus RelativeSizeAxes = Axes.Y, Width = 2, }, - new ToolbarClock(), + toolbarClock = new ToolbarClock(), new Box { Colour = Color4.DarkRed, @@ -65,6 +71,12 @@ namespace osu.Game.Tests.Visual.Menus AddSliderStep("scale", 0.5, 4, 1, scale => mainContainer.Scale = new Vector2((float)scale)); } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + clockDisplayMode = config.GetBindable(OsuSetting.ToolbarClockDisplayMode); + } + [Test] public void TestRealGameTime() { @@ -76,5 +88,20 @@ namespace osu.Game.Tests.Visual.Menus { AddStep("Set game time long", () => mainContainer.Clock = new FramedOffsetClock(Clock, false) { Offset = 3600.0 * 24 * 1000 * 98 }); } + + [Test] + public void TestDisplayModeChange() + { + AddStep("Set clock display mode", () => clockDisplayMode.Value = ToolbarClockDisplayMode.Full); + + AddStep("Trigger click", () => toolbarClock.TriggerClick()); + AddAssert("State is digital with runtime", () => clockDisplayMode.Value == ToolbarClockDisplayMode.DigitalWithRuntime); + AddStep("Trigger click", () => toolbarClock.TriggerClick()); + AddAssert("State is digital", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Digital); + AddStep("Trigger click", () => toolbarClock.TriggerClick()); + AddAssert("State is analog", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Analog); + AddStep("Trigger click", () => toolbarClock.TriggerClick()); + AddAssert("State is full", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Full); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index a056e0cd2c..5999125013 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs @@ -5,9 +5,11 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -29,6 +31,14 @@ namespace osu.Game.Tests.Visual.Online private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType().Single(); + private OsuConfigManager localConfig; + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage)); + } + [SetUpSteps] public void SetUpSteps() { @@ -61,6 +71,8 @@ namespace osu.Game.Tests.Visual.Online Id = API.LocalUser.Value.Id + 1, }; }); + + AddStep("reset size", () => localConfig.SetValue(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal)); } [Test] @@ -121,23 +133,23 @@ namespace osu.Game.Tests.Visual.Online } [Test] - public void TestCardSizeSwitching() + public void TestCardSizeSwitching([Values] bool viaConfig) { AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); assertAllCardsOfType(100); - setCardSize(BeatmapCardSize.Extra); + setCardSize(BeatmapCardSize.Extra, viaConfig); assertAllCardsOfType(100); - setCardSize(BeatmapCardSize.Normal); + setCardSize(BeatmapCardSize.Normal, viaConfig); assertAllCardsOfType(100); AddStep("fetch for 0 beatmaps", () => fetchFor()); AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); - setCardSize(BeatmapCardSize.Extra); + setCardSize(BeatmapCardSize.Extra, viaConfig); AddAssert("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); } @@ -361,7 +373,13 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("\"no maps found\" placeholder not shown", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); } - private void setCardSize(BeatmapCardSize cardSize) => AddStep($"set card size to {cardSize}", () => overlay.ChildrenOfType().Single().Current.Value = cardSize); + private void setCardSize(BeatmapCardSize cardSize, bool viaConfig) => AddStep($"set card size to {cardSize}", () => + { + if (viaConfig) + localConfig.SetValue(OsuSetting.BeatmapListingCardSize, cardSize); + else + overlay.ChildrenOfType().Single().Current.Value = cardSize; + }); private void assertAllCardsOfType(int expectedCount) where T : BeatmapCard => @@ -370,5 +388,11 @@ namespace osu.Game.Tests.Visual.Online int loadedCorrectCount = this.ChildrenOfType().Count(card => card.IsLoaded && card.GetType() == typeof(T)); return loadedCorrectCount > 0 && loadedCorrectCount == expectedCount; }); + + protected override void Dispose(bool isDisposing) + { + localConfig?.Dispose(); + base.Dispose(isDisposing); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs index be3fc7aff9..82b34c50c2 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs @@ -71,7 +71,9 @@ namespace osu.Game.Tests.Visual.Online { Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(), Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(), - } + }, + PassCount = RNG.Next(0, 999), + PlayCount = RNG.Next(1000, 1999), }; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs new file mode 100644 index 0000000000..a241aa0517 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs @@ -0,0 +1,122 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Chat; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneChatTextBox : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + [Cached] + private readonly Bindable currentChannel = new Bindable(); + + private OsuSpriteText commitText; + private OsuSpriteText searchText; + private ChatTextBar bar; + + [SetUp] + public void SetUp() + { + Schedule(() => + { + Child = new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 30), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + commitText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Default.With(size: 20), + }, + searchText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Default.With(size: 20), + }, + }, + }, + }, + }, + new Drawable[] + { + bar = new ChatTextBar + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 0.99f, + }, + }, + }, + }; + + bar.OnChatMessageCommitted += text => + { + commitText.Text = $"{nameof(bar.OnChatMessageCommitted)}: {text}"; + commitText.FadeOutFromOne(1000, Easing.InQuint); + }; + + bar.OnSearchTermsChanged += text => + { + searchText.Text = $"{nameof(bar.OnSearchTermsChanged)}: {text}"; + }; + }); + } + + [Test] + public void TestVisual() + { + AddStep("Public Channel", () => currentChannel.Value = createPublicChannel("#osu")); + AddStep("Public Channel Long Name", () => currentChannel.Value = createPublicChannel("#public-channel-long-name")); + AddStep("Private Channel", () => currentChannel.Value = createPrivateChannel("peppy", 2)); + AddStep("Private Long Name", () => currentChannel.Value = createPrivateChannel("test user long name", 3)); + + AddStep("Chat Mode Channel", () => bar.ShowSearch.Value = false); + AddStep("Chat Mode Search", () => bar.ShowSearch.Value = true); + } + + private static Channel createPublicChannel(string name) + => new Channel { Name = name, Type = ChannelType.Public, Id = 1234 }; + + private static Channel createPrivateChannel(string username, int id) + => new Channel(new APIUser { Id = id, Username = username }); + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs index 0af77b3b5a..f9c9b2a68b 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs @@ -1,16 +1,21 @@ // 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.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osu.Game.Overlays.Settings.Sections; +using osu.Game.Overlays.Settings.Sections.Input; +using osuTK.Input; namespace osu.Game.Tests.Visual.Settings { [TestFixture] - public class TestSceneSettingsPanel : OsuTestScene + public class TestSceneSettingsPanel : OsuManualInputManagerTestScene { private SettingsPanel settings; private DialogOverlay dialogOverlay; @@ -33,7 +38,55 @@ namespace osu.Game.Tests.Visual.Settings public void ToggleVisibility() { AddWaitStep("wait some", 5); - AddToggleStep("toggle editor visibility", visible => settings.ToggleVisibility()); + AddToggleStep("toggle visibility", visible => settings.ToggleVisibility()); + } + + [Test] + public void TestTextboxFocusAfterNestedPanelBackButton() + { + AddUntilStep("sections loaded", () => settings.SectionsContainer.Children.Count > 0); + AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); + + AddStep("open key binding subpanel", () => + { + settings.SectionsContainer + .ChildrenOfType().FirstOrDefault()? + .ChildrenOfType().FirstOrDefault()? + .TriggerClick(); + }); + + AddUntilStep("binding panel textbox focused", () => settings + .ChildrenOfType().FirstOrDefault()? + .ChildrenOfType().FirstOrDefault()?.HasFocus == true); + + AddStep("Press back", () => settings + .ChildrenOfType().FirstOrDefault()? + .ChildrenOfType().FirstOrDefault()?.TriggerClick()); + + AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); + } + + [Test] + public void TestTextboxFocusAfterNestedPanelEscape() + { + AddUntilStep("sections loaded", () => settings.SectionsContainer.Children.Count > 0); + AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); + + AddStep("open key binding subpanel", () => + { + settings.SectionsContainer + .ChildrenOfType().FirstOrDefault()? + .ChildrenOfType().FirstOrDefault()? + .TriggerClick(); + }); + + AddUntilStep("binding panel textbox focused", () => settings + .ChildrenOfType().FirstOrDefault()? + .ChildrenOfType().FirstOrDefault()?.HasFocus == true); + + AddStep("Escape", () => InputManager.Key(Key.Escape)); + + AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); } [BackgroundDependencyLoader] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs new file mode 100644 index 0000000000..b5109aa58d --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedToggleButton.cs @@ -0,0 +1,108 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneShearedToggleButton : OsuManualInputManagerTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Test] + public void TestShearedToggleButton() + { + ShearedToggleButton button = null; + + AddStep("create button", () => + { + Child = button = new ShearedToggleButton(200) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Toggle me", + }; + }); + + AddToggleStep("toggle button", active => button.Active.Value = active); + AddToggleStep("toggle disabled", disabled => button.Active.Disabled = disabled); + } + + [Test] + public void TestSizing() + { + ShearedToggleButton toggleButton = null; + + AddStep("create fixed width button", () => Child = toggleButton = new ShearedToggleButton(200) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Fixed width" + }); + AddStep("change text", () => toggleButton.Text = "New text"); + + AddStep("create auto-sizing button", () => Child = toggleButton = new ShearedToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "This button autosizes to its text!" + }); + AddStep("change text", () => toggleButton.Text = "New text"); + } + + [Test] + public void TestDisabledState() + { + ShearedToggleButton button = null; + + AddStep("create button", () => + { + Child = button = new ShearedToggleButton(200) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Toggle me", + }; + }); + + clickToggle(); + assertToggleState(true); + + clickToggle(); + assertToggleState(false); + + setToggleDisabledState(true); + + assertToggleState(false); + clickToggle(); + assertToggleState(false); + + setToggleDisabledState(false); + assertToggleState(false); + clickToggle(); + assertToggleState(true); + + setToggleDisabledState(true); + assertToggleState(true); + clickToggle(); + assertToggleState(true); + + void clickToggle() => AddStep("click toggle", () => + { + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + void assertToggleState(bool active) => AddAssert($"toggle is {(active ? "" : "not ")}active", () => button.Active.Value == active); + + void setToggleDisabledState(bool disabled) => AddStep($"{(disabled ? "disable" : "enable")} toggle", () => button.Active.Disabled = disabled); + } + } +} diff --git a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs index 65753bfe00..4c1256df2e 100644 --- a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs @@ -1,9 +1,13 @@ // 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.IO; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Game.Rulesets; using osu.Game.Tests; @@ -12,6 +16,45 @@ namespace osu.Game.Tournament.Tests.NonVisual { public class DataLoadTest : TournamentHostTest { + [Test] + public void TestRulesetGetsValidOnlineID() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = new TestTournament(runOnLoadComplete: () => + { + // ReSharper disable once AccessToDisposedClosure + var storage = host.Storage.GetStorageForDirectory(Path.Combine("tournaments", "default")); + + using (var stream = storage.GetStream("bracket.json", FileAccess.Write, FileMode.Create)) + using (var writer = new StreamWriter(stream)) + { + writer.Write(@"{ + ""Ruleset"": { + ""ShortName"": ""taiko"", + ""OnlineID"": -1, + ""Name"": ""osu!taiko"", + ""InstantiationInfo"": ""osu.Game.Rulesets.OsuTaiko.TaikoRuleset, osu.Game.Rulesets.Taiko"", + ""Available"": true + } }"); + } + }); + + LoadTournament(host, osu); + + osu.BracketLoadTask.WaitSafely(); + + Assert.That(osu.Dependencies.Get>().Value.OnlineID, Is.EqualTo(1)); + } + finally + { + host.Exit(); + } + } + } + [Test] public void TestUnavailableRuleset() { @@ -19,7 +62,7 @@ namespace osu.Game.Tournament.Tests.NonVisual { try { - var osu = new TestTournament(); + var osu = new TestTournament(true); LoadTournament(host, osu); var storage = osu.Dependencies.Get(); @@ -35,10 +78,23 @@ namespace osu.Game.Tournament.Tests.NonVisual public class TestTournament : TournamentGameBase { + private readonly bool resetRuleset; + private readonly Action runOnLoadComplete; + + public new Task BracketLoadTask => base.BracketLoadTask; + + public TestTournament(bool resetRuleset = false, Action runOnLoadComplete = null) + { + this.resetRuleset = resetRuleset; + this.runOnLoadComplete = runOnLoadComplete; + } + protected override void LoadComplete() { + runOnLoadComplete?.Invoke(); base.LoadComplete(); - Ruleset.Value = new RulesetInfo(); // not available + if (resetRuleset) + Ruleset.Value = new RulesetInfo(); // not available } } } diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index f318c8bd85..a251a043f7 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -64,6 +64,16 @@ namespace osu.Game.Tournament Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage))); dependencies.CacheAs(new StableInfo(storage)); + } + + protected override void LoadComplete() + { + MenuCursorContainer.Cursor.AlwaysPresent = true; // required for tooltip display + + // we don't want to show the menu cursor as it would appear on stream output. + MenuCursorContainer.Cursor.Alpha = 0; + + base.LoadComplete(); Task.Run(readBracket); } @@ -81,10 +91,14 @@ namespace osu.Game.Tournament ladder ??= new LadderInfo(); - ladder.Ruleset.Value = ladder.Ruleset.Value != null + var resolvedRuleset = ladder.Ruleset.Value != null ? RulesetStore.GetRuleset(ladder.Ruleset.Value.ShortName) : RulesetStore.AvailableRulesets.First(); + // Must set to null initially to avoid the following re-fetch hitting `ShortName` based equality check. + ladder.Ruleset.Value = null; + ladder.Ruleset.Value = resolvedRuleset; + bool addedInfo = false; // assign teams @@ -282,16 +296,6 @@ namespace osu.Game.Tournament } } - protected override void LoadComplete() - { - MenuCursorContainer.Cursor.AlwaysPresent = true; // required for tooltip display - - // we don't want to show the menu cursor as it would appear on stream output. - MenuCursorContainer.Cursor.Alpha = 0; - - base.LoadComplete(); - } - protected virtual void SaveChanges() { if (!bracketLoadTaskCompletionSource.Task.IsCompletedSuccessfully) diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs index 80a9c07cde..98338244e4 100644 --- a/osu.Game.Tournament/TournamentSceneManager.cs +++ b/osu.Game.Tournament/TournamentSceneManager.cs @@ -7,8 +7,10 @@ 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.Framework.Threading; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; using osu.Game.Tournament.Screens; using osu.Game.Tournament.Screens.Drawings; @@ -23,6 +25,7 @@ using osu.Game.Tournament.Screens.TeamIntro; using osu.Game.Tournament.Screens.TeamWin; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tournament { @@ -123,16 +126,16 @@ namespace osu.Game.Tournament new ScreenButton(typeof(RoundEditorScreen)) { Text = "Rounds Editor", RequestSelection = SetScreen }, new ScreenButton(typeof(LadderEditorScreen)) { Text = "Bracket Editor", RequestSelection = SetScreen }, new Separator(), - new ScreenButton(typeof(ScheduleScreen)) { Text = "Schedule", RequestSelection = SetScreen }, - new ScreenButton(typeof(LadderScreen)) { Text = "Bracket", RequestSelection = SetScreen }, + new ScreenButton(typeof(ScheduleScreen), Key.S) { Text = "Schedule", RequestSelection = SetScreen }, + new ScreenButton(typeof(LadderScreen), Key.B) { Text = "Bracket", RequestSelection = SetScreen }, new Separator(), - new ScreenButton(typeof(TeamIntroScreen)) { Text = "Team Intro", RequestSelection = SetScreen }, - new ScreenButton(typeof(SeedingScreen)) { Text = "Seeding", RequestSelection = SetScreen }, + new ScreenButton(typeof(TeamIntroScreen), Key.I) { Text = "Team Intro", RequestSelection = SetScreen }, + new ScreenButton(typeof(SeedingScreen), Key.D) { Text = "Seeding", RequestSelection = SetScreen }, new Separator(), - new ScreenButton(typeof(MapPoolScreen)) { Text = "Map Pool", RequestSelection = SetScreen }, - new ScreenButton(typeof(GameplayScreen)) { Text = "Gameplay", RequestSelection = SetScreen }, + new ScreenButton(typeof(MapPoolScreen), Key.M) { Text = "Map Pool", RequestSelection = SetScreen }, + new ScreenButton(typeof(GameplayScreen), Key.G) { Text = "Gameplay", RequestSelection = SetScreen }, new Separator(), - new ScreenButton(typeof(TeamWinScreen)) { Text = "Win", RequestSelection = SetScreen }, + new ScreenButton(typeof(TeamWinScreen), Key.W) { Text = "Win", RequestSelection = SetScreen }, new Separator(), new ScreenButton(typeof(DrawingsScreen)) { Text = "Drawings", RequestSelection = SetScreen }, new ScreenButton(typeof(ShowcaseScreen)) { Text = "Showcase", RequestSelection = SetScreen }, @@ -231,13 +234,60 @@ namespace osu.Game.Tournament { public readonly Type Type; - public ScreenButton(Type type) + private readonly Key? shortcutKey; + + public ScreenButton(Type type, Key? shortcutKey = null) { + this.shortcutKey = shortcutKey; + Type = type; + BackgroundColour = OsuColour.Gray(0.2f); Action = () => RequestSelection?.Invoke(type); RelativeSizeAxes = Axes.X; + + if (shortcutKey != null) + { + Add(new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(24), + Margin = new MarginPadding(5), + Masking = true, + CornerRadius = 4, + Alpha = 0.5f, + Blending = BlendingParameters.Additive, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.1f), + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Font = OsuFont.Default.With(size: 24), + Y = -2, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = shortcutKey.ToString(), + } + } + }); + } + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == shortcutKey) + { + TriggerClick(); + return true; + } + + return base.OnKeyDown(e); } private bool isSelected; diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index e8f13ba902..2f966ac0a9 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -10,6 +10,7 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; @@ -44,6 +45,8 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); + SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal); + SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full); // Online settings @@ -297,6 +300,7 @@ namespace osu.Game.Configuration RandomSelectAlgorithm, ShowFpsDisplay, ChatDisplayHeight, + BeatmapListingCardSize, ToolbarClockDisplayMode, Version, ShowConvertedBeatmaps, diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index 578ff3c618..d3a76a0f1a 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs @@ -28,7 +28,7 @@ namespace osu.Game.Graphics.UserInterface if (!allowImmediateFocus) return; - Scheduler.Add(() => GetContainingInputManager().ChangeFocus(this), false); + Scheduler.Add(() => GetContainingInputManager().ChangeFocus(this)); } public new void KillFocus() => base.KillFocus(); diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs new file mode 100644 index 0000000000..aed3be20a0 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs @@ -0,0 +1,177 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public class ShearedToggleButton : OsuClickableContainer + { + public BindableBool Active { get; } = new BindableBool(); + + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + private readonly Box background; + private readonly OsuSpriteText text; + + private Sample? sampleOff; + private Sample? sampleOn; + + private const float shear = 0.2f; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + /// + /// Creates a new + /// + /// + /// The width of the button. + /// + /// If a non- value is provided, this button will have a fixed width equal to the provided value. + /// If a value is provided (or the argument is omitted entirely), the button will autosize in width to fit the text. + /// + /// + public ShearedToggleButton(float? width = null) + { + Height = 50; + Padding = new MarginPadding { Horizontal = shear * 50 }; + + Content.CornerRadius = 7; + Content.Shear = new Vector2(shear, 0); + Content.Masking = true; + Content.BorderThickness = 2; + Content.Anchor = Content.Origin = Anchor.Centre; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.TorusAlternate.With(size: 17), + Shear = new Vector2(-shear, 0) + } + }; + + if (width != null) + { + Width = width.Value; + } + else + { + AutoSizeAxes = Axes.X; + text.Margin = new MarginPadding { Horizontal = 15 }; + } + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleOn = audio.Samples.Get(@"UI/check-on"); + sampleOff = audio.Samples.Get(@"UI/check-off"); + } + + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); + + protected override void LoadComplete() + { + base.LoadComplete(); + + Active.BindValueChanged(_ => + { + updateState(); + playSample(); + }); + Active.BindDisabledChanged(disabled => + { + updateState(); + Action = disabled ? (Action?)null : Active.Toggle; + }, true); + + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + Content.ScaleTo(0.8f, 2000, Easing.OutQuint); + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + Content.ScaleTo(1, 1000, Easing.OutElastic); + base.OnMouseUp(e); + } + + private void updateState() + { + var darkerColour = Active.Value ? colourProvider.Highlight1 : colourProvider.Background3; + var lighterColour = Active.Value ? colourProvider.Colour0 : colourProvider.Background1; + + if (Active.Disabled) + { + darkerColour = darkerColour.Darken(0.3f); + lighterColour = lighterColour.Darken(0.3f); + } + else if (IsHovered) + { + darkerColour = darkerColour.Lighten(0.3f); + lighterColour = lighterColour.Lighten(0.3f); + } + + background.FadeColour(darkerColour, 150, Easing.OutQuint); + Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(darkerColour, lighterColour), 150, Easing.OutQuint); + + var textColour = Active.Value ? colourProvider.Background6 : colourProvider.Content1; + if (Active.Disabled) + textColour = textColour.Opacity(0.6f); + + text.FadeColour(textColour, 150, Easing.OutQuint); + } + + private void playSample() + { + if (Active.Value) + sampleOn?.Play(); + else + sampleOff?.Play(); + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 4cd954a646..73121f6e7d 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1061,6 +1061,12 @@ namespace osu.Game return true; case GlobalAction.RandomSkin: + // Don't allow random skin selection while in the skin editor. + // This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path. + // If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow. + if (skinEditor.State.Value == Visibility.Visible) + return false; + SkinManager.SelectRandomSkin(); return true; } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 0f87f04270..e4628e3723 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -53,7 +54,9 @@ namespace osu.Game.Overlays.BeatmapListing /// /// The currently selected . /// - public IBindable CardSize { get; } = new Bindable(); + public IBindable CardSize => cardSize; + + private readonly Bindable cardSize = new Bindable(); private readonly BeatmapListingSearchControl searchControl; private readonly BeatmapListingSortTabControl sortControl; @@ -128,6 +131,9 @@ namespace osu.Game.Overlays.BeatmapListing }; } + [Resolved] + private OsuConfigManager config { get; set; } + [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, IAPIProvider api) { @@ -141,6 +147,8 @@ namespace osu.Game.Overlays.BeatmapListing { base.LoadComplete(); + config.BindWith(OsuSetting.BeatmapListingCardSize, cardSize); + var sortCriteria = sortControl.Current; var sortDirection = sortControl.SortDirection; diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs index e08f099226..fed3d7ddaa 100644 --- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs +++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs @@ -5,6 +5,8 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -19,7 +21,7 @@ namespace osu.Game.Overlays.BeatmapSet protected readonly FailRetryGraph Graph; private readonly FillFlowContainer header; - private readonly OsuSpriteText successPercent; + private readonly SuccessRatePercentage successPercent; private readonly Bar successRate; private readonly Container percentContainer; @@ -45,6 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet float rate = playCount != 0 ? (float)passCount / playCount : 0; successPercent.Text = rate.ToLocalisableString(@"0.#%"); + successPercent.TooltipText = $"{passCount} / {playCount}"; successRate.Length = rate; percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic); @@ -80,7 +83,7 @@ namespace osu.Game.Overlays.BeatmapSet RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Width = 0f, - Child = successPercent = new OsuSpriteText + Child = successPercent = new SuccessRatePercentage { Anchor = Anchor.TopRight, Origin = Anchor.TopCentre, @@ -121,5 +124,10 @@ namespace osu.Game.Overlays.BeatmapSet Graph.Padding = new MarginPadding { Top = header.DrawHeight }; } + + private class SuccessRatePercentage : OsuSpriteText, IHasTooltip + { + public LocalisableString TooltipText { get; set; } + } } } diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs new file mode 100644 index 0000000000..ef20149dac --- /dev/null +++ b/osu.Game/Overlays/Chat/ChatTextBar.cs @@ -0,0 +1,163 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; +using osuTK; + +namespace osu.Game.Overlays.Chat +{ + public class ChatTextBar : Container + { + public readonly BindableBool ShowSearch = new BindableBool(); + + public event Action? OnChatMessageCommitted; + + public event Action? OnSearchTermsChanged; + + [Resolved] + private Bindable currentChannel { get; set; } = null!; + + private OsuTextFlowContainer chattingTextContainer = null!; + private Container searchIconContainer = null!; + private ChatTextBox chatTextBox = null!; + + private const float chatting_text_width = 180; + private const float search_icon_width = 40; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + Height = 60; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + chattingTextContainer = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 20)) + { + Masking = true, + Width = chatting_text_width, + Padding = new MarginPadding { Left = 10 }, + RelativeSizeAxes = Axes.Y, + TextAnchor = Anchor.CentreRight, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = colourProvider.Background1, + }, + searchIconContainer = new Container + { + RelativeSizeAxes = Axes.Y, + Width = search_icon_width, + Child = new SpriteIcon + { + Icon = FontAwesome.Solid.Search, + Origin = Anchor.CentreRight, + Anchor = Anchor.CentreRight, + Size = new Vector2(20), + Margin = new MarginPadding { Right = 2 }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 5 }, + Child = chatTextBox = new ChatTextBox + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + ShowSearch = { BindTarget = ShowSearch }, + HoldFocus = true, + ReleaseFocusOnCommit = false, + }, + }, + }, + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + chatTextBox.Current.ValueChanged += chatTextBoxChange; + chatTextBox.OnCommit += chatTextBoxCommit; + + ShowSearch.BindValueChanged(change => + { + bool showSearch = change.NewValue; + + chattingTextContainer.FadeTo(showSearch ? 0 : 1); + searchIconContainer.FadeTo(showSearch ? 1 : 0); + + // Clear search terms if any exist when switching back to chat mode + if (!showSearch) + OnSearchTermsChanged?.Invoke(string.Empty); + }, true); + + currentChannel.BindValueChanged(change => + { + Channel newChannel = change.NewValue; + + switch (newChannel?.Type) + { + case ChannelType.Public: + chattingTextContainer.Text = $"chatting in {newChannel.Name}"; + break; + + case ChannelType.PM: + chattingTextContainer.Text = $"chatting with {newChannel.Name}"; + break; + + default: + chattingTextContainer.Text = string.Empty; + break; + } + }, true); + } + + private void chatTextBoxChange(ValueChangedEvent change) + { + if (ShowSearch.Value) + OnSearchTermsChanged?.Invoke(change.NewValue); + } + + private void chatTextBoxCommit(TextBox sender, bool newText) + { + if (ShowSearch.Value) + return; + + OnChatMessageCommitted?.Invoke(sender.Text); + sender.Text = string.Empty; + } + } +} diff --git a/osu.Game/Overlays/Chat/ChatTextBox.cs b/osu.Game/Overlays/Chat/ChatTextBox.cs new file mode 100644 index 0000000000..e0f949caba --- /dev/null +++ b/osu.Game/Overlays/Chat/ChatTextBox.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using osu.Framework.Bindables; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Chat +{ + public class ChatTextBox : FocusedTextBox + { + public readonly BindableBool ShowSearch = new BindableBool(); + + public override bool HandleLeftRightArrows => !ShowSearch.Value; + + protected override void LoadComplete() + { + base.LoadComplete(); + + ShowSearch.BindValueChanged(change => + { + bool showSearch = change.NewValue; + + PlaceholderText = showSearch ? "type here to search" : "type here"; + Text = string.Empty; + }, true); + } + + protected override void Commit() + { + if (ShowSearch.Value) + return; + + base.Commit(); + } + } +} diff --git a/osu.Game/Overlays/SettingsSubPanel.cs b/osu.Game/Overlays/SettingsSubPanel.cs index da806c09d3..d55c609d3f 100644 --- a/osu.Game/Overlays/SettingsSubPanel.cs +++ b/osu.Game/Overlays/SettingsSubPanel.cs @@ -32,7 +32,7 @@ namespace osu.Game.Overlays protected override bool DimMainContent => false; // dimming is handled by main overlay - private class BackButton : SidebarButton + public class BackButton : SidebarButton { private Container content; diff --git a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs index 090f8c4a0f..81a362450c 100644 --- a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs @@ -6,7 +6,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK; namespace osu.Game.Overlays.Toolbar { @@ -42,7 +41,7 @@ namespace osu.Game.Overlays.Toolbar { Y = 14, Colour = colours.PinkLight, - Scale = new Vector2(0.6f) + Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold), } }; diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index c855b76680..4a839b048c 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -190,7 +190,7 @@ namespace osu.Game.Overlays.Toolbar public bool OnPressed(KeyBindingPressEvent e) { - if (e.Action == Hotkey) + if (e.Action == Hotkey && !e.Repeat) { TriggerClick(); return true; diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs index ad5c9ac7a1..22a96603dc 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs @@ -3,27 +3,35 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarClock : CompositeDrawable + public class ToolbarClock : OsuClickableContainer { private Bindable clockDisplayMode; + private Box hoverBackground; + private Box flashBackground; + private DigitalClockDisplay digital; private AnalogClockDisplay analog; public ToolbarClock() + : base(HoverSampleSet.Toolbar) { RelativeSizeAxes = Axes.Y; AutoSizeAxes = Axes.X; - - Padding = new MarginPadding(10); } [BackgroundDependencyLoader] @@ -31,23 +39,41 @@ namespace osu.Game.Overlays.Toolbar { clockDisplayMode = config.GetBindable(OsuSetting.ToolbarClockDisplayMode); - InternalChild = new FillFlowContainer + Children = new Drawable[] { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Children = new Drawable[] + hoverBackground = new Box { - analog = new AnalogClockDisplay + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(80).Opacity(180), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + flashBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.White.Opacity(100), + Blending = BlendingParameters.Additive, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - digital = new DigitalClockDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + analog = new AnalogClockDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + digital = new DigitalClockDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } } } }; @@ -72,8 +98,25 @@ namespace osu.Game.Overlays.Toolbar protected override bool OnClick(ClickEvent e) { + flashBackground.FadeOutFromOne(800, Easing.OutQuint); + cycleDisplayMode(); - return true; + + return base.OnClick(e); + } + + protected override bool OnHover(HoverEvent e) + { + hoverBackground.FadeIn(200); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverBackground.FadeOut(200); + + base.OnHoverLost(e); } private void cycleDisplayMode() diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs index cd0b56d338..13af04cd4b 100644 --- a/osu.Game/Screens/Edit/Timing/TimingSection.cs +++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs @@ -1,19 +1,16 @@ // 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.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Overlays.Settings; namespace osu.Game.Screens.Edit.Timing { internal class TimingSection : Section { - private SettingsSlider bpmSlider; private LabelledTimeSignature timeSignature; private BPMTextBox bpmTextEntry; @@ -23,7 +20,6 @@ namespace osu.Game.Screens.Edit.Timing Flow.AddRange(new Drawable[] { bpmTextEntry = new BPMTextBox(), - bpmSlider = new BPMSlider(), timeSignature = new LabelledTimeSignature { Label = "Time Signature" @@ -35,11 +31,8 @@ namespace osu.Game.Screens.Edit.Timing { if (point.NewValue != null) { - bpmSlider.Current = point.NewValue.BeatLengthBindable; - bpmSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); - bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; - // no need to hook change handler here as it's the same bindable as above + bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); timeSignature.Current = point.NewValue.TimeSignatureBindable; timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); @@ -102,51 +95,6 @@ namespace osu.Game.Screens.Edit.Timing } } - private class BPMSlider : SettingsSlider - { - private const double sane_minimum = 60; - private const double sane_maximum = 240; - - private readonly BindableNumber beatLengthBindable = new TimingControlPoint().BeatLengthBindable; - - private readonly BindableDouble bpmBindable = new BindableDouble(60000 / TimingControlPoint.DEFAULT_BEAT_LENGTH) - { - MinValue = sane_minimum, - MaxValue = sane_maximum, - }; - - public BPMSlider() - { - beatLengthBindable.BindValueChanged(beatLength => updateCurrent(beatLengthToBpm(beatLength.NewValue)), true); - bpmBindable.BindValueChanged(bpm => beatLengthBindable.Value = beatLengthToBpm(bpm.NewValue)); - - base.Current = bpmBindable; - - TransferValueOnCommit = true; - } - - public override Bindable Current - { - get => base.Current; - set - { - // incoming will be beat length, not bpm - beatLengthBindable.UnbindBindings(); - beatLengthBindable.BindTo(value); - } - } - - private void updateCurrent(double newValue) - { - // we use a more sane range for the slider display unless overridden by the user. - // if a value comes in outside our range, we should expand temporarily. - bpmBindable.MinValue = Math.Min(newValue, sane_minimum); - bpmBindable.MaxValue = Math.Max(newValue, sane_maximum); - - bpmBindable.Value = newValue; - } - } - private static double beatLengthToBpm(double beatLength) => 60000 / beatLength; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs index d467a32acb..49b5b7fed9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs @@ -37,21 +37,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.UserJoined(user); - userJoinedSample?.Play(); + Scheduler.AddOnce(() => userJoinedSample?.Play()); } protected override void UserLeft(MultiplayerRoomUser user) { base.UserLeft(user); - userLeftSample?.Play(); + Scheduler.AddOnce(() => userLeftSample?.Play()); } protected override void UserKicked(MultiplayerRoomUser user) { base.UserKicked(user); - userKickedSample?.Play(); + Scheduler.AddOnce(() => userKickedSample?.Play()); } private void hostChanged(ValueChangedEvent value) @@ -59,7 +59,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer // only play sound when the host changes from an already-existing host. if (value.OldValue == null) return; - hostChangedSample?.Play(); + Scheduler.AddOnce(() => hostChangedSample?.Play()); } } } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 71998622ef..4f5edab526 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -227,15 +227,15 @@ namespace osu.Game.Screens.Play.HUD public int? Team => (User.MatchState as TeamVersusUserState)?.TeamID; - private readonly RulesetInfo ruleset; + private readonly ScoreInfo scoreInfo; public TrackedUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) { - this.ruleset = ruleset; - User = user; ScoreProcessor = scoreProcessor; + scoreInfo = new ScoreInfo { Ruleset = ruleset }; + ScoringMode.BindValueChanged(_ => UpdateScore()); } @@ -253,12 +253,10 @@ namespace osu.Game.Screens.Play.HUD { var header = frame.Header; - Score.Value = ScoreProcessor.ComputePartialScore(ScoringMode.Value, new ScoreInfo - { - Ruleset = ruleset, - MaxCombo = header.MaxCombo, - Statistics = header.Statistics - }); + scoreInfo.MaxCombo = header.MaxCombo; + scoreInfo.Statistics = header.Statistics; + + Score.Value = ScoreProcessor.ComputePartialScore(ScoringMode.Value, scoreInfo); Accuracy.Value = header.Accuracy; CurrentCombo.Value = header.Combo; diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs index 95395f8181..1f659fd5bf 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Skinning; @@ -84,9 +85,17 @@ namespace osu.Game.Screens.Play.HUD /// The new instance. public Drawable CreateInstance() { - Drawable d = (Drawable)Activator.CreateInstance(Type); - d.ApplySkinnableInfo(this); - return d; + try + { + Drawable d = (Drawable)Activator.CreateInstance(Type); + d.ApplySkinnableInfo(this); + return d; + } + catch (Exception e) + { + Logger.Error(e, $"Unable to create skin component {Type.Name}"); + return Drawable.Empty(); + } } } } diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 2f01bb7301..5d4afc00c4 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -155,16 +155,7 @@ namespace osu.Game.Skinning var components = new List(); foreach (var i in skinnableInfo) - { - try - { - components.Add(i.CreateInstance()); - } - catch (Exception e) - { - Logger.Error(e, $"Unable to create skin component {i.Type.Name}"); - } - } + components.Add(i.CreateInstance()); return new SkinnableTargetComponentsContainer { diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index bad559d9fe..71920fb166 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -24,6 +24,7 @@ using osu.Game.Database; using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; +using osu.Game.Utils; namespace osu.Game.Skinning { @@ -144,20 +145,26 @@ namespace osu.Game.Skinning if (!s.Protected) return; + string[] existingSkinNames = realm.Run(r => r.All() + .Where(skin => !skin.DeletePending) + .AsEnumerable() + .Select(skin => skin.Name).ToArray()); + // if the user is attempting to save one of the default skin implementations, create a copy first. - var result = skinModelManager.Import(new SkinInfo + var skinInfo = new SkinInfo { - Name = s.Name + @" (modified)", Creator = s.Creator, InstantiationInfo = s.InstantiationInfo, - }); + Name = NamingUtils.GetNextBestName(existingSkinNames, $"{s.Name} (modified)") + }; + + var result = skinModelManager.Import(skinInfo); if (result != null) { // save once to ensure the required json content is populated. // currently this only happens on save. result.PerformRead(skin => Save(skin.CreateInstance(this))); - CurrentSkinInfo.Value = result; } });