1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-21 22:53:22 +08:00

Merge branch 'master' into pp-dev

This commit is contained in:
Dean Herbert 2025-02-17 11:59:14 +09:00
commit ab88ccb081
No known key found for this signature in database
86 changed files with 2480 additions and 559 deletions

View File

@ -96,7 +96,7 @@ jobs:
build-only-android: build-only-android:
name: Build only (Android) name: Build only (Android)
runs-on: windows-2019 runs-on: windows-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- name: Checkout - name: Checkout
@ -114,10 +114,7 @@ jobs:
dotnet-version: "8.0.x" dotnet-version: "8.0.x"
- name: Install .NET workloads - name: Install .NET workloads
# since windows image 20241113.3.0, not specifying a version here run: dotnet workload install android
# 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)
- name: Compile - name: Compile
run: dotnet build -c Debug osu.Android.slnf run: dotnet build -c Debug osu.Android.slnf

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="osu.Android">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

View File

@ -12,6 +12,7 @@ using osu.Game;
using osu.Game.Screens; using osu.Game.Screens;
using osu.Game.Updater; using osu.Game.Updater;
using osu.Game.Utils; using osu.Game.Utils;
using osuTK;
namespace osu.Android namespace osu.Android
{ {
@ -20,6 +21,8 @@ namespace osu.Android
[Cached] [Cached]
private readonly OsuGameActivity gameActivity; private readonly OsuGameActivity gameActivity;
protected override Vector2 ScalingContainerTargetDrawSize => new Vector2(1024, 1024 * DrawHeight / DrawWidth);
public OsuGameAndroid(OsuGameActivity activity) public OsuGameAndroid(OsuGameActivity activity)
: base(null) : base(null)
{ {

View File

@ -3,6 +3,7 @@
using System; using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
@ -12,6 +13,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -26,18 +28,22 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
[Resolved] [Resolved]
private OsuColour colours { get; set; } = null!; private OsuColour colours { get; set; } = null!;
private IBindable<ScrollingDirection> direction = null!;
public ArgonJudgementPiece(HitResult result) public ArgonJudgementPiece(HitResult result)
: base(result) : base(result)
{ {
AutoSizeAxes = Axes.Both; AutoSizeAxes = Axes.Both;
Origin = Anchor.Centre; Origin = Anchor.Centre;
Y = judgement_y_position;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(IScrollingInfo scrollingInfo)
{ {
direction = scrollingInfo.Direction.GetBoundCopy();
direction.BindValueChanged(_ => onDirectionChanged(), true);
if (Result.IsHit()) if (Result.IsHit())
{ {
AddInternal(ringExplosion = new RingExplosion(Result) 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() => protected override SpriteText CreateJudgementText() =>
new OsuSpriteText new OsuSpriteText
{ {
@ -78,7 +86,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
this.ScaleTo(1.6f); this.ScaleTo(1.6f);
this.ScaleTo(1, 100, Easing.In); 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.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint);
this.RotateTo(0); this.RotateTo(0);

View File

@ -2,12 +2,14 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.Skinning.Legacy namespace osu.Game.Rulesets.Mania.Skinning.Legacy
@ -28,14 +30,16 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
AutoSizeAxes = Axes.Both; AutoSizeAxes = Axes.Both;
} }
[BackgroundDependencyLoader] private IBindable<ScrollingDirection> direction = null!;
private void load(ISkinSource skin)
{
float hitPosition = skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? 0;
float scorePosition = skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.ScorePosition)?.Value ?? 0;
float absoluteHitPosition = 480f * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR - hitPosition; [Resolved]
Y = scorePosition - absoluteHitPosition; private ISkinSource skin { get; set; } = null!;
[BackgroundDependencyLoader]
private void load(IScrollingInfo scrollingInfo)
{
direction = scrollingInfo.Direction.GetBoundCopy();
direction.BindValueChanged(_ => onDirectionChanged(), true);
InternalChild = animation.With(d => InternalChild = animation.With(d =>
{ {
@ -44,6 +48,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
}); });
} }
private void onDirectionChanged()
{
float hitPosition = skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? 0;
float scorePosition = skin.GetManiaSkinConfig<float>(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() public void PlayAnimation()
{ {
(animation as IFramedAnimation)?.GotoFrame(0); (animation as IFramedAnimation)?.GotoFrame(0);

View File

@ -0,0 +1,75 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.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<ScrollingDirection> 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;
}
}
}
}

View File

@ -3,65 +3,32 @@
#nullable disable #nullable disable
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osuTK; using osu.Game.Rulesets.UI.Scrolling;
namespace osu.Game.Rulesets.Mania.UI namespace osu.Game.Rulesets.Mania.UI
{ {
public partial class DrawableManiaJudgement : DrawableJudgement public partial class DrawableManiaJudgement : DrawableJudgement
{ {
protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); private IBindable<ScrollingDirection> direction;
private partial class DefaultManiaJudgementPiece : DefaultJudgementPiece [BackgroundDependencyLoader]
private void load(IScrollingInfo scrollingInfo)
{ {
private const float judgement_y_position = -180f; direction = scrollingInfo.Direction.GetBoundCopy();
direction.BindValueChanged(_ => onDirectionChanged(), true);
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;
}
}
} }
private void onDirectionChanged()
{
Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
Origin = Anchor.Centre;
}
protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result);
} }
} }

View File

@ -216,13 +216,7 @@ namespace osu.Game.Rulesets.Mania.UI
return; return;
judgements.Clear(false); judgements.Clear(false);
judgements.Add(judgementPooler.Get(result.Type, j => judgements.Add(judgementPooler.Get(result.Type, j => j.Apply(result, judgedObject))!);
{
j.Apply(result, judgedObject);
j.Anchor = Anchor.BottomCentre;
j.Origin = Anchor.Centre;
})!);
} }
protected override void Update() protected override void Update()

View File

@ -4,6 +4,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
@ -13,8 +14,9 @@ namespace osu.Game.Rulesets.Osu.Skinning
{ {
public abstract partial class FollowCircle : CompositeDrawable public abstract partial class FollowCircle : CompositeDrawable
{ {
[Resolved] protected DrawableSlider? DrawableObject { get; private set; }
protected DrawableHitObject? ParentObject { get; private set; }
private readonly IBindable<bool> tracking = new Bindable<bool>();
protected FollowCircle() protected FollowCircle()
{ {
@ -22,65 +24,73 @@ namespace osu.Game.Rulesets.Osu.Skinning
} }
[BackgroundDependencyLoader] [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); tracking.BindTo(DrawableObject.Tracking);
tracking.BindValueChanged(tracking =>
if (ParentObject.Judged)
return;
using (BeginAbsoluteSequence(Math.Max(Time.Current, ParentObject.HitObject?.StartTime ?? 0)))
{ {
if (tracking.NewValue) if (DrawableObject.Judged)
OnSliderPress(); return;
else
OnSliderRelease(); using (BeginAbsoluteSequence(Math.Max(Time.Current, DrawableObject.HitObject?.StartTime ?? 0)))
} {
}, true); if (tracking.NewValue)
OnSliderPress();
else
OnSliderRelease();
}
}, true);
}
} }
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
if (ParentObject != null) if (DrawableObject != null)
{ {
ParentObject.HitObjectApplied += onHitObjectApplied; DrawableObject.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(ParentObject); onHitObjectApplied(DrawableObject);
ParentObject.ApplyCustomUpdateState += updateStateTransforms; DrawableObject.ApplyCustomUpdateState += updateStateTransforms;
updateStateTransforms(ParentObject, ParentObject.State.Value); updateStateTransforms(DrawableObject, DrawableObject.State.Value);
} }
} }
private void onHitObjectApplied(DrawableHitObject drawableObject) private void onHitObjectApplied(DrawableHitObject drawableObject)
{ {
// Sane defaults when a new hitobject is applied to the drawable slider.
this.ScaleTo(1f) this.ScaleTo(1f)
.FadeOut(); .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) switch (state)
{ {
case ArmedState.Hit: case ArmedState.Hit:
switch (drawableObject) switch (d)
{ {
case DrawableSliderTail: 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 // HitStateUpdateTime is ~36ms before the actual slider end (aka slider
// tail leniency) // tail leniency)
using (BeginAbsoluteSequence(ParentObject.HitStateUpdateTime)) using (BeginAbsoluteSequence(DrawableObject.HitStateUpdateTime))
OnSliderEnd(); OnSliderEnd();
break; break;
case DrawableSliderTick: case DrawableSliderTick:
case DrawableSliderRepeat: case DrawableSliderRepeat:
using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) using (BeginAbsoluteSequence(d.HitStateUpdateTime))
OnSliderTick(); OnSliderTick();
break; break;
} }
@ -88,15 +98,15 @@ namespace osu.Game.Rulesets.Osu.Skinning
break; break;
case ArmedState.Miss: case ArmedState.Miss:
switch (drawableObject) switch (d)
{ {
case DrawableSliderTail: case DrawableSliderTail:
case DrawableSliderTick: case DrawableSliderTick:
case DrawableSliderRepeat: 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 // here, since on stable, the break anim plays right when the tail is
// missed, not when the slider ends // missed, not when the slider ends
using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) using (BeginAbsoluteSequence(d.HitStateUpdateTime))
OnSliderBreak(); OnSliderBreak();
break; break;
} }
@ -109,10 +119,10 @@ namespace osu.Game.Rulesets.Osu.Skinning
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
if (ParentObject != null) if (DrawableObject != null)
{ {
ParentObject.HitObjectApplied -= onHitObjectApplied; DrawableObject.HitObjectApplied -= onHitObjectApplied;
ParentObject.ApplyCustomUpdateState -= updateStateTransforms; DrawableObject.ApplyCustomUpdateState -= updateStateTransforms;
} }
} }

View File

@ -22,9 +22,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
protected override void OnSliderPress() 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. // 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). // This means the actual tracking area for gameplay purposes is larger than the sprite (but skins may be accounting for this).

View File

@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Scale = new osuTK.Vector2(0.5f),
})); }));
} }

View File

