1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-28 01:05:52 +08:00

Compare commits

..

135 Commits

87 changed files with 2168 additions and 813 deletions
+2 -2
View File
@@ -27,10 +27,10 @@
]
},
"ppy.localisationanalyser.tools": {
"version": "2021.725.0",
"version": "2021.1210.0",
"commands": [
"localisation"
]
}
}
}
}
+1 -1
View File
@@ -52,7 +52,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1206.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1210.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
@@ -32,5 +32,7 @@
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/AppIcon.appiconset</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
@@ -32,5 +32,7 @@
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/AppIcon.appiconset</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
@@ -32,5 +32,7 @@
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/AppIcon.appiconset</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
@@ -11,49 +11,65 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
{
public class OsuDifficultyHitObject : DifficultyHitObject
{
private const int normalized_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
private const int normalised_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
private const int min_delta_time = 25;
private const float maximum_slider_radius = normalized_radius * 2.4f;
private const float assumed_slider_radius = normalized_radius * 1.8f;
private const float maximum_slider_radius = normalised_radius * 2.4f;
private const float assumed_slider_radius = normalised_radius * 1.8f;
protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject;
/// <summary>
/// Normalized distance from the end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// Milliseconds elapsed since the start time of the previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms.
/// </summary>
public double JumpDistance { get; private set; }
public readonly double StrainTime;
/// <summary>
/// Minimum distance from the end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// <para>
/// The "lazy" end position is the position at which the cursor ends up if the previous hitobject is followed with as minimal movement as possible (i.e. on the edge of slider follow circles).
/// </para>
/// </summary>
public double MovementDistance { get; private set; }
public double LazyJumpDistance { get; private set; }
/// <summary>
/// Normalized distance between the start and end position of the previous <see cref="OsuDifficultyHitObject"/>.
/// Normalised shortest distance to consider for a jump between the previous <see cref="OsuDifficultyHitObject"/> and this <see cref="OsuDifficultyHitObject"/>.
/// </summary>
/// <remarks>
/// This is bounded from above by <see cref="LazyJumpDistance"/>, and is smaller than the former if a more natural path is able to be taken through the previous <see cref="OsuDifficultyHitObject"/>.
/// </remarks>
/// <example>
/// Suppose a linear slider - circle pattern.
/// <br />
/// Following the slider lazily (see: <see cref="LazyJumpDistance"/>) will result in underestimating the true end position of the slider as being closer towards the start position.
/// As a result, <see cref="LazyJumpDistance"/> overestimates the jump distance because the player is able to take a more natural path by following through the slider to its end,
/// such that the jump is felt as only starting from the slider's true end position.
/// <br />
/// Now consider a slider - circle pattern where the circle is stacked along the path inside the slider.
/// In this case, the lazy end position correctly estimates the true end position of the slider and provides the more natural movement path.
/// </example>
public double MinimumJumpDistance { get; private set; }
/// <summary>
/// The time taken to travel through <see cref="MinimumJumpDistance"/>, with a minimum value of 25ms.
/// </summary>
public double MinimumJumpTime { get; private set; }
/// <summary>
/// Normalised distance between the start and end position of this <see cref="OsuDifficultyHitObject"/>.
/// </summary>
public double TravelDistance { get; private set; }
/// <summary>
/// The time taken to travel through <see cref="TravelDistance"/>, with a minimum value of 25ms for a non-zero distance.
/// </summary>
public double TravelTime { get; private set; }
/// <summary>
/// Angle the player has to take to hit this <see cref="OsuDifficultyHitObject"/>.
/// Calculated as the angle between the circles (current-2, current-1, current).
/// </summary>
public double? Angle { get; private set; }
/// <summary>
/// Milliseconds elapsed since the end time of the previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms.
/// </summary>
public double MovementTime { get; private set; }
/// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="OsuDifficultyHitObject"/> to the end time of the same previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms.
/// </summary>
public double TravelTime { get; private set; }
/// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms.
/// </summary>
public readonly double StrainTime;
private readonly OsuHitObject lastLastObject;
private readonly OsuHitObject lastObject;
@@ -71,12 +87,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
private void setDistances(double clockRate)
{
if (BaseObject is Slider currentSlider)
{
computeSliderCursorPosition(currentSlider);
TravelDistance = currentSlider.LazyTravelDistance;
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time);
}
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
if (BaseObject is Spinner || lastObject is Spinner)
return;
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
float scalingFactor = normalized_radius / (float)BaseObject.Radius;
float scalingFactor = normalised_radius / (float)BaseObject.Radius;
if (BaseObject.Radius < 30)
{
@@ -85,29 +108,40 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
}
Vector2 lastCursorPosition = getEndCursorPosition(lastObject);
JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length;
LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length;
MinimumJumpTime = StrainTime;
MinimumJumpDistance = LazyJumpDistance;
if (lastObject is Slider lastSlider)
{
computeSliderCursorPosition(lastSlider);
TravelDistance = lastSlider.LazyTravelDistance;
TravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time);
MovementTime = Math.Max(StrainTime - TravelTime, min_delta_time);
double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time);
MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time);
//
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.
//
// 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
//
// <======o==> ← slider
// | ← most natural jump path
// o ← a follow-up hitcircle
//
// In this case the most natural jump path is approximated by LazyJumpDistance.
//
// 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
//
// <======o==>---o
// ↑
// most natural jump path
//
// In this case the most natural jump path is better approximated by a new distance called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
//
// Thus, the player is assumed to jump the minimum of these two distances in all cases.
//
// Jump distance from the slider tail to the next object, as opposed to the lazy position of JumpDistance.
float tailJumpDistance = Vector2.Subtract(lastSlider.TailCircle.StackedPosition, BaseObject.StackedPosition).Length * scalingFactor;
// For hitobjects which continue in the direction of the slider, the player will normally follow through the slider,
// such that they're not jumping from the lazy position but rather from very close to (or the end of) the slider.
// In such cases, a leniency is applied by also considering the jump distance from the tail of the slider, and taking the minimum jump distance.
// Additional distance is removed based on position of jump relative to slider follow circle radius.
// JumpDistance is the leniency distance beyond the assumed_slider_radius. tailJumpDistance is maximum_slider_radius since the full distance of radial leniency is still possible.
MovementDistance = Math.Max(0, Math.Min(JumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius));
}
else
{
MovementTime = StrainTime;
MovementDistance = JumpDistance;
MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius));
}
if (lastLastObject != null && !(lastLastObject is Spinner))
@@ -139,7 +173,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived.
var currCursorPosition = slider.StackedPosition;
double scalingFactor = normalized_radius / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used.
double scalingFactor = normalised_radius / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used.
for (int i = 1; i < slider.NestedHitObjects.Count; i++)
{
@@ -167,7 +201,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
else if (currMovementObj is SliderRepeat)
{
// For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
requiredMovement = normalized_radius;
requiredMovement = normalised_radius;
}
if (currMovementLength > requiredMovement)
+12 -12
View File
@@ -44,24 +44,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
var osuLastLastObj = (OsuDifficultyHitObject)Previous[1];
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.JumpDistance / osuCurrObj.StrainTime;
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliders)
{
double movementVelocity = osuCurrObj.MovementDistance / osuCurrObj.MovementTime; // calculate the movement velocity from slider end to current object
double travelVelocity = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; // calculate the slider velocity from slider head to slider end.
double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end.
double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object
currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity.
}
// As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.JumpDistance / osuLastObj.StrainTime;
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime;
if (osuLastLastObj.BaseObject is Slider && withSliders)
{
double movementVelocity = osuLastObj.MovementDistance / osuLastObj.MovementTime;
double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime;
double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime;
double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime;
prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity);
}
@@ -94,7 +94,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
* Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
* Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4
* Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.JumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter).
* Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter).
}
// Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
@@ -107,8 +107,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
if (Math.Max(prevVelocity, currVelocity) != 0)
{
// We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
prevVelocity = (osuLastObj.JumpDistance + osuLastObj.TravelDistance) / osuLastObj.StrainTime;
currVelocity = (osuCurrObj.JumpDistance + osuCurrObj.TravelDistance) / osuCurrObj.StrainTime;
prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.StrainTime;
currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime;
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2);
@@ -119,7 +119,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
// Reward for % distance slowed down compared to previous, paying attention to not award overlap
double nonOverlapVelocityBuff = Math.Abs(prevVelocity - currVelocity)
// do not award overlap
* Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, Math.Min(osuCurrObj.JumpDistance, osuLastObj.JumpDistance) / 100)), 2);
* Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, Math.Min(osuCurrObj.LazyJumpDistance, osuLastObj.LazyJumpDistance) / 100)), 2);
// Choose the largest bonus, multiplied by ratio.
velocityChangeBonus = Math.Max(overlapVelocityBuff, nonOverlapVelocityBuff) * distRatio;
@@ -128,10 +128,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2);
}
if (osuCurrObj.TravelTime != 0)
if (osuLastObj.TravelTime != 0)
{
// Reward sliders based on velocity.
sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime;
sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime;
}
// Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
@@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
smallDistNerf = Math.Min(1.0, jumpDistance / 75.0);
// We also want to nerf stacks so that only the first object of the stack is accounted for.
double stackNerf = Math.Min(1.0, (osuPrevious.JumpDistance / scalingFactor) / 25.0);
double stackNerf = Math.Min(1.0, (osuPrevious.LazyJumpDistance / scalingFactor) / 25.0);
result += Math.Pow(0.8, i) * stackNerf * scalingFactor * jumpDistance / cumulativeStrainTime;
}
@@ -154,7 +154,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
if (strainTime < min_speed_bonus)
speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2);
double distance = Math.Min(single_spacing_threshold, osuCurrObj.TravelDistance + osuCurrObj.JumpDistance);
double travelDistance = osuPrevObj?.TravelDistance ?? 0;
double distance = Math.Min(single_spacing_threshold, travelDistance + osuCurrObj.MinimumJumpDistance);
return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / strainTime;
}
@@ -32,5 +32,7 @@
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/AppIcon.appiconset</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
+2
View File
@@ -32,5 +32,7 @@
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/AppIcon.appiconset</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
@@ -6,12 +6,14 @@ using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
@@ -227,7 +229,7 @@ namespace osu.Game.Tests.Visual.Beatmaps
new BasicScrollContainer
{
RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer
Child = new ReverseChildIDFillFlowContainer<Drawable>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
@@ -248,6 +250,17 @@ namespace osu.Game.Tests.Visual.Beatmaps
}
[Test]
public void TestNormal() => createTestCase(beatmapSetInfo => new BeatmapCard(beatmapSetInfo));
public void TestNormal()
{
createTestCase(beatmapSetInfo => new BeatmapCard(beatmapSetInfo));
AddToggleStep("toggle expanded state", expanded =>
{
var card = this.ChildrenOfType<BeatmapCard>().Last();
if (!card.Expanded.Disabled)
card.Expanded.Value = expanded;
});
AddToggleStep("disable/enable expansion", disabled => this.ChildrenOfType<BeatmapCard>().ForEach(card => card.Expanded.Disabled = disabled));
}
}
}
@@ -10,6 +10,7 @@ using osu.Game.Beatmaps;
using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
using osu.Game.Tests.Beatmaps.IO;
@@ -89,6 +90,7 @@ namespace osu.Game.Tests.Visual.Editing
confirmEditingBeatmap(() => targetDifficulty);
AddAssert("no objects selected", () => !EditorBeatmap.SelectedHitObjects.Any());
AddUntilStep("wait for drawable ruleset", () => Editor.ChildrenOfType<DrawableRuleset>().SingleOrDefault()?.IsLoaded == true);
AddStep("paste object", () => Editor.Paste());
if (sameRuleset)
@@ -49,7 +49,7 @@ namespace osu.Game.Tests.Visual.Editing
public void TestCreateNewBeatmap()
{
AddStep("save beatmap", () => Editor.Save());
AddAssert("new beatmap persisted", () => EditorBeatmap.BeatmapInfo.ID > 0);
AddAssert("new beatmap persisted", () => EditorBeatmap.BeatmapInfo.IsManaged);
AddAssert("new beatmap in database", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == false);
}
@@ -251,7 +251,12 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestMutedNotificationMuteButton()
{
addVolumeSteps("mute button", () => volumeOverlay.IsMuted.Value = true, () => !volumeOverlay.IsMuted.Value);
addVolumeSteps("mute button", () =>
{
// Importantly, in the case the volume is muted but the user has a volume level set, it should be retained.
audioManager.VolumeTrack.Value = 0.5f;
volumeOverlay.IsMuted.Value = true;
}, () => !volumeOverlay.IsMuted.Value && audioManager.VolumeTrack.Value == 0.5f);
}
/// <remarks>
@@ -87,9 +87,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
private void addItem(Func<BeatmapInfo> beatmap)
{
AddStep("click edit button", () =>
AddStep("click add button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSubScreen>().Single().AddOrEditPlaylistButton);
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSubScreen.AddItemButton>().Single());
InputManager.Click(MouseButton.Left);
});
@@ -11,12 +11,14 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Lounge;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Screens.OnlinePlay.Match;
using osu.Game.Tests.Beatmaps;
using osuTK;
@@ -172,6 +174,39 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType<DrawableRoom.PasswordProtectedIcon>().Single().Alpha));
}
[Test]
public void TestMultiplayerRooms()
{
AddStep("create rooms", () => Child = new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5),
Children = new[]
{
new DrawableMatchRoom(new Room
{
Name = { Value = "A host-only room" },
QueueMode = { Value = QueueMode.HostOnly },
Type = { Value = MatchType.HeadToHead }
}),
new DrawableMatchRoom(new Room
{
Name = { Value = "An all-players, team-versus room" },
QueueMode = { Value = QueueMode.AllPlayers },
Type = { Value = MatchType.TeamVersus }
}),
new DrawableMatchRoom(new Room
{
Name = { Value = "A round-robin room" },
QueueMode = { Value = QueueMode.AllPlayersRoundRobin },
Type = { Value = MatchType.HeadToHead }
}),
}
});
}
private DrawableRoom createLoungeRoom(Room room)
{
room.Host.Value ??= new APIUser { Username = "peppy", Id = 2 };
@@ -1,6 +1,7 @@
// 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.Linq;
@@ -48,7 +49,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestNonEditableNonSelectable()
{
createPlaylist(false, false);
createPlaylist();
moveToItem(0);
assertHandleVisibility(0, false);
@@ -61,7 +62,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestEditable()
{
createPlaylist(true, false);
createPlaylist(p =>
{
p.AllowReordering = true;
p.AllowDeletion = true;
});
moveToItem(0);
assertHandleVisibility(0, true);
@@ -74,7 +79,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestMarkInvalid()
{
createPlaylist(true, true);
createPlaylist(p =>
{
p.AllowReordering = true;
p.AllowDeletion = true;
p.AllowSelection = true;
});
AddStep("mark item 0 as invalid", () => playlist.Items[0].MarkInvalid());
@@ -87,7 +97,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestSelectable()
{
createPlaylist(false, true);
createPlaylist(p => p.AllowSelection = true);
moveToItem(0);
assertHandleVisibility(0, false);
@@ -101,7 +111,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestEditableSelectable()
{
createPlaylist(true, true);
createPlaylist(p =>
{
p.AllowReordering = true;
p.AllowDeletion = true;
p.AllowSelection = true;
});
moveToItem(0);
assertHandleVisibility(0, true);
@@ -115,7 +130,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestSelectionNotLostAfterRearrangement()
{
createPlaylist(true, true);
createPlaylist(p =>
{
p.AllowReordering = true;
p.AllowDeletion = true;
p.AllowSelection = true;
});
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
@@ -128,95 +148,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("item 1 is selected", () => playlist.SelectedItem.Value == playlist.Items[1]);
}
[Test]
public void TestItemRemovedOnDeletion()
{
PlaylistItem selectedItem = null;
createPlaylist(true, true);
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddStep("retrieve selection", () => selectedItem = playlist.SelectedItem.Value);
moveToDeleteButton(0);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
AddAssert("item removed", () => !playlist.Items.Contains(selectedItem));
}
[Test]
public void TestNextItemSelectedAfterDeletion()
{
createPlaylist(true, true);
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveToDeleteButton(0);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]);
}
[Test]
public void TestLastItemSelectedAfterLastItemDeleted()
{
createPlaylist(true, true);
AddWaitStep("wait for flow", 5); // Items may take 1 update frame to flow. A wait count of 5 is guaranteed to result in the flow being updated as desired.
AddStep("scroll to bottom", () => playlist.ChildrenOfType<ScrollContainer<Drawable>>().First().ScrollToEnd(false));
moveToItem(19);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveToDeleteButton(19);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
AddAssert("item 18 is selected", () => playlist.SelectedItem.Value == playlist.Items[18]);
}
[Test]
public void TestSelectionResetWhenAllItemsDeleted()
{
createPlaylist(true, true);
AddStep("remove all but one item", () =>
{
playlist.Items.RemoveRange(1, playlist.Items.Count - 1);
});
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveToDeleteButton(0);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
AddAssert("no item selected", () => playlist.SelectedItem.Value == null);
}
// Todo: currently not possible due to bindable list shortcomings (https://github.com/ppy/osu-framework/issues/3081)
// [Test]
public void TestNextItemSelectedAfterExternalDeletion()
{
createPlaylist(true, true);
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddStep("remove item 0", () => playlist.Items.RemoveAt(0));
AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]);
}
[Test]
public void TestChangeBeatmapAndRemove()
{
createPlaylist(true, true);
AddStep("change beatmap of first item", () => playlist.Items[0].BeatmapID = 30);
moveToDeleteButton(0);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
}
[Test]
public void TestDownloadButtonHiddenWhenBeatmapExists()
{
@@ -224,7 +155,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("import beatmap", () => manager.Import(beatmap.BeatmapSet).Wait());
createPlaylist(beatmap);
createPlaylistWithBeatmaps(beatmap);
assertDownloadButtonVisible(false);
@@ -247,7 +178,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
var byChecksum = CreateAPIBeatmap();
byChecksum.Checksum = "1337"; // Some random checksum that does not exist locally.
createPlaylist(byOnlineId, byChecksum);
createPlaylistWithBeatmaps(byOnlineId, byChecksum);
AddAssert("download buttons shown", () => playlist.ChildrenOfType<BeatmapDownloadButton>().All(d => d.IsPresent));
}
@@ -261,7 +192,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
beatmap.BeatmapSet.HasExplicitContent = true;
createPlaylist(beatmap);
createPlaylistWithBeatmaps(beatmap);
}
[Test]
@@ -269,7 +200,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
AddStep("create playlist", () =>
{
Child = playlist = new TestPlaylist(false, false)
Child = playlist = new TestPlaylist
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@@ -312,11 +243,22 @@ namespace osu.Game.Tests.Visual.Multiplayer
[TestCase(true)]
public void TestWithOwner(bool withOwner)
{
createPlaylist(false, false, withOwner);
createPlaylist(p => p.ShowItemOwners = withOwner);
AddAssert("owner visible", () => playlist.ChildrenOfType<UpdateableAvatar>().All(a => a.IsPresent == withOwner));
}
[Test]
public void TestWithAllButtonsEnabled()
{
createPlaylist(p =>
{
p.AllowDeletion = true;
p.AllowShowingResults = true;
p.AllowEditing = true;
});
}
private void moveToItem(int index, Vector2? offset = null)
=> AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType<DifficultyIcon>().ElementAt(index), offset));
@@ -326,12 +268,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
InputManager.MoveMouseTo(item.ChildrenOfType<OsuRearrangeableListItem<PlaylistItem>.PlaylistItemHandle>().Single(), offset);
});
private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () =>
{
var item = playlist.ChildrenOfType<OsuRearrangeableListItem<PlaylistItem>>().ElementAt(index);
InputManager.MoveMouseTo(item.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAt(0), offset);
});
private void assertHandleVisibility(int index, bool visible)
=> AddAssert($"handle {index} {(visible ? "is" : "is not")} visible",
() => (playlist.ChildrenOfType<OsuRearrangeableListItem<PlaylistItem>.PlaylistItemHandle>().ElementAt(index).Alpha > 0) == visible);
@@ -340,17 +276,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
=> AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible",
() => (playlist.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAt(2 + index * 2).Alpha > 0) == visible);
private void createPlaylist(bool allowEdit, bool allowSelection, bool showItemOwner = false)
private void createPlaylist(Action<TestPlaylist> setupPlaylist = null)
{
AddStep("create playlist", () =>
{
Child = playlist = new TestPlaylist(allowEdit, allowSelection, showItemOwner)
Child = playlist = new TestPlaylist
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(500, 300)
};
setupPlaylist?.Invoke(playlist);
for (int i = 0; i < 20; i++)
{
playlist.Items.Add(new PlaylistItem
@@ -386,11 +324,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded));
}
private void createPlaylist(params IBeatmapInfo[] beatmaps)
private void createPlaylistWithBeatmaps(params IBeatmapInfo[] beatmaps)
{
AddStep("create playlist", () =>
{
Child = playlist = new TestPlaylist(false, false)
Child = playlist = new TestPlaylist
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@@ -423,11 +361,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
private class TestPlaylist : DrawableRoomPlaylist
{
public new IReadOnlyDictionary<PlaylistItem, RearrangeableListItem<PlaylistItem>> ItemMap => base.ItemMap;
public TestPlaylist(bool allowEdit, bool allowSelection, bool showItemOwner = false)
: base(allowEdit, allowSelection, showItemOwner: showItemOwner)
{
}
}
}
}
@@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Online.Multiplayer;
using osu.Game.Screens.OnlinePlay;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osuTK.Input;
@@ -74,11 +75,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("api room updated", () => Client.APIRoom?.QueueMode.Value == QueueMode.AllPlayers);
}
[Test]
public void TestAddItemsAsHost()
{
addItem(() => OtherBeatmap);
AddAssert("playlist contains two items", () => Client.APIRoom?.Playlist.Count == 2);
}
private void selectNewItem(Func<BeatmapInfo> beatmap)
{
AddStep("click edit button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSubScreen>().Single().AddOrEditPlaylistButton);
InputManager.MoveMouseTo(this.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistEditButton>().First());
InputManager.Click(MouseButton.Left);
});
@@ -90,5 +99,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen);
AddUntilStep("selected item is new beatmap", () => Client.CurrentMatchPlayingItem.Value?.Beatmap.Value?.OnlineID == otherBeatmap.OnlineID);
}
private void addItem(Func<BeatmapInfo> beatmap)
{
AddStep("click add button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSubScreen.AddItemButton>().Single());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("wait for song select", () => CurrentSubScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded);
AddStep("select other beatmap", () => ((Screens.Select.SongSelect)CurrentSubScreen).FinaliseSelection(beatmap()));
AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen);
}
}
}
@@ -416,8 +416,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("Enter song select", () =>
{
var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerScreenStack.CurrentScreen).CurrentSubScreen;
((MultiplayerMatchSubScreen)currentSubScreen).SelectBeatmap();
((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(client.CurrentMatchPlayingItem.Value);
});
AddUntilStep("wait for song select", () => this.ChildrenOfType<MultiplayerMatchSongSelect>().FirstOrDefault()?.BeatmapSetsLoaded == true);
@@ -179,7 +179,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
public new BeatmapCarousel Carousel => base.Carousel;
public TestMultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null)
: base(room, beatmap, ruleset)
: base(room, null, beatmap, ruleset)
{
}
}
@@ -0,0 +1,168 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Screens.OnlinePlay;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiplayerQueueList : MultiplayerTestScene
{
private MultiplayerQueueList playlist;
[Cached(typeof(UserLookupCache))]
private readonly TestUserLookupCache userLookupCache = new TestUserLookupCache();
private BeatmapManager beatmaps;
private RulesetStore rulesets;
private BeatmapSetInfo importedSet;
private BeatmapInfo importedBeatmap;
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default));
}
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("create playlist", () =>
{
Child = playlist = new MultiplayerQueueList
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(500, 300),
SelectedItem = { BindTarget = Client.CurrentMatchPlayingItem },
Items = { BindTarget = Client.APIRoom!.Playlist }
};
});
AddStep("import beatmap", () =>
{
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait();
importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First();
importedBeatmap = importedSet.Beatmaps.First(b => b.RulesetID == 0);
});
AddStep("change to all players mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }));
}
[Test]
public void TestDeleteButtonAlwaysVisibleForHost()
{
AddStep("set all players queue mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }));
AddUntilStep("wait for queue mode change", () => Client.APIRoom?.QueueMode.Value == QueueMode.AllPlayers);
addPlaylistItem(() => API.LocalUser.Value.OnlineID);
assertDeleteButtonVisibility(1, true);
addPlaylistItem(() => 1234);
assertDeleteButtonVisibility(2, true);
}
[Test]
public void TestDeleteButtonOnlyVisibleForItemOwnerIfNotHost()
{
AddStep("set all players queue mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }));
AddUntilStep("wait for queue mode change", () => Client.APIRoom?.QueueMode.Value == QueueMode.AllPlayers);
AddStep("join other user", () => Client.AddUser(new APIUser { Id = 1234 }));
AddStep("set other user as host", () => Client.TransferHost(1234));
addPlaylistItem(() => API.LocalUser.Value.OnlineID);
assertDeleteButtonVisibility(1, true);
addPlaylistItem(() => 1234);
assertDeleteButtonVisibility(2, false);
AddStep("set local user as host", () => Client.TransferHost(API.LocalUser.Value.OnlineID));
assertDeleteButtonVisibility(1, true);
assertDeleteButtonVisibility(2, true);
}
[Test]
public void TestCurrentItemDoesNotHaveDeleteButton()
{
AddStep("set all players queue mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }));
AddUntilStep("wait for queue mode change", () => Client.APIRoom?.QueueMode.Value == QueueMode.AllPlayers);
assertDeleteButtonVisibility(0, false);
addPlaylistItem(() => API.LocalUser.Value.OnlineID);
assertDeleteButtonVisibility(0, false);
assertDeleteButtonVisibility(1, true);
// Run through gameplay.
AddStep("set state to ready", () => Client.ChangeUserState(API.LocalUser.Value.Id, MultiplayerUserState.Ready));
AddUntilStep("local state is ready", () => Client.LocalUser?.State == MultiplayerUserState.Ready);
AddStep("start match", () => Client.StartMatch());
AddUntilStep("match started", () => Client.LocalUser?.State == MultiplayerUserState.WaitingForLoad);
AddStep("set state to loaded", () => Client.ChangeUserState(API.LocalUser.Value.Id, MultiplayerUserState.Loaded));
AddUntilStep("local state is playing", () => Client.LocalUser?.State == MultiplayerUserState.Playing);
AddStep("set state to finished play", () => Client.ChangeUserState(API.LocalUser.Value.Id, MultiplayerUserState.FinishedPlay));
AddUntilStep("local state is results", () => Client.LocalUser?.State == MultiplayerUserState.Results);
assertDeleteButtonVisibility(1, false);
}
private void addPlaylistItem(Func<int> userId)
{
long itemId = -1;
AddStep("add playlist item", () =>
{
MultiplayerPlaylistItem item = new MultiplayerPlaylistItem(new PlaylistItem
{
Beatmap = { Value = importedBeatmap },
BeatmapID = importedBeatmap.OnlineID ?? -1,
});
Client.AddUserPlaylistItem(userId(), item);
itemId = item.ID;
});
AddUntilStep("item arrived in playlist", () => playlist.ChildrenOfType<RearrangeableListItem<PlaylistItem>>().Any(i => i.Model.ID == itemId));
}
private void deleteItem(int index)
{
OsuRearrangeableListItem<PlaylistItem> item = null;
AddStep($"move mouse to delete button {index}", () =>
{
item = playlist.ChildrenOfType<OsuRearrangeableListItem<PlaylistItem>>().ElementAt(index);
InputManager.MoveMouseTo(item.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAt(0));
});
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddUntilStep("item removed from playlist", () => !playlist.ChildrenOfType<RearrangeableListItem<PlaylistItem>>().Contains(item));
}
private void assertDeleteButtonVisibility(int index, bool visible)
=> AddUntilStep($"delete button {index} {(visible ? "is" : "is not")} visible",
() => (playlist.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAt(index).Alpha > 0) == visible);
}
}
@@ -0,0 +1,188 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Screens.OnlinePlay;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Tests.Beatmaps;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestScenePlaylistsRoomSettingsPlaylist : OsuManualInputManagerTestScene
{
private TestPlaylist playlist;
[Cached(typeof(UserLookupCache))]
private readonly TestUserLookupCache userLookupCache = new TestUserLookupCache();
[Test]
public void TestItemRemovedOnDeletion()
{
PlaylistItem selectedItem = null;
createPlaylist();
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddStep("retrieve selection", () => selectedItem = playlist.SelectedItem.Value);
moveToDeleteButton(0);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
AddAssert("item removed", () => !playlist.Items.Contains(selectedItem));
}
[Test]
public void TestNextItemSelectedAfterDeletion()
{
createPlaylist();
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveToDeleteButton(0);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]);
}
[Test]
public void TestLastItemSelectedAfterLastItemDeleted()
{
createPlaylist();
AddWaitStep("wait for flow", 5); // Items may take 1 update frame to flow. A wait count of 5 is guaranteed to result in the flow being updated as desired.
AddStep("scroll to bottom", () => playlist.ChildrenOfType<ScrollContainer<Drawable>>().First().ScrollToEnd(false));
moveToItem(19);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveToDeleteButton(19);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
AddAssert("item 18 is selected", () => playlist.SelectedItem.Value == playlist.Items[18]);
}
[Test]
public void TestSelectionResetWhenAllItemsDeleted()
{
createPlaylist();
AddStep("remove all but one item", () =>
{
playlist.Items.RemoveRange(1, playlist.Items.Count - 1);
});
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveToDeleteButton(0);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
AddAssert("no item selected", () => playlist.SelectedItem.Value == null);
}
// Todo: currently not possible due to bindable list shortcomings (https://github.com/ppy/osu-framework/issues/3081)
// [Test]
public void TestNextItemSelectedAfterExternalDeletion()
{
createPlaylist();
moveToItem(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddStep("remove item 0", () => playlist.Items.RemoveAt(0));
AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]);
}
[Test]
public void TestChangeBeatmapAndRemove()
{
createPlaylist();
AddStep("change beatmap of first item", () => playlist.Items[0].BeatmapID = 30);
moveToDeleteButton(0);
AddStep("click delete button", () => InputManager.Click(MouseButton.Left));
}
private void moveToItem(int index, Vector2? offset = null)
=> AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType<DifficultyIcon>().ElementAt(index), offset));
private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () =>
{
var item = playlist.ChildrenOfType<OsuRearrangeableListItem<PlaylistItem>>().ElementAt(index);
InputManager.MoveMouseTo(item.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAt(0), offset);
});
private void createPlaylist()
{
AddStep("create playlist", () =>
{
Child = playlist = new TestPlaylist
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(500, 300)
};
for (int i = 0; i < 20; i++)
{
playlist.Items.Add(new PlaylistItem
{
ID = i,
OwnerID = 2,
Beatmap =
{
Value = i % 2 == 1
? new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo
: new BeatmapInfo
{
Metadata = new BeatmapMetadata
{
Artist = "Artist",
Author = new APIUser { Username = "Creator name here" },
Title = "Long title used to check background colour",
},
BeatmapSet = new BeatmapSetInfo()
}
},
Ruleset = { Value = new OsuRuleset().RulesetInfo },
RequiredMods =
{
new OsuModHardRock(),
new OsuModDoubleTime(),
new OsuModAutoplay()
}
});
}
});
AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded));
}
private class TestPlaylist : PlaylistsRoomSettingsPlaylist
{
public new IReadOnlyDictionary<PlaylistItem, RearrangeableListItem<PlaylistItem>> ItemMap => base.ItemMap;
public TestPlaylist()
{
AllowSelection = true;
}
}
}
}
@@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
});
AddAssert("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus);
AddUntilStep("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus);
AddAssert("user state arrived", () => client.Room?.Users.FirstOrDefault()?.MatchState is TeamVersusUserState);
}
@@ -162,13 +162,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
});
AddAssert("room type is head to head", () => client.Room?.Settings.MatchType == MatchType.HeadToHead);
AddUntilStep("room type is head to head", () => client.Room?.Settings.MatchType == MatchType.HeadToHead);
AddUntilStep("team displays are not displaying teams", () => multiplayerScreenStack.ChildrenOfType<TeamDisplay>().All(d => d.DisplayedTeam == null));
AddStep("change to team vs", () => client.ChangeSettings(matchType: MatchType.TeamVersus));
AddAssert("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus);
AddUntilStep("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus);
AddUntilStep("team displays are displaying teams", () => multiplayerScreenStack.ChildrenOfType<TeamDisplay>().All(d => d.DisplayedTeam != null));
}
+2
View File
@@ -15,6 +15,8 @@ namespace osu.Game.Beatmaps
public int ID { get; set; }
public bool IsManaged => ID > 0;
public float DrainRate { get; set; } = DEFAULT_DIFFICULTY;
public float CircleSize { get; set; } = DEFAULT_DIFFICULTY;
public float OverallDifficulty { get; set; } = DEFAULT_DIFFICULTY;
+1 -1
View File
@@ -135,7 +135,7 @@ namespace osu.Game.Beatmaps
var localRulesetInfo = rulesetInfo as RulesetInfo;
// Difficulty can only be computed if the beatmap and ruleset are locally available.
if (localBeatmapInfo == null || localBeatmapInfo.ID == 0 || localRulesetInfo == null)
if (localBeatmapInfo?.IsManaged != true || localRulesetInfo == null)
{
// If not, fall back to the existing star difficulty (e.g. from an online source).
return Task.FromResult<StarDifficulty?>(new StarDifficulty(beatmapInfo.StarRating, (beatmapInfo as IBeatmapOnlineInfo)?.MaxCombo ?? 0));
+2
View File
@@ -20,6 +20,8 @@ namespace osu.Game.Beatmaps
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
public int BeatmapVersion;
private int? onlineID;
+2
View File
@@ -20,6 +20,8 @@ namespace osu.Game.Beatmaps
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
public string Title { get; set; } = string.Empty;
[JsonProperty("title_unicode")]
+2
View File
@@ -11,6 +11,8 @@ namespace osu.Game.Beatmaps
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
public int BeatmapSetInfoID { get; set; }
public int FileInfoID { get; set; }
+2
View File
@@ -18,6 +18,8 @@ namespace osu.Game.Beatmaps
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
private int? onlineID;
[Column("OnlineBeatmapSetID")]
+224 -206
View File
@@ -31,10 +31,12 @@ namespace osu.Game.Beatmaps.Drawables.Cards
public class BeatmapCard : OsuClickableContainer
{
public const float TRANSITION_DURATION = 400;
public const float CORNER_RADIUS = 10;
public Bindable<bool> Expanded { get; } = new BindableBool();
private const float width = 408;
private const float height = 100;
private const float corner_radius = 10;
private const float icon_area_width = 30;
private readonly APIBeatmapSet beatmapSet;
@@ -42,6 +44,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards
private readonly BeatmapDownloadTracker downloadTracker;
private BeatmapCardContent content = null!;
private BeatmapCardThumbnail thumbnail = null!;
private Container rightAreaBackground = null!;
@@ -73,242 +77,247 @@ namespace osu.Game.Beatmaps.Drawables.Cards
{
Width = width;
Height = height;
CornerRadius = corner_radius;
Masking = true;
FillFlowContainer leftIconArea;
GridContainer titleContainer;
GridContainer artistContainer;
InternalChildren = new Drawable[]
InternalChild = content = new BeatmapCardContent(height)
{
downloadTracker,
rightAreaBackground = new Container
MainContent = new Container
{
RelativeSizeAxes = Axes.Y,
Width = icon_area_width + 2 * corner_radius,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
// workaround for masking artifacts at the top & bottom of card,
// which become especially visible on downloaded beatmaps (when the icon area has a lime background).
Padding = new MarginPadding { Vertical = 1 },
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Colour4.White
},
},
thumbnail = new BeatmapCardThumbnail(beatmapSet)
{
Name = @"Left (icon) area",
Size = new Vector2(height),
Padding = new MarginPadding { Right = corner_radius },
Child = leftIconArea = new FillFlowContainer
{
Margin = new MarginPadding(5),
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(1)
}
},
new Container
{
Name = @"Right (button) area",
Width = 30,
RelativeSizeAxes = Axes.Y,
Origin = Anchor.TopRight,
Anchor = Anchor.TopRight,
Padding = new MarginPadding { Vertical = 17.5f },
Child = rightAreaButtons = new Container<BeatmapCardIconButton>
{
RelativeSizeAxes = Axes.Both,
Children = new BeatmapCardIconButton[]
{
new FavouriteButton(beatmapSet)
{
Current = favouriteState,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
},
new DownloadButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State }
},
new GoToBeatmapButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State }
}
}
}
},
mainContent = new Container
{
Name = @"Main content",
X = height - corner_radius,
Height = height,
CornerRadius = corner_radius,
Masking = true,
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
mainContentBackground = new BeatmapCardContentBackground(beatmapSet)
downloadTracker,
rightAreaBackground = new Container
{
RelativeSizeAxes = Axes.Both,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
RelativeSizeAxes = Axes.Y,
Width = icon_area_width + 2 * CORNER_RADIUS,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
// workaround for masking artifacts at the top & bottom of card,
// which become especially visible on downloaded beatmaps (when the icon area has a lime background).
Padding = new MarginPadding { Vertical = 1 },
Child = new Box
{
Horizontal = 10,
Vertical = 4
RelativeSizeAxes = Axes.Both,
Colour = Colour4.White
},
Direction = FillDirection.Vertical,
Children = new Drawable[]
},
thumbnail = new BeatmapCardThumbnail(beatmapSet)
{
Name = @"Left (icon) area",
Size = new Vector2(height),
Padding = new MarginPadding { Right = CORNER_RADIUS },
Child = leftIconArea = new FillFlowContainer
{
titleContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title),
Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
}
}
},
artistContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = createArtistText(),
Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
},
}
},
new LinkFlowContainer(s =>
{
s.Shadow = false;
s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold);
}).With(d =>
{
d.AutoSizeAxes = Axes.Both;
d.Margin = new MarginPadding { Top = 2 };
d.AddText("mapped by ", t => t.Colour = colourProvider.Content2);
d.AddUserLink(beatmapSet.Author);
}),
Margin = new MarginPadding(5),
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(1)
}
},
new Container
{
Name = @"Bottom content",
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Padding = new MarginPadding
Name = @"Right (button) area",
Width = 30,
RelativeSizeAxes = Axes.Y,
Origin = Anchor.TopRight,
Anchor = Anchor.TopRight,
Padding = new MarginPadding { Vertical = 17.5f },
Child = rightAreaButtons = new Container<BeatmapCardIconButton>
{
Horizontal = 10,
Vertical = 4
},
RelativeSizeAxes = Axes.Both,
Children = new BeatmapCardIconButton[]
{
new FavouriteButton(beatmapSet)
{
Current = favouriteState,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
},
new DownloadButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State }
},
new GoToBeatmapButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State }
}
}
}
},
mainContent = new Container
{
Name = @"Main content",
X = height - CORNER_RADIUS,
Height = height,
CornerRadius = CORNER_RADIUS,
Masking = true,
Children = new Drawable[]
{
idleBottomContent = new FillFlowContainer
mainContentBackground = new BeatmapCardContentBackground(beatmapSet)
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.Both,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Horizontal = 10,
Vertical = 4
},
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 3),
AlwaysPresent = true,
Children = new Drawable[]
{
statisticsContainer = new FillFlowContainer<BeatmapCardStatistic>
titleContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Alpha = 0,
AlwaysPresent = true,
ChildrenEnumerable = createStatistics()
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(4, 0),
Children = new Drawable[]
ColumnDimensions = new[]
{
new BeatmapSetOnlineStatusPill
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
AutoSizeAxes = Axes.Both,
Status = beatmapSet.Status,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft
},
new DifficultySpectrumDisplay(beatmapSet)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
DotSize = new Vector2(6, 12)
new OsuSpriteText
{
Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title),
Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
}
}
}
},
artistContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = createArtistText(),
Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
},
}
},
new LinkFlowContainer(s =>
{
s.Shadow = false;
s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold);
}).With(d =>
{
d.AutoSizeAxes = Axes.Both;
d.Margin = new MarginPadding { Top = 2 };
d.AddText("mapped by ", t => t.Colour = colourProvider.Content2);
d.AddUserLink(beatmapSet.Author);
}),
}
},
downloadProgressBar = new BeatmapCardDownloadProgressBar
new Container
{
Name = @"Bottom content",
RelativeSizeAxes = Axes.X,
Height = 6,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
State = { BindTarget = downloadTracker.State },
Progress = { BindTarget = downloadTracker.Progress }
AutoSizeAxes = Axes.Y,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Padding = new MarginPadding
{
Horizontal = 10,
Vertical = 4
},
Children = new Drawable[]
{
idleBottomContent = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 3),
AlwaysPresent = true,
Children = new Drawable[]
{
statisticsContainer = new FillFlowContainer<BeatmapCardStatistic>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Alpha = 0,
AlwaysPresent = true,
ChildrenEnumerable = createStatistics()
},
new BeatmapCardExtraInfoRow(beatmapSet)
{
Hovered = _ =>
{
content.ScheduleShow();
return false;
},
Unhovered = _ =>
{
// This hide should only trigger if the expanded content has not shown yet.
// ie. if the user has not shown intent to want to see it (quickly moved over the info row area).
if (!Expanded.Value)
content.ScheduleHide();
}
}
}
},
downloadProgressBar = new BeatmapCardDownloadProgressBar
{
RelativeSizeAxes = Axes.X,
Height = 6,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
State = { BindTarget = downloadTracker.State },
Progress = { BindTarget = downloadTracker.Progress }
}
}
}
}
}
}
}
},
ExpandedContent = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10, Vertical = 13 },
Child = new BeatmapCardDifficultyList(beatmapSet)
},
Expanded = { BindTarget = Expanded }
};
if (beatmapSet.HasVideo)
@@ -344,7 +353,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards
{
base.LoadComplete();
downloadTracker.State.BindValueChanged(_ => updateState(), true);
downloadTracker.State.BindValueChanged(_ => updateState());
Expanded.BindValueChanged(_ => updateState(), true);
FinishTransforms(true);
}
@@ -356,6 +366,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards
protected override void OnHoverLost(HoverLostEvent e)
{
content.ScheduleHide();
updateState();
base.OnHoverLost(e);
}
@@ -386,19 +398,25 @@ namespace osu.Game.Beatmaps.Drawables.Cards
private void updateState()
{
float targetWidth = width - height;
if (IsHovered)
targetWidth = targetWidth - icon_area_width + corner_radius;
bool showDetails = IsHovered || Expanded.Value;
thumbnail.Dimmed.Value = IsHovered;
float targetWidth = width - height;
if (showDetails)
targetWidth = targetWidth - icon_area_width + CORNER_RADIUS;
thumbnail.Dimmed.Value = showDetails;
// Scale value is intentionally chosen to fit in the spacing of listing displays, as to not overlap horizontally with adjacent cards.
// This avoids depth issues where a hovered (scaled) card to the right of another card would be beneath the card to the left.
content.ScaleTo(Expanded.Value ? 1.03f : 1, 500, Easing.OutQuint);
mainContent.ResizeWidthTo(targetWidth, TRANSITION_DURATION, Easing.OutQuint);
mainContentBackground.Dimmed.Value = IsHovered;
mainContentBackground.Dimmed.Value = showDetails;
statisticsContainer.FadeTo(IsHovered ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
statisticsContainer.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
rightAreaBackground.FadeColour(downloadTracker.State.Value == DownloadState.LocallyAvailable ? colours.Lime0 : colourProvider.Background3, TRANSITION_DURATION, Easing.OutQuint);
rightAreaButtons.FadeTo(IsHovered ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
rightAreaButtons.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
foreach (var button in rightAreaButtons)
{
@@ -0,0 +1,234 @@
// 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.
#nullable enable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public class BeatmapCardContent : CompositeDrawable
{
public Drawable MainContent
{
set => bodyContent.Child = value;
}
public Drawable ExpandedContent
{
set => dropdownScroll.Child = value;
}
public Bindable<bool> Expanded { get; } = new BindableBool();
private readonly Box background;
private readonly Container content;
private readonly Container bodyContent;
private readonly Container dropdownContent;
private readonly OsuScrollContainer dropdownScroll;
private readonly Container borderContainer;
public BeatmapCardContent(float height)
{
RelativeSizeAxes = Axes.X;
Height = height;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
InternalChild = content = new HoverHandlingContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
CornerRadius = BeatmapCard.CORNER_RADIUS,
Masking = true,
Unhovered = _ => checkForHide(),
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both
},
bodyContent = new Container
{
RelativeSizeAxes = Axes.X,
Height = height,
CornerRadius = BeatmapCard.CORNER_RADIUS,
Masking = true,
},
dropdownContent = new HoverHandlingContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Top = height },
Alpha = 0,
Hovered = _ =>
{
keep();
return true;
},
Unhovered = _ => checkForHide(),
Child = dropdownScroll = new ExpandedContentScrollContainer
{
RelativeSizeAxes = Axes.X,
ScrollbarVisible = false
}
},
borderContainer = new Container
{
RelativeSizeAxes = Axes.Both,
CornerRadius = BeatmapCard.CORNER_RADIUS,
Masking = true,
BorderThickness = 3,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true
}
}
}
};
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
background.Colour = colourProvider.Background2;
borderContainer.BorderColour = colourProvider.Highlight1;
}
protected override void LoadComplete()
{
base.LoadComplete();
Expanded.BindValueChanged(_ => updateState(), true);
FinishTransforms(true);
}
private ScheduledDelegate? scheduledExpandedChange;
public void ScheduleShow()
{
scheduledExpandedChange?.Cancel();
if (Expanded.Disabled || Expanded.Value)
return;
scheduledExpandedChange = Scheduler.AddDelayed(() =>
{
if (!Expanded.Disabled)
Expanded.Value = true;
}, 100);
}
public void ScheduleHide()
{
scheduledExpandedChange?.Cancel();
if (Expanded.Disabled || !Expanded.Value)
return;
scheduledExpandedChange = Scheduler.AddDelayed(() =>
{
if (!Expanded.Disabled)
Expanded.Value = false;
}, 500);
}
private void checkForHide()
{
if (Expanded.Disabled)
return;
if (content.IsHovered || dropdownContent.IsHovered)
return;
scheduledExpandedChange?.Cancel();
Expanded.Value = false;
}
private void keep()
{
if (Expanded.Disabled)
return;
scheduledExpandedChange?.Cancel();
Expanded.Value = true;
}
private void updateState()
{
background.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
dropdownContent.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
borderContainer.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
content.TweenEdgeEffectTo(new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Offset = new Vector2(0, 2),
Radius = 10,
Colour = Colour4.Black.Opacity(Expanded.Value ? 0.3f : 0f),
Hollow = true,
}, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
}
private class ExpandedContentScrollContainer : OsuScrollContainer
{
public ExpandedContentScrollContainer()
{
ScrollbarVisible = false;
}
protected override void Update()
{
base.Update();
Height = Math.Min(Content.DrawHeight, 400);
}
private bool allowScroll => !Precision.AlmostEquals(DrawSize, Content.DrawSize);
protected override bool OnDragStart(DragStartEvent e)
{
if (!allowScroll)
return false;
return base.OnDragStart(e);
}
protected override void OnDrag(DragEvent e)
{
if (!allowScroll)
return;
base.OnDrag(e);
}
protected override void OnDragEnd(DragEndEvent e)
{
if (!allowScroll)
return;
base.OnDragEnd(e);
}
protected override bool OnScroll(ScrollEvent e)
{
if (!allowScroll)
return false;
return base.OnScroll(e);
}
}
}
}
@@ -0,0 +1,43 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public class BeatmapCardExtraInfoRow : HoverHandlingContainer
{
public BeatmapCardExtraInfoRow(APIBeatmapSet beatmapSet)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(4, 0),
Children = new Drawable[]
{
new BeatmapSetOnlineStatusPill
{
AutoSizeAxes = Axes.Both,
Status = beatmapSet.Status,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft
},
new DifficultySpectrumDisplay(beatmapSet)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
DotSize = new Vector2(6, 12)
}
}
};
}
}
}
@@ -0,0 +1,29 @@
// 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.
#nullable enable
using System;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public class HoverHandlingContainer : Container
{
public Func<HoverEvent, bool>? Hovered { get; set; }
public Action<HoverLostEvent>? Unhovered { get; set; }
protected override bool OnHover(HoverEvent e)
{
bool handledByBase = base.OnHover(e);
return Hovered?.Invoke(e) ?? handledByBase;
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
Unhovered?.Invoke(e);
}
}
}
@@ -11,6 +11,8 @@ namespace osu.Game.Configuration
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
public int? RulesetID { get; set; }
public int? Variant { get; set; }
+3 -3
View File
@@ -476,7 +476,7 @@ namespace osu.Game.Database
{
Files.Dereference(file.FileInfo);
if (file.ID > 0)
if (file.IsManaged)
{
// This shouldn't be required, but here for safety in case the provided TModel is not being change tracked
// Definitely can be removed once we rework the database backend.
@@ -505,7 +505,7 @@ namespace osu.Game.Database
});
}
if (model.ID > 0)
if (model.IsManaged)
Update(model);
}
@@ -737,7 +737,7 @@ namespace osu.Game.Database
/// <param name="items">The usable items present in the store.</param>
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
protected virtual bool CheckLocalAvailability(TModel model, IQueryable<TModel> items)
=> model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any());
=> model.IsManaged && items.Any(i => i.ID == model.ID && i.Files.Any());
/// <summary>
/// Whether import can be skipped after finding an existing import early in the process.
+10 -10
View File
@@ -35,6 +35,16 @@ namespace osu.Game.Database
private void migrateSkins(DatabaseWriteUsage db)
{
// can be removed 20220530.
var existingSkins = db.Context.SkinInfo
.Include(s => s.Files)
.ThenInclude(f => f.FileInfo)
.ToList();
// previous entries in EF are removed post migration.
if (!existingSkins.Any())
return;
var userSkinChoice = config.GetBindable<string>(OsuSetting.Skin);
int.TryParse(userSkinChoice.Value, out int userSkinInt);
@@ -49,16 +59,6 @@ namespace osu.Game.Database
break;
}
// migrate ruleset settings. can be removed 20220530.
var existingSkins = db.Context.SkinInfo
.Include(s => s.Files)
.ThenInclude(f => f.FileInfo)
.ToList();
// previous entries in EF are removed post migration.
if (!existingSkins.Any())
return;
using (var realm = realmContextFactory.CreateContext())
using (var transaction = realm.BeginWrite())
{
+2
View File
@@ -11,5 +11,7 @@ namespace osu.Game.Database
[JsonIgnore]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
int ID { get; set; }
bool IsManaged { get; }
}
}
+67 -1
View File
@@ -93,7 +93,7 @@ namespace osu.Game.Graphics.UserInterface
if (added.Any(char.IsUpper) && AllowUniqueCharacterSamples)
capsTextAddedSample?.Play();
else
textAddedSamples[RNG.Next(0, 3)]?.Play();
playTextAddedSample();
}
protected override void OnUserTextRemoved(string removed)
@@ -117,6 +117,70 @@ namespace osu.Game.Graphics.UserInterface
caretMovedSample?.Play();
}
protected override void OnImeComposition(string newComposition, int removedTextLength, int addedTextLength, bool caretMoved)
{
base.OnImeComposition(newComposition, removedTextLength, addedTextLength, caretMoved);
if (string.IsNullOrEmpty(newComposition))
{
switch (removedTextLength)
{
case 0:
// empty composition event, composition wasn't changed, don't play anything.
return;
case 1:
// composition probably ended by pressing backspace, or was cancelled.
textRemovedSample?.Play();
return;
default:
// longer text removed, composition ended because it was cancelled.
// could be a different sample if desired.
textRemovedSample?.Play();
return;
}
}
if (addedTextLength > 0)
{
// some text was added, probably due to typing new text or by changing the candidate.
playTextAddedSample();
return;
}
if (removedTextLength > 0)
{
// text was probably removed by backspacing.
// it's also possible that a candidate that only removed text was changed to.
textRemovedSample?.Play();
return;
}
if (caretMoved)
{
// only the caret/selection was moved.
caretMovedSample?.Play();
}
}
protected override void OnImeResult(string result, bool successful)
{
base.OnImeResult(result, successful);
if (successful)
{
// composition was successfully completed, usually by pressing the enter key.
textCommittedSample?.Play();
}
else
{
// composition was prematurely ended, eg. by clicking inside the textbox.
// could be a different sample if desired.
textCommittedSample?.Play();
}
}
protected override void OnFocus(FocusEvent e)
{
BorderThickness = 3;
@@ -142,6 +206,8 @@ namespace osu.Game.Graphics.UserInterface
SelectionColour = SelectionColour,
};
private void playTextAddedSample() => textAddedSamples[RNG.Next(0, textAddedSamples.Length)]?.Play();
private class OsuCaret : Caret
{
private const float caret_move_time = 60;
+2
View File
@@ -9,6 +9,8 @@ namespace osu.Game.IO
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
public string Hash { get; set; }
public int ReferenceCount { get; set; }
+1 -1
View File
@@ -42,7 +42,7 @@ namespace osu.Game.Online.API
if (WebRequest != null)
{
Response = ((OsuJsonWebRequest<T>)WebRequest).ResponseObject;
Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes");
Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes", LoggingTarget.Network);
}
}
@@ -82,5 +82,17 @@ namespace osu.Game.Online.Multiplayer
/// </summary>
/// <param name="item">The item to add.</param>
Task AddPlaylistItem(MultiplayerPlaylistItem item);
/// <summary>
/// Edits an existing playlist item with new values.
/// </summary>
/// <param name="item">The item to edit, containing new properties. Must have an ID.</param>
Task EditPlaylistItem(MultiplayerPlaylistItem item);
/// <summary>
/// Removes an item from the playlist.
/// </summary>
/// <param name="playlistItemId">The item to remove.</param>
Task RemovePlaylistItem(long playlistItemId);
}
}
@@ -163,7 +163,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(joinedRoom != null);
// Populate playlist items.
var playlistItems = await Task.WhenAll(joinedRoom.Playlist.Select(createPlaylistItem)).ConfigureAwait(false);
var playlistItems = await Task.WhenAll(joinedRoom.Playlist.Select(item => createPlaylistItem(item, item.ID == joinedRoom.Settings.PlaylistItemId))).ConfigureAwait(false);
// Populate users.
Debug.Assert(joinedRoom.Users != null);
@@ -335,6 +335,10 @@ namespace osu.Game.Online.Multiplayer
public abstract Task AddPlaylistItem(MultiplayerPlaylistItem item);
public abstract Task EditPlaylistItem(MultiplayerPlaylistItem item);
public abstract Task RemovePlaylistItem(long playlistItemId);
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
{
if (Room == null)
@@ -470,7 +474,32 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
{
Scheduler.Add(() => updateLocalRoomSettings(newSettings));
Debug.Assert(APIRoom != null);
Debug.Assert(Room != null);
Scheduler.Add(() =>
{
// ensure the new selected item is populated immediately.
var playlistItem = APIRoom.Playlist.Single(p => p.ID == newSettings.PlaylistItemId);
if (playlistItem != null)
{
GetAPIBeatmap(playlistItem.BeatmapID).ContinueWith(b =>
{
// Should be called outside of the `Scheduler` logic (and specifically accessing `Exception`) to suppress an exception from firing outwards.
bool success = b.Exception == null;
Scheduler.Add(() =>
{
if (success)
playlistItem.Beatmap.Value = b.Result;
updateLocalRoomSettings(newSettings);
});
});
}
});
return Task.CompletedTask;
}
@@ -629,7 +658,7 @@ namespace osu.Game.Online.Multiplayer
if (Room == null)
return;
var playlistItem = await createPlaylistItem(item).ConfigureAwait(false);
var playlistItem = await createPlaylistItem(item, true).ConfigureAwait(false);
Scheduler.Add(() =>
{
@@ -673,7 +702,7 @@ namespace osu.Game.Online.Multiplayer
if (Room == null)
return;
var playlistItem = await createPlaylistItem(item).ConfigureAwait(false);
var playlistItem = await createPlaylistItem(item, true).ConfigureAwait(false);
Scheduler.Add(() =>
{
@@ -728,10 +757,8 @@ namespace osu.Game.Online.Multiplayer
CurrentMatchPlayingItem.Value = APIRoom.Playlist.SingleOrDefault(p => p.ID == settings.PlaylistItemId);
}
private async Task<PlaylistItem> createPlaylistItem(MultiplayerPlaylistItem item)
private async Task<PlaylistItem> createPlaylistItem(MultiplayerPlaylistItem item, bool populateBeatmapImmediately)
{
var apiBeatmap = await GetAPIBeatmap(item.BeatmapID).ConfigureAwait(false);
var ruleset = Rulesets.GetRuleset(item.RulesetID);
Debug.Assert(ruleset != null);
@@ -741,8 +768,8 @@ namespace osu.Game.Online.Multiplayer
var playlistItem = new PlaylistItem
{
ID = item.ID,
BeatmapID = item.BeatmapID,
OwnerID = item.OwnerID,
Beatmap = { Value = apiBeatmap },
Ruleset = { Value = ruleset },
Expired = item.Expired,
PlaylistOrder = item.PlaylistOrder,
@@ -752,6 +779,9 @@ namespace osu.Game.Online.Multiplayer
playlistItem.RequiredMods.AddRange(item.RequiredMods.Select(m => m.ToMod(rulesetInstance)));
playlistItem.AllowedMods.AddRange(item.AllowedMods.Select(m => m.ToMod(rulesetInstance)));
if (populateBeatmapImmediately)
playlistItem.Beatmap.Value = await GetAPIBeatmap(item.BeatmapID).ConfigureAwait(false);
return playlistItem;
}
@@ -162,6 +162,22 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMultiplayerServer.AddPlaylistItem), item);
}
public override Task EditPlaylistItem(MultiplayerPlaylistItem item)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.EditPlaylistItem), item);
}
public override Task RemovePlaylistItem(long playlistItemId)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.RemovePlaylistItem), playlistItemId);
}
protected override Task<APIBeatmap> GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default)
{
return beatmapLookupCache.GetBeatmapAsync(beatmapId, cancellationToken);
@@ -62,6 +62,7 @@ namespace osu.Game.Online.Rooms
public MultiplayerPlaylistItem(PlaylistItem item)
{
ID = item.ID;
OwnerID = item.OwnerID;
BeatmapID = item.BeatmapID;
BeatmapChecksum = item.Beatmap.Value?.MD5Hash ?? string.Empty;
RulesetID = item.RulesetID;
+2 -1
View File
@@ -151,7 +151,8 @@ namespace osu.Game.Overlays
}
// spawn new children with the contained so we only clear old content at the last moment.
var content = new FillFlowContainer<BeatmapCard>
// reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most).
var content = new ReverseChildIDFillFlowContainer<BeatmapCard>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
+5 -2
View File
@@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
@@ -22,7 +23,7 @@ namespace osu.Game.Overlays.Profile
public abstract string Identifier { get; }
private readonly FillFlowContainer content;
private readonly FillFlowContainer<Drawable> content;
private readonly Box background;
private readonly Box underscore;
@@ -79,7 +80,9 @@ namespace osu.Game.Overlays.Profile
}
}
},
content = new FillFlowContainer
// reverse ID flow is required for correct Z-ordering of the content (last item should be front-most).
// particularly important in BeatmapsSection, as it uses beatmap cards, which have expandable overhanging content.
content = new ReverseChildIDFillFlowContainer<Drawable>
{
Direction = FillDirection.Vertical,
AutoSizeAxes = Axes.Y,
@@ -13,6 +13,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
@@ -26,7 +27,7 @@ namespace osu.Game.Overlays.Profile.Sections
protected int VisiblePages;
protected int ItemsPerPage;
protected FillFlowContainer ItemsContainer { get; private set; }
protected ReverseChildIDFillFlowContainer<Drawable> ItemsContainer { get; private set; }
private APIRequest<List<TModel>> retrievalRequest;
private CancellationTokenSource loadCancellation;
@@ -48,11 +49,15 @@ namespace osu.Game.Overlays.Profile.Sections
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
ItemsContainer = new FillFlowContainer
// reverse ID flow is required for correct Z-ordering of the items (last item should be front-most).
// particularly important in PaginatedBeatmapContainer, as it uses beatmap cards, which have expandable overhanging content.
ItemsContainer = new ReverseChildIDFillFlowContainer<Drawable>
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Spacing = new Vector2(0, 2),
// ensure the container and its contents are in front of the "more" button.
Depth = float.MinValue
},
moreButton = new ShowMoreButton
{
@@ -135,7 +135,8 @@ namespace osu.Game.Overlays.Rankings
Children = new Drawable[]
{
new ScoresTable(1, response.Users),
new FillFlowContainer
// reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most).
new ReverseChildIDFillFlowContainer<BeatmapCard>
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
@@ -120,14 +120,14 @@ namespace osu.Game.Rulesets.Difficulty
/// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
/// </summary>
/// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
public IEnumerable<DifficultyAttributes> CalculateAll()
public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
{
foreach (var combination in CreateDifficultyAdjustmentModCombinations())
{
if (combination is MultiMod multi)
yield return Calculate(multi.Mods);
yield return Calculate(multi.Mods, cancellationToken);
else
yield return Calculate(combination.Yield());
yield return Calculate(combination.Yield(), cancellationToken);
}
}
@@ -145,7 +145,11 @@ namespace osu.Game.Rulesets.Difficulty
{
playableMods = mods.Select(m => m.DeepClone()).ToArray();
Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken);
// Only pass through the cancellation token if it's non-default.
// This allows for the default timeout to be applied for playable beatmap construction.
Beatmap = cancellationToken == default
? beatmap.GetPlayableBeatmap(ruleset, playableMods)
: beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken);
var track = new TrackVirtual(10000);
playableMods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
+2
View File
@@ -11,6 +11,8 @@ namespace osu.Game.Scoring
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
public int FileInfoID { get; set; }
public FileInfo FileInfo { get; set; }
+2
View File
@@ -22,6 +22,8 @@ namespace osu.Game.Scoring
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
public ScoreRank Rank { get; set; }
public long TotalScore { get; set; }
@@ -83,7 +83,9 @@ namespace osu.Game.Screens.Edit.Compose
{
base.LoadComplete();
EditorBeatmap.SelectedHitObjects.BindCollectionChanged((_, __) => updateClipboardActionAvailability());
clipboard.BindValueChanged(_ => updateClipboardActionAvailability(), true);
clipboard.BindValueChanged(_ => updateClipboardActionAvailability());
composer.OnLoadComplete += _ => updateClipboardActionAvailability();
updateClipboardActionAvailability();
}
#region Clipboard operations
@@ -131,7 +133,7 @@ namespace osu.Game.Screens.Edit.Compose
private void updateClipboardActionAvailability()
{
CanCut.Value = CanCopy.Value = EditorBeatmap.SelectedHitObjects.Any();
CanPaste.Value = !string.IsNullOrEmpty(clipboard.Value);
CanPaste.Value = composer.IsLoaded && !string.IsNullOrEmpty(clipboard.Value);
}
private string formatSelectionAsString()
@@ -9,6 +9,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.Select;
using osuTK;
@@ -43,9 +44,9 @@ namespace osu.Game.Screens.OnlinePlay.Components
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Bottom = 10 },
Child = playlist = new DrawableRoomPlaylist(true, false)
Child = playlist = new PlaylistsRoomSettingsPlaylist
{
RelativeSizeAxes = Axes.Both,
RelativeSizeAxes = Axes.Both
}
}
},
@@ -7,6 +7,7 @@ using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Online.API;
@@ -107,6 +108,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
public void AddOrUpdateRoom(Room room)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
Debug.Assert(room.RoomID.Value != null);
if (ignoredRooms.Contains(room.RoomID.Value.Value))
@@ -136,12 +138,16 @@ namespace osu.Game.Screens.OnlinePlay.Components
public void RemoveRoom(Room room)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
rooms.Remove(room);
notifyRoomsUpdated();
}
public void ClearRooms()
{
Debug.Assert(ThreadSafety.IsUpdateThread);
rooms.Clear();
notifyRoomsUpdated();
}
@@ -80,7 +80,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
private void updateRange(object sender, NotifyCollectionChangedEventArgs e)
{
var orderedDifficulties = Playlist.Select(p => p.Beatmap.Value).OrderBy(b => b.StarRating).ToArray();
var orderedDifficulties = Playlist.Where(p => p.Beatmap.Value != null).Select(p => p.Beatmap.Value).OrderBy(b => b.StarRating).ToArray();
StarDifficulty minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarRating : 0, 0);
StarDifficulty maxDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[^1].StarRating : 0, 0);
@@ -1,9 +1,9 @@
// 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.Collections.Specialized;
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
@@ -12,36 +12,136 @@ using osuTK;
namespace osu.Game.Screens.OnlinePlay
{
/// <summary>
/// A scrollable list which displays the <see cref="PlaylistItem"/>s in a <see cref="Room"/>.
/// </summary>
public class DrawableRoomPlaylist : OsuRearrangeableListContainer<PlaylistItem>
{
/// <summary>
/// The currently-selected item. Selection is visually represented with a border.
/// May be updated by clicking playlist items if <see cref="AllowSelection"/> is <c>true</c>.
/// </summary>
public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>();
private readonly bool allowEdit;
private readonly bool allowSelection;
private readonly bool showItemOwner;
/// <summary>
/// Invoked when an item is requested to be deleted.
/// </summary>
public Action<PlaylistItem> RequestDeletion;
public DrawableRoomPlaylist(bool allowEdit, bool allowSelection, bool showItemOwner = false)
/// <summary>
/// Invoked when an item requests its results to be shown.
/// </summary>
public Action<PlaylistItem> RequestResults;
/// <summary>
/// Invoked when an item requests to be edited.
/// </summary>
public Action<PlaylistItem> RequestEdit;
private bool allowReordering;
/// <summary>
/// Whether to allow reordering items in the playlist.
/// </summary>
public bool AllowReordering
{
this.allowEdit = allowEdit;
this.allowSelection = allowSelection;
this.showItemOwner = showItemOwner;
get => allowReordering;
set
{
allowReordering = value;
foreach (var item in ListContainer.OfType<DrawableRoomPlaylistItem>())
item.AllowReordering = value;
}
}
protected override void LoadComplete()
{
base.LoadComplete();
private bool allowDeletion;
// Scheduled since items are removed and re-added upon rearrangement
Items.CollectionChanged += (_, args) => Schedule(() =>
/// <summary>
/// Whether to allow deleting items from the playlist.
/// If <c>true</c>, requests to delete items may be satisfied via <see cref="RequestDeletion"/>.
/// </summary>
public bool AllowDeletion
{
get => allowDeletion;
set
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Remove:
if (allowSelection && args.OldItems.Contains(SelectedItem))
SelectedItem.Value = null;
break;
}
});
allowDeletion = value;
foreach (var item in ListContainer.OfType<DrawableRoomPlaylistItem>())
item.AllowDeletion = value;
}
}
private bool allowSelection;
/// <summary>
/// Whether to allow selecting items from the playlist.
/// If <c>true</c>, clicking on items in the playlist will change the value of <see cref="SelectedItem"/>.
/// </summary>
public bool AllowSelection
{
get => allowSelection;
set
{
allowSelection = value;
foreach (var item in ListContainer.OfType<DrawableRoomPlaylistItem>())
item.AllowSelection = value;
}
}
private bool allowShowingResults;
/// <summary>
/// Whether to allow items to request their results to be shown.
/// If <c>true</c>, requests to show the results may be satisfied via <see cref="RequestResults"/>.
/// </summary>
public bool AllowShowingResults
{
get => allowShowingResults;
set
{
allowShowingResults = value;
foreach (var item in ListContainer.OfType<DrawableRoomPlaylistItem>())
item.AllowShowingResults = value;
}
}
private bool allowEditing;
/// <summary>
/// Whether to allow items to be edited.
/// If <c>true</c>, requests to edit items may be satisfied via <see cref="RequestEdit"/>.
/// </summary>
public bool AllowEditing
{
get => allowEditing;
set
{
allowEditing = value;
foreach (var item in ListContainer.OfType<DrawableRoomPlaylistItem>())
item.AllowEditing = value;
}
}
private bool showItemOwners;
/// <summary>
/// Whether to show the avatar of users which own each playlist item.
/// </summary>
public bool ShowItemOwners
{
get => showItemOwners;
set
{
showItemOwners = value;
foreach (var item in ListContainer.OfType<DrawableRoomPlaylistItem>())
item.ShowItemOwner = value;
}
}
protected override ScrollContainer<Drawable> CreateScrollContainer() => base.CreateScrollContainer().With(d =>
@@ -54,23 +154,20 @@ namespace osu.Game.Screens.OnlinePlay
Spacing = new Vector2(0, 2)
};
protected override OsuRearrangeableListItem<PlaylistItem> CreateOsuDrawable(PlaylistItem item) => new DrawableRoomPlaylistItem(item, allowEdit, allowSelection, showItemOwner)
protected sealed override OsuRearrangeableListItem<PlaylistItem> CreateOsuDrawable(PlaylistItem item) => CreateDrawablePlaylistItem(item).With(d =>
{
SelectedItem = { BindTarget = SelectedItem },
RequestDeletion = requestDeletion
};
d.SelectedItem.BindTarget = SelectedItem;
d.RequestDeletion = i => RequestDeletion?.Invoke(i);
d.RequestResults = i => RequestResults?.Invoke(i);
d.RequestEdit = i => RequestEdit?.Invoke(i);
d.AllowReordering = AllowReordering;
d.AllowDeletion = AllowDeletion;
d.AllowSelection = AllowSelection;
d.AllowShowingResults = AllowShowingResults;
d.AllowEditing = AllowEditing;
d.ShowItemOwner = ShowItemOwners;
});
private void requestDeletion(PlaylistItem item)
{
if (allowSelection && SelectedItem.Value == item)
{
if (Items.Count == 1)
SelectedItem.Value = null;
else
SelectedItem.Value = Items.GetNext(item) ?? Items[^2];
}
Items.Remove(item);
}
protected virtual DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new DrawableRoomPlaylistItem(item);
}
}
@@ -7,7 +7,6 @@ using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
@@ -16,6 +15,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Database;
@@ -38,27 +38,51 @@ namespace osu.Game.Screens.OnlinePlay
public class DrawableRoomPlaylistItem : OsuRearrangeableListItem<PlaylistItem>
{
public const float HEIGHT = 50;
public const float ICON_HEIGHT = 34;
private const float icon_height = 34;
/// <summary>
/// Invoked when this item requests to be deleted.
/// </summary>
public Action<PlaylistItem> RequestDeletion;
/// <summary>
/// Invoked when this item requests its results to be shown.
/// </summary>
public Action<PlaylistItem> RequestResults;
/// <summary>
/// Invoked when this item requests to be edited.
/// </summary>
public Action<PlaylistItem> RequestEdit;
/// <summary>
/// The currently-selected item, used to show a border around this item.
/// May be updated by this item if <see cref="AllowSelection"/> is <c>true</c>.
/// </summary>
public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>();
public readonly PlaylistItem Item;
private readonly DelayedLoadWrapper onScreenLoader = new DelayedLoadWrapper(Empty) { RelativeSizeAxes = Axes.Both };
private readonly IBindable<bool> valid = new Bindable<bool>();
private readonly Bindable<IBeatmapInfo> beatmap = new Bindable<IBeatmapInfo>();
private readonly Bindable<IRulesetInfo> ruleset = new Bindable<IRulesetInfo>();
private readonly BindableList<Mod> requiredMods = new BindableList<Mod>();
private Container maskingContainer;
private Container difficultyIconContainer;
private LinkFlowContainer beatmapText;
private LinkFlowContainer authorText;
private ExplicitContentBeatmapPill explicitContentPill;
private ModDisplay modDisplay;
private FillFlowContainer buttonsFlow;
private UpdateableAvatar ownerAvatar;
private readonly IBindable<bool> valid = new Bindable<bool>();
private readonly Bindable<IBeatmapInfo> beatmap = new Bindable<IBeatmapInfo>();
private readonly Bindable<IRulesetInfo> ruleset = new Bindable<IRulesetInfo>();
private readonly BindableList<Mod> requiredMods = new BindableList<Mod>();
public readonly PlaylistItem Item;
private Drawable showResultsButton;
private Drawable editButton;
private Drawable removeButton;
private PanelBackground panelBackground;
private FillFlowContainer mainFillFlow;
[Resolved]
private OsuColour colours { get; set; }
@@ -66,29 +90,21 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved]
private UserLookupCache userLookupCache { get; set; }
private readonly bool allowEdit;
private readonly bool allowSelection;
private readonly bool showItemOwner;
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; }
protected override bool ShouldBeConsideredForInput(Drawable child) => allowEdit || !allowSelection || SelectedItem.Value == Model;
protected override bool ShouldBeConsideredForInput(Drawable child) => AllowReordering || AllowDeletion || !AllowSelection || SelectedItem.Value == Model;
public DrawableRoomPlaylistItem(PlaylistItem item, bool allowEdit, bool allowSelection, bool showItemOwner)
public DrawableRoomPlaylistItem(PlaylistItem item)
: base(item)
{
Item = item;
// TODO: edit support should be moved out into a derived class
this.allowEdit = allowEdit;
this.allowSelection = allowSelection;
this.showItemOwner = showItemOwner;
beatmap.BindTo(item.Beatmap);
valid.BindTo(item.Valid);
ruleset.BindTo(item.Ruleset);
requiredMods.BindTo(item.RequiredMods);
ShowDragHandle.Value = allowEdit;
if (item.Expired)
Colour = OsuColour.Gray(0.5f);
}
@@ -96,9 +112,6 @@ namespace osu.Game.Screens.OnlinePlay
[BackgroundDependencyLoader]
private void load()
{
if (!allowEdit)
HandleColour = HandleColour.Opacity(0);
maskingContainer.BorderColour = colours.Yellow;
}
@@ -130,10 +143,115 @@ namespace osu.Game.Screens.OnlinePlay
valid.BindValueChanged(_ => Scheduler.AddOnce(refresh));
requiredMods.CollectionChanged += (_, __) => Scheduler.AddOnce(refresh);
onScreenLoader.DelayedLoadStarted += _ =>
{
Task.Run(async () =>
{
try
{
if (showItemOwner)
{
var foundUser = await userLookupCache.GetUserAsync(Item.OwnerID).ConfigureAwait(false);
Schedule(() => ownerAvatar.User = foundUser);
}
if (Item.Beatmap.Value == null)
{
var foundBeatmap = await beatmapLookupCache.GetBeatmapAsync(Item.BeatmapID).ConfigureAwait(false);
Schedule(() => Item.Beatmap.Value = foundBeatmap);
}
}
catch (Exception e)
{
Logger.Log($"Error while populating playlist item {e}");
}
});
};
refresh();
}
private PanelBackground panelBackground;
/// <summary>
/// Whether this item can be selected.
/// </summary>
public bool AllowSelection { get; set; }
/// <summary>
/// Whether this item can be reordered in the playlist.
/// </summary>
public bool AllowReordering
{
get => ShowDragHandle.Value;
set => ShowDragHandle.Value = value;
}
private bool allowDeletion;
/// <summary>
/// Whether this item can be deleted.
/// </summary>
public bool AllowDeletion
{
get => allowDeletion;
set
{
allowDeletion = value;
if (removeButton != null)
removeButton.Alpha = value ? 1 : 0;
}
}
private bool allowShowingResults;
/// <summary>
/// Whether this item can have results shown.
/// </summary>
public bool AllowShowingResults
{
get => allowShowingResults;
set
{
allowShowingResults = value;
if (showResultsButton != null)
showResultsButton.Alpha = value ? 1 : 0;
}
}
private bool allowEditing;
/// <summary>
/// Whether this item can be edited.
/// </summary>
public bool AllowEditing
{
get => allowEditing;
set
{
allowEditing = value;
if (editButton != null)
editButton.Alpha = value ? 1 : 0;
}
}
private bool showItemOwner;
/// <summary>
/// Whether to display the avatar of the user which owns this playlist item.
/// </summary>
public bool ShowItemOwner
{
get => showItemOwner;
set
{
showItemOwner = value;
if (ownerAvatar != null)
ownerAvatar.Alpha = value ? 1 : 0;
}
}
private void refresh()
{
@@ -143,22 +261,22 @@ namespace osu.Game.Screens.OnlinePlay
maskingContainer.BorderColour = colours.Red;
}
if (showItemOwner)
{
ownerAvatar.Show();
userLookupCache.GetUserAsync(Item.OwnerID)
.ContinueWith(u => Schedule(() => ownerAvatar.User = u.Result), TaskContinuationOptions.OnlyOnRanToCompletion);
}
difficultyIconContainer.Child = new DifficultyIcon(Item.Beatmap.Value, ruleset.Value, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(ICON_HEIGHT) };
if (Item.Beatmap.Value != null)
difficultyIconContainer.Child = new DifficultyIcon(Item.Beatmap.Value, ruleset.Value, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(icon_height) };
else
difficultyIconContainer.Clear();
panelBackground.Beatmap.Value = Item.Beatmap.Value;
beatmapText.Clear();
beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineID.ToString(), null, text =>
if (Item.Beatmap.Value != null)
{
text.Truncate = true;
});
beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineID.ToString(), null, text =>
{
text.Truncate = true;
});
}
authorText.Clear();
@@ -168,10 +286,16 @@ namespace osu.Game.Screens.OnlinePlay
authorText.AddUserLink(Item.Beatmap.Value.Metadata.Author);
}
bool hasExplicitContent = (Item.Beatmap.Value.BeatmapSet as IBeatmapSetOnlineInfo)?.HasExplicitContent == true;
bool hasExplicitContent = (Item.Beatmap.Value?.BeatmapSet as IBeatmapSetOnlineInfo)?.HasExplicitContent == true;
explicitContentPill.Alpha = hasExplicitContent ? 1 : 0;
modDisplay.Current.Value = requiredMods.ToArray();
buttonsFlow.Clear();
buttonsFlow.ChildrenEnumerable = createButtons();
difficultyIconContainer.FadeInFromZero(500, Easing.OutQuint);
mainFillFlow.FadeInFromZero(500, Easing.OutQuint);
}
protected override Drawable CreateContent()
@@ -192,6 +316,7 @@ namespace osu.Game.Screens.OnlinePlay
Alpha = 0,
AlwaysPresent = true
},
onScreenLoader,
panelBackground = new PanelBackground
{
RelativeSizeAxes = Axes.Both,
@@ -217,7 +342,7 @@ namespace osu.Game.Screens.OnlinePlay
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Left = 8, Right = 8 },
},
new FillFlowContainer
mainFillFlow = new FillFlowContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
@@ -273,7 +398,7 @@ namespace osu.Game.Screens.OnlinePlay
}
}
},
new FillFlowContainer
buttonsFlow = new FillFlowContainer
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
@@ -281,7 +406,7 @@ namespace osu.Game.Screens.OnlinePlay
Margin = new MarginPadding { Horizontal = 8 },
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(5),
ChildrenEnumerable = CreateButtons().Select(button => button.With(b =>
ChildrenEnumerable = createButtons().Select(button => button.With(b =>
{
b.Anchor = Anchor.Centre;
b.Origin = Anchor.Centre;
@@ -291,11 +416,11 @@ namespace osu.Game.Screens.OnlinePlay
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(ICON_HEIGHT),
Size = new Vector2(icon_height),
Margin = new MarginPadding { Right = 8 },
Masking = true,
CornerRadius = 4,
Alpha = showItemOwner ? 1 : 0
Alpha = ShowItemOwner ? 1 : 0
},
}
}
@@ -304,38 +429,53 @@ namespace osu.Game.Screens.OnlinePlay
};
}
protected virtual IEnumerable<Drawable> CreateButtons() =>
new Drawable[]
private IEnumerable<Drawable> createButtons() => new[]
{
showResultsButton = new GrayButton(FontAwesome.Solid.ChartPie)
{
new PlaylistDownloadButton(Item),
new PlaylistRemoveButton
{
Size = new Vector2(30, 30),
Alpha = allowEdit ? 1 : 0,
Action = () => RequestDeletion?.Invoke(Model),
},
};
Size = new Vector2(30, 30),
Action = () => RequestResults?.Invoke(Item),
Alpha = AllowShowingResults ? 1 : 0,
TooltipText = "View results"
},
Item.Beatmap.Value == null ? Empty() : new PlaylistDownloadButton(Item),
editButton = new PlaylistEditButton
{
Size = new Vector2(30, 30),
Alpha = AllowEditing ? 1 : 0,
Action = () => RequestEdit?.Invoke(Item),
TooltipText = "Edit"
},
removeButton = new PlaylistRemoveButton
{
Size = new Vector2(30, 30),
Alpha = AllowDeletion ? 1 : 0,
Action = () => RequestDeletion?.Invoke(Item),
TooltipText = "Remove from playlist"
},
};
protected override bool OnClick(ClickEvent e)
{
if (AllowSelection && valid.Value)
SelectedItem.Value = Model;
return true;
}
public class PlaylistEditButton : GrayButton
{
public PlaylistEditButton()
: base(FontAwesome.Solid.Edit)
{
}
}
public class PlaylistRemoveButton : GrayButton
{
public PlaylistRemoveButton()
: base(FontAwesome.Solid.MinusSquare)
{
TooltipText = "Remove from playlist";
}
[BackgroundDependencyLoader]
private void load()
{
Icon.Scale = new Vector2(0.8f);
}
}
protected override bool OnClick(ClickEvent e)
{
if (allowSelection && valid.Value)
SelectedItem.Value = Model;
return true;
}
private sealed class PlaylistDownloadButton : BeatmapDownloadButton
@@ -1,69 +0,0 @@
// 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;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Rooms;
namespace osu.Game.Screens.OnlinePlay
{
public class DrawableRoomPlaylistWithResults : DrawableRoomPlaylist
{
public Action<PlaylistItem> RequestShowResults;
private readonly bool showItemOwner;
public DrawableRoomPlaylistWithResults(bool showItemOwner = false)
: base(false, true, showItemOwner: showItemOwner)
{
this.showItemOwner = showItemOwner;
}
protected override OsuRearrangeableListItem<PlaylistItem> CreateOsuDrawable(PlaylistItem item) =>
new DrawableRoomPlaylistItemWithResults(item, false, true, showItemOwner)
{
RequestShowResults = () => RequestShowResults(item),
SelectedItem = { BindTarget = SelectedItem },
};
private class DrawableRoomPlaylistItemWithResults : DrawableRoomPlaylistItem
{
public Action RequestShowResults;
public DrawableRoomPlaylistItemWithResults(PlaylistItem item, bool allowEdit, bool allowSelection, bool showItemOwner)
: base(item, allowEdit, allowSelection, showItemOwner)
{
}
protected override IEnumerable<Drawable> CreateButtons() =>
base.CreateButtons().Prepend(new FilledIconButton
{
Icon = FontAwesome.Solid.ChartPie,
Action = () => RequestShowResults?.Invoke(),
TooltipText = "View results"
});
private class FilledIconButton : IconButton
{
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Add(new Box
{
RelativeSizeAxes = Axes.Both,
Depth = float.MaxValue,
Colour = colours.Gray4,
});
}
}
}
}
}
@@ -1,6 +1,7 @@
// 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.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@@ -184,20 +185,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Children = new Drawable[]
{
new PlaylistCountPill
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new StarRatingRangeDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Scale = new Vector2(0.8f)
}
}
ChildrenEnumerable = CreateBottomDetails()
}
}
},
@@ -287,6 +275,37 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
protected virtual Drawable CreateBackground() => new OnlinePlayBackgroundSprite();
protected virtual IEnumerable<Drawable> CreateBottomDetails()
{
var pills = new List<Drawable>();
if (Room.Type.Value != MatchType.Playlists)
{
pills.AddRange(new OnlinePlayComposite[]
{
new MatchTypePill(),
new QueueModePill(),
});
}
pills.AddRange(new Drawable[]
{
new PlaylistCountPill
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new StarRatingRangeDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Scale = new Vector2(0.8f)
}
});
return pills;
}
private class RoomNameText : OsuSpriteText
{
[Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))]
@@ -0,0 +1,50 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Online.Rooms;
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
public class MatchTypePill : OnlinePlayComposite
{
private OsuTextFlowContainer textFlow;
public MatchTypePill()
{
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new PillContainer
{
Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12))
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Type.BindValueChanged(onMatchTypeChanged, true);
}
private void onMatchTypeChanged(ValueChangedEvent<MatchType> type)
{
textFlow.Clear();
textFlow.AddText(type.NewValue.GetLocalisableDescription());
}
}
}
@@ -0,0 +1,50 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Online.Multiplayer;
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
public class QueueModePill : OnlinePlayComposite
{
private OsuTextFlowContainer textFlow;
public QueueModePill()
{
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new PillContainer
{
Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12))
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
QueueMode.BindValueChanged(onQueueModeChanged, true);
}
private void onQueueModeChanged(ValueChangedEvent<QueueMode> mode)
{
textFlow.Clear();
textFlow.AddText(mode.NewValue.GetLocalisableDescription());
}
}
}
@@ -248,7 +248,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Spacing = new Vector2(5),
Children = new Drawable[]
{
drawablePlaylist = new DrawableRoomPlaylist(false, false)
drawablePlaylist = new DrawableRoomPlaylist
{
RelativeSizeAxes = Axes.X,
Height = DrawableRoomPlaylistItem.HEIGHT
@@ -16,8 +16,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
public class MultiplayerHistoryList : DrawableRoomPlaylist
{
public MultiplayerHistoryList()
: base(false, false, true)
{
ShowItemOwners = true;
}
protected override FillFlowContainer<RearrangeableListItem<PlaylistItem>> CreateListFillFlowContainer() => new HistoryFillFlowContainer
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@@ -19,6 +20,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
{
public readonly Bindable<MultiplayerPlaylistDisplayMode> DisplayMode = new Bindable<MultiplayerPlaylistDisplayMode>();
/// <summary>
/// Invoked when an item requests to be edited.
/// </summary>
public Action<PlaylistItem> RequestEdit;
private MultiplayerQueueList queueList;
private MultiplayerHistoryList historyList;
private bool firstPopulation = true;
@@ -46,7 +52,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
queueList = new MultiplayerQueueList
{
RelativeSizeAxes = Axes.Both,
SelectedItem = { BindTarget = SelectedItem }
SelectedItem = { BindTarget = SelectedItem },
RequestEdit = item => RequestEdit?.Invoke(item)
},
historyList = new MultiplayerHistoryList
{
@@ -7,6 +7,9 @@ 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.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osuTK;
@@ -18,8 +21,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
public class MultiplayerQueueList : DrawableRoomPlaylist
{
public MultiplayerQueueList()
: base(false, false, true)
{
ShowItemOwners = true;
}
protected override FillFlowContainer<RearrangeableListItem<PlaylistItem>> CreateListFillFlowContainer() => new QueueFillFlowContainer
@@ -27,6 +30,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
Spacing = new Vector2(0, 2)
};
protected override DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new QueuePlaylistItem(item);
private class QueueFillFlowContainer : FillFlowContainer<RearrangeableListItem<PlaylistItem>>
{
[Resolved(typeof(Room), nameof(Room.Playlist))]
@@ -40,5 +45,44 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
public override IEnumerable<Drawable> FlowingChildren => base.FlowingChildren.OfType<RearrangeableListItem<PlaylistItem>>().OrderBy(item => item.Model.PlaylistOrder);
}
private class QueuePlaylistItem : DrawableRoomPlaylistItem
{
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
private MultiplayerClient multiplayerClient { get; set; }
[Resolved(typeof(Room), nameof(Room.Host))]
private Bindable<APIUser> host { get; set; }
[Resolved(typeof(Room), nameof(Room.QueueMode))]
private Bindable<QueueMode> queueMode { get; set; }
public QueuePlaylistItem(PlaylistItem item)
: base(item)
{
}
protected override void LoadComplete()
{
base.LoadComplete();
RequestDeletion = item => multiplayerClient.RemovePlaylistItem(item.ID);
host.BindValueChanged(_ => updateDeleteButtonVisibility());
queueMode.BindValueChanged(_ => updateDeleteButtonVisibility());
SelectedItem.BindValueChanged(_ => updateDeleteButtonVisibility(), true);
}
private void updateDeleteButtonVisibility()
{
bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost;
AllowDeletion = isItemOwner && SelectedItem.Value != Item;
AllowEditing = isItemOwner;
}
}
}
}
@@ -4,6 +4,7 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using osu.Framework.Allocation;
using osu.Framework.Logging;
@@ -24,17 +25,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
[Resolved]
private MultiplayerClient client { get; set; }
private readonly PlaylistItem itemToEdit;
private LoadingLayer loadingLayer;
/// <summary>
/// Construct a new instance of multiplayer song select.
/// </summary>
/// <param name="room">The room.</param>
/// <param name="itemToEdit">The item to be edited. May be null, in which case a new item will be added to the playlist.</param>
/// <param name="beatmap">An optional initial beatmap selection to perform.</param>
/// <param name="ruleset">An optional initial ruleset selection to perform.</param>
public MultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null)
public MultiplayerMatchSongSelect(Room room, PlaylistItem itemToEdit = null, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null)
: base(room)
{
this.itemToEdit = itemToEdit;
if (beatmap != null || ruleset != null)
{
Schedule(() =>
@@ -59,14 +65,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
loadingLayer.Show();
client.AddPlaylistItem(new MultiplayerPlaylistItem
var multiplayerItem = new MultiplayerPlaylistItem
{
ID = itemToEdit?.ID ?? 0,
BeatmapID = item.BeatmapID,
BeatmapChecksum = item.Beatmap.Value.MD5Hash,
RulesetID = item.RulesetID,
RequiredMods = item.RequiredMods.Select(m => new APIMod(m)).ToArray(),
AllowedMods = item.AllowedMods.Select(m => new APIMod(m)).ToArray()
}).ContinueWith(t =>
};
Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem);
task.ContinueWith(t =>
{
Schedule(() =>
{
@@ -14,7 +14,6 @@ using osu.Framework.Screens;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
@@ -44,8 +43,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
public override string ShortTitle => "room";
public OsuButton AddOrEditPlaylistButton { get; private set; }
[Resolved]
private MultiplayerClient client { get; set; }
@@ -57,6 +54,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
[CanBeNull]
private IDisposable readyClickOperation;
private AddItemButton addItemButton;
public MultiplayerMatchSubScreen(Room room)
: base(room)
{
@@ -134,12 +133,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
new Drawable[] { new OverlinedHeader("Beatmap") },
new Drawable[]
{
AddOrEditPlaylistButton = new PurpleTriangleButton
addItemButton = new AddItemButton
{
RelativeSizeAxes = Axes.X,
Height = 40,
Action = SelectBeatmap,
Alpha = 0
Text = "Add item",
Action = () => OpenSongSelection()
},
},
null,
@@ -147,7 +146,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
new MultiplayerPlaylist
{
RelativeSizeAxes = Axes.Both
RelativeSizeAxes = Axes.Both,
RequestEdit = OpenSongSelection
}
},
new[]
@@ -220,12 +220,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
}
};
internal void SelectBeatmap()
/// <summary>
/// Opens the song selection screen to add or edit an item.
/// </summary>
/// <param name="itemToEdit">An optional playlist item to edit. If null, a new item will be added instead.</param>
internal void OpenSongSelection([CanBeNull] PlaylistItem itemToEdit = null)
{
if (!this.IsCurrentScreen())
return;
this.Push(new MultiplayerMatchSongSelect(Room));
this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit));
}
protected override Drawable CreateFooter() => new MultiplayerMatchFooter
@@ -385,23 +389,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
return;
}
switch (client.Room.Settings.QueueMode)
{
case QueueMode.HostOnly:
AddOrEditPlaylistButton.Text = "Edit beatmap";
AddOrEditPlaylistButton.Alpha = client.IsHost ? 1 : 0;
break;
case QueueMode.AllPlayers:
case QueueMode.AllPlayersRoundRobin:
AddOrEditPlaylistButton.Text = "Add beatmap";
AddOrEditPlaylistButton.Alpha = 1;
break;
default:
AddOrEditPlaylistButton.Alpha = 0;
break;
}
addItemButton.Alpha = client.IsHost || Room.QueueMode.Value != QueueMode.HostOnly ? 1 : 0;
Scheduler.AddOnce(UpdateMods);
}
@@ -466,7 +454,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
return;
}
this.Push(new MultiplayerMatchSongSelect(Room, beatmap, ruleset));
this.Push(new MultiplayerMatchSongSelect(Room, SelectedItem.Value, beatmap, ruleset));
}
protected override void Dispose(bool isDisposing)
@@ -481,5 +469,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
modSettingChangeTracker?.Dispose();
}
public class AddItemButton : PurpleTriangleButton
{
}
}
}
@@ -77,7 +77,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
else
{
// Remove panels for users no longer in the room.
panels.RemoveAll(p => !Room.Users.Contains(p.User));
foreach (var p in panels)
{
// Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run.
if (Room.Users.All(u => !ReferenceEquals(p.User, u)))
p.Expire();
}
// Add panels for all users new to the room.
foreach (var user in Room.Users.Except(panels.Select(p => p.User)))
@@ -33,14 +33,14 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved(typeof(Room), nameof(Room.Playlist))]
protected BindableList<PlaylistItem> Playlist { get; private set; }
[CanBeNull]
[Resolved(CanBeNull = true)]
protected IBindable<PlaylistItem> SelectedItem { get; private set; }
protected override UserActivity InitialActivity => new UserActivity.InLobby(room);
protected readonly Bindable<IReadOnlyList<Mod>> FreeMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
[CanBeNull]
[Resolved(CanBeNull = true)]
private IBindable<PlaylistItem> selectedItem { get; set; }
private readonly FreeModSelectOverlay freeModSelectOverlay;
private readonly Room room;
@@ -80,8 +80,8 @@ namespace osu.Game.Screens.OnlinePlay
// At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods.
// Similarly, freeMods is currently empty but should only contain the allowed mods.
Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty<Mod>();
FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty<Mod>();
Mods.Value = SelectedItem?.Value?.RequiredMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty<Mod>();
FreeMods.Value = SelectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty<Mod>();
Mods.BindValueChanged(onModsChanged);
Ruleset.BindValueChanged(onRulesetChanged);
@@ -5,7 +5,9 @@ using System;
using System.Collections.Specialized;
using System.Linq;
using Humanizer;
using Humanizer.Localisation;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@@ -14,6 +16,8 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Match.Components;
@@ -69,6 +73,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
[Resolved(CanBeNull = true)]
private IRoomManager manager { get; set; }
[Resolved]
private IAPIProvider api { get; set; }
private readonly Room room;
public MatchSettings(Room room)
@@ -134,19 +141,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
Child = DurationField = new DurationDropdown
{
RelativeSizeAxes = Axes.X,
Items = new[]
{
TimeSpan.FromMinutes(30),
TimeSpan.FromHours(1),
TimeSpan.FromHours(2),
TimeSpan.FromHours(4),
TimeSpan.FromHours(8),
TimeSpan.FromHours(12),
//TimeSpan.FromHours(16),
TimeSpan.FromHours(24),
TimeSpan.FromDays(3),
TimeSpan.FromDays(7)
}
}
},
new Section("Allowed attempts (across all playlist items)")
@@ -205,7 +199,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
{
new Drawable[]
{
playlist = new DrawableRoomPlaylist(true, false) { RelativeSizeAxes = Axes.Both }
playlist = new PlaylistsRoomSettingsPlaylist
{
RelativeSizeAxes = Axes.Both,
}
},
new Drawable[]
{
@@ -300,10 +297,40 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
MaxAttempts.BindValueChanged(count => MaxAttemptsField.Text = count.NewValue?.ToString(), true);
Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue ?? TimeSpan.FromMinutes(30), true);
api.LocalUser.BindValueChanged(populateDurations, true);
playlist.Items.BindTo(Playlist);
Playlist.BindCollectionChanged(onPlaylistChanged, true);
}
private void populateDurations(ValueChangedEvent<APIUser> user)
{
DurationField.Items = new[]
{
TimeSpan.FromMinutes(30),
TimeSpan.FromHours(1),
TimeSpan.FromHours(2),
TimeSpan.FromHours(4),
TimeSpan.FromHours(8),
TimeSpan.FromHours(12),
TimeSpan.FromHours(24),
TimeSpan.FromDays(3),
TimeSpan.FromDays(7),
TimeSpan.FromDays(14),
};
// TODO: show these in the interface at all times.
if (user.NewValue.IsSupporter)
{
// roughly correct (see https://github.com/Humanizr/Humanizer/blob/18167e56c082449cc4fe805b8429e3127a7b7f93/readme.md?plain=1#L427)
// if we want this to be more accurate we might consider sending an actual end time, not a time span. probably not required though.
const int days_in_month = 31;
DurationField.AddDropdownItem(TimeSpan.FromDays(days_in_month));
DurationField.AddDropdownItem(TimeSpan.FromDays(days_in_month * 3));
}
}
protected override void Update()
{
base.Update();
@@ -402,7 +429,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
Menu.MaxHeight = 100;
}
protected override LocalisableString GenerateItemText(TimeSpan item) => item.Humanize();
protected override LocalisableString GenerateItemText(TimeSpan item) => item.Humanize(maxUnit: TimeUnit.Month);
}
}
}
@@ -0,0 +1,29 @@
// 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.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
namespace osu.Game.Screens.OnlinePlay.Playlists
{
/// <summary>
/// A <see cref="DrawableRoomPlaylist"/> which is displayed during the setup stage of a playlists room.
/// </summary>
public class PlaylistsRoomSettingsPlaylist : DrawableRoomPlaylist
{
public PlaylistsRoomSettingsPlaylist()
{
AllowReordering = true;
AllowDeletion = true;
RequestDeletion = item =>
{
var nextItem = Items.GetNext(item);
Items.Remove(item);
SelectedItem.Value = nextItem ?? Items.LastOrDefault();
};
}
}
}
@@ -88,12 +88,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
new Drawable[] { new OverlinedPlaylistHeader(), },
new Drawable[]
{
new DrawableRoomPlaylistWithResults
new DrawableRoomPlaylist
{
RelativeSizeAxes = Axes.Both,
Items = { BindTarget = Room.Playlist },
SelectedItem = { BindTarget = SelectedItem },
RequestShowResults = item =>
AllowSelection = true,
AllowShowingResults = true,
RequestResults = item =>
{
Debug.Assert(RoomId.Value != null);
ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item, false));
+23 -20
View File
@@ -67,22 +67,32 @@ namespace osu.Game.Screens.Play.HUD
Scale = new Vector2(1.2f);
InternalChild = counterContainer = new Container
InternalChildren = new[]
{
AutoSizeAxes = Axes.Both,
AlwaysPresent = true,
Children = new[]
popOutCount = new LegacySpriteText(LegacyFont.Combo)
{
popOutCount = new LegacySpriteText(LegacyFont.Combo)
Alpha = 0,
Margin = new MarginPadding(0.05f),
Blending = BlendingParameters.Additive,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
BypassAutoSizeAxes = Axes.Both,
},
counterContainer = new Container
{
AutoSizeAxes = Axes.Both,
AlwaysPresent = true,
Children = new[]
{
Alpha = 0,
Margin = new MarginPadding(0.05f),
Blending = BlendingParameters.Additive,
},
displayedCountSpriteText = new LegacySpriteText(LegacyFont.Combo)
{
Alpha = 0,
},
displayedCountSpriteText = new LegacySpriteText(LegacyFont.Combo)
{
// Initial text and AlwaysPresent allow the counter to have a size before it first displays a combo.
// This is useful for display in the skin editor.
Text = formatCount(0),
AlwaysPresent = true,
Alpha = 0,
},
}
}
};
}
@@ -121,13 +131,6 @@ namespace osu.Game.Screens.Play.HUD
((IHasText)displayedCountSpriteText).Text = formatCount(Current.Value);
counterContainer.Anchor = Anchor;
counterContainer.Origin = Origin;
displayedCountSpriteText.Anchor = Anchor;
displayedCountSpriteText.Origin = Origin;
popOutCount.Anchor = Anchor;
popOutCount.Origin = Origin;
Current.BindValueChanged(combo => updateCount(combo.NewValue == 0), true);
}
+10 -4
View File
@@ -468,12 +468,14 @@ namespace osu.Game.Screens.Play
private int restartCount;
private const double volume_requirement = 0.05;
private void showMuteWarningIfNeeded()
{
if (!muteWarningShownOnce.Value)
{
// Checks if the notification has not been shown yet and also if master volume is muted, track/music volume is muted or if the whole game is muted.
if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= audioManager.Volume.MinValue || audioManager.VolumeTrack.Value <= audioManager.VolumeTrack.MinValue)
if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= volume_requirement || audioManager.VolumeTrack.Value <= volume_requirement)
{
notificationOverlay?.Post(new MutedNotification());
muteWarningShownOnce.Value = true;
@@ -487,7 +489,7 @@ namespace osu.Game.Screens.Play
public MutedNotification()
{
Text = "Your music volume is set to 0%! Click here to restore it.";
Text = "Your game volume is too low to hear anything! Click here to restore it.";
}
[BackgroundDependencyLoader]
@@ -501,8 +503,12 @@ namespace osu.Game.Screens.Play
notificationOverlay.Hide();
volumeOverlay.IsMuted.Value = false;
audioManager.Volume.SetDefault();
audioManager.VolumeTrack.SetDefault();
// Check values before resetting, as the user may have only had mute enabled, in which case we might not need to adjust volumes.
if (audioManager.Volume.Value <= volume_requirement)
audioManager.Volume.SetDefault();
if (audioManager.VolumeTrack.Value <= volume_requirement)
audioManager.VolumeTrack.SetDefault();
return true;
};
+4 -1
View File
@@ -228,7 +228,10 @@ namespace osu.Game.Screens.Play
onlineBeatmapRequest.Success += beatmapSet => Schedule(() =>
{
this.beatmapSet = beatmapSet;
beatmapPanelContainer.Child = new BeatmapCard(this.beatmapSet);
beatmapPanelContainer.Child = new BeatmapCard(this.beatmapSet)
{
Expanded = { Disabled = true }
};
checkForAutomaticDownload();
});
@@ -157,6 +157,8 @@ namespace osu.Game.Screens.Play
request.Success += s =>
{
score.ScoreInfo.OnlineScoreID = s.ID;
score.ScoreInfo.Position = s.Position;
scoreSubmissionSource.SetResult(true);
};
+48 -42
View File
@@ -163,7 +163,7 @@ namespace osu.Game.Screens.Select
private FillFlowContainer infoLabelContainer;
private Container bpmLabelContainer;
private readonly WorkingBeatmap beatmap;
private readonly WorkingBeatmap working;
private readonly RulesetInfo ruleset;
[Resolved]
@@ -171,10 +171,10 @@ namespace osu.Game.Screens.Select
private ModSettingChangeTracker settingChangeTracker;
public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset)
public WedgeInfoText(WorkingBeatmap working, RulesetInfo userRuleset)
{
this.beatmap = beatmap;
ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset;
this.working = working;
ruleset = userRuleset ?? working.BeatmapInfo.Ruleset;
}
private CancellationTokenSource cancellationSource;
@@ -183,8 +183,8 @@ namespace osu.Game.Screens.Select
[BackgroundDependencyLoader]
private void load(OsuColour colours, LocalisationManager localisation, BeatmapDifficultyCache difficultyCache)
{
var beatmapInfo = beatmap.BeatmapInfo;
var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata();
var beatmapInfo = working.BeatmapInfo;
var metadata = beatmapInfo.Metadata ?? working.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata();
RelativeSizeAxes = Axes.Both;
@@ -330,36 +330,9 @@ namespace osu.Game.Screens.Select
addInfoLabels();
}
private void setMetadata(string source)
protected override void LoadComplete()
{
ArtistLabel.Text = artistBinding.Value;
TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value;
}
private void addInfoLabels()
{
if (beatmap.Beatmap?.HitObjects?.Any() != true)
return;
infoLabelContainer.Children = new Drawable[]
{
new InfoLabel(new BeatmapStatistic
{
Name = "Length",
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length),
Content = beatmap.BeatmapInfo.Length.ToFormattedDuration().ToString(),
}),
bpmLabelContainer = new Container
{
AutoSizeAxes = Axes.Both,
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(20, 0),
Children = getRulesetInfoLabels()
}
};
base.LoadComplete();
mods.BindValueChanged(m =>
{
@@ -372,6 +345,38 @@ namespace osu.Game.Screens.Select
}, true);
}
private void setMetadata(string source)
{
ArtistLabel.Text = artistBinding.Value;
TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value;
}
private void addInfoLabels()
{
if (working.Beatmap?.HitObjects?.Any() != true)
return;
infoLabelContainer.Children = new Drawable[]
{
new InfoLabel(new BeatmapStatistic
{
Name = "Length",
CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length),
Content = working.BeatmapInfo.Length.ToFormattedDuration().ToString(),
}),
bpmLabelContainer = new Container
{
AutoSizeAxes = Axes.Both,
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(20, 0),
Children = getRulesetInfoLabels()
}
};
}
private InfoLabel[] getRulesetInfoLabels()
{
try
@@ -381,12 +386,12 @@ namespace osu.Game.Screens.Select
try
{
// Try to get the beatmap with the user's ruleset
playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty<Mod>());
playableBeatmap = working.GetPlayableBeatmap(ruleset, Array.Empty<Mod>());
}
catch (BeatmapInvalidForRulesetException)
{
// Can't be converted to the user's ruleset, so use the beatmap's own ruleset
playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset, Array.Empty<Mod>());
playableBeatmap = working.GetPlayableBeatmap(working.BeatmapInfo.Ruleset, Array.Empty<Mod>());
}
return playableBeatmap.GetStatistics().Select(s => new InfoLabel(s)).ToArray();
@@ -401,8 +406,9 @@ namespace osu.Game.Screens.Select
private void refreshBPMLabel()
{
var b = beatmap.Beatmap;
if (b == null)
var beatmap = working.Beatmap;
if (beatmap == null || bpmLabelContainer == null)
return;
// this doesn't consider mods which apply variable rates, yet.
@@ -410,9 +416,9 @@ namespace osu.Game.Screens.Select
foreach (var mod in mods.Value.OfType<IApplicableToRate>())
rate = mod.ApplyToRate(0, rate);
double bpmMax = b.ControlPointInfo.BPMMaximum * rate;
double bpmMin = b.ControlPointInfo.BPMMinimum * rate;
double mostCommonBPM = 60000 / b.GetMostCommonBeatLength() * rate;
double bpmMax = beatmap.ControlPointInfo.BPMMaximum * rate;
double bpmMin = beatmap.ControlPointInfo.BPMMinimum * rate;
double mostCommonBPM = 60000 / beatmap.GetMostCommonBeatLength() * rate;
string labelText = Precision.AlmostEquals(bpmMin, bpmMax)
? $"{bpmMin:0}"
+2 -2
View File
@@ -807,14 +807,14 @@ namespace osu.Game.Screens.Select
private void delete(BeatmapSetInfo beatmap)
{
if (beatmap == null || beatmap.ID <= 0) return;
if (beatmap == null || !beatmap.IsManaged) return;
dialogOverlay?.Push(new BeatmapDeleteDialog(beatmap));
}
private void clearScores(BeatmapInfo beatmapInfo)
{
if (beatmapInfo == null || beatmapInfo.ID <= 0) return;
if (beatmapInfo == null || !beatmapInfo.IsManaged) return;
dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmapInfo, () =>
// schedule done here rather than inside the dialog as the dialog may fade out and never callback.
+2
View File
@@ -57,5 +57,7 @@ namespace osu.Game.Skinning
string author = Creator == null ? string.Empty : $"({Creator})";
return $"{Name} {author}".Trim();
}
public bool IsManaged => ID > 0;
}
}
+2
View File
@@ -11,6 +11,8 @@ namespace osu.Game.Skinning
{
public int ID { get; set; }
public bool IsManaged => ID > 0;
public int SkinInfoID { get; set; }
public EFSkinInfo SkinInfo { get; set; }
+1 -1
View File
@@ -1,4 +1,4 @@
// 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.
using System;
@@ -314,31 +314,68 @@ namespace osu.Game.Tests.Visual.Multiplayer
item.OwnerID = userId;
switch (Room.Settings.QueueMode)
{
case QueueMode.HostOnly:
// In host-only mode, the current item is re-used.
item.ID = currentItem.ID;
item.PlaylistOrder = currentItem.PlaylistOrder;
serverSidePlaylist[currentIndex] = item;
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
// Note: Unlike the server, this is the easiest way to update the current item at this point.
await updateCurrentItem(Room, false).ConfigureAwait(false);
break;
default:
await addItem(item).ConfigureAwait(false);
// The current item can change as a result of an item being added. For example, if all items earlier in the queue were expired.
await updateCurrentItem(Room).ConfigureAwait(false);
break;
}
await addItem(item).ConfigureAwait(false);
await updateCurrentItem(Room).ConfigureAwait(false);
}
public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, item);
public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item)
{
Debug.Assert(Room != null);
Debug.Assert(APIRoom != null);
Debug.Assert(currentItem != null);
item.OwnerID = userId;
var existingItem = serverSidePlaylist.SingleOrDefault(i => i.ID == item.ID);
if (existingItem == null)
throw new InvalidOperationException("Attempted to change an item that doesn't exist.");
if (existingItem.OwnerID != userId && Room.Host?.UserID != LocalUser?.UserID)
throw new InvalidOperationException("Attempted to change an item which is not owned by the user.");
if (existingItem.Expired)
throw new InvalidOperationException("Attempted to change an item which has already been played.");
// Ensure the playlist order doesn't change.
item.PlaylistOrder = existingItem.PlaylistOrder;
serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item;
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
}
public override Task EditPlaylistItem(MultiplayerPlaylistItem item) => EditUserPlaylistItem(api.LocalUser.Value.OnlineID, item);
public async Task RemoveUserPlaylistItem(int userId, long playlistItemId)
{
Debug.Assert(Room != null);
Debug.Assert(APIRoom != null);
var item = serverSidePlaylist.Find(i => i.ID == playlistItemId);
if (item == null)
throw new InvalidOperationException("Item does not exist in the room.");
if (item == currentItem)
throw new InvalidOperationException("The room's current item cannot be removed.");
if (item.OwnerID != userId)
throw new InvalidOperationException("Attempted to remove an item which is not owned by the user.");
if (item.Expired)
throw new InvalidOperationException("Attempted to remove an item which has already been played.");
serverSidePlaylist.Remove(item);
await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false);
await updateCurrentItem(Room).ConfigureAwait(false);
}
public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, playlistItemId);
protected override Task<APIBeatmap> GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default)
{
IBeatmapSetInfo? set = roomManager.ServerSideRooms.SelectMany(r => r.Playlist)
@@ -438,11 +475,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
await updatePlaylistOrder(Room).ConfigureAwait(false);
}
private IEnumerable<MultiplayerPlaylistItem> upcomingItems => serverSidePlaylist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder);
private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true)
{
// Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item.
MultiplayerPlaylistItem nextItem = serverSidePlaylist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder).FirstOrDefault()
?? serverSidePlaylist.OrderByDescending(i => i.PlayedAt).First();
MultiplayerPlaylistItem nextItem = upcomingItems.FirstOrDefault() ?? serverSidePlaylist.OrderByDescending(i => i.PlayedAt).First();
currentIndex = serverSidePlaylist.IndexOf(nextItem);
@@ -464,32 +502,28 @@ namespace osu.Game.Tests.Visual.Multiplayer
break;
case QueueMode.AllPlayersRoundRobin:
orderedActiveItems = new List<MultiplayerPlaylistItem>();
var itemsByPriority = new List<(MultiplayerPlaylistItem item, int priority)>();
// Todo: This could probably be more efficient, likely at the cost of increased complexity.
// Number of "expired" or "used" items per player.
Dictionary<int, int> perUserCounts = serverSidePlaylist
.GroupBy(item => item.OwnerID)
.ToDictionary(group => group.Key, group => group.Count(item => item.Expired));
// We'll run a simulation over all items which are not expired ("unprocessed"). Expired items will not have their ordering updated.
List<MultiplayerPlaylistItem> unprocessedItems = serverSidePlaylist.Where(item => !item.Expired).ToList();
// In every iteration of the simulation, pick the first available item from the user with the lowest number of items in the queue to add to the result set.
// If multiple users have the same number of items in the queue, then the item with the lowest ID is chosen.
while (unprocessedItems.Count > 0)
// Assign a priority for items from each user, starting from 0 and increasing in order which the user added the items.
foreach (var group in room.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).GroupBy(item => item.OwnerID))
{
MultiplayerPlaylistItem candidateItem = unprocessedItems
.OrderBy(item => perUserCounts[item.OwnerID])
.ThenBy(item => item.ID)
.First();
unprocessedItems.Remove(candidateItem);
orderedActiveItems.Add(candidateItem);
perUserCounts[candidateItem.OwnerID]++;
int priority = 0;
itemsByPriority.AddRange(group.Select(item => (item, priority++)));
}
orderedActiveItems = itemsByPriority
// Order by each user's priority.
.OrderBy(i => i.priority)
// Many users will have the same priority of items, so attempt to break the tie by maintaining previous ordering.
// Suppose there are two users: User1 and User2. User1 adds two items, and then User2 adds a third. If the previous order is not maintained,
// then after playing the first item by User1, their second item will become priority=0 and jump to the front of the queue (because it was added first).
.ThenBy(i => i.item.PlaylistOrder)
// If there are still ties (normally shouldn't happen), break ties by making items added earlier go first.
// This could happen if e.g. the item orders get reset.
.ThenBy(i => i.item.ID)
.Select(i => i.item)
.ToList();
break;
}
@@ -4,9 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
@@ -89,15 +87,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay
getRoomRequest.TriggerSuccess(createResponseRoom(ServerSideRooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId), true));
return true;
case GetBeatmapSetRequest getBeatmapSetRequest:
var onlineReq = new GetBeatmapSetRequest(getBeatmapSetRequest.ID, getBeatmapSetRequest.Type);
onlineReq.Success += res => getBeatmapSetRequest.TriggerSuccess(res);
onlineReq.Failure += e => getBeatmapSetRequest.TriggerFailure(e);
// Get the online API from the game's dependencies.
game.Dependencies.Get<IAPIProvider>().Queue(onlineReq);
return true;
case CreateRoomScoreRequest createRoomScoreRequest:
createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 });
return true;
+5 -5
View File
@@ -20,7 +20,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="DiffPlex" Version="1.7.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.38" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.39" />
<PackageReference Include="Humanizer" Version="2.13.14" />
<PackageReference Include="MessagePack" Version="2.3.85" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="5.0.11" />
@@ -31,15 +31,15 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.NETCore.Targets" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.LocalisationAnalyser" Version="2021.725.0">
<PackageReference Include="ppy.LocalisationAnalyser" Version="2021.1210.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.7.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1206.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1210.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
<PackageReference Include="Sentry" Version="3.11.1" />
<PackageReference Include="SharpCompress" Version="0.30.0" />
<PackageReference Include="Sentry" Version="3.12.1" />
<PackageReference Include="SharpCompress" Version="0.30.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="TagLibSharp" Version="2.2.0" />
+2 -2
View File
@@ -60,7 +60,7 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1206.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1210.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
@@ -83,7 +83,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1206.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1210.0" />
<PackageReference Include="SharpCompress" Version="0.30.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="SharpRaven" Version="2.4.0" />
+2
View File
@@ -33,6 +33,8 @@
<true/>
<key>UIStatusBarHidden</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>We don&apos;t really use the camera.</string>
<key>NSMicrophoneUsageDescription</key>