1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-08 03:32:55 +08:00

Merge branch 'master' into pr

This commit is contained in:
NecoDev 2025-02-05 14:53:20 +08:00 committed by GitHub
commit 13fa49d5b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 2124 additions and 584 deletions

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.129.1" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.204.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -2,10 +2,13 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osuTK;
using osuTK.Input;
@ -20,12 +23,23 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
[Resolved]
private OsuHitObjectComposer? composer { get; set; }
[Resolved]
private EditorClock? editorClock { get; set; }
private Bindable<bool> limitedDistanceSnap { get; set; } = null!;
public HitCirclePlacementBlueprint()
: base(new HitCircle())
{
InternalChild = circlePiece = new HitCirclePiece();
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
limitedDistanceSnap = config.GetBindable<bool>(OsuSetting.EditorLimitedDistanceSnap);
}
protected override void LoadComplete()
{
base.LoadComplete();
@ -53,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime)
{
var result = composer?.TrySnapToNearbyObjects(screenSpacePosition, fallbackTime);
result ??= composer?.TrySnapToDistanceGrid(screenSpacePosition);
result ??= composer?.TrySnapToDistanceGrid(screenSpacePosition, limitedDistanceSnap.Value && editorClock != null ? editorClock.CurrentTime : null);
if (composer?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? screenSpacePosition, result?.Time ?? fallbackTime) is SnapResult gridSnapResult)
result = gridSnapResult;
result ??= new SnapResult(screenSpacePosition, fallbackTime);

View File

@ -21,6 +21,7 @@ using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
@ -55,6 +56,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider distanceSnapProvider { get; set; }
private Bindable<bool> limitedDistanceSnap { get; set; } = null!;
public PathControlPointVisualiser(T hitObject, bool allowSelection)
{
this.hitObject = hitObject;
@ -69,6 +72,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
};
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
limitedDistanceSnap = config.GetBindable<bool>(OsuSetting.EditorLimitedDistanceSnap);
}
protected override void LoadComplete()
{
base.LoadComplete();
@ -437,7 +446,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex]));
var result = positionSnapProvider?.TrySnapToNearbyObjects(newHeadPosition, oldStartTime);
result ??= positionSnapProvider?.TrySnapToDistanceGrid(newHeadPosition);
result ??= positionSnapProvider?.TrySnapToDistanceGrid(newHeadPosition, limitedDistanceSnap.Value ? oldStartTime : null);
if (positionSnapProvider?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? newHeadPosition, result?.Time ?? oldStartTime) is SnapResult gridSnapResult)
result = gridSnapResult;
result ??= new SnapResult(newHeadPosition, oldStartTime);

View File

@ -5,10 +5,12 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
@ -49,6 +51,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
[Resolved]
private FreehandSliderToolboxGroup? freehandToolboxGroup { get; set; }
[Resolved]
private EditorClock? editorClock { get; set; }
private Bindable<bool> limitedDistanceSnap { get; set; } = null!;
private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 };
protected override bool IsValidForPlacement => HitObject.Path.HasValidLength;
@ -63,7 +70,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
[BackgroundDependencyLoader]
private void load()
private void load(OsuConfigManager config)
{
InternalChildren = new Drawable[]
{
@ -74,6 +81,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
};
state = SliderPlacementState.Initial;
limitedDistanceSnap = config.GetBindable<bool>(OsuSetting.EditorLimitedDistanceSnap);
}
protected override void LoadComplete()
@ -109,7 +117,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime)
{
var result = composer?.TrySnapToNearbyObjects(screenSpacePosition, fallbackTime);
result ??= composer?.TrySnapToDistanceGrid(screenSpacePosition);
result ??= composer?.TrySnapToDistanceGrid(screenSpacePosition, limitedDistanceSnap.Value && editorClock != null ? editorClock.CurrentTime : null);
if (composer?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? screenSpacePosition, result?.Time ?? fallbackTime) is SnapResult gridSnapResult)
result = gridSnapResult;
result ??= new SnapResult(screenSpacePosition, fallbackTime);

View File

@ -3,7 +3,10 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
@ -17,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuBlueprintContainer : ComposeBlueprintContainer
{
private Bindable<bool> limitedDistanceSnap { get; set; } = null!;
public new OsuHitObjectComposer Composer => (OsuHitObjectComposer)base.Composer;
public OsuBlueprintContainer(OsuHitObjectComposer composer)
@ -24,6 +29,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
limitedDistanceSnap = config.GetBindable<bool>(OsuSetting.EditorLimitedDistanceSnap);
}
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new OsuSelectionHandler();
public override HitObjectSelectionBlueprint? CreateHitObjectBlueprintFor(HitObject hitObject)
@ -58,15 +69,15 @@ namespace osu.Game.Rulesets.Osu.Edit
// The final movement position, relative to movementBlueprintOriginalPosition.
Vector2 movePosition = blueprints.First().originalSnapPositions.First() + distanceTravelled;
var referenceBlueprint = blueprints.First().blueprint;
// Retrieve a snapped position.
var result = Composer.TrySnapToNearbyObjects(movePosition);
result ??= Composer.TrySnapToDistanceGrid(movePosition);
result ??= Composer.TrySnapToDistanceGrid(movePosition, limitedDistanceSnap.Value ? referenceBlueprint.Item.StartTime : null);
if (Composer.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? movePosition, result?.Time) is SnapResult gridSnapResult)
result = gridSnapResult;
result ??= new SnapResult(movePosition, null);
var referenceBlueprint = blueprints.First().blueprint;
bool moved = SelectionHandler.HandleMovement(new MoveSelectionEvent<HitObject>(referenceBlueprint, result.ScreenSpacePosition - referenceBlueprint.ScreenSpaceSelectionPoint));
if (moved)
ApplySnapResultTime(result, referenceBlueprint.Item.StartTime);

View File

@ -250,13 +250,13 @@ namespace osu.Game.Rulesets.Osu.Edit
}
[CanBeNull]
public SnapResult TrySnapToDistanceGrid(Vector2 screenSpacePosition)
public SnapResult TrySnapToDistanceGrid(Vector2 screenSpacePosition, double? fixedTime = null)
{
if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True || distanceSnapGrid == null)
return null;
var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition);
(Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition));
(Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition), fixedTime);
return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, playfield);
}

View File

@ -181,7 +181,7 @@ namespace osu.Game.Tests.Visual.Editing
}
}
public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition)
public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition, double? fixedTime = null)
=> (Vector2.Zero, 0);
}

View File

@ -60,14 +60,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
private void setUp()
{
AddStep("reset", () =>
AddStep("create song select", () =>
{
Ruleset.Value = new OsuRuleset().RulesetInfo;
Beatmap.SetDefault();
SelectedMods.SetDefault();
LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value!));
});
AddStep("create song select", () => LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value!)));
AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded);
}

View File

@ -30,6 +30,7 @@ using osu.Game.Screens.OnlinePlay;
using osu.Game.Screens.OnlinePlay.Match;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist;
using osu.Game.Screens.OnlinePlay.Multiplayer.Participants;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Resources;
@ -271,7 +272,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("last playlist item selected", () =>
{
var lastItem = this.ChildrenOfType<DrawableRoomPlaylistItem>().Single(p => p.Item.ID == MultiplayerClient.ServerAPIRoom?.Playlist.Last().ID);
var lastItem = this.ChildrenOfType<MultiplayerQueueList>()
.Single()
.ChildrenOfType<DrawableRoomPlaylistItem>()
.Single(p => p.Item.ID == MultiplayerClient.ServerAPIRoom?.Playlist.Last().ID);
return lastItem.IsSelectedItem;
});
}

View File

@ -308,6 +308,33 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("set state: locally available", () => MultiplayerClient.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable()));
}
[Test]
public void TestUserWithStyle()
{
AddStep("add users", () =>
{
MultiplayerClient.AddUser(new APIUser
{
Id = 0,
Username = "User 0",
RulesetsStatistics = new Dictionary<string, UserStatistics>
{
{
Ruleset.Value.ShortName,
new UserStatistics { GlobalRank = RNG.Next(1, 100000), }
}
},
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
});
MultiplayerClient.ChangeUserStyle(0, 259, 2);
});
AddStep("set beatmap locally available", () => MultiplayerClient.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable()));
AddStep("change user style to beatmap: 258, ruleset: 1", () => MultiplayerClient.ChangeUserStyle(0, 258, 1));
AddStep("change user style to beatmap: null, ruleset: null", () => MultiplayerClient.ChangeUserStyle(0, null, null));
}
[Test]
public void TestModOverlap()
{

View File

@ -21,6 +21,7 @@ using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Resources;
using osuTK.Graphics;
using osuTK.Input;
using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel;
namespace osu.Game.Tests.Visual.SongSelect
@ -53,7 +54,7 @@ namespace osu.Game.Tests.Visual.SongSelect
}
[SetUpSteps]
public void SetUpSteps()
public virtual void SetUpSteps()
{
RemoveAllBeatmaps();
@ -129,12 +130,61 @@ namespace osu.Game.Tests.Visual.SongSelect
});
}
protected void SortBy(FilterCriteria criteria) => AddStep($"sort by {criteria.Sort}", () => Carousel.Filter(criteria));
protected void SortBy(FilterCriteria criteria) => AddStep($"sort {criteria.Sort} group {criteria.Group}", () => Carousel.Filter(criteria));
protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType<ICarouselPanel>().Count(), () => Is.GreaterThan(0));
protected void WaitForSorting() => AddUntilStep("sorting finished", () => Carousel.IsFiltering, () => Is.False);
protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target));
protected void SelectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down));
protected void SelectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up));
protected void SelectNextGroup() => AddStep("select next group", () => InputManager.Key(Key.Right));
protected void SelectPrevGroup() => AddStep("select prev group", () => InputManager.Key(Key.Left));
protected void Select() => AddStep("select", () => InputManager.Key(Key.Enter));
protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null);
protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null);
protected void WaitForGroupSelection(int group, int panel)
{
AddUntilStep($"selected is group{group} panel{panel}", () =>
{
var groupingFilter = Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single();
GroupDefinition g = groupingFilter.GroupItems.Keys.ElementAt(group);
// offset by one because the group itself is included in the items list.
CarouselItem item = groupingFilter.GroupItems[g].ElementAt(panel + 1);
return ReferenceEquals(Carousel.CurrentSelection, item.Model);
});
}
protected void WaitForSelection(int set, int? diff = null)
{
AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () =>
{
if (diff != null)
return ReferenceEquals(Carousel.CurrentSelection, BeatmapSets[set].Beatmaps[diff.Value]);
return BeatmapSets[set].Beatmaps.Contains(Carousel.CurrentSelection);
});
}
protected void ClickVisiblePanel<T>(int index)
where T : Drawable
{
AddStep($"click panel at index {index}", () =>
{
Carousel.ChildrenOfType<UserTrackingScrollContainer>().Single()
.ChildrenOfType<T>()
.Where(p => ((ICarouselPanel)p).Item?.IsVisible == true)
.OrderBy(p => p.Y)
.ElementAt(index)
.TriggerClick();
});
}
/// <summary>
/// Add requested beatmap sets count to list.
/// </summary>

View File

@ -32,6 +32,13 @@ namespace osu.Game.Tests.Visual.SongSelect
RemoveAllBeatmaps();
}
[Test]
public void TestOffScreenLoading()
{
AddStep("disable masking", () => Scroll.Masking = false);
AddStep("enable masking", () => Scroll.Masking = true);
}
[Test]
public void TestAddRemoveOneByOne()
{
@ -43,7 +50,7 @@ namespace osu.Game.Tests.Visual.SongSelect
public void TestSorting()
{
AddBeatmaps(10);
SortBy(new FilterCriteria { Sort = SortMode.Difficulty });
SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty });
SortBy(new FilterCriteria { Sort = SortMode.Artist });
}

View File

@ -0,0 +1,158 @@
// 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 NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
namespace osu.Game.Tests.Visual.SongSelect
{
[TestFixture]
public partial class TestSceneBeatmapCarouselV2GroupSelection : BeatmapCarouselV2TestScene
{
public override void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty });
}
[Test]
public void TestOpenCloseGroupWithNoSelectionMouse()
{
AddBeatmaps(10, 5);
WaitForDrawablePanels();
AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
CheckNoSelection();
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
}
[Test]
public void TestOpenCloseGroupWithNoSelectionKeyboard()
{
AddBeatmaps(10, 5);
WaitForDrawablePanels();
AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
SelectNextPanel();
Select();
AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
AddAssert("keyboard selected is expanded", () => getKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
CheckNoSelection();
Select();
AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
AddAssert("keyboard selected is collapsed", () => getKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
CheckNoSelection();
GroupPanel? getKeyboardSelectedPanel() => Carousel.ChildrenOfType<GroupPanel>().SingleOrDefault(p => p.KeyboardSelected.Value);
}
[Test]
public void TestCarouselRemembersSelection()
{
AddBeatmaps(10);
WaitForDrawablePanels();
SelectNextGroup();
object? selection = null;
AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model);
CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
RemoveAllBeatmaps();
AddUntilStep("no drawable selection", getSelectedPanel, () => Is.Null);
AddBeatmaps(10);
WaitForDrawablePanels();
CheckHasSelection();
AddAssert("no drawable selection", getSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True);
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("carousel item not visible", getSelectedPanel, () => Is.Null);
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True);
BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapPanel>().SingleOrDefault(p => p.Selected.Value);
}
[Test]
public void TestGroupSelectionOnHeader()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextGroup();
WaitForGroupSelection(0, 0);
SelectPrevPanel();
SelectPrevGroup();
WaitForGroupSelection(2, 9);
}
[Test]
public void TestKeyboardSelection()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
CheckNoSelection();
// open first group
Select();
CheckNoSelection();
AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
SelectNextPanel();
Select();
WaitForGroupSelection(0, 0);
SelectNextGroup();
WaitForGroupSelection(0, 1);
SelectNextGroup();
WaitForGroupSelection(0, 2);
SelectPrevGroup();
WaitForGroupSelection(0, 1);
SelectPrevGroup();
WaitForGroupSelection(0, 0);
SelectPrevGroup();
WaitForGroupSelection(2, 9);
}
}
}

