1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-14 03:25:11 +08:00

Merge branch 'master' into mania-conversion-precision

This commit is contained in:
Bartłomiej Dach 2020-06-30 20:16:47 +02:00
commit b37eb63579
40 changed files with 1925 additions and 95 deletions

View File

@ -30,6 +30,7 @@ using osu.Game.Rulesets.Mania.Skinning;
using osu.Game.Rulesets.Scoring;
using osu.Game.Skinning;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics;
namespace osu.Game.Rulesets.Mania
{
@ -309,6 +310,21 @@ namespace osu.Game.Rulesets.Mania
{
return (PlayfieldType)Enum.GetValues(typeof(PlayfieldType)).Cast<int>().OrderByDescending(i => i).First(v => variant >= v);
}
public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
{
new StatisticRow
{
Columns = new[]
{
new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents)
{
RelativeSizeAxes = Axes.X,
Height = 250
}),
}
}
};
}
public enum PlayfieldType

View File

@ -0,0 +1,132 @@
// 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 NUnit.Framework;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Rulesets.Osu.Statistics;
using osu.Game.Scoring;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Visual;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Tests
{
public class TestSceneAccuracyHeatmap : OsuManualInputManagerTestScene
{
private Box background;
private Drawable object1;
private Drawable object2;
private TestAccuracyHeatmap accuracyHeatmap;
private ScheduledDelegate automaticAdditionDelegate;
[SetUp]
public void Setup() => Schedule(() =>
{
automaticAdditionDelegate?.Cancel();
automaticAdditionDelegate = null;
Children = new[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex("#333"),
},
object1 = new BorderCircle
{
Position = new Vector2(256, 192),
Colour = Color4.Yellow,
},
object2 = new BorderCircle
{
Position = new Vector2(100, 300),
},
accuracyHeatmap = new TestAccuracyHeatmap(new ScoreInfo { Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo })
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(130)
}
};
});
[Test]
public void TestManyHitPointsAutomatic()
{
AddStep("add scheduled delegate", () =>
{
automaticAdditionDelegate = Scheduler.AddDelayed(() =>
{
var randomPos = new Vector2(
RNG.NextSingle(object1.DrawPosition.X - object1.DrawSize.X / 2, object1.DrawPosition.X + object1.DrawSize.X / 2),
RNG.NextSingle(object1.DrawPosition.Y - object1.DrawSize.Y / 2, object1.DrawPosition.Y + object1.DrawSize.Y / 2));
// The background is used for ToLocalSpace() since we need to go _inside_ the DrawSizePreservingContainer (Content of TestScene).
accuracyHeatmap.AddPoint(object2.Position, object1.Position, randomPos, RNG.NextSingle(10, 500));
InputManager.MoveMouseTo(background.ToScreenSpace(randomPos));
}, 1, true);
});
AddWaitStep("wait for some hit points", 10);
}
[Test]
public void TestManualPlacement()
{
AddStep("return user input", () => InputManager.UseParentInput = true);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
accuracyHeatmap.AddPoint(object2.Position, object1.Position, background.ToLocalSpace(e.ScreenSpaceMouseDownPosition), 50);
return true;
}
private class TestAccuracyHeatmap : AccuracyHeatmap
{
public TestAccuracyHeatmap(ScoreInfo score)
: base(score, new TestBeatmap(new OsuRuleset().RulesetInfo))
{
}
public new void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius)
=> base.AddPoint(start, end, hitPoint, radius);
}
private class BorderCircle : CircularContainer
{
public BorderCircle()
{
Origin = Anchor.Centre;
Size = new Vector2(100);
Masking = true;
BorderThickness = 2;
BorderColour = Color4.White;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true
},
new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(4),
}
};
}
}
}
}

View File

@ -0,0 +1,28 @@
// 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 osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Judgements
{
public class OsuHitCircleJudgementResult : OsuJudgementResult
{
/// <summary>
/// The <see cref="HitCircle"/>.
/// </summary>
public HitCircle HitCircle => (HitCircle)HitObject;
/// <summary>
/// The position of the player's cursor when <see cref="HitCircle"/> was hit.
/// </summary>
public Vector2? CursorPositionAtHit;
public OsuHitCircleJudgementResult(HitObject hitObject, Judgement judgement)
: base(hitObject, judgement)
{
}
}
}

View File

@ -7,8 +7,11 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces;
using osu.Game.Rulesets.Scoring;
using osuTK;
@ -32,6 +35,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle;
private InputManager inputManager;
public DrawableHitCircle(HitCircle h)
: base(h)
{
@ -86,6 +91,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
AccentColour.BindValueChanged(accent => ApproachCircle.Colour = accent.NewValue, true);
}
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
}
public override double LifetimeStart
{
get => base.LifetimeStart;
@ -126,7 +138,19 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
return;
}
ApplyResult(r => r.Type = result);
ApplyResult(r =>
{
var circleResult = (OsuHitCircleJudgementResult)r;
// Todo: This should also consider misses, but they're a little more interesting to handle, since we don't necessarily know the position at the time of a miss.
if (result != HitResult.Miss)
{
var localMousePosition = ToLocalSpace(inputManager.CurrentState.Mouse.Position);
circleResult.CursorPositionAtHit = HitObject.StackedPosition + (localMousePosition - DrawSize / 2);
}
circleResult.Type = result;
});
}
protected override void UpdateInitialTransforms()
@ -172,6 +196,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public Drawable ProxiedLayer => ApproachCircle;
protected override JudgementResult CreateResult(Judgement judgement) => new OsuHitCircleJudgementResult(HitObject, judgement);
public class HitReceptor : CompositeDrawable, IKeyBindingHandler<OsuAction>
{
// IsHovered is used

View File

@ -29,6 +29,10 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Skinning;
using System;
using System.Linq;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Statistics;
using osu.Game.Screens.Ranking.Statistics;
namespace osu.Game.Rulesets.Osu
{
@ -186,5 +190,31 @@ namespace osu.Game.Rulesets.Osu
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new OsuRulesetConfigManager(settings, RulesetInfo);
public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
{
new StatisticRow
{
Columns = new[]
{
new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)).ToList())
{
RelativeSizeAxes = Axes.X,
Height = 250
}),
}
},
new StatisticRow
{
Columns = new[]
{
new StatisticItem("Accuracy Heatmap", new AccuracyHeatmap(score, playableBeatmap)
{
RelativeSizeAxes = Axes.X,
Height = 250
}),
}
}
};
}
}

View File

@ -4,13 +4,27 @@
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Scoring
{
public class OsuScoreProcessor : ScoreProcessor
{
protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new OsuJudgementResult(hitObject, judgement);
protected override HitEvent CreateHitEvent(JudgementResult result)
=> base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit);
protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement)
{
switch (hitObject)
{
case HitCircle _:
return new OsuHitCircleJudgementResult(hitObject, judgement);
default:
return new OsuJudgementResult(hitObject, judgement);
}
}
public override HitWindows CreateHitWindows() => new OsuHitWindows();
}

View File

@ -0,0 +1,297 @@
// 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.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Scoring;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Statistics
{
public class AccuracyHeatmap : CompositeDrawable
{
/// <summary>
/// Size of the inner circle containing the "hit" points, relative to the size of this <see cref="AccuracyHeatmap"/>.
/// All other points outside of the inner circle are "miss" points.
/// </summary>
private const float inner_portion = 0.8f;
/// <summary>
/// Number of rows/columns of points.
/// ~4px per point @ 128x128 size (the contents of the <see cref="AccuracyHeatmap"/> are always square). 1089 total points.
/// </summary>
private const int points_per_dimension = 33;
private const float rotation = 45;
private BufferedContainer bufferedGrid;
private GridContainer pointGrid;
private readonly ScoreInfo score;
private readonly IBeatmap playableBeatmap;
private const float line_thickness = 2;
/// <summary>
/// The highest count of any point currently being displayed.
/// </summary>
protected float PeakValue { get; private set; }
public AccuracyHeatmap(ScoreInfo score, IBeatmap playableBeatmap)
{
this.score = score;
this.playableBeatmap = playableBeatmap;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fit,
Children = new Drawable[]
{
new CircularContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Size = new Vector2(inner_portion),
Masking = true,
BorderThickness = line_thickness,
BorderColour = Color4.White,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex("#202624")
}
},
new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(1),
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
Children = new Drawable[]
{
new Box
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
EdgeSmoothness = new Vector2(1),
RelativeSizeAxes = Axes.Y,
Height = 2, // We're rotating along a diagonal - we don't really care how big this is.
Width = line_thickness / 2,
Rotation = -rotation,
Alpha = 0.3f,
},
new Box
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
EdgeSmoothness = new Vector2(1),
RelativeSizeAxes = Axes.Y,
Height = 2, // We're rotating along a diagonal - we don't really care how big this is.
Width = line_thickness / 2, // adjust for edgesmoothness
Rotation = rotation
},
}
},
},
new Box
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Width = 10,
EdgeSmoothness = new Vector2(1),
Height = line_thickness / 2, // adjust for edgesmoothness
},
new Box
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
EdgeSmoothness = new Vector2(1),
Width = line_thickness / 2, // adjust for edgesmoothness
Height = 10,
}
}
},
bufferedGrid = new BufferedContainer
{
RelativeSizeAxes = Axes.Both,
CacheDrawnFrameBuffer = true,
BackgroundColour = Color4Extensions.FromHex("#202624").Opacity(0),
Child = pointGrid = new GridContainer
{
RelativeSizeAxes = Axes.Both
}
},
}
};
Vector2 centre = new Vector2(points_per_dimension) / 2;
float innerRadius = centre.X * inner_portion;
Drawable[][] points = new Drawable[points_per_dimension][];
for (int r = 0; r < points_per_dimension; r++)
{
points[r] = new Drawable[points_per_dimension];
for (int c = 0; c < points_per_dimension; c++)
{
HitPointType pointType = Vector2.Distance(new Vector2(c, r), centre) <= innerRadius
? HitPointType.Hit
: HitPointType.Miss;
var point = new HitPoint(pointType, this)
{
Colour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255)
};
points[r][c] = point;
}
}
pointGrid.Content = points;
if (score.HitEvents == null || score.HitEvents.Count == 0)
return;
// Todo: This should probably not be done like this.
float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.BeatmapInfo.BaseDifficulty.CircleSize - 5) / 5) / 2;
foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)))
{
if (e.LastHitObject == null || e.Position == null)
continue;
AddPoint(((OsuHitObject)e.LastHitObject).StackedEndPosition, ((OsuHitObject)e.HitObject).StackedEndPosition, e.Position.Value, radius);
}
}
protected void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius)
{
if (pointGrid.Content.Length == 0)
return;
double angle1 = Math.Atan2(end.Y - hitPoint.Y, hitPoint.X - end.X); // Angle between the end point and the hit point.
double angle2 = Math.Atan2(end.Y - start.Y, start.X - end.X); // Angle between the end point and the start point.
double finalAngle = angle2 - angle1; // Angle between start, end, and hit points.
float normalisedDistance = Vector2.Distance(hitPoint, end) / radius;
// Consider two objects placed horizontally, with the start on the left and the end on the right.
// The above calculated the angle between {end, start}, and the angle between {end, hitPoint}, in the form:
// +pi | 0
// O --------- O -----> Note: Math.Atan2 has a range (-pi <= theta <= +pi)
// -pi | 0
// E.g. If the hit point was directly above end, it would have an angle pi/2.
//
// It also calculated the angle separating hitPoint from the line joining {start, end}, that is anti-clockwise in the form:
// 0 | pi
// O --------- O ----->
// 2pi | pi
//
// However keep in mind that cos(0)=1 and cos(2pi)=1, whereas we actually want these values to appear on the left, so the x-coordinate needs to be inverted.
// Likewise sin(pi/2)=1 and sin(3pi/2)=-1, whereas we actually want these values to appear on the bottom/top respectively, so the y-coordinate also needs to be inverted.
//
// We also need to apply the anti-clockwise rotation.
var rotatedAngle = finalAngle - MathUtils.DegreesToRadians(rotation);
var rotatedCoordinate = -1 * new Vector2((float)Math.Cos(rotatedAngle), (float)Math.Sin(rotatedAngle));
Vector2 localCentre = new Vector2(points_per_dimension - 1) / 2;
float localRadius = localCentre.X * inner_portion * normalisedDistance; // The radius inside the inner portion which of the heatmap which the closest point lies.
Vector2 localPoint = localCentre + localRadius * rotatedCoordinate;
// Find the most relevant hit point.
int r = Math.Clamp((int)Math.Round(localPoint.Y), 0, points_per_dimension - 1);
int c = Math.Clamp((int)Math.Round(localPoint.X), 0, points_per_dimension - 1);
PeakValue = Math.Max(PeakValue, ((HitPoint)pointGrid.Content[r][c]).Increment());
bufferedGrid.ForceRedraw();
}
private class HitPoint : Circle
{
private readonly HitPointType pointType;
private readonly AccuracyHeatmap heatmap;
public override bool IsPresent => count > 0;
public HitPoint(HitPointType pointType, AccuracyHeatmap heatmap)
{
this.pointType = pointType;
this.heatmap = heatmap;
RelativeSizeAxes = Axes.Both;
Alpha = 1;
}
private int count;
/// <summary>
/// Increment the value of this point by one.
/// </summary>
/// <returns>The value after incrementing.</returns>
public int Increment()
{
return ++count;
}
protected override void Update()
{
base.Update();
// the point at which alpha is saturated and we begin to adjust colour lightness.
const float lighten_cutoff = 0.95f;
// the amount of lightness to attribute regardless of relative value to peak point.
const float non_relative_portion = 0.2f;
float amount = 0;
// give some amount of alpha regardless of relative count
amount += non_relative_portion * Math.Min(1, count / 10f);
// add relative portion
amount += (1 - non_relative_portion) * (count / heatmap.PeakValue);
// apply easing
amount = (float)Interpolation.ApplyEasing(Easing.OutQuint, Math.Min(1, amount));
Debug.Assert(amount <= 1);
Alpha = Math.Min(amount / lighten_cutoff, 1);
if (pointType == HitPointType.Hit)
Colour = ((Color4)Colour).Lighten(Math.Max(0, amount - lighten_cutoff));
}
}
private enum HitPointType
{
Hit,
Miss
}
}
}

View File

@ -21,9 +21,12 @@ using osu.Game.Rulesets.Taiko.Difficulty;
using osu.Game.Rulesets.Taiko.Scoring;
using osu.Game.Scoring;
using System;
using System.Linq;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Taiko.Edit;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Skinning;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko
@ -155,5 +158,20 @@ namespace osu.Game.Rulesets.Taiko
public int LegacyID => 1;
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame();
public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
{
new StatisticRow
{
Columns = new[]
{
new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents.Where(e => e.HitObject is Hit).ToList())
{
RelativeSizeAxes = Axes.X,
Height = 250
}),
}
}
};
}
}

View File

@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Taiko.UI
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Scale = new Vector2(TaikoHitObject.DEFAULT_STRONG_SIZE),
Size = new Vector2(TaikoHitObject.DEFAULT_STRONG_SIZE),
Masking = true,
BorderColour = Color4.White,
BorderThickness = border_thickness,
@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Taiko.UI
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Scale = new Vector2(TaikoHitObject.DEFAULT_SIZE),
Size = new Vector2(TaikoHitObject.DEFAULT_SIZE),
Masking = true,
BorderColour = Color4.White,
BorderThickness = border_thickness,

View File

@ -49,9 +49,32 @@ namespace osu.Game.Tests.Online
Assert.That(converted.TestSetting.Value, Is.EqualTo(2));
}
[Test]
public void TestDeserialiseTimeRampMod()
{
// Create the mod with values different from default.
var apiMod = new APIMod(new TestModTimeRamp
{
AdjustPitch = { Value = false },
InitialRate = { Value = 1.25 },
FinalRate = { Value = 0.25 }
});
var deserialised = JsonConvert.DeserializeObject<APIMod>(JsonConvert.SerializeObject(apiMod));
var converted = (TestModTimeRamp)deserialised.ToMod(new TestRuleset());
Assert.That(converted.AdjustPitch.Value, Is.EqualTo(false));
Assert.That(converted.InitialRate.Value, Is.EqualTo(1.25));
Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25));
}
private class TestRuleset : Ruleset
{
public override IEnumerable<Mod> GetModsFor(ModType type) => new[] { new TestMod() };
public override IEnumerable<Mod> GetModsFor(ModType type) => new Mod[]
{
new TestMod(),
new TestModTimeRamp(),
};
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => throw new System.NotImplementedException();
@ -78,5 +101,39 @@ namespace osu.Game.Tests.Online
Precision = 0.01,
};
}
private class TestModTimeRamp : ModTimeRamp
{
public override string Name => "Test Mod";
public override string Acronym => "TMTR";
public override double ScoreMultiplier => 1;
[SettingSource("Initial rate", "The starting speed of the track")]
public override BindableNumber<double> InitialRate { get; } = new BindableDouble
{
MinValue = 1,
MaxValue = 2,
Default = 1.5,
Value = 1.5,
Precision = 0.01,
};
[SettingSource("Final rate", "The speed increase to ramp towards")]
public override BindableNumber<double> FinalRate { get; } = new BindableDouble
{
MinValue = 0,
MaxValue = 1,
Default = 0.5,
Value = 0.5,
Precision = 0.01,
};
[SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
public override BindableBool AdjustPitch { get; } = new BindableBool
{
Default = true,
Value = true
};
}
}
}