@ -6,14 +6,9 @@
using System; using System;
using System.Linq; using System.Linq;
using JetBrains.Annotations; using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osuTK.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Rulesets.Taiko.Skinning.Default;
@ -25,11 +20,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{ {
public partial class DrawableSwell : DrawableTaikoHitObject<Swell> public partial class DrawableSwell : DrawableTaikoHitObject<Swell>
{ {
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;
/// <summary> /// <summary>
/// Offset away from the start time of the swell at which the ring starts appearing. /// Offset away from the start time of the swell at which the ring starts appearing.
/// </summary> /// </summary>
@ -38,9 +28,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
private Vector2 baseSize; private Vector2 baseSize;
private readonly Container<DrawableSwellTick> ticks; private readonly Container<DrawableSwellTick> ticks;
private readonly Container bodyContainer;
private readonly CircularContainer targetRing;
private readonly CircularContainer expandingRing;
private double? lastPressHandleTime; private double? lastPressHandleTime;
@ -51,6 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
/// </summary> /// </summary>
public bool MustAlternate { get; internal set; } = true; public bool MustAlternate { get; internal set; } = true;
public event Action<int> UpdateHitProgress;
public DrawableSwell() public DrawableSwell()
: this(null) : this(null)
{ {
@ -61,87 +50,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{ {
FillMode = FillMode.Fit; 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<DrawableSwellTick> { RelativeSizeAxes = Axes.Both }); AddInternal(ticks = new Container<DrawableSwellTick> { 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), protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Swell),
_ => new SwellCirclePiece _ => new DefaultSwell
{ {
// to allow for rotation transform
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
}); });
protected override void RecreatePieces() protected override void RecreatePieces()
@ -208,16 +125,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
int numHits = ticks.Count(r => r.IsHit); int numHits = ticks.Count(r => r.IsHit);
float completion = (float)numHits / HitObject.RequiredHits; UpdateHitProgress?.Invoke(numHits);
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);
if (numHits == HitObject.RequiredHits) if (numHits == HitObject.RequiredHits)
ApplyMaxResult(); 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) protected override void UpdateHitStateTransforms(ArmedState state)
{ {
const double transition_duration = 300; base.UpdateHitStateTransforms(state);
switch (state) switch (state)
{ {
case ArmedState.Idle: case ArmedState.Idle:
expandingRing.FadeTo(0);
break; break;
case ArmedState.Miss: case ArmedState.Miss:
this.Delay(300).FadeOut();
break;
case ArmedState.Hit: case ArmedState.Hit:
this.FadeOut(transition_duration, Easing.Out); this.Delay(660).FadeOut();
bodyContainer.ScaleTo(1.4f, transition_duration);
break; break;
} }
} }

View File

@ -0,0 +1,20 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.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,
};
}
}
}

View File

@ -69,7 +69,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
return new ArgonHitExplosion(taikoComponent.Component); return new ArgonHitExplosion(taikoComponent.Component);
case TaikoSkinComponents.Swell: case TaikoSkinComponents.Swell:
return new ArgonSwellCirclePiece(); return new ArgonSwell();
} }
break; break;

View File

@ -0,0 +1,190 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.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;
}
}
}
}

View File

@ -0,0 +1,201 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.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;
}
}
}
}

View File

@ -66,7 +66,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
return this.GetAnimation("sliderscorepoint", false, false); return this.GetAnimation("sliderscorepoint", false, false);
case TaikoSkinComponents.Swell: 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; return null;
case TaikoSkinComponents.HitTarget: case TaikoSkinComponents.HitTarget:

View File

@ -6,6 +6,7 @@ using System.Linq;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
@ -342,6 +343,40 @@ namespace osu.Game.Tests.Mods
Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x"); 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 public abstract class CustomMod1 : Mod, IModCompatibilitySpecification
{ {
} }

View File

@ -73,6 +73,8 @@ namespace osu.Game.Tests.Skins
"Archives/modified-default-20241207.osk", "Archives/modified-default-20241207.osk",
// Covers skinnable spectator list // Covers skinnable spectator list
"Archives/modified-argon-20250116.osk", "Archives/modified-argon-20250116.osk",
// Covers player team flag
"Archives/modified-argon-20250214.osk",
}; };
/// <summary> /// <summary>

View File

@ -376,6 +376,6 @@ namespace osu.Game.Tests.Visual.Collections
private void assertCollectionName(int index, string name) private void assertCollectionName(int index, string name)
=> AddUntilStep($"item {index + 1} has correct name", => AddUntilStep($"item {index + 1} has correct name",
() => dialog.ChildrenOfType<DrawableCollectionList>().Single().OrderedItems.ElementAt(index).ChildrenOfType<TextBox>().First().Text == name); () => dialog.ChildrenOfType<DrawableCollectionList>().Single().OrderedItems.ElementAtOrDefault(index)?.ChildrenOfType<TextBox>().First().Text == name);
} }
} }

View File

@ -24,7 +24,11 @@ namespace osu.Game.Tests.Visual.Editing
Child = new DependencyProvidingContainer Child = new DependencyProvidingContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
CachedDependencies = new[] { (typeof(ScreenFooter), (object)footer) }, CachedDependencies = new[]
{
(typeof(ScreenFooter), (object)footer),
(typeof(BeatmapSubmissionSettings), new BeatmapSubmissionSettings()),
},
Children = new Drawable[] Children = new Drawable[]
{ {
receptor, receptor,

View File

@ -8,11 +8,14 @@ using osu.Framework.Graphics;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Tests.Visual.OnlinePlay;
using osu.Game.Tests.Visual.Spectator; using osu.Game.Tests.Visual.Spectator;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
@ -28,20 +31,23 @@ namespace osu.Game.Tests.Visual.Gameplay
SpectatorList list = null!; SpectatorList list = null!;
Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>(); Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>();
GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), healthProcessor: new OsuHealthProcessor(0), localUserPlayingState: playingState); 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", () => AddStep("create spectator list", () =>
{ {
Children = new Drawable[] Children = new Drawable[]
{ {
client, spectatorClient,
multiplayerClient,
new DependencyProvidingContainer new DependencyProvidingContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
CachedDependencies = CachedDependencies =
[ [
(typeof(GameplayState), gameplayState), (typeof(GameplayState), gameplayState),
(typeof(SpectatorClient), client) (typeof(SpectatorClient), spectatorClient),
(typeof(MultiplayerClient), multiplayerClient),
], ],
Child = list = new SpectatorList Child = list = new SpectatorList
{ {
@ -57,7 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddRepeatStep("add a user", () => AddRepeatStep("add a user", () =>
{ {
int id = Interlocked.Increment(ref counter); int id = Interlocked.Increment(ref counter);
((ISpectatorClient)client).UserStartedWatching([ ((ISpectatorClient)spectatorClient).UserStartedWatching([
new SpectatorUser new SpectatorUser
{ {
OnlineID = id, OnlineID = id,
@ -66,7 +72,8 @@ namespace osu.Game.Tests.Visual.Gameplay
]); ]);
}, 10); }, 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 venera", () => list.Font.Value = Typeface.Venera);
AddStep("change font to torus", () => list.Font.Value = Typeface.Torus); AddStep("change font to torus", () => list.Font.Value = Typeface.Torus);

View File

@ -166,7 +166,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Anchor = Anchor.BottomRight, Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight, Origin = Anchor.BottomRight,
Y = -ScreenFooter.HEIGHT, Y = -ScreenFooter.HEIGHT,
Current = { BindTarget = freeModSelectOverlay.SelectedMods }, FreeMods = { BindTarget = freeModSelectOverlay.SelectedMods },
}, },
footer = new ScreenFooter(), footer = new ScreenFooter(),
}, },

View File

@ -12,11 +12,14 @@ using osu.Framework.Utils;
using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online; using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants;
using osu.Game.Users; using osu.Game.Users;
using osuTK; 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<string, UserStatistics>
{
{
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() private void createNewParticipantsList()
{ {
ParticipantsList? participantsList = null; ParticipantsList? participantsList = null;

View File

@ -266,7 +266,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private void assertQueueTabCount(int count) 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}\"", () => AddUntilStep($"Queue tab shows \"{queueTabText}\"", () =>
{ {
return this.ChildrenOfType<OsuTabControl<MultiplayerPlaylistDisplayMode>.OsuTabItem>() return this.ChildrenOfType<OsuTabControl<MultiplayerPlaylistDisplayMode>.OsuTabItem>()

View File

@ -346,6 +346,13 @@ namespace osu.Game.Tests.Visual.Online
Twitter = "test_user", Twitter = "test_user",
Discord = "test_user", Discord = "test_user",
Website = "https://google.com", Website = "https://google.com",
Team = new APITeam
{
Id = 1,
Name = "Collective Wangs",
ShortName = "WANG",
FlagUrl = "https://assets.ppy.sh/teams/logo/1/wanglogo.jpg",
}
}; };
} }
} }

View File

@ -0,0 +1,113 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Linq;
using 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<BeatmapCollection>()));
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<BeatmapCollection>().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
}
];
});
}
}

View File