View File

@ -22,10 +22,10 @@ namespace osu.Game.Tests.Visual.SongSelect
{
AddBeatmaps(10);
WaitForDrawablePanels();
checkNoSelection();
CheckNoSelection();
select();
checkNoSelection();
Select();
CheckNoSelection();
AddStep("press down arrow", () => InputManager.PressKey(Key.Down));
checkSelectionIterating(false);
@ -39,8 +39,8 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up));
checkSelectionIterating(false);
select();
checkHasSelection();
Select();
CheckHasSelection();
}
/// <summary>
@ -52,7 +52,7 @@ namespace osu.Game.Tests.Visual.SongSelect
{
AddBeatmaps(10);
WaitForDrawablePanels();
checkNoSelection();
CheckNoSelection();
AddStep("press right arrow", () => InputManager.PressKey(Key.Right));
checkSelectionIterating(true);
@ -73,13 +73,13 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(10);
WaitForDrawablePanels();
selectNextGroup();
SelectNextGroup();
object? selection = null;
AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model);
checkHasSelection();
CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
@ -89,13 +89,14 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(10);
WaitForDrawablePanels();
checkHasSelection();
CheckHasSelection();
AddAssert("no drawable selection", getSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddAssert("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True);
BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapPanel>().SingleOrDefault(p => p.Selected.Value);
}
@ -108,10 +109,10 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(total_set_count);
WaitForDrawablePanels();
selectNextGroup();
waitForSelection(0, 0);
selectPrevGroup();
waitForSelection(total_set_count - 1, 0);
SelectNextGroup();
WaitForSelection(0, 0);
SelectPrevGroup();
WaitForSelection(total_set_count - 1, 0);
}
[Test]
@ -122,10 +123,25 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(total_set_count);
WaitForDrawablePanels();
selectPrevGroup();
waitForSelection(total_set_count - 1, 0);
selectNextGroup();
waitForSelection(0, 0);
SelectPrevGroup();
WaitForSelection(total_set_count - 1, 0);
SelectNextGroup();
WaitForSelection(0, 0);
}
[Test]
public void TestGroupSelectionOnHeader()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextGroup();
SelectNextGroup();
WaitForSelection(1, 0);
SelectPrevPanel();
SelectPrevGroup();
WaitForSelection(0, 0);
}
[Test]
@ -134,71 +150,50 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(10, 3);
WaitForDrawablePanels();
selectNextPanel();
selectNextPanel();
selectNextPanel();
selectNextPanel();
checkNoSelection();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
CheckNoSelection();
select();
waitForSelection(3, 0);
Select();
WaitForSelection(3, 0);
selectNextPanel();
waitForSelection(3, 0);
SelectNextPanel();
WaitForSelection(3, 0);
select();
waitForSelection(3, 1);
Select();
WaitForSelection(3, 1);
selectNextPanel();
waitForSelection(3, 1);
SelectNextPanel();
WaitForSelection(3, 1);
select();
waitForSelection(3, 2);
Select();
WaitForSelection(3, 2);
selectNextPanel();
waitForSelection(3, 2);
SelectNextPanel();
WaitForSelection(3, 2);
select();
waitForSelection(4, 0);
Select();
WaitForSelection(4, 0);
}
[Test]
public void TestEmptyTraversal()
{
selectNextPanel();
checkNoSelection();
SelectNextPanel();
CheckNoSelection();
selectNextGroup();
checkNoSelection();
SelectNextGroup();
CheckNoSelection();
selectPrevPanel();
checkNoSelection();
SelectPrevPanel();
CheckNoSelection();
selectPrevGroup();
checkNoSelection();
SelectPrevGroup();
CheckNoSelection();
}
private void waitForSelection(int set, int? diff = null)
{
AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () =>
{
if (diff != null)
return ReferenceEquals(Carousel.CurrentSelection, BeatmapSets[set].Beatmaps[diff.Value]);
return BeatmapSets[set].Beatmaps.Contains(Carousel.CurrentSelection);
});
}
private void selectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down));
private void selectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up));
private void selectNextGroup() => AddStep("select next group", () => InputManager.Key(Key.Right));
private void selectPrevGroup() => AddStep("select prev group", () => InputManager.Key(Key.Left));
private void select() => AddStep("select", () => InputManager.Key(Key.Enter));
private void checkNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null);
private void checkHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null);
private void checkSelectionIterating(bool isIterating)
{
object? selection = null;

View File

@ -6,6 +6,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
@ -27,87 +28,102 @@ namespace osu.Game.Tests.Visual.UserInterface
Child = new PopoverContainer
{
RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer
Child = new OsuScrollContainer
{
RelativeSizeAxes = Axes.Y,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 400,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5),
Padding = new MarginPadding(10),
Children = new Drawable[]
RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer
{
new FormTextBox
AutoSizeAxes = Axes.Y,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 400,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5),
Padding = new MarginPadding(10),
Children = new Drawable[]
{
Caption = "Artist",
HintText = "Poot artist here!",
PlaceholderText = "Here is an artist",
TabbableContentContainer = this,
},
new FormTextBox
{
Caption = "Artist",
HintText = "Poot artist here!",
PlaceholderText = "Here is an artist",
Current = { Disabled = true },
TabbableContentContainer = this,
},
new FormNumberBox(allowDecimals: true)
{
Caption = "Number",
HintText = "Insert your favourite number",
PlaceholderText = "Mine is 42!",
TabbableContentContainer = this,
},
new FormCheckBox
{
Caption = EditorSetupStrings.LetterboxDuringBreaks,
HintText = EditorSetupStrings.LetterboxDuringBreaksDescription,
},
new FormCheckBox
{
Caption = EditorSetupStrings.LetterboxDuringBreaks,
HintText = EditorSetupStrings.LetterboxDuringBreaksDescription,
Current = { Disabled = true },
},
new FormSliderBar<float>
{
Caption = "Slider",
Current = new BindableFloat
new FormTextBox
{
MinValue = 0,
MaxValue = 10,
Value = 5,
Precision = 0.1f,
Caption = "Artist",
HintText = "Poot artist here!",
PlaceholderText = "Here is an artist",
TabbableContentContainer = this,
},
TabbableContentContainer = this,
},
new FormEnumDropdown<CountdownType>
{
Caption = EditorSetupStrings.EnableCountdown,
HintText = EditorSetupStrings.CountdownDescription,
},
new FormFileSelector
{
Caption = "File selector",
PlaceholderText = "Select a file",
},
new FormBeatmapFileSelector(true)
{
Caption = "File selector with intermediate choice dialog",
PlaceholderText = "Select a file",
},
new FormColourPalette
{
Caption = "Combo colours",
Colours =
new FormTextBox
{
Colour4.Red,
Colour4.Green,
Colour4.Blue,
Colour4.Yellow,
}
Caption = "Artist",
HintText = "Poot artist here!",
PlaceholderText = "Here is an artist",
Current = { Disabled = true },
TabbableContentContainer = this,
},
new FormNumberBox(allowDecimals: true)
{
Caption = "Number",
HintText = "Insert your favourite number",
PlaceholderText = "Mine is 42!",
TabbableContentContainer = this,
},
new FormCheckBox
{
Caption = EditorSetupStrings.LetterboxDuringBreaks,
HintText = EditorSetupStrings.LetterboxDuringBreaksDescription,
},
new FormCheckBox
{
Caption = EditorSetupStrings.LetterboxDuringBreaks,
HintText = EditorSetupStrings.LetterboxDuringBreaksDescription,
Current = { Disabled = true },
},
new FormSliderBar<float>
{
Caption = "Slider",
Current = new BindableFloat
{
MinValue = 0,
MaxValue = 10,
Value = 5,
Precision = 0.1f,
},
TabbableContentContainer = this,
},
new FormEnumDropdown<CountdownType>
{
Caption = EditorSetupStrings.EnableCountdown,
HintText = EditorSetupStrings.CountdownDescription,
},
new FormFileSelector
{
Caption = "File selector",
PlaceholderText = "Select a file",
},
new FormBeatmapFileSelector(true)
{
Caption = "File selector with intermediate choice dialog",
PlaceholderText = "Select a file",
},
new FormColourPalette
{
Caption = "Combo colours",
Colours =
{
Colour4.Red,
Colour4.Green,
Colour4.Blue,
Colour4.Yellow,
}
},
new FormButton
{
Caption = "No text in button",
Action = () => { },
},
new FormButton
{
Caption = "Text in button which is pretty long and is very likely to wrap",
ButtonText = "Foo the bar",
Action = () => { },
},
},
},
},

View File

@ -447,60 +447,31 @@ namespace osu.Game.Beatmaps.Formats
private void addPathData(TextWriter writer, IHasPath pathData, Vector2 position)
{
PathType? lastType = null;
for (int i = 0; i < pathData.Path.ControlPoints.Count; i++)
{
PathControlPoint point = pathData.Path.ControlPoints[i];
// Note that lazer's encoding format supports specifying multiple curve types for a slider path, which is not supported by stable.
// Backwards compatibility with stable is handled by `LegacyBeatmapExporter` and `BezierConverter.ConvertToModernBezier()`.
if (point.Type != null)
{
// We've reached a new (explicit) segment!
// Explicit segments have a new format in which the type is injected into the middle of the control point string.
// To preserve compatibility with osu-stable as much as possible, explicit segments with the same type are converted to use implicit segments by duplicating the control point.
// One exception are consecutive perfect curves, which aren't supported in osu!stable and can lead to decoding issues if encoded as implicit segments
bool needsExplicitSegment = point.Type != lastType || point.Type == PathType.PERFECT_CURVE || i == pathData.Path.ControlPoints.Count - 1;
// Another exception to this is when the last two control points of the last segment were duplicated. This is not a scenario supported by osu!stable.
// Lazer does not add implicit segments for the last two control points of _any_ explicit segment, so an explicit segment is forced in order to maintain consistency with the decoder.
if (i > 1)
switch (point.Type?.Type)
{
// We need to use the absolute control point position to determine equality, otherwise floating point issues may arise.
Vector2 p1 = position + pathData.Path.ControlPoints[i - 1].Position;
Vector2 p2 = position + pathData.Path.ControlPoints[i - 2].Position;
case SplineType.BSpline:
writer.Write(point.Type.Value.Degree > 0 ? $"B{point.Type.Value.Degree}|" : "B|");
break;
if ((int)p1.X == (int)p2.X && (int)p1.Y == (int)p2.Y)
needsExplicitSegment = true;
}
case SplineType.Catmull:
writer.Write("C|");
break;
if (needsExplicitSegment)
{
switch (point.Type?.Type)
{
case SplineType.BSpline:
writer.Write(point.Type.Value.Degree > 0 ? $"B{point.Type.Value.Degree}|" : "B|");
break;
case SplineType.PerfectCurve:
writer.Write("P|");
break;
case SplineType.Catmull:
writer.Write("C|");
break;
case SplineType.PerfectCurve:
writer.Write("P|");
break;
case SplineType.Linear:
writer.Write("L|");
break;
}
lastType = point.Type;
}
else
{
// New segment with the same type - duplicate the control point
writer.Write(FormattableString.Invariant($"{position.X + point.Position.X}:{position.Y + point.Position.Y}|"));
case SplineType.Linear:
writer.Write("L|");
break;
}
}

View File

@ -203,6 +203,8 @@ namespace osu.Game.Beatmaps
{
try
{
// TODO: This is a touch expensive and can become an issue if being accessed every Update call.
// Optimally we would not involve the async flow if things are already loaded.
return loadBeatmapAsync().GetResultSafely();
}
catch (AggregateException ae)

View File

@ -120,18 +120,30 @@ namespace osu.Game.Database
if (BezierConverter.CountSegments(hasPath.Path.ControlPoints) <= 1
&& hasPath.Path.ControlPoints[0].Type!.Value.Degree == null) continue;
var newControlPoints = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints);
// Truncate control points to integer positions
foreach (var pathControlPoint in newControlPoints)
{
pathControlPoint.Position = new Vector2(
(float)Math.Floor(pathControlPoint.Position.X),
(float)Math.Floor(pathControlPoint.Position.Y));
}
var convertedToBezier = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints);
hasPath.Path.ControlPoints.Clear();
hasPath.Path.ControlPoints.AddRange(newControlPoints);
for (int i = 0; i < convertedToBezier.Count; i++)
{
var convertedPoint = convertedToBezier[i];
// Truncate control points to integer positions
var position = new Vector2(
(float)Math.Floor(convertedPoint.Position.X),
(float)Math.Floor(convertedPoint.Position.Y));
// stable only supports a single curve type specification per slider.
// we exploit the fact that the converted-to-Bézier path only has Bézier segments,
// and thus we specify the Bézier curve type once ever at the start of the slider.
hasPath.Path.ControlPoints.Add(new PathControlPoint(position, i == 0 ? PathType.BEZIER : null));
// however, the Bézier path as output by the converter has multiple segments.
// `LegacyBeatmapEncoder` will attempt to encode this by emitting per-control-point curve type specs which don't do anything for stable.
// instead, stable expects control points that start a segment to be present in the path twice in succession.
if (convertedPoint.Type == PathType.BEZIER && i > 0)
hasPath.Path.ControlPoints.Add(new PathControlPoint(position));
}
}
// Encode to legacy format

View File

@ -0,0 +1,189 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Graphics.UserInterfaceV2
{
public partial class FormButton : CompositeDrawable
{
/// <summary>
/// Caption describing this button, displayed on the left of it.
/// </summary>
public LocalisableString Caption { get; init; }
public LocalisableString ButtonText { get; init; }
public Action? Action { get; init; }
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.X;
Height = 50;
Masking = true;
CornerRadius = 5;
CornerExponent = 2.5f;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5,
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Left = 9,
Right = 5,
Vertical = 5,
},
Children = new Drawable[]
{
new OsuTextFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Width = 0.45f,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Text = Caption,
},
new Button
{
Action = Action,
Text = ButtonText,
RelativeSizeAxes = ButtonText == default ? Axes.None : Axes.X,
Width = ButtonText == default ? 90 : 0.45f,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
}
},
},
};
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
updateState();
}
private void updateState()
{
BorderThickness = IsHovered ? 2 : 0;
if (IsHovered)
BorderColour = colourProvider.Light4;
}
public partial class Button : OsuButton
{
private TrianglesV2? triangles { get; set; }
protected override float HoverLayerFinalAlpha => 0;
private Color4? triangleGradientSecondColour;
public override Color4 BackgroundColour
{
get => base.BackgroundColour;
set
{
base.BackgroundColour = value;
triangleGradientSecondColour = BackgroundColour.Lighten(0.2f);
updateColours();
}
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider overlayColourProvider)
{
DefaultBackgroundColour = overlayColourProvider.Colour3;
triangleGradientSecondColour ??= overlayColourProvider.Colour1;
if (Text == default)
{
Add(new SpriteIcon
{
Icon = FontAwesome.Solid.ChevronRight,
Size = new Vector2(16),
Shadow = true,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
}
}
protected override void LoadComplete()
{
base.LoadComplete();
Content.CornerRadius = 2;
Add(triangles = new TrianglesV2
{
Thickness = 0.02f,
SpawnRatio = 0.6f,
RelativeSizeAxes = Axes.Both,
Depth = float.MaxValue,
});
updateColours();
}
private void updateColours()
{
if (triangles == null)
return;
Debug.Assert(triangleGradientSecondColour != null);
triangles.Colour = ColourInfo.GradientVertical(triangleGradientSecondColour.Value, BackgroundColour);
}
protected override bool OnHover(HoverEvent e)
{
Debug.Assert(triangleGradientSecondColour != null);
Background.FadeColour(triangleGradientSecondColour.Value, 300, Easing.OutQuint);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
Background.FadeColour(BackgroundColour, 300, Easing.OutQuint);
base.OnHoverLost(e);
}
}
}
}

View File

@ -24,6 +24,21 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString StartMatchWithCountdown(string humanReadableTime) => new TranslatableString(getKey(@"start_match_width_countdown"), @"Start match in {0}", humanReadableTime);
/// <summary>
/// "Choose the mods which all players should play with."
/// </summary>
public static LocalisableString RequiredModsButtonTooltip => new TranslatableString(getKey(@"required_mods_button_tooltip"), @"Choose the mods which all players should play with.");
/// <summary>
/// "Each player can choose their preferred mods from a selected list."
/// </summary>
public static LocalisableString FreeModsButtonTooltip => new TranslatableString(getKey(@"free_mods_button_tooltip"), @"Each player can choose their preferred mods from a selected list.");
/// <summary>
/// "Each player can choose their preferred difficulty, ruleset and mods."
/// </summary>
public static LocalisableString FreestyleButtonTooltip => new TranslatableString(getKey(@"freestyle_button_tooltip"), @"Each player can choose their preferred difficulty, ruleset and mods.");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -95,6 +95,14 @@ namespace osu.Game.Online.Multiplayer
/// <param name="beatmapAvailability">The new beatmap availability state of the user.</param>
Task UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability);
/// <summary>
/// Signals that a user in this room changed their style.
/// </summary>
/// <param name="userId">The ID of the user whose style changed.</param>
/// <param name="beatmapId">The user's beatmap.</param>
/// <param name="rulesetId">The user's ruleset.</param>
Task UserStyleChanged(int userId, int? beatmapId, int? rulesetId);
/// <summary>
/// Signals that a user in this room changed their local mods.
/// </summary>

View File

@ -57,6 +57,13 @@ namespace osu.Game.Online.Multiplayer
/// <param name="newBeatmapAvailability">The proposed new beatmap availability state.</param>
Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
/// <summary>
/// Change the local user's style in the currently joined room.
/// </summary>
/// <param name="beatmapId">The beatmap.</param>
/// <param name="rulesetId">The ruleset.</param>
Task ChangeUserStyle(int? beatmapId, int? rulesetId);
/// <summary>
/// Change the local user's mods in the currently joined room.
/// </summary>

View File

@ -358,6 +358,8 @@ namespace osu.Game.Online.Multiplayer
public abstract Task DisconnectInternal();
public abstract Task ChangeUserStyle(int? beatmapId, int? rulesetId);
/// <summary>
/// Change the local user's mods in the currently joined room.
/// </summary>
@ -653,6 +655,25 @@ namespace osu.Game.Online.Multiplayer
return Task.CompletedTask;
}
public Task UserStyleChanged(int userId, int? beatmapId, int? rulesetId)
{
Scheduler.Add(() =>
{
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
// errors here are not critical - user style is mostly for display.
if (user == null)
return;
user.BeatmapId = beatmapId;
user.RulesetId = rulesetId;
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
public Task UserModsChanged(int userId, IEnumerable<APIMod> mods)
{
Scheduler.Add(() =>

View File

@ -22,9 +22,6 @@ namespace osu.Game.Online.Multiplayer
[Key(1)]
public MultiplayerUserState State { get; set; } = MultiplayerUserState.Idle;
[Key(4)]
public MatchUserState? MatchState { get; set; }
/// <summary>
/// The availability state of the current beatmap.
/// </summary>
@ -37,6 +34,21 @@ namespace osu.Game.Online.Multiplayer
[Key(3)]
public IEnumerable<APIMod> Mods { get; set; } = Enumerable.Empty<APIMod>();
[Key(4)]
public MatchUserState? MatchState { get; set; }
/// <summary>
/// If not-null, a local override for this user's ruleset selection.
/// </summary>
[Key(5)]
public int? RulesetId;
/// <summary>
/// If not-null, a local override for this user's beatmap selection.
/// </summary>
[Key(6)]
public int? BeatmapId;
[IgnoreMember]
public APIUser? User { get; set; }

View File

@ -60,6 +60,7 @@ namespace osu.Game.Online.Multiplayer
connection.On(nameof(IMultiplayerClient.GameplayStarted), ((IMultiplayerClient)this).GameplayStarted);
connection.On<GameplayAbortReason>(nameof(IMultiplayerClient.GameplayAborted), ((IMultiplayerClient)this).GameplayAborted);
connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
connection.On<int, int?, int?>(nameof(IMultiplayerClient.UserStyleChanged), ((IMultiplayerClient)this).UserStyleChanged);
connection.On<int, IEnumerable<APIMod>>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
connection.On<int, BeatmapAvailability>(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
connection.On<MatchRoomState>(nameof(IMultiplayerClient.MatchRoomStateChanged), ((IMultiplayerClient)this).MatchRoomStateChanged);
@ -186,6 +187,16 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
}
public override Task ChangeUserStyle(int? beatmapId, int? rulesetId)
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserStyle), beatmapId, rulesetId);
}
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
{
if (!IsConnected.Value)

View File

@ -31,6 +31,7 @@ namespace osu.Game.Online.Rooms
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
req.AddParameter("version_hash", versionHash);
req.AddParameter("beatmap_id", beatmapInfo.OnlineID.ToString(CultureInfo.InvariantCulture));
req.AddParameter("beatmap_hash", beatmapInfo.MD5Hash);
req.AddParameter("ruleset_id", rulesetId.ToString(CultureInfo.InvariantCulture));
return req;

View File

@ -56,6 +56,12 @@ namespace osu.Game.Online.Rooms
[Key(10)]
public double StarRating { get; set; }
/// <summary>
/// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty and ruleset.
/// </summary>
[Key(11)]
public bool Freestyle { get; set; }
[SerializationConstructor]
public MultiplayerPlaylistItem()
{

View File

@ -77,11 +77,14 @@ namespace osu.Game.Online.Rooms
[CanBeNull]
public MultiplayerScoresAround ScoresAround { get; set; }
public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap)
[JsonProperty("ruleset_id")]
public int RulesetId { get; set; }
public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, [NotNull] BeatmapInfo beatmap)
{
var ruleset = rulesets.GetRuleset(playlistItem.RulesetID);
var ruleset = rulesets.GetRuleset(RulesetId);
if (ruleset == null)
throw new InvalidOperationException($"Couldn't create score with unknown ruleset: {playlistItem.RulesetID}");
throw new InvalidOperationException($"Couldn't create score with unknown ruleset: {RulesetId}");
var rulesetInstance = ruleset.CreateInstance();
@ -91,7 +94,7 @@ namespace osu.Game.Online.Rooms
TotalScore = TotalScore,
MaxCombo = MaxCombo,
BeatmapInfo = beatmap,
Ruleset = rulesets.GetRuleset(playlistItem.RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {playlistItem.RulesetID} not found locally"),
Ruleset = ruleset,
Passed = Passed,
Statistics = Statistics,
MaximumStatistics = MaximumStatistics,

View File

@ -67,6 +67,12 @@ namespace osu.Game.Online.Rooms
set => Beatmap = new APIBeatmap { OnlineID = value };
}
/// <summary>
/// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty and ruleset.
/// </summary>
[JsonProperty("freestyle")]
public bool Freestyle { get; set; }
/// <summary>
/// A beatmap representing this playlist item.
/// In many cases, this will *not* contain any usable information apart from OnlineID.
@ -101,6 +107,7 @@ namespace osu.Game.Online.Rooms
PlayedAt = item.PlayedAt;
RequiredMods = item.RequiredMods.ToArray();
AllowedMods = item.AllowedMods.ToArray();
Freestyle = item.Freestyle;
}
public void MarkInvalid() => valid.Value = false;
@ -120,18 +127,19 @@ namespace osu.Game.Online.Rooms
#endregion
public PlaylistItem With(Optional<long> id = default, Optional<IBeatmapInfo> beatmap = default, Optional<ushort?> playlistOrder = default)
public PlaylistItem With(Optional<long> id = default, Optional<IBeatmapInfo> beatmap = default, Optional<ushort?> playlistOrder = default, Optional<int> ruleset = default)
{
return new PlaylistItem(beatmap.GetOr(Beatmap))
{
ID = id.GetOr(ID),
OwnerID = OwnerID,
RulesetID = RulesetID,
RulesetID = ruleset.GetOr(RulesetID),
Expired = Expired,
PlaylistOrder = playlistOrder.GetOr(PlaylistOrder),
PlayedAt = PlayedAt,
AllowedMods = AllowedMods,
RequiredMods = RequiredMods,
Freestyle = Freestyle,
valid = { Value = Valid.Value },
};
}
@ -143,6 +151,7 @@ namespace osu.Game.Online.Rooms
&& Expired == other.Expired
&& PlaylistOrder == other.PlaylistOrder
&& AllowedMods.SequenceEqual(other.AllowedMods)
&& RequiredMods.SequenceEqual(other.RequiredMods);
&& RequiredMods.SequenceEqual(other.RequiredMods)
&& Freestyle == other.Freestyle;
}
}

View File

@ -16,9 +16,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
public abstract partial class CircularDistanceSnapGrid : DistanceSnapGrid
{
[Resolved]
private EditorClock editorClock { get; set; } = null!;
protected CircularDistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null)
: base(referenceObject, startPosition, startTime, endTime)
{
@ -76,7 +73,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
}
public override (Vector2 position, double time) GetSnappedPosition(Vector2 position)
public override (Vector2 position, double time) GetSnappedPosition(Vector2 position, double? fixedTime = null)
{
if (MaxIntervals == 0)
return (StartPosition, StartTime);
@ -100,8 +97,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (travelLength < DistanceBetweenTicks)
travelLength = DistanceBetweenTicks;
float snappedDistance = LimitedDistanceSnap.Value
? SnapProvider.DurationToDistance(ReferenceObject, editorClock.CurrentTime - ReferenceObject.GetEndTime())
float snappedDistance = fixedTime != null
? SnapProvider.DurationToDistance(ReferenceObject, fixedTime.Value - ReferenceObject.GetEndTime())
// When interacting with the resolved snap provider, the distance spacing multiplier should first be removed
// to allow for snapping at a non-multiplied ratio.
: SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier, DistanceSnapTarget.End);

View File

@ -10,7 +10,6 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Layout;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
@ -61,18 +60,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
[Resolved]
private BindableBeatDivisor beatDivisor { get; set; }
/// <summary>
/// When enabled, distance snap should only snap to the current time (as per the editor clock).
/// This is to emulate stable behaviour.
/// </summary>
protected Bindable<bool> LimitedDistanceSnap { get; private set; }
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
LimitedDistanceSnap = config.GetBindable<bool>(OsuSetting.EditorLimitedDistanceSnap);
}
private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit);
protected readonly HitObject ReferenceObject;
@ -143,8 +130,12 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// Snaps a position to this grid.
/// </summary>
/// <param name="position">The original position in coordinate space local to this <see cref="DistanceSnapGrid"/>.</param>
/// <param name="fixedTime">
/// Whether the snap operation should be temporally constrained to a particular time instant,
/// thus fixing the possible positions to a set distance from the <see cref="ReferenceObject"/>.
/// </param>
/// <returns>A tuple containing the snapped position in coordinate space local to this <see cref="DistanceSnapGrid"/> and the respective time value.</returns>
public abstract (Vector2 position, double time) GetSnappedPosition(Vector2 position);
public abstract (Vector2 position, double time) GetSnappedPosition(Vector2 position, double? fixedTime = null);
/// <summary>
/// Retrieves the applicable colour for a beat index.