View File

@ -3,6 +3,7 @@
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Rulesets.Scoring;
@ -14,6 +15,8 @@ namespace osu.Game.Tests.Visual.Gameplay
{
private FailingLayer layer;
private readonly Bindable<bool> showHealth = new Bindable<bool>();
[Resolved]
private OsuConfigManager config { get; set; }
@ -24,8 +27,10 @@ namespace osu.Game.Tests.Visual.Gameplay
{
Child = layer = new FailingLayer();
layer.BindHealthProcessor(new DrainingHealthProcessor(1));
layer.ShowHealth.BindTo(showHealth);
});
AddStep("show health", () => showHealth.Value = true);
AddStep("enable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true));
AddUntilStep("layer is visible", () => layer.IsPresent);
}
@ -69,5 +74,27 @@ namespace osu.Game.Tests.Visual.Gameplay
AddWaitStep("wait for potential fade", 10);
AddAssert("layer is still visible", () => layer.IsPresent);
}
[Test]
public void TestLayerVisibilityWithDifferentOptions()
{
AddStep("set health to 0.10", () => layer.Current.Value = 0.1);
AddStep("don't show health", () => showHealth.Value = false);
AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false));
AddUntilStep("layer fade is invisible", () => !layer.IsPresent);
AddStep("don't show health", () => showHealth.Value = false);
AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true));
AddUntilStep("layer fade is invisible", () => !layer.IsPresent);
AddStep("show health", () => showHealth.Value = true);
AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false));
AddUntilStep("layer fade is invisible", () => !layer.IsPresent);
AddStep("show health", () => showHealth.Value = true);
AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true));
AddUntilStep("layer fade is visible", () => layer.IsPresent);
}
}
}

View File

@ -19,10 +19,10 @@ namespace osu.Game.Tests.Visual.Menus
[Cached]
private OsuLogo logo;
protected OsuScreenStack IntroStack;
protected IntroTestScene()
{
OsuScreenStack introStack = null;
Children = new Drawable[]
{
new Box
@ -45,17 +45,17 @@ namespace osu.Game.Tests.Visual.Menus
logo.FinishTransforms();
logo.IsTracking = false;
introStack?.Expire();
IntroStack?.Expire();
Add(introStack = new OsuScreenStack
Add(IntroStack = new OsuScreenStack
{
RelativeSizeAxes = Axes.Both,
});
introStack.Push(CreateScreen());
IntroStack.Push(CreateScreen());
});
AddUntilStep("wait for menu", () => introStack.CurrentScreen is MainMenu);
AddUntilStep("wait for menu", () => IntroStack.CurrentScreen is MainMenu);
}
protected abstract IScreen CreateScreen();

View File

@ -11,5 +11,18 @@ namespace osu.Game.Tests.Visual.Menus
public class TestSceneIntroWelcome : IntroTestScene
{
protected override IScreen CreateScreen() => new IntroWelcome();
public TestSceneIntroWelcome()
{
AddAssert("check if menu music loops", () =>
{
var menu = IntroStack?.CurrentScreen as MainMenu;
if (menu == null)
return false;
return menu.Track.Looping;
});
}
}
}

View File

@ -0,0 +1,71 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Ranking.Statistics;
using osuTK;
namespace osu.Game.Tests.Visual.Ranking
{
public class TestSceneHitEventTimingDistributionGraph : OsuTestScene
{
[Test]
public void TestManyDistributedEvents()
{
createTest(CreateDistributedHitEvents());
}
[Test]
public void TestZeroTimeOffset()
{
createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList());
}
[Test]
public void TestNoEvents()
{
createTest(new List<HitEvent>());
}
private void createTest(List<HitEvent> events) => AddStep("create test", () =>
{
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex("#333")
},
new HitEventTimingDistributionGraph(events)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(600, 130)
}
};
});
public static List<HitEvent> CreateDistributedHitEvents()
{
var hitEvents = new List<HitEvent>();
for (int i = 0; i < 50; i++)
{
int count = (int)(Math.Pow(25 - Math.Abs(i - 25), 2));
for (int j = 0; j < count; j++)
hitEvents.Add(new HitEvent(i - 25, HitResult.Perfect, new HitCircle(), new HitCircle(), null));
}
return hitEvents;
}
}
}

View File

@ -1,23 +1,32 @@
// 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.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Rulesets.Osu;
using osu.Game.Scoring;
using osu.Game.Screens;
using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Ranking.Statistics;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Ranking
{
[TestFixture]
public class TestSceneResultsScreen : ScreenTestScene
public class TestSceneResultsScreen : OsuManualInputManagerTestScene
{
private BeatmapManager beatmaps;
@ -41,7 +50,7 @@ namespace osu.Game.Tests.Visual.Ranking
private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo));
[Test]
public void ResultsWithoutPlayer()
public void TestResultsWithoutPlayer()
{
TestResultsScreen screen = null;
OsuScreenStack stack;
@ -60,7 +69,7 @@ namespace osu.Game.Tests.Visual.Ranking
}
[Test]
public void ResultsWithPlayer()
public void TestResultsWithPlayer()
{
TestResultsScreen screen = null;
@ -70,7 +79,7 @@ namespace osu.Game.Tests.Visual.Ranking
}
[Test]
public void ResultsForUnranked()
public void TestResultsForUnranked()
{
UnrankedSoloResultsScreen screen = null;
@ -79,6 +88,130 @@ namespace osu.Game.Tests.Visual.Ranking
AddAssert("retry overlay present", () => screen.RetryOverlay != null);
}
[Test]
public void TestShowHideStatisticsViaOutsideClick()
{
TestResultsScreen screen = null;
AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen()));
AddUntilStep("wait for loaded", () => screen.IsLoaded);
AddStep("click expanded panel", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
InputManager.MoveMouseTo(expandedPanel);
InputManager.Click(MouseButton.Left);
});
AddAssert("statistics shown", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Visible);
AddUntilStep("expanded panel at the left of the screen", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150;
});
AddStep("click to right of panel", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
InputManager.MoveMouseTo(expandedPanel.ScreenSpaceDrawQuad.TopRight + new Vector2(100, 0));
InputManager.Click(MouseButton.Left);
});
AddAssert("statistics hidden", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Hidden);
AddUntilStep("expanded panel in centre of screen", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1);
});
}
[Test]
public void TestShowHideStatistics()
{
TestResultsScreen screen = null;
AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen()));
AddUntilStep("wait for loaded", () => screen.IsLoaded);
AddStep("click expanded panel", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
InputManager.MoveMouseTo(expandedPanel);
InputManager.Click(MouseButton.Left);
});
AddAssert("statistics shown", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Visible);
AddUntilStep("expanded panel at the left of the screen", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150;
});
AddStep("click expanded panel", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
InputManager.MoveMouseTo(expandedPanel);
InputManager.Click(MouseButton.Left);
});
AddAssert("statistics hidden", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Hidden);
AddUntilStep("expanded panel in centre of screen", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1);
});
}
[Test]
public void TestShowStatisticsAndClickOtherPanel()
{
TestResultsScreen screen = null;
AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen()));
AddUntilStep("wait for loaded", () => screen.IsLoaded);
ScorePanel expandedPanel = null;
ScorePanel contractedPanel = null;
AddStep("click expanded panel then contracted panel", () =>
{
expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
InputManager.MoveMouseTo(expandedPanel);
InputManager.Click(MouseButton.Left);
contractedPanel = this.ChildrenOfType<ScorePanel>().First(p => p.State == PanelState.Contracted && p.ScreenSpaceDrawQuad.TopLeft.X > screen.ScreenSpaceDrawQuad.TopLeft.X);
InputManager.MoveMouseTo(contractedPanel);
InputManager.Click(MouseButton.Left);
});
AddAssert("statistics shown", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Visible);
AddAssert("contracted panel still contracted", () => contractedPanel.State == PanelState.Contracted);
AddAssert("expanded panel still expanded", () => expandedPanel.State == PanelState.Expanded);
}
[Test]
public void TestFetchScoresAfterShowingStatistics()
{
DelayedFetchResultsScreen screen = null;
AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo), 3000)));
AddUntilStep("wait for loaded", () => screen.IsLoaded);
AddStep("click expanded panel", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
InputManager.MoveMouseTo(expandedPanel);
InputManager.Click(MouseButton.Left);
});
AddUntilStep("wait for fetch", () => screen.FetchCompleted);
AddAssert("expanded panel still on screen", () => this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded).ScreenSpaceDrawQuad.TopLeft.X > 0);
}
private class TestResultsContainer : Container
{
[Cached(typeof(Player))]
@ -113,6 +246,58 @@ namespace osu.Game.Tests.Visual.Ranking
RetryOverlay = InternalChildren.OfType<HotkeyRetryOverlay>().SingleOrDefault();
}
protected override APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback)
{
var scores = new List<ScoreInfo>();
for (int i = 0; i < 20; i++)
{
var score = new TestScoreInfo(new OsuRuleset().RulesetInfo);
score.TotalScore += 10 - i;
scores.Add(score);
}
scoresCallback?.Invoke(scores);
return null;
}
}
private class DelayedFetchResultsScreen : TestResultsScreen
{
public bool FetchCompleted { get; private set; }
private readonly double delay;
public DelayedFetchResultsScreen(ScoreInfo score, double delay)
: base(score)
{
this.delay = delay;
}
protected override APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback)
{
Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromMilliseconds(delay));
var scores = new List<ScoreInfo>();
for (int i = 0; i < 20; i++)
{
var score = new TestScoreInfo(new OsuRuleset().RulesetInfo);
score.TotalScore += 10 - i;
scores.Add(score);
}
scoresCallback?.Invoke(scores);
Schedule(() => FetchCompleted = true);
});
return null;
}
}
private class UnrankedSoloResultsScreen : SoloResultsScreen

