diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs new file mode 100644 index 000000000000..184938ceda8b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneOsuAnalysisContainer : OsuTestScene + { + private TestReplayAnalysisOverlay analysisContainer = null!; + private ReplayAnalysisSettings settings = null!; + + [Cached] + private OsuRulesetConfigManager config = new OsuRulesetConfigManager(null, new OsuRuleset().RulesetInfo); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create analysis container", () => + { + Children = new Drawable[] + { + new OsuPlayfieldAdjustmentContainer + { + Child = analysisContainer = new TestReplayAnalysisOverlay(fabricateReplay()), + }, + settings = new ReplayAnalysisSettings(config), + }; + + settings.ShowClickMarkers.Value = false; + settings.ShowAimMarkers.Value = false; + settings.ShowCursorPath.Value = false; + }); + } + + [Test] + public void TestEverythingOn() + { + AddStep("enable everything", () => + { + settings.ShowClickMarkers.Value = true; + settings.ShowAimMarkers.Value = true; + settings.ShowCursorPath.Value = true; + }); + } + + [Test] + public void TestHitMarkers() + { + AddStep("enable hit markers", () => settings.ShowClickMarkers.Value = true); + AddUntilStep("hit markers visible", () => analysisContainer.HitMarkersVisible); + AddStep("disable hit markers", () => settings.ShowClickMarkers.Value = false); + AddUntilStep("hit markers not visible", () => !analysisContainer.HitMarkersVisible); + } + + [Test] + public void TestAimMarker() + { + AddStep("enable aim markers", () => settings.ShowAimMarkers.Value = true); + AddUntilStep("aim markers visible", () => analysisContainer.AimMarkersVisible); + AddStep("disable aim markers", () => settings.ShowAimMarkers.Value = false); + AddUntilStep("aim markers not visible", () => !analysisContainer.AimMarkersVisible); + } + + [Test] + public void TestAimLines() + { + AddStep("enable aim lines", () => settings.ShowCursorPath.Value = true); + AddUntilStep("aim lines visible", () => analysisContainer.AimLinesVisible); + AddStep("disable aim lines", () => settings.ShowCursorPath.Value = false); + AddUntilStep("aim lines not visible", () => !analysisContainer.AimLinesVisible); + } + + private Replay fabricateReplay() + { + var frames = new List(); + var random = new Random(); + int posX = 250; + int posY = 250; + + var actions = new HashSet(); + + for (int i = 0; i < 1000; i++) + { + posX = Math.Clamp(posX + random.Next(-20, 21), -100, 600); + posY = Math.Clamp(posY + random.Next(-20, 21), -100, 600); + + if (random.NextDouble() > (actions.Count == 0 ? 0.9 : 0.95)) + { + actions.Add(random.NextDouble() > 0.5 ? OsuAction.LeftButton : OsuAction.RightButton); + } + else if (random.NextDouble() > 0.7) + { + actions.Remove(random.NextDouble() > 0.5 ? OsuAction.LeftButton : OsuAction.RightButton); + } + + frames.Add(new OsuReplayFrame + { + Time = Time.Current + i * 15, + Position = new Vector2(posX, posY), + Actions = actions.ToList(), + }); + } + + return new Replay { Frames = frames }; + } + + private partial class TestReplayAnalysisOverlay : ReplayAnalysisOverlay + { + public TestReplayAnalysisOverlay(Replay replay) + : base(replay) + { + } + + public bool HitMarkersVisible => ClickMarkers?.Alpha > 0 && ClickMarkers.Entries.Any(); + public bool AimMarkersVisible => FrameMarkers?.Alpha > 0 && FrameMarkers.Entries.Any(); + public bool AimLinesVisible => CursorPath?.Alpha > 0 && CursorPath.Vertices.Count > 1; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs index 2056a50edab2..8a8b78b64509 100644 --- a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Configuration; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.UI; @@ -11,7 +9,7 @@ namespace osu.Game.Rulesets.Osu.Configuration { public class OsuRulesetConfigManager : RulesetConfigManager { - public OsuRulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null) + public OsuRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null) : base(settings, ruleset, variant) { } @@ -24,6 +22,12 @@ protected override void InitialiseDefaults() SetDefault(OsuRulesetSetting.ShowCursorTrail, true); SetDefault(OsuRulesetSetting.ShowCursorRipples, false); SetDefault(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None); + + SetDefault(OsuRulesetSetting.ReplayClickMarkersEnabled, false); + SetDefault(OsuRulesetSetting.ReplayFrameMarkersEnabled, false); + SetDefault(OsuRulesetSetting.ReplayCursorPathEnabled, false); + SetDefault(OsuRulesetSetting.ReplayCursorHideEnabled, false); + SetDefault(OsuRulesetSetting.ReplayAnalysisDisplayLength, 750); } } @@ -34,5 +38,12 @@ public enum OsuRulesetSetting ShowCursorTrail, ShowCursorRipples, PlayfieldBorderStyle, + + // Replay + ReplayClickMarkersEnabled, + ReplayFrameMarkersEnabled, + ReplayCursorPathEnabled, + ReplayCursorHideEnabled, + ReplayAnalysisDisplayLength, } } diff --git a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs index f0390ad716d5..16edc654a7dd 100644 --- a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs @@ -1,11 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Input; using osu.Game.Beatmaps; using osu.Game.Input.Handlers; @@ -25,18 +26,36 @@ namespace osu.Game.Rulesets.Osu.UI { public partial class DrawableOsuRuleset : DrawableRuleset { - protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config; + private Bindable? cursorHideEnabled; public new OsuInputManager KeyBindingInputManager => (OsuInputManager)base.KeyBindingInputManager; public new OsuPlayfield Playfield => (OsuPlayfield)base.Playfield; - public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config; + + public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) : base(ruleset, beatmap, mods) { } - public override DrawableHitObject CreateDrawableRepresentation(OsuHitObject h) => null; + [BackgroundDependencyLoader] + private void load(ReplayPlayer? replayPlayer) + { + if (replayPlayer != null) + { + PlayfieldAdjustmentContainer.Add(new ReplayAnalysisOverlay(replayPlayer.Score.Replay)); + replayPlayer.AddSettings(new ReplayAnalysisSettings(Config)); + + cursorHideEnabled = Config.GetBindable(OsuRulesetSetting.ReplayCursorHideEnabled); + + // I have little faith in this working (other things touch cursor visibility) but haven't broken it yet. + // Let's wait for someone to report an issue before spending too much time on it. + cursorHideEnabled.BindValueChanged(enabled => Playfield.Cursor.FadeTo(enabled.NewValue ? 0 : 1), true); + } + } + + public override DrawableHitObject? CreateDrawableRepresentation(OsuHitObject h) => null; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // always show the gameplay cursor diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisFrameEntry.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisFrameEntry.cs new file mode 100644 index 000000000000..116bccc74705 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisFrameEntry.cs @@ -0,0 +1,23 @@ +// 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.Graphics.Performance; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public partial class AnalysisFrameEntry : LifetimeEntry + { + public OsuAction[] Action { get; } + + public Vector2 Position { get; } + + public AnalysisFrameEntry(double time, double displayLength, Vector2 position, params OsuAction[] action) + { + LifetimeStart = time; + LifetimeEnd = time + displayLength; + Position = position; + Action = action; + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisMarker.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisMarker.cs new file mode 100644 index 000000000000..9b602c88a854 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisMarker.cs @@ -0,0 +1,27 @@ +// 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.Game.Graphics; +using osu.Game.Rulesets.Objects.Pooling; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public abstract partial class AnalysisMarker : PoolableDrawableWithLifetime + { + [Resolved] + protected OsuColour Colours { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Origin = Anchor.Centre; + } + + protected override void OnApply(AnalysisFrameEntry entry) + { + Position = entry.Position; + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarker.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarker.cs new file mode 100644 index 000000000000..9788ea1aa9bc --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarker.cs @@ -0,0 +1,88 @@ +// 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.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + /// + /// A marker which shows one click, with visuals focusing on the button which was clicked and the precise location of the click. + /// + public partial class ClickMarker : AnalysisMarker + { + private CircularProgress leftClickDisplay = null!; + private CircularProgress rightClickDisplay = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.125f), + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Colour = Colours.Gray5, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Colours.Gray5, + Masking = true, + BorderThickness = 2.2f, + BorderColour = Color4.White, + Child = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0, + }, + }, + leftClickDisplay = new CircularProgress + { + Colour = Colours.Yellow, + Size = new Vector2(0.95f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Rotation = 180, + Progress = 0.5f, + InnerRadius = 0.18f, + RelativeSizeAxes = Axes.Both, + }, + rightClickDisplay = new CircularProgress + { + Colour = Colours.Yellow, + Size = new Vector2(0.95f), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Progress = 0.5f, + InnerRadius = 0.18f, + RelativeSizeAxes = Axes.Both, + }, + }; + + Size = new Vector2(16); + } + + protected override void OnApply(AnalysisFrameEntry entry) + { + base.OnApply(entry); + + leftClickDisplay.Alpha = entry.Action.Contains(OsuAction.LeftButton) ? 1 : 0; + rightClickDisplay.Alpha = entry.Action.Contains(OsuAction.RightButton) ? 1 : 0; + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarkerContainer.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarkerContainer.cs new file mode 100644 index 000000000000..ff9444952131 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarkerContainer.cs @@ -0,0 +1,20 @@ +// 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.Graphics.Pooling; +using osu.Game.Rulesets.Objects.Pooling; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public partial class ClickMarkerContainer : PooledDrawableWithLifetimeContainer + { + private readonly DrawablePool clickMarkerPool; + + public ClickMarkerContainer() + { + AddInternal(clickMarkerPool = new DrawablePool(30)); + } + + protected override AnalysisMarker GetDrawable(AnalysisFrameEntry entry) => clickMarkerPool.Get(d => d.Apply(entry)); + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs new file mode 100644 index 000000000000..1951d467e230 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.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 System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Performance; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public partial class CursorPathContainer : Path + { + private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); + private readonly SortedSet aliveEntries = new SortedSet(new AimLinePointComparator()); + + public CursorPathContainer() + { + lifetimeManager.EntryBecameAlive += entryBecameAlive; + lifetimeManager.EntryBecameDead += entryBecameDead; + + PathRadius = 0.5f; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.Pink2; + BackgroundColour = colours.Pink2.Opacity(0); + } + + protected override void Update() + { + base.Update(); + + lifetimeManager.Update(Time.Current); + } + + public void Add(AnalysisFrameEntry entry) => lifetimeManager.AddEntry(entry); + + private void entryBecameAlive(LifetimeEntry entry) + { + aliveEntries.Add((AnalysisFrameEntry)entry); + updateVertices(); + } + + private void entryBecameDead(LifetimeEntry entry) + { + aliveEntries.Remove((AnalysisFrameEntry)entry); + updateVertices(); + } + + private void updateVertices() + { + ClearVertices(); + + Vector2 min = Vector2.Zero; + + foreach (var entry in aliveEntries) + { + AddVertex(entry.Position); + if (entry.Position.X < min.X) + min.X = entry.Position.X; + + if (entry.Position.Y < min.Y) + min.Y = entry.Position.Y; + } + + Position = min; + } + + private sealed class AimLinePointComparator : IComparer + { + public int Compare(AnalysisFrameEntry? x, AnalysisFrameEntry? y) + { + ArgumentNullException.ThrowIfNull(x); + ArgumentNullException.ThrowIfNull(y); + + return x.LifetimeStart.CompareTo(y.LifetimeStart); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarker.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarker.cs new file mode 100644 index 000000000000..35ee1445683a --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarker.cs @@ -0,0 +1,69 @@ +// 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.Shapes; +using osu.Framework.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + /// + /// A marker which shows one movement frame, include any buttons which are pressed. + /// + public partial class FrameMarker : AnalysisMarker + { + private CircularProgress leftClickDisplay = null!; + private CircularProgress rightClickDisplay = null!; + private Circle mainCircle = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + mainCircle = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Colours.Pink2, + }, + leftClickDisplay = new CircularProgress + { + Colour = Colours.Yellow, + Size = new Vector2(0.8f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Rotation = 180, + Progress = 0.5f, + InnerRadius = 0.5f, + RelativeSizeAxes = Axes.Both, + }, + rightClickDisplay = new CircularProgress + { + Colour = Colours.Yellow, + Size = new Vector2(0.8f), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Progress = 0.5f, + InnerRadius = 0.5f, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override void OnApply(AnalysisFrameEntry entry) + { + base.OnApply(entry); + Size = new Vector2(entry.Action.Any() ? 4 : 2.5f); + + mainCircle.Colour = entry.Action.Any() ? Colours.Gray4 : Colours.Pink2; + + leftClickDisplay.Alpha = entry.Action.Contains(OsuAction.LeftButton) ? 1 : 0; + rightClickDisplay.Alpha = entry.Action.Contains(OsuAction.RightButton) ? 1 : 0; + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarkerContainer.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarkerContainer.cs new file mode 100644 index 000000000000..63aea259f7cf --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarkerContainer.cs @@ -0,0 +1,20 @@ +// 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.Graphics.Pooling; +using osu.Game.Rulesets.Objects.Pooling; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public partial class FrameMarkerContainer : PooledDrawableWithLifetimeContainer + { + private readonly DrawablePool pool; + + public FrameMarkerContainer() + { + AddInternal(pool = new DrawablePool(80)); + } + + protected override AnalysisMarker GetDrawable(AnalysisFrameEntry entry) => pool.Get(d => d.Apply(entry)); + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysisOverlay.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisOverlay.cs new file mode 100644 index 000000000000..2b7f6c9fc96d --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisOverlay.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI.ReplayAnalysis; + +namespace osu.Game.Rulesets.Osu.UI +{ + public partial class ReplayAnalysisOverlay : CompositeDrawable + { + private BindableBool showClickMarkers { get; } = new BindableBool(); + private BindableBool showFrameMarkers { get; } = new BindableBool(); + private BindableBool showCursorPath { get; } = new BindableBool(); + private BindableInt displayLength { get; } = new BindableInt(); + + protected ClickMarkerContainer? ClickMarkers; + protected FrameMarkerContainer? FrameMarkers; + protected CursorPathContainer? CursorPath; + + private readonly Replay replay; + + public ReplayAnalysisOverlay(Replay replay) + { + RelativeSizeAxes = Axes.Both; + + this.replay = replay; + } + + private bool requireDisplay => showClickMarkers.Value || showFrameMarkers.Value || showCursorPath.Value; + + [BackgroundDependencyLoader] + private void load(OsuRulesetConfigManager config) + { + config.BindWith(OsuRulesetSetting.ReplayClickMarkersEnabled, showClickMarkers); + config.BindWith(OsuRulesetSetting.ReplayFrameMarkersEnabled, showFrameMarkers); + config.BindWith(OsuRulesetSetting.ReplayCursorPathEnabled, showCursorPath); + config.BindWith(OsuRulesetSetting.ReplayAnalysisDisplayLength, displayLength); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + displayLength.BindValueChanged(_ => + { + // Need to fully reload to make this work. + loaded.Invalidate(); + }, true); + } + + private readonly Cached loaded = new Cached(); + + private CancellationTokenSource? generationCancellationSource; + + protected override void Update() + { + base.Update(); + + if (requireDisplay) + initialise(); + + if (ClickMarkers != null) ClickMarkers.Alpha = showClickMarkers.Value ? 1 : 0; + if (FrameMarkers != null) FrameMarkers.Alpha = showFrameMarkers.Value ? 1 : 0; + if (CursorPath != null) CursorPath.Alpha = showCursorPath.Value ? 1 : 0; + } + + private void initialise() + { + if (loaded.IsValid) + return; + + loaded.Validate(); + + generationCancellationSource?.Cancel(); + generationCancellationSource = new CancellationTokenSource(); + + // It's faster to reinitialise the whole drawable stack than use `Clear` on `PooledDrawableWithLifetimeContainer` + var newDrawables = new Drawable[] + { + CursorPath = new CursorPathContainer(), + ClickMarkers = new ClickMarkerContainer(), + FrameMarkers = new FrameMarkerContainer(), + }; + + bool leftHeld = false; + bool rightHeld = false; + + // This should probably be async as well, but it's a bit of a pain to debounce and everything. + // Let's address concerns when they are raised. + foreach (var frame in replay.Frames) + { + var osuFrame = (OsuReplayFrame)frame; + + bool leftButton = osuFrame.Actions.Contains(OsuAction.LeftButton); + bool rightButton = osuFrame.Actions.Contains(OsuAction.RightButton); + + if (leftHeld && !leftButton) + leftHeld = false; + else if (!leftHeld && leftButton) + { + leftHeld = true; + ClickMarkers.Add(new AnalysisFrameEntry(osuFrame.Time, displayLength.Value, osuFrame.Position, OsuAction.LeftButton)); + } + + if (rightHeld && !rightButton) + rightHeld = false; + else if (!rightHeld && rightButton) + { + rightHeld = true; + ClickMarkers.Add(new AnalysisFrameEntry(osuFrame.Time, displayLength.Value, osuFrame.Position, OsuAction.RightButton)); + } + + FrameMarkers.Add(new AnalysisFrameEntry(osuFrame.Time, displayLength.Value, osuFrame.Position, osuFrame.Actions.ToArray())); + CursorPath.Add(new AnalysisFrameEntry(osuFrame.Time, displayLength.Value, osuFrame.Position)); + } + + LoadComponentsAsync(newDrawables, drawables => InternalChildrenEnumerable = drawables, generationCancellationSource.Token); + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs new file mode 100644 index 000000000000..dc4730d76ab5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs @@ -0,0 +1,55 @@ +// 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.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Screens.Play.PlayerSettings; + +namespace osu.Game.Rulesets.Osu.UI +{ + public partial class ReplayAnalysisSettings : PlayerSettingsGroup + { + private readonly OsuRulesetConfigManager config; + + [SettingSource("Show click markers", SettingControlType = typeof(PlayerCheckbox))] + public BindableBool ShowClickMarkers { get; } = new BindableBool(); + + [SettingSource("Show frame markers", SettingControlType = typeof(PlayerCheckbox))] + public BindableBool ShowAimMarkers { get; } = new BindableBool(); + + [SettingSource("Show cursor path", SettingControlType = typeof(PlayerCheckbox))] + public BindableBool ShowCursorPath { get; } = new BindableBool(); + + [SettingSource("Hide gameplay cursor", SettingControlType = typeof(PlayerCheckbox))] + public BindableBool HideSkinCursor { get; } = new BindableBool(); + + [SettingSource("Display length", SettingControlType = typeof(PlayerSliderBar))] + public BindableInt DisplayLength { get; } = new BindableInt + { + MinValue = 200, + MaxValue = 2000, + Default = 800, + Precision = 200, + }; + + public ReplayAnalysisSettings(OsuRulesetConfigManager config) + : base("Analysis Settings") + { + this.config = config; + } + + [BackgroundDependencyLoader] + private void load() + { + AddRange(this.CreateSettingsControls()); + + config.BindWith(OsuRulesetSetting.ReplayClickMarkersEnabled, ShowClickMarkers); + config.BindWith(OsuRulesetSetting.ReplayFrameMarkersEnabled, ShowAimMarkers); + config.BindWith(OsuRulesetSetting.ReplayCursorPathEnabled, ShowCursorPath); + config.BindWith(OsuRulesetSetting.ReplayCursorHideEnabled, HideSkinCursor); + config.BindWith(OsuRulesetSetting.ReplayAnalysisDisplayLength, DisplayLength); + } + } +} diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index ff60dbc0d0d1..0c125264a1a1 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -55,6 +55,16 @@ public ReplayPlayer(Func, Score> createScore, Playe this.createScore = createScore; } + /// + /// Add a settings group to the HUD overlay. Intended to be used by rulesets to add replay-specific settings. + /// + /// The settings group to be shown. + public void AddSettings(PlayerSettingsGroup settings) => Schedule(() => + { + settings.Expanded.Value = false; + HUDOverlay.PlayerSettingsOverlay.Add(settings); + }); + [BackgroundDependencyLoader] private void load(OsuConfigManager config) {