View File

@ -330,6 +330,18 @@ namespace osu.Game.Screens.Edit
editorTimelineShowTicks = config.GetBindable<bool>(OsuSetting.EditorTimelineShowTicks);
editorContractSidebars = config.GetBindable<bool>(OsuSetting.EditorContractSidebars);
// These two settings don't work together. Make them mutually exclusive to let the user know.
editorAutoSeekOnPlacement.BindValueChanged(enabled =>
{
if (enabled.NewValue)
editorLimitedDistanceSnap.Value = false;
});
editorLimitedDistanceSnap.BindValueChanged(enabled =>
{
if (enabled.NewValue)
editorAutoSeekOnPlacement.Value = false;
});
AddInternal(new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Both,

View File

@ -20,6 +20,8 @@ namespace osu.Game.Screens.Edit.Timing
{
public partial class ControlPointList : CompositeDrawable
{
public Action? SelectClosestTimingPoint { get; init; }
private ControlPointTable table = null!;
private Container controls = null!;
private OsuButton deleteButton = null!;
@ -75,7 +77,7 @@ namespace osu.Game.Screens.Edit.Timing
new RoundedButton
{
Text = "Select closest to current time",
Action = goToCurrentGroup,
Action = SelectClosestTimingPoint,
Size = new Vector2(220, 30),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
@ -146,17 +148,6 @@ namespace osu.Game.Screens.Edit.Timing
table.Padding = new MarginPadding { Bottom = controls.DrawHeight };
}
private void goToCurrentGroup()
{
double accurateTime = clock.CurrentTimeAccurate;
var activeTimingPoint = Beatmap.ControlPointInfo.TimingPointAt(accurateTime);
var activeEffectPoint = Beatmap.ControlPointInfo.EffectPointAt(accurateTime);
double latestActiveTime = Math.Max(activeTimingPoint.Time, activeEffectPoint.Time);
selectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(latestActiveTime);
}
private void delete()
{
if (selectedGroup.Value == null)

View File

@ -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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -37,7 +38,10 @@ namespace osu.Game.Screens.Edit.Timing
{
new Drawable[]
{
new ControlPointList(),
new ControlPointList
{
SelectClosestTimingPoint = selectClosestTimingPoint,
},
new ControlPointSettings(),
},
}
@ -70,8 +74,18 @@ namespace osu.Game.Screens.Edit.Timing
if (editorClock == null)
return;
var nearestTimingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime);
SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(nearestTimingPoint.Time);
double accurateTime = editorClock.CurrentTimeAccurate;
var activeTimingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(accurateTime);
var activeEffectPoint = EditorBeatmap.ControlPointInfo.EffectPointAt(accurateTime);
if (activeEffectPoint.Equals(EffectControlPoint.DEFAULT))
SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(activeTimingPoint.Time);
else
{
double latestActiveTime = Math.Max(activeTimingPoint.Time, activeEffectPoint.Time);
SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(latestActiveTime);
}
}
protected override void ConfigureTimeline(TimelineArea timelineArea)

View File

@ -161,7 +161,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge
{
new Drawable[]
{
new DrawableRoomPlaylistItem(playlistItem)
new DrawableRoomPlaylistItem(playlistItem, true)
{
RelativeSizeAxes = Axes.X,
AllowReordering = false,

View File

@ -142,10 +142,10 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge
request.Success += req => Schedule(() =>
{
var best = req.Scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, beatmap.Value.BeatmapInfo)).ToArray();
var best = req.Scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, beatmap.Value.BeatmapInfo)).ToArray();
userBestScore.Value = req.UserScore;
var userBest = userBestScore.Value?.CreateScoreInfo(scoreManager, rulesets, playlistItem, beatmap.Value.BeatmapInfo);
var userBest = userBestScore.Value?.CreateScoreInfo(scoreManager, rulesets, beatmap.Value.BeatmapInfo);
cancellationTokenSource?.Cancel();
cancellationTokenSource = null;

View File

@ -74,7 +74,7 @@ namespace osu.Game.Screens.OnlinePlay
public bool IsSelectedItem => SelectedItem.Value?.ID == Item.ID;
private readonly DelayedLoadWrapper onScreenLoader = new DelayedLoadWrapper(Empty) { RelativeSizeAxes = Axes.Both };
private readonly DelayedLoadWrapper onScreenLoader;
private readonly IBindable<bool> valid = new Bindable<bool>();
private IBeatmapInfo? beatmap;
@ -120,9 +120,11 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved(CanBeNull = true)]
private ManageCollectionsDialog? manageCollectionsDialog { get; set; }
public DrawableRoomPlaylistItem(PlaylistItem item)
public DrawableRoomPlaylistItem(PlaylistItem item, bool loadImmediately = false)
: base(item)
{
onScreenLoader = new DelayedLoadWrapper(Empty, timeBeforeLoad: loadImmediately ? 0 : 500) { RelativeSizeAxes = Axes.Both };
Item = item;
valid.BindTo(item.Valid);

View File

@ -18,6 +18,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select;
using osuTK;
using osu.Game.Localisation;
namespace osu.Game.Screens.OnlinePlay
{
@ -36,8 +37,9 @@ namespace osu.Game.Screens.OnlinePlay
}
}
private OsuSpriteText count = null!;
public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); }
private OsuSpriteText count = null!;
private Circle circle = null!;
private readonly FreeModSelectOverlay freeModSelectOverlay;
@ -45,6 +47,9 @@ namespace osu.Game.Screens.OnlinePlay
public FooterButtonFreeMods(FreeModSelectOverlay freeModSelectOverlay)
{
this.freeModSelectOverlay = freeModSelectOverlay;
// Overwrite any external behaviour as we delegate the main toggle action to a sub-button.
base.Action = toggleAllFreeMods;
}
[Resolved]
@ -91,6 +96,8 @@ namespace osu.Game.Screens.OnlinePlay
SelectedColour = colours.Yellow;
DeselectedColour = SelectedColour.Opacity(0.5f);
Text = @"freemods";
TooltipText = MultiplayerMatchStrings.FreeModsButtonTooltip;
}
protected override void LoadComplete()
@ -98,9 +105,6 @@ namespace osu.Game.Screens.OnlinePlay
base.LoadComplete();
Current.BindValueChanged(_ => updateModDisplay(), true);
// Overwrite any external behaviour as we delegate the main toggle action to a sub-button.
Action = toggleAllFreeMods;
}
/// <summary>

View File

@ -0,0 +1,103 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Screens.Select;
using osu.Game.Localisation;
namespace osu.Game.Screens.OnlinePlay
{
public partial class FooterButtonFreestyle : FooterButton, IHasCurrentValue<bool>
{
private readonly BindableWithCurrent<bool> current = new BindableWithCurrent<bool>();
public Bindable<bool> Current
{
get => current.Current;
set => current.Current = value;
}
public new Action Action { set => throw new NotSupportedException("The click action is handled by the button itself."); }
private OsuSpriteText text = null!;
private Circle circle = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public FooterButtonFreestyle()
{
// Overwrite any external behaviour as we delegate the main toggle action to a sub-button.
base.Action = () => current.Value = !current.Value;
}
[BackgroundDependencyLoader]
private void load()
{
ButtonContentContainer.AddRange(new[]
{
new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Children = new Drawable[]
{
circle = new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Colour = colours.YellowDark,
RelativeSizeAxes = Axes.Both,
},
text = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Padding = new MarginPadding(5),
UseFullGlyphHeight = false,
}
}
}
});
SelectedColour = colours.Yellow;
DeselectedColour = SelectedColour.Opacity(0.5f);
Text = @"freestyle";
TooltipText = MultiplayerMatchStrings.FreestyleButtonTooltip;
}
protected override void LoadComplete()
{
base.LoadComplete();
Current.BindValueChanged(_ => updateDisplay(), true);
}
private void updateDisplay()
{
if (current.Value)
{
text.Text = "on";
text.FadeColour(colours.Gray2, 200, Easing.OutQuint);
circle.FadeColour(colours.Yellow, 200, Easing.OutQuint);
}
else
{
text.Text = "off";
text.FadeColour(colours.GrayF, 200, Easing.OutQuint);
circle.FadeColour(colours.Gray4, 200, Easing.OutQuint);
}
}
}
}

View File

@ -179,6 +179,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft
},
new FreestyleStatusPill(Room)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft
},
endDateInfo = new EndDateInfo(Room)
{
Anchor = Anchor.CentreLeft,

View File

@ -0,0 +1,64 @@
// 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.ComponentModel;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Online.Rooms;
using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
public partial class FreestyleStatusPill : OnlinePlayPill
{
private readonly Room room;
[Resolved]
private OsuColour colours { get; set; } = null!;
protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold);
public FreestyleStatusPill(Room room)
{
this.room = room;
}
protected override void LoadComplete()
{
base.LoadComplete();
Pill.Background.Alpha = 1;
Pill.Background.Colour = colours.Yellow;
TextFlow.Text = "Freestyle";
TextFlow.Colour = Color4.Black;
room.PropertyChanged += onRoomPropertyChanged;
updateFreestyleStatus();
}
private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(Room.CurrentPlaylistItem):
case nameof(Room.Playlist):
updateFreestyleStatus();
break;
}
}
private void updateFreestyleStatus()
{
PlaylistItem? currentItem = room.Playlist.GetCurrentItem() ?? room.CurrentPlaylistItem;
Alpha = currentItem?.Freestyle == true ? 1 : 0;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
room.PropertyChanged -= onRoomPropertyChanged;
}
}
}

View File

