Files
Ez2Lazer/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs
Bartłomiej Dach 9bf52034d7 Make Hidden and Freeze Frame mods incompatible (#36515)
Effectively closing https://github.com/ppy/osu/issues/21471 as a
wontfix.

The issue is demonstrated by the following test case:

<details>
<summary>patch</summary>

```diff
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs
index 31498295da..36b4fe5122 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs
@@ -55,5 +55,49 @@ public void TestSkipToFirstSpinnerNotSuppressed()
                 PassCondition = () => Player.GameplayClockContainer.GameplayStartTime > 0
             });
         }
+
+        [Test]
+        public void TestFreezeFrameAppliedBeforeHidden()
+        {
+            CreateModTest(new ModTestData
+            {
+                Mods =
+                [
+                    new OsuModFreezeFrame(),
+                    new OsuModHidden(),
+                ],
+                CreateBeatmap = () => new OsuBeatmap
+                {
+                    HitObjects =
+                    {
+                        new HitCircle { StartTime = 3000, Position = OsuPlayfield.BASE_SIZE / 2, NewCombo = true },
+                        new HitCircle { StartTime = 5000, Position = OsuPlayfield.BASE_SIZE / 2 },
+                    }
+                },
+                PassCondition = () => ((HitCircle)Player.GameplayState.Beatmap.HitObjects[1]).TimeFadeIn == 480
+            });
+        }
+
+        [Test]
+        public void TestFreezeFrameAppliedAfterHidden()
+        {
+            CreateModTest(new ModTestData
+            {
+                Mods =
+                [
+                    new OsuModHidden(),
+                    new OsuModFreezeFrame(),
+                ],
+                CreateBeatmap = () => new OsuBeatmap
+                {
+                    HitObjects =
+                    {
+                        new HitCircle { StartTime = 3000, Position = OsuPlayfield.BASE_SIZE / 2, NewCombo = true },
+                        new HitCircle { StartTime = 5000, Position = OsuPlayfield.BASE_SIZE / 2 },
+                    }
+                },
+                PassCondition = () => ((HitCircle)Player.GameplayState.Beatmap.HitObjects[1]).TimeFadeIn == 480
+            });
+        }
     }
 }

```

</details>

The reason that this is happening is a data dependency. Freeze Frame
modifies `TimePreempt` of hitobjects:


54c0b2c20c/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs (L53)

while Hidden uses `TimePreempt` to set `TimeFadeIn`:


54c0b2c20c/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs (L45)

Therefore the final value of `TimeFadeIn` with these two mods active
depends on the order of application.

The reason why I'm bothering to do this is that I was pinged from the PP
development server about this again in the context of some ongoing
'rework' and I wish to get ahead of this before it becomes a bigger
problem than it already is.

The current order of application of these mods as done in `Player` is
constant, but essentially undefined. I'm not even sure what even
enforces the current order. It's currently Hidden, then Freeze Frame. If
I were to guess, the thing "enforcing" (insert 400 tonne solid lead air
quotes) it is probably the mod select overlay with its "I need to own
all of the mod instances in the global bindable" substitution logic.

I'm already getting pushback for this from the PP server crowd who are
attempting to justify the current "behaviour" by saying that the player
base wants this or by saying that it's already broken so it should
remain that way forever. I am however not willing to accept
[stable-taiko-tier stupidity
again](https://github.com/ppy/osu/pull/27136#issuecomment-1957055402)
and am not willing to accept the complexity this would invite everywhere
else.

I do not see any other easy way of fixing this problem at this point in
time. I had hoped that I could inline the `TimePreempt` read in
`OsuModHidden`, but it's not that easy because it's set in multiple
places.

Existing HDFF scores would probably need to be delisted from the
leaderboards. I'm not even sure I can get myself to pretend to care.
2026-01-29 16:35:13 +09:00

197 lines
7.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Skinning;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModHidden : ModHidden, IHidesApproachCircles
{
[SettingSource("Only fade approach circles", "The main object body will not fade when enabled.")]
public Bindable<bool> OnlyFadeApproachCircles { get; } = new BindableBool();
public override LocalisableString Description => @"Play with no approach circles and fading circles/sliders.";
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn), typeof(OsuModDepth), typeof(OsuModFreezeFrame) };
public const double FADE_IN_DURATION_MULTIPLIER = 0.4;
public const double FADE_OUT_DURATION_MULTIPLIER = 0.3;
protected override bool IsFirstAdjustableObject(HitObject hitObject) => !(hitObject is Spinner || hitObject is SpinnerTick);
public override void ApplyToBeatmap(IBeatmap beatmap)
{
base.ApplyToBeatmap(beatmap);
foreach (var obj in beatmap.HitObjects.OfType<OsuHitObject>())
applyFadeInAdjustment(obj);
static void applyFadeInAdjustment(OsuHitObject osuObject)
{
osuObject.TimeFadeIn = osuObject.TimePreempt * FADE_IN_DURATION_MULTIPLIER;
foreach (var nested in osuObject.NestedHitObjects.OfType<OsuHitObject>())
applyFadeInAdjustment(nested);
}
}
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
applyHiddenState(hitObject, true);
}
protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
applyHiddenState(hitObject, false);
}
private void applyHiddenState(DrawableHitObject drawableObject, bool increaseVisibility)
{
if (!(drawableObject is DrawableOsuHitObject drawableOsuObject))
return;
OsuHitObject hitObject = drawableOsuObject.HitObject;
(double fadeStartTime, double fadeDuration) = getFadeOutParameters(drawableOsuObject);
// process approach circle hiding first (to allow for early return below).
if (!increaseVisibility)
{
if (drawableObject is DrawableHitCircle circle)
{
using (circle.BeginAbsoluteSequence(hitObject.StartTime - hitObject.TimePreempt))
circle.ApproachCircle.Hide();
}
else if (drawableObject is DrawableSpinner spinner)
{
spinner.Body.OnSkinChanged += () => hideSpinnerApproachCircle(spinner);
hideSpinnerApproachCircle(spinner);
}
}
if (OnlyFadeApproachCircles.Value)
return;
switch (drawableObject)
{
case DrawableSliderTail:
using (drawableObject.BeginAbsoluteSequence(fadeStartTime))
drawableObject.FadeOut(fadeDuration);
break;
case DrawableSliderRepeat sliderRepeat:
using (drawableObject.BeginAbsoluteSequence(fadeStartTime))
// only apply to circle piece reverse arrow is not affected by hidden.
sliderRepeat.CirclePiece.FadeOut(fadeDuration);
using (drawableObject.BeginAbsoluteSequence(drawableObject.HitStateUpdateTime))
sliderRepeat.FadeOut();
break;
case DrawableHitCircle circle:
Drawable fadeTarget = circle;
if (increaseVisibility)
{
// only fade the circle piece (not the approach circle) for the increased visibility object.
fadeTarget = circle.CirclePiece;
}
using (drawableObject.BeginAbsoluteSequence(fadeStartTime))
fadeTarget.FadeOut(fadeDuration);
break;
case DrawableSlider slider:
using (slider.BeginAbsoluteSequence(fadeStartTime))
slider.Body.FadeOut(fadeDuration, Easing.Out);
break;
case DrawableSliderTick sliderTick:
using (sliderTick.BeginAbsoluteSequence(fadeStartTime))
sliderTick.FadeOut(fadeDuration);
break;
case DrawableSpinner spinner:
// hide elements we don't care about.
// todo: hide background
using (spinner.BeginAbsoluteSequence(fadeStartTime))
spinner.FadeOut(fadeDuration);
break;
}
}
private (double fadeStartTime, double fadeDuration) getFadeOutParameters(DrawableOsuHitObject drawableObject)
{
switch (drawableObject)
{
case DrawableSliderTail tail:
// Use the same fade sequence as the slider head.
Debug.Assert(tail.Slider != null);
return getParameters(tail.Slider.HeadCircle);
case DrawableSliderRepeat repeat:
// Use the same fade sequence as the slider head.
Debug.Assert(repeat.Slider != null);
return getParameters(repeat.Slider.HeadCircle);
default:
return getParameters(drawableObject.HitObject);
}
static (double fadeStartTime, double fadeDuration) getParameters(OsuHitObject hitObject)
{
double fadeOutStartTime = hitObject.StartTime - hitObject.TimePreempt + hitObject.TimeFadeIn;
double fadeOutDuration = hitObject.TimePreempt * FADE_OUT_DURATION_MULTIPLIER;
// new duration from completed fade in to end (before fading out)
double longFadeDuration = hitObject.GetEndTime() - fadeOutStartTime;
switch (hitObject)
{
case Slider:
return (fadeOutStartTime, longFadeDuration);
case SliderTick:
double tickFadeOutDuration = Math.Min(hitObject.TimePreempt - DrawableSliderTick.ANIM_DURATION, 1000);
return (hitObject.StartTime - tickFadeOutDuration, tickFadeOutDuration);
case Spinner:
return (fadeOutStartTime + longFadeDuration, fadeOutDuration);
default:
return (fadeOutStartTime, fadeOutDuration);
}
}
}
private static void hideSpinnerApproachCircle(DrawableSpinner spinner)
{
var approachCircle = (spinner.Body.Drawable as IHasApproachCircle)?.ApproachCircle;
if (approachCircle == null)
return;
using (spinner.BeginAbsoluteSequence(spinner.HitObject.StartTime - spinner.HitObject.TimePreempt))
approachCircle.Hide();
}
}
}