@ -96,7 +96,6 @@ namespace osu.Game.Collections
lastCreated = collections[changes.InsertedIndices[0]].ID; lastCreated = collections[changes.InsertedIndices[0]].ID;
foreach (int i in changes.NewModifiedIndices) foreach (int i in changes.NewModifiedIndices)
{ {
var updatedItem = collections[i]; var updatedItem = collections[i];

View File

@ -15,6 +15,7 @@ using osu.Framework.Localisation;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays; using osu.Game.Overlays;
using osuTK; using osuTK;
@ -27,7 +28,7 @@ namespace osu.Game.Collections
/// </summary> /// </summary>
public partial class DrawableCollectionListItem : OsuRearrangeableListItem<Live<BeatmapCollection>>, IFilterable public partial class DrawableCollectionListItem : OsuRearrangeableListItem<Live<BeatmapCollection>>, IFilterable
{ {
private const float item_height = 35; private const float item_height = 45;
private const float button_width = item_height * 0.75f; private const float button_width = item_height * 0.75f;
protected TextBox TextBox => content.TextBox; protected TextBox TextBox => content.TextBox;
@ -92,13 +93,11 @@ namespace osu.Game.Collections
Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 },
Children = new Drawable[] Children = new Drawable[]
{ {
TextBox = new ItemTextBox TextBox = new ItemTextBox(collection)
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.X,
Size = Vector2.One, Height = item_height,
CornerRadius = item_height / 2,
CommitOnFocusLost = true, 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; protected override float LeftRightPadding => item_height / 2;
private const float count_text_size = 12;
private readonly Live<BeatmapCollection> collection;
private OsuSpriteText countText = null!;
public ItemTextBox(Live<BeatmapCollection> collection)
{
this.collection = collection;
CornerRadius = item_height / 2;
}
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuColour colours) private void load(OsuColour colours)
{ {
BackgroundUnfocused = colours.GreySeaFoamDarker.Darken(0.5f); BackgroundUnfocused = colours.GreySeaFoamDarker.Darken(0.5f);
BackgroundFocused = colours.GreySeaFoam; 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)); private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c));
} }
public IEnumerable<LocalisableString> FilterTerms => [(LocalisableString)Model.Value.Name]; public IEnumerable<LocalisableString> FilterTerms => Model.PerformRead(m => m.IsValid ? new[] { (LocalisableString)m.Name } : []);
private bool matchingFilter = true; private bool matchingFilter = true;

View File

@ -238,7 +238,7 @@ namespace osu.Game.Configuration
public void Migrate() public void Migrate()
{ {
// arrives as 2020.123.0 // arrives as 2020.123.0-lazer
string rawVersion = Get<string>(OsuSetting.Version); string rawVersion = Get<string>(OsuSetting.Version);
if (rawVersion.Length < 6) 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[0], out int year)) return;
if (!int.TryParse(pieces[1], out int monthDay)) 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 < 20250214)
// if (combined < 20220103) { performMigration() } {
// UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before.
if (RuntimeInfo.IsMobile)
GetBindable<float>(OsuSetting.UIScale).SetDefault();
}
} }
public override TrackedSettings CreateTrackedSettings() public override TrackedSettings CreateTrackedSettings()

View File

@ -61,6 +61,20 @@ namespace osu.Game.Database
Configuration = new LegacySkinDecoder().Decode(skinStreamReader) 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 // 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 // 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)); 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"; protected override string FileExtension => @".osz";

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -26,17 +24,17 @@ namespace osu.Game.Graphics.Containers
{ {
internal const float TRANSITION_DURATION = 500; internal const float TRANSITION_DURATION = 500;
private Bindable<float> sizeX; private Bindable<float> sizeX = null!;
private Bindable<float> sizeY; private Bindable<float> sizeY = null!;
private Bindable<float> posX; private Bindable<float> posX = null!;
private Bindable<float> posY; private Bindable<float> posY = null!;
private Bindable<bool> applySafeAreaPadding; private Bindable<bool> applySafeAreaPadding = null!;
private Bindable<MarginPadding> safeAreaPadding; private Bindable<MarginPadding> safeAreaPadding = null!;
private readonly ScalingMode? targetMode; private readonly ScalingMode? targetMode;
private Bindable<ScalingMode> scalingMode; private Bindable<ScalingMode> scalingMode = null!;
private readonly Container content; private readonly Container content;
protected override Container<Drawable> Content => content; protected override Container<Drawable> Content => content;
@ -45,9 +43,9 @@ namespace osu.Game.Graphics.Containers
private readonly Container sizableContainer; private readonly Container sizableContainer;
private BackgroundScreenStack backgroundStack; private BackgroundScreenStack? backgroundStack;
private Bindable<float> scalingMenuBackgroundDim; private Bindable<float> scalingMenuBackgroundDim = null!;
private RectangleF? customRect; private RectangleF? customRect;
private bool customRectIsRelativePosition; private bool customRectIsRelativePosition;
@ -88,7 +86,8 @@ namespace osu.Game.Graphics.Containers
public partial class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer public partial class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer
{ {
private readonly bool applyUIScale; private readonly bool applyUIScale;
private Bindable<float> uiScale;
private Bindable<float>? uiScale;
protected float CurrentScale { get; private set; } = 1; protected float CurrentScale { get; private set; } = 1;
@ -99,6 +98,9 @@ namespace osu.Game.Graphics.Containers
this.applyUIScale = applyUIScale; this.applyUIScale = applyUIScale;
} }
[Resolved(canBeNull: true)]
private OsuGame? game { get; set; }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuConfigManager osuConfig) private void load(OsuConfigManager osuConfig)
{ {
@ -111,6 +113,8 @@ namespace osu.Game.Graphics.Containers
protected override void Update() protected override void Update()
{ {
if (game != null)
TargetDrawSize = game.ScalingContainerTargetDrawSize;
Scale = new Vector2(CurrentScale); Scale = new Vector2(CurrentScale);
Size = new Vector2(1 / CurrentScale); Size = new Vector2(1 / CurrentScale);
@ -233,13 +237,13 @@ namespace osu.Game.Graphics.Containers
private partial class SizeableAlwaysInputContainer : Container private partial class SizeableAlwaysInputContainer : Container
{ {
[Resolved] [Resolved]
private GameHost host { get; set; } private GameHost host { get; set; } = null!;
[Resolved] [Resolved]
private ISafeArea safeArea { get; set; } private ISafeArea safeArea { get; set; } = null!;
[Resolved] [Resolved]
private OsuConfigManager config { get; set; } private OsuConfigManager config { get; set; } = null!;
private readonly bool confineHostCursor; private readonly bool confineHostCursor;
private readonly LayoutValue cursorRectCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); private readonly LayoutValue cursorRectCache = new LayoutValue(Invalidation.RequiredParentSizeToFit);

View File

@ -18,6 +18,8 @@ namespace osu.Game.Graphics.UserInterface
public abstract partial class OsuSliderBar<T> : SliderBar<T>, IHasTooltip public abstract partial class OsuSliderBar<T> : SliderBar<T>, IHasTooltip
where T : struct, INumber<T>, IMinMaxValue<T> where T : struct, INumber<T>, IMinMaxValue<T>
{ {
public override bool AcceptsFocus => !Current.Disabled;
public bool PlaySamplesOnAdjust { get; set; } = true; public bool PlaySamplesOnAdjust { get; set; } = true;
/// <summary> /// <summary>

View File

@ -162,6 +162,8 @@ namespace osu.Game.Graphics.UserInterface
protected partial class BoundSlider : RoundedSliderBar<double> protected partial class BoundSlider : RoundedSliderBar<double>
{ {
public override bool AcceptsFocus => false;
public new Nub Nub => base.Nub; public new Nub Nub => base.Nub;
public string? DefaultString; public string? DefaultString;

View File

@ -227,6 +227,10 @@ namespace osu.Game.Input.Bindings
}; };
} }
/// <remarks>
/// 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.
/// </remarks>
public enum GlobalAction public enum GlobalAction
{ {
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChat))] [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChat))]

View File

@ -39,6 +39,31 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString SubmissionSettings => new TranslatableString(getKey(@"submission_settings"), @"Submission settings"); public static LocalisableString SubmissionSettings => new TranslatableString(getKey(@"submission_settings"), @"Submission settings");
/// <summary>
/// "Submit beatmap!"
/// </summary>
public static LocalisableString ConfirmSubmission => new TranslatableString(getKey(@"confirm_submission"), @"Submit beatmap!");
/// <summary>
/// "Exporting beatmap for compatibility..."
/// </summary>
public static LocalisableString Exporting => new TranslatableString(getKey(@"exporting"), @"Exporting beatmap for compatibility...");
/// <summary>
/// "Preparing for upload..."
/// </summary>
public static LocalisableString Preparing => new TranslatableString(getKey(@"preparing"), @"Preparing for upload...");
/// <summary>
/// "Uploading beatmap contents..."
/// </summary>
public static LocalisableString Uploading => new TranslatableString(getKey(@"uploading"), @"Uploading beatmap contents...");
/// <summary>
/// "Finishing up..."
/// </summary>
public static LocalisableString Finishing => new TranslatableString(getKey(@"finishing"), @"Finishing up...");
/// <summary> /// <summary>
/// "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!" /// "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!"
/// </summary> /// </summary>
@ -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"); public static LocalisableString LoadInBrowserAfterSubmission => new TranslatableString(getKey(@"load_in_browser_after_submission"), @"Load in browser after submission");
/// <summary> /// <summary>
/// "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."
/// </summary> /// </summary>
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.");
/// <summary>
/// "Empty beatmaps cannot be submitted."
/// </summary>
public static LocalisableString EmptyBeatmapsCannotBeSubmitted => new TranslatableString(getKey(@"empty_beatmaps_cannot_be_submitted"), @"Empty beatmaps cannot be submitted.");
/// <summary>
/// "Update beatmap!"
/// </summary>
public static LocalisableString UpdateBeatmap => new TranslatableString(getKey(@"update_beatmap"), @"Update beatmap!");
/// <summary>
/// "Upload NEW beatmap!"
/// </summary>
public static LocalisableString UploadNewBeatmap => new TranslatableString(getKey(@"upload_new_beatmap"), @"Upload NEW beatmap!");
private static string getKey(string key) => $@"{prefix}:{key}"; private static string getKey(string key) => $@"{prefix}:{key}";
} }

View File

@ -69,6 +69,16 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString DeleteDifficulty => new TranslatableString(getKey(@"delete_difficulty"), @"Delete difficulty"); public static LocalisableString DeleteDifficulty => new TranslatableString(getKey(@"delete_difficulty"), @"Delete difficulty");
/// <summary>
/// "Edit externally"
/// </summary>
public static LocalisableString EditExternally => new TranslatableString(getKey(@"edit_externally"), @"Edit externally");
/// <summary>
/// "Submit beatmap"
/// </summary>
public static LocalisableString SubmitBeatmap => new TranslatableString(getKey(@"submit_beatmap"), @"Submit beatmap");
/// <summary> /// <summary>
/// "setup" /// "setup"
/// </summary> /// </summary>
@ -184,6 +194,16 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString ResetBookmarks => new TranslatableString(getKey(@"reset_bookmarks"), @"Reset bookmarks"); public static LocalisableString ResetBookmarks => new TranslatableString(getKey(@"reset_bookmarks"), @"Reset bookmarks");
/// <summary>
/// "Open beatmap info page"
/// </summary>
public static LocalisableString OpenInfoPage => new TranslatableString(getKey(@"open_info_page"), @"Open beatmap info page");
/// <summary>
/// "Open beatmap discussion page"
/// </summary>
public static LocalisableString OpenDiscussionPage => new TranslatableString(getKey(@"open_discussion_page"), @"Open beatmap discussion page");
private static string getKey(string key) => $@"{prefix}:{key}"; private static string getKey(string key) => $@"{prefix}:{key}";
} }
} }