@ -28,6 +28,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Menu;
using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Utils;
using Container = osu.Framework.Graphics.Containers.Container;
namespace osu.Game.Screens.OnlinePlay.Match
@ -50,7 +51,18 @@ namespace osu.Game.Screens.OnlinePlay.Match
/// A container that provides controls for selection of user mods.
/// This will be shown/hidden automatically when applicable.
/// </summary>
protected Drawable? UserModsSection;
protected Drawable UserModsSection = null!;
/// <summary>
/// A container that provides controls for selection of the user style.
/// This will be shown/hidden automatically when applicable.
/// </summary>
protected Drawable UserStyleSection = null!;
/// <summary>
/// A container that will display the user's style.
/// </summary>
protected Container<DrawableRoomPlaylistItem> UserStyleDisplayContainer = null!;
private Sample? sampleStart;
@ -254,11 +266,11 @@ namespace osu.Game.Screens.OnlinePlay.Match
{
base.LoadComplete();
SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged));
UserMods.BindValueChanged(_ => Scheduler.AddOnce(UpdateMods));
SelectedItem.BindValueChanged(_ => updateSpecifics());
UserMods.BindValueChanged(_ => updateSpecifics());
beatmapAvailabilityTracker.SelectedItem.BindTo(SelectedItem);
beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateWorkingBeatmap());
beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateSpecifics());
userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(UserModsSelectOverlay);
@ -327,7 +339,7 @@ namespace osu.Game.Screens.OnlinePlay.Match
public override void OnSuspending(ScreenTransitionEvent e)
{
// Should be a noop in most cases, but let's ensure beyond doubt that the beatmap is in a correct state.
updateWorkingBeatmap();
updateSpecifics();
onLeaving();
base.OnSuspending(e);
@ -336,10 +348,10 @@ namespace osu.Game.Screens.OnlinePlay.Match
public override void OnResuming(ScreenTransitionEvent e)
{
base.OnResuming(e);
updateWorkingBeatmap();
updateSpecifics();
beginHandlingTrack();
Scheduler.AddOnce(UpdateMods);
Scheduler.AddOnce(updateRuleset);
}
protected bool ExitConfirmed { get; private set; }
@ -389,9 +401,13 @@ namespace osu.Game.Screens.OnlinePlay.Match
protected void StartPlay()
{
if (SelectedItem.Value == null)
if (SelectedItem.Value is not PlaylistItem item)
return;
item = item.With(
ruleset: GetGameplayRuleset().OnlineID,
beatmap: new Optional<IBeatmapInfo>(GetGameplayBeatmap()));
// User may be at song select or otherwise when the host starts gameplay.
// Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state.
if (!this.IsCurrentScreen())
@ -407,7 +423,7 @@ namespace osu.Game.Screens.OnlinePlay.Match
// fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes).
var targetScreen = (Screen?)ParentScreen ?? this;
targetScreen.Push(CreateGameplayScreen(SelectedItem.Value));
targetScreen.Push(CreateGameplayScreen(item));
}
/// <summary>
@ -417,66 +433,75 @@ namespace osu.Game.Screens.OnlinePlay.Match
/// <returns>The screen to enter.</returns>
protected abstract Screen CreateGameplayScreen(PlaylistItem selectedItem);
private void selectedItemChanged()
private void updateSpecifics()
{
updateWorkingBeatmap();
if (SelectedItem.Value is not PlaylistItem selected)
if (!this.IsCurrentScreen() || SelectedItem.Value is not PlaylistItem item)
return;
var rulesetInstance = Rulesets.GetRuleset(selected.RulesetID)?.CreateInstance();
Debug.Assert(rulesetInstance != null);
var allowedMods = selected.AllowedMods.Select(m => m.ToMod(rulesetInstance));
var rulesetInstance = GetGameplayRuleset().CreateInstance();
// Remove any user mods that are no longer allowed.
UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToList();
Mod[] allowedMods = item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
Mod[] newUserMods = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray();
if (!newUserMods.SequenceEqual(UserMods.Value))
UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToList();
UpdateMods();
updateRuleset();
// Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info
int beatmapId = GetGameplayBeatmap().OnlineID;
var localBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == beatmapId);
Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap);
UserModsSelectOverlay.Beatmap.Value = Beatmap.Value;
if (!selected.AllowedMods.Any())
Mods.Value = GetGameplayMods().Select(m => m.ToMod(rulesetInstance)).ToArray();
Ruleset.Value = GetGameplayRuleset();
bool freeMod = item.AllowedMods.Any();
bool freestyle = item.Freestyle;
// For now, the game can never be in a state where freemod and freestyle are on at the same time.
// This will change, but due to the current implementation if this was to occur drawables will overlap so let's assert.
Debug.Assert(!freeMod || !freestyle);
if (freeMod)
{
UserModsSection?.Hide();
UserModsSelectOverlay.Hide();
UserModsSelectOverlay.IsValidMod = _ => false;
UserModsSection.Show();
UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType());
}
else
{
UserModsSection?.Show();
UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType());
UserModsSection.Hide();
UserModsSelectOverlay.Hide();
UserModsSelectOverlay.IsValidMod = _ => false;
}
if (freestyle)
{
UserStyleSection.Show();
PlaylistItem gameplayItem = SelectedItem.Value.With(ruleset: GetGameplayRuleset().OnlineID, beatmap: new Optional<IBeatmapInfo>(GetGameplayBeatmap()));
PlaylistItem? currentItem = UserStyleDisplayContainer.SingleOrDefault()?.Item;
if (gameplayItem.Equals(currentItem))
return;
UserStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(gameplayItem, true)
{
AllowReordering = false,
AllowEditing = freestyle,
RequestEdit = _ => OpenStyleSelection()
};
}
else
UserStyleSection.Hide();
}
private void updateWorkingBeatmap()
{
if (SelectedItem.Value == null || !this.IsCurrentScreen())
return;
protected virtual APIMod[] GetGameplayMods() => UserMods.Value.Select(m => new APIMod(m)).Concat(SelectedItem.Value!.RequiredMods).ToArray();
var beatmap = SelectedItem.Value?.Beatmap;
protected virtual RulesetInfo GetGameplayRuleset() => Rulesets.GetRuleset(SelectedItem.Value!.RulesetID)!;
// Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info
var localBeatmap = beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineID == beatmap.OnlineID);
protected virtual IBeatmapInfo GetGameplayBeatmap() => SelectedItem.Value!.Beatmap;
UserModsSelectOverlay.Beatmap.Value = Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap);
}
protected virtual void UpdateMods()
{
if (SelectedItem.Value == null || !this.IsCurrentScreen())
return;
var rulesetInstance = Rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance();
Debug.Assert(rulesetInstance != null);
Mods.Value = UserMods.Value.Concat(SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToList();
}
private void updateRuleset()
{
if (SelectedItem.Value == null || !this.IsCurrentScreen())
return;
Ruleset.Value = Rulesets.GetRuleset(SelectedItem.Value.RulesetID);
}
protected abstract void OpenStyleSelection();
private void beginHandlingTrack()
{

View File

@ -0,0 +1,89 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
public partial class MultiplayerMatchFreestyleSelect : OnlinePlayFreestyleSelect
{
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private OngoingOperationTracker operationTracker { get; set; } = null!;
private readonly IBindable<bool> operationInProgress = new Bindable<bool>();
private LoadingLayer loadingLayer = null!;
private IDisposable? selectionOperation;
public MultiplayerMatchFreestyleSelect(Room room, PlaylistItem item)
: base(room, item)
{
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(loadingLayer = new LoadingLayer(true));
}
protected override void LoadComplete()
{
base.LoadComplete();
operationInProgress.BindTo(operationTracker.InProgress);
operationInProgress.BindValueChanged(_ => updateLoadingLayer(), true);
}
private void updateLoadingLayer()
{
if (operationInProgress.Value)
loadingLayer.Show();
else
loadingLayer.Hide();
}
protected override bool OnStart()
{
if (operationInProgress.Value)
{
Logger.Log($"{nameof(OnStart)} aborted due to {nameof(operationInProgress)}");
return false;
}
selectionOperation = operationTracker.BeginOperation();
client.ChangeUserStyle(Beatmap.Value.BeatmapInfo.OnlineID, Ruleset.Value.OnlineID)
.FireAndForget(onSuccess: () =>
{
selectionOperation.Dispose();
Schedule(() =>
{
// If an error or server side trigger occurred this screen may have already exited by external means.
if (this.IsCurrentScreen())
this.Exit();
});
}, onError: _ =>
{
selectionOperation.Dispose();
Schedule(() =>
{
Carousel.AllowSelection = true;
});
});
return true;
}
}
}

View File

@ -86,7 +86,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
BeatmapChecksum = item.Beatmap.MD5Hash,
RulesetID = item.RulesetID,
RequiredMods = item.RequiredMods.ToArray(),
AllowedMods = item.AllowedMods.ToArray()
AllowedMods = item.AllowedMods.ToArray(),
Freestyle = item.Freestyle
};
Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem);

View File

@ -16,6 +16,8 @@ using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Graphics.Cursor;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
@ -145,43 +147,66 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
SelectedItem = SelectedItem
}
},
new[]
new Drawable[]
{
UserModsSection = new FillFlowContainer
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Top = 10 },
Alpha = 0,
Children = new Drawable[]
Children = new[]
{
new OverlinedHeader("Extra mods"),
new FillFlowContainer
UserModsSection = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
Children = new Drawable[]
{
new UserModSelectButton
new OverlinedHeader("Extra mods"),
new FillFlowContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Width = 90,
Text = "Select",
Action = ShowUserModSelect,
},
new ModDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Current = UserMods,
Scale = new Vector2(0.8f),
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Children = new Drawable[]
{
new UserModSelectButton
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Width = 90,
Text = "Select",
Action = ShowUserModSelect,
},
new ModDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Current = UserMods,
Scale = new Vector2(0.8f),
},
}
},
}
},
UserStyleSection = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
Children = new Drawable[]
{
new OverlinedHeader("Difficulty"),
UserStyleDisplayContainer = new Container<DrawableRoomPlaylistItem>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}
}
},
}
},
}
},
},
RowDimensions = new[]
@ -228,6 +253,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit));
}
protected override void OpenStyleSelection()
{
if (!this.IsCurrentScreen() || SelectedItem.Value is not PlaylistItem item)
return;
this.Push(new MultiplayerMatchFreestyleSelect(Room, item));
}
protected override Drawable CreateFooter() => new MultiplayerMatchFooter
{
SelectedItem = SelectedItem
@ -238,16 +271,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
SelectedItem = SelectedItem
};
protected override void UpdateMods()
protected override APIMod[] GetGameplayMods()
{
if (SelectedItem.Value == null || client.LocalUser == null || !this.IsCurrentScreen())
return;
// Using the room's reported status makes the server authoritative.
return client.LocalUser?.Mods != null ? client.LocalUser.Mods.Concat(SelectedItem.Value!.RequiredMods).ToArray() : base.GetGameplayMods();
}
// update local mods based on room's reported status for the local user (omitting the base call implementation).
// this makes the server authoritative, and avoids the local user potentially setting mods that the server is not aware of (ie. if the match was started during the selection being changed).
var rulesetInstance = Rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance();
Debug.Assert(rulesetInstance != null);
Mods.Value = client.LocalUser.Mods.Select(m => m.ToMod(rulesetInstance)).Concat(SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToList();
protected override RulesetInfo GetGameplayRuleset()
{
// Using the room's reported status makes the server authoritative.
return client.LocalUser?.RulesetId != null ? Rulesets.GetRuleset(client.LocalUser.RulesetId.Value)! : base.GetGameplayRuleset();
}
protected override IBeatmapInfo GetGameplayBeatmap()
{
// Using the room's reported status makes the server authoritative.
return client.LocalUser?.BeatmapId != null ? new APIBeatmap { OnlineID = client.LocalUser.BeatmapId.Value } : base.GetGameplayBeatmap();
}
[Resolved(canBeNull: true)]
@ -349,23 +388,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
return;
}
updateCurrentItem();
SelectedItem.Value = Room.Playlist.SingleOrDefault(i => i.ID == client.Room.Settings.PlaylistItemId);
addItemButton.Alpha = localUserCanAddItem ? 1 : 0;
Scheduler.AddOnce(UpdateMods);
Activity.Value = new UserActivity.InLobby(Room);
}
private bool localUserCanAddItem => client.IsHost || Room.QueueMode != QueueMode.HostOnly;
private void updateCurrentItem()
{
Debug.Assert(client.Room != null);
SelectedItem.Value = Room.Playlist.SingleOrDefault(i => i.ID == client.Room.Settings.PlaylistItemId);
}
private void handleRoomLost() => Schedule(() =>
{
Logger.Log($"{this} exiting due to loss of room or connection");

View File

@ -4,6 +4,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
@ -14,6 +16,9 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Logging;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@ -47,6 +52,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
private SpriteIcon crown = null!;
private OsuSpriteText userRankText = null!;
private StyleDisplayIcon userStyleDisplay = null!;
private ModDisplay userModsDisplay = null!;
private StateDisplay userStateDisplay = null!;
@ -149,16 +155,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
}
}
},
new Container
new FillFlowContainer
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Right = 70 },
Child = userModsDisplay = new ModDisplay
Children = new Drawable[]
{
Scale = new Vector2(0.5f),
ExpansionMode = ExpansionMode.AlwaysContracted,
userStyleDisplay = new StyleDisplayIcon(),
userModsDisplay = new ModDisplay
{
Scale = new Vector2(0.5f),
ExpansionMode = ExpansionMode.AlwaysContracted,
}
}
},
userStateDisplay = new StateDisplay
@ -208,9 +218,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability);
if ((User.BeatmapAvailability.State == DownloadState.LocallyAvailable) && (User.State != MultiplayerUserState.Spectating))
{
userModsDisplay.FadeIn(fade_time);
userStyleDisplay.FadeIn(fade_time);
}
else
{
userModsDisplay.FadeOut(fade_time);
userStyleDisplay.FadeOut(fade_time);
}
if ((User.BeatmapId == null && User.RulesetId == null) || (User.BeatmapId == currentItem?.BeatmapID && User.RulesetId == currentItem?.RulesetID))
userStyleDisplay.Style = null;
else
userStyleDisplay.Style = (User.BeatmapId ?? currentItem?.BeatmapID ?? 0, User.RulesetId ?? currentItem?.RulesetID ?? 0);
kickButton.Alpha = client.IsHost && !User.Equals(client.LocalUser) ? 1 : 0;
crown.Alpha = client.Room.Host?.Equals(User) == true ? 1 : 0;
@ -284,5 +305,81 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
IconHoverColour = colours.Red;
}
}
private partial class StyleDisplayIcon : CompositeComponent
{
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
public StyleDisplayIcon()
{
AutoSizeAxes = Axes.Both;
}
private (int beatmap, int ruleset)? style;
public (int beatmap, int ruleset)? Style
{
get => style;
set
{
if (style == value)
return;
style = value;
Scheduler.Add(refresh);
}
}
private CancellationTokenSource? cancellationSource;
private void refresh()
{
cancellationSource?.Cancel();
cancellationSource?.Dispose();
cancellationSource = null;
if (Style == null)
{
ClearInternal();
return;
}
cancellationSource = new CancellationTokenSource();
CancellationToken token = cancellationSource.Token;
int localBeatmap = Style.Value.beatmap;
int localRuleset = Style.Value.ruleset;
Task.Run(async () =>
{
try
{
var beatmap = await beatmapLookupCache.GetBeatmapAsync(localBeatmap, token).ConfigureAwait(false);
if (beatmap == null)
return;
Schedule(() =>
{
if (token.IsCancellationRequested)
return;
InternalChild = new DifficultyIcon(beatmap, rulesets.GetRuleset(localRuleset))
{
Size = new Vector2(20),
TooltipType = DifficultyIconTooltipType.Extended,
};
});
}
catch (Exception e)
{
Logger.Log($"Error while populating participant style icon {e}");
}
}, token);
}
}
}
}

View File