View File

@ -0,0 +1,48 @@
// 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 NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Osu;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics;
namespace osu.Game.Tests.Visual.Ranking
{
public class TestSceneStatisticsPanel : OsuTestScene
{
[Test]
public void TestScoreWithStatistics()
{
var score = new TestScoreInfo(new OsuRuleset().RulesetInfo)
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents()
};
loadPanel(score);
}
[Test]
public void TestScoreWithoutStatistics()
{
loadPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo));
}
[Test]
public void TestNullScore()
{
loadPanel(null);
}
private void loadPanel(ScoreInfo score) => AddStep("load panel", () =>
{
Child = new StatisticsPanel
{
RelativeSizeAxes = Axes.Both,
State = { Value = Visibility.Visible },
Score = { Value = score }
};
});
}
}

View File

@ -6,6 +6,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps;
@ -66,6 +67,9 @@ namespace osu.Game.Tournament.Components
}
}
// Todo: This is a hack for https://github.com/ppy/osu-framework/issues/3617 since this container is at the very edge of the screen and potentially initially masked away.
protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
[BackgroundDependencyLoader]
private void load()
{
@ -77,8 +81,6 @@ namespace osu.Game.Tournament.Components
flow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
// Todo: This is a hack for https://github.com/ppy/osu-framework/issues/3617 since this container is at the very edge of the screen and potentially initially masked away.
Height = 1,
AutoSizeAxes = Axes.Y,
LayoutDuration = 500,
LayoutEasing = Easing.OutQuint,

View File

@ -240,6 +240,9 @@ namespace osu.Game.Beatmaps
beatmapInfo = QueryBeatmap(b => b.ID == info.ID);
}
if (beatmapInfo == null)
return DefaultBeatmap;
lock (workingCache)
{
var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID);

View File

@ -89,9 +89,9 @@ namespace osu.Game.Rulesets.Mods
private void applyPitchAdjustment(ValueChangedEvent<bool> adjustPitchSetting)
{
// remove existing old adjustment
track.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange);
track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange);
track.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange);
track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange);
}
private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue)

View File

@ -23,6 +23,7 @@ using osu.Game.Scoring;
using osu.Game.Skinning;
using osu.Game.Users;
using JetBrains.Annotations;
using osu.Game.Screens.Ranking.Statistics;
namespace osu.Game.Rulesets
{
@ -208,5 +209,14 @@ namespace osu.Game.Rulesets
/// </summary>
/// <returns>An empty frame for the current ruleset, or null if unsupported.</returns>
public virtual IConvertibleReplayFrame CreateConvertibleReplayFrame() => null;
/// <summary>
/// Creates the statistics for a <see cref="ScoreInfo"/> to be displayed in the results screen.
/// </summary>
/// <param name="score">The <see cref="ScoreInfo"/> to create the statistics for. The score is guaranteed to have <see cref="ScoreInfo.HitEvents"/> populated.</param>
/// <param name="playableBeatmap">The <see cref="IBeatmap"/>, converted for this <see cref="Ruleset"/> with all relevant <see cref="Mod"/>s applied.</param>
/// <returns>The <see cref="StatisticRow"/>s to display. Each <see cref="StatisticRow"/> may contain 0 or more <see cref="StatisticItem"/>.</returns>
[NotNull]
public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty<StatisticRow>();
}
}

View File

@ -0,0 +1,66 @@
// 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 JetBrains.Annotations;
using osu.Game.Rulesets.Objects;
using osuTK;
namespace osu.Game.Rulesets.Scoring
{
/// <summary>
/// A <see cref="HitEvent"/> generated by the <see cref="ScoreProcessor"/> containing extra statistics around a <see cref="HitResult"/>.
/// </summary>
public readonly struct HitEvent
{
/// <summary>
/// The time offset from the end of <see cref="HitObject"/> at which the event occurred.
/// </summary>
public readonly double TimeOffset;
/// <summary>
/// The hit result.
/// </summary>
public readonly HitResult Result;
/// <summary>
/// The <see cref="HitObject"/> on which the result occurred.
/// </summary>
public readonly HitObject HitObject;
/// <summary>
/// The <see cref="HitObject"/> occurring prior to <see cref="HitObject"/>.
/// </summary>
[CanBeNull]
public readonly HitObject LastHitObject;
/// <summary>
/// A position, if available, at the time of the event.
/// </summary>
[CanBeNull]
public readonly Vector2? Position;
/// <summary>
/// Creates a new <see cref="HitEvent"/>.
/// </summary>
/// <param name="timeOffset">The time offset from the end of <paramref name="hitObject"/> at which the event occurs.</param>
/// <param name="result">The <see cref="HitResult"/>.</param>
/// <param name="hitObject">The <see cref="HitObject"/> that triggered the event.</param>
/// <param name="lastHitObject">The previous <see cref="HitObject"/>.</param>
/// <param name="position">A position corresponding to the event.</param>
public HitEvent(double timeOffset, HitResult result, HitObject hitObject, [CanBeNull] HitObject lastHitObject, [CanBeNull] Vector2? position)
{
TimeOffset = timeOffset;
Result = result;
HitObject = hitObject;
LastHitObject = lastHitObject;
Position = position;
}
/// <summary>
/// Creates a new <see cref="HitEvent"/> with an optional positional offset.
/// </summary>
/// <param name="positionOffset">The positional offset.</param>
/// <returns>The new <see cref="HitEvent"/>.</returns>
public HitEvent With(Vector2? positionOffset) => new HitEvent(TimeOffset, Result, HitObject, LastHitObject, positionOffset);
}
}

View File

