mirror of
https://github.com/ppy/osu.git
synced 2025-01-18 05:42:56 +08:00
Merge branch 'master' into spectator-list-visuals
This commit is contained in:
commit
e3b780d0fb
@ -9,7 +9,7 @@
|
||||
]
|
||||
},
|
||||
"nvika": {
|
||||
"version": "3.0.0",
|
||||
"version": "4.0.0",
|
||||
"commands": [
|
||||
"nvika"
|
||||
]
|
||||
|
@ -10,7 +10,7 @@
|
||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.114.1" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.115.0" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<!-- Fody does not handle Android build well, and warns when unchanged.
|
||||
|
@ -159,27 +159,26 @@ namespace osu.Game.Rulesets.Catch.Objects
|
||||
{
|
||||
// Note that this implementation is shared with the osu! ruleset's implementation.
|
||||
// If a change is made here, OsuHitObject.cs should also be updated.
|
||||
ComboIndex = lastObj?.ComboIndex ?? 0;
|
||||
ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
|
||||
IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
|
||||
int index = lastObj?.ComboIndex ?? 0;
|
||||
int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
|
||||
int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
|
||||
|
||||
if (this is BananaShower)
|
||||
// - For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
|
||||
// - At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo,
|
||||
// but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
|
||||
if (this is not BananaShower && (NewCombo || lastObj == null || lastObj is BananaShower))
|
||||
{
|
||||
// For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
|
||||
return;
|
||||
}
|
||||
|
||||
// At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo,
|
||||
// but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
|
||||
if (NewCombo || lastObj == null || lastObj is BananaShower)
|
||||
{
|
||||
IndexInCurrentCombo = 0;
|
||||
ComboIndex++;
|
||||
ComboIndexWithOffsets += ComboOffset + 1;
|
||||
inCurrentCombo = 0;
|
||||
index++;
|
||||
indexWithOffsets += ComboOffset + 1;
|
||||
|
||||
if (lastObj != null)
|
||||
lastObj.LastInCombo = true;
|
||||
}
|
||||
|
||||
ComboIndex = index;
|
||||
ComboIndexWithOffsets = indexWithOffsets;
|
||||
IndexInCurrentCombo = inCurrentCombo;
|
||||
}
|
||||
|
||||
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
|
||||
|
@ -6,6 +6,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using JetBrains.Annotations;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Audio.Sample;
|
||||
@ -17,6 +18,7 @@ using osu.Framework.Graphics.Textures;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Testing.Input;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Rulesets.Osu.Skinning;
|
||||
using osu.Game.Rulesets.Osu.Skinning.Legacy;
|
||||
using osu.Game.Rulesets.Osu.UI.Cursor;
|
||||
using osu.Game.Skinning;
|
||||
@ -103,6 +105,23 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
AddStep("contract", () => this.ChildrenOfType<CursorTrail>().Single().NewPartScale = Vector2.One);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRotation()
|
||||
{
|
||||
createTest(() =>
|
||||
{
|
||||
var skinContainer = new LegacySkinContainer(renderer, provideMiddle: true, enableRotation: true);
|
||||
var legacyCursorTrail = new LegacyRotatingCursorTrail(skinContainer)
|
||||
{
|
||||
NewPartScale = new Vector2(10)
|
||||
};
|
||||
|
||||
skinContainer.Child = legacyCursorTrail;
|
||||
|
||||
return skinContainer;
|
||||
});
|
||||
}
|
||||
|
||||
private void createTest(Func<Drawable> createContent) => AddStep("create trail", () =>
|
||||
{
|
||||
Clear();
|
||||
@ -121,12 +140,14 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
private readonly IRenderer renderer;
|
||||
private readonly bool provideMiddle;
|
||||
private readonly bool provideCursor;
|
||||
private readonly bool enableRotation;
|
||||
|
||||
public LegacySkinContainer(IRenderer renderer, bool provideMiddle, bool provideCursor = true)
|
||||
public LegacySkinContainer(IRenderer renderer, bool provideMiddle, bool provideCursor = true, bool enableRotation = false)
|
||||
{
|
||||
this.renderer = renderer;
|
||||
this.provideMiddle = provideMiddle;
|
||||
this.provideCursor = provideCursor;
|
||||
this.enableRotation = enableRotation;
|
||||
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
}
|
||||
@ -152,7 +173,19 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
|
||||
public ISample GetSample(ISampleInfo sampleInfo) => null;
|
||||
|
||||
public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) => null;
|
||||
public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup)
|
||||
{
|
||||
switch (lookup)
|
||||
{
|
||||
case OsuSkinConfiguration osuLookup:
|
||||
if (osuLookup == OsuSkinConfiguration.CursorTrailRotate)
|
||||
return SkinUtils.As<TValue>(new BindableBool(enableRotation));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public ISkin FindProvider(Func<ISkin, bool> lookupFunction) => lookupFunction(this) ? this : null;
|
||||
|
||||
@ -185,5 +218,19 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
MoveMouseTo(ToScreenSpace(DrawSize / 2 + DrawSize / 3 * rPos));
|
||||
}
|
||||
}
|
||||
|
||||
private partial class LegacyRotatingCursorTrail : LegacyCursorTrail
|
||||
{
|
||||
public LegacyRotatingCursorTrail([NotNull] ISkin skin)
|
||||
: base(skin)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
PartRotation += (float)(Time.Elapsed * 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -184,27 +184,26 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
{
|
||||
// Note that this implementation is shared with the osu!catch ruleset's implementation.
|
||||
// If a change is made here, CatchHitObject.cs should also be updated.
|
||||
ComboIndex = lastObj?.ComboIndex ?? 0;
|
||||
ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
|
||||
IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
|
||||
int index = lastObj?.ComboIndex ?? 0;
|
||||
int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
|
||||
int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
|
||||
|
||||
if (this is Spinner)
|
||||
// - For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
|
||||
// - At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo,
|
||||
// but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
|
||||
if (this is not Spinner && (NewCombo || lastObj == null || lastObj is Spinner))
|
||||
{
|
||||
// For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
|
||||
return;
|
||||
}
|
||||
|
||||
// At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo,
|
||||
// but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
|
||||
if (NewCombo || lastObj == null || lastObj is Spinner)
|
||||
{
|
||||
IndexInCurrentCombo = 0;
|
||||
ComboIndex++;
|
||||
ComboIndexWithOffsets += ComboOffset + 1;
|
||||
inCurrentCombo = 0;
|
||||
index++;
|
||||
indexWithOffsets += ComboOffset + 1;
|
||||
|
||||
if (lastObj != null)
|
||||
lastObj.LastInCombo = true;
|
||||
}
|
||||
|
||||
ComboIndex = index;
|
||||
ComboIndexWithOffsets = indexWithOffsets;
|
||||
IndexInCurrentCombo = inCurrentCombo;
|
||||
}
|
||||
|
||||
protected override HitWindows CreateHitWindows() => new OsuHitWindows();
|
||||
|
@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
{
|
||||
public partial class LegacyCursor : SkinnableCursor
|
||||
{
|
||||
public static readonly int REVOLUTION_DURATION = 10000;
|
||||
|
||||
private const float pressed_scale = 1.3f;
|
||||
private const float released_scale = 1f;
|
||||
|
||||
@ -52,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
if (spin)
|
||||
ExpandTarget.Spin(10000, RotationDirection.Clockwise);
|
||||
ExpandTarget.Spin(REVOLUTION_DURATION, RotationDirection.Clockwise);
|
||||
}
|
||||
|
||||
public override void Expand()
|
||||
|
@ -34,6 +34,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
private void load(OsuConfigManager config, ISkinSource skinSource)
|
||||
{
|
||||
cursorSize = config.GetBindable<float>(OsuSetting.GameplayCursorSize).GetBoundCopy();
|
||||
AllowPartRotation = skin.GetConfig<OsuSkinConfiguration, bool>(OsuSkinConfiguration.CursorTrailRotate)?.Value ?? true;
|
||||
|
||||
Texture = skin.GetTexture("cursortrail");
|
||||
|
||||
|
@ -9,6 +9,7 @@ namespace osu.Game.Rulesets.Osu.Skinning
|
||||
CursorCentre,
|
||||
CursorExpand,
|
||||
CursorRotate,
|
||||
CursorTrailRotate,
|
||||
HitCircleOverlayAboveNumber,
|
||||
|
||||
// ReSharper disable once IdentifierTypo
|
||||
|
@ -34,19 +34,24 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
/// </summary>
|
||||
protected virtual float FadeExponent => 1.7f;
|
||||
|
||||
private readonly TrailPart[] parts = new TrailPart[max_sprites];
|
||||
private int currentIndex;
|
||||
private IShader shader;
|
||||
private double timeOffset;
|
||||
private float time;
|
||||
|
||||
/// <summary>
|
||||
/// The scale used on creation of a new trail part.
|
||||
/// </summary>
|
||||
public Vector2 NewPartScale = Vector2.One;
|
||||
public Vector2 NewPartScale { get; set; } = Vector2.One;
|
||||
|
||||
private Anchor trailOrigin = Anchor.Centre;
|
||||
/// <summary>
|
||||
/// The rotation (in degrees) to apply to trail parts when <see cref="AllowPartRotation"/> is <c>true</c>.
|
||||
/// </summary>
|
||||
public float PartRotation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to rotate trail parts based on the value of <see cref="PartRotation"/>.
|
||||
/// </summary>
|
||||
protected bool AllowPartRotation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The trail part texture origin.
|
||||
/// </summary>
|
||||
protected Anchor TrailOrigin
|
||||
{
|
||||
get => trailOrigin;
|
||||
@ -57,6 +62,13 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
}
|
||||
}
|
||||
|
||||
private readonly TrailPart[] parts = new TrailPart[max_sprites];
|
||||
private Anchor trailOrigin = Anchor.Centre;
|
||||
private int currentIndex;
|
||||
private IShader shader;
|
||||
private double timeOffset;
|
||||
private float time;
|
||||
|
||||
public CursorTrail()
|
||||
{
|
||||
// as we are currently very dependent on having a running clock, let's make our own clock for the time being.
|
||||
@ -220,6 +232,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
|
||||
private float time;
|
||||
private float fadeExponent;
|
||||
private float angle;
|
||||
|
||||
private readonly TrailPart[] parts = new TrailPart[max_sprites];
|
||||
private Vector2 originPosition;
|
||||
@ -239,6 +252,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
texture = Source.texture;
|
||||
time = Source.time;
|
||||
fadeExponent = Source.FadeExponent;
|
||||
angle = Source.AllowPartRotation ? float.DegreesToRadians(Source.PartRotation) : 0;
|
||||
|
||||
originPosition = Vector2.Zero;
|
||||
|
||||
@ -279,6 +293,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
|
||||
renderer.PushLocalMatrix(DrawInfo.Matrix);
|
||||
|
||||
float sin = MathF.Sin(angle);
|
||||
float cos = MathF.Cos(angle);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.InvalidationID == -1)
|
||||
@ -289,7 +306,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
|
||||
vertexBatch.Add(new TexturedTrailVertex
|
||||
{
|
||||
Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y),
|
||||
Position = rotateAround(
|
||||
new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y),
|
||||
part.Position, sin, cos),
|
||||
TexturePosition = textureRect.BottomLeft,
|
||||
TextureRect = new Vector4(0, 0, 1, 1),
|
||||
Colour = DrawColourInfo.Colour.BottomLeft.Linear,
|
||||
@ -298,7 +317,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
|
||||
vertexBatch.Add(new TexturedTrailVertex
|
||||
{
|
||||
Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y),
|
||||
Position = rotateAround(
|
||||
new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X,
|
||||
part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos),
|
||||
TexturePosition = textureRect.BottomRight,
|
||||
TextureRect = new Vector4(0, 0, 1, 1),
|
||||
Colour = DrawColourInfo.Colour.BottomRight.Linear,
|
||||
@ -307,7 +328,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
|
||||
vertexBatch.Add(new TexturedTrailVertex
|
||||
{
|
||||
Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
|
||||
Position = rotateAround(
|
||||
new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
|
||||
part.Position, sin, cos),
|
||||
TexturePosition = textureRect.TopRight,
|
||||
TextureRect = new Vector4(0, 0, 1, 1),
|
||||
Colour = DrawColourInfo.Colour.TopRight.Linear,
|
||||
@ -316,7 +339,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
|
||||
vertexBatch.Add(new TexturedTrailVertex
|
||||
{
|
||||
Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
|
||||
Position = rotateAround(
|
||||
new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
|
||||
part.Position, sin, cos),
|
||||
TexturePosition = textureRect.TopLeft,
|
||||
TextureRect = new Vector4(0, 0, 1, 1),
|
||||
Colour = DrawColourInfo.Colour.TopLeft.Linear,
|
||||
@ -330,6 +355,14 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
shader.Unbind();
|
||||
}
|
||||
|
||||
private static Vector2 rotateAround(Vector2 input, Vector2 origin, float sin, float cos)
|
||||
{
|
||||
float xTranslated = input.X - origin.X;
|
||||
float yTranslated = input.Y - origin.Y;
|
||||
|
||||
return new Vector2(xTranslated * cos - yTranslated * sin, xTranslated * sin + yTranslated * cos) + origin;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
|
@ -36,6 +36,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
/// </summary>
|
||||
public Vector2 CurrentExpandedScale => skinnableCursor.ExpandTarget?.Scale ?? Vector2.One;
|
||||
|
||||
/// <summary>
|
||||
/// The current rotation of the cursor.
|
||||
/// </summary>
|
||||
public float CurrentRotation => skinnableCursor.ExpandTarget?.Rotation ?? 0;
|
||||
|
||||
public IBindable<float> CursorScale => cursorScale;
|
||||
|
||||
/// <summary>
|
||||
|
@ -83,7 +83,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
base.Update();
|
||||
|
||||
if (cursorTrail.Drawable is CursorTrail trail)
|
||||
{
|
||||
trail.NewPartScale = ActiveCursor.CurrentExpandedScale;
|
||||
trail.PartRotation = ActiveCursor.CurrentRotation;
|
||||
}
|
||||
}
|
||||
|
||||
public bool OnPressed(KeyBindingPressEvent<OsuAction> e)
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
#nullable disable
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
@ -15,6 +16,7 @@ using osu.Framework.Input.Events;
|
||||
using osu.Framework.Input.States;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Screens;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Configuration;
|
||||
@ -31,6 +33,7 @@ using osu.Game.Screens.Play;
|
||||
using osu.Game.Screens.Play.PlayerSettings;
|
||||
using osu.Game.Screens.Ranking;
|
||||
using osu.Game.Screens.Select;
|
||||
using osu.Game.Storyboards.Drawables;
|
||||
using osu.Game.Tests.Resources;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
@ -45,6 +48,7 @@ namespace osu.Game.Tests.Visual.Background
|
||||
private LoadBlockingTestPlayer player;
|
||||
private BeatmapManager manager;
|
||||
private RulesetStore rulesets;
|
||||
private UpdateCounter storyboardUpdateCounter;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(GameHost host, AudioManager audio)
|
||||
@ -194,6 +198,28 @@ namespace osu.Game.Tests.Visual.Background
|
||||
AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestStoryboardUpdatesWhenDimmed()
|
||||
{
|
||||
performFullSetup();
|
||||
createFakeStoryboard();
|
||||
|
||||
AddStep("Enable fully dimmed storyboard", () =>
|
||||
{
|
||||
player.StoryboardReplacesBackground.Value = true;
|
||||
player.StoryboardEnabled.Value = true;
|
||||
player.DimmableStoryboard.IgnoreUserSettings.Value = false;
|
||||
songSelect.DimLevel.Value = 1f;
|
||||
});
|
||||
|
||||
AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible);
|
||||
|
||||
AddWaitStep("wait some", 20);
|
||||
|
||||
AddUntilStep("Storyboard is always present", () => player.ChildrenOfType<DrawableStoryboard>().Single().AlwaysPresent, () => Is.True);
|
||||
AddUntilStep("Dimmable storyboard content is being updated", () => storyboardUpdateCounter.StoryboardContentLastUpdated, () => Is.EqualTo(Time.Current).Within(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestStoryboardIgnoreUserSettings()
|
||||
{
|
||||
@ -269,15 +295,19 @@ namespace osu.Game.Tests.Visual.Background
|
||||
{
|
||||
player.StoryboardEnabled.Value = false;
|
||||
player.StoryboardReplacesBackground.Value = false;
|
||||
player.DimmableStoryboard.Add(new OsuSpriteText
|
||||
player.DimmableStoryboard.AddRange(new Drawable[]
|
||||
{
|
||||
Size = new Vector2(500, 50),
|
||||
Alpha = 1,
|
||||
Colour = Color4.White,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Text = "THIS IS A STORYBOARD",
|
||||
Font = new FontUsage(size: 50)
|
||||
storyboardUpdateCounter = new UpdateCounter(),
|
||||
new OsuSpriteText
|
||||
{
|
||||
Size = new Vector2(500, 50),
|
||||
Alpha = 1,
|
||||
Colour = Color4.White,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Text = "THIS IS A STORYBOARD",
|
||||
Font = new FontUsage(size: 50)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -353,7 +383,7 @@ namespace osu.Game.Tests.Visual.Background
|
||||
/// <summary>
|
||||
/// Make sure every time a screen gets pushed, the background doesn't get replaced
|
||||
/// </summary>
|
||||
/// <returns>Whether or not the original background (The one created in DummySongSelect) is still the current background</returns>
|
||||
/// <returns>Whether the original background (The one created in DummySongSelect) is still the current background</returns>
|
||||
public bool IsBackgroundCurrent() => background?.IsCurrentScreen() == true;
|
||||
}
|
||||
|
||||
@ -384,7 +414,7 @@ namespace osu.Game.Tests.Visual.Background
|
||||
|
||||
public new DimmableStoryboard DimmableStoryboard => base.DimmableStoryboard;
|
||||
|
||||
// Whether or not the player should be allowed to load.
|
||||
// Whether the player should be allowed to load.
|
||||
public bool BlockLoad;
|
||||
|
||||
public Bindable<bool> StoryboardEnabled;
|
||||
@ -451,6 +481,17 @@ namespace osu.Game.Tests.Visual.Background
|
||||
}
|
||||
}
|
||||
|
||||
private partial class UpdateCounter : Drawable
|
||||
{
|
||||
public double StoryboardContentLastUpdated;
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
StoryboardContentLastUpdated = Time.Current;
|
||||
}
|
||||
}
|
||||
|
||||
private partial class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground
|
||||
{
|
||||
public Color4 CurrentColour => Content.Colour;
|
||||
|
273
osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs
Normal file
273
osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs
Normal file
@ -0,0 +1,273 @@
|
||||
// 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 System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Primitives;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Screens.Select;
|
||||
using osu.Game.Screens.Select.Filter;
|
||||
using osu.Game.Screens.SelectV2;
|
||||
using osu.Game.Tests.Beatmaps;
|
||||
using osu.Game.Tests.Resources;
|
||||
using osuTK.Graphics;
|
||||
using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel;
|
||||
|
||||
namespace osu.Game.Tests.Visual.SongSelect
|
||||
{
|
||||
[TestFixture]
|
||||
public partial class TestSceneBeatmapCarouselV2 : OsuManualInputManagerTestScene
|
||||
{
|
||||
private readonly BindableList<BeatmapSetInfo> beatmapSets = new BindableList<BeatmapSetInfo>();
|
||||
|
||||
[Cached(typeof(BeatmapStore))]
|
||||
private BeatmapStore store;
|
||||
|
||||
private OsuTextFlowContainer stats = null!;
|
||||
private BeatmapCarousel carousel = null!;
|
||||
|
||||
private OsuScrollContainer scroll => carousel.ChildrenOfType<OsuScrollContainer>().Single();
|
||||
|
||||
private int beatmapCount;
|
||||
|
||||
public TestSceneBeatmapCarouselV2()
|
||||
{
|
||||
store = new TestBeatmapStore
|
||||
{
|
||||
BeatmapSets = { BindTarget = beatmapSets }
|
||||
};
|
||||
|
||||
beatmapSets.BindCollectionChanged((_, _) =>
|
||||
{
|
||||
beatmapCount = beatmapSets.Sum(s => s.Beatmaps.Count);
|
||||
});
|
||||
|
||||
Scheduler.AddDelayed(updateStats, 100, true);
|
||||
}
|
||||
|
||||
[SetUpSteps]
|
||||
public void SetUpSteps()
|
||||
{
|
||||
AddStep("create components", () =>
|
||||
{
|
||||
beatmapSets.Clear();
|
||||
|
||||
Box topBox;
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new GridContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
ColumnDimensions = new[]
|
||||
{
|
||||
new Dimension(GridSizeMode.Relative, 1),
|
||||
},
|
||||
RowDimensions = new[]
|
||||
{
|
||||
new Dimension(GridSizeMode.Absolute, 200),
|
||||
new Dimension(),
|
||||
new Dimension(GridSizeMode.Absolute, 200),
|
||||
},
|
||||
Content = new[]
|
||||
{
|
||||
new Drawable[]
|
||||
{
|
||||
topBox = new Box
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Colour = Color4.Cyan,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Alpha = 0.4f,
|
||||
},
|
||||
},
|
||||
new Drawable[]
|
||||
{
|
||||
carousel = new BeatmapCarousel
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Width = 500,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
},
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Colour = Color4.Cyan,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Alpha = 0.4f,
|
||||
},
|
||||
topBox.CreateProxy(),
|
||||
}
|
||||
}
|
||||
},
|
||||
stats = new OsuTextFlowContainer(cp => cp.Font = FrameworkFont.Regular.With())
|
||||
{
|
||||
Padding = new MarginPadding(10),
|
||||
TextAnchor = Anchor.CentreLeft,
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
AddStep("sort by title", () =>
|
||||
{
|
||||
carousel.Filter(new FilterCriteria { Sort = SortMode.Title });
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestBasic()
|
||||
{
|
||||
AddStep("add 10 beatmaps", () =>
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
||||
});
|
||||
|
||||
AddStep("add 1 beatmap", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))));
|
||||
|
||||
AddStep("remove all beatmaps", () => beatmapSets.Clear());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestSorting()
|
||||
{
|
||||
AddStep("add 10 beatmaps", () =>
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
||||
});
|
||||
|
||||
AddStep("sort by difficulty", () =>
|
||||
{
|
||||
carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty });
|
||||
});
|
||||
|
||||
AddStep("sort by artist", () =>
|
||||
{
|
||||
carousel.Filter(new FilterCriteria { Sort = SortMode.Artist });
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestScrollPositionMaintainedOnAddSecondSelected()
|
||||
{
|
||||
Quad positionBefore = default;
|
||||
|
||||
AddStep("add 10 beatmaps", () =>
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
||||
});
|
||||
|
||||
AddUntilStep("visual item added", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Count(), () => Is.GreaterThan(0));
|
||||
|
||||
AddStep("select middle beatmap", () => carousel.CurrentSelection = beatmapSets.ElementAt(beatmapSets.Count - 2));
|
||||
AddStep("scroll to selected item", () => scroll.ScrollTo(scroll.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value)));
|
||||
|
||||
AddUntilStep("wait for scroll finished", () => scroll.Current, () => Is.EqualTo(scroll.Target));
|
||||
|
||||
AddStep("save selected screen position", () => positionBefore = carousel.ChildrenOfType<BeatmapCarouselPanel>().FirstOrDefault(p => p.Item!.Selected.Value)!.ScreenSpaceDrawQuad);
|
||||
|
||||
AddStep("remove first beatmap", () => beatmapSets.Remove(beatmapSets.Last()));
|
||||
AddUntilStep("sorting finished", () => carousel.IsFiltering, () => Is.False);
|
||||
AddAssert("select screen position unchanged", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value).ScreenSpaceDrawQuad,
|
||||
() => Is.EqualTo(positionBefore));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestScrollPositionMaintainedOnAddLastSelected()
|
||||
{
|
||||
Quad positionBefore = default;
|
||||
|
||||
AddStep("add 10 beatmaps", () =>
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
||||
});
|
||||
|
||||
AddUntilStep("visual item added", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Count(), () => Is.GreaterThan(0));
|
||||
|
||||
AddStep("scroll to last item", () => scroll.ScrollToEnd(false));
|
||||
|
||||
AddStep("select last beatmap", () => carousel.CurrentSelection = beatmapSets.First());
|
||||
|
||||
AddUntilStep("wait for scroll finished", () => scroll.Current, () => Is.EqualTo(scroll.Target));
|
||||
|
||||
AddStep("save selected screen position", () => positionBefore = carousel.ChildrenOfType<BeatmapCarouselPanel>().FirstOrDefault(p => p.Item!.Selected.Value)!.ScreenSpaceDrawQuad);
|
||||
|
||||
AddStep("remove first beatmap", () => beatmapSets.Remove(beatmapSets.Last()));
|
||||
AddUntilStep("sorting finished", () => carousel.IsFiltering, () => Is.False);
|
||||
AddAssert("select screen position unchanged", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value).ScreenSpaceDrawQuad,
|
||||
() => Is.EqualTo(positionBefore));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestAddRemoveOneByOne()
|
||||
{
|
||||
AddRepeatStep("add beatmaps", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20);
|
||||
|
||||
AddRepeatStep("remove beatmaps", () => beatmapSets.RemoveAt(RNG.Next(0, beatmapSets.Count)), 20);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Explicit]
|
||||
public void TestInsane()
|
||||
{
|
||||
const int count = 200000;
|
||||
|
||||
List<BeatmapSetInfo> generated = new List<BeatmapSetInfo>();
|
||||
|
||||
AddStep($"populate {count} test beatmaps", () =>
|
||||
{
|
||||
generated.Clear();
|
||||
Task.Run(() =>
|
||||
{
|
||||
for (int j = 0; j < count; j++)
|
||||
generated.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
||||
}).ConfigureAwait(true);
|
||||
});
|
||||
|
||||
AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3));
|
||||
AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2));
|
||||
AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count));
|
||||
|
||||
AddStep("add all beatmaps", () => beatmapSets.AddRange(generated));
|
||||
}
|
||||
|
||||
private void updateStats()
|
||||
{
|
||||
if (carousel.IsNull())
|
||||
return;
|
||||
|
||||
stats.Text = $"""
|
||||
store
|
||||
sets: {beatmapSets.Count}
|
||||
beatmaps: {beatmapCount}
|
||||
carousel:
|
||||
sorting: {carousel.IsFiltering}
|
||||
tracked: {carousel.ItemsTracked}
|
||||
displayable: {carousel.DisplayableItems}
|
||||
displayed: {carousel.VisibleItems}
|
||||
""";
|
||||
}
|
||||
}
|
||||
}
|
@ -112,8 +112,14 @@ namespace osu.Game.Online.API
|
||||
|
||||
config.BindWith(OsuSetting.UserOnlineStatus, configStatus);
|
||||
|
||||
// Early call to ensure the local user / "logged in" state is correct immediately.
|
||||
setPlaceholderLocalUser();
|
||||
if (HasLogin)
|
||||
{
|
||||
// Early call to ensure the local user / "logged in" state is correct immediately.
|
||||
setPlaceholderLocalUser();
|
||||
|
||||
// This is required so that Queue() requests during startup sequence don't fail due to "not logged in".
|
||||
state.Value = APIState.Connecting;
|
||||
}
|
||||
|
||||
localUser.BindValueChanged(u =>
|
||||
{
|
||||
|
@ -87,19 +87,23 @@ namespace osu.Game.Rulesets.Objects.Types
|
||||
/// <param name="lastObj">The previous hitobject, or null if this is the first object in the beatmap.</param>
|
||||
void UpdateComboInformation(IHasComboInformation? lastObj)
|
||||
{
|
||||
ComboIndex = lastObj?.ComboIndex ?? 0;
|
||||
ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
|
||||
IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
|
||||
int index = lastObj?.ComboIndex ?? 0;
|
||||
int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
|
||||
int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
|
||||
|
||||
if (NewCombo || lastObj == null)
|
||||
{
|
||||
IndexInCurrentCombo = 0;
|
||||
ComboIndex++;
|
||||
ComboIndexWithOffsets += ComboOffset + 1;
|
||||
inCurrentCombo = 0;
|
||||
index++;
|
||||
indexWithOffsets += ComboOffset + 1;
|
||||
|
||||
if (lastObj != null)
|
||||
lastObj.LastInCombo = true;
|
||||
}
|
||||
|
||||
ComboIndex = index;
|
||||
ComboIndexWithOffsets = indexWithOffsets;
|
||||
IndexInCurrentCombo = inCurrentCombo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Scoring
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This miss window should determine how early a hit can be before it is considered for judgement (as opposed to being ignored as
|
||||
/// "too far in the future). It should also define when a forced miss should be triggered (as a result of no user input in time).
|
||||
/// "too far in the future"). It should also define when a forced miss should be triggered (as a result of no user input in time).
|
||||
/// </remarks>
|
||||
[Description(@"Miss")]
|
||||
[EnumMember(Value = "miss")]
|
||||
|
@ -69,7 +69,22 @@ namespace osu.Game.Screens.Play
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
ShowStoryboard.BindValueChanged(_ => initializeStoryboard(true), true);
|
||||
ShowStoryboard.BindValueChanged(show =>
|
||||
{
|
||||
initializeStoryboard(true);
|
||||
|
||||
if (drawableStoryboard != null)
|
||||
{
|
||||
// Regardless of user dim setting, for the time being we need to ensure storyboards are still updated in the background (even if not displayed).
|
||||
// If we don't do this, an intensive storyboard will have a lot of catch-up work to do at the start of a break, causing a huge stutter.
|
||||
//
|
||||
// This can be reconsidered when https://github.com/ppy/osu-framework/issues/6491 is resolved.
|
||||
bool alwaysPresent = show.NewValue;
|
||||
|
||||
Content.AlwaysPresent = alwaysPresent;
|
||||
drawableStoryboard.AlwaysPresent = alwaysPresent;
|
||||
}
|
||||
}, true);
|
||||
base.LoadComplete();
|
||||
}
|
||||
|
||||
|
@ -165,13 +165,14 @@ namespace osu.Game.Screens.Play.PlayerSettings
|
||||
if (setInfo == null) // only the case for tests.
|
||||
return;
|
||||
|
||||
// Apply to all difficulties in a beatmap set for now (they generally always share timing).
|
||||
// Apply to all difficulties in a beatmap set if they have the same audio
|
||||
// (they generally always share timing).
|
||||
foreach (var b in setInfo.Beatmaps)
|
||||
{
|
||||
BeatmapUserSettings userSettings = b.UserSettings;
|
||||
double val = Current.Value;
|
||||
|
||||
if (userSettings.Offset != val)
|
||||
if (userSettings.Offset != val && b.AudioEquals(beatmap.Value.BeatmapInfo))
|
||||
userSettings.Offset = val;
|
||||
}
|
||||
});
|
||||
|
106
osu.Game/Screens/SelectV2/BeatmapCarousel.cs
Normal file
106
osu.Game/Screens/SelectV2/BeatmapCarousel.cs
Normal file
@ -0,0 +1,106 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Pooling;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Online.Multiplayer;
|
||||
using osu.Game.Screens.Select;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
[Cached]
|
||||
public partial class BeatmapCarousel : Carousel<BeatmapInfo>
|
||||
{
|
||||
private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
|
||||
|
||||
private readonly DrawablePool<BeatmapCarouselPanel> carouselPanelPool = new DrawablePool<BeatmapCarouselPanel>(100);
|
||||
|
||||
private readonly LoadingLayer loading;
|
||||
|
||||
public BeatmapCarousel()
|
||||
{
|
||||
DebounceDelay = 100;
|
||||
DistanceOffscreenToPreload = 100;
|
||||
|
||||
Filters = new ICarouselFilter[]
|
||||
{
|
||||
new BeatmapCarouselFilterSorting(() => Criteria),
|
||||
new BeatmapCarouselFilterGrouping(() => Criteria),
|
||||
};
|
||||
|
||||
AddInternal(carouselPanelPool);
|
||||
|
||||
AddInternal(loading = new LoadingLayer(dimBackground: true));
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken)
|
||||
{
|
||||
detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
|
||||
detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true);
|
||||
}
|
||||
|
||||
protected override Drawable GetDrawableForDisplay(CarouselItem item) => carouselPanelPool.Get();
|
||||
|
||||
protected override CarouselItem CreateCarouselItemForModel(BeatmapInfo model) => new BeatmapCarouselItem(model);
|
||||
|
||||
private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed)
|
||||
{
|
||||
// TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider.
|
||||
// right now we are managing this locally which is a bit of added overhead.
|
||||
IEnumerable<BeatmapSetInfo>? newBeatmapSets = changed.NewItems?.Cast<BeatmapSetInfo>();
|
||||
IEnumerable<BeatmapSetInfo>? beatmapSetInfos = changed.OldItems?.Cast<BeatmapSetInfo>();
|
||||
|
||||
switch (changed.Action)
|
||||
{
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps));
|
||||
break;
|
||||
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
|
||||
foreach (var set in beatmapSetInfos!)
|
||||
{
|
||||
foreach (var beatmap in set.Beatmaps)
|
||||
Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case NotifyCollectionChangedAction.Move:
|
||||
case NotifyCollectionChangedAction.Replace:
|
||||
throw new NotImplementedException();
|
||||
|
||||
case NotifyCollectionChangedAction.Reset:
|
||||
Items.Clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public FilterCriteria Criteria { get; private set; } = new FilterCriteria();
|
||||
|
||||
public void Filter(FilterCriteria criteria)
|
||||
{
|
||||
Criteria = criteria;
|
||||
FilterAsync().FireAndForget();
|
||||
}
|
||||
|
||||
protected override async Task FilterAsync()
|
||||
{
|
||||
loading.Show();
|
||||
await base.FilterAsync().ConfigureAwait(true);
|
||||
loading.Hide();
|
||||
}
|
||||
}
|
||||
}
|
60
osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs
Normal file
60
osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs
Normal file
@ -0,0 +1,60 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Screens.Select;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
public class BeatmapCarouselFilterGrouping : ICarouselFilter
|
||||
{
|
||||
private readonly Func<FilterCriteria> getCriteria;
|
||||
|
||||
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
|
||||
{
|
||||
this.getCriteria = getCriteria;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
|
||||
{
|
||||
var criteria = getCriteria();
|
||||
|
||||
if (criteria.SplitOutDifficulties)
|
||||
{
|
||||
foreach (var item in items)
|
||||
((BeatmapCarouselItem)item).HasGroupHeader = false;
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
CarouselItem? lastItem = null;
|
||||
|
||||
var newItems = new List<CarouselItem>(items.Count());
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (item.Model is BeatmapInfo b)
|
||||
{
|
||||
// Add set header
|
||||
if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID))
|
||||
newItems.Add(new BeatmapCarouselItem(b.BeatmapSet!) { IsGroupHeader = true });
|
||||
}
|
||||
|
||||
newItems.Add(item);
|
||||
lastItem = item;
|
||||
|
||||
var beatmapCarouselItem = (BeatmapCarouselItem)item;
|
||||
beatmapCarouselItem.HasGroupHeader = true;
|
||||
}
|
||||
|
||||
return newItems;
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
65
osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs
Normal file
65
osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs
Normal file
@ -0,0 +1,65 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Screens.Select;
|
||||
using osu.Game.Screens.Select.Filter;
|
||||
using osu.Game.Utils;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
public class BeatmapCarouselFilterSorting : ICarouselFilter
|
||||
{
|
||||
private readonly Func<FilterCriteria> getCriteria;
|
||||
|
||||
public BeatmapCarouselFilterSorting(Func<FilterCriteria> getCriteria)
|
||||
{
|
||||
this.getCriteria = getCriteria;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
|
||||
{
|
||||
var criteria = getCriteria();
|
||||
|
||||
return items.OrderDescending(Comparer<CarouselItem>.Create((a, b) =>
|
||||
{
|
||||
int comparison = 0;
|
||||
|
||||
if (a.Model is BeatmapInfo ab && b.Model is BeatmapInfo bb)
|
||||
{
|
||||
switch (criteria.Sort)
|
||||
{
|
||||
case SortMode.Artist:
|
||||
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist);
|
||||
if (comparison == 0)
|
||||
goto case SortMode.Title;
|
||||
break;
|
||||
|
||||
case SortMode.Difficulty:
|
||||
comparison = ab.StarRating.CompareTo(bb.StarRating);
|
||||
break;
|
||||
|
||||
case SortMode.Title:
|
||||
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
if (comparison != 0) return comparison;
|
||||
|
||||
if (a is BeatmapCarouselItem aItem && b is BeatmapCarouselItem bItem)
|
||||
return aItem.ID.CompareTo(bItem.ID);
|
||||
|
||||
return 0;
|
||||
}));
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
48
osu.Game/Screens/SelectV2/BeatmapCarouselItem.cs
Normal file
48
osu.Game/Screens/SelectV2/BeatmapCarouselItem.cs
Normal file
@ -0,0 +1,48 @@
|
||||
// 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.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
public class BeatmapCarouselItem : CarouselItem
|
||||
{
|
||||
public readonly Guid ID;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this item has a header providing extra information for it.
|
||||
/// When displaying items which don't have header, we should make sure enough information is included inline.
|
||||
/// </summary>
|
||||
public bool HasGroupHeader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this item is a group header.
|
||||
/// Group headers are generally larger in display. Setting this will account for the size difference.
|
||||
/// </summary>
|
||||
public bool IsGroupHeader { get; set; }
|
||||
|
||||
public override float DrawHeight => IsGroupHeader ? 80 : 40;
|
||||
|
||||
public BeatmapCarouselItem(object model)
|
||||
: base(model)
|
||||
{
|
||||
ID = (Model as IHasGuidPrimaryKey)?.ID ?? Guid.NewGuid();
|
||||
}
|
||||
|
||||
public override string? ToString()
|
||||
{
|
||||
switch (Model)
|
||||
{
|
||||
case BeatmapInfo bi:
|
||||
return $"Difficulty: {bi.DifficultyName} ({bi.StarRating:N1}*)";
|
||||
|
||||
case BeatmapSetInfo si:
|
||||
return $"{si.Metadata}";
|
||||
}
|
||||
|
||||
return Model.ToString();
|
||||
}
|
||||
}
|
||||
}
|
102
osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs
Normal file
102
osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs
Normal file
@ -0,0 +1,102 @@
|
||||
// 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.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.Beatmaps;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
public partial class BeatmapCarouselPanel : PoolableDrawable, ICarouselPanel
|
||||
{
|
||||
[Resolved]
|
||||
private BeatmapCarousel carousel { get; set; } = null!;
|
||||
|
||||
public CarouselItem? Item
|
||||
{
|
||||
get => item;
|
||||
set
|
||||
{
|
||||
item = value;
|
||||
|
||||
selected.UnbindBindings();
|
||||
|
||||
if (item != null)
|
||||
selected.BindTo(item.Selected);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly BindableBool selected = new BindableBool();
|
||||
private CarouselItem? item;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
selected.BindValueChanged(value =>
|
||||
{
|
||||
if (value.NewValue)
|
||||
{
|
||||
BorderThickness = 5;
|
||||
BorderColour = Color4.Pink;
|
||||
}
|
||||
else
|
||||
{
|
||||
BorderThickness = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void FreeAfterUse()
|
||||
{
|
||||
base.FreeAfterUse();
|
||||
Item = null;
|
||||
}
|
||||
|
||||
protected override void PrepareForUse()
|
||||
{
|
||||
base.PrepareForUse();
|
||||
|
||||
Debug.Assert(Item != null);
|
||||
|
||||
DrawYPosition = Item.CarouselYPosition;
|
||||
|
||||
Size = new Vector2(500, Item.DrawHeight);
|
||||
Masking = true;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5),
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Text = Item.ToString() ?? string.Empty,
|
||||
Padding = new MarginPadding(5),
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
}
|
||||
};
|
||||
|
||||
this.FadeInFromZero(500, Easing.OutQuint);
|
||||
}
|
||||
|
||||
protected override bool OnClick(ClickEvent e)
|
||||
{
|
||||
carousel.CurrentSelection = Item!.Model;
|
||||
return true;
|
||||
}
|
||||
|
||||
public double DrawYPosition { get; set; }
|
||||
}
|
||||
}
|
482
osu.Game/Screens/SelectV2/Carousel.cs
Normal file
482
osu.Game/Screens/SelectV2/Carousel.cs
Normal file
@ -0,0 +1,482 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.TypeExtensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Pooling;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
/// <summary>
|
||||
/// A highly efficient vertical list display that is used primarily for the song select screen,
|
||||
/// but flexible enough to be used for other use cases.
|
||||
/// </summary>
|
||||
public abstract partial class Carousel<T> : CompositeDrawable
|
||||
{
|
||||
/// <summary>
|
||||
/// A collection of filters which should be run each time a <see cref="FilterAsync"/> is executed.
|
||||
/// </summary>
|
||||
protected IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>();
|
||||
|
||||
/// <summary>
|
||||
/// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it.
|
||||
/// </summary>
|
||||
public float BleedTop { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it.
|
||||
/// </summary>
|
||||
public float BleedBottom { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// The number of pixels outside the carousel's vertical bounds to manifest drawables.
|
||||
/// This allows preloading content before it scrolls into view.
|
||||
/// </summary>
|
||||
public float DistanceOffscreenToPreload { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When a new request arrives to change filtering, the number of milliseconds to wait before performing the filter.
|
||||
/// Regardless of any external debouncing, this is a safety measure to avoid triggering too many threaded operations.
|
||||
/// </summary>
|
||||
public int DebounceDelay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an asynchronous filter / group operation is currently underway.
|
||||
/// </summary>
|
||||
public bool IsFiltering => !filterTask.IsCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// The number of displayable items currently being tracked (before filtering).
|
||||
/// </summary>
|
||||
public int ItemsTracked => Items.Count;
|
||||
|
||||
/// <summary>
|
||||
/// The number of carousel items currently in rotation for display.
|
||||
/// </summary>
|
||||
public int DisplayableItems => displayedCarouselItems?.Count ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// The number of items currently actualised into drawables.
|
||||
/// </summary>
|
||||
public int VisibleItems => scroll.Panels.Count;
|
||||
|
||||
/// <summary>
|
||||
/// All items which are to be considered for display in this carousel.
|
||||
/// Mutating this list will automatically queue a <see cref="FilterAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that an <see cref="ICarouselFilter"/> may add new items which are displayed but not tracked in this list.
|
||||
/// </remarks>
|
||||
protected readonly BindableList<T> Items = new BindableList<T>();
|
||||
|
||||
/// <summary>
|
||||
/// The currently selected model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Setting this will ensure <see cref="CarouselItem.Selected"/> is set to <c>true</c> only on the matching <see cref="CarouselItem"/>.
|
||||
/// Of note, if no matching item exists all items will be deselected while waiting for potential new item which matches.
|
||||
/// </remarks>
|
||||
public virtual object? CurrentSelection
|
||||
{
|
||||
get => currentSelection;
|
||||
set
|
||||
{
|
||||
if (currentSelectionCarouselItem != null)
|
||||
currentSelectionCarouselItem.Selected.Value = false;
|
||||
|
||||
currentSelection = value;
|
||||
|
||||
currentSelectionCarouselItem = null;
|
||||
currentSelectionYPosition = null;
|
||||
updateSelection();
|
||||
}
|
||||
}
|
||||
|
||||
private List<CarouselItem>? displayedCarouselItems;
|
||||
|
||||
private readonly CarouselScrollContainer scroll;
|
||||
|
||||
protected Carousel()
|
||||
{
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.Black,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
scroll = new CarouselScrollContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Masking = false,
|
||||
}
|
||||
};
|
||||
|
||||
Items.BindCollectionChanged((_, _) => FilterAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue an asynchronous filter operation.
|
||||
/// </summary>
|
||||
protected virtual Task FilterAsync() => filterTask = performFilter();
|
||||
|
||||
/// <summary>
|
||||
/// Create a drawable for the given carousel item so it can be displayed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For efficiency, it is recommended the drawables are retrieved from a <see cref="DrawablePool{T}"/>.
|
||||
/// </remarks>
|
||||
/// <param name="item">The item which should be represented by the returned drawable.</param>
|
||||
/// <returns>The manifested drawable.</returns>
|
||||
protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Create an internal carousel representation for the provided model object.
|
||||
/// </summary>
|
||||
/// <param name="model">The model.</param>
|
||||
/// <returns>A <see cref="CarouselItem"/> representing the model.</returns>
|
||||
protected abstract CarouselItem CreateCarouselItemForModel(T model);
|
||||
|
||||
#region Filtering and display preparation
|
||||
|
||||
private Task filterTask = Task.CompletedTask;
|
||||
private CancellationTokenSource cancellationSource = new CancellationTokenSource();
|
||||
|
||||
private async Task performFilter()
|
||||
{
|
||||
Debug.Assert(SynchronizationContext.Current != null);
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
lock (this)
|
||||
{
|
||||
cancellationSource.Cancel();
|
||||
cancellationSource = cts;
|
||||
}
|
||||
|
||||
if (DebounceDelay > 0)
|
||||
{
|
||||
log($"Filter operation queued, waiting for {DebounceDelay} ms debounce");
|
||||
await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// Copy must be performed on update thread for now (see ConfigureAwait above).
|
||||
// Could potentially be optimised in the future if it becomes an issue.
|
||||
IEnumerable<CarouselItem> items = new List<CarouselItem>(Items.Select(CreateCarouselItemForModel));
|
||||
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var filter in Filters)
|
||||
{
|
||||
log($"Performing {filter.GetType().ReadableName()}");
|
||||
items = await filter.Run(items, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
log("Updating Y positions");
|
||||
await updateYPositions(items, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
log("Cancelled due to newer request arriving");
|
||||
}
|
||||
}, cts.Token).ConfigureAwait(true);
|
||||
|
||||
if (cts.Token.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
log("Items ready for display");
|
||||
displayedCarouselItems = items.ToList();
|
||||
displayedRange = null;
|
||||
|
||||
updateSelection();
|
||||
|
||||
void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}");
|
||||
}
|
||||
|
||||
private async Task updateYPositions(IEnumerable<CarouselItem> carouselItems, CancellationToken cancellationToken) => await Task.Run(() =>
|
||||
{
|
||||
const float spacing = 10;
|
||||
float yPos = 0;
|
||||
|
||||
foreach (var item in carouselItems)
|
||||
{
|
||||
item.CarouselYPosition = yPos;
|
||||
yPos += item.DrawHeight + spacing;
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Selection handling
|
||||
|
||||
private object? currentSelection;
|
||||
private CarouselItem? currentSelectionCarouselItem;
|
||||
private double? currentSelectionYPosition;
|
||||
|
||||
private void updateSelection()
|
||||
{
|
||||
currentSelectionCarouselItem = null;
|
||||
|
||||
if (displayedCarouselItems == null) return;
|
||||
|
||||
foreach (var item in displayedCarouselItems)
|
||||
{
|
||||
bool isSelected = item.Model == currentSelection;
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
currentSelectionCarouselItem = item;
|
||||
|
||||
if (currentSelectionYPosition != item.CarouselYPosition)
|
||||
{
|
||||
if (currentSelectionYPosition != null)
|
||||
{
|
||||
float adjustment = (float)(item.CarouselYPosition - currentSelectionYPosition.Value);
|
||||
scroll.OffsetScrollPosition(adjustment);
|
||||
}
|
||||
|
||||
currentSelectionYPosition = item.CarouselYPosition;
|
||||
}
|
||||
}
|
||||
|
||||
item.Selected.Value = isSelected;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Display handling
|
||||
|
||||
private DisplayRange? displayedRange;
|
||||
|
||||
private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem();
|
||||
|
||||
/// <summary>
|
||||
/// The position of the lower visible bound with respect to the current scroll position.
|
||||
/// </summary>
|
||||
private float visibleBottomBound => (float)(scroll.Current + DrawHeight + BleedBottom);
|
||||
|
||||
/// <summary>
|
||||
/// The position of the upper visible bound with respect to the current scroll position.
|
||||
/// </summary>
|
||||
private float visibleUpperBound => (float)(scroll.Current - BleedTop);
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
if (displayedCarouselItems == null)
|
||||
return;
|
||||
|
||||
var range = getDisplayRange();
|
||||
|
||||
if (range != displayedRange)
|
||||
{
|
||||
Logger.Log($"Updating displayed range of carousel from {displayedRange} to {range}");
|
||||
displayedRange = range;
|
||||
|
||||
updateDisplayedRange(range);
|
||||
}
|
||||
|
||||
foreach (var panel in scroll.Panels)
|
||||
{
|
||||
var carouselPanel = (ICarouselPanel)panel;
|
||||
|
||||
if (panel.Depth != carouselPanel.DrawYPosition)
|
||||
scroll.Panels.ChangeChildDepth(panel, (float)carouselPanel.DrawYPosition);
|
||||
}
|
||||
}
|
||||
|
||||
private DisplayRange getDisplayRange()
|
||||
{
|
||||
Debug.Assert(displayedCarouselItems != null);
|
||||
|
||||
// Find index range of all items that should be on-screen
|
||||
carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload;
|
||||
int firstIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem);
|
||||
if (firstIndex < 0) firstIndex = ~firstIndex;
|
||||
|
||||
carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload;
|
||||
int lastIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem);
|
||||
if (lastIndex < 0) lastIndex = ~lastIndex;
|
||||
|
||||
firstIndex = Math.Max(0, firstIndex - 1);
|
||||
lastIndex = Math.Max(0, lastIndex - 1);
|
||||
|
||||
return new DisplayRange(firstIndex, lastIndex);
|
||||
}
|
||||
|
||||
private void updateDisplayedRange(DisplayRange range)
|
||||
{
|
||||
Debug.Assert(displayedCarouselItems != null);
|
||||
|
||||
List<CarouselItem> toDisplay = range.Last - range.First == 0
|
||||
? new List<CarouselItem>()
|
||||
: displayedCarouselItems.GetRange(range.First, range.Last - range.First + 1);
|
||||
|
||||
// Iterate over all panels which are already displayed and figure which need to be displayed / removed.
|
||||
foreach (var panel in scroll.Panels)
|
||||
{
|
||||
var carouselPanel = (ICarouselPanel)panel;
|
||||
|
||||
// The case where we're intending to display this panel, but it's already displayed.
|
||||
// Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation.
|
||||
var existing = toDisplay.FirstOrDefault(i => i.Model == carouselPanel.Item!.Model);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
carouselPanel.Item = existing;
|
||||
toDisplay.Remove(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the new display range doesn't contain the panel, it's no longer required for display.
|
||||
expirePanelImmediately(panel);
|
||||
}
|
||||
|
||||
// Add any new items which need to be displayed and haven't yet.
|
||||
foreach (var item in toDisplay)
|
||||
{
|
||||
var drawable = GetDrawableForDisplay(item);
|
||||
|
||||
if (drawable is not ICarouselPanel carouselPanel)
|
||||
throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
|
||||
|
||||
carouselPanel.Item = item;
|
||||
scroll.Add(drawable);
|
||||
}
|
||||
|
||||
// Update the total height of all items (to make the scroll container scrollable through the full height even though
|
||||
// most items are not displayed / loaded).
|
||||
if (displayedCarouselItems.Count > 0)
|
||||
{
|
||||
var lastItem = displayedCarouselItems[^1];
|
||||
scroll.SetLayoutHeight((float)(lastItem.CarouselYPosition + lastItem.DrawHeight));
|
||||
}
|
||||
else
|
||||
scroll.SetLayoutHeight(0);
|
||||
}
|
||||
|
||||
private static void expirePanelImmediately(Drawable panel)
|
||||
{
|
||||
panel.FinishTransforms();
|
||||
panel.Expire();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal helper classes
|
||||
|
||||
private record DisplayRange(int First, int Last);
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of scroll container which handles very large vertical lists by internally using <c>double</c> precision
|
||||
/// for pre-display Y values.
|
||||
/// </summary>
|
||||
private partial class CarouselScrollContainer : OsuScrollContainer
|
||||
{
|
||||
public readonly Container Panels;
|
||||
|
||||
public void SetLayoutHeight(float height) => Panels.Height = height;
|
||||
|
||||
public CarouselScrollContainer()
|
||||
{
|
||||
// Managing our own custom layout within ScrollContent causes feedback with public ScrollContainer calculations,
|
||||
// so we must maintain one level of separation from ScrollContent.
|
||||
base.Add(Panels = new Container
|
||||
{
|
||||
Name = "Layout content",
|
||||
RelativeSizeAxes = Axes.X,
|
||||
});
|
||||
}
|
||||
|
||||
public override void OffsetScrollPosition(double offset)
|
||||
{
|
||||
base.OffsetScrollPosition(offset);
|
||||
|
||||
foreach (var panel in Panels)
|
||||
{
|
||||
var c = (ICarouselPanel)panel;
|
||||
Debug.Assert(c.Item != null);
|
||||
|
||||
c.DrawYPosition += offset;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
foreach (var panel in Panels)
|
||||
{
|
||||
var c = (ICarouselPanel)panel;
|
||||
Debug.Assert(c.Item != null);
|
||||
|
||||
if (c.DrawYPosition != c.Item.CarouselYPosition)
|
||||
c.DrawYPosition = Interpolation.DampContinuously(c.DrawYPosition, c.Item.CarouselYPosition, 50, Time.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Clear(bool disposeChildren)
|
||||
{
|
||||
Panels.Height = 0;
|
||||
Panels.Clear(disposeChildren);
|
||||
}
|
||||
|
||||
public override void Add(Drawable drawable)
|
||||
{
|
||||
if (drawable is not ICarouselPanel)
|
||||
throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
|
||||
|
||||
Panels.Add(drawable);
|
||||
}
|
||||
|
||||
public override double GetChildPosInContent(Drawable d, Vector2 offset)
|
||||
{
|
||||
if (d is not ICarouselPanel panel)
|
||||
return base.GetChildPosInContent(d, offset);
|
||||
|
||||
return panel.DrawYPosition + offset.X;
|
||||
}
|
||||
|
||||
protected override void ApplyCurrentToContent()
|
||||
{
|
||||
Debug.Assert(ScrollDirection == Direction.Vertical);
|
||||
|
||||
double scrollableExtent = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y;
|
||||
|
||||
foreach (var d in Panels)
|
||||
d.Y = (float)(((ICarouselPanel)d).DrawYPosition + scrollableExtent);
|
||||
}
|
||||
}
|
||||
|
||||
private class BoundsCarouselItem : CarouselItem
|
||||
{
|
||||
public override float DrawHeight => 0;
|
||||
|
||||
public BoundsCarouselItem()
|
||||
: base(new object())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
44
osu.Game/Screens/SelectV2/CarouselItem.cs
Normal file
44
osu.Game/Screens/SelectV2/CarouselItem.cs
Normal file
@ -0,0 +1,44 @@
|
||||
// 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.Bindables;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single display item for display in a <see cref="Carousel{T}"/>.
|
||||
/// This is used to house information related to the attached model that helps with display and tracking.
|
||||
/// </summary>
|
||||
public abstract class CarouselItem : IComparable<CarouselItem>
|
||||
{
|
||||
public readonly BindableBool Selected = new BindableBool();
|
||||
|
||||
/// <summary>
|
||||
/// The model this item is representing.
|
||||
/// </summary>
|
||||
public readonly object Model;
|
||||
|
||||
/// <summary>
|
||||
/// The current Y position in the carousel. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
|
||||
/// </summary>
|
||||
public double CarouselYPosition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The height this item will take when displayed.
|
||||
/// </summary>
|
||||
public abstract float DrawHeight { get; }
|
||||
|
||||
protected CarouselItem(object model)
|
||||
{
|
||||
Model = model;
|
||||
}
|
||||
|
||||
public int CompareTo(CarouselItem? other)
|
||||
{
|
||||
if (other == null) return 1;
|
||||
|
||||
return CarouselYPosition.CompareTo(other.CarouselYPosition);
|
||||
}
|
||||
}
|
||||
}
|
23
osu.Game/Screens/SelectV2/ICarouselFilter.cs
Normal file
23
osu.Game/Screens/SelectV2/ICarouselFilter.cs
Normal file
@ -0,0 +1,23 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
/// <summary>
|
||||
/// An interface representing a filter operation which can be run on a <see cref="Carousel{T}"/>.
|
||||
/// </summary>
|
||||
public interface ICarouselFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute the filter operation.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to be filtered.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>The post-filtered items.</returns>
|
||||
Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
23
osu.Game/Screens/SelectV2/ICarouselPanel.cs
Normal file
23
osu.Game/Screens/SelectV2/ICarouselPanel.cs
Normal file
@ -0,0 +1,23 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
/// <summary>
|
||||
/// An interface to be attached to any <see cref="Drawable"/>s which are used for display inside a <see cref="Carousel{T}"/>.
|
||||
/// </summary>
|
||||
public interface ICarouselPanel
|
||||
{
|
||||
/// <summary>
|
||||
/// The Y position which should be used for displaying this item within the carousel. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
|
||||
/// </summary>
|
||||
double DrawYPosition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The carousel item this drawable is representing. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
|
||||
/// </summary>
|
||||
CarouselItem? Item { get; set; }
|
||||
}
|
||||
}
|
@ -35,7 +35,7 @@ namespace osu.Game.Storyboards.Drawables
|
||||
|
||||
protected override Container<DrawableStoryboardLayer> Content { get; }
|
||||
|
||||
protected override Vector2 DrawScale => new Vector2(Parent!.DrawHeight / 480);
|
||||
protected override Vector2 DrawScale => new Vector2((Parent?.DrawHeight ?? 0) / 480);
|
||||
|
||||
public override bool RemoveCompletedTransforms => false;
|
||||
|
||||
|
@ -11,6 +11,6 @@ namespace osu.Game.Tests.Beatmaps
|
||||
internal partial class TestBeatmapStore : BeatmapStore
|
||||
{
|
||||
public readonly BindableList<BeatmapSetInfo> BeatmapSets = new BindableList<BeatmapSetInfo>();
|
||||
public override IBindableList<BeatmapSetInfo> GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets;
|
||||
public override IBindableList<BeatmapSetInfo> GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets.GetBoundCopy();
|
||||
}
|
||||
}
|
||||
|
@ -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.114.1" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2025.115.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.1224.0" />
|
||||
<PackageReference Include="Sentry" Version="5.0.0" />
|
||||
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
|
||||
|
@ -17,6 +17,6 @@
|
||||
<MtouchInterpreter>-all</MtouchInterpreter>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.114.1" />
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.115.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
Loading…
Reference in New Issue
Block a user