@ -0,0 +1,104 @@
// 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 Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Screens.Select;
using osu.Game.Users;
namespace osu.Game.Screens.OnlinePlay
{
public abstract partial class OnlinePlayFreestyleSelect : SongSelect, IOnlinePlaySubScreen, IHandlePresentBeatmap
{
public string ShortTitle => "style selection";
public override string Title => ShortTitle.Humanize();
public override bool AllowEditing => false;
protected override UserActivity InitialActivity => new UserActivity.InLobby(room);
private readonly Room room;
private readonly PlaylistItem item;
protected OnlinePlayFreestyleSelect(Room room, PlaylistItem item)
{
this.room = room;
this.item = item;
Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING };
}
[BackgroundDependencyLoader]
private void load()
{
LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT };
}
protected override FilterControl CreateFilterControl() => new DifficultySelectFilterControl(item);
protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons()
{
// Required to create the drawable components.
base.CreateSongSelectFooterButtons();
return Enumerable.Empty<(FooterButton, OverlayContainer?)>();
}
protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea();
public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset)
{
// This screen cannot present beatmaps.
}
private partial class DifficultySelectFilterControl : FilterControl
{
private readonly PlaylistItem item;
private double itemLength;
private int beatmapSetId;
public DifficultySelectFilterControl(PlaylistItem item)
{
this.item = item;
}
[BackgroundDependencyLoader]
private void load(RealmAccess realm)
{
realm.Run(r =>
{
int beatmapId = item.Beatmap.OnlineID;
BeatmapInfo? beatmap = r.All<BeatmapInfo>().FirstOrDefault(b => b.OnlineID == beatmapId);
itemLength = beatmap?.Length ?? 0;
beatmapSetId = beatmap?.BeatmapSet?.OnlineID ?? 0;
});
}
public override FilterCriteria CreateCriteria()
{
var criteria = base.CreateCriteria();
// Must be from the same set as the playlist item.
criteria.BeatmapSetId = beatmapSetId;
criteria.HasOnlineID = true;
// Must be within 30s of the playlist item.
criteria.Length.Min = itemLength - 30000;
criteria.Length.Max = itemLength + 30000;
criteria.Length.IsLowerInclusive = true;
criteria.Length.IsUpperInclusive = true;
return criteria;
}
}
}
}

View File

@ -21,6 +21,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select;
using osu.Game.Users;
using osu.Game.Utils;
using osu.Game.Localisation;
namespace osu.Game.Screens.OnlinePlay
{
@ -41,10 +42,12 @@ namespace osu.Game.Screens.OnlinePlay
protected override UserActivity InitialActivity => new UserActivity.InLobby(room);
protected readonly Bindable<IReadOnlyList<Mod>> FreeMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
protected readonly Bindable<bool> Freestyle = new Bindable<bool>();
private readonly Room room;
private readonly PlaylistItem? initialItem;
private readonly FreeModSelectOverlay freeModSelectOverlay;
private readonly FreeModSelectOverlay freeModSelect;
private FooterButton freeModsFooterButton = null!;
private IDisposable? freeModSelectOverlayRegistration;
@ -61,7 +64,7 @@ namespace osu.Game.Screens.OnlinePlay
Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING };
freeModSelectOverlay = new FreeModSelectOverlay
freeModSelect = new FreeModSelectOverlay
{
SelectedMods = { BindTarget = FreeMods },
IsValidMod = IsValidFreeMod,
@ -72,7 +75,7 @@ namespace osu.Game.Screens.OnlinePlay
private void load()
{
LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT };
LoadComponent(freeModSelectOverlay);
LoadComponent(freeModSelect);
}
protected override void LoadComplete()
@ -108,12 +111,35 @@ namespace osu.Game.Screens.OnlinePlay
Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
}
Freestyle.Value = initialItem.Freestyle;
}
Mods.BindValueChanged(onModsChanged);
Ruleset.BindValueChanged(onRulesetChanged);
Freestyle.BindValueChanged(onFreestyleChanged, true);
freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelectOverlay);
freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect);
}
private void onFreestyleChanged(ValueChangedEvent<bool> enabled)
{
if (enabled.NewValue)
{
freeModsFooterButton.Enabled.Value = false;
ModsFooterButton.Enabled.Value = false;
ModSelect.Hide();
freeModSelect.Hide();
Mods.Value = [];
FreeMods.Value = [];
}
else
{
freeModsFooterButton.Enabled.Value = true;
ModsFooterButton.Enabled.Value = true;
}
}
private void onModsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods)
@ -121,7 +147,7 @@ namespace osu.Game.Screens.OnlinePlay
FreeMods.Value = FreeMods.Value.Where(checkCompatibleFreeMod).ToList();
// Reset the validity delegate to update the overlay's display.
freeModSelectOverlay.IsValidMod = IsValidFreeMod;
freeModSelect.IsValidMod = IsValidFreeMod;
}
private void onRulesetChanged(ValueChangedEvent<RulesetInfo> ruleset)
@ -135,7 +161,8 @@ namespace osu.Game.Screens.OnlinePlay
{
RulesetID = Ruleset.Value.OnlineID,
RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(),
AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray()
AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(),
Freestyle = Freestyle.Value
};
return SelectItem(item);
@ -150,9 +177,9 @@ namespace osu.Game.Screens.OnlinePlay
public override bool OnBackButton()
{
if (freeModSelectOverlay.State.Value == Visibility.Visible)
if (freeModSelect.State.Value == Visibility.Visible)
{
freeModSelectOverlay.Hide();
freeModSelect.Hide();
return true;
}
@ -161,7 +188,7 @@ namespace osu.Game.Screens.OnlinePlay
public override bool OnExiting(ScreenExitEvent e)
{
freeModSelectOverlay.Hide();
freeModSelect.Hide();
return base.OnExiting(e);
}
@ -170,12 +197,17 @@ namespace osu.Game.Screens.OnlinePlay
IsValidMod = IsValidMod
};
protected override IEnumerable<(FooterButton, OverlayContainer?)> CreateSongSelectFooterButtons()
protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons()
{
var baseButtons = base.CreateSongSelectFooterButtons().ToList();
var freeModsButton = new FooterButtonFreeMods(freeModSelectOverlay) { Current = FreeMods };
baseButtons.Insert(baseButtons.FindIndex(b => b.Item1 is FooterButtonMods) + 1, (freeModsButton, freeModSelectOverlay));
baseButtons.Single(i => i.button is FooterButtonMods).button.TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip;
baseButtons.InsertRange(baseButtons.FindIndex(b => b.button is FooterButtonMods) + 1, new (FooterButton, OverlayContainer?)[]
{
(freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) { Current = FreeMods }, null),
(new FooterButtonFreestyle { Current = Freestyle }, null)
});
return baseButtons;
}

View File

@ -189,7 +189,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
/// <param name="pivot">An optional pivot around which the scores were retrieved.</param>
protected virtual ScoreInfo[] PerformSuccessCallback(Action<IEnumerable<ScoreInfo>> callback, List<MultiplayerScore> scores, MultiplayerScores? pivot = null)
{
var scoreInfos = scores.Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, PlaylistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray();
var scoreInfos = scores.Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray();
// Invoke callback to add the scores. Exclude the score provided to this screen since it's added already.
callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID));

View File

@ -0,0 +1,37 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
namespace osu.Game.Screens.OnlinePlay.Playlists
{
public partial class PlaylistsRoomFreestyleSelect : OnlinePlayFreestyleSelect
{
public new readonly Bindable<BeatmapInfo?> Beatmap = new Bindable<BeatmapInfo?>();
public new readonly Bindable<RulesetInfo?> Ruleset = new Bindable<RulesetInfo?>();
public PlaylistsRoomFreestyleSelect(Room room, PlaylistItem item)
: base(room, item)
{
}
protected override bool OnStart()
{
// Beatmaps without a valid online ID are filtered away; this is just a final safety.
if (base.Beatmap.Value.BeatmapInfo.OnlineID < 0)
return false;
if (base.Ruleset.Value.OnlineID < 0)
return false;
Beatmap.Value = base.Beatmap.Value.BeatmapInfo;
Ruleset.Value = base.Ruleset.Value;
this.Exit();
return true;
}
}
}

View File

@ -11,11 +11,13 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Cursor;
using osu.Game.Input;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Screens.OnlinePlay.Components;
using osu.Game.Screens.OnlinePlay.Match;
using osu.Game.Screens.OnlinePlay.Match.Components;
@ -46,6 +48,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
private FillFlowContainer progressSection = null!;
private DrawableRoomPlaylist drawablePlaylist = null!;
private readonly Bindable<BeatmapInfo?> userBeatmap = new Bindable<BeatmapInfo?>();
private readonly Bindable<RulesetInfo?> userRuleset = new Bindable<RulesetInfo?>();
public PlaylistsRoomSubScreen(Room room)
: base(room, false) // Editing is temporarily not allowed.
{
@ -66,6 +71,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
{
base.LoadComplete();
SelectedItem.BindValueChanged(onSelectedItemChanged, true);
isIdle.BindValueChanged(_ => updatePollingRate(), true);
Room.PropertyChanged += onRoomPropertyChanged;
@ -74,6 +80,16 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
updateRoomPlaylist();
}
private void onSelectedItemChanged(ValueChangedEvent<PlaylistItem?> item)
{
// Simplest for now.
userBeatmap.Value = null;
userRuleset.Value = null;
}
protected override IBeatmapInfo GetGameplayBeatmap() => userBeatmap.Value ?? base.GetGameplayBeatmap();
protected override RulesetInfo GetGameplayRuleset() => userRuleset.Value ?? base.GetGameplayRuleset();
private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
@ -168,41 +184,65 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
RelativeSizeAxes = Axes.Both,
Content = new[]
{
new[]
new Drawable[]
{
UserModsSection = new FillFlowContainer
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
Margin = new MarginPadding { Bottom = 10 },
Children = new Drawable[]
Children = new[]
{
new OverlinedHeader("Extra mods"),
new FillFlowContainer
UserModsSection = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
Margin = new MarginPadding { Bottom = 10 },
Children = new Drawable[]
{
new UserModSelectButton
new OverlinedHeader("Extra mods"),
new FillFlowContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Width = 90,
Text = "Select",
Action = ShowUserModSelect,
},
new ModDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Current = UserMods,
Scale = new Vector2(0.8f),
},
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Children = new Drawable[]
{
new UserModSelectButton
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Width = 90,
Text = "Select",
Action = ShowUserModSelect,
},
new ModDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Current = UserMods,
Scale = new Vector2(0.8f),
},
}
}
}
}
},
UserStyleSection = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
Children = new Drawable[]
{
new OverlinedHeader("Difficulty"),
UserStyleDisplayContainer = new Container<DrawableRoomPlaylistItem>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}
}
},
}
},
},
@ -274,6 +314,18 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
},
};
protected override void OpenStyleSelection()
{
if (!this.IsCurrentScreen() || SelectedItem.Value is not PlaylistItem item)
return;
this.Push(new PlaylistsRoomFreestyleSelect(Room, item)
{
Beatmap = { BindTarget = userBeatmap },
Ruleset = { BindTarget = userRuleset }
});
}
private void updatePollingRate()
{
selectionPollingComponent.TimeBetweenPolls.Value = isIdle.Value ? 30000 : 5000;

View File

@ -39,7 +39,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
ID = room.Playlist.Count == 0 ? 0 : room.Playlist.Max(p => p.ID) + 1,
RulesetID = Ruleset.Value.OnlineID,
RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(),
AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray()
AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(),
Freestyle = Freestyle.Value
};
}
}

View File

@ -12,6 +12,7 @@ using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Overlays;
using osu.Game.Storyboards;
namespace osu.Game.Screens.Play
{
@ -53,7 +54,7 @@ namespace osu.Game.Screens.Play
private readonly Bindable<bool> playbackRateValid = new Bindable<bool>(true);
private readonly WorkingBeatmap beatmap;
private readonly IBeatmap beatmap;
private Track track;
@ -63,20 +64,19 @@ namespace osu.Game.Screens.Play
/// <summary>
/// Create a new master gameplay clock container.
/// </summary>
/// <param name="beatmap">The beatmap to be used for time and metadata references.</param>
/// <param name="working">The beatmap to be used for time and metadata references.</param>
/// <param name="gameplayStartTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime)
: base(beatmap.Track, applyOffsets: true, requireDecoupling: true)
public MasterGameplayClockContainer(WorkingBeatmap working, double gameplayStartTime)
: base(working.Track, applyOffsets: true, requireDecoupling: true)
{
this.beatmap = beatmap;
track = beatmap.Track;
beatmap = working.Beatmap;
track = working.Track;
GameplayStartTime = gameplayStartTime;
StartTime = findEarliestStartTime(gameplayStartTime, beatmap);
StartTime = findEarliestStartTime(gameplayStartTime, beatmap, working.Storyboard);
}
private static double findEarliestStartTime(double gameplayStartTime, WorkingBeatmap beatmap)
private static double findEarliestStartTime(double gameplayStartTime, IBeatmap beatmap, Storyboard storyboard)
{
// here we are trying to find the time to start playback from the "zero" point.
// generally this is either zero, or some point earlier than zero in the case of storyboards, lead-ins etc.
@ -86,15 +86,15 @@ namespace osu.Game.Screens.Play
// if a storyboard is present, it may dictate the appropriate start time by having events in negative time space.
// this is commonly used to display an intro before the audio track start.
double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime;
double? firstStoryboardEvent = storyboard.EarliestEventTime;
if (firstStoryboardEvent != null)
time = Math.Min(time, firstStoryboardEvent.Value);
// some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available.
// this is not available as an option in the live editor but can still be applied via .osu editing.
double firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime;
if (beatmap.Beatmap.AudioLeadIn > 0)
time = Math.Min(time, firstHitObjectTime - beatmap.Beatmap.AudioLeadIn);
double firstHitObjectTime = beatmap.HitObjects.First().StartTime;
if (beatmap.AudioLeadIn > 0)
time = Math.Min(time, firstHitObjectTime - beatmap.AudioLeadIn);
return time;
}
@ -136,7 +136,7 @@ namespace osu.Game.Screens.Play
{
removeAdjustmentsFromTrack();
track = new TrackVirtual(beatmap.Track.Length);
track = new TrackVirtual(track.Length);
track.Seek(CurrentTime);
if (IsRunning)
track.Start();
@ -228,9 +228,8 @@ namespace osu.Game.Screens.Play
removeAdjustmentsFromTrack();
}
ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.Beatmap.ControlPointInfo;
ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.ControlPointInfo;
ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => track.CurrentAmplitudes;
IClock IBeatSyncProvider.Clock => this;
ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : ChannelAmplitudes.Empty;
}
}

View File

@ -90,6 +90,12 @@ namespace osu.Game.Screens.Select.Carousel
if (match && criteria.RulesetCriteria != null)
match &= criteria.RulesetCriteria.Matches(BeatmapInfo, criteria);
if (match && criteria.HasOnlineID == true)
match &= BeatmapInfo.OnlineID >= 0;
if (match && criteria.BeatmapSetId != null)
match &= criteria.BeatmapSetId == BeatmapInfo.BeatmapSet?.OnlineID;
return match;
}

View File

@ -57,7 +57,7 @@ namespace osu.Game.Screens.Select
[CanBeNull]
private FilterCriteria currentCriteria;
public FilterCriteria CreateCriteria()
public virtual FilterCriteria CreateCriteria()
{
string query = searchTextBox.Text;

View File

@ -56,6 +56,9 @@ namespace osu.Game.Screens.Select
public RulesetInfo? Ruleset;
public IReadOnlyList<Mod>? Mods;
public bool AllowConvertedBeatmaps;
public int? BeatmapSetId;
public bool? HasOnlineID;
private string searchText = string.Empty;

View File

@ -83,6 +83,11 @@ namespace osu.Game.Screens.Select
/// </summary>
protected Container FooterPanels { get; private set; } = null!;
/// <summary>
/// The <see cref="FooterButton"/> that opens the mod select dialog.
/// </summary>
protected FooterButton ModsFooterButton { get; private set; } = null!;
/// <summary>
/// Whether entering editor mode should be allowed.
/// </summary>
@ -214,11 +219,11 @@ namespace osu.Game.Screens.Select
},
}
},
FilterControl = new FilterControl
FilterControl = CreateFilterControl().With(d =>
{
RelativeSizeAxes = Axes.X,
Height = FilterControl.HEIGHT,
},
d.RelativeSizeAxes = Axes.X;
d.Height = FilterControl.HEIGHT;
}),
new GridContainer // used for max width implementation
{
RelativeSizeAxes = Axes.Both,
@ -387,6 +392,8 @@ namespace osu.Game.Screens.Select
SampleConfirm = audio.Samples.Get(@"SongSelect/confirm-selection");
}
protected virtual FilterControl CreateFilterControl() => new FilterControl();
protected override void LoadComplete()
{
base.LoadComplete();
@ -408,9 +415,9 @@ namespace osu.Game.Screens.Select
/// Creates the buttons to be displayed in the footer.
/// </summary>
/// <returns>A set of <see cref="FooterButton"/> and an optional <see cref="OverlayContainer"/> which the button opens when pressed.</returns>
protected virtual IEnumerable<(FooterButton, OverlayContainer?)> CreateSongSelectFooterButtons() => new (FooterButton, OverlayContainer?)[]
protected virtual IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() => new (FooterButton, OverlayContainer?)[]
{
(new FooterButtonMods { Current = Mods }, ModSelect),
(ModsFooterButton = new FooterButtonMods { Current = Mods }, ModSelect),
(new FooterButtonRandom
{
NextRandom = () => Carousel.SelectNextRandom(),

View File

@ -92,35 +92,113 @@ namespace osu.Game.Screens.SelectV2
#region Selection handling
protected override void HandleItemSelected(object? model)
private GroupDefinition? lastSelectedGroup;
private BeatmapInfo? lastSelectedBeatmap;
protected override bool HandleItemSelected(object? model)
{
base.HandleItemSelected(model);
// Selecting a set isn't valid let's re-select the first difficulty.
if (model is BeatmapSetInfo setInfo)
switch (model)
{
CurrentSelection = setInfo.Beatmaps.First();
return;
case GroupDefinition group:
// Special case collapsing an open group.
if (lastSelectedGroup == group)
{
setExpansionStateOfGroup(lastSelectedGroup, false);
lastSelectedGroup = null;
return false;
}
setExpandedGroup(group);
return false;
case BeatmapSetInfo setInfo:
// Selecting a set isn't valid let's re-select the first difficulty.
CurrentSelection = setInfo.Beatmaps.First();
return false;
case BeatmapInfo beatmapInfo:
// If we have groups, we need to account for them.
if (Criteria.SplitOutDifficulties)
{
// Find the containing group. There should never be too many groups so iterating is efficient enough.
GroupDefinition? group = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key;
if (group != null)
setExpandedGroup(group);
}
else
{
setExpandedSet(beatmapInfo);
}
return true;
}
if (model is BeatmapInfo beatmapInfo)
setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, true);
return true;
}
protected override void HandleItemDeselected(object? model)
protected override bool CheckValidForGroupSelection(CarouselItem item)
{
base.HandleItemDeselected(model);
if (model is BeatmapInfo beatmapInfo)
setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, false);
}
private void setVisibilityOfSetItems(BeatmapSetInfo set, bool visible)
{
if (grouping.SetItems.TryGetValue(set, out var group))
switch (item.Model)
{
foreach (var i in group)
i.IsVisible = visible;
case BeatmapSetInfo:
return true;
case BeatmapInfo:
return Criteria.SplitOutDifficulties;
case GroupDefinition:
return false;
default:
throw new ArgumentException($"Unsupported model type {item.Model}");
}
}
private void setExpandedGroup(GroupDefinition group)
{
if (lastSelectedGroup != null)
setExpansionStateOfGroup(lastSelectedGroup, false);
lastSelectedGroup = group;
setExpansionStateOfGroup(group, true);
}
private void setExpansionStateOfGroup(GroupDefinition group, bool expanded)
{
if (grouping.GroupItems.TryGetValue(group, out var items))
{
foreach (var i in items)
{
if (i.Model is GroupDefinition)
i.IsExpanded = expanded;
else
i.IsVisible = expanded;
}
}
}
private void setExpandedSet(BeatmapInfo beatmapInfo)
{
if (lastSelectedBeatmap != null)
setExpansionStateOfSetItems(lastSelectedBeatmap.BeatmapSet!, false);
lastSelectedBeatmap = beatmapInfo;
setExpansionStateOfSetItems(beatmapInfo.BeatmapSet!, true);
}
private void setExpansionStateOfSetItems(BeatmapSetInfo set, bool expanded)
{
if (grouping.SetItems.TryGetValue(set, out var items))
{
foreach (var i in items)
{
if (i.Model is BeatmapSetInfo)
i.IsExpanded = expanded;
else
i.IsVisible = expanded;
}
}
}
@ -143,9 +221,11 @@ namespace osu.Game.Screens.SelectV2
private readonly DrawablePool<BeatmapPanel> beatmapPanelPool = new DrawablePool<BeatmapPanel>(100);
private readonly DrawablePool<BeatmapSetPanel> setPanelPool = new DrawablePool<BeatmapSetPanel>(100);
private readonly DrawablePool<GroupPanel> groupPanelPool = new DrawablePool<GroupPanel>(100);
private void setupPools()
{
AddInternal(groupPanelPool);
AddInternal(beatmapPanelPool);
AddInternal(setPanelPool);
}
@ -154,7 +234,12 @@ namespace osu.Game.Screens.SelectV2
{
switch (item.Model)
{
case GroupDefinition:
return groupPanelPool.Get();
case BeatmapInfo:
// TODO: if beatmap is a group selection target, it needs to be a different drawable
// with more information attached.
return beatmapPanelPool.Get();
case BeatmapSetInfo:
@ -166,4 +251,6 @@ namespace osu.Game.Screens.SelectV2
#endregion
}
public record GroupDefinition(string Title);
}

View File

@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
namespace osu.Game.Screens.SelectV2
{
@ -18,7 +19,13 @@ namespace osu.Game.Screens.SelectV2
/// </summary>
public IDictionary<BeatmapSetInfo, HashSet<CarouselItem>> SetItems => setItems;
/// <summary>
/// Groups contain children which are group-selectable. This dictionary holds the relationships between groups-panels to allow expanding them on selection.
/// </summary>
public IDictionary<GroupDefinition, HashSet<CarouselItem>> GroupItems => groupItems;
private readonly Dictionary<BeatmapSetInfo, HashSet<CarouselItem>> setItems = new Dictionary<BeatmapSetInfo, HashSet<CarouselItem>>();
private readonly Dictionary<GroupDefinition, HashSet<CarouselItem>> groupItems = new Dictionary<GroupDefinition, HashSet<CarouselItem>>();
private readonly Func<FilterCriteria> getCriteria;
@ -29,50 +36,97 @@ namespace osu.Game.Screens.SelectV2
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
{
bool groupSetsTogether;
setItems.Clear();
groupItems.Clear();
var criteria = getCriteria();
if (criteria.SplitOutDifficulties)
{
foreach (var item in items)
{
item.IsVisible = true;
item.IsGroupSelectionTarget = true;
}
return items;
}
CarouselItem? lastItem = null;
var newItems = new List<CarouselItem>(items.Count());
foreach (var item in items)
// Add criteria groups.
switch (criteria.Group)
{
default:
groupSetsTogether = true;
newItems.AddRange(items);
break;
case GroupMode.Difficulty:
groupSetsTogether = false;
int starGroup = int.MinValue;
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var b = (BeatmapInfo)item.Model;
if (b.StarRating > starGroup)
{
starGroup = (int)Math.Floor(b.StarRating);
var groupDefinition = new GroupDefinition($"{starGroup} - {++starGroup} *");
var groupItem = new CarouselItem(groupDefinition) { DrawHeight = GroupPanel.HEIGHT };
newItems.Add(groupItem);
groupItems[groupDefinition] = new HashSet<CarouselItem> { groupItem };
}
newItems.Add(item);
}
break;
}
// Add set headers wherever required.
CarouselItem? lastItem = null;
if (groupSetsTogether)
{
for (int i = 0; i < newItems.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var item = newItems[i];
if (item.Model is BeatmapInfo beatmap)
{
bool newBeatmapSet = lastItem == null || (lastItem.Model is BeatmapInfo lastBeatmap && lastBeatmap.BeatmapSet!.ID != beatmap.BeatmapSet!.ID);
if (newBeatmapSet)
{
var setItem = new CarouselItem(beatmap.BeatmapSet!) { DrawHeight = BeatmapSetPanel.HEIGHT };
setItems[beatmap.BeatmapSet!] = new HashSet<CarouselItem> { setItem };
newItems.Insert(i, setItem);
i++;
}
setItems[beatmap.BeatmapSet!].Add(item);
item.IsVisible = false;
}
lastItem = item;
}
}
// Link group items to their headers.
GroupDefinition? lastGroup = null;
foreach (var item in newItems)
{
cancellationToken.ThrowIfCancellationRequested();
if (item.Model is BeatmapInfo b)
if (item.Model is GroupDefinition group)
{
// Add set header
if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID))
{
newItems.Add(new CarouselItem(b.BeatmapSet!)
{
DrawHeight = BeatmapSetPanel.HEIGHT,
IsGroupSelectionTarget = true
});
}
if (!setItems.TryGetValue(b.BeatmapSet!, out var related))
setItems[b.BeatmapSet!] = related = new HashSet<CarouselItem>();
related.Add(item);
lastGroup = group;
continue;
}
newItems.Add(item);
lastItem = item;
item.IsGroupSelectionTarget = false;
item.IsVisible = false;
if (lastGroup != null)
{
groupItems[lastGroup].Add(item);
item.IsVisible = false;
}
}
return newItems;

View File

@ -100,6 +100,7 @@ namespace osu.Game.Screens.SelectV2
public CarouselItem? Item { get; set; }
public BindableBool Selected { get; } = new BindableBool();
public BindableBool Expanded { get; } = new BindableBool();
public BindableBool KeyboardSelected { get; } = new BindableBool();
public double DrawYPosition { get; set; }

View File

@ -25,6 +25,7 @@ namespace osu.Game.Screens.SelectV2
private BeatmapCarousel carousel { get; set; } = null!;
private OsuSpriteText text = null!;
private Box box = null!;
[BackgroundDependencyLoader]
private void load()
@ -34,7 +35,7 @@ namespace osu.Game.Screens.SelectV2
InternalChildren = new Drawable[]
{
new Box
box = new Box
{
Colour = Color4.Yellow.Darken(5),
Alpha = 0.8f,
@ -48,6 +49,11 @@ namespace osu.Game.Screens.SelectV2
}
};
Expanded.BindValueChanged(value =>
{
box.FadeColour(value.NewValue ? Color4.Yellow.Darken(2) : Color4.Yellow.Darken(5), 500, Easing.OutQuint);
});
KeyboardSelected.BindValueChanged(value =>
{
if (value.NewValue)
@ -67,7 +73,6 @@ namespace osu.Game.Screens.SelectV2
base.PrepareForUse();
Debug.Assert(Item != null);
Debug.Assert(Item.IsGroupSelectionTarget);
var beatmapSetInfo = (BeatmapSetInfo)Item.Model;
@ -86,6 +91,7 @@ namespace osu.Game.Screens.SelectV2
public CarouselItem? Item { get; set; }
public BindableBool Selected { get; } = new BindableBool();
public BindableBool Expanded { get; } = new BindableBool();
public BindableBool KeyboardSelected { get; } = new BindableBool();
public double DrawYPosition { get; set; }

View File

@ -8,6 +8,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -130,7 +131,7 @@ namespace osu.Game.Screens.SelectV2
///
/// A filter may add, mutate or remove items.
/// </remarks>
protected IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>();
public IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>();
/// <summary>
/// All items which are to be considered for display in this carousel.
@ -167,12 +168,18 @@ namespace osu.Game.Screens.SelectV2
protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) =>
scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item);
/// <summary>
/// When a user is traversing the carousel via group selection keys, assert whether the item provided is a valid target.
/// </summary>
/// <param name="item">The candidate item.</param>
/// <returns>Whether the provided item is a valid group target. If <c>false</c>, more panels will be checked in the user's requested direction until a valid target is found.</returns>
protected virtual bool CheckValidForGroupSelection(CarouselItem item) => true;
/// <summary>
/// Called when an item is "selected".
/// </summary>
protected virtual void HandleItemSelected(object? model)
{
}
/// <returns>Whether the item should be selected.</returns>
protected virtual bool HandleItemSelected(object? model) => true;
/// <summary>
/// Called when an item is "deselected".
@ -205,7 +212,6 @@ namespace osu.Game.Screens.SelectV2
InternalChild = scroll = new CarouselScrollContainer
{
RelativeSizeAxes = Axes.Both,
Masking = false,
};
Items.BindCollectionChanged((_, _) => FilterAsync());
@ -303,19 +309,19 @@ namespace osu.Game.Screens.SelectV2
return true;
case GlobalAction.SelectNext:
selectNext(1, isGroupSelection: false);
return true;
case GlobalAction.SelectNextGroup:
selectNext(1, isGroupSelection: true);
traverseKeyboardSelection(1);
return true;
case GlobalAction.SelectPrevious:
selectNext(-1, isGroupSelection: false);
traverseKeyboardSelection(-1);
return true;
case GlobalAction.SelectNextGroup:
traverseGroupSelection(1);
return true;
case GlobalAction.SelectPreviousGroup:
selectNext(-1, isGroupSelection: true);
traverseGroupSelection(-1);
return true;
}
@ -326,91 +332,99 @@ namespace osu.Game.Screens.SelectV2
{
}
/// <summary>
/// Select the next valid selection relative to a current selection.
/// This is generally for keyboard based traversal.
/// </summary>
/// <param name="direction">Positive for downwards, negative for upwards.</param>
/// <param name="isGroupSelection">Whether the selection should traverse groups. Group selection updates the actual selection immediately, while non-group selection will only prepare a future keyboard selection.</param>
/// <returns>Whether selection was possible.</returns>
private bool selectNext(int direction, bool isGroupSelection)
private void traverseKeyboardSelection(int direction)
{
// Ensure sanity
Debug.Assert(direction != 0);
direction = direction > 0 ? 1 : -1;
if (carouselItems == null || carouselItems.Count == 0) return;
if (carouselItems == null || carouselItems.Count == 0)
return false;
int originalIndex;
// If the user has a different keyboard selection and requests
// group selection, first transfer the keyboard selection to actual selection.
if (isGroupSelection && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{
TryActivateSelection();
return true;
}
if (currentKeyboardSelection.Index != null)
originalIndex = currentKeyboardSelection.Index.Value;
else if (direction > 0)
originalIndex = carouselItems.Count - 1;
else
originalIndex = 0;
CarouselItem? selectionItem = currentKeyboardSelection.CarouselItem;
int selectionIndex = currentKeyboardSelection.Index ?? -1;
// To keep things simple, let's first handle the cases where there's no selection yet.
if (selectionItem == null || selectionIndex < 0)
{
// Start by selecting the first item.
selectionItem = carouselItems.First();
selectionIndex = 0;
// In the forwards case, immediately attempt selection of this panel.
// If selection fails, continue with standard logic to find the next valid selection.
if (direction > 0 && attemptSelection(selectionItem))
return true;
// In the backwards direction we can just allow the selection logic to go ahead and loop around to the last valid.
}
Debug.Assert(selectionItem != null);
// As a second special case, if we're group selecting backwards and the current selection isn't a group,
// make sure to go back to the group header this item belongs to, so that the block below doesn't find it and stop too early.
if (isGroupSelection && direction < 0)
{
while (!carouselItems[selectionIndex].IsGroupSelectionTarget)
selectionIndex--;
}
CarouselItem? newItem;
int newIndex = originalIndex;
// Iterate over every item back to the current selection, finding the first valid item.
// The fail condition is when we reach the selection after a cyclic loop over every item.
do
{
selectionIndex += direction;
newItem = carouselItems[(selectionIndex + carouselItems.Count) % carouselItems.Count];
newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count;
var newItem = carouselItems[newIndex];
if (attemptSelection(newItem))
return true;
} while (newItem != selectionItem);
if (newItem.IsVisible)
{
setKeyboardSelection(newItem.Model);
return;
}
} while (newIndex != originalIndex);
}
return false;
/// <summary>
/// Select the next valid selection relative to a current selection.
/// This is generally for keyboard based traversal.
/// </summary>
/// <param name="direction">Positive for downwards, negative for upwards.</param>
/// <returns>Whether selection was possible.</returns>
private void traverseGroupSelection(int direction)
{
if (carouselItems == null || carouselItems.Count == 0) return;
bool attemptSelection(CarouselItem item)
// If the user has a different keyboard selection and requests
// group selection, first transfer the keyboard selection to actual selection.
if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{
if (!item.IsVisible || (isGroupSelection && !item.IsGroupSelectionTarget))
return false;
TryActivateSelection();
if (isGroupSelection)
setSelection(item.Model);
else
setKeyboardSelection(item.Model);
return true;
// There's a chance this couldn't resolve, at which point continue with standard traversal.
if (currentSelection.CarouselItem == currentKeyboardSelection.CarouselItem)
return;
}
int originalIndex;
int newIndex;
if (currentSelection.Index == null)
{
// If there's no current selection, start from either end of the full list.
newIndex = originalIndex = direction > 0 ? carouselItems.Count - 1 : 0;
}
else
{
newIndex = originalIndex = currentSelection.Index.Value;
// As a second special case, if we're group selecting backwards and the current selection isn't a group,
// make sure to go back to the group header this item belongs to, so that the block below doesn't find it and stop too early.
if (direction < 0)
{
while (!CheckValidForGroupSelection(carouselItems[newIndex]))
newIndex--;
}
}
// Iterate over every item back to the current selection, finding the first valid item.
// The fail condition is when we reach the selection after a cyclic loop over every item.
do
{
newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count;
var newItem = carouselItems[newIndex];
if (CheckValidForGroupSelection(newItem))
{
setSelection(newItem.Model);
return;
}
} while (newIndex != originalIndex);
}
#endregion
#region Selection handling
private readonly Cached selectionValid = new Cached();
private Selection currentKeyboardSelection = new Selection();
private Selection currentSelection = new Selection();
@ -419,29 +433,22 @@ namespace osu.Game.Screens.SelectV2
if (currentSelection.Model == model)
return;
var previousSelection = currentSelection;
if (HandleItemSelected(model))
{
if (currentSelection.Model != null)
HandleItemDeselected(currentSelection.Model);
if (previousSelection.Model != null)
HandleItemDeselected(previousSelection.Model);
currentKeyboardSelection = new Selection(model);
currentSelection = currentKeyboardSelection;
}
currentSelection = currentKeyboardSelection = new Selection(model);
HandleItemSelected(currentSelection.Model);
// `HandleItemSelected` can alter `CurrentSelection`, which will recursively call `setSelection()` again.
// if that happens, the rest of this method should be a no-op.
if (currentSelection.Model != model)
return;
refreshAfterSelection();
scrollToSelection();
selectionValid.Invalidate();
}
private void setKeyboardSelection(object? model)
{
currentKeyboardSelection = new Selection(model);
refreshAfterSelection();
scrollToSelection();
selectionValid.Invalidate();
}
/// <summary>
@ -526,6 +533,13 @@ namespace osu.Game.Screens.SelectV2
if (carouselItems == null)
return;
if (!selectionValid.IsValid)
{
refreshAfterSelection();
scrollToSelection();
selectionValid.Validate();
}
var range = getDisplayRange();
if (range != displayedRange)
@ -544,8 +558,8 @@ namespace osu.Game.Screens.SelectV2
if (c.Item == null)
continue;
if (panel.Depth != c.DrawYPosition)
scroll.Panels.ChangeChildDepth(panel, (float)c.DrawYPosition);
double selectedYPos = currentSelection?.CarouselItem?.CarouselYPosition ?? 0;
scroll.Panels.ChangeChildDepth(panel, (float)Math.Abs(c.DrawYPosition - selectedYPos));
if (c.DrawYPosition != c.Item.CarouselYPosition)
c.DrawYPosition = Interpolation.DampContinuously(c.DrawYPosition, c.Item.CarouselYPosition, 50, Time.Elapsed);
@ -557,6 +571,7 @@ namespace osu.Game.Screens.SelectV2
c.Selected.Value = c.Item == currentSelection?.CarouselItem;
c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem;
c.Expanded.Value = c.Item.IsExpanded;
}
}
@ -660,6 +675,7 @@ namespace osu.Game.Screens.SelectV2
carouselPanel.Item = null;
carouselPanel.Selected.Value = false;
carouselPanel.KeyboardSelected.Value = false;
carouselPanel.Expanded.Value = false;
}
#endregion

