diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk b/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk
new file mode 100644
index 000000000000..92215cbf8655
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk differ
diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
index c7eb334f255b..1b03f8ef6bea 100644
--- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
+++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
@@ -36,7 +36,9 @@ public class SkinDeserialisationTest
"Archives/modified-default-20220723.osk",
"Archives/modified-classic-20220723.osk",
// Covers legacy song progress, UR counter, colour hit error metre.
- "Archives/modified-classic-20220801.osk"
+ "Archives/modified-classic-20220801.osk",
+ // Covers clicks/s counter
+ "Archives/modified-default-20220818.osk"
};
///
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs
new file mode 100644
index 000000000000..2dad5e2c3241
--- /dev/null
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs
@@ -0,0 +1,130 @@
+// 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 NUnit.Framework;
+using osu.Framework.Audio;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Framework.Timing;
+using osu.Game.Screens.Play;
+using osu.Game.Screens.Play.HUD.ClicksPerSecond;
+using osuTK;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+ public class TestSceneClicksPerSecondCalculator : OsuTestScene
+ {
+ private ClicksPerSecondCalculator calculator = null!;
+
+ private TestGameplayClock manualGameplayClock = null!;
+
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddStep("create components", () =>
+ {
+ manualGameplayClock = new TestGameplayClock();
+
+ Child = new DependencyProvidingContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies = new (Type, object)[] { (typeof(IGameplayClock), manualGameplayClock) },
+ Children = new Drawable[]
+ {
+ calculator = new ClicksPerSecondCalculator(),
+ new DependencyProvidingContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies = new (Type, object)[] { (typeof(ClicksPerSecondCalculator), calculator) },
+ Child = new ClicksPerSecondCounter
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Scale = new Vector2(5),
+ }
+ }
+ },
+ };
+ });
+ }
+
+ [Test]
+ public void TestBasicConsistency()
+ {
+ seek(1000);
+ AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 }));
+ checkClicksPerSecondValue(10);
+ }
+
+ [Test]
+ public void TestRateAdjustConsistency()
+ {
+ seek(1000);
+ AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 }));
+ checkClicksPerSecondValue(10);
+ AddStep("set rate 0.5x", () => manualGameplayClock.TrueGameplayRate = 0.5);
+ checkClicksPerSecondValue(5);
+ }
+
+ [Test]
+ public void TestInputsDiscardedOnRewind()
+ {
+ seek(1000);
+ AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 }));
+ checkClicksPerSecondValue(10);
+ seek(500);
+ checkClicksPerSecondValue(6);
+ seek(1000);
+ checkClicksPerSecondValue(6);
+ }
+
+ private void checkClicksPerSecondValue(int i) => AddAssert("clicks/s is correct", () => calculator.Value, () => Is.EqualTo(i));
+
+ private void seekClockImmediately(double time) => manualGameplayClock.CurrentTime = time;
+
+ private void seek(double time) => AddStep($"Seek to {time}ms", () => seekClockImmediately(time));
+
+ private void addInputs(IEnumerable inputs)
+ {
+ double baseTime = manualGameplayClock.CurrentTime;
+
+ foreach (double timestamp in inputs)
+ {
+ seekClockImmediately(timestamp);
+ calculator.AddInputTimestamp();
+ }
+
+ seekClockImmediately(baseTime);
+ }
+
+ private class TestGameplayClock : IGameplayClock
+ {
+ public double CurrentTime { get; set; }
+
+ public double Rate => 1;
+
+ public bool IsRunning => true;
+
+ public double TrueGameplayRate { set => adjustableAudioComponent.Tempo.Value = value; }
+
+ private readonly AudioAdjustments adjustableAudioComponent = new AudioAdjustments();
+
+ public void ProcessFrame()
+ {
+ }
+
+ public double ElapsedFrameTime => throw new NotImplementedException();
+ public double FramesPerSecond => throw new NotImplementedException();
+ public FrameTimeInfo TimeInfo => throw new NotImplementedException();
+ public double StartTime => throw new NotImplementedException();
+
+ public IAdjustableAudioComponent AdjustmentsFromMods => adjustableAudioComponent;
+
+ public IEnumerable NonGameplayAdjustments => throw new NotImplementedException();
+ public IBindable IsPaused => throw new NotImplementedException();
+ }
+ }
+}
diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs
index 59c114699597..73acb1759fe7 100644
--- a/osu.Game/Rulesets/UI/DrawableRuleset.cs
+++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs
@@ -30,6 +30,7 @@
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
+using osu.Game.Screens.Play.HUD.ClicksPerSecond;
using osuTK;
namespace osu.Game.Rulesets.UI
@@ -38,7 +39,7 @@ namespace osu.Game.Rulesets.UI
/// Displays an interactive ruleset gameplay instance.
///
/// The type of HitObject contained by this DrawableRuleset.
- public abstract class DrawableRuleset : DrawableRuleset, IProvideCursor, ICanAttachKeyCounter
+ public abstract class DrawableRuleset : DrawableRuleset, IProvideCursor, ICanAttachHUDPieces
where TObject : HitObject
{
public override event Action NewResult;
@@ -338,7 +339,10 @@ public override void SetReplayScore(Score replayScore)
public abstract DrawableHitObject CreateDrawableRepresentation(TObject h);
public void Attach(KeyCounterDisplay keyCounter) =>
- (KeyBindingInputManager as ICanAttachKeyCounter)?.Attach(keyCounter);
+ (KeyBindingInputManager as ICanAttachHUDPieces)?.Attach(keyCounter);
+
+ public void Attach(ClicksPerSecondCalculator calculator) =>
+ (KeyBindingInputManager as ICanAttachHUDPieces)?.Attach(calculator);
///
/// Creates a key conversion input manager. An exception will be thrown if a valid is not returned.
diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
index 6f57cfe2f73f..3b35fba122cb 100644
--- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
+++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
@@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.UI
///
[Cached(typeof(IGameplayClock))]
[Cached(typeof(IFrameStableClock))]
- public sealed class FrameStabilityContainer : Container, IHasReplayHandler, IFrameStableClock, IGameplayClock
+ public sealed class FrameStabilityContainer : Container, IHasReplayHandler, IFrameStableClock
{
public ReplayInputHandler? ReplayInputHandler { get; set; }
diff --git a/osu.Game/Rulesets/UI/IFrameStableClock.cs b/osu.Game/Rulesets/UI/IFrameStableClock.cs
index 569ef5e06cc0..4e50d059e9e6 100644
--- a/osu.Game/Rulesets/UI/IFrameStableClock.cs
+++ b/osu.Game/Rulesets/UI/IFrameStableClock.cs
@@ -2,11 +2,11 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
-using osu.Framework.Timing;
+using osu.Game.Screens.Play;
namespace osu.Game.Rulesets.UI
{
- public interface IFrameStableClock : IFrameBasedClock
+ public interface IFrameStableClock : IGameplayClock
{
IBindable IsCatchingUp { get; }
diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs
index 7c37913576f6..1a97153f2f91 100644
--- a/osu.Game/Rulesets/UI/RulesetInputManager.cs
+++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs
@@ -20,11 +20,12 @@
using osu.Game.Input.Handlers;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play;
+using osu.Game.Screens.Play.HUD.ClicksPerSecond;
using static osu.Game.Input.Handlers.ReplayInputHandler;
namespace osu.Game.Rulesets.UI
{
- public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachKeyCounter, IHasReplayHandler, IHasRecordingHandler
+ public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachHUDPieces, IHasReplayHandler, IHasRecordingHandler
where T : struct
{
public readonly KeyBindingContainer KeyBindingContainer;
@@ -168,7 +169,7 @@ public void Attach(KeyCounterDisplay keyCounter)
.Select(action => new KeyCounterAction(action)));
}
- public class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler
+ private class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler
{
public ActionReceptor(KeyCounterDisplay target)
: base(target)
@@ -186,6 +187,37 @@ public void OnReleased(KeyBindingReleaseEvent e)
#endregion
+ #region Keys per second Counter Attachment
+
+ public void Attach(ClicksPerSecondCalculator calculator)
+ {
+ var listener = new ActionListener(calculator);
+
+ KeyBindingContainer.Add(listener);
+ }
+
+ private class ActionListener : Component, IKeyBindingHandler
+ {
+ private readonly ClicksPerSecondCalculator calculator;
+
+ public ActionListener(ClicksPerSecondCalculator calculator)
+ {
+ this.calculator = calculator;
+ }
+
+ public bool OnPressed(KeyBindingPressEvent e)
+ {
+ calculator.AddInputTimestamp();
+ return false;
+ }
+
+ public void OnReleased(KeyBindingReleaseEvent e)
+ {
+ }
+ }
+
+ #endregion
+
protected virtual KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique)
=> new RulesetKeyBindingContainer(ruleset, variant, unique);
@@ -221,12 +253,13 @@ public interface IHasRecordingHandler
}
///
- /// Supports attaching a .
+ /// Supports attaching various HUD pieces.
/// Keys will be populated automatically and a receptor will be injected inside.
///
- public interface ICanAttachKeyCounter
+ public interface ICanAttachHUDPieces
{
void Attach(KeyCounterDisplay keyCounter);
+ void Attach(ClicksPerSecondCalculator calculator);
}
public class RulesetInputManagerInputState : InputState
diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs
new file mode 100644
index 000000000000..04774b974f05
--- /dev/null
+++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs
@@ -0,0 +1,59 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Screens.Play.HUD.ClicksPerSecond
+{
+ public class ClicksPerSecondCalculator : Component
+ {
+ private readonly List timestamps = new List();
+
+ [Resolved]
+ private IGameplayClock gameplayClock { get; set; } = null!;
+
+ [Resolved(canBeNull: true)]
+ private DrawableRuleset? drawableRuleset { get; set; }
+
+ public int Value { get; private set; }
+
+ // Even though `FrameStabilityContainer` caches as a `GameplayClock`, we need to check it directly via `drawableRuleset`
+ // as this calculator is not contained within the `FrameStabilityContainer` and won't see the dependency.
+ private IGameplayClock clock => drawableRuleset?.FrameStableClock ?? gameplayClock;
+
+ public ClicksPerSecondCalculator()
+ {
+ RelativeSizeAxes = Axes.Both;
+ }
+
+ public void AddInputTimestamp() => timestamps.Add(clock.CurrentTime);
+
+ protected override void Update()
+ {
+ base.Update();
+
+ double latestValidTime = clock.CurrentTime;
+ double earliestTimeValid = latestValidTime - 1000 * gameplayClock.GetTrueGameplayRate();
+
+ int count = 0;
+
+ for (int i = timestamps.Count - 1; i >= 0; i--)
+ {
+ // handle rewinding by removing future timestamps as we go
+ if (timestamps[i] > latestValidTime)
+ {
+ timestamps.RemoveAt(i);
+ continue;
+ }
+
+ if (timestamps[i] >= earliestTimeValid)
+ count++;
+ }
+
+ Value = count;
+ }
+ }
+}
diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs
new file mode 100644
index 000000000000..243d8ed1e86e
--- /dev/null
+++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs
@@ -0,0 +1,102 @@
+// 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.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Localisation;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Skinning;
+using osuTK;
+
+namespace osu.Game.Screens.Play.HUD.ClicksPerSecond
+{
+ public class ClicksPerSecondCounter : RollingCounter, ISkinnableDrawable
+ {
+ [Resolved]
+ private ClicksPerSecondCalculator calculator { get; set; } = null!;
+
+ protected override double RollingDuration => 350;
+
+ public bool UsesFixedAnchor { get; set; }
+
+ public ClicksPerSecondCounter()
+ {
+ Current.Value = 0;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ Colour = colours.BlueLighter;
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ Current.Value = calculator.Value;
+ }
+
+ protected override IHasText CreateText() => new TextComponent();
+
+ private class TextComponent : CompositeDrawable, IHasText
+ {
+ public LocalisableString Text
+ {
+ get => text.Text;
+ set => text.Text = value;
+ }
+
+ private readonly OsuSpriteText text;
+
+ public TextComponent()
+ {
+ AutoSizeAxes = Axes.Both;
+
+ InternalChild = new FillFlowContainer
+ {
+ AutoSizeAxes = Axes.Both,
+ Spacing = new Vector2(2),
+ Children = new Drawable[]
+ {
+ text = new OsuSpriteText
+ {
+ Anchor = Anchor.BottomLeft,
+ Origin = Anchor.BottomLeft,
+ Font = OsuFont.Numeric.With(size: 16, fixedWidth: true)
+ },
+ new FillFlowContainer
+ {
+ Anchor = Anchor.BottomLeft,
+ Origin = Anchor.BottomLeft,
+ Direction = FillDirection.Vertical,
+ AutoSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Anchor = Anchor.TopLeft,
+ Origin = Anchor.TopLeft,
+ Font = OsuFont.Numeric.With(size: 6, fixedWidth: false),
+ Text = @"clicks",
+ },
+ new OsuSpriteText
+ {
+ Anchor = Anchor.TopLeft,
+ Origin = Anchor.TopLeft,
+ Font = OsuFont.Numeric.With(size: 6, fixedWidth: false),
+ Text = @"/sec",
+ Padding = new MarginPadding { Bottom = 3f }, // align baseline better
+ }
+ }
+ }
+ }
+ };
+ }
+ }
+ }
+}
diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs
index 8f80644d5250..f9f3693385f1 100644
--- a/osu.Game/Screens/Play/HUDOverlay.cs
+++ b/osu.Game/Screens/Play/HUDOverlay.cs
@@ -22,6 +22,7 @@
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play.HUD;
+using osu.Game.Screens.Play.HUD.ClicksPerSecond;
using osu.Game.Skinning;
using osuTK;
@@ -49,6 +50,9 @@ public class HUDOverlay : Container, IKeyBindingHandler
public readonly HoldForMenuButton HoldToQuit;
public readonly PlayerSettingsOverlay PlayerSettingsOverlay;
+ [Cached]
+ private readonly ClicksPerSecondCalculator clicksPerSecondCalculator;
+
public Bindable ShowHealthBar = new Bindable(true);
private readonly DrawableRuleset drawableRuleset;
@@ -122,7 +126,8 @@ public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods)
KeyCounter = CreateKeyCounter(),
HoldToQuit = CreateHoldForMenuButton(),
}
- }
+ },
+ clicksPerSecondCalculator = new ClicksPerSecondCalculator()
};
}
@@ -259,7 +264,11 @@ private void replayLoadedValueChanged(ValueChangedEvent e)
protected virtual void BindDrawableRuleset(DrawableRuleset drawableRuleset)
{
- (drawableRuleset as ICanAttachKeyCounter)?.Attach(KeyCounter);
+ if (drawableRuleset is ICanAttachHUDPieces attachTarget)
+ {
+ attachTarget.Attach(KeyCounter);
+ attachTarget.Attach(clicksPerSecondCalculator);
+ }
replayLoaded.BindTo(drawableRuleset.HasReplayLoaded);
}
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 1732c6533ed8..91e9c3b58fff 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -1049,6 +1049,7 @@ public override bool OnExiting(ScreenExitEvent e)
musicController.ResetTrackAdjustments();
fadeOut();
+
return base.OnExiting(e);
}