diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a88f1320cd..d75f09f184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: build-only-android: name: Build only (Android) - runs-on: windows-2019 + runs-on: windows-latest timeout-minutes: 60 steps: - name: Checkout @@ -114,10 +114,7 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET workloads - # since windows image 20241113.3.0, not specifying a version here - # installs the .NET 7 version of android workload for very unknown reasons. - # revisit once we upgrade to .NET 9, it's probably fixed there. - run: dotnet workload install android --version (dotnet --version) + run: dotnet workload install android - name: Compile run: dotnet build -c Debug osu.Android.slnf diff --git a/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml b/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000000..4432459b86 --- /dev/null +++ b/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 0f2451f0a0..e725f9245f 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -12,6 +12,7 @@ using osu.Game; using osu.Game.Screens; using osu.Game.Updater; using osu.Game.Utils; +using osuTK; namespace osu.Android { @@ -20,6 +21,8 @@ namespace osu.Android [Cached] private readonly OsuGameActivity gameActivity; + protected override Vector2 ScalingContainerTargetDrawSize => new Vector2(1024, 1024 * DrawHeight / DrawWidth); + public OsuGameAndroid(OsuGameActivity activity) : base(null) { diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs index a1c81d3a6a..6098459f6b 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -12,6 +13,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; using osuTK; using osuTK.Graphics; @@ -26,18 +28,22 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon [Resolved] private OsuColour colours { get; set; } = null!; + private IBindable direction = null!; + public ArgonJudgementPiece(HitResult result) : base(result) { AutoSizeAxes = Axes.Both; Origin = Anchor.Centre; - Y = judgement_y_position; } [BackgroundDependencyLoader] - private void load() + private void load(IScrollingInfo scrollingInfo) { + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => onDirectionChanged(), true); + if (Result.IsHit()) { AddInternal(ringExplosion = new RingExplosion(Result) @@ -47,6 +53,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon } } + private void onDirectionChanged() => Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position; + protected override SpriteText CreateJudgementText() => new OsuSpriteText { @@ -78,7 +86,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon this.ScaleTo(1.6f); this.ScaleTo(1, 100, Easing.In); - this.MoveToY(judgement_y_position); + this.MoveToY(direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position); this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); this.RotateTo(0); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs index 4b0cc482d9..3752c5f27a 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs @@ -2,12 +2,14 @@ // 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.Animations; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Skinning.Legacy @@ -28,14 +30,16 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy AutoSizeAxes = Axes.Both; } - [BackgroundDependencyLoader] - private void load(ISkinSource skin) - { - float hitPosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? 0; - float scorePosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ScorePosition)?.Value ?? 0; + private IBindable direction = null!; - float absoluteHitPosition = 480f * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR - hitPosition; - Y = scorePosition - absoluteHitPosition; + [Resolved] + private ISkinSource skin { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => onDirectionChanged(), true); InternalChild = animation.With(d => { @@ -44,6 +48,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }); } + private void onDirectionChanged() + { + float hitPosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? 0; + float scorePosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ScorePosition)?.Value ?? 0; + + float absoluteHitPosition = 480f * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR - hitPosition; + float finalPosition = scorePosition - absoluteHitPosition; + + Y = direction.Value == ScrollingDirection.Up ? -finalPosition : finalPosition; + } + public void PlayAnimation() { (animation as IFramedAnimation)?.GotoFrame(0); diff --git a/osu.Game.Rulesets.Mania/UI/DefaultManiaJudgementPiece.cs b/osu.Game.Rulesets.Mania/UI/DefaultManiaJudgementPiece.cs new file mode 100644 index 0000000000..f0af6085d0 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/DefaultManiaJudgementPiece.cs @@ -0,0 +1,75 @@ +// 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.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Mania.UI +{ + public partial class DefaultManiaJudgementPiece : DefaultJudgementPiece + { + private const float judgement_y_position = -180f; + + private IBindable direction = null!; + + public DefaultManiaJudgementPiece(HitResult result) + : base(result) + { + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => onDirectionChanged(), true); + } + + private void onDirectionChanged() => Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position; + + protected override void LoadComplete() + { + base.LoadComplete(); + + JudgementText.Font = JudgementText.Font.With(size: 25); + } + + public override void PlayAnimation() + { + switch (Result) + { + case HitResult.None: + this.FadeOutFromOne(800); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + this.MoveToY(direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); + + this.FadeOutFromOne(800); + break; + + default: + this.ScaleTo(0.8f); + this.ScaleTo(1, 250, Easing.OutElastic); + + this.Delay(50) + .ScaleTo(0.75f, 250) + .FadeOut(200); + + // osu!mania uses a custom fade length, so the base call is intentionally omitted. + break; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index 75f56bffa4..40fef1a56a 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -3,65 +3,32 @@ #nullable disable +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osuTK; +using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Mania.UI { public partial class DrawableManiaJudgement : DrawableJudgement { - protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); + private IBindable direction; - private partial class DefaultManiaJudgementPiece : DefaultJudgementPiece + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) { - private const float judgement_y_position = -180f; - - public DefaultManiaJudgementPiece(HitResult result) - : base(result) - { - Y = judgement_y_position; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - JudgementText.Font = JudgementText.Font.With(size: 25); - } - - public override void PlayAnimation() - { - switch (Result) - { - case HitResult.None: - this.FadeOutFromOne(800); - break; - - case HitResult.Miss: - this.ScaleTo(1.6f); - this.ScaleTo(1, 100, Easing.In); - - this.MoveToY(judgement_y_position); - this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); - - this.RotateTo(0); - this.RotateTo(40, 800, Easing.InQuint); - - this.FadeOutFromOne(800); - break; - - default: - this.ScaleTo(0.8f); - this.ScaleTo(1, 250, Easing.OutElastic); - - this.Delay(50) - .ScaleTo(0.75f, 250) - .FadeOut(200); - break; - } - } + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => onDirectionChanged(), true); } + + private void onDirectionChanged() + { + Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + Origin = Anchor.Centre; + } + + protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); } } diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index fb9671c14d..faa9fc318c 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -216,13 +216,7 @@ namespace osu.Game.Rulesets.Mania.UI return; judgements.Clear(false); - judgements.Add(judgementPooler.Get(result.Type, j => - { - j.Apply(result, judgedObject); - - j.Anchor = Anchor.BottomCentre; - j.Origin = Anchor.Centre; - })!); + judgements.Add(judgementPooler.Get(result.Type, j => j.Apply(result, judgedObject))!); } protected override void Update() diff --git a/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs index 4fadb09948..db789166c6 100644 --- a/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; @@ -13,8 +14,9 @@ namespace osu.Game.Rulesets.Osu.Skinning { public abstract partial class FollowCircle : CompositeDrawable { - [Resolved] - protected DrawableHitObject? ParentObject { get; private set; } + protected DrawableSlider? DrawableObject { get; private set; } + + private readonly IBindable tracking = new Bindable(); protected FollowCircle() { @@ -22,65 +24,73 @@ namespace osu.Game.Rulesets.Osu.Skinning } [BackgroundDependencyLoader] - private void load() + private void load(DrawableHitObject? hitObject) { - ((DrawableSlider?)ParentObject)?.Tracking.BindValueChanged(tracking => + DrawableObject = hitObject as DrawableSlider; + + if (DrawableObject != null) { - Debug.Assert(ParentObject != null); - - if (ParentObject.Judged) - return; - - using (BeginAbsoluteSequence(Math.Max(Time.Current, ParentObject.HitObject?.StartTime ?? 0))) + tracking.BindTo(DrawableObject.Tracking); + tracking.BindValueChanged(tracking => { - if (tracking.NewValue) - OnSliderPress(); - else - OnSliderRelease(); - } - }, true); + if (DrawableObject.Judged) + return; + + using (BeginAbsoluteSequence(Math.Max(Time.Current, DrawableObject.HitObject?.StartTime ?? 0))) + { + if (tracking.NewValue) + OnSliderPress(); + else + OnSliderRelease(); + } + }, true); + } } protected override void LoadComplete() { base.LoadComplete(); - if (ParentObject != null) + if (DrawableObject != null) { - ParentObject.HitObjectApplied += onHitObjectApplied; - onHitObjectApplied(ParentObject); + DrawableObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(DrawableObject); - ParentObject.ApplyCustomUpdateState += updateStateTransforms; - updateStateTransforms(ParentObject, ParentObject.State.Value); + DrawableObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(DrawableObject, DrawableObject.State.Value); } } private void onHitObjectApplied(DrawableHitObject drawableObject) { + // Sane defaults when a new hitobject is applied to the drawable slider. this.ScaleTo(1f) .FadeOut(); + + // Immediately play out any pending transforms from press/release + FinishTransforms(true); } - private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState state) + private void updateStateTransforms(DrawableHitObject d, ArmedState state) { - Debug.Assert(ParentObject != null); + Debug.Assert(DrawableObject != null); switch (state) { case ArmedState.Hit: - switch (drawableObject) + switch (d) { case DrawableSliderTail: - // Use ParentObject instead of drawableObject because slider tail's + // Use DrawableObject instead of local object because slider tail's // HitStateUpdateTime is ~36ms before the actual slider end (aka slider // tail leniency) - using (BeginAbsoluteSequence(ParentObject.HitStateUpdateTime)) + using (BeginAbsoluteSequence(DrawableObject.HitStateUpdateTime)) OnSliderEnd(); break; case DrawableSliderTick: case DrawableSliderRepeat: - using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + using (BeginAbsoluteSequence(d.HitStateUpdateTime)) OnSliderTick(); break; } @@ -88,15 +98,15 @@ namespace osu.Game.Rulesets.Osu.Skinning break; case ArmedState.Miss: - switch (drawableObject) + switch (d) { case DrawableSliderTail: case DrawableSliderTick: case DrawableSliderRepeat: - // Despite above comment, ok to use drawableObject.HitStateUpdateTime + // Despite above comment, ok to use d.HitStateUpdateTime // here, since on stable, the break anim plays right when the tail is // missed, not when the slider ends - using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + using (BeginAbsoluteSequence(d.HitStateUpdateTime)) OnSliderBreak(); break; } @@ -109,10 +119,10 @@ namespace osu.Game.Rulesets.Osu.Skinning { base.Dispose(isDisposing); - if (ParentObject != null) + if (DrawableObject != null) { - ParentObject.HitObjectApplied -= onHitObjectApplied; - ParentObject.ApplyCustomUpdateState -= updateStateTransforms; + DrawableObject.HitObjectApplied -= onHitObjectApplied; + DrawableObject.ApplyCustomUpdateState -= updateStateTransforms; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs index 4a8b737206..f60b5cfe12 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs @@ -22,9 +22,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy protected override void OnSliderPress() { - Debug.Assert(ParentObject != null); + Debug.Assert(DrawableObject != null); - double remainingTime = Math.Max(0, ParentObject.HitStateUpdateTime - Time.Current); + double remainingTime = Math.Max(0, DrawableObject.HitStateUpdateTime - Time.Current); // Note that the scale adjust here is 2 instead of DrawableSliderBall.FOLLOW_AREA to match legacy behaviour. // This means the actual tracking area for gameplay purposes is larger than the sprite (but skins may be accounting for this). diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs index c130b5f366..286b16aa34 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning { Anchor = Anchor.Centre, Origin = Anchor.Centre, + Scale = new osuTK.Vector2(0.5f), })); } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 28617b35f6..6ad14c87d1 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -6,14 +6,9 @@ using System; using System.Linq; using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; -using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Skinning.Default; @@ -25,11 +20,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public partial class DrawableSwell : DrawableTaikoHitObject { - private const float target_ring_thick_border = 1.4f; - private const float target_ring_thin_border = 1f; - private const float target_ring_scale = 5f; - private const float inner_ring_alpha = 0.65f; - /// /// Offset away from the start time of the swell at which the ring starts appearing. /// @@ -38,9 +28,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private Vector2 baseSize; private readonly Container ticks; - private readonly Container bodyContainer; - private readonly CircularContainer targetRing; - private readonly CircularContainer expandingRing; private double? lastPressHandleTime; @@ -51,6 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// public bool MustAlternate { get; internal set; } = true; + public event Action UpdateHitProgress; + public DrawableSwell() : this(null) { @@ -61,87 +50,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { FillMode = FillMode.Fit; - Content.Add(bodyContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Depth = 1, - Children = new Drawable[] - { - expandingRing = new CircularContainer - { - Name = "Expanding ring", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0, - RelativeSizeAxes = Axes.Both, - Blending = BlendingParameters.Additive, - Masking = true, - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = inner_ring_alpha, - } - } - }, - targetRing = new CircularContainer - { - Name = "Target ring (thick border)", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = target_ring_thick_border, - Blending = BlendingParameters.Additive, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }, - new CircularContainer - { - Name = "Target ring (thin border)", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = target_ring_thin_border, - BorderColour = Color4.White, - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } - } - } - } - } - }); - AddInternal(ticks = new Container { RelativeSizeAxes = Axes.Both }); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - expandingRing.Colour = colours.YellowLight; - targetRing.BorderColour = colours.YellowDark.Opacity(0.25f); - } - protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Swell), - _ => new SwellCirclePiece + _ => new DefaultSwell { - // to allow for rotation transform Anchor = Anchor.Centre, Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, }); protected override void RecreatePieces() @@ -208,16 +125,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables int numHits = ticks.Count(r => r.IsHit); - float completion = (float)numHits / HitObject.RequiredHits; - - expandingRing - .FadeTo(expandingRing.Alpha + Math.Clamp(completion / 16, 0.1f, 0.6f), 50) - .Then() - .FadeTo(completion / 8, 2000, Easing.OutQuint); - - MainPiece.Drawable.RotateTo((float)(completion * HitObject.Duration / 8), 4000, Easing.OutQuint); - - expandingRing.ScaleTo(1f + Math.Min(target_ring_scale - 1f, (target_ring_scale - 1f) * completion * 1.3f), 260, Easing.OutQuint); + UpdateHitProgress?.Invoke(numHits); if (numHits == HitObject.RequiredHits) ApplyMaxResult(); @@ -248,28 +156,21 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } } - protected override void UpdateStartTimeStateTransforms() - { - base.UpdateStartTimeStateTransforms(); - - using (BeginDelayedSequence(-ring_appear_offset)) - targetRing.ScaleTo(target_ring_scale, 400, Easing.OutQuint); - } - protected override void UpdateHitStateTransforms(ArmedState state) { - const double transition_duration = 300; + base.UpdateHitStateTransforms(state); switch (state) { case ArmedState.Idle: - expandingRing.FadeTo(0); break; case ArmedState.Miss: + this.Delay(300).FadeOut(); + break; + case ArmedState.Hit: - this.FadeOut(transition_duration, Easing.Out); - bodyContainer.ScaleTo(1.4f, transition_duration); + this.Delay(660).FadeOut(); break; } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwell.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwell.cs new file mode 100644 index 0000000000..3b3684d219 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwell.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Taiko.Skinning.Default; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonSwell : DefaultSwell + { + protected override Drawable CreateCentreCircle() + { + return new ArgonSwellCirclePiece + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs index bfc9e8648d..26bb1900b9 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs @@ -69,7 +69,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon return new ArgonHitExplosion(taikoComponent.Component); case TaikoSkinComponents.Swell: - return new ArgonSwellCirclePiece(); + return new ArgonSwell(); } break; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultSwell.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultSwell.cs new file mode 100644 index 0000000000..ac72ba73b8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultSwell.cs @@ -0,0 +1,190 @@ +// 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.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Default +{ + public partial class DefaultSwell : Container + { + private const float target_ring_thick_border = 1.4f; + private const float target_ring_thin_border = 1f; + private const float target_ring_scale = 5f; + private const float inner_ring_alpha = 0.65f; + + private DrawableSwell drawableSwell = null!; + + private readonly Container bodyContainer; + private readonly CircularContainer targetRing; + private readonly CircularContainer expandingRing; + private readonly Drawable centreCircle; + private int numHits; + + public DefaultSwell() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; + + Content.Add(bodyContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Depth = 1, + Children = new[] + { + expandingRing = new CircularContainer + { + Name = "Expanding ring", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Masking = true, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = inner_ring_alpha, + } + } + }, + targetRing = new CircularContainer + { + Name = "Target ring (thick border)", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = target_ring_thick_border, + Blending = BlendingParameters.Additive, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new CircularContainer + { + Name = "Target ring (thin border)", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = target_ring_thin_border, + BorderColour = Color4.White, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + } + }, + centreCircle = CreateCentreCircle(), + } + }); + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject hitObject, OsuColour colours) + { + drawableSwell = (DrawableSwell)hitObject; + drawableSwell.UpdateHitProgress += animateSwellProgress; + drawableSwell.ApplyCustomUpdateState += updateStateTransforms; + + expandingRing.Colour = colours.YellowLight; + targetRing.BorderColour = colours.YellowDark.Opacity(0.25f); + } + + protected virtual Drawable CreateCentreCircle() + { + return new SwellCirclePiece + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + private void animateSwellProgress(int numHits) + { + this.numHits = numHits; + + float completion = (float)numHits / drawableSwell.HitObject.RequiredHits; + expandingRing.Alpha += Math.Clamp(completion / 16, 0.1f, 0.6f); + } + + protected override void Update() + { + base.Update(); + + float completion = (float)numHits / drawableSwell.HitObject.RequiredHits; + + centreCircle.Rotation = (float)Interpolation.DampContinuously(centreCircle.Rotation, + (float)(completion * drawableSwell.HitObject.Duration / 8), 500, Math.Abs(Time.Elapsed)); + expandingRing.Scale = new Vector2((float)Interpolation.DampContinuously(expandingRing.Scale.X, + 1f + Math.Min(target_ring_scale - 1f, (target_ring_scale - 1f) * completion * 1.3f), 35, Math.Abs(Time.Elapsed))); + expandingRing.Alpha = (float)Interpolation.DampContinuously(expandingRing.Alpha, completion / 16, 250, Math.Abs(Time.Elapsed)); + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSwell)) + return; + + Swell swell = drawableSwell.HitObject; + + using (BeginAbsoluteSequence(swell.StartTime)) + { + if (state == ArmedState.Idle) + expandingRing.FadeTo(0); + + const double ring_appear_offset = 100; + + targetRing.Delay(ring_appear_offset).ScaleTo(target_ring_scale, 400, Easing.OutQuint); + } + + using (BeginAbsoluteSequence(drawableSwell.HitStateUpdateTime)) + { + const double transition_duration = 300; + + bodyContainer.FadeOut(transition_duration, Easing.OutQuad); + bodyContainer.ScaleTo(1.4f, transition_duration); + centreCircle.FadeOut(transition_duration, Easing.OutQuad); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSwell.IsNotNull()) + { + drawableSwell.UpdateHitProgress -= animateSwellProgress; + drawableSwell.ApplyCustomUpdateState -= updateStateTransforms; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/SwellSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/SwellCirclePiece.cs similarity index 100% rename from osu.Game.Rulesets.Taiko/Skinning/Default/SwellSymbolPiece.cs rename to osu.Game.Rulesets.Taiko/Skinning/Default/SwellCirclePiece.cs diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacySwell.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacySwell.cs new file mode 100644 index 0000000000..9f1b692984 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacySwell.cs @@ -0,0 +1,201 @@ +// 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.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Game.Skinning; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Audio; +using osuTK; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Framework.Extensions.ObjectExtensions; +using System; +using System.Globalization; +using osu.Framework.Utils; + +namespace osu.Game.Rulesets.Taiko.Skinning.Legacy +{ + public partial class LegacySwell : Container + { + private const float scale_adjust = 768f / 480; + private static readonly Vector2 swell_display_position = new Vector2(250f, 100f); + + private DrawableSwell drawableSwell = null!; + + private Container bodyContainer = null!; + private Sprite warning = null!; + private Sprite spinnerCircle = null!; + private Sprite approachCircle = null!; + private Sprite clearAnimation = null!; + private SkinnableSound clearSample = null!; + private LegacySpriteText remainingHitsText = null!; + + private bool samplePlayed; + + private int numHits; + + public LegacySwell() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject hitObject, ISkinSource skin) + { + Children = new Drawable[] + { + warning = new Sprite + { + Texture = skin.GetTexture("spinner-warning"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = skin.GetTexture("spinner-warning") != null ? Vector2.One : new Vector2(0.18f), + }, + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Position = swell_display_position, // ballparked to be horizontally centred on 4:3 resolution + + Children = new Drawable[] + { + bodyContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + + Children = new Drawable[] + { + spinnerCircle = new Sprite + { + Texture = skin.GetTexture("spinner-circle"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.8f), + }, + approachCircle = new Sprite + { + Texture = skin.GetTexture("spinner-approachcircle"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.86f * 0.8f), + Alpha = 0.8f, + }, + remainingHitsText = new LegacySpriteText(LegacyFont.Score) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Position = new Vector2(0f, 130f), + Scale = Vector2.One, + }, + } + }, + clearAnimation = new Sprite + { + Texture = skin.GetTexture("spinner-osu"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Y = -40, + }, + }, + }, + clearSample = new SkinnableSound(new SampleInfo("spinner-osu")), + }; + + drawableSwell = (DrawableSwell)hitObject; + drawableSwell.UpdateHitProgress += animateSwellProgress; + drawableSwell.ApplyCustomUpdateState += updateStateTransforms; + } + + private void animateSwellProgress(int numHits) + { + this.numHits = numHits; + remainingHitsText.Text = (drawableSwell.HitObject.RequiredHits - numHits).ToString(CultureInfo.InvariantCulture); + spinnerCircle.Scale = new Vector2(Math.Min(0.94f, spinnerCircle.Scale.X + 0.02f)); + } + + protected override void Update() + { + base.Update(); + + int requiredHits = drawableSwell.HitObject.RequiredHits; + int remainingHits = requiredHits - numHits; + remainingHitsText.Scale = new Vector2((float)Interpolation.DampContinuously( + remainingHitsText.Scale.X, 1.6f - (0.6f * ((float)remainingHits / requiredHits)), 17.5, Math.Abs(Time.Elapsed))); + + spinnerCircle.Rotation = (float)Interpolation.DampContinuously(spinnerCircle.Rotation, 180f * numHits, 130, Math.Abs(Time.Elapsed)); + spinnerCircle.Scale = new Vector2((float)Interpolation.DampContinuously( + spinnerCircle.Scale.X, 0.8f, 120, Math.Abs(Time.Elapsed))); + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSwell)) + return; + + Swell swell = drawableSwell.HitObject; + + using (BeginAbsoluteSequence(swell.StartTime)) + { + if (state == ArmedState.Idle) + { + remainingHitsText.Text = $"{swell.RequiredHits}"; + samplePlayed = false; + } + + const double body_transition_duration = 200; + + warning.MoveTo(swell_display_position, body_transition_duration) + .ScaleTo(3, body_transition_duration, Easing.Out) + .FadeOut(body_transition_duration); + + bodyContainer.FadeIn(body_transition_duration); + approachCircle.ResizeTo(0.1f * 0.8f, swell.Duration); + } + + using (BeginAbsoluteSequence(drawableSwell.HitStateUpdateTime)) + { + const double clear_transition_duration = 300; + const double clear_fade_in = 120; + + bodyContainer.FadeOut(clear_transition_duration, Easing.OutQuad); + spinnerCircle.ScaleTo(spinnerCircle.Scale.X + 0.05f, clear_transition_duration, Easing.OutQuad); + + if (state == ArmedState.Hit) + { + if (!samplePlayed) + { + clearSample.Play(); + samplePlayed = true; + } + + clearAnimation + .MoveToOffset(new Vector2(0, -90 * scale_adjust), clear_fade_in * 2, Easing.Out) + .ScaleTo(0.4f) + .ScaleTo(1f, clear_fade_in * 2, Easing.Out) + .FadeIn() + .Delay(clear_fade_in * 3) + .FadeOut(clear_fade_in * 2.5); + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSwell.IsNotNull()) + { + drawableSwell.UpdateHitProgress -= animateSwellProgress; + drawableSwell.ApplyCustomUpdateState -= updateStateTransforms; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index 5bdb824f1c..c6221e0589 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -66,7 +66,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy return this.GetAnimation("sliderscorepoint", false, false); case TaikoSkinComponents.Swell: - // todo: support taiko legacy swell (https://github.com/ppy/osu/issues/13601). + if (GetTexture("spinner-circle") != null) + return new LegacySwell(); + return null; case TaikoSkinComponents.HitTarget: diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index decb0a31ac..2964ca9396 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -6,6 +6,7 @@ using System.Linq; using Moq; using NUnit.Framework; using osu.Framework.Localisation; +using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -342,6 +343,40 @@ namespace osu.Game.Tests.Mods Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x"); } + [Test] + public void TestRoomModValidity() + { + Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModHardRock(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidModForMatchType(new ModAdaptiveSpeed(), MatchType.Playlists)); + Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModAutoplay(), MatchType.Playlists)); + Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModTouchDevice(), MatchType.Playlists)); + + Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModHardRock(), MatchType.HeadToHead)); + Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead)); + // For now, adaptive speed isn't allowed in multiplayer because it's a per-user rate adjustment. + Assert.IsFalse(ModUtils.IsValidModForMatchType(new ModAdaptiveSpeed(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModAutoplay(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead)); + } + + [Test] + public void TestRoomFreeModValidity() + { + Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModDoubleTime(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new ModAdaptiveSpeed(), MatchType.Playlists)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModAutoplay(), MatchType.Playlists)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.Playlists)); + + Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.HeadToHead)); + // For now, all rate adjustment mods aren't allowed as free mods in multiplayer. + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new ModAdaptiveSpeed(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModAutoplay(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead)); + } + public abstract class CustomMod1 : Mod, IModCompatibilitySpecification { } diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk new file mode 100644 index 0000000000..74abef25ca Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk differ diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index 55836302e6..5b343c80c5 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -73,6 +73,8 @@ namespace osu.Game.Tests.Skins "Archives/modified-default-20241207.osk", // Covers skinnable spectator list "Archives/modified-argon-20250116.osk", + // Covers player team flag + "Archives/modified-argon-20250214.osk", }; /// diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 0f2f716a07..60675018e9 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -376,6 +376,6 @@ namespace osu.Game.Tests.Visual.Collections private void assertCollectionName(int index, string name) => AddUntilStep($"item {index + 1} has correct name", - () => dialog.ChildrenOfType().Single().OrderedItems.ElementAt(index).ChildrenOfType().First().Text == name); + () => dialog.ChildrenOfType().Single().OrderedItems.ElementAtOrDefault(index)?.ChildrenOfType().First().Text == name); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs index e3e8c0de39..f83d424d56 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs @@ -24,7 +24,11 @@ namespace osu.Game.Tests.Visual.Editing Child = new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, - CachedDependencies = new[] { (typeof(ScreenFooter), (object)footer) }, + CachedDependencies = new[] + { + (typeof(ScreenFooter), (object)footer), + (typeof(BeatmapSubmissionSettings), new BeatmapSubmissionSettings()), + }, Children = new Drawable[] { receptor, diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs index 66a87c0715..66c465cbed 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs @@ -8,11 +8,14 @@ using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Gameplay @@ -28,20 +31,23 @@ namespace osu.Game.Tests.Visual.Gameplay SpectatorList list = null!; Bindable playingState = new Bindable(); GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), healthProcessor: new OsuHealthProcessor(0), localUserPlayingState: playingState); - TestSpectatorClient client = new TestSpectatorClient(); + TestSpectatorClient spectatorClient = new TestSpectatorClient(); + TestMultiplayerClient multiplayerClient = new TestMultiplayerClient(new TestMultiplayerRoomManager(new TestRoomRequestsHandler())); AddStep("create spectator list", () => { Children = new Drawable[] { - client, + spectatorClient, + multiplayerClient, new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, CachedDependencies = [ (typeof(GameplayState), gameplayState), - (typeof(SpectatorClient), client) + (typeof(SpectatorClient), spectatorClient), + (typeof(MultiplayerClient), multiplayerClient), ], Child = list = new SpectatorList { @@ -57,7 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddRepeatStep("add a user", () => { int id = Interlocked.Increment(ref counter); - ((ISpectatorClient)client).UserStartedWatching([ + ((ISpectatorClient)spectatorClient).UserStartedWatching([ new SpectatorUser { OnlineID = id, @@ -66,7 +72,8 @@ namespace osu.Game.Tests.Visual.Gameplay ]); }, 10); - AddRepeatStep("remove random user", () => ((ISpectatorClient)client).UserEndedWatching(client.WatchingUsers[RNG.Next(client.WatchingUsers.Count)].OnlineID), 5); + AddRepeatStep("remove random user", () => ((ISpectatorClient)spectatorClient).UserEndedWatching( + spectatorClient.WatchingUsers[RNG.Next(spectatorClient.WatchingUsers.Count)].OnlineID), 5); AddStep("change font to venera", () => list.Font.Value = Typeface.Venera); AddStep("change font to torus", () => list.Font.Value = Typeface.Torus); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index fb54b89a4b..fd589e928a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -166,7 +166,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, Y = -ScreenFooter.HEIGHT, - Current = { BindTarget = freeModSelectOverlay.SelectedMods }, + FreeMods = { BindTarget = freeModSelectOverlay.SelectedMods }, }, footer = new ScreenFooter(), }, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 238a716f91..d3c967a8d5 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -12,11 +12,14 @@ using osu.Framework.Utils; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Users; using osuTK; @@ -393,6 +396,40 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + [Test] + public void TestModsAndRuleset() + { + AddStep("add another user", () => + { + MultiplayerClient.AddUser(new APIUser + { + Id = 0, + Username = "User 0", + RulesetsStatistics = new Dictionary + { + { + Ruleset.Value.ShortName, + new UserStatistics { GlobalRank = RNG.Next(1, 100000), } + } + }, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + + MultiplayerClient.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable()); + }); + + AddStep("set user styles", () => + { + MultiplayerClient.ChangeUserStyle(API.LocalUser.Value.OnlineID, 259, 1); + MultiplayerClient.ChangeUserMods(API.LocalUser.Value.OnlineID, + [new APIMod(new TaikoModConstantSpeed()), new APIMod(new TaikoModHidden()), new APIMod(new TaikoModFlashlight()), new APIMod(new TaikoModHardRock())]); + + MultiplayerClient.ChangeUserStyle(0, 259, 2); + MultiplayerClient.ChangeUserMods(0, + [new APIMod(new CatchModFloatingFruits()), new APIMod(new CatchModHidden()), new APIMod(new CatchModMirror())]); + }); + } + private void createNewParticipantsList() { ParticipantsList? participantsList = null; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index 36f5bba384..37a3cc2faf 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -266,7 +266,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void assertQueueTabCount(int count) { - string queueTabText = count > 0 ? $"Queue ({count})" : "Queue"; + string queueTabText = count > 0 ? $"Up next ({count})" : "Up next"; AddUntilStep($"Queue tab shows \"{queueTabText}\"", () => { return this.ChildrenOfType.OsuTabItem>() diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index d16ed46bd2..a4a9816337 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -346,6 +346,13 @@ namespace osu.Game.Tests.Visual.Online Twitter = "test_user", Discord = "test_user", Website = "https://google.com", + Team = new APITeam + { + Id = 1, + Name = "Collective Wangs", + ShortName = "WANG", + FlagUrl = "https://assets.ppy.sh/teams/logo/1/wanglogo.jpg", + } }; } } diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs b/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs new file mode 100644 index 0000000000..46c93d9ae2 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs @@ -0,0 +1,113 @@ +// 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.Diagnostics; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Playlists; +using osuTK; +using osuTK.Input; +using SharpCompress; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneAddPlaylistToCollectionButton : OsuManualInputManagerTestScene + { + private BeatmapManager manager = null!; + private BeatmapSetInfo importedBeatmap = null!; + private Room room = null!; + private AddPlaylistToCollectionButton button = null!; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(Realm); + + Add(notificationOverlay); + } + + [Cached(typeof(INotificationOverlay))] + private NotificationOverlay notificationOverlay = new NotificationOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear realm", () => Realm.Realm.Write(() => Realm.Realm.RemoveAll())); + + AddStep("clear notifications", () => notificationOverlay.AllNotifications.Empty()); + + importBeatmap(); + + setupRoom(); + + AddStep("create button", () => + { + Add(button = new AddPlaylistToCollectionButton(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(300, 40), + }); + }); + } + + [Test] + public void TestButtonFlow() + { + AddStep("move mouse to button", () => InputManager.MoveMouseTo(button)); + + AddStep("click button", () => InputManager.Click(MouseButton.Left)); + + AddUntilStep("notification shown", () => notificationOverlay.AllNotifications.Any(n => n.Text.ToString().StartsWith("Created new collection", StringComparison.Ordinal))); + + AddUntilStep("realm is updated", () => Realm.Realm.All().FirstOrDefault(c => c.Name == room.Name) != null); + } + + private void importBeatmap() => AddStep("import beatmap", () => + { + var beatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); + + Debug.Assert(beatmap.BeatmapInfo.BeatmapSet != null); + + importedBeatmap = manager.Import(beatmap.BeatmapInfo.BeatmapSet)!.Value.Detach(); + }); + + private void setupRoom() => AddStep("setup room", () => + { + room = new Room + { + Name = "my awesome room", + MaxAttempts = 5, + Host = API.LocalUser.Value + }; + room.RecentParticipants = [room.Host]; + room.EndDate = DateTimeOffset.Now.AddMinutes(5); + room.Playlist = + [ + new PlaylistItem(importedBeatmap.Beatmaps.First()) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + } +} diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index 85af1d383d..c494b830d1 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -96,7 +96,6 @@ namespace osu.Game.Collections lastCreated = collections[changes.InsertedIndices[0]].ID; foreach (int i in changes.NewModifiedIndices) - { var updatedItem = collections[i]; diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index e86254329f..b0dd70227c 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -15,6 +15,7 @@ using osu.Framework.Localisation; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK; @@ -27,7 +28,7 @@ namespace osu.Game.Collections /// public partial class DrawableCollectionListItem : OsuRearrangeableListItem>, IFilterable { - private const float item_height = 35; + private const float item_height = 45; private const float button_width = item_height * 0.75f; protected TextBox TextBox => content.TextBox; @@ -92,13 +93,11 @@ namespace osu.Game.Collections Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, Children = new Drawable[] { - TextBox = new ItemTextBox + TextBox = new ItemTextBox(collection) { - RelativeSizeAxes = Axes.Both, - Size = Vector2.One, - CornerRadius = item_height / 2, + RelativeSizeAxes = Axes.X, + Height = item_height, CommitOnFocusLost = true, - PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection" }, } }, @@ -125,11 +124,57 @@ namespace osu.Game.Collections { protected override float LeftRightPadding => item_height / 2; + private const float count_text_size = 12; + + private readonly Live collection; + + private OsuSpriteText countText = null!; + + public ItemTextBox(Live collection) + { + this.collection = collection; + + CornerRadius = item_height / 2; + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { BackgroundUnfocused = colours.GreySeaFoamDarker.Darken(0.5f); BackgroundFocused = colours.GreySeaFoam; + + if (collection.IsManaged) + { + TextContainer.Height *= (Height - count_text_size) / Height; + TextContainer.Margin = new MarginPadding { Bottom = count_text_size }; + + TextContainer.Add(countText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + Depth = float.MinValue, + Font = OsuFont.Default.With(size: count_text_size, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Top = 2, Left = 2 }, + Colour = colours.Yellow + }); + + // interestingly, it is not required to subscribe to change notifications on this collection at all for this to work correctly. + // the reasoning for this is that `DrawableCollectionList` already takes out a subscription on the set of all `BeatmapCollection`s - + // but that subscription does not only cover *changes to the set of collections* (i.e. addition/removal/rearrangement of collections), + // but also covers *changes to the properties of collections*, which `BeatmapMD5Hashes` is one. + // when a collection item changes due to `BeatmapMD5Hashes` changing, the list item is deleted and re-inserted, thus guaranteeing this to work correctly. + int count = collection.PerformRead(c => c.BeatmapMD5Hashes.Count); + + countText.Text = count == 1 + // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 + // but also in this case we want support for formatting a number within a string). + ? $"{count:#,0} item" + : $"{count:#,0} items"; + } + else + { + PlaceholderText = "Create a new collection"; + } } } @@ -210,7 +255,7 @@ namespace osu.Game.Collections private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c)); } - public IEnumerable FilterTerms => [(LocalisableString)Model.Value.Name]; + public IEnumerable FilterTerms => Model.PerformRead(m => m.IsValid ? new[] { (LocalisableString)m.Name } : []); private bool matchingFilter = true; diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 1244dd8cfc..76d06f3665 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -238,7 +238,7 @@ namespace osu.Game.Configuration public void Migrate() { - // arrives as 2020.123.0 + // arrives as 2020.123.0-lazer string rawVersion = Get(OsuSetting.Version); if (rawVersion.Length < 6) @@ -251,11 +251,14 @@ namespace osu.Game.Configuration if (!int.TryParse(pieces[0], out int year)) return; if (!int.TryParse(pieces[1], out int monthDay)) return; - // ReSharper disable once UnusedVariable - int combined = (year * 10000) + monthDay; + int combined = year * 10000 + monthDay; - // migrations can be added here using a condition like: - // if (combined < 20220103) { performMigration() } + if (combined < 20250214) + { + // UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before. + if (RuntimeInfo.IsMobile) + GetBindable(OsuSetting.UIScale).SetDefault(); + } } public override TrackedSettings CreateTrackedSettings() diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index 8f94fc9e63..e7e5ddb4d2 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -61,6 +61,20 @@ namespace osu.Game.Database Configuration = new LegacySkinDecoder().Decode(skinStreamReader) }; + MutateBeatmap(model, playableBeatmap); + + // Encode to legacy format + var stream = new MemoryStream(); + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw); + + stream.Seek(0, SeekOrigin.Begin); + + return stream; + } + + protected virtual void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap) + { // Convert beatmap elements to be compatible with legacy format // So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves @@ -145,15 +159,6 @@ namespace osu.Game.Database hasPath.Path.ControlPoints.Add(new PathControlPoint(position)); } } - - // Encode to legacy format - var stream = new MemoryStream(); - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw); - - stream.Seek(0, SeekOrigin.Begin); - - return stream; } protected override string FileExtension => @".osz"; diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index c47aba2f0c..9d2a1c16af 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -26,17 +24,17 @@ namespace osu.Game.Graphics.Containers { internal const float TRANSITION_DURATION = 500; - private Bindable sizeX; - private Bindable sizeY; - private Bindable posX; - private Bindable posY; - private Bindable applySafeAreaPadding; + private Bindable sizeX = null!; + private Bindable sizeY = null!; + private Bindable posX = null!; + private Bindable posY = null!; + private Bindable applySafeAreaPadding = null!; - private Bindable safeAreaPadding; + private Bindable safeAreaPadding = null!; private readonly ScalingMode? targetMode; - private Bindable scalingMode; + private Bindable scalingMode = null!; private readonly Container content; protected override Container Content => content; @@ -45,9 +43,9 @@ namespace osu.Game.Graphics.Containers private readonly Container sizableContainer; - private BackgroundScreenStack backgroundStack; + private BackgroundScreenStack? backgroundStack; - private Bindable scalingMenuBackgroundDim; + private Bindable scalingMenuBackgroundDim = null!; private RectangleF? customRect; private bool customRectIsRelativePosition; @@ -88,7 +86,8 @@ namespace osu.Game.Graphics.Containers public partial class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer { private readonly bool applyUIScale; - private Bindable uiScale; + + private Bindable? uiScale; protected float CurrentScale { get; private set; } = 1; @@ -99,6 +98,9 @@ namespace osu.Game.Graphics.Containers this.applyUIScale = applyUIScale; } + [Resolved(canBeNull: true)] + private OsuGame? game { get; set; } + [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig) { @@ -111,6 +113,8 @@ namespace osu.Game.Graphics.Containers protected override void Update() { + if (game != null) + TargetDrawSize = game.ScalingContainerTargetDrawSize; Scale = new Vector2(CurrentScale); Size = new Vector2(1 / CurrentScale); @@ -233,13 +237,13 @@ namespace osu.Game.Graphics.Containers private partial class SizeableAlwaysInputContainer : Container { [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; [Resolved] - private ISafeArea safeArea { get; set; } + private ISafeArea safeArea { get; set; } = null!; [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; private readonly bool confineHostCursor; private readonly LayoutValue cursorRectCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 334fe343ae..4b52ac4a3a 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -18,6 +18,8 @@ namespace osu.Game.Graphics.UserInterface public abstract partial class OsuSliderBar : SliderBar, IHasTooltip where T : struct, INumber, IMinMaxValue { + public override bool AcceptsFocus => !Current.Disabled; + public bool PlaySamplesOnAdjust { get; set; } = true; /// diff --git a/osu.Game/Graphics/UserInterface/RangeSlider.cs b/osu.Game/Graphics/UserInterface/RangeSlider.cs index 422c2ca4a3..acf10ce827 100644 --- a/osu.Game/Graphics/UserInterface/RangeSlider.cs +++ b/osu.Game/Graphics/UserInterface/RangeSlider.cs @@ -162,6 +162,8 @@ namespace osu.Game.Graphics.UserInterface protected partial class BoundSlider : RoundedSliderBar { + public override bool AcceptsFocus => false; + public new Nub Nub => base.Nub; public string? DefaultString; diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 599ca6d6c1..e4dc2d503b 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -227,6 +227,10 @@ namespace osu.Game.Input.Bindings }; } + /// + /// IMPORTANT: New entries should always be added at the end of the enum, as key bindings are stored using the enum's numeric value and + /// changes in order would cause key bindings to get associated with the wrong action. + /// public enum GlobalAction { [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChat))] diff --git a/osu.Game/Localisation/BeatmapSubmissionStrings.cs b/osu.Game/Localisation/BeatmapSubmissionStrings.cs index a4c2b36894..3abe8cc515 100644 --- a/osu.Game/Localisation/BeatmapSubmissionStrings.cs +++ b/osu.Game/Localisation/BeatmapSubmissionStrings.cs @@ -39,6 +39,31 @@ namespace osu.Game.Localisation /// public static LocalisableString SubmissionSettings => new TranslatableString(getKey(@"submission_settings"), @"Submission settings"); + /// + /// "Submit beatmap!" + /// + public static LocalisableString ConfirmSubmission => new TranslatableString(getKey(@"confirm_submission"), @"Submit beatmap!"); + + /// + /// "Exporting beatmap for compatibility..." + /// + public static LocalisableString Exporting => new TranslatableString(getKey(@"exporting"), @"Exporting beatmap for compatibility..."); + + /// + /// "Preparing for upload..." + /// + public static LocalisableString Preparing => new TranslatableString(getKey(@"preparing"), @"Preparing for upload..."); + + /// + /// "Uploading beatmap contents..." + /// + public static LocalisableString Uploading => new TranslatableString(getKey(@"uploading"), @"Uploading beatmap contents..."); + + /// + /// "Finishing up..." + /// + public static LocalisableString Finishing => new TranslatableString(getKey(@"finishing"), @"Finishing up..."); + /// /// "Before you continue, we ask you to check whether the content you are uploading has been cleared for upload. Please understand that you are responsible for the content you upload to the platform and if in doubt, should ask permission from the creators before uploading!" /// @@ -115,9 +140,24 @@ namespace osu.Game.Localisation public static LocalisableString LoadInBrowserAfterSubmission => new TranslatableString(getKey(@"load_in_browser_after_submission"), @"Load in browser after submission"); /// - /// "Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost." + /// "Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost." /// - public static LocalisableString LegacyExportDisclaimer => new TranslatableString(getKey(@"legacy_export_disclaimer"), @"Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost."); + public static LocalisableString LegacyExportDisclaimer => new TranslatableString(getKey(@"legacy_export_disclaimer"), @"Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost."); + + /// + /// "Empty beatmaps cannot be submitted." + /// + public static LocalisableString EmptyBeatmapsCannotBeSubmitted => new TranslatableString(getKey(@"empty_beatmaps_cannot_be_submitted"), @"Empty beatmaps cannot be submitted."); + + /// + /// "Update beatmap!" + /// + public static LocalisableString UpdateBeatmap => new TranslatableString(getKey(@"update_beatmap"), @"Update beatmap!"); + + /// + /// "Upload NEW beatmap!" + /// + public static LocalisableString UploadNewBeatmap => new TranslatableString(getKey(@"upload_new_beatmap"), @"Upload NEW beatmap!"); private static string getKey(string key) => $@"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index 3b4026be11..4ce3e7f12b 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -69,6 +69,16 @@ namespace osu.Game.Localisation /// public static LocalisableString DeleteDifficulty => new TranslatableString(getKey(@"delete_difficulty"), @"Delete difficulty"); + /// + /// "Edit externally" + /// + public static LocalisableString EditExternally => new TranslatableString(getKey(@"edit_externally"), @"Edit externally"); + + /// + /// "Submit beatmap" + /// + public static LocalisableString SubmitBeatmap => new TranslatableString(getKey(@"submit_beatmap"), @"Submit beatmap"); + /// /// "setup" /// @@ -184,6 +194,16 @@ namespace osu.Game.Localisation /// public static LocalisableString ResetBookmarks => new TranslatableString(getKey(@"reset_bookmarks"), @"Reset bookmarks"); + /// + /// "Open beatmap info page" + /// + public static LocalisableString OpenInfoPage => new TranslatableString(getKey(@"open_info_page"), @"Open beatmap info page"); + + /// + /// "Open beatmap discussion page" + /// + public static LocalisableString OpenDiscussionPage => new TranslatableString(getKey(@"open_discussion_page"), @"Open beatmap discussion page"); + private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs b/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs index ffe7b5d1ec..df3c9d071c 100644 --- a/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs +++ b/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs @@ -26,10 +26,8 @@ namespace osu.Game.Online.API.Requests public uint BeatmapSetID { get; } - // ReSharper disable once CollectionNeverUpdated.Global public Dictionary FilesChanged { get; } = new Dictionary(); - // ReSharper disable once CollectionNeverUpdated.Global public HashSet FilesDeleted { get; } = new HashSet(); public PatchBeatmapPackageRequest(uint beatmapSetId) @@ -48,7 +46,7 @@ namespace osu.Game.Online.API.Requests foreach (string filename in FilesDeleted) request.AddParameter(@"filesDeleted", filename, RequestParameterType.Form); - request.Timeout = 60_000; + request.Timeout = 600_000; return request; } } diff --git a/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs b/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs index 2e224ce602..de8af6a623 100644 --- a/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs +++ b/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs @@ -38,7 +38,7 @@ namespace osu.Game.Online.API.Requests var request = base.CreateWebRequest(); request.AddFile(@"beatmapArchive", oszPackage); request.Method = HttpMethod.Put; - request.Timeout = 60_000; + request.Timeout = 600_000; return request; } } diff --git a/osu.Game/Online/API/Requests/Responses/APITeam.cs b/osu.Game/Online/API/Requests/Responses/APITeam.cs new file mode 100644 index 0000000000..b4fcc2d26e --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITeam.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class APITeam + { + [JsonProperty(@"id")] + public int Id { get; set; } = 1; + + [JsonProperty(@"name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty(@"short_name")] + public string ShortName { get; set; } = string.Empty; + + [JsonProperty(@"flag_url")] + public string FlagUrl = string.Empty; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index 30fceab852..92b7d9d874 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -55,6 +55,10 @@ namespace osu.Game.Online.API.Requests.Responses set => countryCodeString = value.ToString(); } + [JsonProperty(@"team")] + [CanBeNull] + public APITeam Team { get; set; } + [JsonProperty(@"profile_colour")] public string Colour; diff --git a/osu.Game/Online/DevelopmentEndpointConfiguration.cs b/osu.Game/Online/DevelopmentEndpointConfiguration.cs index f4e1b257ee..e36e36ee9f 100644 --- a/osu.Game/Online/DevelopmentEndpointConfiguration.cs +++ b/osu.Game/Online/DevelopmentEndpointConfiguration.cs @@ -13,6 +13,7 @@ namespace osu.Game.Online SpectatorUrl = $@"{APIUrl}/signalr/spectator"; MultiplayerUrl = $@"{APIUrl}/signalr/multiplayer"; MetadataUrl = $@"{APIUrl}/signalr/metadata"; + BeatmapSubmissionServiceUrl = $@"{APIUrl}/beatmap-submission"; } } } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 52074119b8..fc30f158f0 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -180,6 +180,7 @@ namespace osu.Game.Online.Leaderboards Height = 28, Direction = FillDirection.Horizontal, Spacing = new Vector2(10f, 0f), + Margin = new MarginPadding { Bottom = -2 }, Children = new Drawable[] { flagBadgeAndDateContainer = new FillFlowContainer @@ -189,7 +190,7 @@ namespace osu.Game.Online.Leaderboards RelativeSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, Spacing = new Vector2(5f, 0f), - Width = 87f, + Width = 114f, Masking = true, Children = new Drawable[] { @@ -199,6 +200,12 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.CentreLeft, Size = new Vector2(28, 20), }, + new UpdateableTeamFlag(user.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(40, 20), + }, new DateLabel(Score.Date) { Anchor = Anchor.CentreLeft, @@ -206,15 +213,6 @@ namespace osu.Game.Online.Leaderboards }, }, }, - new FillFlowContainer - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Left = edge_margin }, - Children = statisticsLabels - }, }, }, }, @@ -234,6 +232,7 @@ namespace osu.Game.Online.Leaderboards GlowColour = Color4Extensions.FromHex(@"83ccfa"), Current = scoreManager.GetBindableTotalScoreString(Score), Font = OsuFont.Numeric.With(size: 23), + Margin = new MarginPadding { Top = 1 }, }, RankContainer = new Container { @@ -250,13 +249,32 @@ namespace osu.Game.Online.Leaderboards }, }, }, - modsContainer = new FillFlowContainer + new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = edge_margin }, + Children = statisticsLabels + }, + modsContainer = new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { Scale = new Vector2(0.34f) }) + }, + } }, }, }, @@ -324,7 +342,7 @@ namespace osu.Game.Online.Leaderboards private partial class ScoreComponentLabel : Container, IHasTooltip { - private const float icon_size = 20; + private const float icon_size = 16; private readonly FillFlowContainer content; public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); @@ -340,7 +358,7 @@ namespace osu.Game.Online.Leaderboards { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Padding = new MarginPadding { Right = 10 }, + Padding = new MarginPadding { Right = 5 }, Children = new Drawable[] { new Container @@ -375,7 +393,8 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Text = statistic.Value, - Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold, fixedWidth: true) + Spacing = new Vector2(-1, 0), + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, fixedWidth: true) }, }, }; @@ -406,7 +425,7 @@ namespace osu.Game.Online.Leaderboards public DateLabel(DateTimeOffset date) : base(date) { - Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold, italics: true); + Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold, italics: true); } protected override string Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30)); diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 6c725cab4f..d379392a7d 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -72,6 +72,7 @@ using osu.Game.Skinning; using osu.Game.Updater; using osu.Game.Users; using osu.Game.Utils; +using osuTK; using osuTK.Graphics; using Sentry; @@ -813,6 +814,12 @@ namespace osu.Game protected virtual UpdateManager CreateUpdateManager() => new UpdateManager(); + /// + /// Adjust the globally applied in every . + /// Useful for changing how the game handles different aspect ratios. + /// + protected internal virtual Vector2 ScalingContainerTargetDrawSize { get; } = new Vector2(1024, 768); + protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything); #region Beatmap progression diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index c70c41feed..be6ad49150 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -160,7 +160,20 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Size = new Vector2(19, 14), }, - username, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + new UpdateableTeamFlag(score.User.Team) + { + Size = new Vector2(28, 14), + }, + username, + } + }, #pragma warning disable 618 new StatisticText(score.MaxCombo, score.BeatmapInfo!.MaxCombo, @"0\x"), #pragma warning restore 618 diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index 13ba9fb74b..14c9bedc67 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -27,7 +27,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly UpdateableAvatar avatar; private readonly LinkFlowContainer usernameText; private readonly DrawableDate achievedOn; + private readonly UpdateableFlag flag; + private readonly UpdateableTeamFlag teamFlag; public TopScoreUserSection() { @@ -112,12 +114,30 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }, } }, - flag = new UpdateableFlag + new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(19, 14), - Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + flag = new UpdateableFlag + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(19, 14), + Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + }, + teamFlag = new UpdateableTeamFlag + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(28, 14), + Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + }, + } }, } } @@ -139,6 +159,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { avatar.User = value.User; flag.CountryCode = value.User.CountryCode; + teamFlag.Team = value.User.Team; achievedOn.Date = value.Date; usernameText.Clear(); diff --git a/osu.Game/Overlays/KudosuTable.cs b/osu.Game/Overlays/KudosuTable.cs index 93884435a4..d6eaf586b9 100644 --- a/osu.Game/Overlays/KudosuTable.cs +++ b/osu.Game/Overlays/KudosuTable.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays protected override CountryCode GetCountryCode(APIUser item) => item.CountryCode; - protected override Drawable CreateFlagContent(APIUser item) + protected override Drawable[] CreateFlagContent(APIUser item) { var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { @@ -89,7 +89,7 @@ namespace osu.Game.Overlays TextAnchor = Anchor.CentreLeft }; username.AddUserLink(item); - return username; + return [username]; } } } diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index ba2cd5b705..3d9539ce1f 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -44,6 +44,8 @@ namespace osu.Game.Overlays.Profile.Header private UpdateableFlag userFlag = null!; private OsuHoverContainer userCountryContainer = null!; private OsuSpriteText userCountryText = null!; + private UpdateableTeamFlag teamFlag = null!; + private OsuSpriteText teamText = null!; private GroupBadgeFlow groupBadgeFlow = null!; private ToggleCoverButton coverToggle = null!; private PreviousUsernamesDisplay previousUsernamesDisplay = null!; @@ -154,29 +156,58 @@ namespace osu.Game.Overlays.Profile.Header titleText = new OsuSpriteText { Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), - Margin = new MarginPadding { Bottom = 5 } + Margin = new MarginPadding { Bottom = 3 }, }, new FillFlowContainer { + Margin = new MarginPadding { Top = 3 }, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), Children = new Drawable[] { - userFlag = new UpdateableFlag - { - Size = new Vector2(28, 20), - }, - userCountryContainer = new OsuHoverContainer + new FillFlowContainer { AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Left = 5 }, - Child = userCountryText = new OsuSpriteText + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new Drawable[] { - Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), - }, + userFlag = new UpdateableFlag + { + Size = new Vector2(28, 20), + }, + userCountryContainer = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Child = userCountryText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), + }, + }, + } }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new Drawable[] + { + teamFlag = new UpdateableTeamFlag + { + Size = new Vector2(40, 20), + }, + teamText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), + }, + } + } } }, } @@ -217,6 +248,8 @@ namespace osu.Game.Overlays.Profile.Header userFlag.CountryCode = user?.CountryCode ?? default; userCountryText.Text = (user?.CountryCode ?? default).GetDescription(); userCountryContainer.Action = () => rankingsOverlay?.ShowCountry(user?.CountryCode ?? default); + teamFlag.Team = user?.Team; + teamText.Text = user?.Team?.Name ?? string.Empty; supporterTag.SupportLevel = user?.SupportLevel ?? 0; titleText.Text = user?.Title ?? string.Empty; titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs index fb3e58d2ac..733aa7ca54 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected override CountryCode GetCountryCode(CountryStatistics item) => item.Code; - protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Code); + protected override Drawable[] CreateFlagContent(CountryStatistics item) => [new CountryName(item.Code)]; protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] { diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index b9f7e443ca..f4ed41800a 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected abstract CountryCode GetCountryCode(TModel item); - protected abstract Drawable CreateFlagContent(TModel item); + protected abstract Drawable[] CreateFlagContent(TModel item); private OsuSpriteText createIndexDrawable(int index) => new RowText { @@ -92,16 +92,13 @@ namespace osu.Game.Overlays.Rankings.Tables { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), + Spacing = new Vector2(5, 0), Margin = new MarginPadding { Bottom = row_spacing }, - Children = new[] - { - new UpdateableFlag(GetCountryCode(item)) - { - Size = new Vector2(28, 20), - }, - CreateFlagContent(item) - } + Children = + [ + new UpdateableFlag(GetCountryCode(item)) { Size = new Vector2(28, 20) }, + ..CreateFlagContent(item) + ] }; protected class RankingsTableColumn : TableColumn diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index 4d25065578..c651108ec3 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -14,6 +14,8 @@ using osu.Game.Users; using osu.Game.Scoring; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; +using osu.Game.Users.Drawables; +using osuTK; namespace osu.Game.Overlays.Rankings.Tables { @@ -61,7 +63,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected sealed override CountryCode GetCountryCode(UserStatistics item) => item.User.CountryCode; - protected sealed override Drawable CreateFlagContent(UserStatistics item) + protected sealed override Drawable[] CreateFlagContent(UserStatistics item) { var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { @@ -70,7 +72,7 @@ namespace osu.Game.Overlays.Rankings.Tables TextAnchor = Anchor.CentreLeft }; username.AddUserLink(item.User); - return username; + return [new UpdateableTeamFlag(item.User.Team) { Size = new Vector2(40, 20) }, username]; } protected sealed override Drawable[] CreateAdditionalContent(UserStatistics item) => new[] diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index f8cf218564..dd41f156f3 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Layout; using osu.Game.Graphics; @@ -54,6 +55,10 @@ namespace osu.Game.Overlays private IconButton expandButton = null!; + private InputManager inputManager = null!; + + private Drawable? draggedChild; + /// /// Create a new instance. /// @@ -125,6 +130,8 @@ namespace osu.Game.Overlays { base.LoadComplete(); + inputManager = GetContainingInputManager()!; + Expanded.BindValueChanged(_ => updateExpandedState(true)); updateExpandedState(false); @@ -156,6 +163,13 @@ namespace osu.Game.Overlays headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint); headerTextVisibilityCache.Validate(); } + + // Dragged child finished its drag operation. + if (draggedChild != null && inputManager.DraggedDrawable != draggedChild) + { + draggedChild = null; + updateExpandedState(true); + } } protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) @@ -168,11 +182,17 @@ namespace osu.Game.Overlays private void updateExpandedState(bool animate) { + // before we collapse down, let's double check the user is not dragging a UI control contained within us. + if (inputManager.DraggedDrawable.IsRootedAt(this)) + { + draggedChild = inputManager.DraggedDrawable; + } + // clearing transforms is necessary to avoid a previous height transform // potentially continuing to get processed while content has changed to autosize. content.ClearTransforms(); - if (Expanded.Value || IsHovered) + if (Expanded.Value || IsHovered || draggedChild != null) { content.AutoSizeAxes = Axes.Y; content.AutoSizeDuration = animate ? transition_duration : 0; diff --git a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs index ada2f6ff86..bd1c944847 100644 --- a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs @@ -7,8 +7,10 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osuTK; namespace osu.Game.Overlays.Toolbar { @@ -17,6 +19,8 @@ namespace osu.Game.Overlays.Toolbar private OsuSpriteText realTime; private OsuSpriteText gameTime; + private FillFlowContainer runningText; + private bool showRuntime = true; public bool ShowRuntime @@ -52,17 +56,36 @@ namespace osu.Game.Overlays.Toolbar [BackgroundDependencyLoader] private void load(OsuColour colours) { - AutoSizeAxes = Axes.Y; + AutoSizeAxes = Axes.Both; InternalChildren = new Drawable[] { - realTime = new OsuSpriteText(), - gameTime = new OsuSpriteText + realTime = new OsuSpriteText + { + Font = OsuFont.Default.With(fixedWidth: true), + Spacing = new Vector2(-1.5f, 0), + }, + runningText = new FillFlowContainer { Y = 14, Colour = colours.PinkLight, - Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold), - } + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "running", + Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold), + }, + gameTime = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 10, fixedWidth: true, weight: FontWeight.SemiBold), + Spacing = new Vector2(-0.5f, 0), + } + } + }, }; updateMetrics(); @@ -71,14 +94,12 @@ namespace osu.Game.Overlays.Toolbar protected override void UpdateDisplay(DateTimeOffset now) { realTime.Text = now.ToLocalisableString(use24HourDisplay ? @"HH:mm:ss" : @"h:mm:ss tt"); - gameTime.Text = $"running {new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}"; + gameTime.Text = $"{new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}"; } private void updateMetrics() { - Width = showRuntime || !use24HourDisplay ? 66 : 45; // Allows for space for game time up to 99 days (in the padding area since this is quite rare). - - gameTime.FadeTo(showRuntime ? 1 : 0); + runningText.FadeTo(showRuntime ? 1 : 0); } } } diff --git a/osu.Game/Overlays/WizardOverlay.cs b/osu.Game/Overlays/WizardOverlay.cs index 34ffa7bd77..2a881045fd 100644 --- a/osu.Game/Overlays/WizardOverlay.cs +++ b/osu.Game/Overlays/WizardOverlay.cs @@ -45,6 +45,8 @@ namespace osu.Game.Overlays private LoadingSpinner loading = null!; private ScheduledDelegate? loadingShowDelegate; + public bool Completed { get; private set; } + protected WizardOverlay(OverlayColourScheme scheme) : base(scheme) { @@ -221,6 +223,7 @@ namespace osu.Game.Overlays else { CurrentStepIndex = null; + Completed = true; Hide(); } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs index 169e72fe3f..f5c0ed2382 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -14,6 +15,9 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary { public partial class TestGameplayButton : OsuButton { + [Resolved] + private OsuColour colours { get; set; } = null!; + protected override SpriteText CreateText() => new OsuSpriteText { Depth = -1, @@ -24,7 +28,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary }; [BackgroundDependencyLoader] - private void load(OsuColour colours, OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider) { BackgroundColour = colours.Orange1; SpriteText.Colour = colourProvider.Background6; @@ -33,5 +37,18 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary Text = EditorStrings.TestBeatmap; } + + protected override bool OnMouseDown(MouseDownEvent e) + { + Background.FadeColour(colours.Orange0, 500, Easing.OutQuint); + // don't call base in order to block scale animation + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + Background.FadeColour(colours.Orange1, 300, Easing.OutQuint); + // don't call base in order to block scale animation + } } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index a5dfda9c95..219e14861f 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -32,6 +32,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -52,6 +53,7 @@ using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Design; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; +using osu.Game.Screens.Edit.Submission; using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Verify; using osu.Game.Screens.OnlinePlay; @@ -111,6 +113,10 @@ namespace osu.Game.Screens.Edit [Resolved(canBeNull: true)] private INotificationOverlay notifications { get; set; } + [Resolved(canBeNull: true)] + [CanBeNull] + private LoginOverlay loginOverlay { get; set; } + [Resolved] private RealmAccess realm { get; set; } @@ -1251,11 +1257,31 @@ namespace osu.Game.Screens.Edit if (RuntimeInfo.IsDesktop) { - var externalEdit = new EditorMenuItem("Edit externally", MenuItemType.Standard, editExternally); + var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally); saveRelatedMenuItems.Add(externalEdit); yield return externalEdit; } + bool isSetMadeOfLegacyRulesetBeatmaps = (isNewBeatmap && Ruleset.Value.IsLegacyRuleset()) + || (!isNewBeatmap && Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Ruleset.IsLegacyRuleset())); + bool submissionAvailable = api.Endpoints.BeatmapSubmissionServiceUrl != null; + + if (isSetMadeOfLegacyRulesetBeatmaps && submissionAvailable) + { + var upload = new EditorMenuItem(EditorStrings.SubmitBeatmap, MenuItemType.Standard, submitBeatmap); + saveRelatedMenuItems.Add(upload); + yield return upload; + } + + if (editorBeatmap.BeatmapInfo.OnlineID > 0) + { + yield return new OsuMenuItemSpacer(); + yield return new EditorMenuItem(EditorStrings.OpenInfoPage, MenuItemType.Standard, + () => (Game as OsuGame)?.OpenUrlExternally(editorBeatmap.BeatmapInfo.GetOnlineURL(api, editorBeatmap.BeatmapInfo.Ruleset))); + yield return new EditorMenuItem(EditorStrings.OpenDiscussionPage, MenuItemType.Standard, + () => (Game as OsuGame)?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/beatmapsets/{editorBeatmap.BeatmapInfo.BeatmapSet!.OnlineID}/discussion/{editorBeatmap.BeatmapInfo.OnlineID}")); + } + yield return new OsuMenuItemSpacer(); yield return new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit); } @@ -1295,6 +1321,42 @@ namespace osu.Game.Screens.Edit } } + private void submitBeatmap() + { + if (api.State.Value != APIState.Online) + { + loginOverlay?.Show(); + return; + } + + if (!editorBeatmap.HitObjects.Any()) + { + notifications?.Post(new SimpleNotification + { + Text = BeatmapSubmissionStrings.EmptyBeatmapsCannotBeSubmitted, + }); + return; + } + + if (HasUnsavedChanges) + { + dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() => + { + if (!Save()) + return false; + + startSubmission(); + return true; + }))); + } + else + { + startSubmission(); + } + + void startSubmission() => this.Push(new BeatmapSubmissionScreen()); + } + private void exportBeatmap(bool legacy) { if (HasUnsavedChanges) diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs index da2abd8c23..cf2fef25d5 100644 --- a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Localisation; @@ -15,10 +17,14 @@ namespace osu.Game.Screens.Edit.Submission } [BackgroundDependencyLoader] - private void load() + private void load(IBindable beatmap) { - AddStep(); - AddStep(); + if (beatmap.Value.BeatmapSetInfo.OnlineID <= 0) + { + AddStep(); + AddStep(); + } + AddStep(); Header.Title = BeatmapSubmissionStrings.BeatmapSubmissionTitle; diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs new file mode 100644 index 0000000000..201888e078 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs @@ -0,0 +1,446 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Development; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO.Archives; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + public partial class BeatmapSubmissionScreen : OsuScreen + { + private BeatmapSubmissionOverlay overlay = null!; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + protected override bool InitialBackButtonVisibility => false; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + [Resolved] + private Storage storage { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Cached] + private BeatmapSubmissionSettings settings { get; } = new BeatmapSubmissionSettings(); + + private Container submissionProgress = null!; + private SubmissionStageProgress exportStep = null!; + private SubmissionStageProgress createSetStep = null!; + private SubmissionStageProgress uploadStep = null!; + private SubmissionStageProgress updateStep = null!; + private Container successContainer = null!; + private Container flashLayer = null!; + + private uint? beatmapSetId; + private MemoryStream? beatmapPackageStream; + + private ProgressNotification? exportProgressNotification; + private ProgressNotification? updateProgressNotification; + + private Live? importedSet; + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + overlay = new BeatmapSubmissionOverlay(), + submissionProgress = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + AutoSizeDuration = 400, + AutoSizeEasing = Easing.OutQuint, + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.6f, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(20), + Spacing = new Vector2(5), + Children = new Drawable[] + { + createSetStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Preparing, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + exportStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Exporting, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + uploadStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Uploading, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + updateStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Finishing, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + successContainer = new Container + { + Padding = new MarginPadding(20), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Child = flashLayer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Depth = float.MinValue, + Alpha = 0, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + } + } + } + } + }); + + overlay.State.BindValueChanged(_ => + { + if (overlay.State.Value == Visibility.Hidden) + { + if (!overlay.Completed) + { + allowExit(); + this.Exit(); + } + else + { + submissionProgress.FadeIn(200, Easing.OutQuint); + createBeatmapSet(); + } + } + }); + } + + private void createBeatmapSet() + { + bool beatmapHasOnlineId = Beatmap.Value.BeatmapSetInfo.OnlineID > 0; + + var createRequest = beatmapHasOnlineId + ? PutBeatmapSetRequest.UpdateExisting( + (uint)Beatmap.Value.BeatmapSetInfo.OnlineID, + Beatmap.Value.BeatmapSetInfo.Beatmaps.Where(b => b.OnlineID > 0).Select(b => (uint)b.OnlineID).ToArray(), + (uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count(b => b.OnlineID <= 0), + settings.Target.Value) + : PutBeatmapSetRequest.CreateNew((uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count, settings.Target.Value); + + createRequest.Success += async response => + { + createSetStep.SetCompleted(); + beatmapSetId = response.BeatmapSetId; + + // at this point the set has an assigned online ID. + // it's important to proactively store it to the realm database, + // so that in the event in further failures in the process, the online ID is not lost. + // losing it can incur creation of redundant new sets server-side, or even cause online ID confusion. + if (!beatmapHasOnlineId) + { + await realmAccess.WriteAsync(r => + { + var refetchedSet = r.Find(Beatmap.Value.BeatmapSetInfo.ID); + refetchedSet!.OnlineID = (int)beatmapSetId.Value; + }).ConfigureAwait(true); + } + + await createBeatmapPackage(response).ConfigureAwait(true); + }; + createRequest.Failure += ex => + { + createSetStep.SetFailed(ex.Message); + Logger.Log($"Beatmap set submission failed on creation: {ex}"); + allowExit(); + }; + + createSetStep.SetInProgress(); + api.Queue(createRequest); + } + + private async Task createBeatmapPackage(PutBeatmapSetResponse response) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + exportStep.SetInProgress(); + + try + { + beatmapPackageStream = new MemoryStream(); + exportProgressNotification = new ProgressNotification(); + + var legacyBeatmapExporter = new SubmissionBeatmapExporter(storage, response); + + await legacyBeatmapExporter + .ExportToStreamAsync(Beatmap.Value.BeatmapSetInfo.ToLive(realmAccess), beatmapPackageStream, exportProgressNotification) + .ConfigureAwait(true); + } + catch (Exception ex) + { + exportStep.SetFailed(ex.Message); + exportProgressNotification = null; + Logger.Log($"Beatmap set submission failed on export: {ex}"); + allowExit(); + } + + exportStep.SetCompleted(); + exportProgressNotification = null; + + await Task.Delay(200).ConfigureAwait(true); + + if (response.Files.Count > 0) + await patchBeatmapSet(response.Files).ConfigureAwait(true); + else + replaceBeatmapSet(); + } + + private async Task patchBeatmapSet(ICollection onlineFiles) + { + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + + var onlineFilesByFilename = onlineFiles.ToDictionary(f => f.Filename, f => f.SHA2Hash); + + // disposing the `ArchiveReader` makes the underlying stream no longer readable which we don't want. + // make a local copy to defend against it. + using var archiveReader = new ZipArchiveReader(new MemoryStream(beatmapPackageStream.ToArray())); + var filesToUpdate = new HashSet(); + + foreach (string filename in archiveReader.Filenames) + { + string localHash = archiveReader.GetStream(filename).ComputeSHA2Hash(); + + if (!onlineFilesByFilename.Remove(filename, out string? onlineHash)) + { + filesToUpdate.Add(filename); + continue; + } + + if (localHash != onlineHash) + filesToUpdate.Add(filename); + } + + var changedFiles = new Dictionary(); + + foreach (string file in filesToUpdate) + changedFiles.Add(file, await archiveReader.GetStream(file).ReadAllBytesToArrayAsync().ConfigureAwait(true)); + + var patchRequest = new PatchBeatmapPackageRequest(beatmapSetId.Value); + patchRequest.FilesChanged.AddRange(changedFiles); + patchRequest.FilesDeleted.AddRange(onlineFilesByFilename.Keys); + patchRequest.Success += uploadCompleted; + patchRequest.Failure += ex => + { + uploadStep.SetFailed(ex.Message); + Logger.Log($"Beatmap submission failed on upload: {ex}"); + allowExit(); + }; + patchRequest.Progressed += (current, total) => uploadStep.SetInProgress((float)current / total); + + api.Queue(patchRequest); + uploadStep.SetInProgress(); + } + + private void replaceBeatmapSet() + { + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + + var uploadRequest = new ReplaceBeatmapPackageRequest(beatmapSetId.Value, beatmapPackageStream.ToArray()); + + uploadRequest.Success += uploadCompleted; + uploadRequest.Failure += ex => + { + uploadStep.SetFailed(ex.Message); + Logger.Log($"Beatmap submission failed on upload: {ex}"); + allowExit(); + }; + uploadRequest.Progressed += (current, total) => uploadStep.SetInProgress((float)current / Math.Max(total, 1)); + + api.Queue(uploadRequest); + uploadStep.SetInProgress(); + } + + private void uploadCompleted() + { + uploadStep.SetCompleted(); + updateLocalBeatmap().ConfigureAwait(true); + } + + private async Task updateLocalBeatmap() + { + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + + updateStep.SetInProgress(); + await Task.Delay(200).ConfigureAwait(true); + + try + { + importedSet = await beatmaps.ImportAsUpdate( + updateProgressNotification = new ProgressNotification(), + new ImportTask(beatmapPackageStream, $"{beatmapSetId}.osz"), + Beatmap.Value.BeatmapSetInfo).ConfigureAwait(true); + } + catch (Exception ex) + { + updateStep.SetFailed(ex.Message); + Logger.Log($"Beatmap submission failed on local update: {ex}"); + allowExit(); + return; + } + + updateStep.SetCompleted(); + showBeatmapCard(); + allowExit(); + + if (configManager.Get(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission)) + { + await Task.Delay(1000).ConfigureAwait(true); + game?.OpenUrlExternally($"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetId}"); + } + } + + private void showBeatmapCard() + { + Debug.Assert(beatmapSetId != null); + + var getBeatmapSetRequest = new GetBeatmapSetRequest((int)beatmapSetId.Value); + getBeatmapSetRequest.Success += beatmapSet => + { + LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded => + { + successContainer.Add(loaded); + flashLayer.FadeOutFromOne(2000, Easing.OutQuint); + }); + }; + + api.Queue(getBeatmapSetRequest); + } + + private void allowExit() + { + BackButtonVisibility.Value = true; + } + + protected override void Update() + { + base.Update(); + + if (exportProgressNotification != null && exportProgressNotification.Ongoing) + exportStep.SetInProgress(exportProgressNotification.Progress); + + if (updateProgressNotification != null && updateProgressNotification.Ongoing) + updateStep.SetInProgress(updateProgressNotification.Progress); + } + + public override bool OnExiting(ScreenExitEvent e) + { + // We probably want a method of cancelling in the future… + if (!BackButtonVisibility.Value) + return true; + + if (importedSet != null) + { + game?.PerformFromScreen(s => + { + if (s is OsuScreen osuScreen) + { + Debug.Assert(importedSet != null); + var targetBeatmap = importedSet.Value.Beatmaps.FirstOrDefault(b => b.DifficultyName == Beatmap.Value.BeatmapInfo.DifficultyName) + ?? importedSet.Value.Beatmaps.First(); + osuScreen.Beatmap.Value = beatmaps.GetWorkingBeatmap(targetBeatmap); + } + + s.Push(new EditorLoader()); + }, [typeof(SongSelect)]); + + return false; + } + + return base.OnExiting(e); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + overlay.Show(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + beatmapPackageStream?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs new file mode 100644 index 0000000000..359dc11f39 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs @@ -0,0 +1,13 @@ +// 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.Bindables; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Screens.Edit.Submission +{ + public class BeatmapSubmissionSettings + { + public Bindable Target { get; } = new Bindable(); + } +} diff --git a/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs b/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs index 72da94afa1..08b4d9f712 100644 --- a/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs +++ b/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Online.API.Requests; using osu.Game.Overlays; using osuTK; @@ -22,8 +23,10 @@ namespace osu.Game.Screens.Edit.Submission private readonly BindableBool notifyOnDiscussionReplies = new BindableBool(); private readonly BindableBool loadInBrowserAfterSubmission = new BindableBool(); + public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ConfirmSubmission; + [BackgroundDependencyLoader] - private void load(OsuConfigManager configManager, OsuColour colours) + private void load(OsuConfigManager configManager, OsuColour colours, BeatmapSubmissionSettings settings) { configManager.BindWith(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, notifyOnDiscussionReplies); configManager.BindWith(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, loadInBrowserAfterSubmission); @@ -39,6 +42,7 @@ namespace osu.Game.Screens.Edit.Submission { RelativeSizeAxes = Axes.X, Caption = BeatmapSubmissionStrings.BeatmapSubmissionTargetCaption, + Current = settings.Target, }, new FormCheckBox { @@ -60,14 +64,5 @@ namespace osu.Game.Screens.Edit.Submission } }); } - - private enum BeatmapSubmissionTarget - { - [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetWIP))] - WIP, - - [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetPending))] - Pending, - } } } diff --git a/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs b/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs new file mode 100644 index 0000000000..fab080cdba --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs @@ -0,0 +1,53 @@ +// 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 osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.Edit.Submission +{ + public class SubmissionBeatmapExporter : LegacyBeatmapExporter + { + private readonly uint? beatmapSetId; + private readonly HashSet? allocatedBeatmapIds; + + public SubmissionBeatmapExporter(Storage storage, PutBeatmapSetResponse putBeatmapSetResponse) + : base(storage) + { + beatmapSetId = putBeatmapSetResponse.BeatmapSetId; + allocatedBeatmapIds = putBeatmapSetResponse.BeatmapIds.Select(id => (int)id).ToHashSet(); + } + + protected override void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap) + { + base.MutateBeatmap(beatmapSet, playableBeatmap); + + if (beatmapSetId != null && allocatedBeatmapIds != null) + { + playableBeatmap.BeatmapInfo.BeatmapSet = beatmapSet; + playableBeatmap.BeatmapInfo.BeatmapSet!.OnlineID = (int)beatmapSetId; + + if (allocatedBeatmapIds.Contains(playableBeatmap.BeatmapInfo.OnlineID)) + { + allocatedBeatmapIds.Remove(playableBeatmap.BeatmapInfo.OnlineID); + return; + } + + if (playableBeatmap.BeatmapInfo.OnlineID > 0) + throw new InvalidOperationException(@"Encountered beatmap with ID that has not been assigned to it by the server!"); + + if (allocatedBeatmapIds.Count == 0) + throw new InvalidOperationException(@"Ran out of new beatmap IDs to assign to unsubmitted beatmaps!"); + + int newId = allocatedBeatmapIds.First(); + allocatedBeatmapIds.Remove(newId); + playableBeatmap.BeatmapInfo.OnlineID = newId; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs index d9cdcac7d7..6dfde183f0 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs @@ -53,13 +53,11 @@ namespace osu.Game.Screens.OnlinePlay.Components { RelativeSizeAxes = Axes.X, Height = 2, - Margin = new MarginPadding { Bottom = 2 } }, new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Top = 5 }, Spacing = new Vector2(10, 0), Children = new Drawable[] { diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index 402f538716..3605412b2b 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -11,31 +11,22 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; using osuTK; -using osu.Game.Localisation; namespace osu.Game.Screens.OnlinePlay { - public partial class FooterButtonFreeMods : FooterButton, IHasCurrentValue> + public partial class FooterButtonFreeMods : FooterButton { - private readonly BindableWithCurrent> current = new BindableWithCurrent>(Array.Empty()); + public readonly Bindable> FreeMods = new Bindable>(); + public readonly IBindable Freestyle = new Bindable(); - public Bindable> Current - { - get => current.Current; - set - { - ArgumentNullException.ThrowIfNull(value); - - current.Current = value; - } - } + protected override bool IsActive => FreeMods.Value.Count > 0; public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); } @@ -104,7 +95,8 @@ namespace osu.Game.Screens.OnlinePlay { base.LoadComplete(); - Current.BindValueChanged(_ => updateModDisplay(), true); + Freestyle.BindValueChanged(_ => updateModDisplay()); + FreeMods.BindValueChanged(_ => updateModDisplay(), true); } /// @@ -114,16 +106,16 @@ namespace osu.Game.Screens.OnlinePlay { var availableMods = allAvailableAndValidMods.ToArray(); - Current.Value = Current.Value.Count == availableMods.Length + FreeMods.Value = FreeMods.Value.Count == availableMods.Length ? Array.Empty() : availableMods; } private void updateModDisplay() { - int currentCount = Current.Value.Count; + int currentCount = FreeMods.Value.Count; - if (currentCount == allAvailableAndValidMods.Count()) + if (currentCount == allAvailableAndValidMods.Count() || Freestyle.Value) { count.Text = "all"; count.FadeColour(colours.Gray2, 200, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs index 157f90d078..6ee983af20 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs @@ -8,23 +8,18 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Screens.Select; using osu.Game.Localisation; +using osu.Game.Screens.Select; namespace osu.Game.Screens.OnlinePlay { - public partial class FooterButtonFreestyle : FooterButton, IHasCurrentValue + public partial class FooterButtonFreestyle : FooterButton { - private readonly BindableWithCurrent current = new BindableWithCurrent(); + public readonly Bindable Freestyle = new Bindable(); - public Bindable Current - { - get => current.Current; - set => current.Current = value; - } + protected override bool IsActive => Freestyle.Value; public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); } @@ -37,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay public FooterButtonFreestyle() { // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. - base.Action = () => current.Value = !current.Value; + base.Action = () => Freestyle.Value = !Freestyle.Value; } [BackgroundDependencyLoader] @@ -81,12 +76,12 @@ namespace osu.Game.Screens.OnlinePlay { base.LoadComplete(); - Current.BindValueChanged(_ => updateDisplay(), true); + Freestyle.BindValueChanged(_ => updateDisplay(), true); } private void updateDisplay() { - if (current.Value) + if (Freestyle.Value) { text.Text = "on"; text.FadeColour(colours.Gray2, 200, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index ce51bb3c21..59acd3c17f 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -440,11 +439,14 @@ namespace osu.Game.Screens.OnlinePlay.Match var rulesetInstance = GetGameplayRuleset().CreateInstance(); + Mod[] allowedMods = item.Freestyle + ? rulesetInstance.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, Room.Type)).ToArray() + : item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + // Remove any user mods that are no longer allowed. - Mod[] allowedMods = item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); Mod[] newUserMods = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); if (!newUserMods.SequenceEqual(UserMods.Value)) - UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToList(); + UserMods.Value = newUserMods; // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info int beatmapId = GetGameplayBeatmap().OnlineID; @@ -455,14 +457,7 @@ namespace osu.Game.Screens.OnlinePlay.Match Mods.Value = GetGameplayMods().Select(m => m.ToMod(rulesetInstance)).ToArray(); Ruleset.Value = GetGameplayRuleset(); - bool freeMod = item.AllowedMods.Any(); - bool freestyle = item.Freestyle; - - // For now, the game can never be in a state where freemod and freestyle are on at the same time. - // This will change, but due to the current implementation if this was to occur drawables will overlap so let's assert. - Debug.Assert(!freeMod || !freestyle); - - if (freeMod) + if (allowedMods.Length > 0) { UserModsSection.Show(); UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); @@ -474,7 +469,7 @@ namespace osu.Game.Screens.OnlinePlay.Match UserModsSelectOverlay.IsValidMod = _ => false; } - if (freestyle) + if (item.Freestyle) { UserStyleSection.Show(); @@ -487,7 +482,7 @@ namespace osu.Game.Screens.OnlinePlay.Match UserStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(gameplayItem, true) { AllowReordering = false, - AllowEditing = freestyle, + AllowEditing = true, RequestEdit = _ => OpenStyleSelection() }; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs index e5d94c5358..a7f3e17efa 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs @@ -32,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist protected override void LoadComplete() { base.LoadComplete(); - QueueItems.BindCollectionChanged((_, _) => Text.Text = QueueItems.Count > 0 ? $"Queue ({QueueItems.Count})" : "Queue", true); + QueueItems.BindCollectionChanged((_, _) => Text.Text = QueueItems.Count > 0 ? $"Up next ({QueueItems.Count})" : "Up next", true); } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index b42a58787d..7328e01026 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -11,7 +11,6 @@ using osu.Framework.Screens; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; namespace osu.Game.Screens.OnlinePlay.Multiplayer @@ -122,9 +121,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); - - protected override bool IsValidMod(Mod mod) => base.IsValidMod(mod) && mod.ValidForMultiplayer; - - protected override bool IsValidFreeMod(Mod mod) => base.IsValidFreeMod(mod) && mod.ValidForMultiplayerAsFreeMod; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index b803c5f28b..083c8e070e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -98,7 +98,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { new Drawable?[] { - // Participants column new GridContainer { RelativeSizeAxes = Axes.Both, @@ -118,15 +117,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } }, - // Spacer null, - // Beatmap column new GridContainer { RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, Content = new[] { - new Drawable[] { new OverlinedHeader("Beatmap") }, + new Drawable[] { new OverlinedHeader("Beatmap queue") }, new Drawable[] { addItemButton = new AddItemButton @@ -147,80 +153,67 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer SelectedItem = SelectedItem } }, - new Drawable[] + new[] { - new Container + UserModsSection = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Top = 10 }, - Children = new[] + Alpha = 0, + Children = new Drawable[] { - UserModsSection = new FillFlowContainer + new OverlinedHeader("Extra mods"), + new FillFlowContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), Children = new Drawable[] { - new OverlinedHeader("Extra mods"), - new FillFlowContainer + new UserModSelectButton { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new UserModSelectButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Height = 30, + Text = "Select", + Action = ShowUserModSelect, }, - } - }, - UserStyleSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Children = new Drawable[] - { - new OverlinedHeader("Difficulty"), - UserStyleDisplayContainer = new Container + new ModDisplay { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - } + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + }, } }, } } }, + new[] + { + UserStyleSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Difficulty"), + UserStyleDisplayContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + }, + }, }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - } }, - // Spacer null, - // Main right column new GridContainer { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index a2657019a3..0cedfb9909 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -140,6 +140,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Size = new Vector2(28, 20), CountryCode = user?.CountryCode ?? default }, + new UpdateableTeamFlag(user?.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(40, 20), + }, new OsuSpriteText { Anchor = Anchor.CentreLeft, @@ -161,11 +167,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Origin = Anchor.CentreRight, AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Right = 70 }, + Spacing = new Vector2(2), Children = new Drawable[] { - userStyleDisplay = new StyleDisplayIcon(), + userStyleDisplay = new StyleDisplayIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, userModsDisplay = new ModDisplay { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, Scale = new Vector2(0.5f), ExpansionMode = ExpansionMode.AlwaysContracted, } @@ -209,15 +222,28 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; - MultiplayerPlaylistItem? currentItem = client.Room.GetCurrentItem(); - Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; + if (client.Room.GetCurrentItem() is MultiplayerPlaylistItem currentItem) + { + int userBeatmapId = User.BeatmapId ?? currentItem.BeatmapID; + int userRulesetId = User.RulesetId ?? currentItem.RulesetID; + Ruleset? userRuleset = rulesets.GetRuleset(userRulesetId)?.CreateInstance(); - int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; - userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; + int? currentModeRank = userRuleset == null ? null : User.User?.RulesetsStatistics?.GetValueOrDefault(userRuleset.ShortName)?.GlobalRank; + userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; + + if (userBeatmapId == currentItem.BeatmapID && userRulesetId == currentItem.RulesetID) + userStyleDisplay.Style = null; + else + userStyleDisplay.Style = (userBeatmapId, userRulesetId); + + // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 + // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. + Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? Array.Empty() : User.Mods.Select(m => m.ToMod(userRuleset)).ToList()); + } userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); - if ((User.BeatmapAvailability.State == DownloadState.LocallyAvailable) && (User.State != MultiplayerUserState.Spectating)) + if (User.BeatmapAvailability.State == DownloadState.LocallyAvailable && User.State != MultiplayerUserState.Spectating) { userModsDisplay.FadeIn(fade_time); userStyleDisplay.FadeIn(fade_time); @@ -228,20 +254,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants userStyleDisplay.FadeOut(fade_time); } - if ((User.BeatmapId == null && User.RulesetId == null) || (User.BeatmapId == currentItem?.BeatmapID && User.RulesetId == currentItem?.RulesetID)) - userStyleDisplay.Style = null; - else - userStyleDisplay.Style = (User.BeatmapId ?? currentItem?.BeatmapID ?? 0, User.RulesetId ?? currentItem?.RulesetID ?? 0); - kickButton.Alpha = client.IsHost && !User.Equals(client.LocalUser) ? 1 : 0; crown.Alpha = client.Room.Host?.Equals(User) == true ? 1 : 0; - - // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 - // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. - Schedule(() => - { - userModsDisplay.Current.Value = ruleset != null ? User.Mods.Select(m => m.ToMod(ruleset)).ToList() : Array.Empty(); - }); } public MenuItem[]? ContextMenuItems diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 4ca6abbf7d..9bedecc221 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -42,7 +42,7 @@ namespace osu.Game.Screens.OnlinePlay protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); - protected readonly Bindable Freestyle = new Bindable(); + protected readonly Bindable Freestyle = new Bindable(true); private readonly Room room; private readonly PlaylistItem? initialItem; @@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay freeModSelect = new FreeModSelectOverlay { SelectedMods = { BindTarget = FreeMods }, - IsValidMod = IsValidFreeMod, + IsValidMod = isValidFreeMod, }; } @@ -126,6 +126,7 @@ namespace osu.Game.Screens.OnlinePlay { if (enabled.NewValue) { + freeModsFooterButton.Enabled.Value = false; freeModsFooterButton.Enabled.Value = false; ModsFooterButton.Enabled.Value = false; @@ -144,10 +145,10 @@ namespace osu.Game.Screens.OnlinePlay private void onModsChanged(ValueChangedEvent> mods) { - FreeMods.Value = FreeMods.Value.Where(checkCompatibleFreeMod).ToList(); + FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToList(); // Reset the validity delegate to update the overlay's display. - freeModSelect.IsValidMod = IsValidFreeMod; + freeModSelect.IsValidMod = isValidFreeMod; } private void onRulesetChanged(ValueChangedEvent ruleset) @@ -194,7 +195,7 @@ namespace osu.Game.Screens.OnlinePlay protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) { - IsValidMod = IsValidMod + IsValidMod = isValidMod }; protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() @@ -205,8 +206,15 @@ namespace osu.Game.Screens.OnlinePlay baseButtons.InsertRange(baseButtons.FindIndex(b => b.button is FooterButtonMods) + 1, new (FooterButton, OverlayContainer?)[] { - (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) { Current = FreeMods }, null), - (new FooterButtonFreestyle { Current = Freestyle }, null) + (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) + { + FreeMods = { BindTarget = FreeMods }, + Freestyle = { BindTarget = Freestyle } + }, null), + (new FooterButtonFreestyle + { + Freestyle = { BindTarget = Freestyle } + }, null) }); return baseButtons; @@ -217,18 +225,18 @@ namespace osu.Game.Screens.OnlinePlay /// /// The to check. /// Whether is a valid mod for online play. - protected virtual bool IsValidMod(Mod mod) => mod.HasImplementation && ModUtils.FlattenMod(mod).All(m => m.UserPlayable); + private bool isValidMod(Mod mod) => ModUtils.IsValidModForMatchType(mod, room.Type); /// /// Checks whether a given is valid for per-player free-mod selection. /// /// The to check. /// Whether is a selectable free-mod. - protected virtual bool IsValidFreeMod(Mod mod) => IsValidMod(mod) && checkCompatibleFreeMod(mod); - - private bool checkCompatibleFreeMod(Mod mod) - => Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must not be contained in the required mods. - && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); // Mod must be compatible with all the required mods. + private bool isValidFreeMod(Mod mod) => ModUtils.IsValidFreeModForMatchType(mod, room.Type) + // Mod must not be contained in the required mods. + && Mods.Value.All(m => m.Acronym != mod.Acronym) + // Mod must be compatible with all the required mods. + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs new file mode 100644 index 0000000000..741173f9a3 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs @@ -0,0 +1,178 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using Realms; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class AddPlaylistToCollectionButton : RoundedButton, IHasTooltip + { + private readonly Room room; + + private IDisposable? beatmapSubscription; + private IDisposable? collectionSubscription; + + private Live? collection; + private HashSet localBeatmapHashes = new HashSet(); + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved(canBeNull: true)] + private INotificationOverlay? notifications { get; set; } + + public AddPlaylistToCollectionButton(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load() + { + Action = () => + { + if (room.Playlist.Count == 0) + return; + + int countBefore = 0; + int countAfter = 0; + + Text = "Updating collection..."; + Enabled.Value = false; + + realm.WriteAsync(r => + { + var beatmaps = getBeatmapsForPlaylist(r).ToArray(); + var c = getCollectionsForPlaylist(r).FirstOrDefault() + ?? r.Add(new BeatmapCollection(room.Name)); + + countBefore = c.BeatmapMD5Hashes.Count; + + foreach (var item in beatmaps) + { + if (!c.BeatmapMD5Hashes.Contains(item.MD5Hash)) + c.BeatmapMD5Hashes.Add(item.MD5Hash); + } + + countAfter = c.BeatmapMD5Hashes.Count; + }).ContinueWith(_ => Schedule(() => + { + if (countBefore == 0) + notifications?.Post(new SimpleNotification { Text = $"Created new collection \"{room.Name}\" with {countAfter} beatmaps." }); + else + notifications?.Post(new SimpleNotification { Text = $"Added {countAfter - countBefore} beatmaps to collection \"{room.Name}\"." }); + })); + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // will be updated via updateButtonState() when ready. + Enabled.Value = false; + + if (room.Playlist.Count == 0) + return; + + beatmapSubscription = realm.RegisterForNotifications(getBeatmapsForPlaylist, (sender, _) => + { + localBeatmapHashes = sender.Select(b => b.MD5Hash).ToHashSet(); + Schedule(updateButtonState); + }); + + collectionSubscription = realm.RegisterForNotifications(getCollectionsForPlaylist, (sender, _) => + { + collection = sender.FirstOrDefault()?.ToLive(realm); + Schedule(updateButtonState); + }); + } + + private void updateButtonState() + { + int countToAdd = getCountToBeAdded(); + + if (collection == null) + Text = $"Create new collection with {countToAdd} beatmaps"; + else if (hasAllItemsInCollection) + Text = "Collection complete!"; + else + Text = $"Add {countToAdd} beatmaps to collection"; + + Enabled.Value = countToAdd > 0; + } + + private int getCountToBeAdded() + { + if (collection == null) + return localBeatmapHashes.Count; + + return collection.PerformRead(c => + { + int count = localBeatmapHashes.Count; + + foreach (string hash in localBeatmapHashes) + { + if (c.BeatmapMD5Hashes.Contains(hash)) + count--; + } + + return count; + }); + } + + private IQueryable getCollectionsForPlaylist(Realm r) => r.All().Where(c => c.Name == room.Name); + + private IQueryable getBeatmapsForPlaylist(Realm r) + { + return r.All().Filter(string.Join(" OR ", room.Playlist.Select(item => $"(OnlineID == {item.Beatmap.OnlineID})").Distinct())); + } + + private bool hasAllItemsInCollection + { + get + { + if (collection == null) + return false; + + return room.Playlist.DistinctBy(i => i.Beatmap.OnlineID).Count() == + collection.PerformRead(c => c.BeatmapMD5Hashes.Count); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + beatmapSubscription?.Dispose(); + collectionSubscription?.Dispose(); + } + + public LocalisableString TooltipText + { + get + { + if (Enabled.Value) + return string.Empty; + + if (hasAllItemsInCollection) + return "All beatmaps have been added!"; + + return "Download some beatmaps first."; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 2195ed4722..c64dffa1e3 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -146,7 +146,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { new Drawable?[] { - // Playlist items column new GridContainer { RelativeSizeAxes = Axes.Both, @@ -169,80 +168,84 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } } }, + new Drawable[] + { + new AddPlaylistToCollectionButton(Room) + { + Margin = new MarginPadding { Top = 5 }, + RelativeSizeAxes = Axes.X, + Size = new Vector2(1, 40) + } + } }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize), new Dimension(), + new Dimension(GridSizeMode.AutoSize), } }, - // Spacer null, - // Middle column (mods and leaderboard) new GridContainer { RelativeSizeAxes = Axes.Both, Content = new[] { - new Drawable[] + new[] { - new Container + UserModsSection = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Bottom = 10 }, - Children = new[] + Alpha = 0, + Children = new Drawable[] { - UserModsSection = new FillFlowContainer + new OverlinedHeader("Extra mods"), + new FillFlowContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Margin = new MarginPadding { Bottom = 10 }, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), Children = new Drawable[] { - new OverlinedHeader("Extra mods"), - new FillFlowContainer + new UserModSelectButton { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new UserModSelectButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } - } + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Height = 30, + Text = "Select", + Action = ShowUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + }, } - }, - UserStyleSection = new FillFlowContainer + } + } + }, + }, + new[] + { + UserStyleSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Difficulty"), + UserStyleDisplayContainer = new Container { RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Children = new Drawable[] - { - new OverlinedHeader("Difficulty"), - UserStyleDisplayContainer = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - } - } - }, + AutoSizeAxes = Axes.Y + } } }, }, @@ -273,12 +276,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), new Dimension(), } }, - // Spacer null, - // Main right column new GridContainer { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs b/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs new file mode 100644 index 0000000000..f8ef03c58c --- /dev/null +++ b/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs @@ -0,0 +1,56 @@ +// 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.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Skinning; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class PlayerTeamFlag : CompositeDrawable, ISerialisableDrawable + { + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => false; + + private readonly UpdateableTeamFlag flag; + + private const float default_size = 40f; + + [Resolved] + private GameplayState? gameplayState { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IBindable? apiUser; + + public PlayerTeamFlag() + { + Size = new Vector2(default_size, default_size / 2f); + + InternalChild = flag = new UpdateableTeamFlag + { + RelativeSizeAxes = Axes.Both, + }; + } + + [BackgroundDependencyLoader] + private void load() + { + if (gameplayState != null) + flag.Team = gameplayState.Score.ScoreInfo.User.Team; + else + { + apiUser = api.LocalUser.GetBoundCopy(); + apiUser.BindValueChanged(u => flag.Team = u.NewValue.Team, true); + } + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/SpectatorList.cs b/osu.Game/Screens/Play/HUD/SpectatorList.cs index c784fc298a..4297c62712 100644 --- a/osu.Game/Screens/Play/HUD/SpectatorList.cs +++ b/osu.Game/Screens/Play/HUD/SpectatorList.cs @@ -2,7 +2,9 @@ // 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; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -17,6 +19,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; using osu.Game.Localisation.HUD; using osu.Game.Localisation.SkinComponents; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Skinning; using osuTK; @@ -28,17 +31,17 @@ namespace osu.Game.Screens.Play.HUD { private const int max_spectators_displayed = 10; - public BindableList Spectators { get; } = new BindableList(); - public Bindable UserPlayingState { get; } = new Bindable(); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] public Bindable Font { get; } = new Bindable(Typeface.Torus); [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))] public BindableColour4 HeaderColour { get; } = new BindableColour4(Colour4.White); - protected OsuSpriteText Header { get; private set; } = null!; + private BindableList watchingUsers { get; } = new BindableList(); + private Bindable userPlayingState { get; } = new Bindable(); + private int displayedSpectatorCount; + private OsuSpriteText header = null!; private FillFlowContainer mainFlow = null!; private FillFlowContainer spectatorsFlow = null!; private DrawablePool pool = null!; @@ -49,6 +52,9 @@ namespace osu.Game.Screens.Play.HUD [Resolved] private GameplayState gameplayState { get; set; } = null!; + [Resolved] + private MultiplayerClient multiplayerClient { get; set; } = null!; + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -63,7 +69,7 @@ namespace osu.Game.Screens.Play.HUD Direction = FillDirection.Vertical, Children = new Drawable[] { - Header = new OsuSpriteText + header = new OsuSpriteText { Colour = colours.Blue0, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), @@ -78,18 +84,18 @@ namespace osu.Game.Screens.Play.HUD pool = new DrawablePool(max_spectators_displayed), }; - HeaderColour.Value = Header.Colour; + HeaderColour.Value = header.Colour; } protected override void LoadComplete() { base.LoadComplete(); - ((IBindableList)Spectators).BindTo(client.WatchingUsers); - ((IBindable)UserPlayingState).BindTo(gameplayState.PlayingState); + ((IBindableList)watchingUsers).BindTo(client.WatchingUsers); + ((IBindable)userPlayingState).BindTo(gameplayState.PlayingState); - Spectators.BindCollectionChanged(onSpectatorsChanged, true); - UserPlayingState.BindValueChanged(_ => updateVisibility()); + watchingUsers.BindCollectionChanged(onSpectatorsChanged, true); + userPlayingState.BindValueChanged(_ => updateVisibility()); Font.BindValueChanged(_ => updateAppearance()); HeaderColour.BindValueChanged(_ => updateAppearance(), true); @@ -100,6 +106,20 @@ namespace osu.Game.Screens.Play.HUD private void onSpectatorsChanged(object? sender, NotifyCollectionChangedEventArgs e) { + // the multiplayer gameplay leaderboard relies on calling `SpectatorClient.WatchUser()` to get updates on users' total scores. + // this has an unfortunate side effect of other players showing up in `SpectatorClient.WatchingUsers`. + // + // we do not generally wish to display other players in the room as spectators due to that implementation detail, + // therefore this code is intended to filter out those players on the client side. + // + // note that the way that this is done is rather specific to the multiplayer use case and therefore carries a lot of assumptions + // (e.g. that the `MultiplayerRoomUser`s have the correct `State` at the point wherein they issue the `WatchUser()` calls). + // the more proper way to do this (which is by subscribing to `WatchingUsers` and `RoomUpdated`, and doing a proper diff to a third list on any change of either) + // is a lot more difficult to write correctly, given that we also rely on `BindableList`'s collection changed event arguments to properly animate this component. + var excludedUserIds = new HashSet(); + if (multiplayerClient.Room != null) + excludedUserIds.UnionWith(multiplayerClient.Room.Users.Where(u => u.State != MultiplayerUserState.Spectating).Select(u => u.UserID)); + switch (e.Action) { case NotifyCollectionChangedAction.Add: @@ -109,6 +129,9 @@ namespace osu.Game.Screens.Play.HUD var spectator = (SpectatorUser)e.NewItems![i]!; int index = Math.Max(e.NewStartingIndex, 0) + i; + if (excludedUserIds.Contains(spectator.OnlineID)) + continue; + if (index >= max_spectators_displayed) break; @@ -125,10 +148,10 @@ namespace osu.Game.Screens.Play.HUD for (int i = 0; i < spectatorsFlow.Count; i++) spectatorsFlow.SetLayoutPosition(spectatorsFlow[i], i); - if (Spectators.Count >= max_spectators_displayed && spectatorsFlow.Count < max_spectators_displayed) + if (watchingUsers.Count >= max_spectators_displayed && spectatorsFlow.Count < max_spectators_displayed) { for (int i = spectatorsFlow.Count; i < max_spectators_displayed; i++) - addNewSpectatorToList(i, Spectators[i]); + addNewSpectatorToList(i, watchingUsers[i]); } break; @@ -144,7 +167,8 @@ namespace osu.Game.Screens.Play.HUD throw new NotSupportedException(); } - Header.Text = SpectatorListStrings.SpectatorCount(Spectators.Count).ToUpper(); + displayedSpectatorCount = watchingUsers.Count(s => !excludedUserIds.Contains(s.OnlineID)); + header.Text = SpectatorListStrings.SpectatorCount(displayedSpectatorCount).ToUpper(); updateVisibility(); for (int i = 0; i < spectatorsFlow.Count; i++) @@ -160,7 +184,7 @@ namespace osu.Game.Screens.Play.HUD var entry = pool.Get(entry => { entry.Current.Value = spectator; - entry.UserPlayingState = UserPlayingState; + entry.UserPlayingState = userPlayingState; }); spectatorsFlow.Insert(i, entry); @@ -169,15 +193,15 @@ namespace osu.Game.Screens.Play.HUD private void updateVisibility() { // We don't want to show spectators when we are watching a replay. - mainFlow.FadeTo(Spectators.Count > 0 && UserPlayingState.Value != LocalUserPlayingState.NotPlaying ? 1 : 0, 250, Easing.OutQuint); + mainFlow.FadeTo(displayedSpectatorCount > 0 && userPlayingState.Value != LocalUserPlayingState.NotPlaying ? 1 : 0, 250, Easing.OutQuint); } private void updateAppearance() { - Header.Font = OsuFont.GetFont(Font.Value, 12, FontWeight.Bold); - Header.Colour = HeaderColour.Value; + header.Font = OsuFont.GetFont(Font.Value, 12, FontWeight.Bold); + header.Colour = HeaderColour.Value; - Width = Header.DrawWidth; + Width = header.DrawWidth; } private partial class SpectatorListEntry : PoolableDrawable diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 33b4bf976b..9f7604aa82 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Game.Beatmaps; using osu.Game.Extensions; @@ -35,7 +34,32 @@ namespace osu.Game.Screens.Ranking return null; getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); - getScoreRequest.Success += r => scoresCallback.Invoke(r.Scores.Where(s => !s.MatchesOnlineID(Score)).Select(s => s.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); + getScoreRequest.Success += r => + { + var toDisplay = new List(); + + for (int i = 0; i < r.Scores.Count; ++i) + { + var score = r.Scores[i]; + int position = i + 1; + + if (score.MatchesOnlineID(Score)) + { + // we don't want to add the same score twice, but also setting any properties of `Score` this late will have no visible effect, + // so we have to fish out the actual drawable panel and set the position to it directly. + var panel = ScorePanelList.GetPanelForScore(Score); + Score.Position = panel.ScorePosition.Value = position; + } + else + { + var converted = score.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo); + converted.Position = position; + toDisplay.Add(converted); + } + } + + scoresCallback.Invoke(toDisplay); + }; return getScoreRequest; } diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index 128e750dca..dafa0b0c1c 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -25,6 +25,11 @@ namespace osu.Game.Screens.Select protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0); + /// + /// Used to show an initial animation hinting at the enabled state. + /// + protected virtual bool IsActive => false; + public LocalisableString Text { get => SpriteText?.Text ?? default; @@ -124,6 +129,18 @@ namespace osu.Game.Screens.Select { base.LoadComplete(); Enabled.BindValueChanged(_ => updateDisplay(), true); + + if (IsActive) + { + box.ClearTransforms(); + + using (box.BeginDelayedSequence(200)) + { + box.FadeIn(200) + .Then() + .FadeOut(1500, Easing.OutQuint); + } + } } public Action Hovered; diff --git a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs index a2253b413c..978d6eca32 100644 --- a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs +++ b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs @@ -339,6 +339,12 @@ namespace osu.Game.Screens.SelectV2.Leaderboards Origin = Anchor.CentreLeft, Size = new Vector2(24, 16), }, + new UpdateableTeamFlag(user.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(40, 20), + }, new DateLabel(score.Date) { Anchor = Anchor.CentreLeft, diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index c9149bda22..d73fd5ab22 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; @@ -221,7 +222,15 @@ namespace osu.Game.Tests.Visual.OnlinePlay : new APIUser { Id = id, - Username = $"User {id}" + Username = $"User {id}", + Team = RNG.NextBool() + ? new APITeam + { + Name = "Collective Wangs", + ShortName = "WANG", + FlagUrl = "https://assets.ppy.sh/teams/logo/1/wanglogo.jpg", + } + : null, }) .Where(u => u != null).ToList(), }); diff --git a/osu.Game/Users/Drawables/UpdateableTeamFlag.cs b/osu.Game/Users/Drawables/UpdateableTeamFlag.cs new file mode 100644 index 0000000000..9c2bbb7e3e --- /dev/null +++ b/osu.Game/Users/Drawables/UpdateableTeamFlag.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Users.Drawables +{ + /// + /// A team logo which can update to a new team when needed. + /// + public partial class UpdateableTeamFlag : ModelBackedDrawable + { + public APITeam? Team + { + get => Model; + set + { + Model = value; + Invalidate(Invalidation.Presence); + } + } + + protected override double LoadDelay => 200; + + public UpdateableTeamFlag(APITeam? team = null) + { + Team = team; + + Masking = true; + } + + protected override Drawable? CreateDrawable(APITeam? team) + { + if (team == null) + return Empty(); + + return new TeamFlag(team) { RelativeSizeAxes = Axes.Both }; + } + + // Generally we just want team flags to disappear if the user doesn't have one. + // This also handles fill flow cases and avoids spacing being added for non-displaying flags. + public override bool IsPresent => base.IsPresent && Team != null; + + protected override void Update() + { + base.Update(); + + CornerRadius = DrawHeight / 8; + } + + [LongRunningLoad] + public partial class TeamFlag : CompositeDrawable, IHasTooltip + { + private readonly APITeam team; + + public LocalisableString TooltipText { get; } + + public TeamFlag(APITeam team) + { + this.team = team; + TooltipText = team.Name; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + InternalChildren = new Drawable[] + { + new HoverClickSounds(), + new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(team.FlagUrl) + } + }; + } + } + } +} diff --git a/osu.Game/Users/UserGridPanel.cs b/osu.Game/Users/UserGridPanel.cs index fce543415d..f62c9ab4e7 100644 --- a/osu.Game/Users/UserGridPanel.cs +++ b/osu.Game/Users/UserGridPanel.cs @@ -82,9 +82,10 @@ namespace osu.Game.Users AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(6), - Children = new Drawable[] + Children = new[] { CreateFlag(), + CreateTeamLogo(), // supporter icon is being added later } } diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 0d3ea52611..09a5cb414f 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -130,6 +130,11 @@ namespace osu.Game.Users Action = Action, }; + protected Drawable CreateTeamLogo() => new UpdateableTeamFlag(User.Team) + { + Size = new Vector2(52, 26), + }; + public MenuItem[] ContextMenuItems { get diff --git a/osu.Game/Users/UserRankPanel.cs b/osu.Game/Users/UserRankPanel.cs index 5e3ae172be..ff8adf055c 100644 --- a/osu.Game/Users/UserRankPanel.cs +++ b/osu.Game/Users/UserRankPanel.cs @@ -147,9 +147,10 @@ namespace osu.Game.Users AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(6), - Children = new Drawable[] + Children = new[] { CreateFlag(), + CreateTeamLogo(), // supporter icon is being added later } } diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index 15fc34b468..ac24bf2130 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -292,5 +293,45 @@ namespace osu.Game.Utils return rate; } + + /// + /// Determines whether a mod can be applied to playlist items in the given match type. + /// + /// The mod to test. + /// The match type. + public static bool IsValidModForMatchType(Mod mod, MatchType type) + { + if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) + return false; + + switch (type) + { + case MatchType.Playlists: + return true; + + default: + return mod.ValidForMultiplayer; + } + } + + /// + /// Determines whether a mod can be applied as a free mod to playlist items in the given match type. + /// + /// The mod to test. + /// The match type. + public static bool IsValidFreeModForMatchType(Mod mod, MatchType type) + { + if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) + return false; + + switch (type) + { + case MatchType.Playlists: + return true; + + default: + return mod.ValidForMultiplayerAsFreeMod; + } + } } } diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index a5a42c1e66..883e89e38a 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -11,6 +11,7 @@ using osu.Game; using osu.Game.Screens; using osu.Game.Updater; using osu.Game.Utils; +using osuTK; using UIKit; namespace osu.iOS @@ -22,6 +23,8 @@ namespace osu.iOS public override bool HideUnlicensedContent => true; + protected override Vector2 ScalingContainerTargetDrawSize => new Vector2(1024, 1024 * DrawHeight / DrawWidth); + public OsuGameIOS(AppDelegate appDelegate) { this.appDelegate = appDelegate; @@ -41,7 +44,7 @@ namespace osu.iOS updateOrientation(); } - private void updateOrientation() + private void updateOrientation() => UIApplication.SharedApplication.InvokeOnMainThread(() => { bool iPad = UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad; var orientation = MobileUtils.GetOrientation(this, (IOsuScreen)ScreenStack.CurrentScreen, iPad); @@ -60,7 +63,7 @@ namespace osu.iOS appDelegate.Orientations = null; break; } - } + }); protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier();