diff --git a/osu.Game.Rulesets.Soyokaze/Judgements/SoyokazeHoldJudgementResult.cs b/osu.Game.Rulesets.Soyokaze/Judgements/SoyokazeHoldJudgementResult.cs new file mode 100644 index 0000000..235f652 --- /dev/null +++ b/osu.Game.Rulesets.Soyokaze/Judgements/SoyokazeHoldJudgementResult.cs @@ -0,0 +1,18 @@ +// Copyright (c) Alden Wu . Licensed under the MIT Licence. +// See the LICENSE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Soyokaze.Objects; + +namespace osu.Game.Rulesets.Soyokaze.Judgements +{ + public class SoyokazeHoldJudgementResult : JudgementResult + { + public double TrueTimeOffset { get; set; } + + public SoyokazeHoldJudgementResult(Hold hitObject, Judgement judgement) + : base(hitObject, judgement) + { + } + } +} diff --git a/osu.Game.Rulesets.Soyokaze/Objects/Drawables/DrawableHold.cs b/osu.Game.Rulesets.Soyokaze/Objects/Drawables/DrawableHold.cs index ee4870e..4df7e3a 100644 --- a/osu.Game.Rulesets.Soyokaze/Objects/Drawables/DrawableHold.cs +++ b/osu.Game.Rulesets.Soyokaze/Objects/Drawables/DrawableHold.cs @@ -1,7 +1,9 @@ // Copyright (c) Alden Wu . Licensed under the MIT Licence. // See the LICENSE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,6 +13,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Soyokaze.Judgements; using osu.Game.Rulesets.Soyokaze.Skinning; using osu.Game.Rulesets.Soyokaze.UI; using osu.Game.Skinning; @@ -169,6 +172,8 @@ protected override void UpdateHitStateTransforms(ArmedState state) } } + protected override JudgementResult CreateResult(Judgement judgement) => new SoyokazeHoldJudgementResult(HitObject, judgement); + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (userTriggered || Time.Current < HitObject.EndTime) @@ -207,7 +212,14 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) else result = HitResult.Miss; - ApplyResult(r => r.Type = result); + ApplyResult(r => + { + if (!(r is SoyokazeHoldJudgementResult hr)) + throw new InvalidOperationException($"Expected result of type {nameof(SoyokazeHoldJudgementResult)}"); + + hr.Type = result; + hr.TrueTimeOffset = HoldCircle.TrueTimeOffset; + }); } public override bool Hit(SoyokazeAction action) diff --git a/osu.Game.Rulesets.Soyokaze/Objects/Drawables/DrawableHoldCircle.cs b/osu.Game.Rulesets.Soyokaze/Objects/Drawables/DrawableHoldCircle.cs index d5c3ee8..1f5d7af 100644 --- a/osu.Game.Rulesets.Soyokaze/Objects/Drawables/DrawableHoldCircle.cs +++ b/osu.Game.Rulesets.Soyokaze/Objects/Drawables/DrawableHoldCircle.cs @@ -14,6 +14,7 @@ public partial class DrawableHoldCircle : DrawableHitCircle public new HoldCircle HitObject => (HoldCircle)base.HitObject; public override bool DisplayResult => false; public JudgementResult TrueResult { get; private set; } + public double TrueTimeOffset { get; private set; } protected DrawableHold Hold => (DrawableHold)ParentHitObject; @@ -43,6 +44,7 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) if (!HitObject.HitWindows.CanBeHit(timeOffset)) { TrueResult = new JudgementResult(HitObject, new SoyokazeJudgement()) { Type = HitResult.Miss }; + TrueTimeOffset = timeOffset; ApplyResult(r => r.Type = HitResult.IgnoreMiss); } return; @@ -58,6 +60,7 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) } TrueResult = new JudgementResult(HitObject, new SoyokazeJudgement()) { Type = result }; + TrueTimeOffset = timeOffset; ApplyResult(r => r.Type = HitResult.IgnoreHit); } diff --git a/osu.Game.Rulesets.Soyokaze/Scoring/SoyokazeScoreProcessor.cs b/osu.Game.Rulesets.Soyokaze/Scoring/SoyokazeScoreProcessor.cs new file mode 100644 index 0000000..fda07be --- /dev/null +++ b/osu.Game.Rulesets.Soyokaze/Scoring/SoyokazeScoreProcessor.cs @@ -0,0 +1,27 @@ +// Copyright (c) Alden Wu . Licensed under the MIT Licence. +// See the LICENSE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Soyokaze.Judgements; + +namespace osu.Game.Rulesets.Soyokaze.Scoring +{ + public partial class SoyokazeScoreProcessor : ScoreProcessor + { + public SoyokazeScoreProcessor(Ruleset ruleset) + : base(ruleset) + { + } + + protected override HitEvent CreateHitEvent(JudgementResult result) + { + var hitEvent = base.CreateHitEvent(result); + + if (result is SoyokazeHoldJudgementResult hr) + hitEvent = new HitEvent(hr.TrueTimeOffset, hitEvent.Result, hitEvent.HitObject, hitEvent.LastHitObject, hitEvent.Position); + + return hitEvent; + } + } +} diff --git a/osu.Game.Rulesets.Soyokaze/SoyokazeRuleset.cs b/osu.Game.Rulesets.Soyokaze/SoyokazeRuleset.cs index f8cf97d..7aa07d4 100644 --- a/osu.Game.Rulesets.Soyokaze/SoyokazeRuleset.cs +++ b/osu.Game.Rulesets.Soyokaze/SoyokazeRuleset.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -25,6 +26,7 @@ using osu.Game.Rulesets.Soyokaze.Mods; using osu.Game.Rulesets.Soyokaze.Objects; using osu.Game.Rulesets.Soyokaze.Replays; +using osu.Game.Rulesets.Soyokaze.Scoring; using osu.Game.Rulesets.Soyokaze.Skinning.Legacy; using osu.Game.Rulesets.Soyokaze.Statistics; using osu.Game.Rulesets.Soyokaze.UI; @@ -51,6 +53,8 @@ public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new SoyokazeConfigManager(settings, RulesetInfo); + public override ScoreProcessor CreateScoreProcessor() => new SoyokazeScoreProcessor(this); + public override RulesetSettingsSubsection CreateSettings() => new SoyokazeSettingsSubsection(this); public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => @@ -84,6 +88,8 @@ public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatma if (!(hitEvent.HitObject is SoyokazeHitObject soyokazeObject)) continue; + Logger.Log("OBJECT: " + hitEvent.HitObject.GetType()); + HitEventsLists[(int)soyokazeObject.Button].Add(hitEvent); } @@ -101,7 +107,7 @@ public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatma Origin = Anchor.Centre, AutoSizeAxes = Axes.Both, }; - Vector2[] positions = PositionExtensions.GetPositions(250, 130, true, Anchor.Centre); + Vector2[] positions = PositionExtensions.GetPositions(220, 110, true, Anchor.Centre); for (int i = 0; i < positions.Length; i++) accuracyGraphsContainer.Add(new AccuracyGraph(HitEventsLists[i]) { Position = positions[i] }); return accuracyGraphsContainer; diff --git a/osu.Game.Rulesets.Soyokaze/Statistics/AccuracyGraph.cs b/osu.Game.Rulesets.Soyokaze/Statistics/AccuracyGraph.cs index 541f2d0..7de8981 100644 --- a/osu.Game.Rulesets.Soyokaze/Statistics/AccuracyGraph.cs +++ b/osu.Game.Rulesets.Soyokaze/Statistics/AccuracyGraph.cs @@ -29,14 +29,11 @@ public AccuracyGraph(List hitEvents) { Text = (calculateAccuracy(hitEvents) * 100).ToString("0.00") + "%", Colour = Color4Extensions.FromHex("#66FFCC"), - Font = OsuFont.Torus.With(size: 56), - MaxWidth = 170f, + Font = OsuFont.Torus.With(size: 42), AllowMultiline = false, Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.8f, 0.4f), }, new UnstableRate(hitEvents) { @@ -47,7 +44,7 @@ public AccuracyGraph(List hitEvents) RelativeSizeAxes = Axes.Both, Size = new Vector2(0.8f, 0.13f), }, - new HitEventTimingDistributionGraph(hitEvents) + new MiniHitEventTimingDistributionGraph(hitEvents) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -63,8 +60,6 @@ public AccuracyGraph(List hitEvents) Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.3f, 0.07f), }, }); } diff --git a/osu.Game.Rulesets.Soyokaze/Statistics/MiniHitEventTimingDistributionGraph.cs b/osu.Game.Rulesets.Soyokaze/Statistics/MiniHitEventTimingDistributionGraph.cs new file mode 100644 index 0000000..017b62f --- /dev/null +++ b/osu.Game.Rulesets.Soyokaze/Statistics/MiniHitEventTimingDistributionGraph.cs @@ -0,0 +1,379 @@ +// Copyright (c) Alden Wu . Licensed under the MIT Licence. +// See the LICENSE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; +using osuTK.Graphics; +using osu.Game.Screens.Ranking.Statistics; + +namespace osu.Game.Rulesets.Soyokaze.Statistics +{ + /// + /// THIS CODE IS COPIED FROM . + /// THE ONLY DIFFERENCE IS THE NUMBER OF BINS. + /// TODO: PR a non-const timing_distribution_bins lmfao but shit man i am lazy + /// + public partial class MiniHitEventTimingDistributionGraph : CompositeDrawable + { + /// + /// The number of bins on each side of the timing distribution. + /// + private const int timing_distribution_bins = 18; + + /// + /// The total number of bins in the timing distribution, including bins on both sides and the centre bin at 0. + /// + private const int total_timing_distribution_bins = timing_distribution_bins * 2 + 1; + + /// + /// The centre bin, with a timing distribution very close to/at 0. + /// + private const int timing_distribution_centre_bin_index = timing_distribution_bins; + + /// + /// The number of data points shown on each side of the axis below the graph. + /// + private const float axis_points = 5; + + /// + /// The currently displayed hit events. + /// + private readonly IReadOnlyList hitEvents; + + private readonly IDictionary[] bins; + private double binSize; + private double hitOffset; + + private Bar[]? barDrawables; + + /// + /// Creates a new . + /// + /// The s to display the timing distribution of. + public MiniHitEventTimingDistributionGraph(IReadOnlyList hitEvents) + { + this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList(); + bins = Enumerable.Range(0, total_timing_distribution_bins).Select(_ => new Dictionary()).ToArray>(); + } + + [BackgroundDependencyLoader] + private void load() + { + if (hitEvents.Count == 0) + return; + + binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins); + + // Prevent div-by-0 by enforcing a minimum bin size + binSize = Math.Max(1, binSize); + + Scheduler.AddOnce(updateDisplay); + } + + public void UpdateOffset(double hitOffset) + { + this.hitOffset = hitOffset; + Scheduler.AddOnce(updateDisplay); + } + + private void updateDisplay() + { + bool roundUp = true; + + foreach (var bin in bins) + bin.Clear(); + + foreach (var e in hitEvents) + { + double time = e.TimeOffset + hitOffset; + + double binOffset = time / binSize; + + // .NET's round midpoint handling doesn't provide a behaviour that works amazingly for display + // purposes here. We want midpoint rounding to roughly distribute evenly to each adjacent bucket + // so the easiest way is to cycle between downwards and upwards rounding as we process events. + if (Math.Abs(binOffset - (int)binOffset) == 0.5) + { + binOffset = (int)binOffset + Math.Sign(binOffset) * (roundUp ? 1 : 0); + roundUp = !roundUp; + } + + int index = timing_distribution_centre_bin_index + (int)Math.Round(binOffset, MidpointRounding.AwayFromZero); + + // may be out of range when applying an offset. for such cases we can just drop the results. + if (index >= 0 && index < bins.Length) + { + bins[index].TryGetValue(e.Result, out int value); + bins[index][e.Result] = ++value; + } + } + + if (barDrawables != null) + { + for (int i = 0; i < barDrawables.Length; i++) + { + barDrawables[i].UpdateOffset(bins[i].Sum(b => b.Value)); + } + } + else + { + int maxCount = bins.Max(b => b.Values.Sum()); + barDrawables = bins.Select((bin, i) => new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index)).ToArray(); + + Container axisFlow; + + const float axis_font_size = 12; + + InternalChild = new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.8f, + Content = new[] + { + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] { barDrawables } + } + }, + new Drawable[] + { + axisFlow = new Container + { + RelativeSizeAxes = Axes.X, + Height = axis_font_size, + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + } + }; + + // Our axis will contain one centre element + 5 points on each side, each with a value depending on the number of bins * bin size. + double maxValue = timing_distribution_bins * binSize; + double axisValueStep = maxValue / axis_points; + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "0", + Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold) + }); + + for (int i = 1; i <= axis_points; i++) + { + double axisValue = i * axisValueStep; + float position = (float)(axisValue / maxValue); + float alpha = 1f - position * 0.8f; + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = -position / 2, + Alpha = alpha, + Text = axisValue.ToString("-0"), + Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold) + }); + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = position / 2, + Alpha = alpha, + Text = axisValue.ToString("+0"), + Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold) + }); + } + } + } + + private partial class Bar : CompositeDrawable + { + private readonly IReadOnlyList> values; + private readonly float maxValue; + private readonly bool isCentre; + private readonly float totalValue; + + private float basalHeight; + private float offsetAdjustment; + + private Circle[] boxOriginals = null!; + + private Circle? boxAdjustment; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private const double duration = 300; + + public Bar(IDictionary values, float maxValue, bool isCentre) + { + this.values = values.OrderBy(v => v.Key.GetIndexForOrderedDisplay()).ToList(); + this.maxValue = maxValue; + this.isCentre = isCentre; + totalValue = values.Sum(v => v.Value); + offsetAdjustment = totalValue; + + RelativeSizeAxes = Axes.Both; + Masking = true; + } + + [BackgroundDependencyLoader] + private void load() + { + if (values.Any()) + { + boxOriginals = values.Select((v, i) => new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = isCentre && i == 0 ? Color4.White : colours.ForHitResult(v.Key), + Height = 0, + }).ToArray(); + // The bars of the stacked bar graph will be processed (stacked) from the bottom, which is the base position, + // to the top, and the bottom bar should be drawn more toward the front by design, + // while the drawing order is from the back to the front, so the order passed to `InternalChildren` is the opposite. + InternalChildren = boxOriginals.Reverse().ToArray(); + } + else + { + // A bin with no value draws a grey dot instead. + Circle dot = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = isCentre ? Color4.White : Color4.Gray, + Height = 0, + }; + InternalChildren = boxOriginals = new[] { dot }; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (!values.Any()) + return; + + updateBasalHeight(); + + foreach (var boxOriginal in boxOriginals) + { + boxOriginal.Y = 0; + boxOriginal.Height = basalHeight; + } + + float offsetValue = 0; + + for (int i = 0; i < values.Count; i++) + { + boxOriginals[i].MoveToY(offsetForValue(offsetValue) * BoundingBox.Height, duration, Easing.OutQuint); + boxOriginals[i].ResizeHeightTo(heightForValue(values[i].Value), duration, Easing.OutQuint); + offsetValue -= values[i].Value; + } + } + + protected override void Update() + { + base.Update(); + updateBasalHeight(); + } + + public void UpdateOffset(float adjustment) + { + bool hasAdjustment = adjustment != totalValue; + + if (boxAdjustment == null) + { + if (!hasAdjustment) + return; + + AddInternal(boxAdjustment = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = Color4.Yellow, + Blending = BlendingParameters.Additive, + Alpha = 0.6f, + Height = 0, + }); + } + + offsetAdjustment = adjustment; + drawAdjustmentBar(); + } + + private void updateBasalHeight() + { + float newBasalHeight = DrawHeight > DrawWidth ? DrawWidth / DrawHeight : 1; + + if (newBasalHeight == basalHeight) + return; + + basalHeight = newBasalHeight; + foreach (var dot in boxOriginals) + dot.Height = basalHeight; + + draw(); + } + + private float offsetForValue(float value) => (1 - basalHeight) * value / maxValue; + + private float heightForValue(float value) => MathF.Max(basalHeight + offsetForValue(value), 0); + + private void draw() + { + resizeBars(); + + if (boxAdjustment != null) + drawAdjustmentBar(); + } + + private void resizeBars() + { + float offsetValue = 0; + + for (int i = 0; i < values.Count; i++) + { + boxOriginals[i].Y = offsetForValue(offsetValue) * DrawHeight; + boxOriginals[i].Height = heightForValue(values[i].Value); + offsetValue -= values[i].Value; + } + } + + private void drawAdjustmentBar() + { + bool hasAdjustment = offsetAdjustment != totalValue; + + boxAdjustment.ResizeHeightTo(heightForValue(offsetAdjustment), duration, Easing.OutQuint); + boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint); + } + } + } +}