@ -9,6 +9,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Scoring
@ -61,6 +62,9 @@ namespace osu.Game.Rulesets.Scoring
private double baseScore;
private double bonusScore;
private readonly List<HitEvent> hitEvents = new List<HitEvent>();
private HitObject lastHitObject;
private double scoreMultiplier = 1;
public ScoreProcessor()
@ -128,9 +132,20 @@ namespace osu.Game.Rulesets.Scoring
rollingMaxBaseScore += result.Judgement.MaxNumericResult;
}
hitEvents.Add(CreateHitEvent(result));
lastHitObject = result.HitObject;
updateScore();
}
/// <summary>
/// Creates the <see cref="HitEvent"/> that describes a <see cref="JudgementResult"/>.
/// </summary>
/// <param name="result">The <see cref="JudgementResult"/> to describe.</param>
/// <returns>The <see cref="HitEvent"/>.</returns>
protected virtual HitEvent CreateHitEvent(JudgementResult result)
=> new HitEvent(result.TimeOffset, result.Type, result.HitObject, lastHitObject, null);
protected sealed override void RevertResultInternal(JudgementResult result)
{
Combo.Value = result.ComboAtJudgement;
@ -153,6 +168,10 @@ namespace osu.Game.Rulesets.Scoring
rollingMaxBaseScore -= result.Judgement.MaxNumericResult;
}
Debug.Assert(hitEvents.Count > 0);
lastHitObject = hitEvents[^1].LastHitObject;
hitEvents.RemoveAt(hitEvents.Count - 1);
updateScore();
}
@ -207,6 +226,8 @@ namespace osu.Game.Rulesets.Scoring
base.Reset(storeResults);
scoreResultCounts.Clear();
hitEvents.Clear();
lastHitObject = null;
if (storeResults)
{
@ -247,6 +268,8 @@ namespace osu.Game.Rulesets.Scoring
foreach (var result in Enum.GetValues(typeof(HitResult)).OfType<HitResult>().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r)))
score.Statistics[result] = GetStatistic(result);
score.HitEvents = new List<HitEvent>(hitEvents);
}
/// <summary>

View File

@ -166,6 +166,10 @@ namespace osu.Game.Scoring
}
}
[NotMapped]
[JsonIgnore]
public List<HitEvent> HitEvents { get; set; }
[JsonIgnore]
public List<ScoreFileInfo> Files { get; set; }

View File

@ -73,7 +73,6 @@ namespace osu.Game.Screens.Menu
MenuVoice = config.GetBindable<bool>(OsuSetting.MenuVoice);
MenuMusic = config.GetBindable<bool>(OsuSetting.MenuMusic);
seeya = audio.Samples.Get(SeeyaSampleName);
BeatmapSetInfo setInfo = null;

View File

@ -39,6 +39,8 @@ namespace osu.Game.Screens.Menu
welcome = audio.Samples.Get(@"Intro/Welcome/welcome");
pianoReverb = audio.Samples.Get(@"Intro/Welcome/welcome_piano");
Track.Looping = true;
}
protected override void LogoArriving(OsuLogo logo, bool resuming)

View File

@ -6,6 +6,7 @@ using System.Linq;
using osuTK;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
@ -63,6 +64,8 @@ namespace osu.Game.Screens.Menu
protected override BackgroundScreen CreateBackground() => background;
internal Track Track { get; private set; }
private Bindable<float> holdDelay;
private Bindable<bool> loginDisplayed;
@ -173,15 +176,15 @@ namespace osu.Game.Screens.Menu
base.OnEntering(last);
buttons.FadeInFromZero(500);
var track = Beatmap.Value.Track;
Track = Beatmap.Value.Track;
var metadata = Beatmap.Value.Metadata;
if (last is IntroScreen && track != null)
if (last is IntroScreen && Track != null)
{
if (!track.IsRunning)
if (!Track.IsRunning)
{
track.Seek(metadata.PreviewTime != -1 ? metadata.PreviewTime : 0.4f * track.Length);
track.Start();
Track.Seek(metadata.PreviewTime != -1 ? metadata.PreviewTime : 0.4f * Track.Length);
Track.Start();
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
@ -142,6 +143,8 @@ namespace osu.Game.Screens.Multi
joinedRoom = null;
}
private readonly HashSet<int> ignoredRooms = new HashSet<int>();
/// <summary>
/// Invoked when the listing of all <see cref="Room"/>s is received from the server.
/// </summary>
@ -163,11 +166,27 @@ namespace osu.Game.Screens.Multi
continue;
}
var r = listing[i];
r.Position.Value = i;
var room = listing[i];
update(r, r);
addRoom(r);
Debug.Assert(room.RoomID.Value != null);
if (ignoredRooms.Contains(room.RoomID.Value.Value))
continue;
room.Position.Value = i;
try
{
update(room, room);
addRoom(room);
}
catch (Exception ex)
{
Logger.Error(ex, $"Failed to update room: {room.Name.Value}.");
ignoredRooms.Add(room.RoomID.Value.Value);
rooms.Remove(room);
}
}
RoomsUpdated?.Invoke();

View File

