diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModCrumblingCircles.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModCrumblingCircles.cs new file mode 100644 index 0000000000..a9b9e374c0 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModCrumblingCircles.cs @@ -0,0 +1,68 @@ +// 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 NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModCrumblingCircles : OsuModTestScene + { + private const float initial_circle_size_f = 5; + private const float target_circle_size_f = 10; + + [Test] + public void TestOsuModCrumblingCircles() => CreateModTest(new ModTestData + { + Mod = new OsuModCrumblingCircles + { + TargetCircleSize = { Value = target_circle_size_f } + }, + CreateBeatmap = () => new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Difficulty = new BeatmapDifficulty + { + CircleSize = initial_circle_size_f + } + }, + HitObjects = new List + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1100 }, + new HitCircle { StartTime = 1200 } + } + }, + Autoplay = true, + PassCondition = () => + { + var objects = Player.ChildrenOfType(); + if (!objects.Any()) + return false; + + bool firstHitObjectIsInitialSize = + Precision.AlmostEquals(objects.Last().HitObject.Scale, getRawScaleForCircleSize(initial_circle_size_f)); + bool lastHitObjectIsTargetSize = + Precision.AlmostEquals(objects.First().HitObject.Scale, getRawScaleForCircleSize(target_circle_size_f)); + + // First object of the map should be the initial circle size and last one should be the target size + return firstHitObjectIsInitialSize && lastHitObjectIsTargetSize; + } + }); + + private float getRawScaleForCircleSize(float circleSize) + { + return LegacyRulesetExtensions.CalculateScaleFromCircleSize(circleSize, true); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCrumblingCircles.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCrumblingCircles.cs new file mode 100644 index 0000000000..357027f1ca --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModCrumblingCircles.cs @@ -0,0 +1,86 @@ +// 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.Extensions.IEnumerableExtensions; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModCrumblingCircles : Mod, IApplicableToHitObject, IApplicableToBeatmapProcessor, IApplicableToDifficulty + { + public override string Name => "Crumbling Circles"; + public override string Acronym => "CC"; + public override LocalisableString Description => "The more you play, the smaller the circles get!"; + public override ModType Type => ModType.Fun; + public override double ScoreMultiplier => 1d; + public override bool Ranked => false; + + private const float target_size_precision_f = 0.1f; + + private float initialCircleSize; + private int initialObjectCount; + + private float? currentCircleSize => + (initialCircleSize - TargetCircleSize.Value) / initialObjectCount * hitObjectCount + TargetCircleSize.Value; + + private int hitObjectCount; + + [SettingSource("Target Circle Size", "The size of the circles at the end of the map.", SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable TargetCircleSize { get; set; } = new DifficultyBindable(7) + { + Precision = target_size_precision_f, + MinValue = 0, + MaxValue = 10, + ExtendedMaxValue = 11, + }; + + [SettingSource("Extended Limits", "Adjust target size beyond sane limits.")] + public BindableBool ExtendedLimits { get; } = new BindableBool(); + + public OsuModCrumblingCircles() + { + TargetCircleSize.ExtendedLimits.BindTo(ExtendedLimits); + } + + public void ApplyToHitObject(HitObject hitObject) + { + var osuObject = (OsuHitObject)hitObject; + + // Spinners don't need to have a size change + if (osuObject is not Spinner) + applyCurrentCircleSize(osuObject); + + hitObjectCount--; + } + + public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor) + { + // We use the Beatmap Processor to populate these values before the hit object changes. + initialObjectCount = hitObjectCount = beatmapProcessor.Beatmap.HitObjects.Count - 1; + initialCircleSize = beatmapProcessor.Beatmap.Difficulty.CircleSize; + } + + private void applyCurrentCircleSize(OsuHitObject osuObject) + { + osuObject.Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(currentCircleSize ?? initialCircleSize, true); + osuObject.NestedHitObjects.ForEach(o => applyCurrentCircleSize((OsuHitObject)o)); + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + // Ensure we do not overflow the max value in case circle size is being adjusted to 10 by another mod. + if (difficulty.CircleSize > 9.8f) + return; + + // Set the possible target value range based on current circle size diff. + TargetCircleSize.MinValue = difficulty.CircleSize + target_size_precision_f; + } + } +} diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 25b1dd9b12..9cb3713f5f 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -215,7 +215,8 @@ namespace osu.Game.Rulesets.Osu new OsuModBubbles(), new OsuModSynesthesia(), new OsuModDepth(), - new OsuModBloom() + new OsuModBloom(), + new OsuModCrumblingCircles() }; case ModType.System: