diff --git a/UseLocalResources.ps1 b/UseLocalResources.ps1 new file mode 100644 index 0000000000..f9d9df01bb --- /dev/null +++ b/UseLocalResources.ps1 @@ -0,0 +1,12 @@ +$CSPROJ="osu.Game/osu.Game.csproj" +$SLN="osu.sln" + +dotnet remove $CSPROJ package ppy.osu.Game.Resources; +dotnet sln $SLN add ../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj +dotnet add $CSPROJ reference ../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj + +$SLNF=Get-Content "osu.Desktop.slnf" | ConvertFrom-Json +$TMP=New-TemporaryFile +$SLNF.solution.projects += ("../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj") +ConvertTo-Json $SLNF | Out-File $TMP -Encoding UTF8 +Move-Item -Path $TMP -Destination "osu.Desktop.slnf" -Force diff --git a/UseLocalResources.sh b/UseLocalResources.sh new file mode 100755 index 0000000000..6d9d2b6016 --- /dev/null +++ b/UseLocalResources.sh @@ -0,0 +1,11 @@ +CSPROJ="osu.Game/osu.Game.csproj" +SLN="osu.sln" + +dotnet remove $CSPROJ package ppy.osu.Game.Resources; +dotnet sln $SLN add ../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj +dotnet add $CSPROJ reference ../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj + +SLNF="osu.Desktop.slnf" +TMP=$(mktemp) +jq '.solution.projects += ["../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj"]' $SLNF > $TMP +mv -f $TMP $SLNF diff --git a/osu.Android.props b/osu.Android.props index 8237a570ff..5f3fb858ee 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 5fa4e24f5e..13ea46eadf 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -134,7 +134,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("player score matching expected bonus score", () => { // multipled by 2 to nullify the score multiplier. (autoplay mod selected) - double totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; + long totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; return totalScore == (int)(drawableSpinner.Result.RateAdjustedRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult; }); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index 94655f3cf7..c7e3516d62 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -248,6 +248,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components break; } + slider.Path.ExpectedDistance.Value = null; piece.ControlPoint.Type = type; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs index bea5d4f5d9..0a1aab9ef1 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs @@ -56,6 +56,8 @@ namespace osu.Game.Rulesets.Osu.Mods { switch (nested) { + //Freezing the SliderTicks doesnt play well with snaking sliders + case SliderTick: //SliderRepeat wont layer correctly if preempt is changed. case SliderRepeat: break; diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs index 46c8e7c02a..c19ed3fb35 100644 --- a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs +++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs @@ -49,7 +49,6 @@ namespace osu.Game.Rulesets.Osu.Skinning private const float max_rotation = 0.25f; public IShader? TextureShader { get; private set; } - public IShader? RoundedTextureShader { get; private set; } protected Texture? Texture { get; set; } @@ -69,7 +68,6 @@ namespace osu.Game.Rulesets.Osu.Skinning [BackgroundDependencyLoader] private void load(ShaderManager shaders) { - RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); } @@ -247,18 +245,16 @@ namespace osu.Game.Rulesets.Osu.Skinning texture ??= renderer.WhitePixel; RectangleF textureRect = texture.GetTextureRect(); - var shader = GetAppropriateShader(renderer); - renderer.SetBlend(BlendingParameters.Additive); renderer.PushLocalMatrix(DrawInfo.Matrix); - shader.Bind(); + TextureShader.Bind(); texture.Bind(); for (int i = 0; i < points.Count; i++) drawPointQuad(points[i], textureRect, i + firstVisiblePointIndex); - shader.Unbind(); + TextureShader.Unbind(); renderer.PopLocalMatrix(); } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs index ad6f08dbd4..a4aa0e1fad 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning var cont = new Container { RelativeSizeAxes = Axes.Both, - Height = 0.8f, + Height = 0.2f, Anchor = Anchor.Centre, Origin = Anchor.Centre, Children = new Drawable[] @@ -63,7 +63,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning var cont = new Container { RelativeSizeAxes = Axes.Both, - Height = 0.8f, + Height = 0.2f, Anchor = Anchor.Centre, Origin = Anchor.Centre, Children = new Drawable[] @@ -88,7 +88,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning var barLine = new BarLine { Major = major, - StartTime = Time.Current + 2000, + StartTime = Time.Current + 5000, }; var cpi = new ControlPointInfo(); diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs new file mode 100644 index 0000000000..b8c0f6f11e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . 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.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableSwell : TaikoSkinnableTestScene + { + [Test] + public void TestHits() + { + AddStep("Centre hit", () => SetContents(_ => new DrawableSwell(createHitAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + } + + private Swell createHitAtCurrentTime() + { + var hit = new Swell + { + StartTime = Time.Current + 3000, + EndTime = Time.Current + 6000, + }; + + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + return hit; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs index 52d24b567f..eff9f58751 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -25,11 +26,10 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning TimeRange = { Value = 5000 }, }; - public TestSceneTaikoPlayfield() + [SetUpSteps] + public void SetUpSteps() { TaikoBeatmap beatmap; - bool kiai = false; - AddStep("set beatmap", () => { Beatmap.Value = CreateWorkingBeatmap(beatmap = new TaikoBeatmap()); @@ -41,12 +41,28 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield { + Height = 0.2f, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Height = 0.6f, })); + } + [Test] + public void TestBasic() + { + AddStep("do nothing", () => { }); + } + + [Test] + public void TestHeightChanges() + { AddRepeatStep("change height", () => this.ChildrenOfType().ForEach(p => p.Height = Math.Max(0.2f, (p.Height + 0.2f) % 1f)), 50); + } + + [Test] + public void TestKiai() + { + bool kiai = false; AddStep("Toggle kiai", () => { diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index 4aed8212e8..b01adc880a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -74,7 +74,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables switch (state) { case ArmedState.Hit: - this.ScaleTo(0, 100, Easing.OutQuint); + this.ScaleTo(1.4f, 200, Easing.OutQuint); + this.FadeOut(200, Easing.OutQuint); break; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index c0c80eaa4a..400c2f40b1 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -133,6 +133,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override void OnApply() { base.OnApply(); + + // TODO: THIS CANNOT BE HERE, it makes pooling pointless (see https://github.com/ppy/osu/issues/21072). RecreatePieces(); } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonBarLine.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonBarLine.cs new file mode 100644 index 0000000000..402e88b64d --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonBarLine.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonBarLine : CompositeDrawable + { + private Container majorEdgeContainer = null!; + + private Bindable major = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + RelativeSizeAxes = Axes.Both; + + const float line_offset = 8; + var majorPieceSize = new Vector2(6, 20); + + InternalChildren = new Drawable[] + { + line = new Box + { + RelativeSizeAxes = Axes.Both, + EdgeSmoothness = new Vector2(0.5f, 0), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + majorEdgeContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new Circle + { + Name = "Top line", + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + Size = majorPieceSize, + Y = -line_offset, + }, + new Circle + { + Name = "Bottom line", + Anchor = Anchor.BottomCentre, + Origin = Anchor.TopCentre, + Size = majorPieceSize, + Y = line_offset, + }, + } + } + }; + + major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + major.BindValueChanged(updateMajor, true); + } + + private Box line = null!; + + private void updateMajor(ValueChangedEvent major) + { + line.Alpha = major.NewValue ? 1f : 0.5f; + line.Width = major.NewValue ? 1 : 0.5f; + majorEdgeContainer.Alpha = major.NewValue ? 1 : 0; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCentreCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCentreCirclePiece.cs new file mode 100644 index 0000000000..551a5af078 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCentreCirclePiece.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonCentreCirclePiece : ArgonCirclePiece + { + [BackgroundDependencyLoader] + private void load() + { + AccentColour = ColourInfo.GradientVertical( + new Color4(241, 0, 0, 255), + new Color4(167, 0, 0, 255) + ); + + AddInternal(new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.AngleLeft, + Size = new Vector2(ICON_SIZE), + Scale = new Vector2(0.8f, 1) + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs new file mode 100644 index 0000000000..c22c0e9e79 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public abstract class ArgonCirclePiece : BeatSyncedContainer + { + public const float ICON_SIZE = 20 / 70f; + + private const double pre_beat_transition_time = 80; + + private const float flash_opacity = 0.3f; + + private ColourInfo accentColour; + + /// + /// The colour of the inner circle and outer glows. + /// + public ColourInfo AccentColour + { + get => accentColour; + set + { + accentColour = value; + + ring.Colour = AccentColour.MultiplyAlpha(0.5f); + ring2.Colour = AccentColour; + } + } + + [Resolved] + private DrawableHitObject drawableHitObject { get; set; } = null!; + + private readonly Drawable flash; + + private readonly RingPiece ring; + private readonly RingPiece ring2; + + protected ArgonCirclePiece() + { + RelativeSizeAxes = Axes.Both; + + EarlyActivationMilliseconds = pre_beat_transition_time; + + AddRangeInternal(new[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 0, 0, 190) + }, + ring = new RingPiece(20 / 70f), + ring2 = new RingPiece(5 / 70f), + flash = new Circle + { + Name = "Flash layer", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + drawableHitObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(drawableHitObject, drawableHitObject.State.Value); + } + + private void updateStateTransforms(DrawableHitObject h, ArmedState state) + { + if (h.HitObject is not Hit) + return; + + switch (state) + { + case ArmedState.Hit: + using (BeginAbsoluteSequence(h.HitStateUpdateTime)) + { + flash.FadeTo(0.9f).FadeOut(500, Easing.OutQuint); + } + + break; + } + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!effectPoint.KiaiMode) + return; + + if (drawableHitObject.State.Value == ArmedState.Idle) + { + flash + .FadeTo(flash_opacity) + .Then() + .FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine); + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonElongatedCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonElongatedCirclePiece.cs new file mode 100644 index 0000000000..f86f181b2e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonElongatedCirclePiece.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonElongatedCirclePiece : ArgonCirclePiece + { + public ArgonElongatedCirclePiece() + { + RelativeSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + AccentColour = ColourInfo.GradientVertical( + new Color4(241, 161, 0, 255), + new Color4(167, 111, 0, 255) + ); + } + + protected override void Update() + { + base.Update(); + Width = Parent.DrawSize.X + DrawHeight; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs new file mode 100644 index 0000000000..05bb9bcb9a --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonHitExplosion : CompositeDrawable, IAnimatableHitExplosion + { + private readonly TaikoSkinComponents component; + private readonly Circle outer; + + public ArgonHitExplosion(TaikoSkinComponents component) + { + this.component = component; + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + outer = new Circle + { + Name = "Outer circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical( + new Color4(255, 227, 236, 255), + new Color4(255, 198, 211, 255) + ), + Masking = true, + }, + new Circle + { + Name = "Inner circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Size = new Vector2(0.85f), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = new Color4(255, 132, 191, 255).Opacity(0.5f), + Radius = 45, + }, + Masking = true, + }, + }; + } + + public void Animate(DrawableHitObject drawableHitObject) + { + this.FadeOut(); + + switch (component) + { + case TaikoSkinComponents.TaikoExplosionGreat: + this.FadeIn(30, Easing.In) + .Then() + .FadeOut(450, Easing.OutQuint); + break; + + case TaikoSkinComponents.TaikoExplosionOk: + this.FadeTo(0.2f, 30, Easing.In) + .Then() + .FadeOut(200, Easing.OutQuint); + break; + } + } + + public void AnimateSecondHit() + { + outer.ResizeTo(new Vector2(TaikoStrongableHitObject.STRONG_SCALE), 500, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitTarget.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitTarget.cs new file mode 100644 index 0000000000..ec2eccd595 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitTarget.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Taiko.Objects; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonHitTarget : CompositeDrawable + { + /// + /// Thickness of all drawn line pieces. + /// + public ArgonHitTarget() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + + const float border_thickness = 4f; + + InternalChildren = new Drawable[] + { + new Circle + { + Name = "Bar Upper", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Y = -border_thickness, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(border_thickness, (1 - TaikoStrongableHitObject.DEFAULT_STRONG_SIZE)), + }, + new Circle + { + Name = "Outer circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Blending = BlendingParameters.Additive, + Alpha = 0.1f, + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE), + Masking = true, + }, + new Circle + { + Name = "Inner circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Blending = BlendingParameters.Additive, + Alpha = 0.1f, + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE * 0.85f), + Masking = true, + }, + new Circle + { + Name = "Bar Lower", + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Y, + Y = border_thickness, + Size = new Vector2(border_thickness, (1 - TaikoStrongableHitObject.DEFAULT_STRONG_SIZE)), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs new file mode 100644 index 0000000000..528e75aabb --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs @@ -0,0 +1,218 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Screens.Ranking; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonInputDrum : AspectContainer + { + private const float rim_size = 0.3f; + + public ArgonInputDrum() + { + RelativeSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + const float middle_split = 6; + + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.9f), + Children = new Drawable[] + { + new TaikoHalfDrum(false) + { + Name = "Left Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + RimAction = TaikoAction.LeftRim, + CentreAction = TaikoAction.LeftCentre + }, + new TaikoHalfDrum(true) + { + Name = "Right Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + RimAction = TaikoAction.RightRim, + CentreAction = TaikoAction.RightCentre + }, + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = OsuColour.Gray(38 / 255f), + Width = middle_split, + RelativeSizeAxes = Axes.Y, + }, + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = OsuColour.Gray(48 / 255f), + Width = middle_split, + Height = 1 - rim_size, + RelativeSizeAxes = Axes.Y, + }, + }, + } + } + }; + } + + /// + /// A half-drum. Contains one centre and one rim hit. + /// + private class TaikoHalfDrum : CompositeDrawable, IKeyBindingHandler + { + /// + /// The key to be used for the rim of the half-drum. + /// + public TaikoAction RimAction; + + /// + /// The key to be used for the centre of the half-drum. + /// + public TaikoAction CentreAction; + + private readonly Drawable rimHit; + private readonly Drawable centreHit; + + public TaikoHalfDrum(bool flipped) + { + Anchor anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight; + + Masking = true; + + Anchor = anchor; + Origin = anchor; + + RelativeSizeAxes = Axes.Both; + // Extend maskable region for glow. + Height = 2f; + + InternalChildren = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Children = new[] + { + new Circle + { + Anchor = anchor, + Colour = OsuColour.Gray(51 / 255f), + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + }, + rimHit = new Circle + { + Anchor = anchor, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal( + new Color4(227, 248, 255, 255), + new Color4(198, 245, 255, 255) + ), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = new Color4(126, 215, 253, 170), + Radius = 50, + }, + Alpha = 0, + }, + new Circle + { + Anchor = anchor, + Origin = Anchor.Centre, + Colour = OsuColour.Gray(64 / 255f), + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1 - rim_size) + }, + centreHit = new Circle + { + Anchor = anchor, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal( + new Color4(255, 227, 236, 255), + new Color4(255, 198, 211, 255) + ), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = new Color4(255, 147, 199, 255), + Radius = 50, + }, + Size = new Vector2(1 - rim_size), + Alpha = 0, + } + }, + }, + }; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + Drawable? target = null; + + if (e.Action == CentreAction) + target = centreHit; + else if (e.Action == RimAction) + target = rimHit; + + if (target != null) + { + const float alpha_amount = 0.5f; + + const float down_time = 40; + const float up_time = 750; + + target.Animate( + t => t.FadeTo(Math.Min(target.Alpha + alpha_amount, 1), down_time, Easing.OutQuint) + ).Then( + t => t.FadeOut(up_time, Easing.OutQuint) + ); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonJudgementPiece.cs new file mode 100644 index 0000000000..baaf9e41e2 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonJudgementPiece.cs @@ -0,0 +1,198 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonJudgementPiece : CompositeDrawable, IAnimatableJudgement + { + protected readonly HitResult Result; + + protected SpriteText JudgementText { get; private set; } = null!; + + private RingExplosion? ringExplosion; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonJudgementPiece(HitResult result) + { + Result = result; + RelativePositionAxes = Axes.Both; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + JudgementText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = Result.GetDescription().ToUpperInvariant(), + Colour = colours.ForHitResult(Result), + Blending = BlendingParameters.Additive, + Spacing = new Vector2(10, 0), + RelativePositionAxes = Axes.Both, + Font = OsuFont.Default.With(size: 20, weight: FontWeight.Regular), + }, + }; + + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = colours.ForHitResult(Result), + RelativePositionAxes = Axes.Y, + }); + } + } + + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// + public virtual void PlayAnimation() + { + const double duration = 800; + + switch (Result) + { + default: + JudgementText.MoveToY(-0.6f) + .MoveToY(-1.0f, duration, Easing.OutQuint); + + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.4f), duration, Easing.OutQuint); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + JudgementText.MoveTo(Vector2.Zero); + JudgementText.MoveToOffset(new Vector2(0, 100), duration, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, duration, Easing.InQuint); + break; + } + + this.FadeOutFromOne(duration, Easing.OutQuint); + + ringExplosion?.PlayAnimation(); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => null; + + private class RingExplosion : CompositeDrawable + { + private readonly float travel = 58; + + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 9; + const float large_size = 14; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 3; + travel *= 0.3f; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 4; + travel *= 0.6f; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 4; + countLarge = 4; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation() + { + foreach (var c in InternalChildren) + { + const float start_position_ratio = 0.6f; + + float direction = RNG.NextSingle(0, 360); + float distance = RNG.NextSingle(travel / 2, travel); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance * start_position_ratio, + MathF.Sin(direction) * distance * start_position_ratio + )); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance, + MathF.Sin(direction) * distance + ), 600, Easing.OutQuint); + } + + this.FadeOutFromOne(1000, Easing.OutQuint); + } + + public class RingPiece : CircularContainer + { + public RingPiece(float thickness = 9) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderThickness = thickness; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundLeft.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundLeft.cs new file mode 100644 index 0000000000..ebde83b607 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundLeft.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonPlayfieldBackgroundLeft : CompositeDrawable + { + public ArgonPlayfieldBackgroundLeft() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundRight.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundRight.cs new file mode 100644 index 0000000000..bd0f3ab276 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundRight.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonPlayfieldBackgroundRight : CompositeDrawable + { + public ArgonPlayfieldBackgroundRight() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.Black, + Alpha = 0.7f, + RelativeSizeAxes = Axes.Both, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonRimCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonRimCirclePiece.cs new file mode 100644 index 0000000000..fd81221be3 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonRimCirclePiece.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonRimCirclePiece : ArgonCirclePiece + { + [BackgroundDependencyLoader] + private void load() + { + AccentColour = ColourInfo.GradientVertical( + new Color4(0, 161, 241, 255), + new Color4(0, 111, 167, 255) + ); + + AddInternal(new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.AngleLeft, + Size = new Vector2(ICON_SIZE), + Scale = new Vector2(0.8f, 1) + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwellCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwellCirclePiece.cs new file mode 100644 index 0000000000..82a6e34128 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwellCirclePiece.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonSwellCirclePiece : ArgonCirclePiece + { + [BackgroundDependencyLoader] + private void load() + { + AccentColour = ColourInfo.GradientVertical( + new Color4(240, 201, 0, 255), + new Color4(167, 139, 0, 255) + ); + + AddInternal(new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.Asterisk, + Size = new Vector2(ICON_SIZE), + Scale = new Vector2(0.8f, 1) + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonTickPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonTickPiece.cs new file mode 100644 index 0000000000..df63d4948e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonTickPiece.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class ArgonTickPiece : CompositeDrawable + { + private readonly Bindable isFirstTick = new Bindable(); + + public ArgonTickPiece() + { + const float tick_size = 1 / TaikoHitObject.DEFAULT_SIZE * ArgonCirclePiece.ICON_SIZE; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fit; + Size = new Vector2(tick_size); + } + + [Resolved] + private DrawableHitObject drawableHitObject { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (drawableHitObject is DrawableDrumRollTick drumRollTick) + isFirstTick.BindTo(drumRollTick.IsFirstTick); + + isFirstTick.BindValueChanged(first => + { + if (first.NewValue) + { + InternalChild = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + }; + } + else + { + InternalChild = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.AngleLeft, + Scale = new Vector2(0.8f, 1) + }; + } + }, true); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/RingPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/RingPiece.cs new file mode 100644 index 0000000000..534a1c71a3 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/RingPiece.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class RingPiece : CircularContainer + { + private readonly float relativeBorderThickness; + + public RingPiece(float relativeBorderThickness) + { + this.relativeBorderThickness = relativeBorderThickness; + RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + + protected override void Update() + { + base.Update(); + BorderThickness = relativeBorderThickness * DrawSize.Y; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs new file mode 100644 index 0000000000..a5d091a1c8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class TaikoArgonSkinTransformer : SkinTransformer + { + public TaikoArgonSkinTransformer(ISkin skin) + : base(skin) + { + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup component) + { + switch (component) + { + case GameplaySkinComponentLookup resultComponent: + return new ArgonJudgementPiece(resultComponent.Component); + + case TaikoSkinComponentLookup taikoComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. + switch (taikoComponent.Component) + { + case TaikoSkinComponents.CentreHit: + return new ArgonCentreCirclePiece(); + + case TaikoSkinComponents.RimHit: + return new ArgonRimCirclePiece(); + + case TaikoSkinComponents.PlayfieldBackgroundLeft: + return new ArgonPlayfieldBackgroundLeft(); + + case TaikoSkinComponents.PlayfieldBackgroundRight: + return new ArgonPlayfieldBackgroundRight(); + + case TaikoSkinComponents.InputDrum: + return new ArgonInputDrum(); + + case TaikoSkinComponents.HitTarget: + return new ArgonHitTarget(); + + case TaikoSkinComponents.BarLine: + return new ArgonBarLine(); + + case TaikoSkinComponents.DrumRollBody: + return new ArgonElongatedCirclePiece(); + + case TaikoSkinComponents.DrumRollTick: + return new ArgonTickPiece(); + + case TaikoSkinComponents.TaikoExplosionKiai: + // the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield. + return Drawable.Empty().With(d => d.Expire()); + + case TaikoSkinComponents.TaikoExplosionGreat: + case TaikoSkinComponents.TaikoExplosionMiss: + case TaikoSkinComponents.TaikoExplosionOk: + return new ArgonHitExplosion(taikoComponent.Component); + + case TaikoSkinComponents.Swell: + return new ArgonSwellCirclePiece(); + } + + break; + } + + return base.GetDrawableComponent(component); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs index ccde8e6ac9..7ddc413d98 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs @@ -153,12 +153,15 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default updateStateTransforms(drawableHitObject, drawableHitObject.State.Value); } - private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + private void updateStateTransforms(DrawableHitObject h, ArmedState state) { + if (h.HitObject is not Hit) + return; + switch (state) { case ArmedState.Hit: - using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime)) + using (BeginAbsoluteSequence(h.HitStateUpdateTime)) flashBox.FadeTo(0.9f).FadeOut(300); break; } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultHitExplosion.cs index b7ba76effa..2e76396a4d 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultHitExplosion.cs @@ -10,6 +10,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; +using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Default @@ -74,6 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default public void AnimateSecondHit() { + this.ResizeTo(new Vector2(TaikoStrongableHitObject.STRONG_SCALE), 50); } } } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index dc36bc0320..fe12cf9765 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -24,6 +24,7 @@ using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Rulesets.Taiko.Skinning.Argon; using osu.Game.Rulesets.Taiko.Skinning.Legacy; using osu.Game.Rulesets.Taiko.UI; using osu.Game.Rulesets.UI; @@ -47,6 +48,9 @@ namespace osu.Game.Rulesets.Taiko { switch (skin) { + case ArgonSkin: + return new TaikoArgonSkinTransformer(skin); + case LegacySkin: return new TaikoLegacySkinTransformer(skin); } diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs index d2b5811b56..8f2b702472 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs @@ -90,7 +90,6 @@ namespace osu.Game.Rulesets.Taiko.UI { using (BeginAbsoluteSequence(secondHitTime.Value)) { - this.ResizeTo(new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE), 50); (skinnable.Drawable as IAnimatableHitExplosion)?.AnimateSecondHit(); } } diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs index e7eb06c795..c9a78cbf59 100644 --- a/osu.Game.Tests/Chat/TestSceneChannelManager.cs +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.cs @@ -23,6 +23,7 @@ namespace osu.Game.Tests.Chat private ChannelManager channelManager; private int currentMessageId; private List sentMessages; + private List silencedUserIds; [SetUp] public void Setup() => Schedule(() => @@ -39,6 +40,7 @@ namespace osu.Game.Tests.Chat { currentMessageId = 0; sentMessages = new List(); + silencedUserIds = new List(); ((DummyAPIAccess)API).HandleRequest = req => { @@ -55,6 +57,19 @@ namespace osu.Game.Tests.Chat case MarkChannelAsReadRequest markRead: handleMarkChannelAsReadRequest(markRead); return true; + + case ChatAckRequest ack: + ack.TriggerSuccess(new ChatAckResponse { Silences = silencedUserIds.Select(u => new ChatSilence { UserId = u }).ToList() }); + silencedUserIds.Clear(); + return true; + + case GetUpdatesRequest updatesRequest: + updatesRequest.TriggerSuccess(new GetUpdatesResponse + { + Messages = sentMessages.ToList(), + Presence = new List() + }); + return true; } return false; @@ -95,6 +110,7 @@ namespace osu.Game.Tests.Chat }); AddStep("post message", () => channelManager.PostMessage("Something interesting")); + AddUntilStep("message postesd", () => !channel.Messages.Any(m => m is LocalMessage)); AddStep("post /help command", () => channelManager.PostCommand("help", channel)); AddStep("post /me command with no action", () => channelManager.PostCommand("me", channel)); @@ -106,6 +122,28 @@ namespace osu.Game.Tests.Chat AddAssert("channel's last read ID is set to the latest message", () => channel.LastReadId == sentMessages.Last().Id); } + [Test] + public void TestSilencedUsersAreRemoved() + { + Channel channel = null; + + AddStep("join channel and select it", () => + { + channelManager.JoinChannel(channel = createChannel(1, ChannelType.Public)); + channelManager.CurrentChannel.Value = channel; + }); + + AddStep("post message", () => channelManager.PostMessage("Definitely something bad")); + + AddStep("mark user as silenced and send ack request", () => + { + silencedUserIds.Add(API.LocalUser.Value.OnlineID); + channelManager.SendAck(); + }); + + AddAssert("channel has no more messages", () => channel.Messages, () => Is.Empty); + } + private void handlePostMessageRequest(PostMessageRequest request) { var message = new Message(++currentMessageId) @@ -115,7 +153,8 @@ namespace osu.Game.Tests.Chat Content = request.Message.Content, Links = request.Message.Links, Timestamp = request.Message.Timestamp, - Sender = request.Message.Sender + Sender = request.Message.Sender, + Uuid = request.Message.Uuid }; sentMessages.Add(message); diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs index 1df3d336ee..e7fdb52d2f 100644 --- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs +++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs @@ -65,7 +65,7 @@ namespace osu.Game.Tests.Database private class TestLegacyBeatmapImporter : LegacyBeatmapImporter { public TestLegacyBeatmapImporter() - : base(null) + : base(null!) { } diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs index fb9d841d99..d5219f6391 100644 --- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs @@ -35,7 +35,7 @@ namespace osu.Game.Tests.Gameplay // Apply a miss judgement scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new TestJudgement()) { Type = HitResult.Miss }); - Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(0.0)); + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(0)); } [Test] diff --git a/osu.Game.Tests/Visual/Background/TestSceneTrianglesBackground.cs b/osu.Game.Tests/Visual/Background/TestSceneTrianglesBackground.cs new file mode 100644 index 0000000000..81a3249efb --- /dev/null +++ b/osu.Game.Tests/Visual/Background/TestSceneTrianglesBackground.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Graphics.Backgrounds; +using osu.Framework.Graphics; +using osuTK.Graphics; +using osu.Framework.Graphics.Shapes; + +namespace osu.Game.Tests.Visual.Background +{ + public class TestSceneTrianglesBackground : OsuTestScene + { + private readonly Triangles triangles; + + public TestSceneTrianglesBackground() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black + }, + triangles = new Triangles + { + RelativeSizeAxes = Axes.Both, + ColourLight = Color4.White, + ColourDark = Color4.Gray + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("Triangle scale", 0f, 10f, 1f, s => triangles.TriangleScale = s); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index 981967e413..ee6c322ee3 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -178,7 +178,7 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestSharedClockState() + public void TestClockTimeTransferIsOneDirectional() { AddStep("seek to 00:01:00", () => EditorClock.Seek(60_000)); AddStep("click test gameplay button", () => @@ -195,15 +195,15 @@ namespace osu.Game.Tests.Visual.Editing GameplayClockContainer gameplayClockContainer = null; AddStep("fetch gameplay clock", () => gameplayClockContainer = editorPlayer.ChildrenOfType().First()); AddUntilStep("gameplay clock running", () => gameplayClockContainer.IsRunning); + // when the gameplay test is entered, the clock is expected to continue from where it was in the main editor... AddAssert("gameplay time past 00:01:00", () => gameplayClockContainer.CurrentTime >= 60_000); - double timeAtPlayerExit = 0; AddWaitStep("wait some", 5); - AddStep("store time before exit", () => timeAtPlayerExit = gameplayClockContainer.CurrentTime); AddStep("exit player", () => editorPlayer.Exit()); AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); - AddAssert("time is past player exit", () => EditorClock.CurrentTime >= timeAtPlayerExit); + // but when exiting from gameplay test back to editor, the expectation is that the editor time should revert to what it was at the point of initiating the gameplay test. + AddAssert("time reverted to 00:01:00", () => EditorClock.CurrentTime, () => Is.EqualTo(60_000)); } public override void TearDownSteps() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index 171ae829a9..30d2cc6423 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual.Gameplay { private TestGameplayLeaderboard leaderboard; - private readonly BindableDouble playerScore = new BindableDouble(); + private readonly BindableLong playerScore = new BindableLong(); public TestSceneGameplayLeaderboard() { @@ -76,8 +76,8 @@ namespace osu.Game.Tests.Visual.Gameplay createLeaderboard(); addLocalPlayer(); - var player2Score = new BindableDouble(1234567); - var player3Score = new BindableDouble(1111111); + var player2Score = new BindableLong(1234567); + var player3Score = new BindableLong(1111111); AddStep("add player 2", () => createLeaderboardScore(player2Score, new APIUser { Username = "Player 2" })); AddStep("add player 3", () => createLeaderboardScore(player3Score, new APIUser { Username = "Player 3" })); @@ -161,9 +161,9 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private void createRandomScore(APIUser user) => createLeaderboardScore(new BindableDouble(RNG.Next(0, 5_000_000)), user); + private void createRandomScore(APIUser user) => createLeaderboardScore(new BindableLong(RNG.Next(0, 5_000_000)), user); - private void createLeaderboardScore(BindableDouble score, APIUser user, bool isTracked = false) + private void createLeaderboardScore(BindableLong score, APIUser user, bool isTracked = false) { var leaderboardScore = leaderboard.Add(user, isTracked); leaderboardScore.TotalScore.BindTo(score); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 7c668adba5..b90b9b437d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -163,10 +163,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for bars to disappear", () => !this.ChildrenOfType().Any()); AddUntilStep("ensure max circles not exceeded", () => - { - return this.ChildrenOfType() - .All(m => m.ChildrenOfType().Count() <= max_displayed_judgements); - }); + this.ChildrenOfType().First().ChildrenOfType().Count(), () => Is.LessThanOrEqualTo(max_displayed_judgements)); AddStep("show displays", () => { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs index 60ed0012ae..881870921c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.Gameplay { @@ -26,6 +27,7 @@ namespace osu.Game.Tests.Visual.Gameplay private readonly BindableList scores = new BindableList(); private readonly Bindable configVisibility = new Bindable(); + private readonly Bindable beatmapTabType = new Bindable(); private SoloGameplayLeaderboard leaderboard = null!; @@ -33,6 +35,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void load(OsuConfigManager config) { config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); + config.BindWith(OsuSetting.BeatmapDetailTab, beatmapTabType); } [SetUpSteps] @@ -70,6 +73,25 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value); } + [TestCase(PlayBeatmapDetailArea.TabType.Local, 51)] + [TestCase(PlayBeatmapDetailArea.TabType.Global, null)] + [TestCase(PlayBeatmapDetailArea.TabType.Country, null)] + [TestCase(PlayBeatmapDetailArea.TabType.Friends, null)] + public void TestTrackedScorePosition(PlayBeatmapDetailArea.TabType tabType, int? expectedOverflowIndex) + { + AddStep($"change TabType to {tabType}", () => beatmapTabType.Value = tabType); + AddUntilStep("tracked player is #50", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(50)); + + AddStep("add one more score", () => scores.Add(new ScoreInfo { User = new APIUser { Username = "New player 1" }, TotalScore = RNG.Next(600000, 1000000) })); + + AddUntilStep("wait for sort", () => leaderboard.ChildrenOfType().First().ScorePosition != null); + + if (expectedOverflowIndex == null) + AddUntilStep("tracked player has null position", () => leaderboard.TrackedScore?.ScorePosition, () => Is.Null); + else + AddUntilStep($"tracked player is #{expectedOverflowIndex}", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(expectedOverflowIndex)); + } + [Test] public void TestVisibility() { @@ -95,7 +117,7 @@ namespace osu.Game.Tests.Visual.Gameplay new ScoreInfo { User = new APIUser { Username = @"spaceman_atlas" }, TotalScore = RNG.Next(500000, 1000000) }, new ScoreInfo { User = new APIUser { Username = @"frenzibyte" }, TotalScore = RNG.Next(500000, 1000000) }, new ScoreInfo { User = new APIUser { Username = @"Susko3" }, TotalScore = RNG.Next(500000, 1000000) }, - }.Concat(Enumerable.Range(0, 50).Select(i => new ScoreInfo { User = new APIUser { Username = $"User {i + 1}" }, TotalScore = 1000000 - i * 10000 })).ToList(); + }.Concat(Enumerable.Range(0, 44).Select(i => new ScoreInfo { User = new APIUser { Username = $"User {i + 1}" }, TotalScore = 1000000 - i * 10000 })).ToList(); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs index 5bd879de14..c3d7bde68f 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs @@ -56,6 +56,7 @@ namespace osu.Game.Tests.Visual.Navigation PushAndConfirm(() => new PlaySongSelect()); AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); + AddUntilStep("wait for carousel load", () => songSelect.BeatmapSetsLoaded); AddStep("enter gameplay", () => InputManager.Key(Key.Enter)); @@ -92,6 +93,8 @@ namespace osu.Game.Tests.Visual.Navigation .AsEnumerable() .First(k => k.RulesetName == "osu" && k.ActionInt == 0); + private Screens.Select.SongSelect songSelect => Game.ScreenStack.CurrentScreen as Screens.Select.SongSelect; + private Player player => Game.ScreenStack.CurrentScreen as Player; private KeyCounter keyCounter => player.ChildrenOfType().First(); diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index d1b1ce5c4b..ce0543875b 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -4,6 +4,7 @@ #nullable disable using System.Linq; +using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -85,6 +86,19 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("did perform", () => actionPerformed); } + [Test] + public void TestPerformEnsuresScreenIsLoaded() + { + TestLoadBlockingScreen screen = null; + + AddStep("push blocking screen", () => Game.ScreenStack.Push(screen = new TestLoadBlockingScreen())); + AddStep("perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestLoadBlockingScreen) })); + AddAssert("action not performed", () => !actionPerformed); + + AddStep("allow load", () => screen.LoadEvent.Set()); + AddUntilStep("action performed", () => actionPerformed); + } + [Test] public void TestOverlaysAlwaysClosed() { @@ -270,5 +284,16 @@ namespace osu.Game.Tests.Visual.Navigation return base.OnExiting(e); } } + + public class TestLoadBlockingScreen : OsuScreen + { + public readonly ManualResetEventSlim LoadEvent = new ManualResetEventSlim(); + + [BackgroundDependencyLoader] + private void load() + { + LoadEvent.Wait(10000); + } + } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs index 6469962b08..02b348b439 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -7,12 +7,14 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Menu; +using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.Navigation { @@ -55,6 +57,7 @@ namespace osu.Game.Tests.Visual.Navigation presentAndConfirm(firstImport); var secondImport = importBeatmap(3); + confirmBeatmapInSongSelect(secondImport); presentAndConfirm(secondImport); // Test presenting same beatmap more than once @@ -74,6 +77,7 @@ namespace osu.Game.Tests.Visual.Navigation presentAndConfirm(firstImport); var secondImport = importBeatmap(3, new ManiaRuleset().RulesetInfo); + confirmBeatmapInSongSelect(secondImport); presentAndConfirm(secondImport); presentSecondDifficultyAndConfirm(firstImport, 1); @@ -134,13 +138,22 @@ namespace osu.Game.Tests.Visual.Navigation return () => imported; } + private void confirmBeatmapInSongSelect(Func getImport) + { + AddUntilStep("beatmap in song select", () => + { + var songSelect = (Screens.Select.SongSelect)Game.ScreenStack.CurrentScreen; + return songSelect.ChildrenOfType().Single().BeatmapSets.Any(b => b.MatchesOnlineID(getImport())); + }); + } + private void presentAndConfirm(Func getImport) { AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); - AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapSetInfo.MatchesOnlineID(getImport())); - AddAssert("correct ruleset selected", () => Game.Ruleset.Value.Equals(getImport().Beatmaps.First().Ruleset)); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.IsLoaded); + AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineID, () => Is.EqualTo(getImport().OnlineID)); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value, () => Is.EqualTo(getImport().Beatmaps.First().Ruleset)); } private void presentSecondDifficultyAndConfirm(Func getImport, int importedID) @@ -148,9 +161,9 @@ namespace osu.Game.Tests.Visual.Navigation Predicate pred = b => b.OnlineID == importedID * 2048; AddStep("present difficulty", () => Game.PresentBeatmap(getImport(), pred)); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); - AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID == importedID * 2048); - AddAssert("correct ruleset selected", () => Game.Ruleset.Value.Equals(getImport().Beatmaps.First().Ruleset)); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.IsLoaded); + AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(importedID * 2048)); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value, () => Is.EqualTo(getImport().Beatmaps.First().Ruleset)); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index d58887c090..e500efede3 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -436,6 +436,8 @@ namespace osu.Game.Tests.Visual.Navigation { AddUntilStep("Wait for toolbar to load", () => Game.Toolbar.IsLoaded); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + TestPlaySongSelect songSelect = null; PushAndConfirm(() => songSelect = new TestPlaySongSelect()); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index a537f0660c..de44986001 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -46,6 +46,8 @@ namespace osu.Game.Tests.Visual.Online availableChannels.Add(new Channel { Name = "#english" }); availableChannels.Add(new Channel { Name = "#japanese" }); Dependencies.Cache(chatManager); + + Add(chatManager); } [SetUp] diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 0b982a5745..0b75a2aa05 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -40,8 +40,10 @@ namespace osu.Game.Tests.Visual.Online private ChannelManager channelManager; private readonly APIUser testUser = new APIUser { Username = "test user", Id = 5071479 }; + private readonly APIUser testUser1 = new APIUser { Username = "test user", Id = 5071480 }; private Channel[] testChannels; + private Message[] initialMessages; private Channel testChannel1 => testChannels[0]; private Channel testChannel2 => testChannels[1]; @@ -49,10 +51,14 @@ namespace osu.Game.Tests.Visual.Online [Resolved] private OsuConfigManager config { get; set; } = null!; + private int currentMessageId; + [SetUp] public void SetUp() => Schedule(() => { + currentMessageId = 0; testChannels = Enumerable.Range(1, 10).Select(createPublicChannel).ToArray(); + initialMessages = testChannels.SelectMany(createChannelMessages).ToArray(); Child = new DependencyProvidingContainer { @@ -99,7 +105,7 @@ namespace osu.Game.Tests.Visual.Online return true; case GetMessagesRequest getMessages: - getMessages.TriggerSuccess(createChannelMessages(getMessages.Channel)); + getMessages.TriggerSuccess(initialMessages.ToList()); return true; case GetUserRequest getUser: @@ -495,6 +501,35 @@ namespace osu.Game.Tests.Visual.Online waitForChannel1Visible(); } + [Test] + public void TestRemoveMessages() + { + AddStep("Show overlay with channel", () => + { + chatOverlay.Show(); + channelManager.CurrentChannel.Value = channelManager.JoinChannel(testChannel1); + }); + + AddAssert("Overlay is visible", () => chatOverlay.State.Value == Visibility.Visible); + waitForChannel1Visible(); + + AddStep("Send message from another user", () => + { + testChannel1.AddNewMessages(new Message + { + ChannelId = testChannel1.Id, + Content = "Message from another user", + Timestamp = DateTimeOffset.Now, + Sender = testUser1, + }); + }); + + AddStep("Remove messages from other user", () => + { + testChannel1.RemoveMessagesFromUser(testUser.Id); + }); + } + private void joinTestChannel(int i) { AddStep($"Join test channel {i}", () => channelManager.JoinChannel(testChannels[i])); @@ -546,7 +581,7 @@ namespace osu.Game.Tests.Visual.Online private List createChannelMessages(Channel channel) { - var message = new Message + var message = new Message(currentMessageId++) { ChannelId = channel.Id, Content = $"Hello, this is a message in {channel.Name}", diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index 292facab11..34ecad7dc1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -56,7 +56,9 @@ namespace osu.Game.Tests.Visual.Online protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - Add(channelManager = new ChannelManager(parent.Get())); + var api = parent.Get(); + + Add(channelManager = new ChannelManager(api)); var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index d7d073e908..bd54591b9b 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -40,6 +40,43 @@ namespace osu.Game.Tests.Visual.Settings AddWaitStep("wait for scroll", 5); } + [Test] + public void TestBindingTwoNonModifiers() + { + AddStep("press j", () => InputManager.PressKey(Key.J)); + scrollToAndStartBinding("Increase volume"); + AddStep("press k", () => InputManager.Key(Key.K)); + AddStep("release j", () => InputManager.ReleaseKey(Key.J)); + checkBinding("Increase volume", "K"); + } + + [Test] + public void TestBindingSingleKey() + { + scrollToAndStartBinding("Increase volume"); + AddStep("press k", () => InputManager.Key(Key.K)); + checkBinding("Increase volume", "K"); + } + + [Test] + public void TestBindingSingleModifier() + { + scrollToAndStartBinding("Increase volume"); + AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + checkBinding("Increase volume", "LShift"); + } + + [Test] + public void TestBindingSingleKeyWithModifier() + { + scrollToAndStartBinding("Increase volume"); + AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("press k", () => InputManager.Key(Key.K)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + checkBinding("Increase volume", "LShift-K"); + } + [Test] public void TestBindingMouseWheelToNonGameplay() { @@ -169,7 +206,8 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); - AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); + AddAssert("binding cleared", + () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); } [Test] @@ -198,7 +236,8 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); - AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); + AddAssert("binding cleared", + () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); } [Test] @@ -256,8 +295,8 @@ namespace osu.Game.Tests.Visual.Settings var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == name)); var firstButton = firstRow.ChildrenOfType().First(); - return firstButton.Text.Text == keyName; - }); + return firstButton.Text.Text.ToString(); + }, () => Is.EqualTo(keyName)); } private void scrollToAndStartBinding(string name) diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 63532fdba8..b6b9e8926b 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -1055,6 +1055,18 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("mod overlay hidden", () => songSelect!.ModSelect.State.Value == Visibility.Hidden); } + [Test] + public void TestBeatmapOptionsDisabled() + { + createSongSelect(); + + addRulesetImportStep(0); + + AddAssert("options enabled", () => songSelect.ChildrenOfType().Single().Enabled.Value); + AddStep("delete all beatmaps", () => manager.Delete()); + AddAssert("options disabled", () => !songSelect.ChildrenOfType().Single().Enabled.Value); + } + private void waitForInitialSelection() { AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs index cb78fbfe35..0a88abface 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs @@ -3,8 +3,10 @@ #nullable disable +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Screens.Select; using osuTK; using osuTK.Input; @@ -43,6 +45,12 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.MoveMouseTo(Vector2.Zero); }); + [Test] + public void TestState() + { + AddRepeatStep("toggle options state", () => this.ChildrenOfType().Last().Enabled.Toggle(), 20); + } + [Test] public void TestFooterRandom() { diff --git a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs index 6a8e4aa951..ca2b400e8b 100644 --- a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs @@ -48,7 +48,7 @@ namespace osu.Game.Tournament.Components if (manager == null) { - AddInternal(manager = new ChannelManager(api) { HighPollRate = { Value = true } }); + AddInternal(manager = new ChannelManager(api)); Channel.BindTo(manager.CurrentChannel); } diff --git a/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs index a80a7998a5..c0ed6ac1a9 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs @@ -59,6 +59,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards return base.OnScroll(e); } + protected override bool OnClick(ClickEvent e) => true; + private class ExpandedContentScrollbar : OsuScrollbar { public ExpandedContentScrollbar(Direction scrollDir) diff --git a/osu.Game/Configuration/IGameplaySettings.cs b/osu.Game/Configuration/IGameplaySettings.cs index a35bdd20d0..8d66535017 100644 --- a/osu.Game/Configuration/IGameplaySettings.cs +++ b/osu.Game/Configuration/IGameplaySettings.cs @@ -8,7 +8,7 @@ namespace osu.Game.Configuration { /// /// A settings provider which generally sources from (global user settings) - /// but can allow overriding settings by caching more locally. For instance, in the editor. + /// but can allow overriding settings by caching more locally. For instance, in the editor compose screen. /// /// /// More settings can be moved into this interface as required. diff --git a/osu.Game/Database/LegacyBeatmapImporter.cs b/osu.Game/Database/LegacyBeatmapImporter.cs index 0955461609..20add54949 100644 --- a/osu.Game/Database/LegacyBeatmapImporter.cs +++ b/osu.Game/Database/LegacyBeatmapImporter.cs @@ -1,11 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.IO.Stores; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.IO; @@ -22,22 +22,42 @@ namespace osu.Game.Database { // make sure the directory exists if (!storage.ExistsDirectory(string.Empty)) - yield break; + return Array.Empty(); - foreach (string directory in storage.GetDirectories(string.Empty)) + List paths = new List(); + + try { - var directoryStorage = storage.GetStorageForDirectory(directory); - - if (!directoryStorage.GetFiles(string.Empty).ExcludeSystemFileNames().Any()) + foreach (string directory in storage.GetDirectories(string.Empty)) { - // if a directory doesn't contain files, attempt looking for beatmaps inside of that directory. - // this is a special behaviour in stable for beatmaps only, see https://github.com/ppy/osu/issues/18615. - foreach (string subDirectory in GetStableImportPaths(directoryStorage)) - yield return subDirectory; + var directoryStorage = storage.GetStorageForDirectory(directory); + + try + { + if (!directoryStorage.GetFiles(string.Empty).ExcludeSystemFileNames().Any()) + { + // if a directory doesn't contain files, attempt looking for beatmaps inside of that directory. + // this is a special behaviour in stable for beatmaps only, see https://github.com/ppy/osu/issues/18615. + foreach (string subDirectory in GetStableImportPaths(directoryStorage)) + paths.Add(subDirectory); + } + else + paths.Add(storage.GetFullPath(directory)); + } + catch (Exception e) + { + // Catch any errors when enumerating files + Logger.Log($"Error when enumerating files in {directoryStorage.GetFullPath(string.Empty)}: {e}"); + } } - else - yield return storage.GetFullPath(directory); } + catch (Exception e) + { + // Catch any errors when enumerating directories + Logger.Log($"Error when enumerating directories in {storage.GetFullPath(string.Empty)}: {e}"); + } + + return paths; } public LegacyBeatmapImporter(IModelImporter importer) diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 1166a86814..09d137011c 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -17,6 +17,7 @@ using System.Collections.Generic; using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Lists; +using osu.Framework.Bindables; namespace osu.Game.Graphics.Backgrounds { @@ -25,6 +26,11 @@ namespace osu.Game.Graphics.Backgrounds private const float triangle_size = 100; private const float base_velocity = 50; + /// + /// sqrt(3) / 2 + /// + private const float equilateral_triangle_ratio = 0.866f; + /// /// How many screen-space pixels are smoothed over. /// Same behavior as Sprite's EdgeSmoothness. @@ -69,7 +75,13 @@ namespace osu.Game.Graphics.Backgrounds /// protected virtual float SpawnRatio => 1; - private float triangleScale = 1; + private readonly BindableFloat triangleScale = new BindableFloat(1f); + + public float TriangleScale + { + get => triangleScale.Value; + set => triangleScale.Value = value; + } /// /// Whether we should drop-off alpha values of triangles more quickly to improve @@ -103,30 +115,13 @@ namespace osu.Game.Graphics.Backgrounds private void load(IRenderer renderer, ShaderManager shaders) { texture = renderer.WhitePixel; - shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); } protected override void LoadComplete() { base.LoadComplete(); - addTriangles(true); - } - - public float TriangleScale - { - get => triangleScale; - set - { - float change = value / triangleScale; - triangleScale = value; - - for (int i = 0; i < parts.Count; i++) - { - TriangleParticle newParticle = parts[i]; - newParticle.Scale *= change; - parts[i] = newParticle; - } - } + triangleScale.BindValueChanged(_ => Reset(), true); } protected override void Update() @@ -147,7 +142,7 @@ namespace osu.Game.Graphics.Backgrounds // Since position is relative, the velocity needs to scale inversely with DrawHeight. // Since we will later multiply by the scale of individual triangles we normalize by // dividing by triangleScale. - float movedDistance = -elapsedSeconds * Velocity * base_velocity / (DrawHeight * triangleScale); + float movedDistance = -elapsedSeconds * Velocity * base_velocity / (DrawHeight * TriangleScale); for (int i = 0; i < parts.Count; i++) { @@ -159,7 +154,7 @@ namespace osu.Game.Graphics.Backgrounds parts[i] = newParticle; - float bottomPos = parts[i].Position.Y + triangle_size * parts[i].Scale * 0.866f / DrawHeight; + float bottomPos = parts[i].Position.Y + triangle_size * parts[i].Scale * equilateral_triangle_ratio / DrawHeight; if (bottomPos < 0) parts.RemoveAt(i); } @@ -185,9 +180,11 @@ namespace osu.Game.Graphics.Backgrounds // Limited by the maximum size of QuadVertexBuffer for safety. const int max_triangles = ushort.MaxValue / (IRenderer.VERTICES_PER_QUAD + 2); - AimCount = (int)Math.Min(max_triangles, (DrawWidth * DrawHeight * 0.002f / (triangleScale * triangleScale) * SpawnRatio)); + AimCount = (int)Math.Min(max_triangles, DrawWidth * DrawHeight * 0.002f / (TriangleScale * TriangleScale) * SpawnRatio); - for (int i = 0; i < AimCount - parts.Count; i++) + int currentCount = parts.Count; + + for (int i = 0; i < AimCount - currentCount; i++) parts.Add(createTriangle(randomY)); } @@ -195,13 +192,27 @@ namespace osu.Game.Graphics.Backgrounds { TriangleParticle particle = CreateTriangle(); - particle.Position = new Vector2(nextRandom(), randomY ? nextRandom() : 1); + particle.Position = getRandomPosition(randomY, particle.Scale); particle.ColourShade = nextRandom(); particle.Colour = CreateTriangleShade(particle.ColourShade); return particle; } + private Vector2 getRandomPosition(bool randomY, float scale) + { + float y = 1; + + if (randomY) + { + // since triangles are drawn from the top - allow them to be positioned a bit above the screen + float maxOffset = triangle_size * scale * equilateral_triangle_ratio / DrawHeight; + y = Interpolation.ValueAt(nextRandom(), -maxOffset, 1f, 0f, 1f); + } + + return new Vector2(nextRandom(), y); + } + /// /// Creates a triangle particle with a random scale. /// @@ -214,7 +225,7 @@ namespace osu.Game.Graphics.Backgrounds float u1 = 1 - nextRandom(); //uniform(0,1] random floats float u2 = 1 - nextRandom(); float randStdNormal = (float)(Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2)); // random normal(0,1) - float scale = Math.Max(triangleScale * (mean + std_dev * randStdNormal), 0.1f); // random normal(mean,stdDev^2) + float scale = Math.Max(TriangleScale * (mean + std_dev * randStdNormal), 0.1f); // random normal(mean,stdDev^2) return new TriangleParticle { Scale = scale }; } @@ -284,7 +295,7 @@ namespace osu.Game.Graphics.Backgrounds foreach (TriangleParticle particle in parts) { - var offset = triangle_size * new Vector2(particle.Scale * 0.5f, particle.Scale * 0.866f); + var offset = triangle_size * new Vector2(particle.Scale * 0.5f, particle.Scale * equilateral_triangle_ratio); var triangle = new Triangle( Vector2Extensions.Transform(particle.Position * size, DrawInfo.Matrix), diff --git a/osu.Game/Graphics/Sprites/LogoAnimation.cs b/osu.Game/Graphics/Sprites/LogoAnimation.cs index 7debdc7a37..097de4dfcb 100644 --- a/osu.Game/Graphics/Sprites/LogoAnimation.cs +++ b/osu.Game/Graphics/Sprites/LogoAnimation.cs @@ -17,7 +17,6 @@ namespace osu.Game.Graphics.Sprites private void load(ShaderManager shaders) { TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); - RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); // Masking isn't supported for now } private float animationProgress; @@ -58,7 +57,7 @@ namespace osu.Game.Graphics.Sprites protected override void Blit(IRenderer renderer) { - GetAppropriateShader(renderer).GetUniform("progress").UpdateValue(ref progress); + TextureShader.GetUniform("progress").UpdateValue(ref progress); base.Blit(renderer); } diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index 9140815f32..dae5de2d65 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -4,6 +4,7 @@ #nullable disable using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -69,6 +70,8 @@ namespace osu.Game.Graphics.UserInterface protected Box Background; protected SpriteText SpriteText; + private readonly Box flashLayer; + public OsuButton(HoverSampleSet? hoverSounds = HoverSampleSet.Button) { Height = 40; @@ -99,6 +102,14 @@ namespace osu.Game.Graphics.UserInterface Depth = float.MinValue }, SpriteText = CreateText(), + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Depth = float.MinValue, + Colour = Color4.White.Opacity(0.5f), + Alpha = 0, + }, } }); @@ -125,7 +136,7 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnClick(ClickEvent e) { if (Enabled.Value) - Background.FlashColour(Color4.White, 800, Easing.OutQuint); + flashLayer.FadeOutFromOne(800, Easing.OutQuint); return base.OnClick(e); } diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index e46e2b31ac..2efe27c842 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -11,7 +11,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { - public abstract class ScoreCounter : RollingCounter + public abstract class ScoreCounter : RollingCounter { protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; @@ -36,10 +36,10 @@ namespace osu.Game.Graphics.UserInterface UpdateDisplay(); } - protected override double GetProportionalDuration(double currentValue, double newValue) => + protected override double GetProportionalDuration(long currentValue, long newValue) => currentValue > newValue ? currentValue - newValue : newValue - currentValue; - protected override LocalisableString FormatCount(double count) => ((long)count).ToLocalisableString(formatString); + protected override LocalisableString FormatCount(long count) => count.ToLocalisableString(formatString); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index a0c8e0d555..65f78e1fd7 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -20,6 +20,8 @@ using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Notifications; +using osu.Game.Online.Notifications.WebSocket; using osu.Game.Users; namespace osu.Game.Online.API @@ -299,6 +301,9 @@ namespace osu.Game.Online.API public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack); + public NotificationsClientConnector GetNotificationsConnector() => + new WebSocketNotificationsClientConnector(this); + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); @@ -414,7 +419,7 @@ namespace osu.Game.Online.API failureCount++; log.Add($@"API failure count is now {failureCount}"); - if (failureCount >= 3 && State.Value == APIState.Online) + if (failureCount >= 3) { state.Value = APIState.Failing; flushQueue(); diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 7dc34d1293..609efd8ab6 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -9,6 +9,8 @@ using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Notifications; +using osu.Game.Tests; using osu.Game.Users; namespace osu.Game.Online.API @@ -115,6 +117,8 @@ namespace osu.Game.Online.API public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; + public NotificationsClientConnector GetNotificationsConnector() => new PollingNotificationsClientConnector(this); + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Thread.Sleep(200); diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index a90b11e354..6054effaa1 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -5,6 +5,7 @@ using System; using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Notifications; using osu.Game.Users; namespace osu.Game.Online.API @@ -112,6 +113,11 @@ namespace osu.Game.Online.API /// Whether to use MessagePack for serialisation if available on this platform. IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true); + /// + /// Constructs a new . + /// + NotificationsClientConnector GetNotificationsConnector(); + /// /// Create a new user account. This is a blocking operation. /// diff --git a/osu.Game/Online/API/Requests/ChatAckRequest.cs b/osu.Game/Online/API/Requests/ChatAckRequest.cs new file mode 100644 index 0000000000..306b5acc1d --- /dev/null +++ b/osu.Game/Online/API/Requests/ChatAckRequest.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + /// + /// A request which should be sent occasionally while interested in chat and online state. + /// + /// This will: + /// - Mark the user as "online" (for 10 minutes since the last invocation). + /// - Return any silences since the last invocation (if either or is not null). + /// + /// For silence handling, a should be provided as soon as a message is received by the client. + /// From that point forward, should be preferred after the first + /// arrives in a response from the ack request. Specifying both parameters will prioritise the latter. + /// + public class ChatAckRequest : APIRequest + { + public long? SinceMessageId; + public uint? SinceSilenceId; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + if (SinceMessageId != null) + req.AddParameter(@"since", SinceMessageId.ToString()); + if (SinceSilenceId != null) + req.AddParameter(@"history_since", SinceSilenceId.Value.ToString()); + return req; + } + + protected override string Target => "chat/ack"; + } +} diff --git a/osu.Game/Online/API/Requests/CreateNewPrivateMessageRequest.cs b/osu.Game/Online/API/Requests/CreateNewPrivateMessageRequest.cs index dea94bfce2..6b7192dbf4 100644 --- a/osu.Game/Online/API/Requests/CreateNewPrivateMessageRequest.cs +++ b/osu.Game/Online/API/Requests/CreateNewPrivateMessageRequest.cs @@ -28,6 +28,7 @@ namespace osu.Game.Online.API.Requests req.AddParameter(@"target_id", user.Id.ToString()); req.AddParameter(@"message", message.Content); req.AddParameter(@"is_action", message.IsAction.ToString().ToLowerInvariant()); + req.AddParameter(@"uuid", message.Uuid); return req; } diff --git a/osu.Game/Online/API/Requests/GetChannelRequest.cs b/osu.Game/Online/API/Requests/GetChannelRequest.cs new file mode 100644 index 0000000000..5bc9cb519a --- /dev/null +++ b/osu.Game/Online/API/Requests/GetChannelRequest.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetChannelRequest : APIRequest + { + private readonly long channelId; + + public GetChannelRequest(long channelId) + { + this.channelId = channelId; + } + + protected override string Target => $"chat/channels/{channelId}"; + } +} diff --git a/osu.Game/Online/API/Requests/GetNotificationsRequest.cs b/osu.Game/Online/API/Requests/GetNotificationsRequest.cs new file mode 100644 index 0000000000..afd4da296e --- /dev/null +++ b/osu.Game/Online/API/Requests/GetNotificationsRequest.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetNotificationsRequest : APIRequest + { + protected override string Target => @"notifications"; + } +} diff --git a/osu.Game/Online/API/Requests/GetScoresRequest.cs b/osu.Game/Online/API/Requests/GetScoresRequest.cs index 966e69938c..7d1d26b75d 100644 --- a/osu.Game/Online/API/Requests/GetScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetScoresRequest.cs @@ -16,6 +16,8 @@ namespace osu.Game.Online.API.Requests { public class GetScoresRequest : APIRequest { + public const int MAX_SCORES_PER_REQUEST = 50; + private readonly IBeatmapInfo beatmapInfo; private readonly BeatmapLeaderboardScope scope; private readonly IRulesetInfo ruleset; diff --git a/osu.Game/Online/API/Requests/PostMessageRequest.cs b/osu.Game/Online/API/Requests/PostMessageRequest.cs index 7b20bd9ad5..e3709d8f13 100644 --- a/osu.Game/Online/API/Requests/PostMessageRequest.cs +++ b/osu.Game/Online/API/Requests/PostMessageRequest.cs @@ -25,6 +25,7 @@ namespace osu.Game.Online.API.Requests req.Method = HttpMethod.Post; req.AddParameter(@"is_action", Message.IsAction.ToString().ToLowerInvariant()); req.AddParameter(@"message", Message.Content); + req.AddParameter(@"uuid", Message.Uuid); return req; } diff --git a/osu.Game/Online/API/Requests/Responses/APINotification.cs b/osu.Game/Online/API/Requests/Responses/APINotification.cs new file mode 100644 index 0000000000..de856c0333 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APINotification.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class APINotification + { + [JsonProperty(@"id")] + public long Id { get; set; } + + [JsonProperty(@"name")] + public string Name { get; set; } = null!; + + [JsonProperty(@"created_at")] + public DateTimeOffset? CreatedAt { get; set; } + + [JsonProperty(@"object_type")] + public string ObjectType { get; set; } = null!; + + [JsonProperty(@"object_id")] + public string ObjectId { get; set; } = null!; + + [JsonProperty(@"source_user_id")] + public long? SourceUserId { get; set; } + + [JsonProperty(@"is_read")] + public bool IsRead { get; set; } + + [JsonProperty(@"details")] + public JObject? Details { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APINotificationsBundle.cs b/osu.Game/Online/API/Requests/Responses/APINotificationsBundle.cs new file mode 100644 index 0000000000..ae299e2614 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APINotificationsBundle.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class APINotificationsBundle + { + [JsonProperty(@"has_more")] + public bool HasMore { get; set; } + + [JsonProperty(@"notifications")] + public APINotification[] Notifications { get; set; } = null!; + + [JsonProperty(@"notification_endpoint")] + public string Endpoint { get; set; } = null!; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs b/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs new file mode 100644 index 0000000000..6ed22a19b2 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class ChatAckResponse + { + [JsonProperty("silences")] + public List Silences { get; set; } = null!; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/ChatSilence.cs b/osu.Game/Online/API/Requests/Responses/ChatSilence.cs new file mode 100644 index 0000000000..afb44e385e --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/ChatSilence.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class ChatSilence + { + [JsonProperty("id")] + public uint Id { get; set; } + + [JsonProperty("user_id")] + public int UserId { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/GetChannelResponse.cs b/osu.Game/Online/API/Requests/Responses/GetChannelResponse.cs new file mode 100644 index 0000000000..24b886e74d --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/GetChannelResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class GetChannelResponse + { + [JsonProperty(@"channel")] + public Channel Channel { get; set; } = null!; + + [JsonProperty(@"users")] + public List Users { get; set; } = null!; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index 77dcfd39e3..15f4bace96 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -31,7 +31,7 @@ namespace osu.Game.Online.API.Requests.Responses public bool Passed { get; set; } [JsonProperty("total_score")] - public int TotalScore { get; set; } + public long TotalScore { get; set; } [JsonProperty("accuracy")] public double Accuracy { get; set; } @@ -213,7 +213,7 @@ namespace osu.Game.Online.API.Requests.Responses public static SoloScoreInfo ForSubmission(ScoreInfo score) => new SoloScoreInfo { Rank = score.Rank, - TotalScore = (int)score.TotalScore, + TotalScore = score.TotalScore, Accuracy = score.Accuracy, PP = score.PP, MaxCombo = score.MaxCombo, diff --git a/osu.Game/Online/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs index f51ea3e8d6..24b384b1d4 100644 --- a/osu.Game/Online/Chat/Channel.cs +++ b/osu.Game/Online/Chat/Channel.cs @@ -134,6 +134,14 @@ namespace osu.Game.Online.Chat /// public void AddNewMessages(params Message[] messages) { + foreach (var m in messages) + { + LocalEchoMessage localEcho = pendingMessages.FirstOrDefault(local => local.Uuid == m.Uuid); + + if (localEcho != null) + ReplaceMessage(localEcho, m); + } + messages = messages.Except(Messages).ToArray(); if (messages.Length == 0) return; @@ -149,6 +157,20 @@ namespace osu.Game.Online.Chat NewMessagesArrived?.Invoke(messages); } + public void RemoveMessagesFromUser(int userId) + { + for (int i = 0; i < Messages.Count; i++) + { + var message = Messages[i]; + + if (message.SenderId == userId) + { + Messages.RemoveAt(i--); + MessageRemoved?.Invoke(message); + } + } + } + /// /// Replace or remove a message from the channel. /// @@ -171,6 +193,10 @@ namespace osu.Game.Online.Chat throw new InvalidOperationException("Attempted to add the same message again"); Messages.Add(final); + + if (final.Id > LastMessageId) + LastMessageId = final.Id; + PendingMessageResolved?.Invoke(echo, final); } diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index ec84b0643d..25a53360f0 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -6,16 +6,17 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; +using osu.Framework.Threading; using osu.Game.Database; -using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Notifications; using osu.Game.Overlays.Chat.Listing; namespace osu.Game.Online.Chat @@ -23,7 +24,7 @@ namespace osu.Game.Online.Chat /// /// Manages everything channel related /// - public class ChannelManager : PollingComponent, IChannelPostTarget + public class ChannelManager : CompositeComponent, IChannelPostTarget { /// /// The channels the player joins on startup @@ -64,44 +65,50 @@ namespace osu.Game.Online.Chat public IBindableList AvailableChannels => availableChannels; private readonly IAPIProvider api; + private readonly NotificationsClientConnector connector; [Resolved] private UserLookupCache users { get; set; } - public readonly BindableBool HighPollRate = new BindableBool(); + private readonly IBindable apiState = new Bindable(); + private bool channelsInitialised; + private ScheduledDelegate scheduledAck; - private readonly IBindable isIdle = new BindableBool(); + private long? lastSilenceMessageId; + private uint? lastSilenceId; public ChannelManager(IAPIProvider api) { this.api = api; + + connector = api.GetNotificationsConnector(); + CurrentChannel.ValueChanged += currentChannelChanged; } - [BackgroundDependencyLoader(permitNulls: true)] - private void load(IdleTracker idleTracker) + [BackgroundDependencyLoader] + private void load() { - HighPollRate.BindValueChanged(updatePollRate); - isIdle.BindValueChanged(updatePollRate, true); + connector.ChannelJoined += ch => Schedule(() => joinChannel(ch)); - if (idleTracker != null) - isIdle.BindTo(idleTracker.IsIdle); - } + connector.ChannelParted += ch => Schedule(() => LeaveChannel(getChannel(ch))); - private void updatePollRate(ValueChangedEvent valueChangedEvent) - { - // Polling will eventually be replaced with websocket, but let's avoid doing these background operations as much as possible for now. - // The only loss will be delayed PM/message highlight notifications. - int millisecondsBetweenPolls = HighPollRate.Value ? 1000 : 60000; + connector.NewMessages += msgs => Schedule(() => addMessages(msgs)); - if (isIdle.Value) - millisecondsBetweenPolls *= 10; - - if (TimeBetweenPolls.Value != millisecondsBetweenPolls) + connector.PresenceReceived += () => Schedule(() => { - TimeBetweenPolls.Value = millisecondsBetweenPolls; - Logger.Log($"Chat is now polling every {TimeBetweenPolls.Value} ms"); - } + if (!channelsInitialised) + { + channelsInitialised = true; + // we want this to run after the first presence so we can see if the user is in any channels already. + initializeChannels(); + } + }); + + connector.Start(); + + apiState.BindTo(api.State); + apiState.BindValueChanged(_ => SendAck(), true); } /// @@ -181,7 +188,8 @@ namespace osu.Game.Online.Chat Timestamp = DateTimeOffset.Now, ChannelId = target.Id, IsAction = isAction, - Content = text + Content = text, + Uuid = Guid.NewGuid().ToString() }; target.AddLocalEcho(message); @@ -191,13 +199,7 @@ namespace osu.Game.Online.Chat { var createNewPrivateMessageRequest = new CreateNewPrivateMessageRequest(target.Users.First(), message); - createNewPrivateMessageRequest.Success += createRes => - { - target.Id = createRes.ChannelID; - target.ReplaceMessage(message, createRes.Message); - dequeueAndRun(); - }; - + createNewPrivateMessageRequest.Success += _ => dequeueAndRun(); createNewPrivateMessageRequest.Failure += exception => { handlePostException(exception); @@ -211,12 +213,7 @@ namespace osu.Game.Online.Chat var req = new PostMessageRequest(message); - req.Success += m => - { - target.ReplaceMessage(message, m); - dequeueAndRun(); - }; - + req.Success += m => dequeueAndRun(); req.Failure += exception => { handlePostException(exception); @@ -328,12 +325,14 @@ namespace osu.Game.Online.Chat } } - private void handleChannelMessages(IEnumerable messages) + private void addMessages(List messages) { var channels = JoinedChannels.ToList(); foreach (var group in messages.GroupBy(m => m.ChannelId)) channels.Find(c => c.Id == group.Key)?.AddNewMessages(group.ToArray()); + + lastSilenceMessageId ??= messages.LastOrDefault()?.Id; } private void initializeChannels() @@ -376,13 +375,51 @@ namespace osu.Game.Online.Chat var fetchInitialMsgReq = new GetMessagesRequest(channel); fetchInitialMsgReq.Success += messages => { - handleChannelMessages(messages); + addMessages(messages); channel.MessagesLoaded = true; // this will mark the channel as having received messages even if there were none. }; api.Queue(fetchInitialMsgReq); } + /// + /// Sends an acknowledgement request to the API. + /// This marks the user as online to receive messages from public channels, while also returning a list of silenced users. + /// It needs to be called at least once every 10 minutes to remain visibly marked as online. + /// + public void SendAck() + { + if (apiState.Value != APIState.Online) + return; + + var req = new ChatAckRequest + { + SinceMessageId = lastSilenceMessageId, + SinceSilenceId = lastSilenceId + }; + + req.Failure += _ => scheduleNextRequest(); + req.Success += ack => + { + foreach (var silence in ack.Silences) + { + foreach (var channel in JoinedChannels) + channel.RemoveMessagesFromUser(silence.UserId); + lastSilenceId = Math.Max(lastSilenceId ?? 0, silence.Id); + } + + scheduleNextRequest(); + }; + + api.Queue(req); + + void scheduleNextRequest() + { + scheduledAck?.Cancel(); + scheduledAck = Scheduler.AddDelayed(SendAck, 60000); + } + } + /// /// Find an existing channel instance for the provided channel. Lookup is performed basd on ID. /// The provided channel may be used if an existing instance is not found. @@ -395,7 +432,13 @@ namespace osu.Game.Online.Chat { Channel found = null; - bool lookupCondition(Channel ch) => lookup.Id > 0 ? ch.Id == lookup.Id : lookup.Name == ch.Name; + bool lookupCondition(Channel ch) + { + if (ch.Id > 0 && lookup.Id > 0) + return ch.Id == lookup.Id; + + return ch.Name == lookup.Name; + } var available = AvailableChannels.FirstOrDefault(lookupCondition); if (available != null) @@ -415,6 +458,12 @@ namespace osu.Game.Online.Chat if (foundSelf != null) found.Users.Remove(foundSelf); } + else + { + found.Id = lookup.Id; + found.Name = lookup.Name; + found.LastMessageId = Math.Max(found.LastMessageId ?? 0, lookup.LastMessageId ?? 0); + } if (joined == null && addToJoined) joinedChannels.Add(found); if (available == null && addToAvailable) availableChannels.Add(found); @@ -464,7 +513,7 @@ namespace osu.Game.Online.Chat { channel.Id = resChannel.ChannelID.Value; - handleChannelMessages(resChannel.RecentMessages); + addMessages(resChannel.RecentMessages); channel.MessagesLoaded = true; // this will mark the channel as having received messages even if there were none. } }; @@ -574,57 +623,6 @@ namespace osu.Game.Online.Chat } } - private long lastMessageId; - - private bool channelsInitialised; - - protected override Task Poll() - { - if (!api.IsLoggedIn) - return base.Poll(); - - var fetchReq = new GetUpdatesRequest(lastMessageId); - - var tcs = new TaskCompletionSource(); - - fetchReq.Success += updates => - { - if (updates?.Presence != null) - { - foreach (var channel in updates.Presence) - { - // we received this from the server so should mark the channel already joined. - channel.Joined.Value = true; - joinChannel(channel); - } - - //todo: handle left channels - - handleChannelMessages(updates.Messages); - - foreach (var group in updates.Messages.GroupBy(m => m.ChannelId)) - JoinedChannels.FirstOrDefault(c => c.Id == group.Key)?.AddNewMessages(group.ToArray()); - - lastMessageId = updates.Messages.LastOrDefault()?.Id ?? lastMessageId; - } - - if (!channelsInitialised) - { - channelsInitialised = true; - // we want this to run after the first presence so we can see if the user is in any channels already. - initializeChannels(); - } - - tcs.SetResult(true); - }; - - fetchReq.Failure += _ => tcs.SetResult(false); - - api.Queue(fetchReq); - - return tcs.Task; - } - /// /// Marks the as read /// @@ -646,6 +644,12 @@ namespace osu.Game.Online.Chat api.Queue(req); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + connector?.Dispose(); + } } /// diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index 86562341eb..9f6f9c8d6b 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -30,6 +30,19 @@ namespace osu.Game.Online.Chat [JsonProperty(@"sender")] public APIUser Sender; + [JsonProperty(@"sender_id")] + public int SenderId + { + get => Sender?.Id ?? 0; + set => Sender = new APIUser { Id = value }; + } + + /// + /// A unique identifier for this message. Sent to and from osu!web to use for deduplication. + /// + [JsonProperty(@"uuid")] + public string Uuid { get; set; } = string.Empty; + [JsonConstructor] public Message() { diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 6f246f6dd3..ca6d2932f7 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -49,6 +49,9 @@ namespace osu.Game.Online this.api = api; this.versionHash = versionHash; this.preferMessagePack = preferMessagePack; + + // Automatically start these connections. + Start(); } protected override Task BuildConnectionAsync(CancellationToken cancellationToken) diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 75334952f0..b39781ebc2 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; @@ -727,13 +728,20 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - Debug.Assert(APIRoom != null); + try + { + Debug.Assert(APIRoom != null); - Room.Playlist[Room.Playlist.IndexOf(Room.Playlist.Single(existing => existing.ID == item.ID))] = item; + Room.Playlist[Room.Playlist.IndexOf(Room.Playlist.Single(existing => existing.ID == item.ID))] = item; - int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); - APIRoom.Playlist.RemoveAt(existingIndex); - APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); + int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); + APIRoom.Playlist.RemoveAt(existingIndex); + APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); + } + catch (Exception ex) + { + throw new AggregateException($"Item: {JsonConvert.SerializeObject(createPlaylistItem(item))}\n\nRoom:{JsonConvert.SerializeObject(APIRoom)}", ex); + } ItemChanged?.Invoke(item); RoomUpdated?.Invoke(); diff --git a/osu.Game/Online/Notifications/NotificationsClient.cs b/osu.Game/Online/Notifications/NotificationsClient.cs new file mode 100644 index 0000000000..6198707111 --- /dev/null +++ b/osu.Game/Online/Notifications/NotificationsClient.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.Notifications +{ + /// + /// An abstract client which receives notification-related events (chat/notifications). + /// + public abstract class NotificationsClient : PersistentEndpointClient + { + public Action? ChannelJoined; + public Action? ChannelParted; + public Action>? NewMessages; + public Action? PresenceReceived; + + protected readonly IAPIProvider API; + + private long lastMessageId; + + protected NotificationsClient(IAPIProvider api) + { + API = api; + } + + public override Task ConnectAsync(CancellationToken cancellationToken) + { + API.Queue(CreateFetchMessagesRequest(0)); + return Task.CompletedTask; + } + + protected APIRequest CreateFetchMessagesRequest(long? lastMessageId = null) + { + var fetchReq = new GetUpdatesRequest(lastMessageId ?? this.lastMessageId); + + fetchReq.Success += updates => + { + if (updates?.Presence != null) + { + foreach (var channel in updates.Presence) + HandleChannelJoined(channel); + + //todo: handle left channels + + HandleMessages(updates.Messages); + } + + PresenceReceived?.Invoke(); + }; + + return fetchReq; + } + + protected void HandleChannelJoined(Channel channel) + { + channel.Joined.Value = true; + ChannelJoined?.Invoke(channel); + } + + protected void HandleChannelParted(Channel channel) => ChannelParted?.Invoke(channel); + + protected void HandleMessages(List messages) + { + NewMessages?.Invoke(messages); + lastMessageId = Math.Max(lastMessageId, messages.LastOrDefault()?.Id ?? 0); + } + } +} diff --git a/osu.Game/Online/Notifications/NotificationsClientConnector.cs b/osu.Game/Online/Notifications/NotificationsClientConnector.cs new file mode 100644 index 0000000000..d2c2e6673c --- /dev/null +++ b/osu.Game/Online/Notifications/NotificationsClientConnector.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.Notifications +{ + /// + /// An abstract connector or s. + /// + public abstract class NotificationsClientConnector : PersistentEndpointClientConnector + { + public event Action? ChannelJoined; + public event Action? ChannelParted; + public event Action>? NewMessages; + public event Action? PresenceReceived; + + protected NotificationsClientConnector(IAPIProvider api) + : base(api) + { + } + + protected sealed override async Task BuildConnectionAsync(CancellationToken cancellationToken) + { + var client = await BuildNotificationClientAsync(cancellationToken); + + client.ChannelJoined = c => ChannelJoined?.Invoke(c); + client.ChannelParted = c => ChannelParted?.Invoke(c); + client.NewMessages = m => NewMessages?.Invoke(m); + client.PresenceReceived = () => PresenceReceived?.Invoke(); + + return client; + } + + protected abstract Task BuildNotificationClientAsync(CancellationToken cancellationToken); + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/EndChatRequest.cs b/osu.Game/Online/Notifications/WebSocket/EndChatRequest.cs new file mode 100644 index 0000000000..7f67587f5d --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/EndChatRequest.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A websocket message notifying the server that the client no longer wants to receive chat messages. + /// + [JsonObject(MemberSerialization.OptIn)] + public class EndChatRequest : SocketMessage + { + public EndChatRequest() + { + Event = @"chat.end"; + } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs b/osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs new file mode 100644 index 0000000000..850fbd226b --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A websocket message sent from the server when new messages arrive. + /// + [JsonObject(MemberSerialization.OptIn)] + public class NewChatMessageData + { + [JsonProperty(@"messages")] + public List Messages { get; set; } = null!; + + [JsonProperty(@"users")] + private List users { get; set; } = null!; + + [OnDeserialized] + private void onDeserialised(StreamingContext context) + { + foreach (var m in Messages) + m.Sender = users.Single(u => u.OnlineID == m.SenderId); + } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/SocketMessage.cs b/osu.Game/Online/Notifications/WebSocket/SocketMessage.cs new file mode 100644 index 0000000000..666a9dd8a3 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/SocketMessage.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A websocket message, sent either from the client or server. + /// + [JsonObject(MemberSerialization.OptIn)] + public class SocketMessage + { + [JsonProperty(@"event")] + public string Event { get; set; } = null!; + + [JsonProperty(@"data")] + public JObject? Data { get; set; } + + [JsonProperty(@"error")] + public string? Error { get; set; } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/StartChatRequest.cs b/osu.Game/Online/Notifications/WebSocket/StartChatRequest.cs new file mode 100644 index 0000000000..9dd69a7377 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/StartChatRequest.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A websocket message notifying the server that the client wants to receive chat messages. + /// + [JsonObject(MemberSerialization.OptIn)] + public class StartChatRequest : SocketMessage + { + public StartChatRequest() + { + Event = @"chat.start"; + } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs new file mode 100644 index 0000000000..d8d78297e3 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs @@ -0,0 +1,179 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Logging; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A notifications client which receives events via a websocket. + /// + public class WebSocketNotificationsClient : NotificationsClient + { + private readonly ClientWebSocket socket; + private readonly string endpoint; + private readonly ConcurrentDictionary channelsMap = new ConcurrentDictionary(); + + public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint, IAPIProvider api) + : base(api) + { + this.socket = socket; + this.endpoint = endpoint; + } + + public override async Task ConnectAsync(CancellationToken cancellationToken) + { + await socket.ConnectAsync(new Uri(endpoint), cancellationToken).ConfigureAwait(false); + await sendMessage(new StartChatRequest(), CancellationToken.None); + + runReadLoop(cancellationToken); + + await base.ConnectAsync(cancellationToken); + } + + private void runReadLoop(CancellationToken cancellationToken) => Task.Run(async () => + { + byte[] buffer = new byte[1024]; + StringBuilder messageResult = new StringBuilder(); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, cancellationToken); + + switch (result.MessageType) + { + case WebSocketMessageType.Text: + messageResult.Append(Encoding.UTF8.GetString(buffer[..result.Count])); + + if (result.EndOfMessage) + { + SocketMessage? message = JsonConvert.DeserializeObject(messageResult.ToString()); + messageResult.Clear(); + + Debug.Assert(message != null); + + if (message.Error != null) + { + Logger.Log($"{GetType().ReadableName()} error: {message.Error}", LoggingTarget.Network); + break; + } + + await onMessageReceivedAsync(message); + } + + break; + + case WebSocketMessageType.Binary: + throw new NotImplementedException("Binary message type not supported."); + + case WebSocketMessageType.Close: + throw new Exception("Connection closed by remote host."); + } + } + catch (Exception ex) + { + await InvokeClosed(ex); + return; + } + } + }, cancellationToken); + + private async Task closeAsync() + { + try + { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, @"Disconnecting", CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Closure can fail if the connection is aborted. Don't really care since it's disposed anyway. + } + } + + private async Task sendMessage(SocketMessage message, CancellationToken cancellationToken) + { + if (socket.State != WebSocketState.Open) + return; + + await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken); + } + + private async Task onMessageReceivedAsync(SocketMessage message) + { + switch (message.Event) + { + case @"chat.channel.join": + Debug.Assert(message.Data != null); + + Channel? joinedChannel = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(joinedChannel != null); + + HandleChannelJoined(joinedChannel); + break; + + case @"chat.channel.part": + Debug.Assert(message.Data != null); + + Channel? partedChannel = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(partedChannel != null); + + HandleChannelParted(partedChannel); + break; + + case @"chat.message.new": + Debug.Assert(message.Data != null); + + NewChatMessageData? messageData = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(messageData != null); + + foreach (var msg in messageData.Messages) + HandleChannelJoined(await getChannel(msg.ChannelId)); + + HandleMessages(messageData.Messages); + break; + } + } + + private async Task getChannel(long channelId) + { + if (channelsMap.TryGetValue(channelId, out Channel channel)) + return channel; + + var tsc = new TaskCompletionSource(); + var req = new GetChannelRequest(channelId); + + req.Success += response => + { + channelsMap[channelId] = response.Channel; + tsc.SetResult(response.Channel); + }; + + req.Failure += ex => tsc.SetException(ex); + + API.Queue(req); + + return await tsc.Task; + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await closeAsync(); + socket.Dispose(); + } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs new file mode 100644 index 0000000000..21335a3b59 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A connector for s that receive events via a websocket. + /// + public class WebSocketNotificationsClientConnector : NotificationsClientConnector + { + private readonly IAPIProvider api; + + public WebSocketNotificationsClientConnector(IAPIProvider api) + : base(api) + { + this.api = api; + } + + protected override async Task BuildNotificationClientAsync(CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + + var req = new GetNotificationsRequest(); + req.Success += bundle => tcs.SetResult(bundle.Endpoint); + req.Failure += ex => tcs.SetException(ex); + api.Queue(req); + + string endpoint = await tcs.Task; + + ClientWebSocket socket = new ClientWebSocket(); + socket.Options.SetRequestHeader(@"Authorization", @$"Bearer {api.AccessToken}"); + socket.Options.Proxy = WebRequest.DefaultWebProxy; + if (socket.Options.Proxy != null) + socket.Options.Proxy.Credentials = CredentialCache.DefaultCredentials; + + return new WebSocketNotificationsClient(socket, endpoint, api); + } + } +} diff --git a/osu.Game/Online/PersistentEndpointClientConnector.cs b/osu.Game/Online/PersistentEndpointClientConnector.cs index 70e10c6c7d..be76644745 100644 --- a/osu.Game/Online/PersistentEndpointClientConnector.cs +++ b/osu.Game/Online/PersistentEndpointClientConnector.cs @@ -23,11 +23,13 @@ namespace osu.Game.Online /// public PersistentEndpointClient? CurrentConnection { get; private set; } + protected readonly IAPIProvider API; + + private readonly IBindable apiState = new Bindable(); private readonly Bindable isConnected = new Bindable(); private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1); private CancellationTokenSource connectCancelSource = new CancellationTokenSource(); - - private readonly IBindable apiState = new Bindable(); + private bool started; /// /// Constructs a new . @@ -35,8 +37,20 @@ namespace osu.Game.Online /// An API provider used to react to connection state changes. protected PersistentEndpointClientConnector(IAPIProvider api) { + API = api; apiState.BindTo(api.State); + } + + /// + /// Attempts to connect and begins processing messages from the remote endpoint. + /// + public void Start() + { + if (started) + return; + apiState.BindValueChanged(_ => Task.Run(connectIfPossible), true); + started = true; } public Task Reconnect() @@ -131,7 +145,9 @@ namespace osu.Game.Online private async Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) { - isConnected.Value = false; + bool hasBeenCancelled = cancellationToken.IsCancellationRequested; + + await disconnect(true); if (ex != null) await handleErrorAndDelay(ex, cancellationToken).ConfigureAwait(false); @@ -139,7 +155,7 @@ namespace osu.Game.Online Logger.Log($"{ClientName} disconnected", LoggingTarget.Network); // make sure a disconnect wasn't triggered (and this is still the active connection). - if (!cancellationToken.IsCancellationRequested) + if (!hasBeenCancelled) await Task.Run(connect, default).ConfigureAwait(false); } @@ -160,7 +176,9 @@ namespace osu.Game.Online } finally { + isConnected.Value = false; CurrentConnection = null; + if (takeLock) connectionLock.Release(); } diff --git a/osu.Game/Online/Rooms/IndexedMultiplayerScores.cs b/osu.Game/Online/Rooms/IndexedMultiplayerScores.cs index 459602f1b4..59cba2340d 100644 --- a/osu.Game/Online/Rooms/IndexedMultiplayerScores.cs +++ b/osu.Game/Online/Rooms/IndexedMultiplayerScores.cs @@ -17,7 +17,7 @@ namespace osu.Game.Online.Rooms /// The total scores in the playlist item. /// [JsonProperty("total")] - public int? TotalScores { get; set; } + public long? TotalScores { get; set; } /// /// The user's score, if any. diff --git a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs index 87f25874c5..573c504add 100644 --- a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs +++ b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs @@ -26,7 +26,7 @@ namespace osu.Game.Online.Spectator /// /// The current total score. /// - public readonly BindableDouble TotalScore = new BindableDouble { MinValue = 0 }; + public readonly BindableLong TotalScore = new BindableLong { MinValue = 0 }; /// /// The current accuracy. diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 7476324e11..a93c187e53 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -910,19 +910,6 @@ namespace osu.Game loadComponentSingleFile(new BackgroundBeatmapProcessor(), Add); - chatOverlay.State.BindValueChanged(_ => updateChatPollRate()); - // Multiplayer modes need to increase poll rate temporarily. - API.Activity.BindValueChanged(_ => updateChatPollRate(), true); - - void updateChatPollRate() - { - channelManager.HighPollRate.Value = - chatOverlay.State.Value == Visibility.Visible - || API.Activity.Value is UserActivity.InLobby - || API.Activity.Value is UserActivity.InMultiplayerGame - || API.Activity.Value is UserActivity.SpectatingMultiplayerGame; - } - Add(difficultyRecommender); Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index c278c9cb93..2d9583b864 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -41,11 +41,8 @@ namespace osu.Game.Overlays private IBindable apiUser; - private Drawable currentContent; private Container panelTarget; private FillFlowContainer foundContent; - private NotFoundDrawable notFoundContent; - private SupporterRequiredDrawable supporterRequiredContent; private BeatmapListingFilterControl filterControl; public BeatmapListingOverlay() @@ -86,11 +83,6 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.X, Masking = true, Padding = new MarginPadding { Horizontal = 20 }, - Children = new Drawable[] - { - notFoundContent = new NotFoundDrawable(), - supporterRequiredContent = new SupporterRequiredDrawable(), - } } }, }, @@ -107,7 +99,7 @@ namespace osu.Game.Overlays apiUser.BindValueChanged(_ => Schedule(() => { if (api.IsLoggedIn) - addContentToResultsArea(Drawable.Empty()); + replaceResultsAreaContent(Drawable.Empty()); })); } @@ -155,8 +147,8 @@ namespace osu.Game.Overlays if (searchResult.Type == BeatmapListingFilterControl.SearchResultType.SupporterOnlyFilters) { - supporterRequiredContent.UpdateText(searchResult.SupporterOnlyFiltersUsed); - addContentToResultsArea(supporterRequiredContent); + var supporterOnly = new SupporterRequiredDrawable(searchResult.SupporterOnlyFiltersUsed); + replaceResultsAreaContent(supporterOnly); return; } @@ -167,13 +159,13 @@ namespace osu.Game.Overlays //No matches case if (!newCards.Any()) { - addContentToResultsArea(notFoundContent); + replaceResultsAreaContent(new NotFoundDrawable()); return; } var content = createCardContainerFor(newCards); - panelLoadTask = LoadComponentAsync(foundContent = content, addContentToResultsArea, (cancellationToken = new CancellationTokenSource()).Token); + panelLoadTask = LoadComponentAsync(foundContent = content, replaceResultsAreaContent, (cancellationToken = new CancellationTokenSource()).Token); } else { @@ -221,36 +213,16 @@ namespace osu.Game.Overlays return content; } - private void addContentToResultsArea(Drawable content) + private void replaceResultsAreaContent(Drawable content) { Loading.Hide(); lastFetchDisplayedTime = Time.Current; - if (content == currentContent) - return; - - var lastContent = currentContent; - - if (lastContent != null) - { - lastContent.FadeOut(); - if (!isPlaceholderContent(lastContent)) - lastContent.Expire(); - } - - if (!content.IsAlive) - panelTarget.Add(content); + panelTarget.Child = content; content.FadeInFromZero(); - currentContent = content; } - /// - /// Whether is a static placeholder reused multiple times by this overlay. - /// - private bool isPlaceholderContent(Drawable drawable) - => drawable == notFoundContent || drawable == supporterRequiredContent; - private void onCardSizeChanged() { if (foundContent?.IsAlive != true || !foundContent.Any()) @@ -287,7 +259,7 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { AddInternal(new FillFlowContainer { @@ -324,15 +296,19 @@ namespace osu.Game.Overlays { private LinkFlowContainer supporterRequiredText; - public SupporterRequiredDrawable() + private readonly List filtersUsed; + + public SupporterRequiredDrawable(List filtersUsed) { RelativeSizeAxes = Axes.X; Height = 225; Alpha = 0; + + this.filtersUsed = filtersUsed; } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { AddInternal(new FillFlowContainer { @@ -360,14 +336,9 @@ namespace osu.Game.Overlays }, } }); - } - - public void UpdateText(List filters) - { - supporterRequiredText.Clear(); supporterRequiredText.AddText( - BeatmapsStrings.ListingSearchSupporterFilterQuoteDefault(string.Join(" and ", filters), "").ToString(), + BeatmapsStrings.ListingSearchSupporterFilterQuoteDefault(string.Join(" and ", filtersUsed), "").ToString(), t => { t.Font = OsuFont.GetFont(size: 16); diff --git a/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs b/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs index 8f5c942b6e..1d904526fd 100644 --- a/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs @@ -119,22 +119,17 @@ namespace osu.Game.Overlays.Dashboard.Home.News [BackgroundDependencyLoader] private void load(GameHost host) { - NewsPostBackground bg; - - Child = new DelayedLoadWrapper(bg = new NewsPostBackground(post.FirstImage) + Child = new DelayedLoadUnloadWrapper(() => new NewsPostBackground(post.FirstImage) { RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fill, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Alpha = 0 }) { RelativeSizeAxes = Axes.Both }; - bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); - TooltipText = "view in browser"; Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs index eb76522e11..c8e0b0c7ef 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -49,7 +49,6 @@ namespace osu.Game.Overlays.News Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); } - NewsPostBackground bg; AddRange(new Drawable[] { background = new Box @@ -71,14 +70,14 @@ namespace osu.Game.Overlays.News CornerRadius = 6, Children = new Drawable[] { - new DelayedLoadWrapper(bg = new NewsPostBackground(post.FirstImage) + new DelayedLoadUnloadWrapper(() => new NewsPostBackground(post.FirstImage) { RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fill, Anchor = Anchor.Centre, Origin = Anchor.Centre, Alpha = 0 - }) + }, timeBeforeUnload: 5000) { RelativeSizeAxes = Axes.Both }, @@ -116,8 +115,6 @@ namespace osu.Game.Overlays.News IdleColour = colourProvider.Background4; HoverColour = colourProvider.Background3; - bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); - main.AddParagraph(post.Title, t => t.Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold)); main.AddParagraph(post.Preview, t => t.Font = OsuFont.GetFont(size: 12)); // Should use sans-serif font main.AddParagraph("by ", t => t.Font = OsuFont.GetFont(size: 12)); diff --git a/osu.Game/Overlays/News/NewsPostBackground.cs b/osu.Game/Overlays/News/NewsPostBackground.cs index bddca8f7ec..b77623842c 100644 --- a/osu.Game/Overlays/News/NewsPostBackground.cs +++ b/osu.Game/Overlays/News/NewsPostBackground.cs @@ -4,6 +4,7 @@ #nullable disable using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; @@ -25,6 +26,12 @@ namespace osu.Game.Overlays.News Texture = store.Get(createUrl(sourceUrl)); } + protected override void LoadComplete() + { + base.LoadComplete(); + this.FadeInFromZero(500, Easing.OutQuint); + } + private string createUrl(string source) { if (string.IsNullOrEmpty(source)) diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 900b4bebf0..949f1e7b96 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -100,7 +100,7 @@ namespace osu.Game.Overlays }, Children = new[] { - background = new Background(), + background = Empty(), title = new OsuSpriteText { Origin = Anchor.BottomCentre, @@ -413,7 +413,7 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { sprite.Texture = beatmap?.Background ?? textures.Get(@"Backgrounds/bg4"); } diff --git a/osu.Game/Overlays/OverlayHeaderBackground.cs b/osu.Game/Overlays/OverlayHeaderBackground.cs index c47f16272f..540b28d9b2 100644 --- a/osu.Game/Overlays/OverlayHeaderBackground.cs +++ b/osu.Game/Overlays/OverlayHeaderBackground.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays Height = 80; RelativeSizeAxes = Axes.X; Masking = true; - InternalChild = new Background(textureName); + InternalChild = new DelayedLoadWrapper(() => new Background(textureName)); } private class Background : Sprite @@ -36,10 +36,16 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { Texture = textures.Get(textureName); } + + protected override void LoadComplete() + { + base.LoadComplete(); + this.FadeInFromZero(500, Easing.OutQuint); + } } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index b92746a65a..2f4840a384 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -3,6 +3,8 @@ #nullable disable +using System.Collections.Generic; +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Localisation; @@ -13,6 +15,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { protected override LocalisableString Header => BindingSettingsStrings.ShortcutAndGameplayBindings; + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "keybindings" }); + public BindingSettings(KeyBindingPanel keyConfig) { Children = new Drawable[] diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index c91a6a48d4..25ab8cfad2 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -33,6 +33,11 @@ namespace osu.Game.Overlays.Settings.Sections.Input { public class KeyBindingRow : Container, IFilterable { + /// + /// Invoked when the binding of this row is updated with a change being written. + /// + public Action BindingUpdated { get; set; } + private readonly object action; private readonly IEnumerable bindings; @@ -153,7 +158,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input Spacing = new Vector2(5), Children = new Drawable[] { - new CancelButton { Action = finalise }, + new CancelButton { Action = () => finalise(false) }, new ClearButton { Action = clear }, }, }, @@ -226,7 +231,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromMouseButton(e.Button)); return true; } @@ -240,7 +245,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } if (bindTarget.IsHovered) - finalise(); + finalise(false); // prevent updating bind target before clear button's action else if (!cancelAndClearButtons.Any(b => b.IsHovered)) updateBindTarget(); @@ -252,7 +257,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input { if (bindTarget.IsHovered) { - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState, e.ScrollDelta)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState, e.ScrollDelta), KeyCombination.FromScrollDelta(e.ScrollDelta).First()); finalise(); return true; } @@ -263,10 +268,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input protected override bool OnKeyDown(KeyDownEvent e) { - if (!HasFocus) + if (!HasFocus || e.Repeat) return false; - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromKey(e.Key)); if (!isModifier(e.Key)) finalise(); return true; @@ -288,7 +293,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (!HasFocus) return false; - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromJoystickButton(e.Button)); finalise(); return true; @@ -310,7 +315,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (!HasFocus) return false; - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromMidiKey(e.Key)); finalise(); return true; @@ -332,7 +337,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (!HasFocus) return false; - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromTabletAuxiliaryButton(e.Button)); finalise(); return true; @@ -354,7 +359,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (!HasFocus) return false; - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromTabletPenButton(e.Button)); finalise(); return true; @@ -377,10 +382,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input return; bindTarget.UpdateKeyCombination(InputKey.None); - finalise(); + finalise(false); } - private void finalise() + private void finalise(bool hasChanged = true) { if (bindTarget != null) { @@ -393,6 +398,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { // schedule to ensure we don't instantly get focus back on next OnMouseClick (see AcceptFocus impl.) bindTarget = null; + if (hasChanged) + BindingUpdated?.Invoke(this); }); } @@ -417,7 +424,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input protected override void OnFocusLost(FocusLostEvent e) { - finalise(); + finalise(false); base.OnFocusLost(e); } @@ -563,6 +570,14 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } + /// + /// Update from a key combination, only allowing a single non-modifier key to be specified. + /// + /// A generated from the full input state. + /// The key which triggered this update, and should be used as the binding. + public void UpdateKeyCombination(KeyCombination fullState, InputKey triggerKey) => + UpdateKeyCombination(new KeyCombination(fullState.Keys.Where(KeyCombination.IsModifierKey).Append(triggerKey))); + public void UpdateKeyCombination(KeyCombination newCombination) { if (KeyBinding.RulesetName != null && !RealmKeyBindingStore.CheckValidForGameplay(newCombination)) diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs index 628fe08607..98d569948f 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs @@ -19,6 +19,12 @@ namespace osu.Game.Overlays.Settings.Sections.Input { public abstract class KeyBindingsSubsection : SettingsSubsection { + /// + /// After a successful binding, automatically select the next binding row to make quickly + /// binding a large set of keys easier on the user. + /// + protected virtual bool AutoAdvanceTarget => false; + protected IEnumerable Defaults; public RulesetInfo Ruleset { get; protected set; } @@ -49,7 +55,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => b.ActionInt.Equals(intKey)).ToList()) { AllowMainMouseButtons = Ruleset != null, - Defaults = defaultGroup.Select(d => d.KeyCombination) + Defaults = defaultGroup.Select(d => d.KeyCombination), + BindingUpdated = onBindingUpdated }); } @@ -58,6 +65,16 @@ namespace osu.Game.Overlays.Settings.Sections.Input Action = () => Children.OfType().ForEach(k => k.RestoreDefaults()) }); } + + private void onBindingUpdated(KeyBindingRow sender) + { + if (AutoAdvanceTarget) + { + var next = Children.SkipWhile(c => c != sender).Skip(1).FirstOrDefault(); + if (next != null) + GetContainingInputManager().ChangeFocus(next); + } + } } public class ResetButton : DangerousSettingsButton diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 21a65862f7..f7c372a037 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -110,9 +110,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) { t.NewLine(); - var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedBindableString(TabletSettingsStrings.NoTabletDetectedDescription(RuntimeInfo.OS == RuntimeInfo.Platform.Windows - ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" - : @"https://opentabletdriver.net/Wiki/FAQ/Linux")).Value); + var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedBindableString(TabletSettingsStrings.NoTabletDetectedDescription( + RuntimeInfo.OS == RuntimeInfo.Platform.Windows + ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" + : @"https://opentabletdriver.net/Wiki/FAQ/Linux")).Value); t.AddLinks(formattedSource.Text, formattedSource.Links); } }), @@ -273,6 +274,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input sizeY.Default = sizeY.MaxValue = tab.Size.Y; areaSize.Default = new Vector2(sizeX.Default, sizeY.Default); + areaOffset.Default = new Vector2(offsetX.Default, offsetY.Default); }), true); } diff --git a/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs index a0f069b3bb..a6f6c28463 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs @@ -8,6 +8,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { public class VariantBindingsSubsection : KeyBindingsSubsection { + protected override bool AutoAdvanceTarget => true; + protected override LocalisableString Header { get; } public VariantBindingsSubsection(RulesetInfo ruleset, int variant) diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index a8fe3d04be..4d75537f6b 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -3,8 +3,6 @@ #nullable disable -using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -22,8 +20,6 @@ namespace osu.Game.Overlays.Settings.Sections public override LocalisableString Header => InputSettingsStrings.InputSectionHeader; - public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "keybindings" }); - public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.Keyboard diff --git a/osu.Game/PerformFromMenuRunner.cs b/osu.Game/PerformFromMenuRunner.cs index eae13fe5d0..158350b43d 100644 --- a/osu.Game/PerformFromMenuRunner.cs +++ b/osu.Game/PerformFromMenuRunner.cs @@ -89,6 +89,10 @@ namespace osu.Game // check if we are already at a valid target screen. if (validScreens.Any(t => t.IsAssignableFrom(type))) { + if (!((Drawable)current).IsLoaded) + // wait until screen is loaded before invoking action. + return true; + finalAction(current); Cancel(); return true; diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs index 3fb12041d1..4f802a22a1 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs @@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.Difficulty // calculate total score ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = perfectPlay.Mods; - perfectPlay.TotalScore = (long)scoreProcessor.ComputeScore(ScoringMode.Standardised, perfectPlay); + perfectPlay.TotalScore = scoreProcessor.ComputeScore(ScoringMode.Standardised, perfectPlay); // compute rank achieved // default to SS, then adjust the rank with mods diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 7456ce06bd..899d149cbc 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Scoring /// /// The current total score. /// - public readonly BindableDouble TotalScore = new BindableDouble { MinValue = 0 }; + public readonly BindableLong TotalScore = new BindableLong { MinValue = 0 }; /// /// The current accuracy. @@ -267,7 +267,7 @@ namespace osu.Game.Rulesets.Scoring private void updateScore() { - Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; + Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? (double)currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; TotalScore.Value = ComputeScore(Mode.Value, currentScoringValues, maximumScoringValues); } @@ -298,7 +298,7 @@ namespace osu.Game.Rulesets.Scoring /// The to compute the total score of. /// The total score in the given . [Pure] - public double ComputeScore(ScoringMode mode, ScoreInfo scoreInfo) + public long ComputeScore(ScoringMode mode, ScoreInfo scoreInfo) { if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); @@ -316,9 +316,9 @@ namespace osu.Game.Rulesets.Scoring /// The maximum scoring values. /// The total score computed from the given scoring values. [Pure] - public double ComputeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) + public long ComputeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) { - double accuracyRatio = maximum.BaseScore > 0 ? current.BaseScore / maximum.BaseScore : 1; + double accuracyRatio = maximum.BaseScore > 0 ? (double)current.BaseScore / maximum.BaseScore : 1; double comboRatio = maximum.MaxCombo > 0 ? (double)current.MaxCombo / maximum.MaxCombo : 1; return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); } @@ -333,21 +333,23 @@ namespace osu.Game.Rulesets.Scoring /// The total number of basic (non-tick and non-bonus) hitobjects in the beatmap. /// The total score computed from the given scoring component ratios. [Pure] - public double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects) + public long ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, long bonusScore, int totalBasicHitObjects) { + double accuracyScore = accuracyPortion * accuracyRatio; + double comboScore = comboPortion * comboRatio; + double rawScore = (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; + switch (mode) { default: case ScoringMode.Standardised: - double accuracyScore = accuracyPortion * accuracyRatio; - double comboScore = comboPortion * comboRatio; - return (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; + return (long)Math.Round(rawScore); case ScoringMode.Classic: // This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring. // The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes. - double scaledStandardised = ComputeScore(ScoringMode.Standardised, accuracyRatio, comboRatio, bonusScore, totalBasicHitObjects) / max_score; - return Math.Pow(scaledStandardised * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier; + double scaledRawScore = rawScore / max_score; + return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier); } } @@ -417,7 +419,7 @@ namespace osu.Game.Rulesets.Scoring score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. - score.TotalScore = (long)Math.Round(ComputeScore(ScoringMode.Standardised, score)); + score.TotalScore = ComputeScore(ScoringMode.Standardised, score); } /// diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index ad52b4affc..c50f63c3b2 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -115,7 +115,11 @@ namespace osu.Game.Rulesets.UI public Sample Get(string name) => primary.Get(name) ?? fallback.Get(name); - public Task GetAsync(string name, CancellationToken cancellationToken = default) => primary.GetAsync(name, cancellationToken) ?? fallback.GetAsync(name, cancellationToken); + public async Task GetAsync(string name, CancellationToken cancellationToken = default) + { + return await primary.GetAsync(name, cancellationToken).ConfigureAwait(false) + ?? await fallback.GetAsync(name, cancellationToken).ConfigureAwait(false); + } public Stream GetStream(string name) => primary.GetStream(name) ?? fallback.GetStream(name); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 8342d3bcc1..b2944ad219 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -99,7 +99,7 @@ namespace osu.Game.Scoring var scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = score.Mods; - return (long)Math.Round(scoreProcessor.ComputeScore(mode, score)); + return scoreProcessor.ComputeScore(mode, score); } /// diff --git a/osu.Game/Scoring/ScoringValues.cs b/osu.Game/Scoring/ScoringValues.cs index 9bc4e6e12a..471067c9db 100644 --- a/osu.Game/Scoring/ScoringValues.cs +++ b/osu.Game/Scoring/ScoringValues.cs @@ -20,13 +20,13 @@ namespace osu.Game.Scoring /// The sum of all "basic" scoring values. See: and . /// [Key(0)] - public double BaseScore; + public long BaseScore; /// /// The sum of all "bonus" scoring values. See: and . /// [Key(1)] - public double BonusScore; + public long BonusScore; /// /// The highest achieved combo. diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 974e240552..20ef128ee9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -436,8 +436,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.OnDragEnd(e); - OnDragHandled?.Invoke(null); + dragOperation?.Cancel(); + dragOperation = null; + changeHandler?.EndChange(); + OnDragHandled?.Invoke(null); } } diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 3d18b00e75..d3c330c6d7 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.IO.Serialization; using osu.Game.Rulesets; @@ -19,7 +20,7 @@ using osu.Game.Screens.Edit.Compose.Components.Timeline; namespace osu.Game.Screens.Edit.Compose { - public class ComposeScreen : EditorScreenWithTimeline + public class ComposeScreen : EditorScreenWithTimeline, IGameplaySettings { [Resolved] private GameHost host { get; set; } @@ -27,6 +28,9 @@ namespace osu.Game.Screens.Edit.Compose [Resolved] private EditorClock clock { get; set; } + [Resolved] + private IGameplaySettings globalGameplaySettings { get; set; } + private Bindable clipboard { get; set; } private HitObjectComposer composer; @@ -157,5 +161,12 @@ namespace osu.Game.Screens.Edit.Compose } #endregion + + // Combo colour normalisation should not be applied in the editor. + // Note this doesn't affect editor test mode. + IBindable IGameplaySettings.ComboColourNormalisationAmount => new Bindable(); + + // Arguable. + IBindable IGameplaySettings.PositionalHitsoundsLevel => globalGameplaySettings.PositionalHitsoundsLevel; } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index bb390dfbf3..4c44117581 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -58,8 +58,7 @@ namespace osu.Game.Screens.Edit { [Cached(typeof(IBeatSnapProvider))] [Cached] - public class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider, - IGameplaySettings + public class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider { public override float BackgroundParallaxAmount => 0.1f; @@ -99,9 +98,6 @@ namespace osu.Game.Screens.Edit [Resolved(canBeNull: true)] private INotificationOverlay notifications { get; set; } - [Resolved] - private IGameplaySettings globalGameplaySettings { get; set; } - public readonly Bindable Mode = new Bindable(); public IBindable SamplePlaybackDisabled => samplePlaybackDisabled; @@ -1045,11 +1041,5 @@ namespace osu.Game.Screens.Edit { } } - - // Combo colour normalisation should not be applied in the editor. - IBindable IGameplaySettings.ComboColourNormalisationAmount => new Bindable(); - - // Arguable. - IBindable IGameplaySettings.PositionalHitsoundsLevel => globalGameplaySettings.PositionalHitsoundsLevel; } } diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 81d82130da..f83874e4a0 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -270,7 +270,7 @@ namespace osu.Game.Screens.Edit { IsSeeking &= Transforms.Any(); - if (track.Value?.IsRunning != true) + if (!IsRunning) { // seeking in the editor can happen while the track isn't running. // in this case we always want to expose ourselves as seeking (to avoid sample playback). diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index 94975b6b5e..ad09618279 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -1,8 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Beatmaps; @@ -17,7 +16,7 @@ namespace osu.Game.Screens.Edit.GameplayTest private readonly EditorState editorState; [Resolved] - private MusicController musicController { get; set; } + private MusicController musicController { get; set; } = null!; public EditorPlayer(Editor editor) : base(new PlayerConfiguration { ShowResults = false }) @@ -29,7 +28,12 @@ namespace osu.Game.Screens.Edit.GameplayTest protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) { var masterGameplayClockContainer = new MasterGameplayClockContainer(beatmap, gameplayStart); - masterGameplayClockContainer.Reset(editorState.Time); + + // Only reset the time to the current point if the editor is later than the normal start time (and the first object). + // This allows more sane test playing from the start of the beatmap (ie. correctly adding lead-in time). + if (editorState.Time > gameplayStart && editorState.Time > DrawableRuleset.Objects.FirstOrDefault()?.StartTime) + masterGameplayClockContainer.Reset(editorState.Time); + return masterGameplayClockContainer; } @@ -70,7 +74,6 @@ namespace osu.Game.Screens.Edit.GameplayTest { musicController.Stop(); - editorState.Time = GameplayClockContainer.CurrentTime; editor.RestoreState(editorState); return base.OnExiting(e); } diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index afba00274c..ac22fdce71 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -125,13 +125,11 @@ namespace osu.Game.Screens [BackgroundDependencyLoader] private void load(ShaderManager manager) { - loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED)); - loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.BLUR)); loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE)); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.BLUR)); loadTargets.Add(manager.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE)); - loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_3, FragmentShaderDescriptor.TEXTURE_ROUNDED)); loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_3, FragmentShaderDescriptor.TEXTURE)); } diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index 05a6d25303..4ec877b85a 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -224,8 +224,8 @@ namespace osu.Game.Screens.Menu { rulesetsScale.ScaleTo(0.8f, 1000); rulesets.FadeIn().ScaleTo(1).TransformSpacingTo(new Vector2(200, 0)); - welcomeText.FadeOut(); - triangles.FadeOut(); + welcomeText.FadeOut().Expire(); + triangles.FadeOut().Expire(); } using (BeginDelayedSequence(rulesets_2)) @@ -307,7 +307,7 @@ namespace osu.Game.Screens.Menu } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { InternalChildren = new Drawable[] { diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index c7bda4d8f8..4a20d7cb2b 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -89,7 +89,7 @@ namespace osu.Game.Screens.Menu private void load(IRenderer renderer, ShaderManager shaders) { texture = renderer.WhitePixel; - shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); } private readonly float[] temporalAmplitudes = new float[ChannelAmplitudes.AMPLITUDES_SIZE]; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 1c4d02bb11..2444729118 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); - Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.ComputeScore(ScoringMode.Standardised, Score.ScoreInfo)); + Score.ScoreInfo.TotalScore = ScoreProcessor.ComputeScore(ScoringMode.Standardised, Score.ScoreInfo); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs index 48908fb9a0..47b67fba00 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs @@ -171,12 +171,14 @@ namespace osu.Game.Screens.Play.HUD for (int i = 0; i < Flow.Count; i++) { Flow.SetLayoutPosition(orderedByScore[i], i); - orderedByScore[i].ScorePosition = i + 1; + orderedByScore[i].ScorePosition = CheckValidScorePosition(i + 1) ? i + 1 : null; } sorting.Validate(); } + protected virtual bool CheckValidScorePosition(int i) => true; + private class InputDisabledScrollContainer : OsuScrollContainer { public InputDisabledScrollContainer() diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index 2eec8253b3..0d03d8d090 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -50,7 +50,7 @@ namespace osu.Game.Screens.Play.HUD private OsuSpriteText positionText, scoreText, accuracyText, comboText, usernameText; - public BindableDouble TotalScore { get; } = new BindableDouble(); + public BindableLong TotalScore { get; } = new BindableLong(); public BindableDouble Accuracy { get; } = new BindableDouble(1); public BindableInt Combo { get; } = new BindableInt(); public BindableBool HasQuit { get; } = new BindableBool(); @@ -62,20 +62,22 @@ namespace osu.Game.Screens.Play.HUD private int? scorePosition; + private bool scorePositionIsSet; + public int? ScorePosition { get => scorePosition; set { - if (value == scorePosition) + // We always want to run once, as the incoming value may be null and require a visual update to "-". + if (value == scorePosition && scorePositionIsSet) return; scorePosition = value; - if (scorePosition.HasValue) - positionText.Text = $"#{scorePosition.Value.FormatRank()}"; + positionText.Text = scorePosition.HasValue ? $"#{scorePosition.Value.FormatRank()}" : "-"; + scorePositionIsSet = true; - positionText.FadeTo(scorePosition.HasValue ? 1 : 0); updateState(); } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index e7b2ce1672..c9f1571dfe 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Linq; using osu.Framework.Allocation; @@ -11,6 +9,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; @@ -23,10 +22,9 @@ using osuTK; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { + [Cached] public class BarHitErrorMeter : HitErrorMeter { - private const int judgement_line_width = 14; - [SettingSource("Judgement line thickness", "How thick the individual lines should be.")] public BindableNumber JudgementLineThickness { get; } = new BindableNumber(4) { @@ -44,28 +42,33 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters [SettingSource("Label style", "How to show early/late extremities")] public Bindable LabelStyle { get; } = new Bindable(LabelStyles.Icons); - private SpriteIcon arrow; - private UprightAspectMaintainingContainer labelEarly; - private UprightAspectMaintainingContainer labelLate; + private const int judgement_line_width = 14; - private Container colourBarsEarly; - private Container colourBarsLate; + private const int max_concurrent_judgements = 50; - private Container judgementsContainer; + private const int centre_marker_size = 8; private double maxHitWindow; private double floatingAverage; - private Container colourBars; - private Container arrowContainer; - private (HitResult result, double length)[] hitWindows; + private readonly DrawablePool judgementLinePool = new DrawablePool(50); - private const int max_concurrent_judgements = 50; + private SpriteIcon arrow = null!; + private UprightAspectMaintainingContainer labelEarly = null!; + private UprightAspectMaintainingContainer labelLate = null!; - private Drawable[] centreMarkerDrawables; + private Container colourBarsEarly = null!; + private Container colourBarsLate = null!; - private const int centre_marker_size = 8; + private Container judgementsContainer = null!; + + private Container colourBars = null!; + private Container arrowContainer = null!; + + private (HitResult result, double length)[] hitWindows = null!; + + private Drawable[]? centreMarkerDrawables; public BarHitErrorMeter() { @@ -88,6 +91,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Margin = new MarginPadding(2), Children = new Drawable[] { + judgementLinePool, colourBars = new Container { Name = "colour axis", @@ -403,11 +407,12 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } - judgementsContainer.Add(new JudgementLine + judgementLinePool.Get(drawableJudgement => { - JudgementLineThickness = { BindTarget = JudgementLineThickness }, - Y = getRelativeJudgementPosition(judgement.TimeOffset), - Colour = GetColourForHitResult(judgement.Type), + drawableJudgement.Y = getRelativeJudgementPosition(judgement.TimeOffset); + drawableJudgement.Colour = GetColourForHitResult(judgement.Type); + + judgementsContainer.Add(drawableJudgement); }); arrow.MoveToY( @@ -417,10 +422,13 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private float getRelativeJudgementPosition(double value) => Math.Clamp((float)((value / maxHitWindow) + 1) / 2, 0, 1); - internal class JudgementLine : CompositeDrawable + internal class JudgementLine : PoolableDrawable { public readonly BindableNumber JudgementLineThickness = new BindableFloat(); + [Resolved] + private BarHitErrorMeter barHitErrorMeter { get; set; } = null!; + public JudgementLine() { RelativeSizeAxes = Axes.X; @@ -439,16 +447,22 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters protected override void LoadComplete() { + base.LoadComplete(); + + JudgementLineThickness.BindTo(barHitErrorMeter.JudgementLineThickness); + JudgementLineThickness.BindValueChanged(thickness => Height = thickness.NewValue, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + const int judgement_fade_in_duration = 100; const int judgement_fade_out_duration = 5000; - base.LoadComplete(); - Alpha = 0; Width = 0; - JudgementLineThickness.BindValueChanged(thickness => Height = thickness.NewValue, true); - this .FadeTo(0.6f, judgement_fade_in_duration, Easing.OutQuint) .ResizeWidthTo(1, judgement_fade_in_duration, Easing.OutQuint) diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs index dadec7c06b..86ba85168f 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -3,9 +3,11 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Shapes; using osu.Game.Configuration; using osu.Game.Rulesets.Judgements; @@ -15,6 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { + [Cached] public class ColourHitErrorMeter : HitErrorMeter { private const int animation_duration = 200; @@ -82,7 +85,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { base.LoadComplete(); - JudgementCount.BindValueChanged(count => + JudgementCount.BindValueChanged(_ => { removeExtraJudgements(); updateMetrics(); @@ -91,14 +94,17 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters JudgementSpacing.BindValueChanged(_ => updateMetrics(), true); } + private readonly DrawablePool judgementLinePool = new DrawablePool(50); + public void Push(Color4 colour) { - Add(new HitErrorShape(colour, drawable_judgement_size) + judgementLinePool.Get(shape => { - Shape = { BindTarget = JudgementShape }, - }); + shape.Colour = colour; + Add(shape); - removeExtraJudgements(); + removeExtraJudgements(); + }); } private void removeExtraJudgements() @@ -116,32 +122,32 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } - public class HitErrorShape : Container + public class HitErrorShape : PoolableDrawable { public bool IsRemoved { get; private set; } public readonly Bindable Shape = new Bindable(); - private readonly Color4 colour; + [Resolved] + private ColourHitErrorMeter hitErrorMeter { get; set; } = null!; private Container content = null!; - public HitErrorShape(Color4 colour, int size) + public HitErrorShape() { - this.colour = colour; - Size = new Vector2(size); + Size = new Vector2(drawable_judgement_size); } protected override void LoadComplete() { base.LoadComplete(); - Child = content = new Container + InternalChild = content = new Container { RelativeSizeAxes = Axes.Both, - Colour = colour }; + Shape.BindTo(hitErrorMeter.JudgementShape); Shape.BindValueChanged(shape => { switch (shape.NewValue) @@ -155,17 +161,32 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters break; } }, true); + } - content.FadeInFromZero(animation_duration, Easing.OutQuint); - content.MoveToY(-DrawSize.Y); - content.MoveToY(0, animation_duration, Easing.OutQuint); + protected override void PrepareForUse() + { + base.PrepareForUse(); + + this.FadeInFromZero(animation_duration, Easing.OutQuint) + // On pool re-use, start flow animation from (0,0). + .MoveTo(Vector2.Zero); + + content.MoveToY(-DrawSize.Y) + .MoveToY(0, animation_duration, Easing.OutQuint); + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + IsRemoved = false; } public void Remove() { IsRemoved = true; - this.FadeOut(animation_duration, Easing.OutQuint).Expire(); + this.FadeOut(animation_duration, Easing.OutQuint) + .Expire(); } } diff --git a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs index aa06bb08a5..428390f90c 100644 --- a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs @@ -9,7 +9,7 @@ namespace osu.Game.Screens.Play.HUD { public interface ILeaderboardScore { - BindableDouble TotalScore { get; } + BindableLong TotalScore { get; } BindableDouble Accuracy { get; } BindableInt Combo { get; } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 4201b3f4c9..4ac92056ef 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; @@ -184,7 +183,7 @@ namespace osu.Game.Screens.Play.HUD continue; if (TeamScores.TryGetValue(u.Team.Value, out var team)) - team.Value += (int)Math.Round(u.ScoreProcessor.TotalScore.Value); + team.Value += u.ScoreProcessor.TotalScore.Value; } } diff --git a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs index ab3cf2950c..0df3200adb 100644 --- a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs @@ -7,8 +7,10 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Configuration; +using osu.Game.Online.API.Requests; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Screens.Select; using osu.Game.Users; namespace osu.Game.Screens.Play.HUD @@ -18,6 +20,9 @@ namespace osu.Game.Screens.Play.HUD private const int duration = 100; private readonly Bindable configVisibility = new Bindable(); + + private readonly Bindable scoreSource = new Bindable(); + private readonly IUser trackingUser; public readonly IBindableList Scores = new BindableList(); @@ -46,11 +51,13 @@ namespace osu.Game.Screens.Play.HUD private void load(OsuConfigManager config) { config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); + config.BindWith(OsuSetting.BeatmapDetailTab, scoreSource); } protected override void LoadComplete() { base.LoadComplete(); + Scores.BindCollectionChanged((_, _) => Scheduler.AddOnce(showScores), true); // Alpha will be updated via `updateVisibility` below. @@ -93,6 +100,18 @@ namespace osu.Game.Screens.Play.HUD local.DisplayOrder.Value = long.MaxValue; } + protected override bool CheckValidScorePosition(int i) + { + // change displayed position to '-' when there are 50 already submitted scores and tracked score is last + if (scoreSource.Value != PlayBeatmapDetailArea.TabType.Local) + { + if (i == Flow.Count && Flow.Count > GetScoresRequest.MAX_SCORES_PER_REQUEST) + return false; + } + + return base.CheckValidScorePosition(i); + } + private void updateVisibility() => this.FadeTo(AlwaysVisible.Value || configVisibility.Value ? 1 : 0, duration); } diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 345bd5a134..fb296d7988 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -85,7 +85,7 @@ namespace osu.Game.Screens.Play api.Queue(req); // Generally a timeout would not happen here as APIAccess will timeout first. - if (!tcs.Task.Wait(60000)) + if (!tcs.Task.Wait(30000)) req.TriggerFailure(new InvalidOperationException("Token retrieval timed out (request never run)")); return true; diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs index 86fe76c0c6..f9fc2890b0 100644 --- a/osu.Game/Screens/Select/Footer.cs +++ b/osu.Game/Screens/Select/Footer.cs @@ -57,7 +57,18 @@ namespace osu.Game.Screens.Select } } - private void updateModeLight() => modeLight.FadeColour(buttons.FirstOrDefault(b => b.IsHovered)?.SelectedColour ?? Color4.Transparent, TRANSITION_LENGTH, Easing.OutQuint); + private void updateModeLight() + { + var selectedButton = buttons.FirstOrDefault(b => b.Enabled.Value && b.IsHovered); + + if (selectedButton != null) + { + modeLight.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); + modeLight.FadeColour(selectedButton.SelectedColour, TRANSITION_LENGTH, Easing.OutQuint); + } + else + modeLight.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); + } public Footer() { @@ -78,6 +89,7 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.X, Height = 3, Position = new Vector2(0, -3), + Colour = Color4.Black, }, new FillFlowContainer { diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index 3f8cf2e13a..230cdfc13e 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -120,10 +120,18 @@ namespace osu.Game.Screens.Select }; } + protected override void LoadComplete() + { + base.LoadComplete(); + Enabled.BindValueChanged(_ => updateDisplay(), true); + } + public Action Hovered; public Action HoverLost; public GlobalAction? Hotkey; + private bool mouseDown; + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -140,32 +148,38 @@ namespace osu.Game.Screens.Select protected override bool OnHover(HoverEvent e) { Hovered?.Invoke(); - light.ScaleTo(new Vector2(1, 2), Footer.TRANSITION_LENGTH, Easing.OutQuint); - light.FadeColour(SelectedColour, Footer.TRANSITION_LENGTH, Easing.OutQuint); + updateDisplay(); return true; } protected override void OnHoverLost(HoverLostEvent e) { HoverLost?.Invoke(); - light.ScaleTo(new Vector2(1, 1), Footer.TRANSITION_LENGTH, Easing.OutQuint); - light.FadeColour(DeselectedColour, Footer.TRANSITION_LENGTH, Easing.OutQuint); + updateDisplay(); } protected override bool OnMouseDown(MouseDownEvent e) { - box.FadeTo(0.3f, Footer.TRANSITION_LENGTH * 2, Easing.OutQuint); + if (!Enabled.Value) + return true; + + mouseDown = true; + updateDisplay(); return base.OnMouseDown(e); } protected override void OnMouseUp(MouseUpEvent e) { - box.FadeOut(Footer.TRANSITION_LENGTH, Easing.OutQuint); + mouseDown = false; + updateDisplay(); base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) { + if (!Enabled.Value) + return true; + box.ClearTransforms(); box.Alpha = 1; box.FadeOut(Footer.TRANSITION_LENGTH * 3, Easing.OutQuint); @@ -184,5 +198,20 @@ namespace osu.Game.Screens.Select } public virtual void OnReleased(KeyBindingReleaseEvent e) { } + + private void updateDisplay() + { + this.FadeTo(Enabled.Value ? 1 : 0.25f, Footer.TRANSITION_LENGTH, Easing.OutQuint); + + light.ScaleTo(Enabled.Value && IsHovered ? new Vector2(1, 2) : new Vector2(1), Footer.TRANSITION_LENGTH, Easing.OutQuint); + light.FadeColour(Enabled.Value && IsHovered ? SelectedColour : DeselectedColour, Footer.TRANSITION_LENGTH, Easing.OutQuint); + + box.FadeTo(Enabled.Value & mouseDown ? 0.3f : 0f, Footer.TRANSITION_LENGTH * 2, Easing.OutQuint); + + if (Enabled.Value && IsHovered) + Hovered?.Invoke(); + else + HoverLost?.Invoke(); + } } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 1add51e725..5d5019567a 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -112,6 +112,8 @@ namespace osu.Game.Screens.Select protected BeatmapDetailArea BeatmapDetails { get; private set; } + private FooterButtonOptions beatmapOptionsButton; + private readonly Bindable decoupledRuleset = new Bindable(); private double audioFeedbackLastPlaybackTime; @@ -314,7 +316,7 @@ namespace osu.Game.Screens.Select NextRandom = () => Carousel.SelectNextRandom(), PreviousRandom = Carousel.SelectPreviousRandom }, null), - (new FooterButtonOptions(), BeatmapOptions) + (beatmapOptionsButton = new FooterButtonOptions(), BeatmapOptions) }; protected virtual ModSelectOverlay CreateModSelectOverlay() => new SoloModSelectOverlay(); @@ -739,6 +741,16 @@ namespace osu.Game.Screens.Select beatmapInfoWedge.Beatmap = beatmap; BeatmapDetails.Beatmap = beatmap; + + bool beatmapSelected = beatmap is not DummyWorkingBeatmap; + + if (beatmapSelected) + beatmapOptionsButton.Enabled.Value = true; + else + { + beatmapOptionsButton.Enabled.Value = false; + BeatmapOptions.Hide(); + } } private readonly WeakReference lastTrack = new WeakReference(null); diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs index a2eb07eba3..6a0c4a23e5 100644 --- a/osu.Game/Skinning/ArgonSkin.cs +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -82,21 +82,14 @@ namespace osu.Game.Skinning public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { + // Temporary until default skin has a valid hit lighting. + if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); + if (base.GetDrawableComponent(lookup) is Drawable c) return c; switch (lookup) { - case SkinnableSprite.SpriteComponentLookup spriteLookup: - switch (spriteLookup.LookupName) - { - // Temporary until default skin has a valid hit lighting. - case @"lighting": - return Drawable.Empty(); - } - - break; - case GlobalSkinComponentLookup globalLookup: switch (globalLookup.Lookup) { diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 98618e3dcd..ea223d172d 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -396,9 +396,6 @@ namespace osu.Game.Skinning } return null; - - case SkinnableSprite.SpriteComponentLookup sprite: - return this.GetAnimation(sprite.LookupName, false, false); } return null; diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index e222b9017c..25d1dc903c 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -158,6 +158,10 @@ namespace osu.Game.Skinning { switch (lookup) { + // This fallback is important for user skins which use SkinnableSprites. + case SkinnableSprite.SpriteComponentLookup sprite: + return this.GetAnimation(sprite.LookupName, false, false); + case GlobalSkinComponentLookup target: if (!DrawableComponentInfo.TryGetValue(target.Lookup, out var skinnableInfo)) return null; diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index 2075cfb6f2..62ef94691b 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -60,21 +60,14 @@ namespace osu.Game.Skinning public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { + // Temporary until default skin has a valid hit lighting. + if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); + if (base.GetDrawableComponent(lookup) is Drawable c) return c; switch (lookup) { - case SkinnableSprite.SpriteComponentLookup spriteLookup: - switch (spriteLookup.LookupName) - { - // Temporary until default skin has a valid hit lighting. - case @"lighting": - return Drawable.Empty(); - } - - break; - case GlobalSkinComponentLookup target: switch (target.Lookup) { diff --git a/osu.Game/Tests/PollingNotificationsClient.cs b/osu.Game/Tests/PollingNotificationsClient.cs new file mode 100644 index 0000000000..c1f032a647 --- /dev/null +++ b/osu.Game/Tests/PollingNotificationsClient.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.Notifications; + +namespace osu.Game.Tests +{ + /// + /// A notifications client which polls for new messages every second. + /// + public class PollingNotificationsClient : NotificationsClient + { + public PollingNotificationsClient(IAPIProvider api) + : base(api) + { + } + + public override Task ConnectAsync(CancellationToken cancellationToken) + { + Task.Run(async () => + { + while (!cancellationToken.IsCancellationRequested) + { + await API.PerformAsync(CreateFetchMessagesRequest()); + await Task.Delay(1000, cancellationToken); + } + }, cancellationToken); + + return Task.CompletedTask; + } + } +} diff --git a/osu.Game/Tests/PollingNotificationsClientConnector.cs b/osu.Game/Tests/PollingNotificationsClientConnector.cs new file mode 100644 index 0000000000..823fc9d157 --- /dev/null +++ b/osu.Game/Tests/PollingNotificationsClientConnector.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.Notifications; + +namespace osu.Game.Tests +{ + /// + /// A connector for s that poll for new messages. + /// + public class PollingNotificationsClientConnector : NotificationsClientConnector + { + public PollingNotificationsClientConnector(IAPIProvider api) + : base(api) + { + } + + protected override Task BuildNotificationClientAsync(CancellationToken cancellationToken) + => Task.FromResult((NotificationsClient)new PollingNotificationsClient(API)); + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 09b1bb7162..858cac1dac 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -35,8 +35,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index 4264d9220e..25217b872b 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,8 +61,8 @@ - - + + @@ -82,7 +82,7 @@ - +