View File

@ -30,15 +30,15 @@ namespace osu.Game.Screens.SelectV2
public float DrawHeight { get; set; } = DEFAULT_HEIGHT;
/// <summary>
/// Whether this item should be a valid target for user group selection hotkeys.
/// </summary>
public bool IsGroupSelectionTarget { get; set; }
/// <summary>
/// Whether this item is visible or collapsed (hidden).
/// Whether this item is visible or hidden.
/// </summary>
public bool IsVisible { get; set; } = true;
/// <summary>
/// Whether this item is expanded or not. Should only be used for headers of groups.
/// </summary>
public bool IsExpanded { get; set; }
public CarouselItem(object model)
{
Model = model;

View File

@ -0,0 +1,120 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.SelectV2
{
public partial class GroupPanel : PoolableDrawable, ICarouselPanel
{
public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 2;
[Resolved]
private BeatmapCarousel carousel { get; set; } = null!;
private Box activationFlash = null!;
private OsuSpriteText text = null!;
private Box box = null!;
[BackgroundDependencyLoader]
private void load()
{
Size = new Vector2(500, HEIGHT);
Masking = true;
InternalChildren = new Drawable[]
{
box = new Box
{
Colour = Color4.DarkBlue.Darken(5),
Alpha = 0.8f,
RelativeSizeAxes = Axes.Both,
},
activationFlash = new Box
{
Colour = Color4.White,
Blending = BlendingParameters.Additive,
Alpha = 0,
RelativeSizeAxes = Axes.Both,
},
text = new OsuSpriteText
{
Padding = new MarginPadding(5),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
};
Selected.BindValueChanged(value =>
{
activationFlash.FadeTo(value.NewValue ? 0.2f : 0, 500, Easing.OutQuint);
});
Expanded.BindValueChanged(value =>
{
box.FadeColour(value.NewValue ? Color4.SkyBlue : Color4.DarkBlue.Darken(5), 500, Easing.OutQuint);
});
KeyboardSelected.BindValueChanged(value =>
{
if (value.NewValue)
{
BorderThickness = 5;
BorderColour = Color4.Pink;
}
else
{
BorderThickness = 0;
}
});
}
protected override void PrepareForUse()
{
base.PrepareForUse();
Debug.Assert(Item != null);
GroupDefinition group = (GroupDefinition)Item.Model;
text.Text = group.Title;
this.FadeInFromZero(500, Easing.OutQuint);
}
protected override bool OnClick(ClickEvent e)
{
carousel.CurrentSelection = Item!.Model;
return true;
}
#region ICarouselPanel
public CarouselItem? Item { get; set; }
public BindableBool Selected { get; } = new BindableBool();
public BindableBool Expanded { get; } = new BindableBool();
public BindableBool KeyboardSelected { get; } = new BindableBool();
public double DrawYPosition { get; set; }
public void Activated()
{
// sets should never be activated.
throw new InvalidOperationException();
}
#endregion
}
}

View File

@ -14,10 +14,15 @@ namespace osu.Game.Screens.SelectV2
public interface ICarouselPanel
{
/// <summary>
/// Whether this item has selection. Should be read from to update the visual state.
/// Whether this item has selection (see <see cref="Carousel{T}.CurrentSelection"/>). Should be read from to update the visual state.
/// </summary>
BindableBool Selected { get; }
/// <summary>
/// Whether this item is expanded (see <see cref="CarouselItem.IsExpanded"/>). Should be read from to update the visual state.
/// </summary>
BindableBool Expanded { get; }
/// <summary>
/// Whether this item has keyboard selection. Should be read from to update the visual state.
/// </summary>

View File

@ -335,6 +335,23 @@ namespace osu.Game.Tests.Visual.Multiplayer
return Task.CompletedTask;
}
public override Task ChangeUserStyle(int? beatmapId, int? rulesetId)
{
ChangeUserStyle(api.LocalUser.Value.Id, beatmapId, rulesetId);
return Task.CompletedTask;
}
public void ChangeUserStyle(int userId, int? beatmapId, int? rulesetId)
{
Debug.Assert(ServerRoom != null);
var user = ServerRoom.Users.Single(u => u.UserID == userId);
user.BeatmapId = beatmapId;
user.RulesetId = rulesetId;
((IMultiplayerClient)this).UserStyleChanged(userId, beatmapId, rulesetId);
}
public void ChangeUserMods(int userId, IEnumerable<Mod> newMods)
=> ChangeUserMods(userId, newMods.Select(m => new APIMod(m)));

View File

@ -35,7 +35,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="20.1.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.129.1" />
<PackageReference Include="ppy.osu.Framework" Version="2025.204.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2025.129.0" />
<PackageReference Include="Sentry" Version="5.0.0" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->

View File

@ -17,6 +17,6 @@
<MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.129.1" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.204.0" />
</ItemGroup>
</Project>