View File

@ -26,10 +26,8 @@ namespace osu.Game.Online.API.Requests
public uint BeatmapSetID { get; } public uint BeatmapSetID { get; }
// ReSharper disable once CollectionNeverUpdated.Global
public Dictionary<string, byte[]> FilesChanged { get; } = new Dictionary<string, byte[]>(); public Dictionary<string, byte[]> FilesChanged { get; } = new Dictionary<string, byte[]>();
// ReSharper disable once CollectionNeverUpdated.Global
public HashSet<string> FilesDeleted { get; } = new HashSet<string>(); public HashSet<string> FilesDeleted { get; } = new HashSet<string>();
public PatchBeatmapPackageRequest(uint beatmapSetId) public PatchBeatmapPackageRequest(uint beatmapSetId)
@ -48,7 +46,7 @@ namespace osu.Game.Online.API.Requests
foreach (string filename in FilesDeleted) foreach (string filename in FilesDeleted)
request.AddParameter(@"filesDeleted", filename, RequestParameterType.Form); request.AddParameter(@"filesDeleted", filename, RequestParameterType.Form);
request.Timeout = 60_000; request.Timeout = 600_000;
return request; return request;
} }
} }

View File

@ -38,7 +38,7 @@ namespace osu.Game.Online.API.Requests
var request = base.CreateWebRequest(); var request = base.CreateWebRequest();
request.AddFile(@"beatmapArchive", oszPackage); request.AddFile(@"beatmapArchive", oszPackage);
request.Method = HttpMethod.Put; request.Method = HttpMethod.Put;
request.Timeout = 60_000; request.Timeout = 600_000;
return request; return request;
} }
} }

View File

@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using 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;
}
}

View File

@ -55,6 +55,10 @@ namespace osu.Game.Online.API.Requests.Responses
set => countryCodeString = value.ToString(); set => countryCodeString = value.ToString();
} }
[JsonProperty(@"team")]
[CanBeNull]
public APITeam Team { get; set; }
[JsonProperty(@"profile_colour")] [JsonProperty(@"profile_colour")]
public string Colour; public string Colour;

View File

@ -13,6 +13,7 @@ namespace osu.Game.Online
SpectatorUrl = $@"{APIUrl}/signalr/spectator"; SpectatorUrl = $@"{APIUrl}/signalr/spectator";
MultiplayerUrl = $@"{APIUrl}/signalr/multiplayer"; MultiplayerUrl = $@"{APIUrl}/signalr/multiplayer";
MetadataUrl = $@"{APIUrl}/signalr/metadata"; MetadataUrl = $@"{APIUrl}/signalr/metadata";
BeatmapSubmissionServiceUrl = $@"{APIUrl}/beatmap-submission";
} }
} }
} }

View File

@ -180,6 +180,7 @@ namespace osu.Game.Online.Leaderboards
Height = 28, Height = 28,
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Spacing = new Vector2(10f, 0f), Spacing = new Vector2(10f, 0f),
Margin = new MarginPadding { Bottom = -2 },
Children = new Drawable[] Children = new Drawable[]
{ {
flagBadgeAndDateContainer = new FillFlowContainer flagBadgeAndDateContainer = new FillFlowContainer
@ -189,7 +190,7 @@ namespace osu.Game.Online.Leaderboards
RelativeSizeAxes = Axes.Y, RelativeSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Spacing = new Vector2(5f, 0f), Spacing = new Vector2(5f, 0f),
Width = 87f, Width = 114f,
Masking = true, Masking = true,
Children = new Drawable[] Children = new Drawable[]
{ {
@ -199,6 +200,12 @@ namespace osu.Game.Online.Leaderboards
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Size = new Vector2(28, 20), Size = new Vector2(28, 20),
}, },
new UpdateableTeamFlag(user.Team)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(40, 20),
},
new DateLabel(Score.Date) new DateLabel(Score.Date)
{ {
Anchor = Anchor.CentreLeft, 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"), GlowColour = Color4Extensions.FromHex(@"83ccfa"),
Current = scoreManager.GetBindableTotalScoreString(Score), Current = scoreManager.GetBindableTotalScoreString(Score),
Font = OsuFont.Numeric.With(size: 23), Font = OsuFont.Numeric.With(size: 23),
Margin = new MarginPadding { Top = 1 },
}, },
RankContainer = new Container RankContainer = new Container
{ {
@ -250,13 +249,32 @@ namespace osu.Game.Online.Leaderboards
}, },
}, },
}, },
modsContainer = new FillFlowContainer<ModIcon> new FillFlowContainer
{ {
AutoSizeAxes = Axes.Both,
Anchor = Anchor.BottomRight, Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight, Origin = Anchor.BottomRight,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal, 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<ModIcon>
{
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 partial class ScoreComponentLabel : Container, IHasTooltip
{ {
private const float icon_size = 20; private const float icon_size = 16;
private readonly FillFlowContainer content; private readonly FillFlowContainer content;
public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos);
@ -340,7 +358,7 @@ namespace osu.Game.Online.Leaderboards
{ {
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Padding = new MarginPadding { Right = 10 }, Padding = new MarginPadding { Right = 5 },
Children = new Drawable[] Children = new Drawable[]
{ {
new Container new Container
@ -375,7 +393,8 @@ namespace osu.Game.Online.Leaderboards
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Text = statistic.Value, 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) public DateLabel(DateTimeOffset date)
: base(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)); protected override string Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30));

View File

@ -72,6 +72,7 @@ using osu.Game.Skinning;
using osu.Game.Updater; using osu.Game.Updater;
using osu.Game.Users; using osu.Game.Users;
using osu.Game.Utils; using osu.Game.Utils;
using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
using Sentry; using Sentry;
@ -813,6 +814,12 @@ namespace osu.Game
protected virtual UpdateManager CreateUpdateManager() => new UpdateManager(); protected virtual UpdateManager CreateUpdateManager() => new UpdateManager();
/// <summary>
/// Adjust the globally applied <see cref="DrawSizePreservingFillContainer.TargetDrawSize"/> in every <see cref="ScalingContainer"/>.
/// Useful for changing how the game handles different aspect ratios.
/// </summary>
protected internal virtual Vector2 ScalingContainerTargetDrawSize { get; } = new Vector2(1024, 768);
protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything); protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything);
#region Beatmap progression #region Beatmap progression

View File

@ -160,7 +160,20 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
{ {
Size = new Vector2(19, 14), 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 #pragma warning disable 618
new StatisticText(score.MaxCombo, score.BeatmapInfo!.MaxCombo, @"0\x"), new StatisticText(score.MaxCombo, score.BeatmapInfo!.MaxCombo, @"0\x"),
#pragma warning restore 618 #pragma warning restore 618

View File

@ -27,7 +27,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
private readonly UpdateableAvatar avatar; private readonly UpdateableAvatar avatar;
private readonly LinkFlowContainer usernameText; private readonly LinkFlowContainer usernameText;
private readonly DrawableDate achievedOn; private readonly DrawableDate achievedOn;
private readonly UpdateableFlag flag; private readonly UpdateableFlag flag;
private readonly UpdateableTeamFlag teamFlag;
public TopScoreUserSection() public TopScoreUserSection()
{ {
@ -112,12 +114,30 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
}, },
} }
}, },
flag = new UpdateableFlag new FillFlowContainer
{ {
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Size = new Vector2(19, 14), Direction = FillDirection.Horizontal,
Margin = new MarginPadding { Top = 3 }, // makes spacing look more even 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; avatar.User = value.User;
flag.CountryCode = value.User.CountryCode; flag.CountryCode = value.User.CountryCode;
teamFlag.Team = value.User.Team;
achievedOn.Date = value.Date; achievedOn.Date = value.Date;
usernameText.Clear(); usernameText.Clear();

View File

@ -80,7 +80,7 @@ namespace osu.Game.Overlays
protected override CountryCode GetCountryCode(APIUser item) => item.CountryCode; 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)) 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 TextAnchor = Anchor.CentreLeft
}; };
username.AddUserLink(item); username.AddUserLink(item);
return username; return [username];
} }
} }
} }

View File

@ -44,6 +44,8 @@ namespace osu.Game.Overlays.Profile.Header
private UpdateableFlag userFlag = null!; private UpdateableFlag userFlag = null!;
private OsuHoverContainer userCountryContainer = null!; private OsuHoverContainer userCountryContainer = null!;
private OsuSpriteText userCountryText = null!; private OsuSpriteText userCountryText = null!;
private UpdateableTeamFlag teamFlag = null!;
private OsuSpriteText teamText = null!;
private GroupBadgeFlow groupBadgeFlow = null!; private GroupBadgeFlow groupBadgeFlow = null!;
private ToggleCoverButton coverToggle = null!; private ToggleCoverButton coverToggle = null!;
private PreviousUsernamesDisplay previousUsernamesDisplay = null!; private PreviousUsernamesDisplay previousUsernamesDisplay = null!;
@ -154,29 +156,58 @@ namespace osu.Game.Overlays.Profile.Header
titleText = new OsuSpriteText titleText = new OsuSpriteText
{ {
Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular),
Margin = new MarginPadding { Bottom = 5 } Margin = new MarginPadding { Bottom = 3 },
}, },
new FillFlowContainer new FillFlowContainer
{ {
Margin = new MarginPadding { Top = 3 },
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Children = new Drawable[] Children = new Drawable[]
{ {
userFlag = new UpdateableFlag new FillFlowContainer
{
Size = new Vector2(28, 20),
},
userCountryContainer = new OsuHoverContainer
{ {
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreLeft, Direction = FillDirection.Horizontal,
Origin = Anchor.CentreLeft, Spacing = new Vector2(4, 0),
Margin = new MarginPadding { Left = 5 }, Children = new Drawable[]
Child = userCountryText = new OsuSpriteText
{ {
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; userFlag.CountryCode = user?.CountryCode ?? default;
userCountryText.Text = (user?.CountryCode ?? default).GetDescription(); userCountryText.Text = (user?.CountryCode ?? default).GetDescription();
userCountryContainer.Action = () => rankingsOverlay?.ShowCountry(user?.CountryCode ?? default); userCountryContainer.Action = () => rankingsOverlay?.ShowCountry(user?.CountryCode ?? default);
teamFlag.Team = user?.Team;
teamText.Text = user?.Team?.Name ?? string.Empty;
supporterTag.SupportLevel = user?.SupportLevel ?? 0; supporterTag.SupportLevel = user?.SupportLevel ?? 0;
titleText.Text = user?.Title ?? string.Empty; titleText.Text = user?.Title ?? string.Empty;
titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff");

View File

@ -34,7 +34,7 @@ namespace osu.Game.Overlays.Rankings.Tables
protected override CountryCode GetCountryCode(CountryStatistics item) => item.Code; 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[] protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[]
{ {

View File

@ -80,7 +80,7 @@ namespace osu.Game.Overlays.Rankings.Tables
protected abstract CountryCode GetCountryCode(TModel item); 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 private OsuSpriteText createIndexDrawable(int index) => new RowText
{ {
@ -92,16 +92,13 @@ namespace osu.Game.Overlays.Rankings.Tables
{ {
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0), Spacing = new Vector2(5, 0),
Margin = new MarginPadding { Bottom = row_spacing }, Margin = new MarginPadding { Bottom = row_spacing },
Children = new[] Children =
{ [
new UpdateableFlag(GetCountryCode(item)) new UpdateableFlag(GetCountryCode(item)) { Size = new Vector2(28, 20) },
{ ..CreateFlagContent(item)
Size = new Vector2(28, 20), ]
},
CreateFlagContent(item)
}
}; };
protected class RankingsTableColumn : TableColumn protected class RankingsTableColumn : TableColumn

View File

@ -14,6 +14,8 @@ using osu.Game.Users;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web; using osu.Game.Resources.Localisation.Web;
using osu.Game.Users.Drawables;
using osuTK;
namespace osu.Game.Overlays.Rankings.Tables 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 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)) 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 TextAnchor = Anchor.CentreLeft
}; };
username.AddUserLink(item.User); 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[] protected sealed override Drawable[] CreateAdditionalContent(UserStatistics item) => new[]

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Layout; using osu.Framework.Layout;
using osu.Game.Graphics; using osu.Game.Graphics;
@ -54,6 +55,10 @@ namespace osu.Game.Overlays
private IconButton expandButton = null!; private IconButton expandButton = null!;
private InputManager inputManager = null!;
private Drawable? draggedChild;
/// <summary> /// <summary>
/// Create a new instance. /// Create a new instance.
/// </summary> /// </summary>
@ -125,6 +130,8 @@ namespace osu.Game.Overlays
{ {
base.LoadComplete(); base.LoadComplete();
inputManager = GetContainingInputManager()!;
Expanded.BindValueChanged(_ => updateExpandedState(true)); Expanded.BindValueChanged(_ => updateExpandedState(true));
updateExpandedState(false); updateExpandedState(false);
@ -156,6 +163,13 @@ namespace osu.Game.Overlays
headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint); headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint);
headerTextVisibilityCache.Validate(); 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) protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source)
@ -168,11 +182,17 @@ namespace osu.Game.Overlays
private void updateExpandedState(bool animate) 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 // clearing transforms is necessary to avoid a previous height transform
// potentially continuing to get processed while content has changed to autosize. // potentially continuing to get processed while content has changed to autosize.
content.ClearTransforms(); content.ClearTransforms();
if (Expanded.Value || IsHovered) if (Expanded.Value || IsHovered || draggedChild != null)
{ {
content.AutoSizeAxes = Axes.Y; content.AutoSizeAxes = Axes.Y;
content.AutoSizeDuration = animate ? transition_duration : 0; content.AutoSizeDuration = animate ? transition_duration : 0;

View File

@ -7,8 +7,10 @@ using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Overlays.Toolbar namespace osu.Game.Overlays.Toolbar
{ {
@ -17,6 +19,8 @@ namespace osu.Game.Overlays.Toolbar
private OsuSpriteText realTime; private OsuSpriteText realTime;
private OsuSpriteText gameTime; private OsuSpriteText gameTime;
private FillFlowContainer runningText;
private bool showRuntime = true; private bool showRuntime = true;
public bool ShowRuntime public bool ShowRuntime
@ -52,17 +56,36 @@ namespace osu.Game.Overlays.Toolbar
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuColour colours) private void load(OsuColour colours)
{ {
AutoSizeAxes = Axes.Y; AutoSizeAxes = Axes.Both;
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
realTime = new OsuSpriteText(), realTime = new OsuSpriteText
gameTime = new OsuSpriteText {
Font = OsuFont.Default.With(fixedWidth: true),
Spacing = new Vector2(-1.5f, 0),
},
runningText = new FillFlowContainer
{ {
Y = 14, Y = 14,
Colour = colours.PinkLight, 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(); updateMetrics();
@ -71,14 +94,12 @@ namespace osu.Game.Overlays.Toolbar
protected override void UpdateDisplay(DateTimeOffset now) protected override void UpdateDisplay(DateTimeOffset now)
{ {
realTime.Text = now.ToLocalisableString(use24HourDisplay ? @"HH:mm:ss" : @"h:mm:ss tt"); 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() 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). runningText.FadeTo(showRuntime ? 1 : 0);
gameTime.FadeTo(showRuntime ? 1 : 0);
} }
} }
} }

View File

@ -45,6 +45,8 @@ namespace osu.Game.Overlays
private LoadingSpinner loading = null!; private LoadingSpinner loading = null!;
private ScheduledDelegate? loadingShowDelegate; private ScheduledDelegate? loadingShowDelegate;
public bool Completed { get; private set; }
protected WizardOverlay(OverlayColourScheme scheme) protected WizardOverlay(OverlayColourScheme scheme)
: base(scheme) : base(scheme)
{ {
@ -221,6 +223,7 @@ namespace osu.Game.Overlays
else else
{ {
CurrentStepIndex = null; CurrentStepIndex = null;
Completed = true;
Hide(); Hide();
} }

View File

@ -4,6 +4,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -14,6 +15,9 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary
{ {
public partial class TestGameplayButton : OsuButton public partial class TestGameplayButton : OsuButton
{ {
[Resolved]
private OsuColour colours { get; set; } = null!;
protected override SpriteText CreateText() => new OsuSpriteText protected override SpriteText CreateText() => new OsuSpriteText
{ {
Depth = -1, Depth = -1,
@ -24,7 +28,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary
}; };
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuColour colours, OverlayColourProvider colourProvider) private void load(OverlayColourProvider colourProvider)
{ {
BackgroundColour = colours.Orange1; BackgroundColour = colours.Orange1;
SpriteText.Colour = colourProvider.Background6; SpriteText.Colour = colourProvider.Background6;
@ -33,5 +37,18 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary
Text = EditorStrings.TestBeatmap; 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
}
} }
} }

View File

@ -32,6 +32,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings; 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.Design;
using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.GameplayTest;
using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Edit.Submission;
using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Edit.Verify; using osu.Game.Screens.Edit.Verify;
using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay;
@ -111,6 +113,10 @@ namespace osu.Game.Screens.Edit
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private INotificationOverlay notifications { get; set; } private INotificationOverlay notifications { get; set; }
[Resolved(canBeNull: true)]
[CanBeNull]
private LoginOverlay loginOverlay { get; set; }
[Resolved] [Resolved]
private RealmAccess realm { get; set; } private RealmAccess realm { get; set; }
@ -1251,11 +1257,31 @@ namespace osu.Game.Screens.Edit
if (RuntimeInfo.IsDesktop) if (RuntimeInfo.IsDesktop)
{ {
var externalEdit = new EditorMenuItem("Edit externally", MenuItemType.Standard, editExternally); var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally);
saveRelatedMenuItems.Add(externalEdit); saveRelatedMenuItems.Add(externalEdit);
yield return 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 OsuMenuItemSpacer();
yield return new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit); 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) private void exportBeatmap(bool legacy)
{ {
if (HasUnsavedChanges) if (HasUnsavedChanges)

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Localisation; using osu.Game.Localisation;
@ -15,10 +17,14 @@ namespace osu.Game.Screens.Edit.Submission
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(IBindable<WorkingBeatmap> beatmap)
{ {
AddStep<ScreenContentPermissions>(); if (beatmap.Value.BeatmapSetInfo.OnlineID <= 0)
AddStep<ScreenFrequentlyAskedQuestions>(); {
AddStep<ScreenContentPermissions>();
AddStep<ScreenFrequentlyAskedQuestions>();
}
AddStep<ScreenSubmissionSettings>(); AddStep<ScreenSubmissionSettings>();
Header.Title = BeatmapSubmissionStrings.BeatmapSubmissionTitle; Header.Title = BeatmapSubmissionStrings.BeatmapSubmissionTitle;

View File

@ -0,0 +1,446 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.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<BeatmapSetInfo>? 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<BeatmapSetInfo>(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<BeatmapSetFile> 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<string>();
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<string, byte[]>();
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<bool>(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();
}
}
}

View File

@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Game.Online.API.Requests;
namespace osu.Game.Screens.Edit.Submission
{
public class BeatmapSubmissionSettings
{
public Bindable<BeatmapSubmissionTarget> Target { get; } = new Bindable<BeatmapSubmissionTarget>();
}
}

View File

@ -11,6 +11,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays; using osu.Game.Overlays;
using osuTK; using osuTK;
@ -22,8 +23,10 @@ namespace osu.Game.Screens.Edit.Submission
private readonly BindableBool notifyOnDiscussionReplies = new BindableBool(); private readonly BindableBool notifyOnDiscussionReplies = new BindableBool();
private readonly BindableBool loadInBrowserAfterSubmission = new BindableBool(); private readonly BindableBool loadInBrowserAfterSubmission = new BindableBool();
public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ConfirmSubmission;
[BackgroundDependencyLoader] [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.EditorSubmissionNotifyOnDiscussionReplies, notifyOnDiscussionReplies);
configManager.BindWith(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, loadInBrowserAfterSubmission); configManager.BindWith(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, loadInBrowserAfterSubmission);
@ -39,6 +42,7 @@ namespace osu.Game.Screens.Edit.Submission
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Caption = BeatmapSubmissionStrings.BeatmapSubmissionTargetCaption, Caption = BeatmapSubmissionStrings.BeatmapSubmissionTargetCaption,
Current = settings.Target,
}, },
new FormCheckBox 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,
}
} }
} }

View File

@ -0,0 +1,53 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.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<int>? 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;
}
}
}
}

View File

@ -53,13 +53,11 @@ namespace osu.Game.Screens.OnlinePlay.Components
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Height = 2, Height = 2,
Margin = new MarginPadding { Bottom = 2 }
}, },
new FillFlowContainer new FillFlowContainer
{ {
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Margin = new MarginPadding { Top = 5 },
Spacing = new Vector2(10, 0), Spacing = new Vector2(10, 0),
Children = new Drawable[] Children = new Drawable[]
{ {

View File

@ -11,31 +11,22 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select; using osu.Game.Screens.Select;
using osuTK; using osuTK;
using osu.Game.Localisation;
namespace osu.Game.Screens.OnlinePlay namespace osu.Game.Screens.OnlinePlay
{ {
public partial class FooterButtonFreeMods : FooterButton, IHasCurrentValue<IReadOnlyList<Mod>> public partial class FooterButtonFreeMods : FooterButton
{ {
private readonly BindableWithCurrent<IReadOnlyList<Mod>> current = new BindableWithCurrent<IReadOnlyList<Mod>>(Array.Empty<Mod>()); public readonly Bindable<IReadOnlyList<Mod>> FreeMods = new Bindable<IReadOnlyList<Mod>>();
public readonly IBindable<bool> Freestyle = new Bindable<bool>();
public Bindable<IReadOnlyList<Mod>> Current protected override bool IsActive => FreeMods.Value.Count > 0;
{
get => current.Current;
set
{
ArgumentNullException.ThrowIfNull(value);
current.Current = value;
}
}
public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); } 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(); base.LoadComplete();
Current.BindValueChanged(_ => updateModDisplay(), true); Freestyle.BindValueChanged(_ => updateModDisplay());
FreeMods.BindValueChanged(_ => updateModDisplay(), true);
} }
/// <summary> /// <summary>
@ -114,16 +106,16 @@ namespace osu.Game.Screens.OnlinePlay
{ {
var availableMods = allAvailableAndValidMods.ToArray(); var availableMods = allAvailableAndValidMods.ToArray();
Current.Value = Current.Value.Count == availableMods.Length FreeMods.Value = FreeMods.Value.Count == availableMods.Length
? Array.Empty<Mod>() ? Array.Empty<Mod>()
: availableMods; : availableMods;
} }
private void updateModDisplay() 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.Text = "all";
count.FadeColour(colours.Gray2, 200, Easing.OutQuint); count.FadeColour(colours.Gray2, 200, Easing.OutQuint);

View File

@ -8,23 +8,18 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Screens.Select;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Screens.Select;
namespace osu.Game.Screens.OnlinePlay namespace osu.Game.Screens.OnlinePlay
{ {
public partial class FooterButtonFreestyle : FooterButton, IHasCurrentValue<bool> public partial class FooterButtonFreestyle : FooterButton
{ {
private readonly BindableWithCurrent<bool> current = new BindableWithCurrent<bool>(); public readonly Bindable<bool> Freestyle = new Bindable<bool>();
public Bindable<bool> Current protected override bool IsActive => Freestyle.Value;
{
get => current.Current;
set => current.Current = value;
}
public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); } 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() public FooterButtonFreestyle()
{ {
// Overwrite any external behaviour as we delegate the main toggle action to a sub-button. // 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] [BackgroundDependencyLoader]
@ -81,12 +76,12 @@ namespace osu.Game.Screens.OnlinePlay
{ {
base.LoadComplete(); base.LoadComplete();
Current.BindValueChanged(_ => updateDisplay(), true); Freestyle.BindValueChanged(_ => updateDisplay(), true);
} }
private void updateDisplay() private void updateDisplay()
{ {
if (current.Value) if (Freestyle.Value)
{ {
text.Text = "on"; text.Text = "on";
text.FadeColour(colours.Gray2, 200, Easing.OutQuint); text.FadeColour(colours.Gray2, 200, Easing.OutQuint);

View File

@ -4,7 +4,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
@ -440,11 +439,14 @@ namespace osu.Game.Screens.OnlinePlay.Match
var rulesetInstance = GetGameplayRuleset().CreateInstance(); var rulesetInstance = GetGameplayRuleset().CreateInstance();
Mod[] allowedMods = item.Freestyle
? rulesetInstance.AllMods.OfType<Mod>().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. // 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(); Mod[] newUserMods = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray();
if (!newUserMods.SequenceEqual(UserMods.Value)) 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 // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info
int beatmapId = GetGameplayBeatmap().OnlineID; int beatmapId = GetGameplayBeatmap().OnlineID;
@ -455,14 +457,7 @@ namespace osu.Game.Screens.OnlinePlay.Match
Mods.Value = GetGameplayMods().Select(m => m.ToMod(rulesetInstance)).ToArray(); Mods.Value = GetGameplayMods().Select(m => m.ToMod(rulesetInstance)).ToArray();
Ruleset.Value = GetGameplayRuleset(); Ruleset.Value = GetGameplayRuleset();
bool freeMod = item.AllowedMods.Any(); if (allowedMods.Length > 0)
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)
{ {
UserModsSection.Show(); UserModsSection.Show();
UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType());
@ -474,7 +469,7 @@ namespace osu.Game.Screens.OnlinePlay.Match
UserModsSelectOverlay.IsValidMod = _ => false; UserModsSelectOverlay.IsValidMod = _ => false;
} }
if (freestyle) if (item.Freestyle)
{ {
UserStyleSection.Show(); UserStyleSection.Show();
@ -487,7 +482,7 @@ namespace osu.Game.Screens.OnlinePlay.Match
UserStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(gameplayItem, true) UserStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(gameplayItem, true)
{ {
AllowReordering = false, AllowReordering = false,
AllowEditing = freestyle, AllowEditing = true,
RequestEdit = _ => OpenStyleSelection() RequestEdit = _ => OpenStyleSelection()
}; };
} }

View File

@ -32,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.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);
} }
} }
} }

View File

@ -11,7 +11,6 @@ using osu.Framework.Screens;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select; using osu.Game.Screens.Select;
namespace osu.Game.Screens.OnlinePlay.Multiplayer namespace osu.Game.Screens.OnlinePlay.Multiplayer
@ -122,9 +121,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
} }
protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); 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;
} }
} }

View File

@ -98,7 +98,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{ {
new Drawable?[] new Drawable?[]
{ {
// Participants column
new GridContainer new GridContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
@ -118,15 +117,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
} }
} }
}, },
// Spacer
null, null,
// Beatmap column
new GridContainer new GridContainer
{ {
RelativeSizeAxes = Axes.Both, 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[] Content = new[]
{ {
new Drawable[] { new OverlinedHeader("Beatmap") }, new Drawable[] { new OverlinedHeader("Beatmap queue") },
new Drawable[] new Drawable[]
{ {
addItemButton = new AddItemButton addItemButton = new AddItemButton
@ -147,80 +153,67 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
SelectedItem = SelectedItem SelectedItem = SelectedItem
} }
}, },
new Drawable[] new[]
{ {
new Container UserModsSection = new FillFlowContainer
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Top = 10 }, 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.Both,
AutoSizeAxes = Axes.Y, Direction = FillDirection.Horizontal,
Alpha = 0, Spacing = new Vector2(10, 0),
Children = new Drawable[] Children = new Drawable[]
{ {
new OverlinedHeader("Extra mods"), new UserModSelectButton
new FillFlowContainer
{ {
AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft,
Direction = FillDirection.Horizontal, Origin = Anchor.CentreLeft,
Spacing = new Vector2(10, 0), Width = 90,
Children = new Drawable[] Height = 30,
{ Text = "Select",
new UserModSelectButton Action = ShowUserModSelect,
{
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),
},
}
}, },
} new ModDisplay
},
UserStyleSection = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
Children = new Drawable[]
{
new OverlinedHeader("Difficulty"),
UserStyleDisplayContainer = new Container<DrawableRoomPlaylistItem>
{ {
RelativeSizeAxes = Axes.X, Anchor = Anchor.CentreLeft,
AutoSizeAxes = Axes.Y 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<DrawableRoomPlaylistItem>
{
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, null,
// Main right column
new GridContainer new GridContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,

View File

@ -140,6 +140,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Size = new Vector2(28, 20), Size = new Vector2(28, 20),
CountryCode = user?.CountryCode ?? default CountryCode = user?.CountryCode ?? default
}, },
new UpdateableTeamFlag(user?.Team)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(40, 20),
},
new OsuSpriteText new OsuSpriteText
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
@ -161,11 +167,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Origin = Anchor.CentreRight, Origin = Anchor.CentreRight,
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Right = 70 }, Margin = new MarginPadding { Right = 70 },
Spacing = new Vector2(2),
Children = new Drawable[] Children = new Drawable[]
{ {
userStyleDisplay = new StyleDisplayIcon(), userStyleDisplay = new StyleDisplayIcon
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
userModsDisplay = new ModDisplay userModsDisplay = new ModDisplay
{ {
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Scale = new Vector2(0.5f), Scale = new Vector2(0.5f),
ExpansionMode = ExpansionMode.AlwaysContracted, ExpansionMode = ExpansionMode.AlwaysContracted,
} }
@ -209,15 +222,28 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
const double fade_time = 50; const double fade_time = 50;
MultiplayerPlaylistItem? currentItem = client.Room.GetCurrentItem(); if (client.Room.GetCurrentItem() is MultiplayerPlaylistItem currentItem)
Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; {
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; int? currentModeRank = userRuleset == null ? null : User.User?.RulesetsStatistics?.GetValueOrDefault(userRuleset.ShortName)?.GlobalRank;
userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; 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<Mod>() : User.Mods.Select(m => m.ToMod(userRuleset)).ToList());
}
userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); 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); userModsDisplay.FadeIn(fade_time);
userStyleDisplay.FadeIn(fade_time); userStyleDisplay.FadeIn(fade_time);
@ -228,20 +254,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
userStyleDisplay.FadeOut(fade_time); 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; kickButton.Alpha = client.IsHost && !User.Equals(client.LocalUser) ? 1 : 0;
crown.Alpha = client.Room.Host?.Equals(User) == true ? 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<Mod>();
});
} }
public MenuItem[]? ContextMenuItems public MenuItem[]? ContextMenuItems