@ -18,10 +18,15 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD
{
/// <summary>
/// An overlay layer on top of the playfield which fades to red when the current player health falls below a certain threshold defined by <see cref="LowHealthThreshold"/>.
/// An overlay layer on top of the playfield which fades to red when the current player health falls below a certain threshold defined by <see cref="low_health_threshold"/>.
/// </summary>
public class FailingLayer : HealthDisplay
{
/// <summary>
/// Whether the current player health should be shown on screen.
/// </summary>
public readonly Bindable<bool> ShowHealth = new Bindable<bool>();
private const float max_alpha = 0.4f;
private const int fade_time = 400;
private const float gradient_size = 0.3f;
@ -29,12 +34,11 @@ namespace osu.Game.Screens.Play.HUD
/// <summary>
/// The threshold under which the current player life should be considered low and the layer should start fading in.
/// </summary>
public double LowHealthThreshold = 0.20f;
private const double low_health_threshold = 0.20f;
private readonly Bindable<bool> enabled = new Bindable<bool>();
private readonly Container boxes;
private Bindable<bool> configEnabled;
private Bindable<bool> fadePlayfieldWhenHealthLow;
private HealthProcessor healthProcessor;
public FailingLayer()
@ -73,14 +77,15 @@ namespace osu.Game.Screens.Play.HUD
{
boxes.Colour = color.Red;
configEnabled = config.GetBindable<bool>(OsuSetting.FadePlayfieldWhenHealthLow);
enabled.BindValueChanged(e => this.FadeTo(e.NewValue ? 1 : 0, fade_time, Easing.OutQuint), true);
fadePlayfieldWhenHealthLow = config.GetBindable<bool>(OsuSetting.FadePlayfieldWhenHealthLow);
fadePlayfieldWhenHealthLow.BindValueChanged(_ => updateState());
ShowHealth.BindValueChanged(_ => updateState());
}
protected override void LoadComplete()
{
base.LoadComplete();
updateBindings();
updateState();
}
public override void BindHealthProcessor(HealthProcessor processor)
@ -88,26 +93,19 @@ namespace osu.Game.Screens.Play.HUD
base.BindHealthProcessor(processor);
healthProcessor = processor;
updateBindings();
updateState();
}
private void updateBindings()
private void updateState()
{
if (LoadState < LoadState.Ready)
return;
enabled.UnbindBindings();
// Don't display ever if the ruleset is not using a draining health display.
if (healthProcessor is DrainingHealthProcessor)
enabled.BindTo(configEnabled);
else
enabled.Value = false;
var showLayer = healthProcessor is DrainingHealthProcessor && fadePlayfieldWhenHealthLow.Value && ShowHealth.Value;
this.FadeTo(showLayer ? 1 : 0, fade_time, Easing.OutQuint);
}
protected override void Update()
{
double target = Math.Clamp(max_alpha * (1 - Current.Value / LowHealthThreshold), 0, max_alpha);
double target = Math.Clamp(max_alpha * (1 - Current.Value / low_health_threshold), 0, max_alpha);
boxes.Alpha = (float)Interpolation.Lerp(boxes.Alpha, target, Clock.ElapsedFrameTime * 0.01f);

View File

@ -262,7 +262,10 @@ namespace osu.Game.Screens.Play
Margin = new MarginPadding { Top = 20 }
};
protected virtual FailingLayer CreateFailingLayer() => new FailingLayer();
protected virtual FailingLayer CreateFailingLayer() => new FailingLayer
{
ShowHealth = { BindTarget = ShowHealthbar }
};
protected virtual KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay
{

View File

@ -1,7 +1,6 @@
// 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 osu.Framework.Screens;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking;
@ -25,13 +24,16 @@ namespace osu.Game.Screens.Play
DrawableRuleset?.SetReplayScore(score);
}
protected override void GotoRanking()
{
this.Push(CreateResults(DrawableRuleset.ReplayScore.ScoreInfo));
}
protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false);
protected override ScoreInfo CreateScore() => score.ScoreInfo;
protected override ScoreInfo CreateScore()
{
var baseScore = base.CreateScore();
// Since the replay score doesn't contain statistics, we'll pass them through here.
score.ScoreInfo.HitEvents = baseScore.HitEvents;
return score.ScoreInfo;
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@ -16,6 +17,7 @@ using osu.Game.Online.API;
using osu.Game.Scoring;
using osu.Game.Screens.Backgrounds;
using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking.Statistics;
using osuTK;
namespace osu.Game.Screens.Ranking
@ -23,6 +25,7 @@ namespace osu.Game.Screens.Ranking
public abstract class ResultsScreen : OsuScreen
{
protected const float BACKGROUND_BLUR = 20;
private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y;
public override bool DisallowExternalBeatmapRulesetChanges => true;
@ -42,8 +45,10 @@ namespace osu.Game.Screens.Ranking
[Resolved]
private IAPIProvider api { get; set; }
private StatisticsPanel statisticsPanel;
private Drawable bottomPanel;
private ScorePanelList panels;
private ScorePanelList scorePanelList;
private Container<ScorePanel> detachedPanelContainer;
protected ResultsScreen(ScoreInfo score, bool allowRetry = true)
{
@ -65,12 +70,39 @@ namespace osu.Game.Screens.Ranking
{
new Drawable[]
{
new ResultsScrollContainer
new Container
{
Child = panels = new ScorePanelList
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
RelativeSizeAxes = Axes.Both,
SelectedScore = { BindTarget = SelectedScore }
new OsuScrollContainer
{
RelativeSizeAxes = Axes.Both,
ScrollbarVisible = false,
Child = new Container
{
RelativeSizeAxes = Axes.X,
Height = screen_height,
Children = new Drawable[]
{
scorePanelList = new ScorePanelList
{
RelativeSizeAxes = Axes.Both,
SelectedScore = { BindTarget = SelectedScore },
PostExpandAction = () => statisticsPanel.ToggleVisibility()
},
detachedPanelContainer = new Container<ScorePanel>
{
RelativeSizeAxes = Axes.Both
},
statisticsPanel = new StatisticsPanel
{
RelativeSizeAxes = Axes.Both,
Score = { BindTarget = SelectedScore }
},
}
}
},
}
}
},
@ -118,7 +150,7 @@ namespace osu.Game.Screens.Ranking
};
if (Score != null)
panels.AddScore(Score);
scorePanelList.AddScore(Score);
if (player != null && allowRetry)
{
@ -143,11 +175,13 @@ namespace osu.Game.Screens.Ranking
var req = FetchScores(scores => Schedule(() =>
{
foreach (var s in scores)
panels.AddScore(s);
addScore(s);
}));
if (req != null)
api.Queue(req);
statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true);
}
/// <summary>
@ -169,32 +203,78 @@ namespace osu.Game.Screens.Ranking
public override bool OnExiting(IScreen next)
{
if (statisticsPanel.State.Value == Visibility.Visible)
{
statisticsPanel.Hide();
return true;
}
Background.FadeTo(1, 250);
return base.OnExiting(next);
}
private class ResultsScrollContainer : OsuScrollContainer
private void addScore(ScoreInfo score)
{
private readonly Container content;
var panel = scorePanelList.AddScore(score);
protected override Container<Drawable> Content => content;
if (detachedPanel != null)
panel.Alpha = 0;
}
public ResultsScrollContainer()
private ScorePanel detachedPanel;
private void onStatisticsStateChanged(ValueChangedEvent<Visibility> state)
{
if (state.NewValue == Visibility.Visible)
{
base.Content.Add(content = new Container
{
RelativeSizeAxes = Axes.X
});
// Detach the panel in its original location, and move into the desired location in the local container.
var expandedPanel = scorePanelList.GetPanelForScore(SelectedScore.Value);
var screenSpacePos = expandedPanel.ScreenSpaceDrawQuad.TopLeft;
RelativeSizeAxes = Axes.Both;
ScrollbarVisible = false;
// Detach and move into the local container.
scorePanelList.Detach(expandedPanel);
detachedPanelContainer.Add(expandedPanel);
// Move into its original location in the local container first, then to the final location.
var origLocation = detachedPanelContainer.ToLocalSpace(screenSpacePos);
expandedPanel.MoveTo(origLocation)
.Then()
.MoveTo(new Vector2(StatisticsPanel.SIDE_PADDING, origLocation.Y), 150, Easing.OutQuint);
// Hide contracted panels.
foreach (var contracted in scorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted))
contracted.FadeOut(150, Easing.OutQuint);
scorePanelList.HandleInput = false;
// Dim background.
Background.FadeTo(0.1f, 150);
detachedPanel = expandedPanel;
}
protected override void Update()
else if (detachedPanel != null)
{
base.Update();
content.Height = Math.Max(768 - TwoLayerButton.SIZE_EXTENDED.Y, DrawHeight);
var screenSpacePos = detachedPanel.ScreenSpaceDrawQuad.TopLeft;
// Remove from the local container and re-attach.
detachedPanelContainer.Remove(detachedPanel);
scorePanelList.Attach(detachedPanel);
// Move into its original location in the attached container first, then to the final location.
var origLocation = detachedPanel.Parent.ToLocalSpace(screenSpacePos);
detachedPanel.MoveTo(origLocation)
.Then()
.MoveTo(new Vector2(0, origLocation.Y), 150, Easing.OutQuint);
// Show contracted panels.
foreach (var contracted in scorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted))
contracted.FadeIn(150, Easing.OutQuint);
scorePanelList.HandleInput = true;
// Un-dim background.
Background.FadeTo(0.5f, 150);
detachedPanel = null;
}
}
}

View File

