diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs index 59a68245a6..a80f1178b6 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs @@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.EmptyFreeform protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0]; } } diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs index d5c1e9bd15..f705009d18 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using osu.Game.Beatmaps; -using osu.Game.Rulesets.EmptyFreeform.Objects; using osu.Game.Rulesets.EmptyFreeform.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; @@ -11,7 +10,7 @@ using osu.Game.Users; namespace osu.Game.Rulesets.EmptyFreeform.Mods { - public class EmptyFreeformModAutoplay : ModAutoplay + public class EmptyFreeformModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs index 8ea334c99c..4565c97d1a 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs @@ -4,14 +4,13 @@ using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Pippidon.Objects; using osu.Game.Rulesets.Pippidon.Replays; using osu.Game.Scoring; using osu.Game.Users; namespace osu.Game.Rulesets.Pippidon.Mods { - public class PippidonModAutoplay : ModAutoplay + public class PippidonModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6340f6c25..290148d14b 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.Pippidon protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0]; } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs index 7f29c4e712..f557a4c754 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs @@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.EmptyScrolling protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0]; } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs index 6dad1ff43b..431994e098 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs @@ -3,7 +3,6 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.EmptyScrolling.Objects; using osu.Game.Rulesets.EmptyScrolling.Replays; using osu.Game.Scoring; using osu.Game.Users; @@ -11,7 +10,7 @@ using System.Collections.Generic; namespace osu.Game.Rulesets.EmptyScrolling.Mods { - public class EmptyScrollingModAutoplay : ModAutoplay + public class EmptyScrollingModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs index 8ea334c99c..4565c97d1a 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs @@ -4,14 +4,13 @@ using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Pippidon.Objects; using osu.Game.Rulesets.Pippidon.Replays; using osu.Game.Scoring; using osu.Game.Users; namespace osu.Game.Rulesets.Pippidon.Mods { - public class PippidonModAutoplay : ModAutoplay + public class PippidonModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6340f6c25..290148d14b 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.Pippidon protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0]; } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index f5cce47186..9feaa55051 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty } } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) * 0.5f; @@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty return new Skill[] { - new Movement(mods, halfCatcherWidth), + new Movement(mods, halfCatcherWidth, clockRate), }; } diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index d936ef97ac..e19098c580 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -24,8 +24,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing /// public readonly double StrainTime; - public readonly double ClockRate; - public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth) : base(hitObject, lastObject, clockRate) { @@ -37,7 +35,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure StrainTime = Math.Max(40, DeltaTime); - ClockRate = clockRate; } } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 75e17f6c48..4372ed938c 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -28,10 +28,21 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills private float lastDistanceMoved; private double lastStrainTime; - public Movement(Mod[] mods, float halfCatcherWidth) + /// + /// The speed multiplier applied to the player's catcher. + /// + private readonly double catcherSpeedMultiplier; + + public Movement(Mod[] mods, float halfCatcherWidth, double clockRate) : base(mods) { HalfCatcherWidth = halfCatcherWidth; + + // In catch, clockrate adjustments do not only affect the timings of hitobjects, + // but also the speed of the player's catcher, which has an impact on difficulty + // TODO: Support variable clockrates caused by mods such as ModTimeRamp + // (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing) + catcherSpeedMultiplier = clockRate; } protected override double StrainValueOf(DifficultyHitObject current) @@ -48,7 +59,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills float distanceMoved = playerPosition - lastPlayerPosition.Value; - double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catchCurrent.ClockRate); + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510); double sqrtStrain = Math.Sqrt(weightedStrainTime); @@ -81,7 +92,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills playerPosition = catchCurrent.NormalizedPosition; } - distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catchCurrent.ClockRate, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values + distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values } lastPlayerPosition = playerPosition; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs index e1eceea606..f1b51e51d0 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; @@ -11,7 +10,7 @@ using osu.Game.Users; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModAutoplay : ModAutoplay + public class CatchModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 8c0b9ed8b7..a7a6677b68 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. protected override IEnumerable SortObjects(IEnumerable input) => input; - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new Strain(mods, ((ManiaBeatmap)beatmap).TotalColumns) }; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs index 105d88129c..6ae854e7f3 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; -using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; @@ -12,7 +11,7 @@ using osu.Game.Users; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModAutoplay : ModAutoplay + public class ManiaModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs index 699c58c373..6f2d4fe91e 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs @@ -1,6 +1,7 @@ // 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.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Utils; @@ -17,8 +18,11 @@ namespace osu.Game.Rulesets.Mania.Mods public void ApplyToBeatmap(IBeatmap beatmap) { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + var availableColumns = ((ManiaBeatmap)beatmap).TotalColumns; - var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(item => RNG.Next()).ToList(); + var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(item => rng.Next()).ToList(); beatmap.HitObjects.OfType().ForEach(h => h.Column = shuffledColumns[h.Column]); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs new file mode 100644 index 0000000000..2bce8fa7f2 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs @@ -0,0 +1,30 @@ +// 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.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class TestSceneHitCircleKiai : TestSceneHitCircle + { + [SetUp] + public void SetUp() => Schedule(() => + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + // track needs to be playing for BeatSyncedContainer to work. + Beatmap.Value.Track.Start(); + }); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 75d6786d95..e47f82fb39 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -79,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty } } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new Aim(mods), new Speed(mods) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs index 3b1f271d41..652da7123e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs @@ -6,14 +6,13 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Scoring; using osu.Game.Users; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModAutoplay : ModAutoplay + public class OsuModAutoplay : ModAutoplay { public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).Append(typeof(OsuModSpunOut)).ToArray(); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs new file mode 100644 index 0000000000..4dfadbb835 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -0,0 +1,280 @@ +// 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.Linq; +using osu.Framework.Graphics; +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 osuTK; + +namespace osu.Game.Rulesets.Osu.Mods +{ + /// + /// Mod that randomises the positions of the s + /// + public class OsuModRandom : ModRandom, IApplicableToBeatmap + { + public override string Description => "It never gets boring!"; + public override bool Ranked => false; + + // 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 + private const float playfield_edge_ratio = 0.375f; + + private static readonly float border_distance_x = OsuPlayfield.BASE_SIZE.X * playfield_edge_ratio; + private static readonly float border_distance_y = OsuPlayfield.BASE_SIZE.Y * playfield_edge_ratio; + + private static readonly Vector2 playfield_middle = Vector2.Divide(OsuPlayfield.BASE_SIZE, 2); + + private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; + + private Random rng; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + if (!(beatmap is OsuBeatmap osuBeatmap)) + return; + + var hitObjects = osuBeatmap.HitObjects; + + Seed.Value ??= RNG.Next(); + + rng = new Random((int)Seed.Value); + + RandomObjectInfo previous = null; + + float rateOfChangeMultiplier = 0; + + for (int i = 0; i < hitObjects.Count; i++) + { + var hitObject = hitObjects[i]; + + var current = new RandomObjectInfo(hitObject); + + // rateOfChangeMultiplier only changes every i iterations to prevent shaky-line-shaped streams + if (i % 3 == 0) + rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1; + + if (hitObject is Spinner) + { + previous = null; + continue; + } + + applyRandomisation(rateOfChangeMultiplier, previous, current); + + hitObject.Position = current.PositionRandomised; + + // update end position as it may have changed as a result of the position update. + current.EndPositionRandomised = current.PositionRandomised; + + switch (hitObject) + { + case Slider slider: + // Shift nested objects the same distance as the slider got shifted in the randomisation process + // so that moveSliderIntoPlayfield() can determine their relative distances to slider.Position and thus minMargin + shiftNestedObjects(slider, Vector2.Subtract(slider.Position, current.PositionOriginal)); + + var oldPos = new Vector2(slider.Position.X, slider.Position.Y); + + moveSliderIntoPlayfield(slider, current); + + // Shift them again to move them to their final position after the slider got moved into the playfield + shiftNestedObjects(slider, Vector2.Subtract(slider.Position, oldPos)); + + break; + } + + previous = current; + } + } + + /// + /// Returns the final position of the hit object + /// + /// Final position of the hit object + private void applyRandomisation(float rateOfChangeMultiplier, RandomObjectInfo previous, RandomObjectInfo current) + { + if (previous == null) + { + var playfieldSize = OsuPlayfield.BASE_SIZE; + + current.AngleRad = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); + current.PositionRandomised = new Vector2((float)rng.NextDouble() * playfieldSize.X, (float)rng.NextDouble() * playfieldSize.Y); + + return; + } + + float distanceToPrev = 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. + var randomAngleRad = rateOfChangeMultiplier * 2 * Math.PI * distanceToPrev / playfield_diagonal; + + current.AngleRad = (float)randomAngleRad + previous.AngleRad; + if (current.AngleRad < 0) + current.AngleRad += 2 * (float)Math.PI; + + var posRelativeToPrev = new Vector2( + distanceToPrev * (float)Math.Cos(current.AngleRad), + distanceToPrev * (float)Math.Sin(current.AngleRad) + ); + + posRelativeToPrev = getRotatedVector(previous.EndPositionRandomised, posRelativeToPrev); + + current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X); + + var position = Vector2.Add(previous.EndPositionRandomised, posRelativeToPrev); + + // Move hit objects back into the playfield if they are outside of it, + // which would sometimes happen during big jumps otherwise. + position.X = MathHelper.Clamp(position.X, 0, OsuPlayfield.BASE_SIZE.X); + position.Y = MathHelper.Clamp(position.Y, 0, OsuPlayfield.BASE_SIZE.Y); + + current.PositionRandomised = position; + } + + /// + /// Moves the and all necessary nested s into the if they aren't already. + /// + private void moveSliderIntoPlayfield(Slider slider, RandomObjectInfo currentObjectInfo) + { + // Min. distances from the slider's position to the playfield border + var minMargin = new MarginPadding(); + + foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderEndCircle)) + { + if (!(hitObject is OsuHitObject osuHitObject)) + continue; + + var relativePos = Vector2.Subtract(osuHitObject.Position, slider.Position); + + minMargin.Left = Math.Max(minMargin.Left, -relativePos.X); + minMargin.Right = Math.Max(minMargin.Right, relativePos.X); + minMargin.Top = Math.Max(minMargin.Top, -relativePos.Y); + minMargin.Bottom = Math.Max(minMargin.Bottom, relativePos.Y); + } + + if (slider.Position.X < minMargin.Left) + slider.Position = new Vector2(minMargin.Left, slider.Position.Y); + else if (slider.Position.X + minMargin.Right > OsuPlayfield.BASE_SIZE.X) + slider.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - minMargin.Right, slider.Position.Y); + + if (slider.Position.Y < minMargin.Top) + slider.Position = new Vector2(slider.Position.X, minMargin.Top); + else if (slider.Position.Y + minMargin.Bottom > OsuPlayfield.BASE_SIZE.Y) + slider.Position = new Vector2(slider.Position.X, OsuPlayfield.BASE_SIZE.Y - minMargin.Bottom); + + currentObjectInfo.PositionRandomised = slider.Position; + currentObjectInfo.EndPositionRandomised = slider.EndPosition; + } + + /// + /// 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 = Vector2.Add(osuHitObject.Position, shift); + } + } + + /// + /// Determines the position of the current hit object relative to the previous one. + /// + /// The position of the current hit object relative to the previous one + private Vector2 getRotatedVector(Vector2 prevPosChanged, Vector2 posRelativeToPrev) + { + var relativeRotationDistance = 0f; + + if (prevPosChanged.X < playfield_middle.X) + { + relativeRotationDistance = Math.Max( + (border_distance_x - prevPosChanged.X) / border_distance_x, + relativeRotationDistance + ); + } + else + { + relativeRotationDistance = Math.Max( + (prevPosChanged.X - (OsuPlayfield.BASE_SIZE.X - border_distance_x)) / border_distance_x, + relativeRotationDistance + ); + } + + if (prevPosChanged.Y < playfield_middle.Y) + { + relativeRotationDistance = Math.Max( + (border_distance_y - prevPosChanged.Y) / border_distance_y, + relativeRotationDistance + ); + } + else + { + relativeRotationDistance = Math.Max( + (prevPosChanged.Y - (OsuPlayfield.BASE_SIZE.Y - border_distance_y)) / border_distance_y, + relativeRotationDistance + ); + } + + return rotateVectorTowardsVector(posRelativeToPrev, playfield_middle - prevPosChanged, relativeRotationDistance / 2); + } + + /// + /// Rotates vector "initial" towards vector "destinantion" + /// + /// Vector to rotate to "destination" + /// Vector "initial" should be rotated to + /// The angle the vector should be rotated relative to the difference between the angles of the the two vectors. + /// Resulting vector + private Vector2 rotateVectorTowardsVector(Vector2 initial, Vector2 destination, float relativeDistance) + { + var initialAngleRad = Math.Atan2(initial.Y, initial.X); + var destAngleRad = Math.Atan2(destination.Y, destination.X); + + var diff = destAngleRad - initialAngleRad; + + while (diff < -Math.PI) diff += 2 * Math.PI; + + while (diff > Math.PI) diff -= 2 * Math.PI; + + var finalAngleRad = initialAngleRad + relativeDistance * diff; + + return new Vector2( + initial.Length * (float)Math.Cos(finalAngleRad), + initial.Length * (float)Math.Sin(finalAngleRad) + ); + } + + private class RandomObjectInfo + { + public float AngleRad { get; set; } + + public Vector2 PositionOriginal { get; } + public Vector2 PositionRandomised { get; set; } + + public Vector2 EndPositionOriginal { get; } + public Vector2 EndPositionRandomised { get; set; } + + public RandomObjectInfo(OsuHitObject hitObject) + { + PositionRandomised = PositionOriginal = hitObject.Position; + EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition; + AngleRad = 0; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 465d6d7155..b50d3ad2b4 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -164,7 +164,8 @@ namespace osu.Game.Rulesets.Osu { new OsuModTarget(), new OsuModDifficultyAdjust(), - new OsuModClassic() + new OsuModClassic(), + new OsuModRandom(), }; case ModType.Automation: diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs index ba41ebd445..cb68d4b7a7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs @@ -42,6 +42,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Origin = Anchor.Centre, Texture = textures.Get(@"Gameplay/osu/disc"), }, + new KiaiFlash + { + RelativeSizeAxes = Axes.Both, + }, triangles = new TrianglesPiece { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs b/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs new file mode 100644 index 0000000000..d49b1713f6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public class KiaiFlash : BeatSyncedContainer + { + private const double fade_length = 80; + + private const float flash_opacity = 0.25f; + + public KiaiFlash() + { + EarlyActivationMilliseconds = 80; + Blending = BlendingParameters.Additive; + + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Alpha = 0f, + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!effectPoint.KiaiMode) + return; + + Child + .FadeTo(flash_opacity, EarlyActivationMilliseconds, Easing.OutQuint) + .Then() + .FadeOut(timingPoint.BeatLength - fade_length, Easing.OutSine); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.cs new file mode 100644 index 0000000000..4a1d69ad41 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + internal class KiaiFlashingSprite : BeatSyncedContainer + { + private readonly Sprite mainSprite; + private readonly Sprite flashingSprite; + + public Texture Texture + { + set + { + mainSprite.Texture = value; + flashingSprite.Texture = value; + } + } + + private const float flash_opacity = 0.3f; + + public KiaiFlashingSprite() + { + AutoSizeAxes = Axes.Both; + + Children = new Drawable[] + { + mainSprite = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + flashingSprite = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Blending = BlendingParameters.Additive, + } + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!effectPoint.KiaiMode) + return; + + flashingSprite + .FadeTo(flash_opacity) + .Then() + .FadeOut(timingPoint.BeatLength * 0.75f); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index ffbeea5e0e..822dad8523 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -32,9 +31,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); } - private Container circleSprites; - private Sprite hitCircleSprite; - private Sprite hitCircleOverlay; + private Container circleSprites; + private Drawable hitCircleSprite; + private Drawable hitCircleOverlay; private SkinnableSpriteText hitCircleText; @@ -72,20 +71,20 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy InternalChildren = new Drawable[] { - circleSprites = new Container + circleSprites = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Children = new[] { - hitCircleSprite = new Sprite + hitCircleSprite = new KiaiFlashingSprite { Texture = baseTexture, Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - hitCircleOverlay = new Sprite + hitCircleOverlay = new KiaiFlashingSprite { Texture = overlayTexture, Anchor = Anchor.Centre, diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 6b3e31c5d5..18d06c069f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new Colour(mods), new Rhythm(mods), diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs index 64e59b64d0..31d9abf8b2 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs @@ -4,14 +4,13 @@ using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Scoring; using osu.Game.Users; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModAutoplay : ModAutoplay + public class TaikoModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs index a22f189d5e..307a37bf2e 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs @@ -20,10 +20,13 @@ namespace osu.Game.Rulesets.Taiko.Mods { var taikoBeatmap = (TaikoBeatmap)beatmap; + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + foreach (var obj in taikoBeatmap.HitObjects) { if (obj is Hit hit) - hit.Type = RNG.Next(2) == 0 ? HitType.Centre : HitType.Rim; + hit.Type = rng.Next(2) == 0 ? HitType.Centre : HitType.Rim; } } } diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 16c1004f37..e458e66ab7 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -217,7 +217,7 @@ namespace osu.Game.Tests.NonVisual throw new NotImplementedException(); } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { throw new NotImplementedException(); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index e47c782bca..bc7cf8eee2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -18,12 +18,12 @@ namespace osu.Game.Tests.Visual.Gameplay [Description("Player instantiated with an autoplay mod.")] public class TestSceneAutoplay : TestSceneAllRulesetPlayers { - protected new TestPlayer Player => (TestPlayer)base.Player; + protected new TestReplayPlayer Player => (TestReplayPlayer)base.Player; protected override Player CreatePlayer(Ruleset ruleset) { SelectedMods.Value = new[] { ruleset.GetAutoplayMod() }; - return new TestPlayer(false); + return new TestReplayPlayer(false); } protected override void AddCheckSteps() diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 253e448bb4..0308d74aa4 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -12,6 +12,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; @@ -95,11 +96,12 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - AddStep("set autoplay", () => Game.SelectedMods.Value = new[] { new OsuModAutoplay() }); + AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail(), new OsuModDoubleTime { SpeedChange = { Value = 2 } } }); AddStep("press enter", () => InputManager.Key(Key.Enter)); AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); - AddStep("seek to end", () => player.ChildrenOfType().First().Seek(beatmap().Track.Length)); + AddUntilStep("wait for track playing", () => beatmap().Track.IsRunning); + AddStep("seek to near end", () => player.ChildrenOfType().First().Seek(beatmap().Beatmap.HitObjects[^1].StartTime - 1000)); AddUntilStep("wait for pass", () => (results = Game.ScreenStack.CurrentScreen as ResultsScreen) != null && results.IsLoaded); AddStep("attempt to retry", () => results.ChildrenOfType().First().Action()); AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != player && Game.ScreenStack.CurrentScreen is Player); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index b13dd34ebc..3971146ff8 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -9,6 +9,8 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -36,6 +38,10 @@ namespace osu.Game.Tests.Visual.Online private Channel previousChannel => joinedChannels.ElementAt(joinedChannels.ToList().IndexOf(currentChannel) - 1); private Channel channel1 => channels[0]; private Channel channel2 => channels[1]; + private Channel channel3 => channels[2]; + + [Resolved] + private GameHost host { get; set; } public TestSceneChatOverlay() { @@ -44,7 +50,8 @@ namespace osu.Game.Tests.Visual.Online { Name = $"Channel no. {index}", Topic = index == 3 ? null : $"We talk about the number {index} here", - Type = index % 2 == 0 ? ChannelType.PM : ChannelType.Temporary + Type = index % 2 == 0 ? ChannelType.PM : ChannelType.Temporary, + Id = index }) .ToList(); } @@ -228,6 +235,92 @@ namespace osu.Game.Tests.Visual.Online AddAssert("All channels closed", () => !channelManager.JoinedChannels.Any()); } + [Test] + public void TestCloseTabShortcut() + { + AddStep("Join 2 channels", () => + { + channelManager.JoinChannel(channel1); + channelManager.JoinChannel(channel2); + }); + + // Want to close channel 2 + AddStep("Select channel 2", () => clickDrawable(chatOverlay.TabMap[channel2])); + AddStep("Close tab via shortcut", pressCloseDocumentKeys); + + // Channel 2 should be closed + AddAssert("Channel 1 open", () => channelManager.JoinedChannels.Contains(channel1)); + AddAssert("Channel 2 closed", () => !channelManager.JoinedChannels.Contains(channel2)); + + // Want to close channel 1 + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Close tab via shortcut", pressCloseDocumentKeys); + // Channel 1 and channel 2 should be closed + AddAssert("All channels closed", () => !channelManager.JoinedChannels.Any()); + } + + [Test] + public void TestNewTabShortcut() + { + AddStep("Join 2 channels", () => + { + channelManager.JoinChannel(channel1); + channelManager.JoinChannel(channel2); + }); + + // Want to join another channel + AddStep("Press new tab shortcut", pressNewTabKeys); + + // Selector should be visible + AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible); + } + + [Test] + public void TestRestoreTabShortcut() + { + AddStep("Join 3 channels", () => + { + channelManager.JoinChannel(channel1); + channelManager.JoinChannel(channel2); + channelManager.JoinChannel(channel3); + }); + + // Should do nothing + AddStep("Restore tab via shortcut", pressRestoreTabKeys); + AddAssert("All channels still open", () => channelManager.JoinedChannels.Count == 3); + + // Close channel 1 + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + AddStep("Click normal close button", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel1]).CloseButton.Child)); + AddAssert("Channel 1 closed", () => !channelManager.JoinedChannels.Contains(channel1)); + AddAssert("Other channels still open", () => channelManager.JoinedChannels.Count == 2); + + // Reopen channel 1 + AddStep("Restore tab via shortcut", pressRestoreTabKeys); + AddAssert("All channels now open", () => channelManager.JoinedChannels.Count == 3); + AddAssert("Current channel is channel 1", () => currentChannel == channel1); + + // Close two channels + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + AddStep("Close channel 1", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel1]).CloseButton.Child)); + AddStep("Select channel 2", () => clickDrawable(chatOverlay.TabMap[channel2])); + AddStep("Close channel 2", () => clickDrawable(((TestPrivateChannelTabItem)chatOverlay.TabMap[channel2]).CloseButton.Child)); + AddAssert("Only one channel open", () => channelManager.JoinedChannels.Count == 1); + AddAssert("Current channel is channel 3", () => currentChannel == channel3); + + // Should first re-open channel 2 + AddStep("Restore tab via shortcut", pressRestoreTabKeys); + AddAssert("Channel 1 still closed", () => !channelManager.JoinedChannels.Contains(channel1)); + AddAssert("Channel 2 now open", () => channelManager.JoinedChannels.Contains(channel2)); + AddAssert("Current channel is channel 2", () => currentChannel == channel2); + + // Should then re-open channel 1 + AddStep("Restore tab via shortcut", pressRestoreTabKeys); + AddAssert("All channels now open", () => channelManager.JoinedChannels.Count == 3); + AddAssert("Current channel is channel 1", () => currentChannel == channel1); + } + private void pressChannelHotkey(int number) { var channelKey = Key.Number0 + number; @@ -236,6 +329,23 @@ namespace osu.Game.Tests.Visual.Online InputManager.ReleaseKey(Key.AltLeft); } + private void pressCloseDocumentKeys() => pressKeysFor(PlatformActionType.DocumentClose); + + private void pressNewTabKeys() => pressKeysFor(PlatformActionType.TabNew); + + private void pressRestoreTabKeys() => pressKeysFor(PlatformActionType.TabRestore); + + private void pressKeysFor(PlatformActionType type) + { + var binding = host.PlatformKeyBindings.First(b => ((PlatformAction)b.Action).ActionType == type); + + foreach (var k in binding.KeyCombination.Keys) + InputManager.PressKey((Key)k); + + foreach (var k in binding.KeyCombination.Keys) + InputManager.ReleaseKey((Key)k); + } + private void clickDrawable(Drawable d) { InputManager.MoveMouseTo(d); diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index da4bf82948..4d09ed21dc 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Net; using NUnit.Framework; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -32,7 +33,14 @@ namespace osu.Game.Tests.Visual.Online AddStep("Show Article Page", () => wiki.ShowPage("Interface")); } - private void setUpWikiResponse(APIWikiPage r) + [Test] + public void TestErrorPage() + { + setUpWikiResponse(null, true); + AddStep("Show Error Page", () => wiki.ShowPage("Error")); + } + + private void setUpWikiResponse(APIWikiPage r, bool isFailed = false) => AddStep("set up response", () => { dummyAPI.HandleRequest = request => @@ -40,7 +48,11 @@ namespace osu.Game.Tests.Visual.Online if (!(request is GetWikiRequest getWikiRequest)) return false; - getWikiRequest.TriggerSuccess(r); + if (isFailed) + getWikiRequest.TriggerFailure(new WebException()); + else + getWikiRequest.TriggerSuccess(r); + return true; }; }); diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index a980f4c54b..8507887357 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Chat.Tabs; @@ -33,6 +34,16 @@ namespace osu.Game.Online.Chat private readonly BindableList availableChannels = new BindableList(); private readonly BindableList joinedChannels = new BindableList(); + /// + /// Keeps a stack of recently closed channels + /// + private readonly List closedChannels = new List(); + + // For efficiency purposes, this constant bounds the number of closed channels we store. + // This number is somewhat arbitrary; future developers are free to modify it. + // Must be a positive number. + private const int closed_channels_max_size = 50; + /// /// The currently opened channel /// @@ -51,6 +62,9 @@ namespace osu.Game.Online.Chat [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private UserLookupCache users { get; set; } + public readonly BindableBool HighPollRate = new BindableBool(); public ChannelManager() @@ -420,6 +434,18 @@ namespace osu.Game.Online.Chat joinedChannels.Remove(channel); + // Prevent the closedChannel list from exceeding the max size + // by removing the oldest element + if (closedChannels.Count >= closed_channels_max_size) + { + closedChannels.RemoveAt(0); + } + + // For PM channels, we store the user ID; else, we store the channel ID + closedChannels.Add(channel.Type == ChannelType.PM + ? new ClosedChannel(ChannelType.PM, channel.Users.Single().Id) + : new ClosedChannel(channel.Type, channel.Id)); + if (channel.Joined.Value) { api.Queue(new LeaveChannelRequest(channel)); @@ -427,6 +453,46 @@ namespace osu.Game.Online.Chat } }); + /// + /// Opens the most recently closed channel that has not already been reopened, + /// Works similarly to reopening the last closed tab on a web browser. + /// + public void JoinLastClosedChannel() + { + // This loop could be eliminated if the join channel operation ensured that every channel joined + // is removed from the closedChannels list, but it'd require a linear scan of closed channels on every join. + // To keep the overhead of joining channels low, just lazily scan the list of closed channels locally. + while (closedChannels.Count > 0) + { + ClosedChannel lastClosedChannel = closedChannels.Last(); + closedChannels.RemoveAt(closedChannels.Count - 1); + + // If the user has already joined the channel, try the next one + if (joinedChannels.FirstOrDefault(lastClosedChannel.Matches) != null) + continue; + + Channel lastChannel = AvailableChannels.FirstOrDefault(lastClosedChannel.Matches); + + if (lastChannel != null) + { + // Channel exists as an available channel, directly join it + CurrentChannel.Value = JoinChannel(lastChannel); + } + else if (lastClosedChannel.Type == ChannelType.PM) + { + // Try to get user in order to open PM chat + users.GetUserAsync((int)lastClosedChannel.Id).ContinueWith(u => + { + if (u.Result == null) return; + + Schedule(() => CurrentChannel.Value = JoinChannel(new Channel(u.Result))); + }); + } + + return; + } + } + private long lastMessageId; private bool channelsInitialised; @@ -511,4 +577,28 @@ namespace osu.Game.Online.Chat { } } + + /// + /// Stores information about a closed channel + /// + public class ClosedChannel + { + public readonly ChannelType Type; + public readonly long Id; + + public ClosedChannel(ChannelType type, long id) + { + Type = type; + Id = id; + } + + public bool Matches(Channel channel) + { + if (channel.Type != Type) return false; + + return Type == ChannelType.PM + ? channel.Users.Single().Id == Id + : channel.Id == Id; + } + } } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 7935815f38..9c3adba342 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -58,7 +58,7 @@ namespace osu.Game /// /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. /// - internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.5; + internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.8; public bool UseDevelopmentServer { get; } diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs b/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs index 19c6f437b6..c0de093425 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs @@ -81,9 +81,11 @@ namespace osu.Game.Overlays.Chat.Tabs RemoveItem(channel); if (SelectedTab == null) - SelectTab(selectorTab); + SelectChannelSelectorTab(); } + public void SelectChannelSelectorTab() => SelectTab(selectorTab); + protected override void SelectTab(TabItem tab) { if (tab is ChannelSelectorTabItem) diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 285041800a..0aa6108815 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -24,13 +24,15 @@ using osu.Game.Overlays.Chat.Tabs; using osuTK.Input; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; using osu.Framework.Localisation; using osu.Game.Localisation; using osu.Game.Online; namespace osu.Game.Overlays { - public class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent + public class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, IKeyBindingHandler { public string IconTexture => "Icons/Hexacons/messaging"; public LocalisableString Title => ChatStrings.HeaderTitle; @@ -370,6 +372,30 @@ namespace osu.Game.Overlays return base.OnKeyDown(e); } + public bool OnPressed(PlatformAction action) + { + switch (action.ActionType) + { + case PlatformActionType.TabNew: + ChannelTabControl.SelectChannelSelectorTab(); + return true; + + case PlatformActionType.TabRestore: + channelManager.JoinLastClosedChannel(); + return true; + + case PlatformActionType.DocumentClose: + channelManager.LeaveChannel(channelManager.CurrentChannel.Value); + return true; + } + + return false; + } + + public void OnReleased(PlatformAction action) + { + } + public override bool AcceptsFocus => true; protected override void OnFocus(FocusEvent e) diff --git a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs index 9e397e78c8..35cd6eb03b 100644 --- a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs +++ b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs @@ -6,87 +6,36 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; -using osu.Framework.Graphics.Shapes; using osuTK; using System.Linq; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.News.Sidebar { - public class NewsSidebar : CompositeDrawable + public class NewsSidebar : OverlaySidebar { [Cached] public readonly Bindable Metadata = new Bindable(); private FillFlowContainer monthsFlow; - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + protected override Drawable CreateContent() => new FillFlowContainer { - RelativeSizeAxes = Axes.Y; - Width = 250; - InternalChildren = new Drawable[] + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 20), + Children = new Drawable[] { - new Box + new YearsPanel(), + monthsFlow = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4 - }, - new Box - { - RelativeSizeAxes = Axes.Y, - Width = OsuScrollContainer.SCROLL_BAR_HEIGHT, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = colourProvider.Background3, - Alpha = 0.5f - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin - Child = new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 3 }, // Addeded 3px back - Child = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding - { - Vertical = 20, - Left = 50, - Right = 30 - }, - Child = new FillFlowContainer - { - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 20), - Children = new Drawable[] - { - new YearsPanel(), - monthsFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10) - } - } - } - } - } - } + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10) } - }; - } + } + }; protected override void LoadComplete() { diff --git a/osu.Game/Overlays/OverlaySidebar.cs b/osu.Game/Overlays/OverlaySidebar.cs new file mode 100644 index 0000000000..468b5b6eb3 --- /dev/null +++ b/osu.Game/Overlays/OverlaySidebar.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays +{ + public abstract class OverlaySidebar : CompositeDrawable + { + private readonly Box sidebarBackground; + private readonly Box scrollbarBackground; + + protected OverlaySidebar() + { + RelativeSizeAxes = Axes.Y; + Width = 250; + InternalChildren = new Drawable[] + { + sidebarBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + scrollbarBackground = new Box + { + RelativeSizeAxes = Axes.Y, + Width = OsuScrollContainer.SCROLL_BAR_HEIGHT, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = 0.5f + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 3 }, // Addeded 3px back + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = 20, + Left = 50, + Right = 30 + }, + Child = CreateContent() + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + sidebarBackground.Colour = colourProvider.Background4; + scrollbarBackground.Colour = colourProvider.Background3; + } + + [NotNull] + protected virtual Drawable CreateContent() => Empty(); + } +} diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index af7bc40f17..c5ba0eed66 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -92,7 +92,7 @@ namespace osu.Game.Overlays Loading.Show(); request.Success += response => Schedule(() => onSuccess(response)); - request.Failure += _ => Schedule(() => LoadDisplay(Empty())); + request.Failure += _ => Schedule(onFail); api.PerformAsync(request); } @@ -132,6 +132,24 @@ namespace osu.Game.Overlays } } + private void onFail() + { + LoadDisplay(new WikiMarkdownContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + CurrentPath = $@"{api.WebsiteRootUrl}/wiki/", + Text = $"Something went wrong when trying to fetch page \"{path.Value}\".\n\n[Return to the main page](Main_Page).", + DocumentMargin = new MarginPadding(0), + DocumentPadding = new MarginPadding + { + Vertical = 20, + Left = 30, + Right = 50, + }, + }); + } + private void showParentPage() { var parentPath = string.Join("/", path.Value.Split('/').SkipLast(1)); diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 5780fe39fa..3cc69bd85b 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Difficulty private DifficultyAttributes calculate(IBeatmap beatmap, Mod[] mods, double clockRate) { - var skills = CreateSkills(beatmap, mods); + var skills = CreateSkills(beatmap, mods, clockRate); if (!beatmap.HitObjects.Any()) return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); @@ -180,7 +180,8 @@ namespace osu.Game.Rulesets.Difficulty /// /// The whose difficulty will be calculated. /// Mods to calculate difficulty with. + /// Clockrate to calculate difficulty with. /// The s. - protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods); + protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate); } } diff --git a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs index 8166e6b8ce..071f01ca00 100644 --- a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs +++ b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs @@ -1,9 +1,11 @@ // 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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; @@ -52,15 +54,21 @@ namespace osu.Game.Rulesets.Edit if (changeHandler != null) { // for now only regenerate replay on a finalised state change, not HitObjectUpdated. - changeHandler.OnStateChange += updateReplay; + changeHandler.OnStateChange += () => Scheduler.AddOnce(regenerateAutoplay); } else { - beatmap.HitObjectUpdated += _ => updateReplay(); + beatmap.HitObjectUpdated += _ => Scheduler.AddOnce(regenerateAutoplay); } + + Scheduler.AddOnce(regenerateAutoplay); } - private void updateReplay() => Scheduler.AddOnce(drawableRuleset.RegenerateAutoplay); + private void regenerateAutoplay() + { + var autoplayMod = drawableRuleset.Mods.OfType().Single(); + drawableRuleset.SetReplayScore(autoplayMod.CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); + } private void addHitObject(HitObject hitObject) { diff --git a/osu.Game/Rulesets/Mods/ICreateReplay.cs b/osu.Game/Rulesets/Mods/ICreateReplay.cs new file mode 100644 index 0000000000..098bd8799a --- /dev/null +++ b/osu.Game/Rulesets/Mods/ICreateReplay.cs @@ -0,0 +1,14 @@ +// 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.Game.Beatmaps; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public interface ICreateReplay + { + public Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods); + } +} diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index d6e1d46b06..b84b5671e1 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -7,22 +7,11 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Replays; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.UI; using osu.Game.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModAutoplay : ModAutoplay, IApplicableToDrawableRuleset - where T : HitObject - { - public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) - { - drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); - } - } - - public abstract class ModAutoplay : Mod, IApplicableFailOverride + public abstract class ModAutoplay : Mod, IApplicableFailOverride, ICreateReplay { public override string Name => "Autoplay"; public override string Acronym => "AT"; diff --git a/osu.Game/Rulesets/Mods/ModRandom.cs b/osu.Game/Rulesets/Mods/ModRandom.cs index da55ab3fbf..3f14263420 100644 --- a/osu.Game/Rulesets/Mods/ModRandom.cs +++ b/osu.Game/Rulesets/Mods/ModRandom.cs @@ -1,8 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; namespace osu.Game.Rulesets.Mods { @@ -13,5 +20,89 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.Conversion; public override IconUsage? Icon => OsuIcon.Dice; public override double ScoreMultiplier => 1; + + [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(ModRandomSettingsControl))] + public Bindable Seed { get; } = new Bindable + { + Default = null, + Value = null + }; + + private class ModRandomSettingsControl : SettingsItem + { + protected override Drawable CreateControl() => new SeedControl + { + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Top = 5 } + }; + + private sealed class SeedControl : CompositeDrawable, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current; + set + { + current.Current = value; + seedNumberBox.Text = value.Value.ToString(); + } + } + + private readonly OsuNumberBox seedNumberBox; + + public SeedControl() + { + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 2), + new Dimension(GridSizeMode.Relative, 0.25f) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + seedNumberBox = new OsuNumberBox + { + RelativeSizeAxes = Axes.X, + CommitOnFocusLost = true + } + } + } + } + }; + + seedNumberBox.Current.BindValueChanged(e => + { + int? value = null; + + if (int.TryParse(e.NewValue, out var intVal)) + value = intVal; + + current.Value = value; + }); + } + + protected override void Update() + { + if (current.Value == null) + seedNumberBox.Text = current.Current.Value.ToString(); + } + } + } } } diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index a2dade2627..e2e3c22618 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -182,18 +182,11 @@ namespace osu.Game.Rulesets.UI .WithChild(ResumeOverlay))); } - RegenerateAutoplay(); + applyRulesetMods(Mods, config); loadObjects(cancellationToken ?? default); } - public void RegenerateAutoplay() - { - // for now this is applying mods which aren't just autoplay. - // we'll need to reconsider this flow in the future. - applyRulesetMods(Mods, config); - } - /// /// Creates and adds drawable representations of hit objects to the play field. /// @@ -274,6 +267,12 @@ namespace osu.Game.Rulesets.UI if (!(KeyBindingInputManager is IHasRecordingHandler recordingInputManager)) throw new InvalidOperationException($"A {nameof(KeyBindingInputManager)} which supports recording is not available"); + if (score == null) + { + recordingInputManager.Recorder = null; + return; + } + var recorder = CreateReplayRecorder(score); if (recorder == null) @@ -518,7 +517,7 @@ namespace osu.Game.Rulesets.UI /// Sets a replay to be used to record gameplay. /// /// The target to be recorded to. - public abstract void SetRecordTarget(Score score); + public abstract void SetRecordTarget([CanBeNull] Score score); /// /// Invoked when the interactive user requests resuming from a paused state. diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 75c3a4661c..e3b9ad5641 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -30,12 +30,14 @@ namespace osu.Game.Rulesets.UI { set { - if (recorder != null) + if (value != null && recorder != null) throw new InvalidOperationException("Cannot attach more than one recorder"); + recorder?.Expire(); recorder = value; - KeyBindingContainer.Add(recorder); + if (recorder != null) + KeyBindingContainer.Add(recorder); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index 0fe9e01d9d..2c157b0564 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -17,7 +17,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public class MultiSpectatorPlayer : SpectatorPlayer { private readonly Bindable waitingOnFrames = new Bindable(true); - private readonly Score score; private readonly ISpectatorPlayerClock spectatorPlayerClock; /// @@ -28,7 +27,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock) : base(score) { - this.score = score; this.spectatorPlayerClock = spectatorPlayerClock; } @@ -43,7 +41,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate base.UpdateAfterChildren(); // This is required because the frame stable clock is set to WaitingOnFrames = false for one frame. - waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || score.Replay.Frames.Count == 0; + waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0; } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 260d4961ff..a2ef715367 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -54,11 +54,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return new PlaylistsResultsScreen(score, RoomId.Value.Value, PlaylistItem, true); } - protected override Score CreateScore() + protected override void PrepareScoreForResults() { - var score = base.CreateScore(); - score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); - return score; + base.PrepareScoreForResults(); + + Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index a9f3edf049..20012d0282 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -24,10 +23,8 @@ using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Overlays; -using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; @@ -137,6 +134,8 @@ namespace osu.Game.Screens.Play public readonly PlayerConfiguration Configuration; + protected Score Score { get; private set; } + /// /// Create a new player instance. /// @@ -145,7 +144,7 @@ namespace osu.Game.Screens.Play Configuration = configuration ?? new PlayerConfiguration(); } - private GameplayBeatmap gameplayBeatmap; + protected GameplayBeatmap GameplayBeatmap { get; private set; } private ScreenSuspensionHandler screenSuspension; @@ -161,24 +160,32 @@ namespace osu.Game.Screens.Play if (!LoadedBeatmapSuccessfully) return; - // replays should never be recorded or played back when autoplay is enabled - if (!Mods.Value.Any(m => m is ModAutoplay)) - PrepareReplay(); + Score = CreateScore(); + + // ensure the score is in a consistent state with the current player. + Score.ScoreInfo.Beatmap = Beatmap.Value.BeatmapInfo; + Score.ScoreInfo.Ruleset = rulesetInfo; + Score.ScoreInfo.Mods = Mods.Value.ToArray(); + + PrepareReplay(); + + ScoreProcessor.NewJudgement += result => ScoreProcessor.PopulateScore(Score.ScoreInfo); gameActive.BindValueChanged(_ => updatePauseOnFocusLostState(), true); } - [CanBeNull] - private Score recordingScore; - /// /// Run any recording / playback setup for replays. /// protected virtual void PrepareReplay() { - DrawableRuleset.SetRecordTarget(recordingScore = new Score()); + DrawableRuleset.SetRecordTarget(Score); + } - ScoreProcessor.NewJudgement += result => ScoreProcessor.PopulateScore(recordingScore.ScoreInfo); + protected virtual void PrepareScoreForResults() + { + // perform one final population to ensure everything is up-to-date. + ScoreProcessor.PopulateScore(Score.ScoreInfo); } [BackgroundDependencyLoader(true)] @@ -223,10 +230,10 @@ namespace osu.Game.Screens.Play InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); - AddInternal(gameplayBeatmap = new GameplayBeatmap(playableBeatmap)); + AddInternal(GameplayBeatmap = new GameplayBeatmap(playableBeatmap)); AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); - dependencies.CacheAs(gameplayBeatmap); + dependencies.CacheAs(GameplayBeatmap); var beatmapSkinProvider = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); @@ -284,7 +291,7 @@ namespace osu.Game.Screens.Play { HealthProcessor.ApplyResult(r); ScoreProcessor.ApplyResult(r); - gameplayBeatmap.ApplyResult(r); + GameplayBeatmap.ApplyResult(r); }; DrawableRuleset.RevertResult += r => @@ -629,15 +636,18 @@ namespace osu.Game.Screens.Play ValidForResume = false; + // ensure we are not writing to the replay any more, as we are about to consume and store the score. + DrawableRuleset.SetRecordTarget(null); + if (!Configuration.ShowResults) return; prepareScoreForDisplayTask ??= Task.Run(async () => { - var score = CreateScore(); + PrepareScoreForResults(); try { - await PrepareScoreForResultsAsync(score).ConfigureAwait(false); + await PrepareScoreForResultsAsync(Score).ConfigureAwait(false); } catch (Exception ex) { @@ -646,14 +656,14 @@ namespace osu.Game.Screens.Play try { - await ImportScore(score).ConfigureAwait(false); + await ImportScore(Score).ConfigureAwait(false); } catch (Exception ex) { Logger.Error(ex, "Score import failed!"); } - return score.ScoreInfo; + return Score.ScoreInfo; }); if (skipStoryboardOutro) @@ -905,41 +915,19 @@ namespace osu.Game.Screens.Play } /// - /// Creates the player's . + /// Creates the player's . /// - /// The . - protected virtual Score CreateScore() - { - var score = new Score + /// The . + protected virtual Score CreateScore() => + new Score { - ScoreInfo = new ScoreInfo - { - Beatmap = Beatmap.Value.BeatmapInfo, - Ruleset = rulesetInfo, - Mods = Mods.Value.ToArray(), - } + ScoreInfo = new ScoreInfo { User = api.LocalUser.Value }, }; - if (DrawableRuleset.ReplayScore != null) - { - score.ScoreInfo.User = DrawableRuleset.ReplayScore.ScoreInfo?.User ?? new GuestUser(); - score.Replay = DrawableRuleset.ReplayScore.Replay; - } - else - { - score.ScoreInfo.User = api.LocalUser.Value; - score.Replay = new Replay { Frames = recordingScore?.Replay.Frames.ToList() ?? new List() }; - } - - ScoreProcessor.PopulateScore(score.ScoreInfo); - - return score; - } - /// - /// Imports the player's to the local database. + /// Imports the player's to the local database. /// - /// The to import. + /// The to import. /// The imported score. protected virtual async Task ImportScore(Score score) { @@ -951,7 +939,7 @@ namespace osu.Game.Screens.Play using (var stream = new MemoryStream()) { - new LegacyScoreEncoder(score, gameplayBeatmap.PlayableBeatmap).Encode(stream); + new LegacyScoreEncoder(score, GameplayBeatmap.PlayableBeatmap).Encode(stream); replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); } @@ -970,9 +958,9 @@ namespace osu.Game.Screens.Play } /// - /// Prepare the for display at results. + /// Prepare the for display at results. /// - /// The to prepare. + /// The to prepare. /// A task that prepares the provided score. On completion, the score is assumed to be ready for display. protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask; diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index e23cc22929..f70c05c2ff 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.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.Collections.Generic; using System.Threading.Tasks; using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -11,15 +15,20 @@ namespace osu.Game.Screens.Play { public class ReplayPlayer : Player, IKeyBindingHandler { - protected readonly Score Score; + private readonly Func, Score> createScore; // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) protected override bool CheckModsAllowFailure() => false; public ReplayPlayer(Score score, PlayerConfiguration configuration = null) + : this((_, __) => score, configuration) + { + } + + public ReplayPlayer(Func, Score> createScore, PlayerConfiguration configuration = null) : base(configuration) { - Score = score; + this.createScore = createScore; } protected override void PrepareReplay() @@ -27,15 +36,7 @@ namespace osu.Game.Screens.Play DrawableRuleset?.SetReplayScore(Score); } - protected override Score CreateScore() - { - var baseScore = base.CreateScore(); - - // Since the replay score doesn't contain statistics, we'll pass them through here. - Score.ScoreInfo.HitEvents = baseScore.ScoreInfo.HitEvents; - - return Score; - } + protected override Score CreateScore() => createScore(GameplayBeatmap.PlayableBeatmap, Mods.Value); // Don't re-import replay scores as they're already present in the database. protected override Task ImportScore(Score score) => Task.CompletedTask; diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index a8125dfded..67471dff90 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -18,6 +18,9 @@ namespace osu.Game.Screens.Play { private readonly Score score; + [Resolved] + private SpectatorClient spectatorClient { get; set; } + protected override bool CheckModsAllowFailure() => false; // todo: better support starting mid-way through beatmap public SpectatorPlayer(Score score) @@ -25,13 +28,10 @@ namespace osu.Game.Screens.Play this.score = score; } - protected override ResultsScreen CreateResults(ScoreInfo score) - { - return new SpectatorResultsScreen(score); - } + protected override Score CreateScore() => score; - [Resolved] - private SpectatorClient spectatorClient { get; set; } + protected override ResultsScreen CreateResults(ScoreInfo score) + => new SpectatorResultsScreen(score); [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index b0084735b1..53e30fd9ca 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -186,11 +186,11 @@ namespace osu.Game.Screens.Select.Details set => name.Text = value; } - private (float baseValue, float? adjustedValue) value; + private (float baseValue, float? adjustedValue)? value; public (float baseValue, float? adjustedValue) Value { - get => value; + get => value ?? (0, null); set { if (value == this.value) diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index dfb4b59060..418cf23ce7 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.Select public class PlaySongSelect : SongSelect { private bool removeAutoModOnResume; - private OsuScreen player; + private OsuScreen playerLoader; [Resolved(CanBeNull = true)] private NotificationOverlay notifications { get; set; } @@ -49,7 +49,7 @@ namespace osu.Game.Screens.Select { base.OnResuming(last); - player = null; + playerLoader = null; if (removeAutoModOnResume) { @@ -79,14 +79,14 @@ namespace osu.Game.Screens.Select protected override bool OnStart() { - if (player != null) return false; + if (playerLoader != null) return false; // Ctrl+Enter should start map with autoplay enabled. if (GetContainingInputManager().CurrentState?.Keyboard.ControlPressed == true) { - var autoplayMod = getAutoplayMod(); + var autoInstance = getAutoplayMod(); - if (autoplayMod == null) + if (autoInstance == null) { notifications?.Post(new SimpleNotification { @@ -97,18 +97,26 @@ namespace osu.Game.Screens.Select var mods = Mods.Value; - if (mods.All(m => m.GetType() != autoplayMod.GetType())) + if (mods.All(m => m.GetType() != autoInstance.GetType())) { - Mods.Value = mods.Append(autoplayMod).ToArray(); + Mods.Value = mods.Append(autoInstance).ToArray(); removeAutoModOnResume = true; } } SampleConfirm?.Play(); - this.Push(player = new PlayerLoader(() => new SoloPlayer())); - + this.Push(playerLoader = new PlayerLoader(createPlayer)); return true; + + Player createPlayer() + { + var replayGeneratingMod = Mods.Value.OfType().FirstOrDefault(); + if (replayGeneratingMod != null) + return new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateReplayScore(beatmap, mods)); + + return new SoloPlayer(); + } } } } diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 01dd7a25c8..c7edc0174a 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input; using osu.Framework.Testing.Input; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; @@ -48,7 +49,7 @@ namespace osu.Game.Tests.Visual InputManager = new ManualInputManager { UseParentInput = true, - Child = mainContent + Child = new PlatformActionContainer().WithChild(mainContent) }, new Container { diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index 0addc9de75..09da4db952 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Rulesets.Judgements; @@ -48,6 +49,26 @@ namespace osu.Game.Tests.Visual PauseOnFocusLost = pauseOnFocusLost; } + protected override void PrepareReplay() + { + // Generally, replay generation is handled by whatever is constructing the player. + // This is implemented locally here to ease migration of test scenes that have some executions + // running with autoplay and some not, but are not written in a way that lends to instantiating + // different `Player` types. + // + // Eventually we will want to remove this and update all test usages which rely on autoplay to use + // a `TestReplayPlayer`. + var autoplayMod = Mods.Value.OfType().FirstOrDefault(); + + if (autoplayMod != null) + { + DrawableRuleset?.SetReplayScore(autoplayMod.CreateReplayScore(GameplayBeatmap.PlayableBeatmap, Mods.Value)); + return; + } + + base.PrepareReplay(); + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Tests/Visual/TestReplayPlayer.cs b/osu.Game/Tests/Visual/TestReplayPlayer.cs new file mode 100644 index 0000000000..da302d018d --- /dev/null +++ b/osu.Game/Tests/Visual/TestReplayPlayer.cs @@ -0,0 +1,65 @@ +// 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 System.Linq; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual +{ + /// + /// A player that exposes many components that would otherwise not be available, for testing purposes. + /// + public class TestReplayPlayer : ReplayPlayer + { + protected override bool PauseOnFocusLost { get; } + + public new DrawableRuleset DrawableRuleset => base.DrawableRuleset; + + /// + /// Mods from *player* (not OsuScreen). + /// + public new Bindable> Mods => base.Mods; + + public new HUDOverlay HUDOverlay => base.HUDOverlay; + + public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; + + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + public new HealthProcessor HealthProcessor => base.HealthProcessor; + + public new bool PauseCooldownActive => base.PauseCooldownActive; + + /// + /// Instantiate a replay player that renders an autoplay mod. + /// + public TestReplayPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) + : base((beatmap, mods) => mods.OfType().First().CreateReplayScore(beatmap, mods), new PlayerConfiguration + { + AllowPause = allowPause, + ShowResults = showResults + }) + { + PauseOnFocusLost = pauseOnFocusLost; + } + + /// + /// Instantiate a replay player that renders the provided replay. + /// + public TestReplayPlayer(Score score, bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) + : base(score, new PlayerConfiguration + { + AllowPause = allowPause, + ShowResults = showResults + }) + { + PauseOnFocusLost = pauseOnFocusLost; + } + } +} diff --git a/osu.Game/Users/Drawables/DrawableAvatar.cs b/osu.Game/Users/Drawables/DrawableAvatar.cs index 3dae3afe3f..ef074813a5 100644 --- a/osu.Game/Users/Drawables/DrawableAvatar.cs +++ b/osu.Game/Users/Drawables/DrawableAvatar.cs @@ -31,7 +31,7 @@ namespace osu.Game.Users.Drawables private void load(LargeTextureStore textures) { if (user != null && user.Id > 1) - Texture = textures.Get($@"https://a.ppy.sh/{user.Id}"); + Texture = textures.Get(user.AvatarUrl); Texture ??= textures.Get(@"Online/avatar-guest"); }