View File

@ -42,7 +42,7 @@ namespace osu.Game.Screens.OnlinePlay
protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected override UserActivity InitialActivity => new UserActivity.InLobby(room);
protected readonly Bindable<IReadOnlyList<Mod>> FreeMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); protected readonly Bindable<IReadOnlyList<Mod>> FreeMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
protected readonly Bindable<bool> Freestyle = new Bindable<bool>(); protected readonly Bindable<bool> Freestyle = new Bindable<bool>(true);
private readonly Room room; private readonly Room room;
private readonly PlaylistItem? initialItem; private readonly PlaylistItem? initialItem;
@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay
freeModSelect = new FreeModSelectOverlay freeModSelect = new FreeModSelectOverlay
{ {
SelectedMods = { BindTarget = FreeMods }, SelectedMods = { BindTarget = FreeMods },
IsValidMod = IsValidFreeMod, IsValidMod = isValidFreeMod,
}; };
} }
@ -126,6 +126,7 @@ namespace osu.Game.Screens.OnlinePlay
{ {
if (enabled.NewValue) if (enabled.NewValue)
{ {
freeModsFooterButton.Enabled.Value = false;
freeModsFooterButton.Enabled.Value = false; freeModsFooterButton.Enabled.Value = false;
ModsFooterButton.Enabled.Value = false; ModsFooterButton.Enabled.Value = false;
@ -144,10 +145,10 @@ namespace osu.Game.Screens.OnlinePlay
private void onModsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods) private void onModsChanged(ValueChangedEvent<IReadOnlyList<Mod>> 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. // Reset the validity delegate to update the overlay's display.
freeModSelect.IsValidMod = IsValidFreeMod; freeModSelect.IsValidMod = isValidFreeMod;
} }
private void onRulesetChanged(ValueChangedEvent<RulesetInfo> ruleset) private void onRulesetChanged(ValueChangedEvent<RulesetInfo> ruleset)
@ -194,7 +195,7 @@ namespace osu.Game.Screens.OnlinePlay
protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum)
{ {
IsValidMod = IsValidMod IsValidMod = isValidMod
}; };
protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() 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?)[] baseButtons.InsertRange(baseButtons.FindIndex(b => b.button is FooterButtonMods) + 1, new (FooterButton, OverlayContainer?)[]
{ {
(freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) { Current = FreeMods }, null), (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect)
(new FooterButtonFreestyle { Current = Freestyle }, null) {
FreeMods = { BindTarget = FreeMods },
Freestyle = { BindTarget = Freestyle }
}, null),
(new FooterButtonFreestyle
{
Freestyle = { BindTarget = Freestyle }
}, null)
}); });
return baseButtons; return baseButtons;
@ -217,18 +225,18 @@ namespace osu.Game.Screens.OnlinePlay
/// </summary> /// </summary>
/// <param name="mod">The <see cref="Mod"/> to check.</param> /// <param name="mod">The <see cref="Mod"/> to check.</param>
/// <returns>Whether <paramref name="mod"/> is a valid mod for online play.</returns> /// <returns>Whether <paramref name="mod"/> is a valid mod for online play.</returns>
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);
/// <summary> /// <summary>
/// Checks whether a given <see cref="Mod"/> is valid for per-player free-mod selection. /// Checks whether a given <see cref="Mod"/> is valid for per-player free-mod selection.
/// </summary> /// </summary>
/// <param name="mod">The <see cref="Mod"/> to check.</param> /// <param name="mod">The <see cref="Mod"/> to check.</param>
/// <returns>Whether <paramref name="mod"/> is a selectable free-mod.</returns> /// <returns>Whether <paramref name="mod"/> is a selectable free-mod.</returns>
protected virtual bool IsValidFreeMod(Mod mod) => IsValidMod(mod) && checkCompatibleFreeMod(mod); private bool isValidFreeMod(Mod mod) => ModUtils.IsValidFreeModForMatchType(mod, room.Type)
// Mod must not be contained in the required mods.
private bool checkCompatibleFreeMod(Mod mod) && Mods.Value.All(m => m.Acronym != mod.Acronym)
=> Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must not be contained in the required mods. // Mod must be compatible with all the required mods.
&& ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); // Mod must be compatible with all the required mods. && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray());
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {

View File

@ -0,0 +1,178 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.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<BeatmapCollection>? collection;
private HashSet<string> localBeatmapHashes = new HashSet<string>();
[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<BeatmapCollection> getCollectionsForPlaylist(Realm r) => r.All<BeatmapCollection>().Where(c => c.Name == room.Name);
private IQueryable<BeatmapInfo> getBeatmapsForPlaylist(Realm r)
{
return r.All<BeatmapInfo>().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.";
}
}
}
}

View File