@ -76,6 +76,12 @@ namespace osu.Game.Screens.Ranking
private static readonly Color4 contracted_middle_layer_colour = Color4Extensions.FromHex("#353535");
public event Action<PanelState> StateChanged;
/// <summary>
/// An action to be invoked if this <see cref="ScorePanel"/> is clicked while in an expanded state.
/// </summary>
public Action PostExpandAction;
public readonly ScoreInfo Score;
private Container content;
@ -236,10 +242,28 @@ namespace osu.Game.Screens.Ranking
}
}
public override Vector2 Size
{
get => base.Size;
set
{
base.Size = value;
// Auto-size isn't used to avoid 1-frame issues and because the score panel is removed/re-added to the container.
if (trackingContainer != null)
trackingContainer.Size = value;
}
}
protected override bool OnClick(ClickEvent e)
{
if (State == PanelState.Contracted)
{
State = PanelState.Expanded;
return true;
}
PostExpandAction?.Invoke();
return true;
}
@ -248,5 +272,24 @@ namespace osu.Game.Screens.Ranking
=> base.ReceivePositionalInputAt(screenSpacePos)
|| topLayerContainer.ReceivePositionalInputAt(screenSpacePos)
|| middleLayerContainer.ReceivePositionalInputAt(screenSpacePos);
private ScorePanelTrackingContainer trackingContainer;
/// <summary>
/// Creates a <see cref="ScorePanelTrackingContainer"/> which this <see cref="ScorePanel"/> can reside inside.
/// The <see cref="ScorePanelTrackingContainer"/> will track the size of this <see cref="ScorePanel"/>.
/// </summary>
/// <remarks>
/// This <see cref="ScorePanel"/> is immediately added as a child of the <see cref="ScorePanelTrackingContainer"/>.
/// </remarks>
/// <returns>The <see cref="ScorePanelTrackingContainer"/>.</returns>
/// <exception cref="InvalidOperationException">If a <see cref="ScorePanelTrackingContainer"/> already exists.</exception>
public ScorePanelTrackingContainer CreateTrackingContainer()
{
if (trackingContainer != null)
throw new InvalidOperationException("A score panel container has already been created.");
return trackingContainer = new ScorePanelTrackingContainer(this);
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -25,12 +26,20 @@ namespace osu.Game.Screens.Ranking
/// </summary>
private const float expanded_panel_spacing = 15;
/// <summary>
/// An action to be invoked if a <see cref="ScorePanel"/> is clicked while in an expanded state.
/// </summary>
public Action PostExpandAction;
public readonly Bindable<ScoreInfo> SelectedScore = new Bindable<ScoreInfo>();
private readonly Flow flow;
private readonly Scroll scroll;
private ScorePanel expandedPanel;
/// <summary>
/// Creates a new <see cref="ScorePanelList"/>.
/// </summary>
public ScorePanelList()
{
RelativeSizeAxes = Axes.Both;
@ -38,6 +47,7 @@ namespace osu.Game.Screens.Ranking
InternalChild = scroll = new Scroll
{
RelativeSizeAxes = Axes.Both,
HandleScroll = () => expandedPanel?.IsHovered != true, // handle horizontal scroll only when not hovering the expanded panel.
Child = flow = new Flow
{
Anchor = Anchor.Centre,
@ -60,12 +70,11 @@ namespace osu.Game.Screens.Ranking
/// Adds a <see cref="ScoreInfo"/> to this list.
/// </summary>
/// <param name="score">The <see cref="ScoreInfo"/> to add.</param>
public void AddScore(ScoreInfo score)
public ScorePanel AddScore(ScoreInfo score)
{
flow.Add(new ScorePanel(score)
var panel = new ScorePanel(score)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
PostExpandAction = () => PostExpandAction?.Invoke()
}.With(p =>
{
p.StateChanged += s =>
@ -73,6 +82,12 @@ namespace osu.Game.Screens.Ranking
if (s == PanelState.Expanded)
SelectedScore.Value = p.Score;
};
});
flow.Add(panel.CreateTrackingContainer().With(d =>
{
d.Anchor = Anchor.Centre;
d.Origin = Anchor.Centre;
}));
if (SelectedScore.Value == score)
@ -90,6 +105,8 @@ namespace osu.Game.Screens.Ranking
scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing;
}
}
return panel;
}
/// <summary>
@ -99,24 +116,24 @@ namespace osu.Game.Screens.Ranking
private void selectedScoreChanged(ValueChangedEvent<ScoreInfo> score)
{
// Contract the old panel.
foreach (var p in flow.Where(p => p.Score == score.OldValue))
foreach (var t in flow.Where(t => t.Panel.Score == score.OldValue))
{
p.State = PanelState.Contracted;
p.Margin = new MarginPadding();
t.Panel.State = PanelState.Contracted;
t.Margin = new MarginPadding();
}
// Find the panel corresponding to the new score.
expandedPanel = flow.SingleOrDefault(p => p.Score == score.NewValue);
// handle horizontal scroll only when not hovering the expanded panel.
scroll.HandleScroll = () => expandedPanel?.IsHovered != true;
var expandedTrackingComponent = flow.SingleOrDefault(t => t.Panel.Score == score.NewValue);
expandedPanel = expandedTrackingComponent?.Panel;
if (expandedPanel == null)
return;
Debug.Assert(expandedTrackingComponent != null);
// Expand the new panel.
expandedTrackingComponent.Margin = new MarginPadding { Horizontal = expanded_panel_spacing };
expandedPanel.State = PanelState.Expanded;
expandedPanel.Margin = new MarginPadding { Horizontal = expanded_panel_spacing };
// Scroll to the new panel. This is done manually since we need:
// 1) To scroll after the scroll container's visible range is updated.
@ -145,15 +162,92 @@ namespace osu.Game.Screens.Ranking
flow.Padding = new MarginPadding { Horizontal = offset };
}
private class Flow : FillFlowContainer<ScorePanel>
private bool handleInput = true;
/// <summary>
/// Whether this <see cref="ScorePanelList"/> or any of the <see cref="ScorePanel"/>s contained should handle scroll or click input.
/// Setting to <c>false</c> will also hide the scrollbar.
/// </summary>
public bool HandleInput
{
get => handleInput;
set
{
handleInput = value;
scroll.ScrollbarVisible = value;
}
}
public override bool PropagatePositionalInputSubTree => HandleInput && base.PropagatePositionalInputSubTree;
public override bool PropagateNonPositionalInputSubTree => HandleInput && base.PropagateNonPositionalInputSubTree;
/// <summary>
/// Enumerates all <see cref="ScorePanel"/>s contained in this <see cref="ScorePanelList"/>.
/// </summary>
/// <returns></returns>
public IEnumerable<ScorePanel> GetScorePanels() => flow.Select(t => t.Panel);
/// <summary>
/// Finds the <see cref="ScorePanel"/> corresponding to a <see cref="ScoreInfo"/>.
/// </summary>
/// <param name="score">The <see cref="ScoreInfo"/> to find the corresponding <see cref="ScorePanel"/> for.</param>
/// <returns>The <see cref="ScorePanel"/>.</returns>
public ScorePanel GetPanelForScore(ScoreInfo score) => flow.Single(t => t.Panel.Score == score).Panel;
/// <summary>
/// Detaches a <see cref="ScorePanel"/> from its <see cref="ScorePanelTrackingContainer"/>, allowing the panel to be moved elsewhere in the hierarchy.
/// </summary>
/// <param name="panel">The <see cref="ScorePanel"/> to detach.</param>
/// <exception cref="InvalidOperationException">If <paramref name="panel"/> is not a part of this <see cref="ScorePanelList"/>.</exception>
public void Detach(ScorePanel panel)
{
var container = flow.SingleOrDefault(t => t.Panel == panel);
if (container == null)
throw new InvalidOperationException("Panel is not contained by the score panel list.");
container.Detach();
}
/// <summary>
/// Attaches a <see cref="ScorePanel"/> to its <see cref="ScorePanelTrackingContainer"/> in this <see cref="ScorePanelList"/>.
/// </summary>
/// <param name="panel">The <see cref="ScorePanel"/> to attach.</param>
/// <exception cref="InvalidOperationException">If <paramref name="panel"/> is not a part of this <see cref="ScorePanelList"/>.</exception>
public void Attach(ScorePanel panel)
{
var container = flow.SingleOrDefault(t => t.Panel == panel);
if (container == null)
throw new InvalidOperationException("Panel is not contained by the score panel list.");
container.Attach();
}
private class Flow : FillFlowContainer<ScorePanelTrackingContainer>
{
public override IEnumerable<Drawable> FlowingChildren => applySorting(AliveInternalChildren);
public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Score != score).Count();
public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Panel.Score != score).Count();
private IEnumerable<ScorePanel> applySorting(IEnumerable<Drawable> drawables) => drawables.OfType<ScorePanel>()
.OrderByDescending(s => s.Score.TotalScore)
.ThenBy(s => s.Score.OnlineScoreID);
private IEnumerable<ScorePanelTrackingContainer> applySorting(IEnumerable<Drawable> drawables) => drawables.OfType<ScorePanelTrackingContainer>()
.OrderByDescending(s => s.Panel.Score.TotalScore)
.ThenBy(s => s.Panel.Score.OnlineScoreID);
protected override int Compare(Drawable x, Drawable y)
{
var tX = (ScorePanelTrackingContainer)x;
var tY = (ScorePanelTrackingContainer)y;
int result = tY.Panel.Score.TotalScore.CompareTo(tX.Panel.Score.TotalScore);
if (result != 0)
return result;
if (tX.Panel.Score.OnlineScoreID == null || tY.Panel.Score.OnlineScoreID == null)
return base.Compare(x, y);
return tX.Panel.Score.OnlineScoreID.Value.CompareTo(tY.Panel.Score.OnlineScoreID.Value);
}
}
private class Scroll : OsuScrollContainer

View File

@ -0,0 +1,50 @@
// 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 osu.Framework.Graphics.Containers;
namespace osu.Game.Screens.Ranking
{
/// <summary>
/// A <see cref="CompositeDrawable"/> which tracks the size of a <see cref="ScorePanel"/>, to which the <see cref="ScorePanel"/> can be added or removed.
/// </summary>
public class ScorePanelTrackingContainer : CompositeDrawable
{
/// <summary>
/// The <see cref="ScorePanel"/> that created this <see cref="ScorePanelTrackingContainer"/>.
/// </summary>
public readonly ScorePanel Panel;
internal ScorePanelTrackingContainer(ScorePanel panel)
{
Panel = panel;
Attach();
}
/// <summary>
/// Detaches the <see cref="ScorePanel"/> from this <see cref="ScorePanelTrackingContainer"/>, removing it as a child.
/// This <see cref="ScorePanelTrackingContainer"/> will continue tracking any size changes.
/// </summary>
/// <exception cref="InvalidOperationException">If the <see cref="ScorePanel"/> is already detached.</exception>
public void Detach()
{
if (InternalChildren.Count == 0)
throw new InvalidOperationException("Score panel container is not attached.");
RemoveInternal(Panel);
}
/// <summary>
/// Attaches the <see cref="ScorePanel"/> to this <see cref="ScorePanelTrackingContainer"/>, adding it as a child.
/// </summary>
/// <exception cref="InvalidOperationException">If the <see cref="ScorePanel"/> is already attached.</exception>
public void Attach()
{
if (InternalChildren.Count > 0)
throw new InvalidOperationException("Score panel container is already attached.");
AddInternal(Panel);
}
}
}

View File

@ -0,0 +1,173 @@
// 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.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
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;
namespace osu.Game.Screens.Ranking.Statistics
{
/// <summary>
/// A graph which displays the distribution of hit timing in a series of <see cref="HitEvent"/>s.
/// </summary>
public class HitEventTimingDistributionGraph : CompositeDrawable
{
/// <summary>
/// The number of bins on each side of the timing distribution.
/// </summary>
private const int timing_distribution_bins = 50;
/// <summary>
/// The total number of bins in the timing distribution, including bins on both sides and the centre bin at 0.
/// </summary>
private const int total_timing_distribution_bins = timing_distribution_bins * 2 + 1;
/// <summary>
/// The centre bin, with a timing distribution very close to/at 0.
/// </summary>
private const int timing_distribution_centre_bin_index = timing_distribution_bins;
/// <summary>
/// The number of data points shown on each side of the axis below the graph.
/// </summary>
private const float axis_points = 5;
private readonly IReadOnlyList<HitEvent> hitEvents;
/// <summary>
/// Creates a new <see cref="HitEventTimingDistributionGraph"/>.
/// </summary>
/// <param name="hitEvents">The <see cref="HitEvent"/>s to display the timing distribution of.</param>
public HitEventTimingDistributionGraph(IReadOnlyList<HitEvent> hitEvents)
{
this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows)).ToList();
}
[BackgroundDependencyLoader]
private void load()
{
if (hitEvents == null || hitEvents.Count == 0)
return;
int[] bins = new int[total_timing_distribution_bins];
double 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);
foreach (var e in hitEvents)
{
int binOffset = (int)(e.TimeOffset / binSize);
bins[timing_distribution_centre_bin_index + binOffset]++;
}
int maxCount = bins.Max();
var bars = new Drawable[total_timing_distribution_bins];
for (int i = 0; i < bars.Length; i++)
bars[i] = new Bar { Height = Math.Max(0.05f, (float)bins[i] / maxCount) };
Container axisFlow;
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[] { bars }
}
},
new Drawable[]
{
axisFlow = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}
},
},
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: 12, 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: 12, 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: 12, weight: FontWeight.SemiBold)
});
}
}
private class Bar : CompositeDrawable
{
public Bar()
{
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
RelativeSizeAxes = Axes.Both;
Padding = new MarginPadding { Horizontal = 1 };
InternalChild = new Circle
{
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex("#66FFCC")
};
}
}
}
}

View File

@ -0,0 +1,82 @@
// 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.Diagnostics.CodeAnalysis;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Screens.Ranking.Statistics
{
/// <summary>
/// Wraps a <see cref="StatisticItem"/> to add a header and suitable layout for use in <see cref="ResultsScreen"/>.
/// </summary>
internal class StatisticContainer : CompositeDrawable
{
/// <summary>
/// Creates a new <see cref="StatisticContainer"/>.
/// </summary>
/// <param name="item">The <see cref="StatisticItem"/> to display.</param>
public StatisticContainer([NotNull] StatisticItem item)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChild = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Content = new[]
{
new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5, 0),
Children = new Drawable[]
{
new Circle
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Height = 9,
Width = 4,
Colour = Color4Extensions.FromHex("#00FFAA")
},
new OsuSpriteText
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Text = item.Name,
Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold),
}
}
}
},
new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Top = 15 },
Child = item.Content
}
},
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
}
};
}
}
}

View File

@ -0,0 +1,43 @@
// 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 JetBrains.Annotations;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
namespace osu.Game.Screens.Ranking.Statistics
{
/// <summary>
/// An item to be displayed in a row of statistics inside the results screen.
/// </summary>
public class StatisticItem
{
/// <summary>
/// The name of this item.
/// </summary>
public readonly string Name;
/// <summary>
/// The <see cref="Drawable"/> content to be displayed.
/// </summary>
public readonly Drawable Content;
/// <summary>
/// The <see cref="Dimension"/> of this row. This can be thought of as the column dimension of an encompassing <see cref="GridContainer"/>.
/// </summary>
public readonly Dimension Dimension;
/// <summary>
/// Creates a new <see cref="StatisticItem"/>, to be displayed inside a <see cref="StatisticRow"/> in the results screen.
/// </summary>
/// <param name="name">The name of the item.</param>
/// <param name="content">The <see cref="Drawable"/> content to be displayed.</param>
/// <param name="dimension">The <see cref="Dimension"/> of this item. This can be thought of as the column dimension of an encompassing <see cref="GridContainer"/>.</param>
public StatisticItem([NotNull] string name, [NotNull] Drawable content, [CanBeNull] Dimension dimension = null)
{
Name = name;
Content = content;
Dimension = dimension;
}
}
}

View File

@ -0,0 +1,19 @@
// 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 JetBrains.Annotations;
namespace osu.Game.Screens.Ranking.Statistics
{
/// <summary>
/// A row of statistics to be displayed in the results screen.
/// </summary>
public class StatisticRow
{
/// <summary>
/// The columns of this <see cref="StatisticRow"/>.
/// </summary>
[ItemNotNull]
public StatisticItem[] Columns;
}
}

View File

@ -0,0 +1,150 @@
// 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Placeholders;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osuTK;
namespace osu.Game.Screens.Ranking.Statistics
{
public class StatisticsPanel : VisibilityContainer
{
public const float SIDE_PADDING = 30;
public readonly Bindable<ScoreInfo> Score = new Bindable<ScoreInfo>();
protected override bool StartHidden => true;
[Resolved]
private BeatmapManager beatmapManager { get; set; }
private readonly Container content;
private readonly LoadingSpinner spinner;
public StatisticsPanel()
{
InternalChild = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Left = ScorePanel.EXPANDED_WIDTH + SIDE_PADDING * 3,
Right = SIDE_PADDING,
Top = SIDE_PADDING,
Bottom = 50 // Approximate padding to the bottom of the score panel.
},
Children = new Drawable[]
{
content = new Container { RelativeSizeAxes = Axes.Both },
spinner = new LoadingSpinner()
}
};
}
[BackgroundDependencyLoader]
private void load()
{
Score.BindValueChanged(populateStatistics, true);
}
private CancellationTokenSource loadCancellation;
private void populateStatistics(ValueChangedEvent<ScoreInfo> score)
{
loadCancellation?.Cancel();
loadCancellation = null;
foreach (var child in content)
child.FadeOut(150).Expire();
var newScore = score.NewValue;
if (newScore == null)
return;
if (newScore.HitEvents == null || newScore.HitEvents.Count == 0)
content.Add(new MessagePlaceholder("Score has no statistics :("));
else
{
spinner.Show();
var localCancellationSource = loadCancellation = new CancellationTokenSource();
IBeatmap playableBeatmap = null;
// Todo: The placement of this is temporary. Eventually we'll both generate the playable beatmap _and_ run through it in a background task to generate the hit events.
Task.Run(() =>
{
playableBeatmap = beatmapManager.GetWorkingBeatmap(newScore.Beatmap).GetPlayableBeatmap(newScore.Ruleset, newScore.Mods ?? Array.Empty<Mod>());
}, loadCancellation.Token).ContinueWith(t => Schedule(() =>
{
var rows = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(30, 15),
};
foreach (var row in newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap))
{
rows.Add(new GridContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Content = new[]
{
row.Columns?.Select(c => new StatisticContainer(c)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
}).Cast<Drawable>().ToArray()
},
ColumnDimensions = Enumerable.Range(0, row.Columns?.Length ?? 0)
.Select(i => row.Columns[i].Dimension ?? new Dimension()).ToArray(),
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }
});
}
LoadComponentAsync(rows, d =>
{
if (Score.Value != newScore)
return;
spinner.Hide();
content.Add(d);
}, localCancellationSource.Token);
}), localCancellationSource.Token);
}
}
protected override bool OnClick(ClickEvent e)
{
ToggleVisibility();
return true;
}
protected override void PopIn() => this.FadeIn(150, Easing.OutQuint);
protected override void PopOut() => this.FadeOut(150, Easing.OutQuint);
protected override void Dispose(bool isDisposing)
{
loadCancellation?.Cancel();
base.Dispose(isDisposing);
}
}
}

View File

@ -26,7 +26,7 @@
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="ppy.osu.Framework" Version="2020.623.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2020.622.1" />
<PackageReference Include="Sentry" Version="2.1.3" />
<PackageReference Include="Sentry" Version="2.1.4" />
<PackageReference Include="SharpCompress" Version="0.25.1" />
<PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="4.7.0" />