@ -146,7 +146,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
{ {
new Drawable?[] new Drawable?[]
{ {
// Playlist items column
new GridContainer new GridContainer
{ {
RelativeSizeAxes = Axes.Both, 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[] RowDimensions = new[]
{ {
new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize),
new Dimension(), new Dimension(),
new Dimension(GridSizeMode.AutoSize),
} }
}, },
// Spacer
null, null,
// Middle column (mods and leaderboard)
new GridContainer new GridContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Content = new[] Content = new[]
{ {
new Drawable[] new[]
{ {
new Container UserModsSection = new FillFlowContainer
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Bottom = 10 }, 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.Both,
AutoSizeAxes = Axes.Y, Direction = FillDirection.Horizontal,
Alpha = 0, Spacing = new Vector2(10, 0),
Margin = new MarginPadding { Bottom = 10 },
Children = new Drawable[] Children = new Drawable[]
{ {
new OverlinedHeader("Extra mods"), new UserModSelectButton
new FillFlowContainer
{ {
AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft,
Direction = FillDirection.Horizontal, Origin = Anchor.CentreLeft,
Spacing = new Vector2(10, 0), Width = 90,
Children = new Drawable[] Height = 30,
{ Text = "Select",
new UserModSelectButton Action = ShowUserModSelect,
{ },
Anchor = Anchor.CentreLeft, new ModDisplay
Origin = Anchor.CentreLeft, {
Width = 90, Anchor = Anchor.CentreLeft,
Text = "Select", Origin = Anchor.CentreLeft,
Action = ShowUserModSelect, Current = UserMods,
}, Scale = new Vector2(0.8f),
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<DrawableRoomPlaylistItem>
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y
Alpha = 0, }
Children = new Drawable[]
{
new OverlinedHeader("Difficulty"),
UserStyleDisplayContainer = new Container<DrawableRoomPlaylistItem>
{
RelativeSizeAxes = Axes.X,
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(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(), new Dimension(),
} }
}, },
// Spacer
null, null,
// Main right column
new GridContainer new GridContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,

View File

@ -0,0 +1,56 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.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>? 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; }
}
}

View File

@ -2,7 +2,9 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
@ -17,6 +19,7 @@ using osu.Game.Graphics.Sprites;
using osu.Game.Online.Chat; using osu.Game.Online.Chat;
using osu.Game.Localisation.HUD; using osu.Game.Localisation.HUD;
using osu.Game.Localisation.SkinComponents; using osu.Game.Localisation.SkinComponents;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
@ -28,17 +31,17 @@ namespace osu.Game.Screens.Play.HUD
{ {
private const int max_spectators_displayed = 10; private const int max_spectators_displayed = 10;
public BindableList<SpectatorUser> Spectators { get; } = new BindableList<SpectatorUser>();
public Bindable<LocalUserPlayingState> UserPlayingState { get; } = new Bindable<LocalUserPlayingState>();
[SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))]
public Bindable<Typeface> Font { get; } = new Bindable<Typeface>(Typeface.Torus); public Bindable<Typeface> Font { get; } = new Bindable<Typeface>(Typeface.Torus);
[SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))] [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))]
public BindableColour4 HeaderColour { get; } = new BindableColour4(Colour4.White); public BindableColour4 HeaderColour { get; } = new BindableColour4(Colour4.White);
protected OsuSpriteText Header { get; private set; } = null!; private BindableList<SpectatorUser> watchingUsers { get; } = new BindableList<SpectatorUser>();
private Bindable<LocalUserPlayingState> userPlayingState { get; } = new Bindable<LocalUserPlayingState>();
private int displayedSpectatorCount;
private OsuSpriteText header = null!;
private FillFlowContainer mainFlow = null!; private FillFlowContainer mainFlow = null!;
private FillFlowContainer<SpectatorListEntry> spectatorsFlow = null!; private FillFlowContainer<SpectatorListEntry> spectatorsFlow = null!;
private DrawablePool<SpectatorListEntry> pool = null!; private DrawablePool<SpectatorListEntry> pool = null!;
@ -49,6 +52,9 @@ namespace osu.Game.Screens.Play.HUD
[Resolved] [Resolved]
private GameplayState gameplayState { get; set; } = null!; private GameplayState gameplayState { get; set; } = null!;
[Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuColour colours) private void load(OsuColour colours)
{ {
@ -63,7 +69,7 @@ namespace osu.Game.Screens.Play.HUD
Direction = FillDirection.Vertical, Direction = FillDirection.Vertical,
Children = new Drawable[] Children = new Drawable[]
{ {
Header = new OsuSpriteText header = new OsuSpriteText
{ {
Colour = colours.Blue0, Colour = colours.Blue0,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
@ -78,18 +84,18 @@ namespace osu.Game.Screens.Play.HUD
pool = new DrawablePool<SpectatorListEntry>(max_spectators_displayed), pool = new DrawablePool<SpectatorListEntry>(max_spectators_displayed),
}; };
HeaderColour.Value = Header.Colour; HeaderColour.Value = header.Colour;
} }
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
((IBindableList<SpectatorUser>)Spectators).BindTo(client.WatchingUsers); ((IBindableList<SpectatorUser>)watchingUsers).BindTo(client.WatchingUsers);
((IBindable<LocalUserPlayingState>)UserPlayingState).BindTo(gameplayState.PlayingState); ((IBindable<LocalUserPlayingState>)userPlayingState).BindTo(gameplayState.PlayingState);
Spectators.BindCollectionChanged(onSpectatorsChanged, true); watchingUsers.BindCollectionChanged(onSpectatorsChanged, true);
UserPlayingState.BindValueChanged(_ => updateVisibility()); userPlayingState.BindValueChanged(_ => updateVisibility());
Font.BindValueChanged(_ => updateAppearance()); Font.BindValueChanged(_ => updateAppearance());
HeaderColour.BindValueChanged(_ => updateAppearance(), true); HeaderColour.BindValueChanged(_ => updateAppearance(), true);
@ -100,6 +106,20 @@ namespace osu.Game.Screens.Play.HUD
private void onSpectatorsChanged(object? sender, NotifyCollectionChangedEventArgs e) 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<int>();
if (multiplayerClient.Room != null)
excludedUserIds.UnionWith(multiplayerClient.Room.Users.Where(u => u.State != MultiplayerUserState.Spectating).Select(u => u.UserID));
switch (e.Action) switch (e.Action)
{ {
case NotifyCollectionChangedAction.Add: case NotifyCollectionChangedAction.Add:
@ -109,6 +129,9 @@ namespace osu.Game.Screens.Play.HUD
var spectator = (SpectatorUser)e.NewItems![i]!; var spectator = (SpectatorUser)e.NewItems![i]!;
int index = Math.Max(e.NewStartingIndex, 0) + i; int index = Math.Max(e.NewStartingIndex, 0) + i;
if (excludedUserIds.Contains(spectator.OnlineID))
continue;
if (index >= max_spectators_displayed) if (index >= max_spectators_displayed)
break; break;
@ -125,10 +148,10 @@ namespace osu.Game.Screens.Play.HUD
for (int i = 0; i < spectatorsFlow.Count; i++) for (int i = 0; i < spectatorsFlow.Count; i++)
spectatorsFlow.SetLayoutPosition(spectatorsFlow[i], 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++) for (int i = spectatorsFlow.Count; i < max_spectators_displayed; i++)
addNewSpectatorToList(i, Spectators[i]); addNewSpectatorToList(i, watchingUsers[i]);
} }
break; break;
@ -144,7 +167,8 @@ namespace osu.Game.Screens.Play.HUD
throw new NotSupportedException(); 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(); updateVisibility();
for (int i = 0; i < spectatorsFlow.Count; i++) for (int i = 0; i < spectatorsFlow.Count; i++)
@ -160,7 +184,7 @@ namespace osu.Game.Screens.Play.HUD
var entry = pool.Get(entry => var entry = pool.Get(entry =>
{ {
entry.Current.Value = spectator; entry.Current.Value = spectator;
entry.UserPlayingState = UserPlayingState; entry.UserPlayingState = userPlayingState;
}); });
spectatorsFlow.Insert(i, entry); spectatorsFlow.Insert(i, entry);
@ -169,15 +193,15 @@ namespace osu.Game.Screens.Play.HUD
private void updateVisibility() private void updateVisibility()
{ {
// We don't want to show spectators when we are watching a replay. // 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() private void updateAppearance()
{ {
Header.Font = OsuFont.GetFont(Font.Value, 12, FontWeight.Bold); header.Font = OsuFont.GetFont(Font.Value, 12, FontWeight.Bold);
Header.Colour = HeaderColour.Value; header.Colour = HeaderColour.Value;
Width = Header.DrawWidth; Width = header.DrawWidth;
} }
private partial class SpectatorListEntry : PoolableDrawable private partial class SpectatorListEntry : PoolableDrawable

View File

@ -4,7 +4,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Extensions; using osu.Game.Extensions;
@ -35,7 +34,32 @@ namespace osu.Game.Screens.Ranking
return null; return null;
getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); 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<ScoreInfo>();
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; return getScoreRequest;
} }

View File

@ -25,6 +25,11 @@ namespace osu.Game.Screens.Select
protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0); protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0);
/// <summary>
/// Used to show an initial animation hinting at the enabled state.
/// </summary>
protected virtual bool IsActive => false;
public LocalisableString Text public LocalisableString Text
{ {
get => SpriteText?.Text ?? default; get => SpriteText?.Text ?? default;
@ -124,6 +129,18 @@ namespace osu.Game.Screens.Select
{ {
base.LoadComplete(); base.LoadComplete();
Enabled.BindValueChanged(_ => updateDisplay(), true); Enabled.BindValueChanged(_ => updateDisplay(), true);
if (IsActive)
{
box.ClearTransforms();
using (box.BeginDelayedSequence(200))
{
box.FadeIn(200)
.Then()
.FadeOut(1500, Easing.OutQuint);
}
}
} }
public Action Hovered; public Action Hovered;

View File

@ -339,6 +339,12 @@ namespace osu.Game.Screens.SelectV2.Leaderboards
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Size = new Vector2(24, 16), Size = new Vector2(24, 16),
}, },
new UpdateableTeamFlag(user.Team)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(40, 20),
},
new DateLabel(score.Date) new DateLabel(score.Date)
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using Newtonsoft.Json; using Newtonsoft.Json;
using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Online.API; using osu.Game.Online.API;
@ -221,7 +222,15 @@ namespace osu.Game.Tests.Visual.OnlinePlay
: new APIUser : new APIUser
{ {
Id = id, 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(), .Where(u => u != null).ToList(),
}); });

View File

@ -0,0 +1,87 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.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
{
/// <summary>
/// A team logo which can update to a new team when needed.
/// </summary>
public partial class UpdateableTeamFlag : ModelBackedDrawable<APITeam?>
{
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)
}
};
}
}
}
}

View File

@ -82,9 +82,10 @@ namespace osu.Game.Users
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Spacing = new Vector2(6), Spacing = new Vector2(6),
Children = new Drawable[] Children = new[]
{ {
CreateFlag(), CreateFlag(),
CreateTeamLogo(),
// supporter icon is being added later // supporter icon is being added later
} }
} }

View File

@ -130,6 +130,11 @@ namespace osu.Game.Users
Action = Action, Action = Action,
}; };
protected Drawable CreateTeamLogo() => new UpdateableTeamFlag(User.Team)
{
Size = new Vector2(52, 26),
};
public MenuItem[] ContextMenuItems public MenuItem[] ContextMenuItems
{ {
get get

View File

@ -147,9 +147,10 @@ namespace osu.Game.Users
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Spacing = new Vector2(6), Spacing = new Vector2(6),
Children = new Drawable[] Children = new[]
{ {
CreateFlag(), CreateFlag(),
CreateTeamLogo(),
// supporter icon is being added later // supporter icon is being added later
} }
} }

View File

@ -8,6 +8,7 @@ using System.Linq;
using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@ -292,5 +293,45 @@ namespace osu.Game.Utils
return rate; return rate;
} }
/// <summary>
/// Determines whether a mod can be applied to playlist items in the given match type.
/// </summary>
/// <param name="mod">The mod to test.</param>
/// <param name="type">The match type.</param>
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;
}
}
/// <summary>
/// Determines whether a mod can be applied as a free mod to playlist items in the given match type.
/// </summary>
/// <param name="mod">The mod to test.</param>
/// <param name="type">The match type.</param>
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;
}
}
} }
} }

View File

@ -11,6 +11,7 @@ using osu.Game;
using osu.Game.Screens; using osu.Game.Screens;
using osu.Game.Updater; using osu.Game.Updater;
using osu.Game.Utils; using osu.Game.Utils;
using osuTK;
using UIKit; using UIKit;
namespace osu.iOS namespace osu.iOS
@ -22,6 +23,8 @@ namespace osu.iOS
public override bool HideUnlicensedContent => true; public override bool HideUnlicensedContent => true;
protected override Vector2 ScalingContainerTargetDrawSize => new Vector2(1024, 1024 * DrawHeight / DrawWidth);
public OsuGameIOS(AppDelegate appDelegate) public OsuGameIOS(AppDelegate appDelegate)
{ {
this.appDelegate = appDelegate; this.appDelegate = appDelegate;
@ -41,7 +44,7 @@ namespace osu.iOS
updateOrientation(); updateOrientation();
} }
private void updateOrientation() private void updateOrientation() => UIApplication.SharedApplication.InvokeOnMainThread(() =>
{ {
bool iPad = UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad; bool iPad = UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad;
var orientation = MobileUtils.GetOrientation(this, (IOsuScreen)ScreenStack.CurrentScreen, iPad); var orientation = MobileUtils.GetOrientation(this, (IOsuScreen)ScreenStack.CurrentScreen, iPad);
@ -60,7 +63,7 @@ namespace osu.iOS
appDelegate.Orientations = null; appDelegate.Orientations = null;
break; break;
} }
} });
protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier(); protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier();