mirror of
https://github.com/ppy/osu.git
synced 2025-01-31 05:32:57 +08:00
Merge branch 'master' into fix-editor-textbox-regressions
This commit is contained in:
commit
d87720da1b
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -96,7 +96,7 @@ jobs:
|
||||
|
||||
build-only-android:
|
||||
name: Build only (Android)
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2019
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
@ -10,7 +10,7 @@
|
||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.115.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.129.1" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<!-- Fody does not handle Android build well, and warns when unchanged.
|
||||
|
@ -82,7 +82,7 @@ namespace osu.Desktop
|
||||
};
|
||||
|
||||
client.OnReady += onReady;
|
||||
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Message} ({e.Code})", LoggingTarget.Network, LogLevel.Error);
|
||||
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Message} ({e.Code})", LoggingTarget.Network);
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -30,8 +30,6 @@ namespace osu.Desktop.Security
|
||||
|
||||
private partial class ElevatedPrivilegesNotification : SimpleNotification
|
||||
{
|
||||
public override bool IsImportant => true;
|
||||
|
||||
public ElevatedPrivilegesNotification()
|
||||
{
|
||||
Text = $"Running osu! as {(RuntimeInfo.IsUnix ? "root" : "administrator")} does not improve performance, may break integrations and poses a security risk. Please run the game as a normal user.";
|
||||
|
@ -14,6 +14,7 @@ using osu.Game.Graphics;
|
||||
using osu.Game.Rulesets.Catch.Beatmaps;
|
||||
using osu.Game.Rulesets.Catch.Difficulty;
|
||||
using osu.Game.Rulesets.Catch.Edit;
|
||||
using osu.Game.Rulesets.Catch.Edit.Setup;
|
||||
using osu.Game.Rulesets.Catch.Mods;
|
||||
using osu.Game.Rulesets.Catch.Objects;
|
||||
using osu.Game.Rulesets.Catch.Replays;
|
||||
@ -228,7 +229,7 @@ namespace osu.Game.Rulesets.Catch
|
||||
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
|
||||
[
|
||||
new MetadataSection(),
|
||||
new DifficultySection(),
|
||||
new CatchDifficultySection(),
|
||||
new FillFlowContainer
|
||||
{
|
||||
AutoSizeAxes = Axes.Y,
|
||||
|
125
osu.Game.Rulesets.Catch/Edit/Setup/CatchDifficultySection.cs
Normal file
125
osu.Game.Rulesets.Catch/Edit/Setup/CatchDifficultySection.cs
Normal file
@ -0,0 +1,125 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Linq;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Graphics.UserInterfaceV2;
|
||||
using osu.Game.Localisation;
|
||||
using osu.Game.Resources.Localisation.Web;
|
||||
using osu.Game.Screens.Edit.Setup;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Edit.Setup
|
||||
{
|
||||
public partial class CatchDifficultySection : SetupSection
|
||||
{
|
||||
private FormSliderBar<float> circleSizeSlider { get; set; } = null!;
|
||||
private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
|
||||
private FormSliderBar<float> approachRateSlider { get; set; } = null!;
|
||||
private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
|
||||
private FormSliderBar<double> tickRateSlider { get; set; } = null!;
|
||||
|
||||
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
Children = new Drawable[]
|
||||
{
|
||||
circleSizeSlider = new FormSliderBar<float>
|
||||
{
|
||||
Caption = BeatmapsetsStrings.ShowStatsCs,
|
||||
HintText = EditorSetupStrings.CircleSizeDescription,
|
||||
Current = new BindableFloat(Beatmap.Difficulty.CircleSize)
|
||||
{
|
||||
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
|
||||
MinValue = 0,
|
||||
MaxValue = 10,
|
||||
Precision = 0.1f,
|
||||
},
|
||||
TransferValueOnCommit = true,
|
||||
TabbableContentContainer = this,
|
||||
},
|
||||
healthDrainSlider = new FormSliderBar<float>
|
||||
{
|
||||
Caption = BeatmapsetsStrings.ShowStatsDrain,
|
||||
HintText = EditorSetupStrings.DrainRateDescription,
|
||||
Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
|
||||
{
|
||||
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
|
||||
MinValue = 0,
|
||||
MaxValue = 10,
|
||||
Precision = 0.1f,
|
||||
},
|
||||
TransferValueOnCommit = true,
|
||||
TabbableContentContainer = this,
|
||||
},
|
||||
approachRateSlider = new FormSliderBar<float>
|
||||
{
|
||||
Caption = BeatmapsetsStrings.ShowStatsAr,
|
||||
HintText = EditorSetupStrings.ApproachRateDescription,
|
||||
Current = new BindableFloat(Beatmap.Difficulty.ApproachRate)
|
||||
{
|
||||
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
|
||||
MinValue = 0,
|
||||
MaxValue = 10,
|
||||
Precision = 0.1f,
|
||||
},
|
||||
TransferValueOnCommit = true,
|
||||
TabbableContentContainer = this,
|
||||
},
|
||||
baseVelocitySlider = new FormSliderBar<double>
|
||||
{
|
||||
Caption = EditorSetupStrings.BaseVelocity,
|
||||
HintText = EditorSetupStrings.BaseVelocityDescription,
|
||||
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
|
||||
{
|
||||
Default = 1.4,
|
||||
MinValue = 0.4,
|
||||
MaxValue = 3.6,
|
||||
Precision = 0.01f,
|
||||
},
|
||||
TransferValueOnCommit = true,
|
||||
TabbableContentContainer = this,
|
||||
},
|
||||
tickRateSlider = new FormSliderBar<double>
|
||||
{
|
||||
Caption = EditorSetupStrings.TickRate,
|
||||
HintText = EditorSetupStrings.TickRateDescription,
|
||||
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
|
||||
{
|
||||
Default = 1,
|
||||
MinValue = 1,
|
||||
MaxValue = 4,
|
||||
Precision = 1,
|
||||
},
|
||||
TransferValueOnCommit = true,
|
||||
TabbableContentContainer = this,
|
||||
},
|
||||
};
|
||||
|
||||
foreach (var item in Children.OfType<FormSliderBar<float>>())
|
||||
item.Current.ValueChanged += _ => updateValues();
|
||||
|
||||
foreach (var item in Children.OfType<FormSliderBar<double>>())
|
||||
item.Current.ValueChanged += _ => updateValues();
|
||||
}
|
||||
|
||||
private void updateValues()
|
||||
{
|
||||
// for now, update these on commit rather than making BeatmapMetadata bindables.
|
||||
// after switching database engines we can reconsider if switching to bindables is a good direction.
|
||||
Beatmap.Difficulty.CircleSize = circleSizeSlider.Current.Value;
|
||||
Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value;
|
||||
Beatmap.Difficulty.ApproachRate = approachRateSlider.Current.Value;
|
||||
Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value;
|
||||
Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value;
|
||||
|
||||
Beatmap.UpdateAllHitObjects();
|
||||
Beatmap.SaveState();
|
||||
}
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@
|
||||
using System.Linq;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Screens.Play.HUD;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
@ -47,6 +48,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
return new DefaultSkinComponentsContainer(container =>
|
||||
{
|
||||
var keyCounter = container.OfType<LegacyKeyCounterDisplay>().FirstOrDefault();
|
||||
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
|
||||
|
||||
if (keyCounter != null)
|
||||
{
|
||||
@ -55,11 +57,19 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
keyCounter.Origin = Anchor.TopRight;
|
||||
keyCounter.Position = new Vector2(0, -40) * 1.6f;
|
||||
}
|
||||
|
||||
if (spectatorList != null)
|
||||
{
|
||||
spectatorList.Anchor = Anchor.BottomLeft;
|
||||
spectatorList.Origin = Anchor.BottomLeft;
|
||||
spectatorList.Position = new Vector2(10, -10);
|
||||
}
|
||||
})
|
||||
{
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new LegacyKeyCounterDisplay(),
|
||||
new SpectatorList(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -9,7 +9,9 @@ using osu.Framework.Testing;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Screens.Play.HUD;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Skinning.Argon
|
||||
@ -39,6 +41,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
|
||||
return new DefaultSkinComponentsContainer(container =>
|
||||
{
|
||||
var combo = container.ChildrenOfType<ArgonManiaComboCounter>().FirstOrDefault();
|
||||
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
|
||||
|
||||
if (combo != null)
|
||||
{
|
||||
@ -47,9 +50,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
|
||||
combo.Origin = Anchor.Centre;
|
||||
combo.Y = 200;
|
||||
}
|
||||
|
||||
if (spectatorList != null)
|
||||
spectatorList.Position = new Vector2(36, -66);
|
||||
})
|
||||
{
|
||||
new ArgonManiaComboCounter(),
|
||||
new SpectatorList
|
||||
{
|
||||
Anchor = Anchor.BottomLeft,
|
||||
Origin = Anchor.BottomLeft,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,9 @@ using osu.Game.Beatmaps;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps;
|
||||
using osu.Game.Rulesets.Objects.Legacy;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Screens.Play.HUD;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Skinning.Legacy
|
||||
{
|
||||
@ -95,6 +97,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
|
||||
return new DefaultSkinComponentsContainer(container =>
|
||||
{
|
||||
var combo = container.ChildrenOfType<LegacyManiaComboCounter>().FirstOrDefault();
|
||||
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
|
||||
|
||||
if (combo != null)
|
||||
{
|
||||
@ -102,9 +105,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
|
||||
combo.Origin = Anchor.Centre;
|
||||
combo.Y = this.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.ComboPosition)?.Value ?? 0;
|
||||
}
|
||||
|
||||
if (spectatorList != null)
|
||||
{
|
||||
spectatorList.Anchor = Anchor.BottomLeft;
|
||||
spectatorList.Origin = Anchor.BottomLeft;
|
||||
spectatorList.Position = new Vector2(10, -10);
|
||||
}
|
||||
})
|
||||
{
|
||||
new LegacyManiaComboCounter(),
|
||||
new SpectatorList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
186
osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs
Normal file
186
osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs
Normal file
@ -0,0 +1,186 @@
|
||||
// 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.Diagnostics;
|
||||
using System.Linq;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions;
|
||||
using osu.Game.Graphics.UserInterfaceV2;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Primitives;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Game.Extensions;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Input.Bindings;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.UI;
|
||||
using osu.Game.Screens.Edit;
|
||||
using osu.Game.Utils;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Edit
|
||||
{
|
||||
public partial class PreciseMovementPopover : OsuPopover
|
||||
{
|
||||
[Resolved]
|
||||
private EditorBeatmap editorBeatmap { get; set; } = null!;
|
||||
|
||||
private readonly Dictionary<HitObject, Vector2> initialPositions = new Dictionary<HitObject, Vector2>();
|
||||
private RectangleF initialSurroundingQuad;
|
||||
|
||||
private BindableNumber<float> xBindable = null!;
|
||||
private BindableNumber<float> yBindable = null!;
|
||||
|
||||
private SliderWithTextBoxInput<float> xInput = null!;
|
||||
private OsuCheckbox relativeCheckbox = null!;
|
||||
|
||||
public PreciseMovementPopover()
|
||||
{
|
||||
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
Child = new FillFlowContainer
|
||||
{
|
||||
Width = 220,
|
||||
AutoSizeAxes = Axes.Y,
|
||||
Spacing = new Vector2(20),
|
||||
Children = new Drawable[]
|
||||
{
|
||||
xInput = new SliderWithTextBoxInput<float>("X:")
|
||||
{
|
||||
Current = xBindable = new BindableNumber<float>
|
||||
{
|
||||
Precision = 1,
|
||||
},
|
||||
Instantaneous = true,
|
||||
TabbableContentContainer = this,
|
||||
},
|
||||
new SliderWithTextBoxInput<float>("Y:")
|
||||
{
|
||||
Current = yBindable = new BindableNumber<float>
|
||||
{
|
||||
Precision = 1,
|
||||
},
|
||||
Instantaneous = true,
|
||||
TabbableContentContainer = this,
|
||||
},
|
||||
relativeCheckbox = new OsuCheckbox(false)
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
LabelText = "Relative movement",
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
ScheduleAfterChildren(() => xInput.TakeFocus());
|
||||
}
|
||||
|
||||
protected override void PopIn()
|
||||
{
|
||||
base.PopIn();
|
||||
editorBeatmap.BeginChange();
|
||||
initialPositions.AddRange(editorBeatmap.SelectedHitObjects.Where(ho => ho is not Spinner).Select(ho => new KeyValuePair<HitObject, Vector2>(ho, ((IHasPosition)ho).Position)));
|
||||
initialSurroundingQuad = GeometryUtils.GetSurroundingQuad(initialPositions.Keys.Cast<IHasPosition>()).AABBFloat;
|
||||
|
||||
Debug.Assert(initialPositions.Count > 0);
|
||||
|
||||
if (initialPositions.Count > 1)
|
||||
{
|
||||
relativeCheckbox.Current.Value = true;
|
||||
relativeCheckbox.Current.Disabled = true;
|
||||
}
|
||||
|
||||
relativeCheckbox.Current.BindValueChanged(_ => relativeChanged(), true);
|
||||
xBindable.BindValueChanged(_ => applyPosition());
|
||||
yBindable.BindValueChanged(_ => applyPosition());
|
||||
}
|
||||
|
||||
protected override void PopOut()
|
||||
{
|
||||
base.PopOut();
|
||||
if (IsLoaded) editorBeatmap.EndChange();
|
||||
}
|
||||
|
||||
private void relativeChanged()
|
||||
{
|
||||
// reset bindable bounds to something that is guaranteed to be larger than any previous value.
|
||||
// this prevents crashes that can happen in the middle of changing the bounds, as updating both bound ends at the same is not atomic -
|
||||
// if the old and new bounds are disjoint, assigning X first can produce a situation where MinValue > MaxValue.
|
||||
(xBindable.MinValue, xBindable.MaxValue) = (float.MinValue, float.MaxValue);
|
||||
(yBindable.MinValue, yBindable.MaxValue) = (float.MinValue, float.MaxValue);
|
||||
|
||||
float previousX = xBindable.Value;
|
||||
float previousY = yBindable.Value;
|
||||
|
||||
if (relativeCheckbox.Current.Value)
|
||||
{
|
||||
(xBindable.MinValue, xBindable.MaxValue) = (0 - initialSurroundingQuad.TopLeft.X, OsuPlayfield.BASE_SIZE.X - initialSurroundingQuad.BottomRight.X);
|
||||
(yBindable.MinValue, yBindable.MaxValue) = (0 - initialSurroundingQuad.TopLeft.Y, OsuPlayfield.BASE_SIZE.Y - initialSurroundingQuad.BottomRight.Y);
|
||||
|
||||
xBindable.Default = yBindable.Default = 0;
|
||||
|
||||
if (initialPositions.Count == 1)
|
||||
{
|
||||
var initialPosition = initialPositions.Single().Value;
|
||||
xBindable.Value = previousX - initialPosition.X;
|
||||
yBindable.Value = previousY - initialPosition.Y;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Assert(initialPositions.Count == 1);
|
||||
var initialPosition = initialPositions.Single().Value;
|
||||
|
||||
var quadRelativeToPosition = new RectangleF(initialSurroundingQuad.Location - initialPosition, initialSurroundingQuad.Size);
|
||||
|
||||
(xBindable.MinValue, xBindable.MaxValue) = (0 - quadRelativeToPosition.TopLeft.X, OsuPlayfield.BASE_SIZE.X - quadRelativeToPosition.BottomRight.X);
|
||||
(yBindable.MinValue, yBindable.MaxValue) = (0 - quadRelativeToPosition.TopLeft.Y, OsuPlayfield.BASE_SIZE.Y - quadRelativeToPosition.BottomRight.Y);
|
||||
|
||||
xBindable.Default = initialPosition.X;
|
||||
yBindable.Default = initialPosition.Y;
|
||||
|
||||
xBindable.Value = xBindable.Default + previousX;
|
||||
yBindable.Value = yBindable.Default + previousY;
|
||||
}
|
||||
}
|
||||
|
||||
private void applyPosition()
|
||||
{
|
||||
editorBeatmap.PerformOnSelection(ho =>
|
||||
{
|
||||
if (!initialPositions.TryGetValue(ho, out var initialPosition))
|
||||
return;
|
||||
|
||||
var pos = new Vector2(xBindable.Value, yBindable.Value);
|
||||
if (relativeCheckbox.Current.Value)
|
||||
((IHasPosition)ho).Position = initialPosition + pos;
|
||||
else
|
||||
((IHasPosition)ho).Position = pos;
|
||||
});
|
||||
}
|
||||
|
||||
public override bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
|
||||
{
|
||||
if (e.Action == GlobalAction.Select && !e.Repeat)
|
||||
{
|
||||
this.HidePopover();
|
||||
return true;
|
||||
}
|
||||
|
||||
return base.OnPressed(e);
|
||||
}
|
||||
}
|
||||
}
|
@ -96,11 +96,7 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
ScheduleAfterChildren(() =>
|
||||
{
|
||||
angleInput.TakeFocus();
|
||||
angleInput.SelectAll();
|
||||
});
|
||||
ScheduleAfterChildren(() => angleInput.TakeFocus());
|
||||
angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue });
|
||||
|
||||
rotationHandler.CanRotateAroundSelectionOrigin.BindValueChanged(e =>
|
||||
|
@ -139,11 +139,7 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
ScheduleAfterChildren(() =>
|
||||
{
|
||||
scaleInput.TakeFocus();
|
||||
scaleInput.SelectAll();
|
||||
});
|
||||
ScheduleAfterChildren(() => scaleInput.TakeFocus());
|
||||
scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue });
|
||||
|
||||
xCheckBox.Current.BindValueChanged(_ =>
|
||||
|
@ -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.Linq;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
@ -10,6 +11,9 @@ using osu.Framework.Input.Bindings;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Game.Input.Bindings;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Screens.Edit;
|
||||
using osu.Game.Screens.Edit.Components;
|
||||
using osu.Game.Screens.Edit.Compose.Components;
|
||||
using osuTK;
|
||||
@ -18,9 +22,12 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
{
|
||||
public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler<GlobalAction>
|
||||
{
|
||||
private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
|
||||
private readonly BindableBool canMove = new BindableBool();
|
||||
private readonly AggregateBindable<bool> canRotate = new AggregateBindable<bool>((x, y) => x || y);
|
||||
private readonly AggregateBindable<bool> canScale = new AggregateBindable<bool>((x, y) => x || y);
|
||||
|
||||
private EditorToolButton moveButton = null!;
|
||||
private EditorToolButton rotateButton = null!;
|
||||
private EditorToolButton scaleButton = null!;
|
||||
|
||||
@ -35,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
private void load(EditorBeatmap editorBeatmap)
|
||||
{
|
||||
Child = new FillFlowContainer
|
||||
{
|
||||
@ -44,20 +51,27 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
Spacing = new Vector2(5),
|
||||
Children = new Drawable[]
|
||||
{
|
||||
moveButton = new EditorToolButton("Move",
|
||||
() => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt },
|
||||
() => new PreciseMovementPopover()),
|
||||
rotateButton = new EditorToolButton("Rotate",
|
||||
() => new SpriteIcon { Icon = FontAwesome.Solid.Undo },
|
||||
() => new PreciseRotationPopover(RotationHandler, GridToolbox)),
|
||||
scaleButton = new EditorToolButton("Scale",
|
||||
() => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt },
|
||||
() => new SpriteIcon { Icon = FontAwesome.Solid.ExpandArrowsAlt },
|
||||
() => new PreciseScalePopover(ScaleHandler, GridToolbox))
|
||||
}
|
||||
};
|
||||
|
||||
selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects);
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
selectedHitObjects.BindCollectionChanged((_, _) => canMove.Value = selectedHitObjects.Any(ho => ho is not Spinner), true);
|
||||
|
||||
canRotate.AddSource(RotationHandler.CanRotateAroundPlayfieldOrigin);
|
||||
canRotate.AddSource(RotationHandler.CanRotateAroundSelectionOrigin);
|
||||
|
||||
@ -67,6 +81,7 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
|
||||
// bindings to `Enabled` on the buttons are decoupled on purpose
|
||||
// due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set.
|
||||
canMove.BindValueChanged(move => moveButton.Enabled.Value = move.NewValue, true);
|
||||
canRotate.Result.BindValueChanged(rotate => rotateButton.Enabled.Value = rotate.NewValue, true);
|
||||
canScale.Result.BindValueChanged(scale => scaleButton.Enabled.Value = scale.NewValue, true);
|
||||
}
|
||||
@ -77,6 +92,12 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
|
||||
switch (e.Action)
|
||||
{
|
||||
case GlobalAction.EditorToggleMoveControl:
|
||||
{
|
||||
moveButton.TriggerClick();
|
||||
return true;
|
||||
}
|
||||
|
||||
case GlobalAction.EditorToggleRotateControl:
|
||||
{
|
||||
if (!RotationHandler.OperationInProgress.Value || rotateButton.Selected.Value)
|
||||
|
@ -6,6 +6,7 @@ using System.Linq;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Screens.Play.HUD;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
@ -70,12 +71,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
}
|
||||
|
||||
var combo = container.OfType<LegacyDefaultComboCounter>().FirstOrDefault();
|
||||
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
|
||||
|
||||
Vector2 pos = new Vector2();
|
||||
|
||||
if (combo != null)
|
||||
{
|
||||
combo.Anchor = Anchor.BottomLeft;
|
||||
combo.Origin = Anchor.BottomLeft;
|
||||
combo.Scale = new Vector2(1.28f);
|
||||
|
||||
pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X);
|
||||
}
|
||||
|
||||
if (spectatorList != null)
|
||||
{
|
||||
spectatorList.Anchor = Anchor.BottomLeft;
|
||||
spectatorList.Origin = Anchor.BottomLeft;
|
||||
spectatorList.Position = pos;
|
||||
}
|
||||
})
|
||||
{
|
||||
@ -83,6 +96,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
{
|
||||
new LegacyDefaultComboCounter(),
|
||||
new LegacyKeyCounterDisplay(),
|
||||
new SpectatorList(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
BIN
osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk
Normal file
BIN
osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk
Normal file
Binary file not shown.
@ -71,6 +71,8 @@ namespace osu.Game.Tests.Skins
|
||||
"Archives/modified-classic-20240724.osk",
|
||||
// Covers skinnable mod display
|
||||
"Archives/modified-default-20241207.osk",
|
||||
// Covers skinnable spectator list
|
||||
"Archives/modified-argon-20250116.osk",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Editing
|
||||
|
||||
AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1);
|
||||
|
||||
AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == newTime);
|
||||
AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(newTime, null));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -122,6 +122,8 @@ namespace osu.Game.Tests.Visual.Editing
|
||||
[TestCase(true)]
|
||||
public void TestCopyPaste(bool deselectAfterCopy)
|
||||
{
|
||||
const int paste_time = 2000;
|
||||
|
||||
var addedObject = new HitCircle { StartTime = 1000 };
|
||||
|
||||
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
|
||||
@ -130,7 +132,7 @@ namespace osu.Game.Tests.Visual.Editing
|
||||
|
||||
AddStep("copy hitobject", () => Editor.Copy());
|
||||
|
||||
AddStep("move forward in time", () => EditorClock.Seek(2000));
|
||||
AddStep("move forward in time", () => EditorClock.Seek(paste_time));
|
||||
|
||||
if (deselectAfterCopy)
|
||||
{
|
||||
@ -144,7 +146,7 @@ namespace osu.Game.Tests.Visual.Editing
|
||||
|
||||
AddAssert("are two objects", () => EditorBeatmap.HitObjects.Count == 2);
|
||||
|
||||
AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000);
|
||||
AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(paste_time, null));
|
||||
|
||||
AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType<Timeline>().First().ChildrenOfType<EditorSelectionHandler>().First().Alpha > 0);
|
||||
AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType<HitObjectComposer>().First().ChildrenOfType<EditorSelectionHandler>().First().Alpha > 0);
|
||||
|
@ -0,0 +1,82 @@
|
||||
// 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.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Osu;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Screens.Edit;
|
||||
using osu.Game.Tests.Beatmaps;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Editing
|
||||
{
|
||||
public partial class TestSceneEditorClipboardSnapping : EditorTestScene
|
||||
{
|
||||
protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
|
||||
|
||||
private const double beat_length = 60_000 / 180.0; // 180 bpm
|
||||
private const double timing_point_time = 1500;
|
||||
|
||||
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
|
||||
{
|
||||
var controlPointInfo = new ControlPointInfo();
|
||||
controlPointInfo.Add(timing_point_time, new TimingControlPoint { BeatLength = beat_length });
|
||||
return new TestBeatmap(ruleset, false)
|
||||
{
|
||||
ControlPointInfo = controlPointInfo
|
||||
};
|
||||
}
|
||||
|
||||
[TestCase(1)]
|
||||
[TestCase(2)]
|
||||
[TestCase(3)]
|
||||
[TestCase(4)]
|
||||
[TestCase(6)]
|
||||
[TestCase(8)]
|
||||
[TestCase(12)]
|
||||
[TestCase(16)]
|
||||
public void TestPasteSnapping(int divisor)
|
||||
{
|
||||
const double paste_time = timing_point_time + 1271; // arbitrary timestamp that doesn't snap to the timing point at any divisor
|
||||
|
||||
var addedObjects = new HitObject[]
|
||||
{
|
||||
new HitCircle { StartTime = 1000 },
|
||||
new HitCircle { StartTime = 1200 },
|
||||
};
|
||||
|
||||
AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects));
|
||||
AddStep("select added objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects));
|
||||
AddStep("copy hitobjects", () => Editor.Copy());
|
||||
|
||||
AddStep($"set beat divisor to 1/{divisor}", () =>
|
||||
{
|
||||
var beatDivisor = (BindableBeatDivisor)Editor.Dependencies.Get(typeof(BindableBeatDivisor));
|
||||
beatDivisor.SetArbitraryDivisor(divisor);
|
||||
});
|
||||
|
||||
AddStep("move forward in time", () => EditorClock.Seek(paste_time));
|
||||
AddAssert("not at snapped time", () => EditorClock.CurrentTime != EditorBeatmap.SnapTime(EditorClock.CurrentTime, null));
|
||||
|
||||
AddStep("paste hitobjects", () => Editor.Paste());
|
||||
|
||||
AddAssert("first object is snapped", () => Precision.AlmostEquals(
|
||||
EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!.StartTime,
|
||||
EditorBeatmap.ControlPointInfo.GetClosestSnappedTime(paste_time, divisor)
|
||||
));
|
||||
|
||||
AddAssert("duration between pasted objects is same", () =>
|
||||
{
|
||||
var firstObject = EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!;
|
||||
var secondObject = EditorBeatmap.SelectedHitObjects.MaxBy(h => h.StartTime)!;
|
||||
|
||||
return Precision.AlmostEquals(secondObject.StartTime - firstObject.StartTime, addedObjects[1].StartTime - addedObjects[0].StartTime);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -120,6 +120,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
public double FramesPerSecond => throw new NotImplementedException();
|
||||
public FrameTimeInfo TimeInfo => throw new NotImplementedException();
|
||||
public double StartTime => throw new NotImplementedException();
|
||||
public double GameplayStartTime => throw new NotImplementedException();
|
||||
|
||||
public IAdjustableAudioComponent AdjustmentsFromMods => adjustableAudioComponent;
|
||||
|
||||
|
@ -6,48 +6,74 @@ using NUnit.Framework;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Online.Spectator;
|
||||
using osu.Game.Rulesets.Osu;
|
||||
using osu.Game.Rulesets.Osu.Scoring;
|
||||
using osu.Game.Screens.Play;
|
||||
using osu.Game.Screens.Play.HUD;
|
||||
using osu.Game.Tests.Visual.Spectator;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Gameplay
|
||||
{
|
||||
[TestFixture]
|
||||
public partial class TestSceneSpectatorList : OsuTestScene
|
||||
{
|
||||
private readonly BindableList<SpectatorList.Spectator> spectators = new BindableList<SpectatorList.Spectator>();
|
||||
private readonly Bindable<LocalUserPlayingState> localUserPlayingState = new Bindable<LocalUserPlayingState>();
|
||||
|
||||
private int counter;
|
||||
|
||||
[Test]
|
||||
public void TestBasics()
|
||||
{
|
||||
SpectatorList list = null!;
|
||||
AddStep("create spectator list", () => Child = list = new SpectatorList
|
||||
Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>();
|
||||
GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), healthProcessor: new OsuHealthProcessor(0), localUserPlayingState: playingState);
|
||||
TestSpectatorClient client = new TestSpectatorClient();
|
||||
|
||||
AddStep("create spectator list", () =>
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Spectators = { BindTarget = spectators },
|
||||
UserPlayingState = { BindTarget = localUserPlayingState }
|
||||
Children = new Drawable[]
|
||||
{
|
||||
client,
|
||||
new DependencyProvidingContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
CachedDependencies =
|
||||
[
|
||||
(typeof(GameplayState), gameplayState),
|
||||
(typeof(SpectatorClient), client)
|
||||
],
|
||||
Child = list = new SpectatorList
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
AddStep("start playing", () => localUserPlayingState.Value = LocalUserPlayingState.Playing);
|
||||
AddStep("start playing", () => playingState.Value = LocalUserPlayingState.Playing);
|
||||
|
||||
AddRepeatStep("add a user", () =>
|
||||
{
|
||||
int id = Interlocked.Increment(ref counter);
|
||||
spectators.Add(new SpectatorList.Spectator(id, $"User {id}"));
|
||||
((ISpectatorClient)client).UserStartedWatching([
|
||||
new SpectatorUser
|
||||
{
|
||||
OnlineID = id,
|
||||
Username = $"User {id}"
|
||||
}
|
||||
]);
|
||||
}, 10);
|
||||
|
||||
AddRepeatStep("remove random user", () => spectators.RemoveAt(RNG.Next(0, spectators.Count)), 5);
|
||||
AddRepeatStep("remove random user", () => ((ISpectatorClient)client).UserEndedWatching(client.WatchingUsers[RNG.Next(client.WatchingUsers.Count)].OnlineID), 5);
|
||||
|
||||
AddStep("change font to venera", () => list.Font.Value = Typeface.Venera);
|
||||
AddStep("change font to torus", () => list.Font.Value = Typeface.Torus);
|
||||
AddStep("change header colour", () => list.HeaderColour.Value = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1));
|
||||
|
||||
AddStep("enter break", () => localUserPlayingState.Value = LocalUserPlayingState.Break);
|
||||
AddStep("stop playing", () => localUserPlayingState.Value = LocalUserPlayingState.NotPlaying);
|
||||
AddStep("enter break", () => playingState.Value = LocalUserPlayingState.Break);
|
||||
AddStep("stop playing", () => playingState.Value = LocalUserPlayingState.NotPlaying);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,6 +48,7 @@ using osu.Game.Screens.Select.Carousel;
|
||||
using osu.Game.Screens.Select.Leaderboards;
|
||||
using osu.Game.Screens.Select.Options;
|
||||
using osu.Game.Tests.Beatmaps.IO;
|
||||
using osu.Game.Tests.Resources;
|
||||
using osu.Game.Utils;
|
||||
using osuTK;
|
||||
using osuTK.Input;
|
||||
@ -202,6 +203,38 @@ namespace osu.Game.Tests.Visual.Navigation
|
||||
TextBox filterControlTextBox() => songSelect.ChildrenOfType<FilterControl.FilterControlTextBox>().Single();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestSongSelectRandomRewindButton()
|
||||
{
|
||||
Guid? originalSelection = null;
|
||||
TestPlaySongSelect songSelect = null;
|
||||
|
||||
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
|
||||
AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded);
|
||||
|
||||
AddStep("Add two beatmaps", () =>
|
||||
{
|
||||
Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(8));
|
||||
Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(8));
|
||||
});
|
||||
|
||||
AddUntilStep("wait for selected", () =>
|
||||
{
|
||||
originalSelection = Game.Beatmap.Value.BeatmapInfo.ID;
|
||||
return !Game.Beatmap.IsDefault;
|
||||
});
|
||||
|
||||
AddStep("hit random", () =>
|
||||
{
|
||||
InputManager.MoveMouseTo(Game.ChildrenOfType<FooterButtonRandom>().Single());
|
||||
InputManager.Click(MouseButton.Left);
|
||||
});
|
||||
AddUntilStep("wait for selection changed", () => Game.Beatmap.Value.BeatmapInfo.ID, () => Is.Not.EqualTo(originalSelection));
|
||||
|
||||
AddStep("hit random rewind", () => InputManager.Click(MouseButton.Right));
|
||||
AddUntilStep("wait for selection reverted", () => Game.Beatmap.Value.BeatmapInfo.ID, () => Is.EqualTo(originalSelection));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestSongSelectScrollHandling()
|
||||
{
|
||||
|
@ -7,6 +7,7 @@ using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osu.Game.Overlays;
|
||||
using osu.Game.Overlays.Profile;
|
||||
@ -20,24 +21,16 @@ namespace osu.Game.Tests.Visual.Online
|
||||
public partial class TestSceneUserProfileDailyChallenge : OsuManualInputManagerTestScene
|
||||
{
|
||||
[Cached]
|
||||
public readonly Bindable<UserProfileData?> User = new Bindable<UserProfileData?>(new UserProfileData(new APIUser(), new OsuRuleset().RulesetInfo));
|
||||
private readonly Bindable<UserProfileData?> userProfileData = new Bindable<UserProfileData?>(new UserProfileData(new APIUser(), new OsuRuleset().RulesetInfo));
|
||||
|
||||
[Cached]
|
||||
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink);
|
||||
|
||||
protected override void LoadComplete()
|
||||
private DailyChallengeStatsDisplay display = null!;
|
||||
|
||||
[SetUpSteps]
|
||||
public void SetUpSteps()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
DailyChallengeStatsDisplay display = null!;
|
||||
|
||||
AddSliderStep("daily", 0, 999, 2, v => update(s => s.DailyStreakCurrent = v));
|
||||
AddSliderStep("daily best", 0, 999, 2, v => update(s => s.DailyStreakBest = v));
|
||||
AddSliderStep("weekly", 0, 250, 1, v => update(s => s.WeeklyStreakCurrent = v));
|
||||
AddSliderStep("weekly best", 0, 250, 1, v => update(s => s.WeeklyStreakBest = v));
|
||||
AddSliderStep("top 10%", 0, 999, 0, v => update(s => s.Top10PercentPlacements = v));
|
||||
AddSliderStep("top 50%", 0, 999, 0, v => update(s => s.Top50PercentPlacements = v));
|
||||
AddSliderStep("playcount", 0, 1500, 1, v => update(s => s.PlayCount = v));
|
||||
AddStep("create", () =>
|
||||
{
|
||||
Clear();
|
||||
@ -51,16 +44,40 @@ namespace osu.Game.Tests.Visual.Online
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Scale = new Vector2(1f),
|
||||
User = { BindTarget = User },
|
||||
User = { BindTarget = userProfileData },
|
||||
});
|
||||
});
|
||||
|
||||
AddStep("set local user", () => update(s => s.UserID = API.LocalUser.Value.Id));
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
AddSliderStep("daily", 0, 999, 2, v => update(s => s.DailyStreakCurrent = v));
|
||||
AddSliderStep("daily best", 0, 999, 2, v => update(s => s.DailyStreakBest = v));
|
||||
AddSliderStep("weekly", 0, 250, 1, v => update(s => s.WeeklyStreakCurrent = v));
|
||||
AddSliderStep("weekly best", 0, 250, 1, v => update(s => s.WeeklyStreakBest = v));
|
||||
AddSliderStep("top 10%", 0, 999, 0, v => update(s => s.Top10PercentPlacements = v));
|
||||
AddSliderStep("top 50%", 0, 999, 0, v => update(s => s.Top50PercentPlacements = v));
|
||||
AddSliderStep("playcount", 0, 1500, 1, v => update(s => s.PlayCount = v));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestStates()
|
||||
{
|
||||
AddStep("played today", () => update(s => s.LastUpdate = DateTimeOffset.UtcNow.Date));
|
||||
AddStep("played yesterday", () => update(s => s.LastUpdate = DateTimeOffset.UtcNow.Date.AddDays(-1)));
|
||||
AddStep("change to non-local user", () => update(s => s.UserID = API.LocalUser.Value.Id + 1000));
|
||||
|
||||
AddStep("hover", () => InputManager.MoveMouseTo(display));
|
||||
}
|
||||
|
||||
private void update(Action<APIUserDailyChallengeStatistics> change)
|
||||
{
|
||||
change.Invoke(User.Value!.User.DailyChallengeStatistics);
|
||||
User.Value = new UserProfileData(User.Value.User, User.Value.Ruleset);
|
||||
change.Invoke(userProfileData.Value!.User.DailyChallengeStatistics);
|
||||
userProfileData.Value = new UserProfileData(userProfileData.Value.User, userProfileData.Value.Ruleset);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
189
osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs
Normal file
189
osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs
Normal 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.Linq;
|
||||
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.Shapes;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Graphics;
|
||||
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
|
||||
{
|
||||
public abstract partial class BeatmapCarouselV2TestScene : OsuManualInputManagerTestScene
|
||||
{
|
||||
protected readonly BindableList<BeatmapSetInfo> BeatmapSets = new BindableList<BeatmapSetInfo>();
|
||||
|
||||
protected BeatmapCarousel Carousel = null!;
|
||||
|
||||
protected OsuScrollContainer<Drawable> Scroll => Carousel.ChildrenOfType<OsuScrollContainer<Drawable>>().Single();
|
||||
|
||||
[Cached(typeof(BeatmapStore))]
|
||||
private BeatmapStore store;
|
||||
|
||||
private OsuTextFlowContainer stats = null!;
|
||||
|
||||
private int beatmapCount;
|
||||
|
||||
protected BeatmapCarouselV2TestScene()
|
||||
{
|
||||
store = new TestBeatmapStore
|
||||
{
|
||||
BeatmapSets = { BindTarget = BeatmapSets }
|
||||
};
|
||||
|
||||
BeatmapSets.BindCollectionChanged((_, _) => beatmapCount = BeatmapSets.Sum(s => s.Beatmaps.Count));
|
||||
|
||||
Scheduler.AddDelayed(updateStats, 100, true);
|
||||
}
|
||||
|
||||
[SetUpSteps]
|
||||
public void SetUpSteps()
|
||||
{
|
||||
RemoveAllBeatmaps();
|
||||
|
||||
CreateCarousel();
|
||||
|
||||
SortBy(new FilterCriteria { Sort = SortMode.Title });
|
||||
}
|
||||
|
||||
protected void CreateCarousel()
|
||||
{
|
||||
AddStep("create components", () =>
|
||||
{
|
||||
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
|
||||
{
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Padding = new MarginPadding(10),
|
||||
TextAnchor = Anchor.CentreLeft,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected void SortBy(FilterCriteria criteria) => AddStep($"sort by {criteria.Sort}", () => 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));
|
||||
|
||||
/// <summary>
|
||||
/// Add requested beatmap sets count to list.
|
||||
/// </summary>
|
||||
/// <param name="count">The count of beatmap sets to add.</param>
|
||||
/// <param name="fixedDifficultiesPerSet">If not null, the number of difficulties per set. If null, randomised difficulty count will be used.</param>
|
||||
protected void AddBeatmaps(int count, int? fixedDifficultiesPerSet = null) => AddStep($"add {count} beatmaps", () =>
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(fixedDifficultiesPerSet ?? RNG.Next(1, 4)));
|
||||
});
|
||||
|
||||
protected void RemoveAllBeatmaps() => AddStep("clear all beatmaps", () => BeatmapSets.Clear());
|
||||
|
||||
protected void RemoveFirstBeatmap() =>
|
||||
AddStep("remove first beatmap", () =>
|
||||
{
|
||||
if (BeatmapSets.Count == 0) return;
|
||||
|
||||
BeatmapSets.Remove(BeatmapSets.First());
|
||||
});
|
||||
|
||||
private void updateStats()
|
||||
{
|
||||
if (Carousel.IsNull())
|
||||
return;
|
||||
|
||||
stats.Clear();
|
||||
createHeader("beatmap store");
|
||||
stats.AddParagraph($"""
|
||||
sets: {BeatmapSets.Count}
|
||||
beatmaps: {beatmapCount}
|
||||
""");
|
||||
createHeader("carousel");
|
||||
stats.AddParagraph($"""
|
||||
sorting: {Carousel.IsFiltering}
|
||||
tracked: {Carousel.ItemsTracked}
|
||||
displayable: {Carousel.DisplayableItems}
|
||||
displayed: {Carousel.VisibleItems}
|
||||
selected: {Carousel.CurrentSelection}
|
||||
""");
|
||||
|
||||
void createHeader(string text)
|
||||
{
|
||||
stats.AddParagraph(string.Empty);
|
||||
stats.AddParagraph(text, cp =>
|
||||
{
|
||||
cp.Font = cp.Font.With(size: 18, weight: FontWeight.Bold);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,273 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.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<Drawable> scroll => carousel.ChildrenOfType<OsuScrollContainer<Drawable>>().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}
|
||||
""";
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
// 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.Graphics.Primitives;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Screens.Select;
|
||||
using osu.Game.Screens.Select.Filter;
|
||||
using osu.Game.Screens.SelectV2;
|
||||
using osu.Game.Tests.Resources;
|
||||
|
||||
namespace osu.Game.Tests.Visual.SongSelect
|
||||
{
|
||||
/// <summary>
|
||||
/// Currently covers adding and removing of items and scrolling.
|
||||
/// If we add more tests here, these two categories can likely be split out into separate scenes.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public partial class TestSceneBeatmapCarouselV2Basics : BeatmapCarouselV2TestScene
|
||||
{
|
||||
[Test]
|
||||
public void TestBasics()
|
||||
{
|
||||
AddBeatmaps(1);
|
||||
AddBeatmaps(10);
|
||||
RemoveFirstBeatmap();
|
||||
RemoveAllBeatmaps();
|
||||
}
|
||||
|
||||
[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]
|
||||
public void TestSorting()
|
||||
{
|
||||
AddBeatmaps(10);
|
||||
SortBy(new FilterCriteria { Sort = SortMode.Difficulty });
|
||||
SortBy(new FilterCriteria { Sort = SortMode.Artist });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestScrollPositionMaintainedOnAddSecondSelected()
|
||||
{
|
||||
Quad positionBefore = default;
|
||||
|
||||
AddBeatmaps(10);
|
||||
WaitForDrawablePanels();
|
||||
|
||||
AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2));
|
||||
AddStep("scroll to selected item", () => Scroll.ScrollTo(Scroll.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value)));
|
||||
|
||||
WaitForScrolling();
|
||||
|
||||
AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType<BeatmapPanel>().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad);
|
||||
|
||||
RemoveFirstBeatmap();
|
||||
WaitForSorting();
|
||||
|
||||
AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value).ScreenSpaceDrawQuad,
|
||||
() => Is.EqualTo(positionBefore));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestScrollPositionMaintainedOnAddLastSelected()
|
||||
{
|
||||
Quad positionBefore = default;
|
||||
|
||||
AddBeatmaps(10);
|
||||
WaitForDrawablePanels();
|
||||
|
||||
AddStep("scroll to last item", () => Scroll.ScrollToEnd(false));
|
||||
|
||||
AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last());
|
||||
|
||||
WaitForScrolling();
|
||||
|
||||
AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType<BeatmapPanel>().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad);
|
||||
|
||||
RemoveFirstBeatmap();
|
||||
WaitForSorting();
|
||||
AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value).ScreenSpaceDrawQuad,
|
||||
() => Is.EqualTo(positionBefore));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Explicit]
|
||||
public void TestPerformanceWithManyBeatmaps()
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,216 @@
|
||||
// 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.SelectV2;
|
||||
using osuTK.Input;
|
||||
|
||||
namespace osu.Game.Tests.Visual.SongSelect
|
||||
{
|
||||
[TestFixture]
|
||||
public partial class TestSceneBeatmapCarouselV2Selection : BeatmapCarouselV2TestScene
|
||||
{
|
||||
/// <summary>
|
||||
/// Keyboard selection via up and down arrows doesn't actually change the selection until
|
||||
/// the select key is pressed.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TestKeyboardSelectionKeyRepeat()
|
||||
{
|
||||
AddBeatmaps(10);
|
||||
WaitForDrawablePanels();
|
||||
checkNoSelection();
|
||||
|
||||
select();
|
||||
checkNoSelection();
|
||||
|
||||
AddStep("press down arrow", () => InputManager.PressKey(Key.Down));
|
||||
checkSelectionIterating(false);
|
||||
|
||||
AddStep("press up arrow", () => InputManager.PressKey(Key.Up));
|
||||
checkSelectionIterating(false);
|
||||
|
||||
AddStep("release down arrow", () => InputManager.ReleaseKey(Key.Down));
|
||||
checkSelectionIterating(false);
|
||||
|
||||
AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up));
|
||||
checkSelectionIterating(false);
|
||||
|
||||
select();
|
||||
checkHasSelection();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keyboard selection via left and right arrows moves between groups, updating the selection
|
||||
/// immediately.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TestGroupSelectionKeyRepeat()
|
||||
{
|
||||
AddBeatmaps(10);
|
||||
WaitForDrawablePanels();
|
||||
checkNoSelection();
|
||||
|
||||
AddStep("press right arrow", () => InputManager.PressKey(Key.Right));
|
||||
checkSelectionIterating(true);
|
||||
|
||||
AddStep("press left arrow", () => InputManager.PressKey(Key.Left));
|
||||
checkSelectionIterating(true);
|
||||
|
||||
AddStep("release right arrow", () => InputManager.ReleaseKey(Key.Right));
|
||||
checkSelectionIterating(true);
|
||||
|
||||
AddStep("release left arrow", () => InputManager.ReleaseKey(Key.Left));
|
||||
checkSelectionIterating(false);
|
||||
}
|
||||
|
||||
[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!));
|
||||
|
||||
AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
|
||||
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
|
||||
|
||||
BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapPanel>().SingleOrDefault(p => p.Selected.Value);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestTraversalBeyondStart()
|
||||
{
|
||||
const int total_set_count = 200;
|
||||
|
||||
AddBeatmaps(total_set_count);
|
||||
WaitForDrawablePanels();
|
||||
|
||||
selectNextGroup();
|
||||
waitForSelection(0, 0);
|
||||
selectPrevGroup();
|
||||
waitForSelection(total_set_count - 1, 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestTraversalBeyondEnd()
|
||||
{
|
||||
const int total_set_count = 200;
|
||||
|
||||
AddBeatmaps(total_set_count);
|
||||
WaitForDrawablePanels();
|
||||
|
||||
selectPrevGroup();
|
||||
waitForSelection(total_set_count - 1, 0);
|
||||
selectNextGroup();
|
||||
waitForSelection(0, 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestKeyboardSelection()
|
||||
{
|
||||
AddBeatmaps(10, 3);
|
||||
WaitForDrawablePanels();
|
||||
|
||||
selectNextPanel();
|
||||
selectNextPanel();
|
||||
selectNextPanel();
|
||||
selectNextPanel();
|
||||
checkNoSelection();
|
||||
|
||||
select();
|
||||
waitForSelection(3, 0);
|
||||
|
||||
selectNextPanel();
|
||||
waitForSelection(3, 0);
|
||||
|
||||
select();
|
||||
waitForSelection(3, 1);
|
||||
|
||||
selectNextPanel();
|
||||
waitForSelection(3, 1);
|
||||
|
||||
select();
|
||||
waitForSelection(3, 2);
|
||||
|
||||
selectNextPanel();
|
||||
waitForSelection(3, 2);
|
||||
|
||||
select();
|
||||
waitForSelection(4, 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestEmptyTraversal()
|
||||
{
|
||||
selectNextPanel();
|
||||
checkNoSelection();
|
||||
|
||||
selectNextGroup();
|
||||
checkNoSelection();
|
||||
|
||||
selectPrevPanel();
|
||||
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;
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
AddStep("store selection", () => selection = Carousel.CurrentSelection);
|
||||
if (isIterating)
|
||||
AddUntilStep("selection changed", () => Carousel.CurrentSelection != selection);
|
||||
else
|
||||
AddUntilStep("selection not changed", () => Carousel.CurrentSelection == selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -83,6 +83,40 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
waitForCompletion();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestNormalDoesForwardToOverlay()
|
||||
{
|
||||
SimpleNotification notification = null!;
|
||||
|
||||
AddStep(@"simple #1", () => notificationOverlay.Post(notification = new SimpleNotification
|
||||
{
|
||||
Text = @"This shouldn't annoy you too much",
|
||||
Transient = false,
|
||||
}));
|
||||
|
||||
AddAssert("notification in toast tray", () => notification.IsInToastTray, () => Is.True);
|
||||
AddUntilStep("wait for dismissed", () => notification.IsInToastTray, () => Is.False);
|
||||
|
||||
checkDisplayedCount(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestTransientDoesNotForwardToOverlay()
|
||||
{
|
||||
SimpleNotification notification = null!;
|
||||
|
||||
AddStep(@"simple #1", () => notificationOverlay.Post(notification = new SimpleNotification
|
||||
{
|
||||
Text = @"This shouldn't annoy you too much",
|
||||
Transient = true,
|
||||
}));
|
||||
|
||||
AddAssert("notification in toast tray", () => notification.IsInToastTray, () => Is.True);
|
||||
AddUntilStep("wait for dismissed", () => notification.IsInToastTray, () => Is.False);
|
||||
|
||||
checkDisplayedCount(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestForwardWithFlingRight()
|
||||
{
|
||||
@ -634,12 +668,18 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
|
||||
private partial class BackgroundNotification : SimpleNotification
|
||||
{
|
||||
public override bool IsImportant => false;
|
||||
public BackgroundNotification()
|
||||
{
|
||||
IsImportant = false;
|
||||
}
|
||||
}
|
||||
|
||||
private partial class BackgroundProgressNotification : ProgressNotification
|
||||
{
|
||||
public override bool IsImportant => false;
|
||||
public BackgroundProgressNotification()
|
||||
{
|
||||
IsImportant = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -292,7 +292,7 @@ namespace osu.Game.Beatmaps.Drawables
|
||||
"1407228 II-L - VANGUARD-1.osz",
|
||||
"1422686 II-L - VANGUARD-2.osz",
|
||||
"1429217 Street - Phi.osz",
|
||||
"1442235 2ToneDisco x Cosmicosmo - Shoelaces (feat. Puniden).osz",
|
||||
"1442235 2ToneDisco x Cosmicosmo - Shoelaces (feat. Puniden).osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/157
|
||||
"1447478 Cres. - End Time.osz",
|
||||
"1449942 m108 - Crescent Sakura.osz",
|
||||
"1463778 MuryokuP - A tree without a branch.osz",
|
||||
@ -336,8 +336,8 @@ namespace osu.Game.Beatmaps.Drawables
|
||||
"1854710 Blaster & Extra Terra - Spacecraft (Cut Ver.).osz",
|
||||
"1859322 Hino Isuka - Delightness Brightness.osz",
|
||||
"1884102 Maduk - Go (feat. Lachi) (Cut Ver.).osz",
|
||||
"1884578 Neko Hacker - People People feat. Nanahira.osz",
|
||||
"1897902 uma vs. Morimori Atsushi - Re: End of a Dream.osz",
|
||||
"1884578 Neko Hacker - People People feat. Nanahira.osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/266
|
||||
"1897902 uma vs. Morimori Atsushi - Re: End of a Dream.osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/108
|
||||
"1905582 KINEMA106 - Fly Away (Cut Ver.).osz",
|
||||
"1934686 ARForest - Rainbow Magic!!.osz",
|
||||
"1963076 METAROOM - S.N.U.F.F.Y.osz",
|
||||
@ -345,7 +345,6 @@ namespace osu.Game.Beatmaps.Drawables
|
||||
"1971951 James Landino - Shiba Paradise.osz",
|
||||
"1972518 Toromaru - Sleight of Hand.osz",
|
||||
"1982302 KINEMA106 - INVITE.osz",
|
||||
"1983475 KNOWER - The Government Knows.osz",
|
||||
"2010165 Junk - Yellow Smile (bms edit).osz",
|
||||
"2022737 Andora - Euphoria (feat. WaMi).osz",
|
||||
"2025023 tephe - Genjitsu Escape.osz",
|
||||
|
@ -113,6 +113,31 @@ namespace osu.Game.Beatmaps
|
||||
return queryCacheVersion2(db, beatmapInfo, out onlineMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
onlineMetadata = null;
|
||||
return false;
|
||||
}
|
||||
catch (SqliteException sqliteException)
|
||||
{
|
||||
onlineMetadata = null;
|
||||
|
||||
// There have been cases where the user's local database is corrupt.
|
||||
// Let's attempt to identify these cases and re-initialise the local cache.
|
||||
switch (sqliteException.SqliteErrorCode)
|
||||
{
|
||||
case 26: // SQLITE_NOTADB
|
||||
case 11: // SQLITE_CORRUPT
|
||||
// only attempt purge & re-download if there is no other refetch in progress
|
||||
if (cacheDownloadRequest != null)
|
||||
return false;
|
||||
|
||||
tryPurgeCache();
|
||||
prepareLocalCache();
|
||||
return false;
|
||||
}
|
||||
|
||||
logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} failed with unhandled sqlite error {sqliteException}.");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -120,9 +145,22 @@ namespace osu.Game.Beatmaps
|
||||
onlineMetadata = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
onlineMetadata = null;
|
||||
return false;
|
||||
private void tryPurgeCache()
|
||||
{
|
||||
log(@"Local metadata cache is corrupted; attempting purge.");
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(storage.GetFullPath(cache_database_name));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($@"Failed to purge local metadata cache: {ex}");
|
||||
}
|
||||
|
||||
log(@"Local metadata cache purged due to corruption.");
|
||||
}
|
||||
|
||||
private SqliteConnection getConnection() =>
|
||||
|
@ -131,8 +131,6 @@ namespace osu.Game.Database
|
||||
|
||||
private partial class DownloadNotification : ProgressNotification
|
||||
{
|
||||
public override bool IsImportant => false;
|
||||
|
||||
protected override Notification CreateCompletionNotification() => new SilencedProgressCompletionNotification
|
||||
{
|
||||
Activated = CompletionClickAction,
|
||||
@ -141,7 +139,10 @@ namespace osu.Game.Database
|
||||
|
||||
private partial class SilencedProgressCompletionNotification : ProgressCompletionNotification
|
||||
{
|
||||
public override bool IsImportant => false;
|
||||
public SilencedProgressCompletionNotification()
|
||||
{
|
||||
IsImportant = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,6 @@ using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Development;
|
||||
using osu.Framework.Extensions;
|
||||
@ -97,8 +96,9 @@ namespace osu.Game.Database
|
||||
/// 44 2024-11-22 Removed several properties from BeatmapInfo which did not need to be persisted to realm.
|
||||
/// 45 2024-12-23 Change beat snap divisor adjust defaults to be Ctrl+Scroll instead of Ctrl+Shift+Scroll, if not already changed by user.
|
||||
/// 46 2024-12-26 Change beat snap divisor bindings to match stable directionality ¯\_(ツ)_/¯.
|
||||
/// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions.
|
||||
/// </summary>
|
||||
private const int schema_version = 46;
|
||||
private const int schema_version = 47;
|
||||
|
||||
/// <summary>
|
||||
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
|
||||
@ -413,18 +413,7 @@ namespace osu.Game.Database
|
||||
/// Compact this realm.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public bool Compact()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Realm.Compact(getConfiguration());
|
||||
}
|
||||
// Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator).
|
||||
catch (AggregateException ae) when (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && ae.Flatten().InnerException is TypeInitializationException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
public bool Compact() => Realm.Compact(getConfiguration());
|
||||
|
||||
/// <summary>
|
||||
/// Run work on realm with a return value.
|
||||
@ -720,11 +709,6 @@ namespace osu.Game.Database
|
||||
|
||||
return Realm.GetInstance(getConfiguration());
|
||||
}
|
||||
// Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator).
|
||||
catch (AggregateException ae) when (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && ae.Flatten().InnerException is TypeInitializationException)
|
||||
{
|
||||
return Realm.GetInstance();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tookSemaphoreLock)
|
||||
@ -1239,6 +1223,17 @@ namespace osu.Game.Database
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 47:
|
||||
{
|
||||
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
|
||||
|
||||
var existingBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.AbsoluteScrollSongList);
|
||||
if (existingBinding != null && existingBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.MouseRight }))
|
||||
migration.NewRealm.Remove(existingBinding);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
@ -14,7 +14,6 @@ using osu.Framework.Input.Events;
|
||||
using osu.Game.Input.Bindings;
|
||||
using osu.Game.Overlays;
|
||||
using osuTK;
|
||||
using osuTK.Input;
|
||||
|
||||
namespace osu.Game.Graphics.UserInterfaceV2
|
||||
{
|
||||
@ -75,14 +74,6 @@ namespace osu.Game.Graphics.UserInterfaceV2
|
||||
samplePopOut?.Play();
|
||||
}
|
||||
|
||||
protected override bool OnKeyDown(KeyDownEvent e)
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
return false; // disable the framework-level handling of escape key for conformity (we use GlobalAction.Back).
|
||||
|
||||
return base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
public virtual bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
|
||||
{
|
||||
if (e.Repeat)
|
||||
|
@ -32,6 +32,11 @@ namespace osu.Game.Graphics.UserInterfaceV2
|
||||
set => slider.Current = value;
|
||||
}
|
||||
|
||||
public CompositeDrawable TabbableContentContainer
|
||||
{
|
||||
set => textBox.TabbableContentContainer = value;
|
||||
}
|
||||
|
||||
private bool instantaneous;
|
||||
|
||||
/// <summary>
|
||||
@ -69,6 +74,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
|
||||
textBox = new LabelledTextBox
|
||||
{
|
||||
Label = labelText,
|
||||
SelectAllOnFocus = true,
|
||||
},
|
||||
slider = new SettingsSlider<T>
|
||||
{
|
||||
@ -87,8 +93,6 @@ namespace osu.Game.Graphics.UserInterfaceV2
|
||||
|
||||
public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true;
|
||||
|
||||
public bool SelectAll() => textBox.SelectAll();
|
||||
|
||||
private bool updatingFromTextBox;
|
||||
|
||||
private void textChanged(ValueChangedEvent<string> change)
|
||||
|
@ -9,6 +9,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Toolkit.HighPerformance;
|
||||
using osu.Framework.Extensions;
|
||||
using osu.Framework.IO.Stores;
|
||||
using SharpCompress.Archives.Zip;
|
||||
using SharpCompress.Common;
|
||||
@ -54,12 +55,22 @@ namespace osu.Game.IO.Archives
|
||||
if (entry == null)
|
||||
return null;
|
||||
|
||||
var owner = MemoryAllocator.Default.Allocate<byte>((int)entry.Size);
|
||||
|
||||
using (Stream s = entry.OpenEntryStream())
|
||||
s.ReadExactly(owner.Memory.Span);
|
||||
{
|
||||
if (entry.Size > 0)
|
||||
{
|
||||
var owner = MemoryAllocator.Default.Allocate<byte>((int)entry.Size);
|
||||
s.ReadExactly(owner.Memory.Span);
|
||||
return new MemoryOwnerMemoryStream(owner);
|
||||
}
|
||||
|
||||
return new MemoryOwnerMemoryStream(owner);
|
||||
// due to a sharpcompress bug (https://github.com/adamhathcock/sharpcompress/issues/88),
|
||||
// in rare instances the `ZipArchiveEntry` will not contain a correct `Size` but instead report 0.
|
||||
// this would lead to the block above reading nothing, and the game basically seeing an archive full of empty files.
|
||||
// since the bug is years old now, and this is a rather rare situation anyways (reported once in years),
|
||||
// work around this locally by falling back to reading as many bytes as possible and using a standard non-pooled memory stream.
|
||||
return new MemoryStream(s.ReadAllRemainingBytesToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
|
@ -144,6 +144,7 @@ namespace osu.Game.Input.Bindings
|
||||
new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing),
|
||||
new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelDown }, GlobalAction.EditorCyclePreviousBeatSnapDivisor),
|
||||
new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelUp }, GlobalAction.EditorCycleNextBeatSnapDivisor),
|
||||
new KeyBinding(InputKey.None, GlobalAction.EditorToggleMoveControl),
|
||||
new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl),
|
||||
new KeyBinding(new[] { InputKey.Control, InputKey.E }, GlobalAction.EditorToggleScaleControl),
|
||||
new KeyBinding(new[] { InputKey.Control, InputKey.Left }, GlobalAction.EditorSeekToPreviousHitObject),
|
||||
@ -204,7 +205,7 @@ namespace osu.Game.Input.Bindings
|
||||
new KeyBinding(InputKey.BackSpace, GlobalAction.DeselectAllMods),
|
||||
new KeyBinding(new[] { InputKey.Control, InputKey.Up }, GlobalAction.IncreaseModSpeed),
|
||||
new KeyBinding(new[] { InputKey.Control, InputKey.Down }, GlobalAction.DecreaseModSpeed),
|
||||
new KeyBinding(new[] { InputKey.MouseRight }, GlobalAction.AbsoluteScrollSongList),
|
||||
new KeyBinding(InputKey.None, GlobalAction.AbsoluteScrollSongList),
|
||||
};
|
||||
|
||||
private static IEnumerable<KeyBinding> audioControlKeyBindings => new[]
|
||||
@ -493,7 +494,10 @@ namespace osu.Game.Input.Bindings
|
||||
EditorSeekToNextBookmark,
|
||||
|
||||
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.AbsoluteScrollSongList))]
|
||||
AbsoluteScrollSongList
|
||||
AbsoluteScrollSongList,
|
||||
|
||||
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleMoveControl))]
|
||||
EditorToggleMoveControl,
|
||||
}
|
||||
|
||||
public enum GlobalActionCategory
|
||||
|
@ -454,6 +454,11 @@ namespace osu.Game.Localisation
|
||||
/// </summary>
|
||||
public static LocalisableString AbsoluteScrollSongList => new TranslatableString(getKey(@"absolute_scroll_song_list"), @"Absolute scroll song list");
|
||||
|
||||
/// <summary>
|
||||
/// "Toggle movement control"
|
||||
/// </summary>
|
||||
public static LocalisableString EditorToggleMoveControl => new TranslatableString(getKey(@"editor_toggle_move_control"), @"Toggle movement control");
|
||||
|
||||
private static string getKey(string key) => $@"{prefix}:{key}";
|
||||
}
|
||||
}
|
||||
|
@ -169,9 +169,11 @@ namespace osu.Game.Online
|
||||
|
||||
notifications.Post(new SimpleNotification
|
||||
{
|
||||
Icon = FontAwesome.Solid.UserPlus,
|
||||
Transient = true,
|
||||
IsImportant = false,
|
||||
Icon = FontAwesome.Solid.User,
|
||||
Text = $"Online: {string.Join(@", ", onlineAlertQueue.Select(u => u.Username))}",
|
||||
IconColour = colours.Green,
|
||||
IconColour = colours.GrayD,
|
||||
Activated = () =>
|
||||
{
|
||||
if (singleUser != null)
|
||||
@ -204,9 +206,11 @@ namespace osu.Game.Online
|
||||
|
||||
notifications.Post(new SimpleNotification
|
||||
{
|
||||
Icon = FontAwesome.Solid.UserMinus,
|
||||
Transient = true,
|
||||
IsImportant = false,
|
||||
Icon = FontAwesome.Solid.UserSlash,
|
||||
Text = $"Offline: {string.Join(@", ", offlineAlertQueue.Select(u => u.Username))}",
|
||||
IconColour = colours.Red
|
||||
IconColour = colours.Gray3
|
||||
});
|
||||
|
||||
offlineAlertQueue.Clear();
|
||||
|
@ -37,5 +37,17 @@ namespace osu.Game.Online.Spectator
|
||||
/// <param name="userId">The ID of the user who achieved the score.</param>
|
||||
/// <param name="scoreId">The ID of the score.</param>
|
||||
Task UserScoreProcessed(int userId, long scoreId);
|
||||
|
||||
/// <summary>
|
||||
/// Signals that another user has <see cref="ISpectatorServer.StartWatchingUser">started watching this client</see>.
|
||||
/// </summary>
|
||||
/// <param name="user">The information about the user who started watching.</param>
|
||||
Task UserStartedWatching(SpectatorUser[] user);
|
||||
|
||||
/// <summary>
|
||||
/// Signals that another user has <see cref="ISpectatorServer.EndWatchingUser">ended watching this client</see>
|
||||
/// </summary>
|
||||
/// <param name="userId">The ID of the user who ended watching.</param>
|
||||
Task UserEndedWatching(int userId);
|
||||
}
|
||||
}
|
||||
|
@ -42,6 +42,8 @@ namespace osu.Game.Online.Spectator
|
||||
connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
|
||||
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
|
||||
connection.On<int, long>(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed);
|
||||
connection.On<SpectatorUser[]>(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching);
|
||||
connection.On<int>(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching);
|
||||
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
|
||||
};
|
||||
|
||||
|
@ -6,6 +6,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Development;
|
||||
@ -36,10 +37,16 @@ namespace osu.Game.Online.Spectator
|
||||
public abstract IBindable<bool> IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The states of all users currently being watched.
|
||||
/// The states of all users currently being watched by the local user.
|
||||
/// </summary>
|
||||
[UsedImplicitly] // Marked virtual due to mock use in testing
|
||||
public virtual IBindableDictionary<int, SpectatorState> WatchedUserStates => watchedUserStates;
|
||||
|
||||
/// <summary>
|
||||
/// All users who are currently watching the local user.
|
||||
/// </summary>
|
||||
public IBindableList<SpectatorUser> WatchingUsers => watchingUsers;
|
||||
|
||||
/// <summary>
|
||||
/// A global list of all players currently playing.
|
||||
/// </summary>
|
||||
@ -53,6 +60,7 @@ namespace osu.Game.Online.Spectator
|
||||
/// <summary>
|
||||
/// Called whenever new frames arrive from the server.
|
||||
/// </summary>
|
||||
[UsedImplicitly] // Marked virtual due to mock use in testing
|
||||
public virtual event Action<int, FrameDataBundle>? OnNewFrames;
|
||||
|
||||
/// <summary>
|
||||
@ -82,6 +90,7 @@ namespace osu.Game.Online.Spectator
|
||||
|
||||
private readonly BindableDictionary<int, SpectatorState> watchedUserStates = new BindableDictionary<int, SpectatorState>();
|
||||
|
||||
private readonly BindableList<SpectatorUser> watchingUsers = new BindableList<SpectatorUser>();
|
||||
private readonly BindableList<int> playingUsers = new BindableList<int>();
|
||||
private readonly SpectatorState currentState = new SpectatorState();
|
||||
|
||||
@ -127,6 +136,7 @@ namespace osu.Game.Online.Spectator
|
||||
{
|
||||
playingUsers.Clear();
|
||||
watchedUserStates.Clear();
|
||||
watchingUsers.Clear();
|
||||
}
|
||||
}), true);
|
||||
}
|
||||
@ -179,6 +189,30 @@ namespace osu.Game.Online.Spectator
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task ISpectatorClient.UserStartedWatching(SpectatorUser[] users)
|
||||
{
|
||||
Schedule(() =>
|
||||
{
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (!watchingUsers.Contains(user))
|
||||
watchingUsers.Add(user);
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task ISpectatorClient.UserEndedWatching(int userId)
|
||||
{
|
||||
Schedule(() =>
|
||||
{
|
||||
watchingUsers.RemoveAll(u => u.OnlineID == userId);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IStatefulUserHubClient.DisconnectRequested()
|
||||
{
|
||||
Schedule(() => DisconnectInternal());
|
||||
|
39
osu.Game/Online/Spectator/SpectatorUser.cs
Normal file
39
osu.Game/Online/Spectator/SpectatorUser.cs
Normal file
@ -0,0 +1,39 @@
|
||||
// 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 MessagePack;
|
||||
using osu.Game.Users;
|
||||
|
||||
namespace osu.Game.Online.Spectator
|
||||
{
|
||||
[Serializable]
|
||||
[MessagePackObject]
|
||||
public class SpectatorUser : IUser, IEquatable<SpectatorUser>
|
||||
{
|
||||
[Key(0)]
|
||||
public int OnlineID { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[IgnoreMember]
|
||||
public CountryCode CountryCode => CountryCode.Unknown;
|
||||
|
||||
[IgnoreMember]
|
||||
public bool IsBot => false;
|
||||
|
||||
public bool Equals(SpectatorUser? other)
|
||||
{
|
||||
if (other is null) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
|
||||
return OnlineID == other.OnlineID;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => Equals(obj as SpectatorUser);
|
||||
|
||||
// ReSharper disable once NonReadonlyMemberInGetHashCode
|
||||
public override int GetHashCode() => OnlineID;
|
||||
}
|
||||
}
|
@ -233,8 +233,6 @@ namespace osu.Game
|
||||
|
||||
forwardGeneralLogsToNotifications();
|
||||
forwardTabletLogsToNotifications();
|
||||
|
||||
SentryLogger = new SentryLogger(this);
|
||||
}
|
||||
|
||||
#region IOverlayManager
|
||||
@ -320,6 +318,12 @@ namespace osu.Game
|
||||
private readonly List<string> dragDropFiles = new List<string>();
|
||||
private ScheduledDelegate dragDropImportSchedule;
|
||||
|
||||
public override void SetupLogging(Storage gameStorage, Storage cacheStorage)
|
||||
{
|
||||
base.SetupLogging(gameStorage, cacheStorage);
|
||||
SentryLogger = new SentryLogger(this, cacheStorage);
|
||||
}
|
||||
|
||||
public override void SetHost(GameHost host)
|
||||
{
|
||||
base.SetHost(host);
|
||||
|
@ -41,7 +41,7 @@ namespace osu.Game.Overlays
|
||||
[Resolved]
|
||||
private OverlayColourProvider colourProvider { get; set; } = null!;
|
||||
|
||||
public Action<Notification>? ForwardNotificationToPermanentStore { get; set; }
|
||||
public required Action<Notification> ForwardNotificationToPermanentStore { get; init; }
|
||||
|
||||
public int UnreadCount => Notifications.Count(n => !n.WasClosed && !n.Read);
|
||||
|
||||
@ -142,8 +142,15 @@ namespace osu.Game.Overlays
|
||||
notification.MoveToOffset(new Vector2(400, 0), NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint);
|
||||
notification.FadeOut(NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint).OnComplete(_ =>
|
||||
{
|
||||
if (notification.Transient)
|
||||
{
|
||||
notification.IsInToastTray = false;
|
||||
notification.Close(false);
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveInternal(notification, false);
|
||||
ForwardNotificationToPermanentStore?.Invoke(notification);
|
||||
ForwardNotificationToPermanentStore(notification);
|
||||
|
||||
notification.FadeIn(300, Easing.OutQuint);
|
||||
});
|
||||
|
@ -34,9 +34,15 @@ namespace osu.Game.Overlays.Notifications
|
||||
public abstract LocalisableString Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this notification should forcefully display itself.
|
||||
/// Important notifications display for longer, and announce themselves at an OS level (ie flashing the taskbar).
|
||||
/// This defaults to <c>true</c>.
|
||||
/// </summary>
|
||||
public virtual bool IsImportant => true;
|
||||
public bool IsImportant { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Transient notifications only show as a toast, and do not linger in notification history.
|
||||
/// </summary>
|
||||
public bool Transient { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run on user activating the notification. Return true to close.
|
||||
|
@ -191,8 +191,6 @@ namespace osu.Game.Overlays.Notifications
|
||||
|
||||
public override bool DisplayOnTop => false;
|
||||
|
||||
public override bool IsImportant => false;
|
||||
|
||||
private readonly ProgressBar progressBar;
|
||||
private Color4 colourQueued;
|
||||
private Color4 colourActive;
|
||||
@ -206,6 +204,8 @@ namespace osu.Game.Overlays.Notifications
|
||||
|
||||
public ProgressNotification()
|
||||
{
|
||||
IsImportant = false;
|
||||
|
||||
Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium))
|
||||
{
|
||||
AutoSizeAxes = Axes.Y,
|
||||
|
@ -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.Extensions.LocalisationExtensions;
|
||||
@ -8,11 +9,14 @@ using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Cursor;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Localisation;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Overlays.Profile.Header.Components
|
||||
{
|
||||
@ -23,6 +27,11 @@ namespace osu.Game.Overlays.Profile.Header.Components
|
||||
public DailyChallengeTooltipData? TooltipContent { get; private set; }
|
||||
|
||||
private OsuSpriteText dailyPlayCount = null!;
|
||||
private Container content = null!;
|
||||
private CircularContainer completionMark = null!;
|
||||
|
||||
[Resolved]
|
||||
private IAPIProvider api { get; set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; } = null!;
|
||||
@ -34,58 +43,91 @@ namespace osu.Game.Overlays.Profile.Header.Components
|
||||
private void load()
|
||||
{
|
||||
AutoSizeAxes = Axes.Both;
|
||||
CornerRadius = 5;
|
||||
Masking = true;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
content = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Colour = colourProvider.Background4,
|
||||
},
|
||||
new FillFlowContainer
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Padding = new MarginPadding(5f),
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Direction = FillDirection.Horizontal,
|
||||
CornerRadius = 6,
|
||||
BorderThickness = 2,
|
||||
BorderColour = colourProvider.Background4,
|
||||
Masking = true,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12))
|
||||
new Box
|
||||
{
|
||||
AutoSizeAxes = Axes.Both,
|
||||
// can't use this because osu-web does weird stuff with \\n.
|
||||
// Text = UsersStrings.ShowDailyChallengeTitle.,
|
||||
Text = "Daily\nChallenge",
|
||||
Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f },
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Colour = colourProvider.Background4,
|
||||
},
|
||||
new Container
|
||||
new FillFlowContainer
|
||||
{
|
||||
AutoSizeAxes = Axes.X,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
CornerRadius = 5f,
|
||||
Masking = true,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Padding = new MarginPadding(3f),
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Direction = FillDirection.Horizontal,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12))
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Colour = colourProvider.Background6,
|
||||
AutoSizeAxes = Axes.Both,
|
||||
// can't use this because osu-web does weird stuff with \\n.
|
||||
// Text = UsersStrings.ShowDailyChallengeTitle.,
|
||||
Text = "Daily\nChallenge",
|
||||
Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f },
|
||||
},
|
||||
dailyPlayCount = new OsuSpriteText
|
||||
new Container
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
UseFullGlyphHeight = false,
|
||||
Colour = colourProvider.Content2,
|
||||
Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f },
|
||||
AutoSizeAxes = Axes.X,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
CornerRadius = 3,
|
||||
Masking = true,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Colour = colourProvider.Background6,
|
||||
},
|
||||
dailyPlayCount = new OsuSpriteText
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
UseFullGlyphHeight = false,
|
||||
Colour = colourProvider.Content2,
|
||||
Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
completionMark = new CircularContainer
|
||||
{
|
||||
Alpha = 0,
|
||||
Size = new Vector2(16),
|
||||
Anchor = Anchor.TopRight,
|
||||
Origin = Anchor.Centre,
|
||||
Masking = true,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Colour = colours.Lime1,
|
||||
},
|
||||
new SpriteIcon
|
||||
{
|
||||
Size = new Vector2(8),
|
||||
Colour = colourProvider.Background6,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Icon = FontAwesome.Solid.Check,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -114,6 +156,29 @@ namespace osu.Game.Overlays.Profile.Header.Components
|
||||
dailyPlayCount.Text = DailyChallengeStatsDisplayStrings.UnitDay(stats.PlayCount.ToLocalisableString("N0"));
|
||||
dailyPlayCount.Colour = colours.ForRankingTier(DailyChallengeStatsTooltip.TierForPlayCount(stats.PlayCount));
|
||||
|
||||
bool playedToday = stats.LastUpdate?.Date == DateTimeOffset.UtcNow.Date;
|
||||
bool userIsOnOwnProfile = stats.UserID == api.LocalUser.Value.Id;
|
||||
|
||||
if (playedToday && userIsOnOwnProfile)
|
||||
{
|
||||
if (completionMark.Alpha > 0.8f)
|
||||
{
|
||||
completionMark.ScaleTo(1.2f).ScaleTo(1, 800, Easing.OutElastic);
|
||||
}
|
||||
else
|
||||
{
|
||||
completionMark.FadeIn(500, Easing.OutExpo);
|
||||
completionMark.ScaleTo(1.6f).ScaleTo(1, 500, Easing.OutExpo);
|
||||
}
|
||||
|
||||
content.BorderColour = colours.Lime1;
|
||||
}
|
||||
else
|
||||
{
|
||||
completionMark.FadeOut(50);
|
||||
content.BorderColour = colourProvider.Background4;
|
||||
}
|
||||
|
||||
TooltipContent = new DailyChallengeTooltipData(colourProvider, stats);
|
||||
|
||||
Show();
|
||||
|
@ -41,7 +41,6 @@ namespace osu.Game.Overlays.Profile.Header.Components
|
||||
AutoSizeAxes = Axes.Y,
|
||||
AutoSizeDuration = 200,
|
||||
AutoSizeEasing = Easing.OutQuint,
|
||||
Masking = true,
|
||||
Direction = FillDirection.Vertical,
|
||||
Spacing = new Vector2(0, 15),
|
||||
Children = new Drawable[]
|
||||
|
@ -35,7 +35,8 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay
|
||||
new SettingsCheckbox
|
||||
{
|
||||
LabelText = GameplaySettingsStrings.LightenDuringBreaks,
|
||||
Current = config.GetBindable<bool>(OsuSetting.LightenDuringBreaks)
|
||||
Current = config.GetBindable<bool>(OsuSetting.LightenDuringBreaks),
|
||||
Keywords = new[] { "dim", "level" }
|
||||
},
|
||||
new SettingsCheckbox
|
||||
{
|
||||
|
@ -1,8 +1,12 @@
|
||||
// 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 System.Threading.Tasks;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Screens;
|
||||
using osu.Game.Localisation;
|
||||
using osu.Game.Screens;
|
||||
@ -15,22 +19,33 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
|
||||
{
|
||||
protected override LocalisableString Header => CommonStrings.General;
|
||||
|
||||
private ISystemFileSelector? selector;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(IPerformFromScreenRunner? performer)
|
||||
private void load(OsuGameBase game, GameHost host, IPerformFromScreenRunner? performer)
|
||||
{
|
||||
Children = new[]
|
||||
if ((selector = host.CreateSystemFileSelector(game.HandledExtensions.ToArray())) != null)
|
||||
selector.Selected += f => Task.Run(() => game.Import(f.FullName));
|
||||
|
||||
AddRange(new Drawable[]
|
||||
{
|
||||
new SettingsButton
|
||||
{
|
||||
Text = DebugSettingsStrings.ImportFiles,
|
||||
Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen()))
|
||||
Action = () =>
|
||||
{
|
||||
if (selector != null)
|
||||
selector.Present();
|
||||
else
|
||||
performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen()));
|
||||
},
|
||||
},
|
||||
new SettingsButton
|
||||
{
|
||||
Text = DebugSettingsStrings.RunLatencyCertifier,
|
||||
Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen()))
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.UI
|
||||
|
||||
private readonly Bindable<bool> waitingOnFrames = new Bindable<bool>();
|
||||
|
||||
private readonly double gameplayStartTime;
|
||||
public double GameplayStartTime { get; }
|
||||
|
||||
private IGameplayClock? parentGameplayClock;
|
||||
|
||||
@ -85,7 +85,7 @@ namespace osu.Game.Rulesets.UI
|
||||
|
||||
framedClock = new FramedClock(manualClock = new ManualClock());
|
||||
|
||||
this.gameplayStartTime = gameplayStartTime;
|
||||
GameplayStartTime = gameplayStartTime;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader(true)]
|
||||
@ -257,8 +257,8 @@ namespace osu.Game.Rulesets.UI
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualClock.CurrentTime < gameplayStartTime)
|
||||
manualClock.CurrentTime = proposedTime = Math.Min(gameplayStartTime, proposedTime);
|
||||
if (manualClock.CurrentTime < GameplayStartTime)
|
||||
manualClock.CurrentTime = proposedTime = Math.Min(GameplayStartTime, proposedTime);
|
||||
else if (Math.Abs(manualClock.CurrentTime - proposedTime) > sixty_frame_time * 1.2f)
|
||||
{
|
||||
proposedTime = proposedTime > manualClock.CurrentTime
|
||||
|
@ -149,7 +149,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
|
||||
{
|
||||
Enabled.Value = SelectedHitObject.Value != null;
|
||||
|
||||
if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0)
|
||||
if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1)
|
||||
{
|
||||
BackgroundColour = colourProvider.Background3;
|
||||
icon.Colour = BackgroundColour.Darken(0.5f);
|
||||
|
@ -185,9 +185,28 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
|
||||
private void onDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
|
||||
{
|
||||
Origin = Anchor = direction.NewValue == ScrollingDirection.Up
|
||||
? Anchor.TopLeft
|
||||
: Anchor.BottomLeft;
|
||||
switch (direction.NewValue)
|
||||
{
|
||||
case ScrollingDirection.Up:
|
||||
Anchor = Anchor.TopLeft;
|
||||
Origin = Anchor.CentreLeft;
|
||||
break;
|
||||
|
||||
case ScrollingDirection.Down:
|
||||
Anchor = Anchor.BottomLeft;
|
||||
Origin = Anchor.CentreLeft;
|
||||
break;
|
||||
|
||||
case ScrollingDirection.Left:
|
||||
Anchor = Anchor.TopLeft;
|
||||
Origin = Anchor.TopCentre;
|
||||
break;
|
||||
|
||||
case ScrollingDirection.Right:
|
||||
Anchor = Anchor.TopRight;
|
||||
Origin = Anchor.TopCentre;
|
||||
break;
|
||||
}
|
||||
|
||||
bool isHorizontal = direction.NewValue == ScrollingDirection.Left || direction.NewValue == ScrollingDirection.Right;
|
||||
|
||||
|
@ -111,25 +111,26 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
|
||||
protected override bool OnKeyDown(KeyDownEvent e)
|
||||
{
|
||||
// Until the keys below are global actions, this will prevent conflicts with "seek between sample points"
|
||||
// which has a default of ctrl+shift+arrows.
|
||||
if (e.ShiftPressed)
|
||||
return false;
|
||||
|
||||
if (e.ControlPressed)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Left:
|
||||
nudgeSelection(new Vector2(-1, 0));
|
||||
return true;
|
||||
return nudgeSelection(new Vector2(-1, 0));
|
||||
|
||||
case Key.Right:
|
||||
nudgeSelection(new Vector2(1, 0));
|
||||
return true;
|
||||
return nudgeSelection(new Vector2(1, 0));
|
||||
|
||||
case Key.Up:
|
||||
nudgeSelection(new Vector2(0, -1));
|
||||
return true;
|
||||
return nudgeSelection(new Vector2(0, -1));
|
||||
|
||||
case Key.Down:
|
||||
nudgeSelection(new Vector2(0, 1));
|
||||
return true;
|
||||
return nudgeSelection(new Vector2(0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,7 +152,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// Move the current selection spatially by the specified delta, in gamefield coordinates (ie. the same coordinates as the blueprints).
|
||||
/// </summary>
|
||||
/// <param name="delta"></param>
|
||||
private void nudgeSelection(Vector2 delta)
|
||||
private bool nudgeSelection(Vector2 delta)
|
||||
{
|
||||
if (!nudgeMovementActive)
|
||||
{
|
||||
@ -162,12 +163,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault();
|
||||
|
||||
if (firstBlueprint == null)
|
||||
return;
|
||||
return false;
|
||||
|
||||
// convert to game space coordinates
|
||||
delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero);
|
||||
|
||||
SelectionHandler.HandleMovement(new MoveSelectionEvent<HitObject>(firstBlueprint, delta));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updatePlacementNewCombo()
|
||||
|
@ -31,6 +31,9 @@ namespace osu.Game.Screens.Edit.Compose
|
||||
[Resolved]
|
||||
private IGameplaySettings globalGameplaySettings { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private IBeatSnapProvider beatSnapProvider { get; set; }
|
||||
|
||||
private Bindable<string> clipboard { get; set; }
|
||||
|
||||
private HitObjectComposer composer;
|
||||
@ -150,7 +153,7 @@ namespace osu.Game.Screens.Edit.Compose
|
||||
|
||||
Debug.Assert(objects.Any());
|
||||
|
||||
double timeOffset = clock.CurrentTime - objects.Min(o => o.StartTime);
|
||||
double timeOffset = beatSnapProvider.SnapTime(clock.CurrentTime) - objects.Min(o => o.StartTime);
|
||||
|
||||
foreach (var h in objects)
|
||||
h.StartTime += timeOffset;
|
||||
|
@ -8,6 +8,7 @@ using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Overlays;
|
||||
using osu.Game.Localisation;
|
||||
@ -97,7 +98,17 @@ namespace osu.Game.Screens.Edit.Setup
|
||||
if (!source.Exists)
|
||||
return false;
|
||||
|
||||
var tagSource = TagLib.File.Create(source.FullName);
|
||||
TagLib.File? tagSource;
|
||||
|
||||
try
|
||||
{
|
||||
tagSource = TagLib.File.Create(source.FullName);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Error(e, "The selected audio track appears to be corrupted. Please select another one.");
|
||||
return false;
|
||||
}
|
||||
|
||||
changeResource(source, applyToAllDifficulties, @"audio",
|
||||
metadata => metadata.AudioFile,
|
||||
@ -192,16 +203,40 @@ namespace osu.Game.Screens.Edit.Setup
|
||||
editor?.Save();
|
||||
}
|
||||
|
||||
// to avoid scaring users, both background & audio choosers use fake `FileInfo`s with user-friendly filenames
|
||||
// when displaying an imported beatmap rather than the actual SHA-named file in storage.
|
||||
// however, that means that when a background or audio file is chosen that is broken or doesn't exist on disk when switching away from the fake files,
|
||||
// the rollback could enter an infinite loop, because the fake `FileInfo`s *also* don't exist on disk - at least not in the fake location they indicate.
|
||||
// to circumvent this issue, just allow rollback to proceed always without actually running any of the change logic to ensure visual consistency.
|
||||
// note that this means that `Change{BackgroundImage,AudioTrack}()` are required to not have made any modifications to the beatmap files
|
||||
// (or at least cleaned them up properly themselves) if they return `false`.
|
||||
private bool rollingBackBackgroundChange;
|
||||
private bool rollingBackAudioChange;
|
||||
|
||||
private void backgroundChanged(ValueChangedEvent<FileInfo?> file)
|
||||
{
|
||||
if (rollingBackBackgroundChange)
|
||||
return;
|
||||
|
||||
if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue, backgroundChooser.ApplyToAllDifficulties.Value))
|
||||
{
|
||||
rollingBackBackgroundChange = true;
|
||||
backgroundChooser.Current.Value = file.OldValue;
|
||||
rollingBackBackgroundChange = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void audioTrackChanged(ValueChangedEvent<FileInfo?> file)
|
||||
{
|
||||
if (rollingBackAudioChange)
|
||||
return;
|
||||
|
||||
if (file.NewValue == null || !ChangeAudioTrack(file.NewValue, audioTrackChooser.ApplyToAllDifficulties.Value))
|
||||
{
|
||||
rollingBackAudioChange = true;
|
||||
audioTrackChooser.Current.Value = file.OldValue;
|
||||
rollingBackAudioChange = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,9 +17,10 @@ using osu.Framework.Threading;
|
||||
using osu.Framework.Timing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Overlays;
|
||||
using osu.Game.Utils;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Timing
|
||||
@ -28,7 +29,7 @@ namespace osu.Game.Screens.Edit.Timing
|
||||
{
|
||||
private Container swing = null!;
|
||||
|
||||
private OsuSpriteText bpmText = null!;
|
||||
private OsuTextFlowContainer bpmText = null!;
|
||||
|
||||
private Drawable weight = null!;
|
||||
private Drawable stick = null!;
|
||||
@ -213,10 +214,15 @@ namespace osu.Game.Screens.Edit.Timing
|
||||
},
|
||||
}
|
||||
},
|
||||
bpmText = new OsuSpriteText
|
||||
bpmText = new OsuTextFlowContainer(st =>
|
||||
{
|
||||
st.Font = OsuFont.Default.With(fixedWidth: true);
|
||||
st.Spacing = new Vector2(-2.2f, 0);
|
||||
})
|
||||
{
|
||||
Name = @"BPM display",
|
||||
Colour = overlayColourProvider.Content1,
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Anchor = Anchor.BottomCentre,
|
||||
Origin = Anchor.BottomCentre,
|
||||
Y = -3,
|
||||
@ -228,11 +234,13 @@ namespace osu.Game.Screens.Edit.Timing
|
||||
|
||||
private double effectiveBeatLength;
|
||||
|
||||
private double effectiveBpm => 60_000 / effectiveBeatLength;
|
||||
|
||||
private TimingControlPoint timingPoint = null!;
|
||||
|
||||
private bool isSwinging;
|
||||
|
||||
private readonly BindableInt interpolatedBpm = new BindableInt();
|
||||
private readonly BindableDouble interpolatedBpm = new BindableDouble();
|
||||
|
||||
private ScheduledDelegate? latchDelegate;
|
||||
|
||||
@ -255,7 +263,25 @@ namespace osu.Game.Screens.Edit.Timing
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
interpolatedBpm.BindValueChanged(_ => bpmText.Text = interpolatedBpm.Value.ToLocalisableString());
|
||||
interpolatedBpm.BindValueChanged(_ => updateBpmText());
|
||||
}
|
||||
|
||||
private void updateBpmText()
|
||||
{
|
||||
int intPart = (int)interpolatedBpm.Value;
|
||||
|
||||
bpmText.Text = intPart.ToLocalisableString();
|
||||
|
||||
// While interpolating between two integer values, showing the decimal places would look a bit odd
|
||||
// so rounding is applied until we're close to the final value.
|
||||
int decimalPlaces = FormatUtils.FindPrecision((decimal)effectiveBpm);
|
||||
|
||||
if (decimalPlaces > 0)
|
||||
{
|
||||
bool reachedFinalNumber = intPart == (int)effectiveBpm;
|
||||
|
||||
bpmText.AddText((effectiveBpm % 1).ToLocalisableString("." + new string('0', decimalPlaces)), cp => cp.Alpha = reachedFinalNumber ? 0.5f : 0.1f);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
@ -277,12 +303,11 @@ namespace osu.Game.Screens.Edit.Timing
|
||||
|
||||
EarlyActivationMilliseconds = timingPoint.BeatLength / 2;
|
||||
|
||||
double effectiveBpm = 60000 / effectiveBeatLength;
|
||||
|
||||
float bpmRatio = (float)Interpolation.ApplyEasing(Easing.OutQuad, Math.Clamp((effectiveBpm - 30) / 480, 0, 1));
|
||||
|
||||
weight.MoveToY((float)Interpolation.Lerp(0.1f, 0.83f, bpmRatio), 600, Easing.OutQuint);
|
||||
this.TransformBindableTo(interpolatedBpm, (int)Math.Round(effectiveBpm), 600, Easing.OutQuint);
|
||||
|
||||
this.TransformBindableTo(interpolatedBpm, effectiveBpm, 300, Easing.OutExpo);
|
||||
}
|
||||
|
||||
if (!BeatSyncSource.Clock.IsRunning && isSwinging)
|
||||
|
@ -245,6 +245,15 @@ namespace osu.Game.Screens.Menu
|
||||
if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed || e.SuperPressed)
|
||||
return false;
|
||||
|
||||
if (e.Key >= Key.F1 && e.Key <= Key.F35)
|
||||
return false;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (triggerInitialOsuLogo())
|
||||
return true;
|
||||
|
||||
|
@ -12,15 +12,19 @@ using osu.Framework.Extensions.Color4Extensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Colour;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Cursor;
|
||||
using osu.Framework.Graphics.Effects;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.UserInterface;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Beatmaps.Drawables;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.Chat;
|
||||
using osu.Game.Online.Rooms;
|
||||
using osu.Game.Overlays;
|
||||
@ -31,11 +35,17 @@ using Container = osu.Framework.Graphics.Containers.Container;
|
||||
|
||||
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
|
||||
{
|
||||
public abstract partial class DrawableRoom : CompositeDrawable
|
||||
public abstract partial class DrawableRoom : CompositeDrawable, IHasContextMenu
|
||||
{
|
||||
protected const float CORNER_RADIUS = 10;
|
||||
private const float height = 100;
|
||||
|
||||
[Resolved]
|
||||
private IAPIProvider api { get; set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private OsuGame? game { get; set; }
|
||||
|
||||
public readonly Room Room;
|
||||
|
||||
protected readonly Bindable<PlaylistItem?> SelectedItem = new Bindable<PlaylistItem?>();
|
||||
@ -330,6 +340,26 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
|
||||
}
|
||||
}
|
||||
|
||||
public virtual MenuItem[] ContextMenuItems
|
||||
{
|
||||
get
|
||||
{
|
||||
var items = new List<MenuItem>();
|
||||
|
||||
if (Room.RoomID.HasValue)
|
||||
{
|
||||
items.AddRange([
|
||||
new OsuMenuItem("View in browser", MenuItemType.Standard, () => game?.OpenUrlExternally(formatRoomUrl(Room.RoomID.Value))),
|
||||
new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyUrlToClipboard(formatRoomUrl(Room.RoomID.Value)))
|
||||
]);
|
||||
}
|
||||
|
||||
return items.ToArray();
|
||||
|
||||
string formatRoomUrl(long id) => $@"{api.WebsiteRootUrl}/multiplayer/rooms/{id}";
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual UpdateableBeatmapBackgroundSprite CreateBackground() => new UpdateableBeatmapBackgroundSprite();
|
||||
|
||||
protected virtual IEnumerable<Drawable> CreateBottomDetails()
|
||||
|
@ -39,7 +39,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
|
||||
/// <summary>
|
||||
/// A <see cref="DrawableRoom"/> with lounge-specific interactions such as selection and hover sounds.
|
||||
/// </summary>
|
||||
public partial class DrawableLoungeRoom : DrawableRoom, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler<GlobalAction>
|
||||
public partial class DrawableLoungeRoom : DrawableRoom, IFilterable, IHasPopover, IKeyBindingHandler<GlobalAction>
|
||||
{
|
||||
private const float transition_duration = 60;
|
||||
private const float selection_border_width = 4;
|
||||
@ -155,17 +155,16 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
|
||||
|
||||
public Popover GetPopover() => new PasswordEntryPopover(Room);
|
||||
|
||||
public MenuItem[] ContextMenuItems
|
||||
public override MenuItem[] ContextMenuItems
|
||||
{
|
||||
get
|
||||
{
|
||||
var items = new List<MenuItem>
|
||||
{
|
||||
new OsuMenuItem("Create copy", MenuItemType.Standard, () =>
|
||||
{
|
||||
lounge?.OpenCopy(Room);
|
||||
})
|
||||
};
|
||||
var items = new List<MenuItem>();
|
||||
|
||||
items.AddRange(base.ContextMenuItems);
|
||||
|
||||
items.Add(new OsuMenuItemSpacer());
|
||||
items.Add(new OsuMenuItem("Create copy", MenuItemType.Standard, () => lounge?.OpenCopy(Room)));
|
||||
|
||||
if (Room.Type == MatchType.Playlists && Room.Host?.Id == api.LocalUser.Value.Id && Room.StartDate?.AddMinutes(5) >= DateTimeOffset.Now && !Room.HasEnded)
|
||||
{
|
||||
|
@ -18,6 +18,7 @@ using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Screens;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Graphics.Cursor;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.Rooms;
|
||||
using osu.Game.Overlays;
|
||||
@ -156,10 +157,15 @@ namespace osu.Game.Screens.OnlinePlay.Match
|
||||
{
|
||||
new Drawable[]
|
||||
{
|
||||
new DrawableMatchRoom(Room, allowEdit)
|
||||
new OsuContextMenuContainer
|
||||
{
|
||||
OnEdit = () => settingsOverlay.Show(),
|
||||
SelectedItem = SelectedItem
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y,
|
||||
Child = new DrawableMatchRoom(Room, allowEdit)
|
||||
{
|
||||
OnEdit = () => settingsOverlay.Show(),
|
||||
SelectedItem = SelectedItem
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
|
@ -39,6 +39,8 @@ namespace osu.Game.Screens.Play
|
||||
/// </remarks>
|
||||
public double StartTime { get; protected set; }
|
||||
|
||||
public double GameplayStartTime { get; protected set; }
|
||||
|
||||
public IAdjustableAudioComponent AdjustmentsFromMods { get; } = new AudioAdjustments();
|
||||
|
||||
private readonly BindableBool isPaused = new BindableBool(true);
|
||||
|
@ -166,11 +166,6 @@ namespace osu.Game.Screens.Play
|
||||
|
||||
protected override void PopOut() => this.FadeOut(TRANSITION_DURATION, Easing.In);
|
||||
|
||||
// Don't let mouse down events through the overlay or people can click circles while paused.
|
||||
protected override bool OnMouseDown(MouseDownEvent e) => true;
|
||||
|
||||
protected override bool OnMouseMove(MouseMoveEvent e) => true;
|
||||
|
||||
protected void AddButton(LocalisableString text, Color4 colour, Action? action)
|
||||
{
|
||||
var button = new Button
|
||||
|
@ -69,6 +69,11 @@ namespace osu.Game.Screens.Play
|
||||
|
||||
private readonly Bindable<JudgementResult> lastJudgementResult = new Bindable<JudgementResult>();
|
||||
|
||||
/// <summary>
|
||||
/// The local user's playing state (whether actively playing, paused, or not playing due to watching a replay or similar).
|
||||
/// </summary>
|
||||
public IBindable<LocalUserPlayingState> PlayingState { get; } = new Bindable<LocalUserPlayingState>();
|
||||
|
||||
public GameplayState(
|
||||
IBeatmap beatmap,
|
||||
Ruleset ruleset,
|
||||
@ -76,7 +81,8 @@ namespace osu.Game.Screens.Play
|
||||
Score? score = null,
|
||||
ScoreProcessor? scoreProcessor = null,
|
||||
HealthProcessor? healthProcessor = null,
|
||||
Storyboard? storyboard = null)
|
||||
Storyboard? storyboard = null,
|
||||
IBindable<LocalUserPlayingState>? localUserPlayingState = null)
|
||||
{
|
||||
Beatmap = beatmap;
|
||||
Ruleset = ruleset;
|
||||
@ -92,6 +98,9 @@ namespace osu.Game.Screens.Play
|
||||
ScoreProcessor = scoreProcessor ?? ruleset.CreateScoreProcessor();
|
||||
HealthProcessor = healthProcessor ?? ruleset.CreateHealthProcessor(beatmap.HitObjects[0].StartTime);
|
||||
Storyboard = storyboard ?? new Storyboard();
|
||||
|
||||
if (localUserPlayingState != null)
|
||||
PlayingState.BindTo(localUserPlayingState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -122,7 +122,10 @@ namespace osu.Game.Screens.Play.HUD
|
||||
{
|
||||
float screenMouseX = inputManager.CurrentState.Mouse.Position.X;
|
||||
|
||||
Expanded.Value = screenMouseX >= button.ScreenSpaceDrawQuad.TopLeft.X && screenMouseX <= ToScreenSpace(new Vector2(DrawWidth + EXPANDED_WIDTH, 0)).X;
|
||||
Expanded.Value =
|
||||
(screenMouseX >= button.ScreenSpaceDrawQuad.TopLeft.X && screenMouseX <= ToScreenSpace(new Vector2(DrawWidth + EXPANDED_WIDTH, 0)).X)
|
||||
// Stay expanded if the user is dragging a slider.
|
||||
|| inputManager.DraggedDrawable != null;
|
||||
}
|
||||
|
||||
protected override void OnHoverLost(HoverLostEvent e)
|
||||
|
@ -15,18 +15,20 @@ using osu.Game.Configuration;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Online.Chat;
|
||||
using osu.Game.Users;
|
||||
using osu.Game.Localisation.HUD;
|
||||
using osu.Game.Localisation.SkinComponents;
|
||||
using osu.Game.Online.Spectator;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.Play.HUD
|
||||
{
|
||||
public partial class SpectatorList : CompositeDrawable
|
||||
public partial class SpectatorList : CompositeDrawable, ISerialisableDrawable
|
||||
{
|
||||
private const int max_spectators_displayed = 10;
|
||||
|
||||
public BindableList<Spectator> Spectators { get; } = new BindableList<Spectator>();
|
||||
public BindableList<SpectatorUser> Spectators { get; } = new BindableList<SpectatorUser>();
|
||||
public Bindable<LocalUserPlayingState> UserPlayingState { get; } = new Bindable<LocalUserPlayingState>();
|
||||
|
||||
[SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))]
|
||||
@ -41,13 +43,20 @@ namespace osu.Game.Screens.Play.HUD
|
||||
private FillFlowContainer<SpectatorListEntry> spectatorsFlow = null!;
|
||||
private DrawablePool<SpectatorListEntry> pool = null!;
|
||||
|
||||
[Resolved]
|
||||
private SpectatorClient client { get; set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private GameplayState gameplayState { get; set; } = null!;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OsuColour colours)
|
||||
{
|
||||
AutoSizeAxes = Axes.Y;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
InternalChildren = new[]
|
||||
{
|
||||
Empty().With(t => t.Size = new Vector2(100, 50)),
|
||||
mainFlow = new FillFlowContainer
|
||||
{
|
||||
AutoSizeAxes = Axes.Both,
|
||||
@ -76,6 +85,9 @@ namespace osu.Game.Screens.Play.HUD
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
((IBindableList<SpectatorUser>)Spectators).BindTo(client.WatchingUsers);
|
||||
((IBindable<LocalUserPlayingState>)UserPlayingState).BindTo(gameplayState.PlayingState);
|
||||
|
||||
Spectators.BindCollectionChanged(onSpectatorsChanged, true);
|
||||
UserPlayingState.BindValueChanged(_ => updateVisibility());
|
||||
|
||||
@ -94,7 +106,7 @@ namespace osu.Game.Screens.Play.HUD
|
||||
{
|
||||
for (int i = 0; i < e.NewItems!.Count; i++)
|
||||
{
|
||||
var spectator = (Spectator)e.NewItems![i]!;
|
||||
var spectator = (SpectatorUser)e.NewItems![i]!;
|
||||
int index = Math.Max(e.NewStartingIndex, 0) + i;
|
||||
|
||||
if (index >= max_spectators_displayed)
|
||||
@ -143,7 +155,7 @@ namespace osu.Game.Screens.Play.HUD
|
||||
}
|
||||
}
|
||||
|
||||
private void addNewSpectatorToList(int i, Spectator spectator)
|
||||
private void addNewSpectatorToList(int i, SpectatorUser spectator)
|
||||
{
|
||||
var entry = pool.Get(entry =>
|
||||
{
|
||||
@ -156,6 +168,7 @@ namespace osu.Game.Screens.Play.HUD
|
||||
|
||||
private void updateVisibility()
|
||||
{
|
||||
// We don't want to show spectators when we are watching a replay.
|
||||
mainFlow.FadeTo(Spectators.Count > 0 && UserPlayingState.Value != LocalUserPlayingState.NotPlaying ? 1 : 0, 250, Easing.OutQuint);
|
||||
}
|
||||
|
||||
@ -169,7 +182,7 @@ namespace osu.Game.Screens.Play.HUD
|
||||
|
||||
private partial class SpectatorListEntry : PoolableDrawable
|
||||
{
|
||||
public Bindable<Spectator> Current { get; } = new Bindable<Spectator>();
|
||||
public Bindable<SpectatorUser> Current { get; } = new Bindable<SpectatorUser>();
|
||||
|
||||
private readonly BindableWithCurrent<LocalUserPlayingState> current = new BindableWithCurrent<LocalUserPlayingState>();
|
||||
|
||||
@ -233,10 +246,6 @@ namespace osu.Game.Screens.Play.HUD
|
||||
}
|
||||
}
|
||||
|
||||
public record Spectator(int OnlineID, string Username) : IUser
|
||||
{
|
||||
public CountryCode CountryCode => CountryCode.Unknown;
|
||||
public bool IsBot => false;
|
||||
}
|
||||
public bool UsesFixedAnchor { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,11 @@ namespace osu.Game.Screens.Play
|
||||
/// </remarks>
|
||||
double StartTime { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The time from which actual gameplay should start. When intro time is skipped, this will be the seeked location.
|
||||
/// </summary>
|
||||
double GameplayStartTime { get; }
|
||||
|
||||
/// <summary>
|
||||
/// All adjustments applied to this clock which come from mods.
|
||||
/// </summary>
|
||||
|
@ -57,8 +57,6 @@ namespace osu.Game.Screens.Play
|
||||
|
||||
private Track track;
|
||||
|
||||
private readonly double skipTargetTime;
|
||||
|
||||
[Resolved]
|
||||
private MusicController musicController { get; set; } = null!;
|
||||
|
||||
@ -66,25 +64,25 @@ namespace osu.Game.Screens.Play
|
||||
/// Create a new master gameplay clock container.
|
||||
/// </summary>
|
||||
/// <param name="beatmap">The beatmap to be used for time and metadata references.</param>
|
||||
/// <param name="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
|
||||
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime)
|
||||
/// <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)
|
||||
{
|
||||
this.beatmap = beatmap;
|
||||
this.skipTargetTime = skipTargetTime;
|
||||
|
||||
track = beatmap.Track;
|
||||
|
||||
StartTime = findEarliestStartTime();
|
||||
GameplayStartTime = gameplayStartTime;
|
||||
StartTime = findEarliestStartTime(gameplayStartTime, beatmap);
|
||||
}
|
||||
|
||||
private double findEarliestStartTime()
|
||||
private static double findEarliestStartTime(double gameplayStartTime, WorkingBeatmap beatmap)
|
||||
{
|
||||
// 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.
|
||||
|
||||
// start with the originally provided latest time (if before zero).
|
||||
double time = Math.Min(0, skipTargetTime);
|
||||
double time = Math.Min(0, gameplayStartTime);
|
||||
|
||||
// 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.
|
||||
@ -119,10 +117,10 @@ namespace osu.Game.Screens.Play
|
||||
/// </summary>
|
||||
public void Skip()
|
||||
{
|
||||
if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
|
||||
if (GameplayClock.CurrentTime > GameplayStartTime - MINIMUM_SKIP_TIME)
|
||||
return;
|
||||
|
||||
double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME;
|
||||
double skipTarget = GameplayStartTime - MINIMUM_SKIP_TIME;
|
||||
|
||||
if (StartTime < -10000 && GameplayClock.CurrentTime < 0 && skipTarget > 6000)
|
||||
// double skip exception for storyboards with very long intros
|
||||
@ -187,7 +185,8 @@ namespace osu.Game.Screens.Play
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Log($"Playback discrepancy detected ({playbackDiscrepancyCount} of allowed {allowed_playback_discrepancies}): {elapsedGameplayClockTime:N1} vs {elapsedValidationTime:N1}");
|
||||
Logger.Log(
|
||||
$"Playback discrepancy detected ({playbackDiscrepancyCount} of allowed {allowed_playback_discrepancies}): {elapsedGameplayClockTime:N1} vs {elapsedValidationTime:N1}");
|
||||
}
|
||||
|
||||
elapsedValidationTime = null;
|
||||
|
@ -261,7 +261,7 @@ namespace osu.Game.Screens.Play
|
||||
Score.ScoreInfo.Ruleset = ruleset.RulesetInfo;
|
||||
Score.ScoreInfo.Mods = gameplayMods;
|
||||
|
||||
dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard));
|
||||
dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard, PlayingState));
|
||||
|
||||
var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin);
|
||||
GameplayClockContainer.Add(new GameplayScrollWheelHandling());
|
||||
|
@ -663,8 +663,6 @@ namespace osu.Game.Screens.Play
|
||||
|
||||
private partial class MutedNotification : SimpleNotification
|
||||
{
|
||||
public override bool IsImportant => true;
|
||||
|
||||
public MutedNotification()
|
||||
{
|
||||
Text = NotificationsStrings.GameVolumeTooLow;
|
||||
@ -716,8 +714,6 @@ namespace osu.Game.Screens.Play
|
||||
|
||||
private partial class BatteryWarningNotification : SimpleNotification
|
||||
{
|
||||
public override bool IsImportant => true;
|
||||
|
||||
public BatteryWarningNotification()
|
||||
{
|
||||
Text = NotificationsStrings.BatteryLow;
|
||||
|
@ -121,7 +121,11 @@ namespace osu.Game.Screens.Play.PlayerSettings
|
||||
// At the point we reach here, it's not guaranteed that all realm writes have taken place (there may be some in-flight).
|
||||
// We are only aware of writes that originated from our own flow, so if we do see one that's active we can avoid handling the feedback value arriving.
|
||||
if (realmWriteTask == null)
|
||||
{
|
||||
Current.Disabled = false;
|
||||
Current.Value = val;
|
||||
Current.Disabled = allowOffsetAdjust;
|
||||
}
|
||||
|
||||
if (realmWriteTask?.IsCompleted == true)
|
||||
{
|
||||
@ -134,15 +138,15 @@ namespace osu.Game.Screens.Play.PlayerSettings
|
||||
ReferenceScore.BindValueChanged(scoreChanged, true);
|
||||
}
|
||||
|
||||
// the last play graph is relative to the offset at the point of the last play, so we need to factor that out for some usages.
|
||||
private double adjustmentSinceLastPlay => lastPlayBeatmapOffset - Current.Value;
|
||||
|
||||
private void currentChanged(ValueChangedEvent<double> offset)
|
||||
{
|
||||
Scheduler.AddOnce(updateOffset);
|
||||
|
||||
void updateOffset()
|
||||
{
|
||||
// the last play graph is relative to the offset at the point of the last play, so we need to factor that out.
|
||||
double adjustmentSinceLastPlay = lastPlayBeatmapOffset - Current.Value;
|
||||
|
||||
// Negative is applied here because the play graph is considering a hit offset, not track (as we currently use for clocks).
|
||||
lastPlayGraph?.UpdateOffset(-adjustmentSinceLastPlay);
|
||||
|
||||
@ -153,11 +157,6 @@ namespace osu.Game.Screens.Play.PlayerSettings
|
||||
return;
|
||||
}
|
||||
|
||||
if (useAverageButton != null)
|
||||
{
|
||||
useAverageButton.Enabled.Value = !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2);
|
||||
}
|
||||
|
||||
realmWriteTask = realm.WriteAsync(r =>
|
||||
{
|
||||
var setInfo = r.Find<BeatmapSetInfo>(beatmap.Value.BeatmapSetInfo.ID);
|
||||
@ -245,10 +244,12 @@ namespace osu.Game.Screens.Play.PlayerSettings
|
||||
Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay,
|
||||
Action = () =>
|
||||
{
|
||||
if (Current.Disabled)
|
||||
return;
|
||||
|
||||
Current.Value = lastPlayBeatmapOffset - lastPlayAverage;
|
||||
lastAppliedScore.Value = ReferenceScore.Value;
|
||||
},
|
||||
Enabled = { Value = !Precision.AlmostEquals(lastPlayAverage, 0, Current.Precision / 2) }
|
||||
},
|
||||
globalOffsetText = new LinkFlowContainer
|
||||
{
|
||||
@ -277,7 +278,13 @@ namespace osu.Game.Screens.Play.PlayerSettings
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
Current.Disabled = !allowOffsetAdjust;
|
||||
|
||||
bool allow = allowOffsetAdjust;
|
||||
|
||||
if (useAverageButton != null)
|
||||
useAverageButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2);
|
||||
|
||||
Current.Disabled = !allow;
|
||||
}
|
||||
|
||||
private bool allowOffsetAdjust
|
||||
@ -291,7 +298,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
|
||||
Debug.Assert(gameplayClock != null);
|
||||
|
||||
// TODO: the blocking conditions should probably display a message.
|
||||
if (!player.IsBreakTime.Value && gameplayClock.CurrentTime - gameplayClock.StartTime > 10000)
|
||||
if (!player.IsBreakTime.Value && gameplayClock.CurrentTime - gameplayClock.GameplayStartTime > 10000)
|
||||
return false;
|
||||
|
||||
if (gameplayClock.IsPaused.Value)
|
||||
|
@ -1181,14 +1181,7 @@ namespace osu.Game.Screens.Select
|
||||
switch (e.Action)
|
||||
{
|
||||
case GlobalAction.AbsoluteScrollSongList:
|
||||
// The default binding for absolute scroll is right mouse button.
|
||||
// To avoid conflicts with context menus, disallow absolute scroll completely if it looks like things will fall over.
|
||||
if (e.CurrentState.Mouse.Buttons.Contains(MouseButton.Right)
|
||||
&& GetContainingInputManager()!.HoveredDrawables.OfType<IHasContextMenu>().Any())
|
||||
return false;
|
||||
|
||||
ScrollToAbsolutePosition(e.CurrentState.Mouse.Position);
|
||||
absoluteScrolling = true;
|
||||
beginAbsoluteScrolling(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1200,11 +1193,32 @@ namespace osu.Game.Screens.Select
|
||||
switch (e.Action)
|
||||
{
|
||||
case GlobalAction.AbsoluteScrollSongList:
|
||||
absoluteScrolling = false;
|
||||
endAbsoluteScrolling();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool OnMouseDown(MouseDownEvent e)
|
||||
{
|
||||
if (e.Button == MouseButton.Right)
|
||||
{
|
||||
// To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over.
|
||||
if (GetContainingInputManager()!.HoveredDrawables.OfType<IHasContextMenu>().Any())
|
||||
return false;
|
||||
|
||||
beginAbsoluteScrolling(e);
|
||||
}
|
||||
|
||||
return base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseUpEvent e)
|
||||
{
|
||||
if (e.Button == MouseButton.Right)
|
||||
endAbsoluteScrolling();
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected override bool OnMouseMove(MouseMoveEvent e)
|
||||
{
|
||||
if (absoluteScrolling)
|
||||
@ -1216,6 +1230,14 @@ namespace osu.Game.Screens.Select
|
||||
return base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
private void beginAbsoluteScrolling(UIEvent e)
|
||||
{
|
||||
ScrollToAbsolutePosition(e.CurrentState.Mouse.Position);
|
||||
absoluteScrolling = true;
|
||||
}
|
||||
|
||||
private void endAbsoluteScrolling() => absoluteScrolling = false;
|
||||
|
||||
#endregion
|
||||
|
||||
protected override ScrollbarContainer CreateScrollbar(Direction direction)
|
||||
|
@ -6,7 +6,6 @@ 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;
|
||||
@ -14,7 +13,6 @@ 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
|
||||
@ -24,10 +22,10 @@ namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
|
||||
|
||||
private readonly DrawablePool<BeatmapCarouselPanel> carouselPanelPool = new DrawablePool<BeatmapCarouselPanel>(100);
|
||||
|
||||
private readonly LoadingLayer loading;
|
||||
|
||||
private readonly BeatmapCarouselFilterGrouping grouping;
|
||||
|
||||
public BeatmapCarousel()
|
||||
{
|
||||
DebounceDelay = 100;
|
||||
@ -36,25 +34,27 @@ namespace osu.Game.Screens.SelectV2
|
||||
Filters = new ICarouselFilter[]
|
||||
{
|
||||
new BeatmapCarouselFilterSorting(() => Criteria),
|
||||
new BeatmapCarouselFilterGrouping(() => Criteria),
|
||||
grouping = new BeatmapCarouselFilterGrouping(() => Criteria),
|
||||
};
|
||||
|
||||
AddInternal(carouselPanelPool);
|
||||
|
||||
AddInternal(loading = new LoadingLayer(dimBackground: true));
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken)
|
||||
{
|
||||
setupPools();
|
||||
setupBeatmaps(beatmapStore, cancellationToken);
|
||||
}
|
||||
|
||||
#region Beatmap source hookup
|
||||
|
||||
private void setupBeatmaps(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.
|
||||
@ -88,19 +88,82 @@ namespace osu.Game.Screens.SelectV2
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Selection handling
|
||||
|
||||
protected override void HandleItemSelected(object? model)
|
||||
{
|
||||
base.HandleItemSelected(model);
|
||||
|
||||
// Selecting a set isn't valid – let's re-select the first difficulty.
|
||||
if (model is BeatmapSetInfo setInfo)
|
||||
{
|
||||
CurrentSelection = setInfo.Beatmaps.First();
|
||||
return;
|
||||
}
|
||||
|
||||
if (model is BeatmapInfo beatmapInfo)
|
||||
setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, true);
|
||||
}
|
||||
|
||||
protected override void HandleItemDeselected(object? model)
|
||||
{
|
||||
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))
|
||||
{
|
||||
foreach (var i in group)
|
||||
i.IsVisible = visible;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filtering
|
||||
|
||||
public FilterCriteria Criteria { get; private set; } = new FilterCriteria();
|
||||
|
||||
public void Filter(FilterCriteria criteria)
|
||||
{
|
||||
Criteria = criteria;
|
||||
FilterAsync().FireAndForget();
|
||||
loading.Show();
|
||||
FilterAsync().ContinueWith(_ => Schedule(() => loading.Hide()));
|
||||
}
|
||||
|
||||
protected override async Task FilterAsync()
|
||||
#endregion
|
||||
|
||||
#region Drawable pooling
|
||||
|
||||
private readonly DrawablePool<BeatmapPanel> beatmapPanelPool = new DrawablePool<BeatmapPanel>(100);
|
||||
private readonly DrawablePool<BeatmapSetPanel> setPanelPool = new DrawablePool<BeatmapSetPanel>(100);
|
||||
|
||||
private void setupPools()
|
||||
{
|
||||
loading.Show();
|
||||
await base.FilterAsync().ConfigureAwait(true);
|
||||
loading.Hide();
|
||||
AddInternal(beatmapPanelPool);
|
||||
AddInternal(setPanelPool);
|
||||
}
|
||||
|
||||
protected override Drawable GetDrawableForDisplay(CarouselItem item)
|
||||
{
|
||||
switch (item.Model)
|
||||
{
|
||||
case BeatmapInfo:
|
||||
return beatmapPanelPool.Get();
|
||||
|
||||
case BeatmapSetInfo:
|
||||
return setPanelPool.Get();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,13 @@ namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
public class BeatmapCarouselFilterGrouping : ICarouselFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection.
|
||||
/// </summary>
|
||||
public IDictionary<BeatmapSetInfo, HashSet<CarouselItem>> SetItems => setItems;
|
||||
|
||||
private readonly Dictionary<BeatmapSetInfo, HashSet<CarouselItem>> setItems = new Dictionary<BeatmapSetInfo, HashSet<CarouselItem>>();
|
||||
|
||||
private readonly Func<FilterCriteria> getCriteria;
|
||||
|
||||
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
|
||||
@ -27,7 +34,10 @@ namespace osu.Game.Screens.SelectV2
|
||||
if (criteria.SplitOutDifficulties)
|
||||
{
|
||||
foreach (var item in items)
|
||||
((BeatmapCarouselItem)item).HasGroupHeader = false;
|
||||
{
|
||||
item.IsVisible = true;
|
||||
item.IsGroupSelectionTarget = true;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@ -44,14 +54,25 @@ namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
// 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(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);
|
||||
}
|
||||
|
||||
newItems.Add(item);
|
||||
lastItem = item;
|
||||
|
||||
var beatmapCarouselItem = (BeatmapCarouselItem)item;
|
||||
beatmapCarouselItem.HasGroupHeader = true;
|
||||
item.IsGroupSelectionTarget = false;
|
||||
item.IsVisible = false;
|
||||
}
|
||||
|
||||
return newItems;
|
||||
|
@ -26,39 +26,34 @@ namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
var criteria = getCriteria();
|
||||
|
||||
return items.OrderDescending(Comparer<CarouselItem>.Create((a, b) =>
|
||||
return items.Order(Comparer<CarouselItem>.Create((a, b) =>
|
||||
{
|
||||
int comparison = 0;
|
||||
int comparison;
|
||||
|
||||
if (a.Model is BeatmapInfo ab && b.Model is BeatmapInfo bb)
|
||||
var ab = (BeatmapInfo)a.Model;
|
||||
var bb = (BeatmapInfo)b.Model;
|
||||
|
||||
switch (criteria.Sort)
|
||||
{
|
||||
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.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.Difficulty:
|
||||
comparison = ab.StarRating.CompareTo(bb.StarRating);
|
||||
break;
|
||||
|
||||
case SortMode.Title:
|
||||
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
|
||||
break;
|
||||
case SortMode.Title:
|
||||
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
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;
|
||||
return comparison;
|
||||
}));
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
@ -1,48 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using 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();
|
||||
}
|
||||
}
|
||||
}
|
111
osu.Game/Screens/SelectV2/BeatmapPanel.cs
Normal file
111
osu.Game/Screens/SelectV2/BeatmapPanel.cs
Normal file
@ -0,0 +1,111 @@
|
||||
// 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 BeatmapPanel : PoolableDrawable, ICarouselPanel
|
||||
{
|
||||
[Resolved]
|
||||
private BeatmapCarousel carousel { get; set; } = null!;
|
||||
|
||||
private Box activationFlash = null!;
|
||||
private OsuSpriteText text = null!;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
Size = new Vector2(500, CarouselItem.DEFAULT_HEIGHT);
|
||||
Masking = true;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.Aqua.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);
|
||||
});
|
||||
|
||||
KeyboardSelected.BindValueChanged(value =>
|
||||
{
|
||||
if (value.NewValue)
|
||||
{
|
||||
BorderThickness = 5;
|
||||
BorderColour = Color4.Pink;
|
||||
}
|
||||
else
|
||||
{
|
||||
BorderThickness = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void PrepareForUse()
|
||||
{
|
||||
base.PrepareForUse();
|
||||
|
||||
Debug.Assert(Item != null);
|
||||
var beatmap = (BeatmapInfo)Item.Model;
|
||||
|
||||
text.Text = $"Difficulty: {beatmap.DifficultyName} ({beatmap.StarRating:N1}*)";
|
||||
|
||||
this.FadeInFromZero(500, Easing.OutQuint);
|
||||
}
|
||||
|
||||
protected override bool OnClick(ClickEvent e)
|
||||
{
|
||||
if (carousel.CurrentSelection != Item!.Model)
|
||||
{
|
||||
carousel.CurrentSelection = Item!.Model;
|
||||
return true;
|
||||
}
|
||||
|
||||
carousel.TryActivateSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
#region ICarouselPanel
|
||||
|
||||
public CarouselItem? Item { get; set; }
|
||||
public BindableBool Selected { get; } = new BindableBool();
|
||||
public BindableBool KeyboardSelected { get; } = new BindableBool();
|
||||
|
||||
public double DrawYPosition { get; set; }
|
||||
|
||||
public void Activated() => activationFlash.FadeOutFromOne(500, Easing.OutQuint);
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
@ -16,32 +17,38 @@ using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
public partial class BeatmapCarouselPanel : PoolableDrawable, ICarouselPanel
|
||||
public partial class BeatmapSetPanel : PoolableDrawable, ICarouselPanel
|
||||
{
|
||||
public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 2;
|
||||
|
||||
[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;
|
||||
private OsuSpriteText text = null!;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
selected.BindValueChanged(value =>
|
||||
Size = new Vector2(500, HEIGHT);
|
||||
Masking = true;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = Color4.Yellow.Darken(5),
|
||||
Alpha = 0.8f,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
text = new OsuSpriteText
|
||||
{
|
||||
Padding = new MarginPadding(5),
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
}
|
||||
};
|
||||
|
||||
KeyboardSelected.BindValueChanged(value =>
|
||||
{
|
||||
if (value.NewValue)
|
||||
{
|
||||
@ -55,38 +62,16 @@ namespace osu.Game.Screens.SelectV2
|
||||
});
|
||||
}
|
||||
|
||||
protected override void FreeAfterUse()
|
||||
{
|
||||
base.FreeAfterUse();
|
||||
Item = null;
|
||||
}
|
||||
|
||||
protected override void PrepareForUse()
|
||||
{
|
||||
base.PrepareForUse();
|
||||
|
||||
Debug.Assert(Item != null);
|
||||
Debug.Assert(Item.IsGroupSelectionTarget);
|
||||
|
||||
DrawYPosition = Item.CarouselYPosition;
|
||||
var beatmapSetInfo = (BeatmapSetInfo)Item.Model;
|
||||
|
||||
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,
|
||||
}
|
||||
};
|
||||
text.Text = $"{beatmapSetInfo.Metadata}";
|
||||
|
||||
this.FadeInFromZero(500, Easing.OutQuint);
|
||||
}
|
||||
@ -97,6 +82,20 @@ namespace osu.Game.Screens.SelectV2
|
||||
return true;
|
||||
}
|
||||
|
||||
#region ICarouselPanel
|
||||
|
||||
public CarouselItem? Item { get; set; }
|
||||
public BindableBool Selected { 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
|
||||
}
|
||||
}
|
@ -28,12 +28,10 @@ namespace osu.Game.Screens.SelectV2
|
||||
/// 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
|
||||
public abstract partial class Carousel<T> : CompositeDrawable, IKeyBindingHandler<GlobalAction>
|
||||
where T : notnull
|
||||
{
|
||||
/// <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>();
|
||||
#region Properties and methods for external usage
|
||||
|
||||
/// <summary>
|
||||
/// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it.
|
||||
@ -75,13 +73,65 @@ namespace osu.Game.Screens.SelectV2
|
||||
/// <summary>
|
||||
/// The number of carousel items currently in rotation for display.
|
||||
/// </summary>
|
||||
public int DisplayableItems => displayedCarouselItems?.Count ?? 0;
|
||||
public int DisplayableItems => carouselItems?.Count ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// The number of items currently actualised into drawables.
|
||||
/// </summary>
|
||||
public int VisibleItems => scroll.Panels.Count;
|
||||
|
||||
/// <summary>
|
||||
/// The currently selected model. Generally of type T.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A carousel may create panels for non-T types.
|
||||
/// To keep things simple, we therefore avoid generic constraints on the current selection.
|
||||
///
|
||||
/// The selection is never reset due to not existing. It can be set to anything.
|
||||
/// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches.
|
||||
/// </remarks>
|
||||
public object? CurrentSelection
|
||||
{
|
||||
get => currentSelection.Model;
|
||||
set => setSelection(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activate the current selection, if a selection exists and matches keyboard selection.
|
||||
/// If keyboard selection does not match selection, this will transfer the selection on first invocation.
|
||||
/// </summary>
|
||||
public void TryActivateSelection()
|
||||
{
|
||||
if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
|
||||
{
|
||||
CurrentSelection = currentKeyboardSelection.Model;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSelection.CarouselItem != null)
|
||||
{
|
||||
(GetMaterialisedDrawableForItem(currentSelection.CarouselItem) as ICarouselPanel)?.Activated();
|
||||
HandleItemActivated(currentSelection.CarouselItem);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties and methods concerning implementations
|
||||
|
||||
/// <summary>
|
||||
/// A collection of filters which should be run each time a <see cref="FilterAsync"/> is executed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations should add all required filters as part of their initialisation.
|
||||
///
|
||||
/// Importantly, each filter is sequentially run in the order provided.
|
||||
/// Each filter receives the output of the previous filter.
|
||||
///
|
||||
/// A filter may add, mutate or remove items.
|
||||
/// </remarks>
|
||||
protected IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>();
|
||||
|
||||
/// <summary>
|
||||
/// All items which are to be considered for display in this carousel.
|
||||
/// Mutating this list will automatically queue a <see cref="FilterAsync"/>.
|
||||
@ -91,44 +141,6 @@ namespace osu.Game.Screens.SelectV2
|
||||
/// </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()
|
||||
{
|
||||
InternalChild = scroll = new CarouselScrollContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Masking = false,
|
||||
};
|
||||
|
||||
Items.BindCollectionChanged((_, _) => FilterAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue an asynchronous filter operation.
|
||||
/// </summary>
|
||||
@ -145,14 +157,66 @@ namespace osu.Game.Screens.SelectV2
|
||||
protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Create an internal carousel representation for the provided model object.
|
||||
/// Given a <see cref="CarouselItem"/>, find a drawable representation if it is currently displayed in the carousel.
|
||||
/// </summary>
|
||||
/// <param name="model">The model.</param>
|
||||
/// <returns>A <see cref="CarouselItem"/> representing the model.</returns>
|
||||
protected abstract CarouselItem CreateCarouselItemForModel(T model);
|
||||
/// <remarks>
|
||||
/// This will only return a drawable if it is "on-screen".
|
||||
/// </remarks>
|
||||
/// <param name="item">The item to find a related drawable representation.</param>
|
||||
/// <returns>The drawable representation if it exists.</returns>
|
||||
protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) =>
|
||||
scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item);
|
||||
|
||||
/// <summary>
|
||||
/// Called when an item is "selected".
|
||||
/// </summary>
|
||||
protected virtual void HandleItemSelected(object? model)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when an item is "deselected".
|
||||
/// </summary>
|
||||
protected virtual void HandleItemDeselected(object? model)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when an item is "activated".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An activated item should for instance:
|
||||
/// - Open or close a folder
|
||||
/// - Start gameplay on a beatmap difficulty.
|
||||
/// </remarks>
|
||||
/// <param name="item">The carousel item which was activated.</param>
|
||||
protected virtual void HandleItemActivated(CarouselItem item)
|
||||
{
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Initialisation
|
||||
|
||||
private readonly CarouselScrollContainer scroll;
|
||||
|
||||
protected Carousel()
|
||||
{
|
||||
InternalChild = scroll = new CarouselScrollContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Masking = false,
|
||||
};
|
||||
|
||||
Items.BindCollectionChanged((_, _) => FilterAsync());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filtering and display preparation
|
||||
|
||||
private List<CarouselItem>? carouselItems;
|
||||
|
||||
private Task filterTask = Task.CompletedTask;
|
||||
private CancellationTokenSource cancellationSource = new CancellationTokenSource();
|
||||
|
||||
@ -177,7 +241,7 @@ namespace osu.Game.Screens.SelectV2
|
||||
|
||||
// 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));
|
||||
IEnumerable<CarouselItem> items = new List<CarouselItem>(Items.Select(m => new CarouselItem(m)));
|
||||
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
@ -190,7 +254,7 @@ namespace osu.Game.Screens.SelectV2
|
||||
}
|
||||
|
||||
log("Updating Y positions");
|
||||
await updateYPositions(items, cts.Token).ConfigureAwait(false);
|
||||
updateYPositions(items, visibleHalfHeight, SpacingBetweenPanels);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@ -202,61 +266,234 @@ namespace osu.Game.Screens.SelectV2
|
||||
return;
|
||||
|
||||
log("Items ready for display");
|
||||
displayedCarouselItems = items.ToList();
|
||||
carouselItems = items.ToList();
|
||||
displayedRange = null;
|
||||
|
||||
updateSelection();
|
||||
// Need to call this to ensure correct post-selection logic is handled on the new items list.
|
||||
HandleItemSelected(currentSelection.Model);
|
||||
|
||||
refreshAfterSelection();
|
||||
|
||||
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(() =>
|
||||
private static void updateYPositions(IEnumerable<CarouselItem> carouselItems, float offset, float spacing)
|
||||
{
|
||||
float yPos = visibleHalfHeight;
|
||||
|
||||
foreach (var item in carouselItems)
|
||||
updateItemYPosition(item, ref offset, spacing);
|
||||
}
|
||||
|
||||
private static void updateItemYPosition(CarouselItem item, ref float offset, float spacing)
|
||||
{
|
||||
item.CarouselYPosition = offset;
|
||||
if (item.IsVisible)
|
||||
offset += item.DrawHeight + spacing;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Input handling
|
||||
|
||||
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
|
||||
{
|
||||
switch (e.Action)
|
||||
{
|
||||
item.CarouselYPosition = yPos;
|
||||
yPos += item.DrawHeight + SpacingBetweenPanels;
|
||||
case GlobalAction.Select:
|
||||
TryActivateSelection();
|
||||
return true;
|
||||
|
||||
case GlobalAction.SelectNext:
|
||||
selectNext(1, isGroupSelection: false);
|
||||
return true;
|
||||
|
||||
case GlobalAction.SelectNextGroup:
|
||||
selectNext(1, isGroupSelection: true);
|
||||
return true;
|
||||
|
||||
case GlobalAction.SelectPrevious:
|
||||
selectNext(-1, isGroupSelection: false);
|
||||
return true;
|
||||
|
||||
case GlobalAction.SelectPreviousGroup:
|
||||
selectNext(-1, isGroupSelection: true);
|
||||
return true;
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
|
||||
{
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// Ensure sanity
|
||||
Debug.Assert(direction != 0);
|
||||
direction = direction > 0 ? 1 : -1;
|
||||
|
||||
if (carouselItems == null || carouselItems.Count == 0)
|
||||
return false;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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];
|
||||
|
||||
if (attemptSelection(newItem))
|
||||
return true;
|
||||
} while (newItem != selectionItem);
|
||||
|
||||
return false;
|
||||
|
||||
bool attemptSelection(CarouselItem item)
|
||||
{
|
||||
if (!item.IsVisible || (isGroupSelection && !item.IsGroupSelectionTarget))
|
||||
return false;
|
||||
|
||||
if (isGroupSelection)
|
||||
setSelection(item.Model);
|
||||
else
|
||||
setKeyboardSelection(item.Model);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Selection handling
|
||||
|
||||
private object? currentSelection;
|
||||
private CarouselItem? currentSelectionCarouselItem;
|
||||
private double? currentSelectionYPosition;
|
||||
private Selection currentKeyboardSelection = new Selection();
|
||||
private Selection currentSelection = new Selection();
|
||||
|
||||
private void updateSelection()
|
||||
private void setSelection(object? model)
|
||||
{
|
||||
currentSelectionCarouselItem = null;
|
||||
if (currentSelection.Model == model)
|
||||
return;
|
||||
|
||||
if (displayedCarouselItems == null) return;
|
||||
var previousSelection = currentSelection;
|
||||
|
||||
foreach (var item in displayedCarouselItems)
|
||||
if (previousSelection.Model != null)
|
||||
HandleItemDeselected(previousSelection.Model);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private void setKeyboardSelection(object? model)
|
||||
{
|
||||
currentKeyboardSelection = new Selection(model);
|
||||
|
||||
refreshAfterSelection();
|
||||
scrollToSelection();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call after a selection of items change to re-attach <see cref="CarouselItem"/>s to current <see cref="Selection"/>s.
|
||||
/// </summary>
|
||||
private void refreshAfterSelection()
|
||||
{
|
||||
float yPos = visibleHalfHeight;
|
||||
|
||||
// Invalidate display range as panel positions and visible status may have changed.
|
||||
// Position transfer won't happen unless we invalidate this.
|
||||
displayedRange = null;
|
||||
|
||||
// The case where no items are available for display yet.
|
||||
if (carouselItems == null)
|
||||
{
|
||||
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;
|
||||
currentKeyboardSelection = new Selection();
|
||||
currentSelection = new Selection();
|
||||
return;
|
||||
}
|
||||
|
||||
float spacing = SpacingBetweenPanels;
|
||||
int count = carouselItems.Count;
|
||||
|
||||
Selection prevKeyboard = currentKeyboardSelection;
|
||||
|
||||
// We are performing two important operations here:
|
||||
// - Update all Y positions. After a selection occurs, panels may have changed visibility state and therefore Y positions.
|
||||
// - Link selected models to CarouselItems. If a selection changed, this is where we find the relevant CarouselItems for further use.
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var item = carouselItems[i];
|
||||
|
||||
updateItemYPosition(item, ref yPos, spacing);
|
||||
|
||||
if (ReferenceEquals(item.Model, currentKeyboardSelection.Model))
|
||||
currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i);
|
||||
|
||||
if (ReferenceEquals(item.Model, currentSelection.Model))
|
||||
currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i);
|
||||
}
|
||||
|
||||
// If a keyboard selection is currently made, we want to keep the view stable around the selection.
|
||||
// That means that we should offset the immediate scroll position by any change in Y position for the selection.
|
||||
if (prevKeyboard.YPosition != null && currentKeyboardSelection.YPosition != prevKeyboard.YPosition)
|
||||
scroll.OffsetScrollPosition((float)(currentKeyboardSelection.YPosition!.Value - prevKeyboard.YPosition.Value));
|
||||
}
|
||||
|
||||
private void scrollToSelection()
|
||||
{
|
||||
if (currentKeyboardSelection.CarouselItem != null)
|
||||
scroll.ScrollTo(currentKeyboardSelection.CarouselItem.CarouselYPosition - visibleHalfHeight);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -265,7 +502,7 @@ namespace osu.Game.Screens.SelectV2
|
||||
|
||||
private DisplayRange? displayedRange;
|
||||
|
||||
private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem();
|
||||
private readonly CarouselItem carouselBoundsItem = new CarouselItem(new object());
|
||||
|
||||
/// <summary>
|
||||
/// The position of the lower visible bound with respect to the current scroll position.
|
||||
@ -286,7 +523,7 @@ namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
base.Update();
|
||||
|
||||
if (displayedCarouselItems == null)
|
||||
if (carouselItems == null)
|
||||
return;
|
||||
|
||||
var range = getDisplayRange();
|
||||
@ -303,11 +540,13 @@ namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
var c = (ICarouselPanel)panel;
|
||||
|
||||
// panel in the process of expiring, ignore it.
|
||||
if (c.Item == null)
|
||||
continue;
|
||||
|
||||
if (panel.Depth != c.DrawYPosition)
|
||||
scroll.Panels.ChangeChildDepth(panel, (float)c.DrawYPosition);
|
||||
|
||||
Debug.Assert(c.Item != null);
|
||||
|
||||
if (c.DrawYPosition != c.Item.CarouselYPosition)
|
||||
c.DrawYPosition = Interpolation.DampContinuously(c.DrawYPosition, c.Item.CarouselYPosition, 50, Time.Elapsed);
|
||||
|
||||
@ -315,6 +554,9 @@ namespace osu.Game.Screens.SelectV2
|
||||
float dist = Math.Abs(1f - posInScroll.Y / visibleHalfHeight);
|
||||
|
||||
panel.X = offsetX(dist, visibleHalfHeight);
|
||||
|
||||
c.Selected.Value = c.Item == currentSelection?.CarouselItem;
|
||||
c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,15 +578,15 @@ namespace osu.Game.Screens.SelectV2
|
||||
|
||||
private DisplayRange getDisplayRange()
|
||||
{
|
||||
Debug.Assert(displayedCarouselItems != null);
|
||||
Debug.Assert(carouselItems != null);
|
||||
|
||||
// Find index range of all items that should be on-screen
|
||||
carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload;
|
||||
int firstIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem);
|
||||
int firstIndex = carouselItems.BinarySearch(carouselBoundsItem);
|
||||
if (firstIndex < 0) firstIndex = ~firstIndex;
|
||||
|
||||
carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload;
|
||||
int lastIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem);
|
||||
int lastIndex = carouselItems.BinarySearch(carouselBoundsItem);
|
||||
if (lastIndex < 0) lastIndex = ~lastIndex;
|
||||
|
||||
firstIndex = Math.Max(0, firstIndex - 1);
|
||||
@ -355,11 +597,13 @@ namespace osu.Game.Screens.SelectV2
|
||||
|
||||
private void updateDisplayedRange(DisplayRange range)
|
||||
{
|
||||
Debug.Assert(displayedCarouselItems != null);
|
||||
Debug.Assert(carouselItems != null);
|
||||
|
||||
List<CarouselItem> toDisplay = range.Last - range.First == 0
|
||||
? new List<CarouselItem>()
|
||||
: displayedCarouselItems.GetRange(range.First, range.Last - range.First + 1);
|
||||
: carouselItems.GetRange(range.First, range.Last - range.First + 1);
|
||||
|
||||
toDisplay.RemoveAll(i => !i.IsVisible);
|
||||
|
||||
// Iterate over all panels which are already displayed and figure which need to be displayed / removed.
|
||||
foreach (var panel in scroll.Panels)
|
||||
@ -389,15 +633,17 @@ namespace osu.Game.Screens.SelectV2
|
||||
if (drawable is not ICarouselPanel carouselPanel)
|
||||
throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
|
||||
|
||||
carouselPanel.DrawYPosition = item.CarouselYPosition;
|
||||
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)
|
||||
if (carouselItems.Count > 0)
|
||||
{
|
||||
var lastItem = displayedCarouselItems[^1];
|
||||
var lastItem = carouselItems[^1];
|
||||
scroll.SetLayoutHeight((float)(lastItem.CarouselYPosition + lastItem.DrawHeight + visibleHalfHeight));
|
||||
}
|
||||
else
|
||||
@ -408,12 +654,27 @@ namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
panel.FinishTransforms();
|
||||
panel.Expire();
|
||||
|
||||
var carouselPanel = (ICarouselPanel)panel;
|
||||
|
||||
carouselPanel.Item = null;
|
||||
carouselPanel.Selected.Value = false;
|
||||
carouselPanel.KeyboardSelected.Value = false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal helper classes
|
||||
|
||||
/// <summary>
|
||||
/// Bookkeeping for a current selection.
|
||||
/// </summary>
|
||||
/// <param name="Model">The selected model. If <c>null</c>, there's no selection.</param>
|
||||
/// <param name="CarouselItem">A related carousel item representation for the model. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
|
||||
/// <param name="YPosition">The Y position of the selection as of the last run of <see cref="Carousel{T}.refreshAfterSelection"/>. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
|
||||
/// <param name="Index">The index of the selection as of the last run of <see cref="Carousel{T}.refreshAfterSelection"/>. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
|
||||
private record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null);
|
||||
|
||||
private record DisplayRange(int First, int Last);
|
||||
|
||||
/// <summary>
|
||||
@ -493,15 +754,7 @@ namespace osu.Game.Screens.SelectV2
|
||||
switch (e.Action)
|
||||
{
|
||||
case GlobalAction.AbsoluteScrollSongList:
|
||||
|
||||
// The default binding for absolute scroll is right mouse button.
|
||||
// To avoid conflicts with context menus, disallow absolute scroll completely if it looks like things will fall over.
|
||||
if (e.CurrentState.Mouse.Buttons.Contains(MouseButton.Right)
|
||||
&& GetContainingInputManager()!.HoveredDrawables.OfType<IHasContextMenu>().Any())
|
||||
return false;
|
||||
|
||||
ScrollToAbsolutePosition(e.CurrentState.Mouse.Position);
|
||||
absoluteScrolling = true;
|
||||
beginAbsoluteScrolling(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -513,11 +766,32 @@ namespace osu.Game.Screens.SelectV2
|
||||
switch (e.Action)
|
||||
{
|
||||
case GlobalAction.AbsoluteScrollSongList:
|
||||
absoluteScrolling = false;
|
||||
endAbsoluteScrolling();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool OnMouseDown(MouseDownEvent e)
|
||||
{
|
||||
if (e.Button == MouseButton.Right)
|
||||
{
|
||||
// To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over.
|
||||
if (GetContainingInputManager()!.HoveredDrawables.OfType<IHasContextMenu>().Any())
|
||||
return false;
|
||||
|
||||
beginAbsoluteScrolling(e);
|
||||
}
|
||||
|
||||
return base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseUpEvent e)
|
||||
{
|
||||
if (e.Button == MouseButton.Right)
|
||||
endAbsoluteScrolling();
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected override bool OnMouseMove(MouseMoveEvent e)
|
||||
{
|
||||
if (absoluteScrolling)
|
||||
@ -529,17 +803,15 @@ namespace osu.Game.Screens.SelectV2
|
||||
return base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
private class BoundsCarouselItem : CarouselItem
|
||||
{
|
||||
public override float DrawHeight => 0;
|
||||
|
||||
public BoundsCarouselItem()
|
||||
: base(new object())
|
||||
private void beginAbsoluteScrolling(UIEvent e)
|
||||
{
|
||||
ScrollToAbsolutePosition(e.CurrentState.Mouse.Position);
|
||||
absoluteScrolling = true;
|
||||
}
|
||||
|
||||
private void endAbsoluteScrolling() => absoluteScrolling = false;
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
@ -2,7 +2,6 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osu.Framework.Bindables;
|
||||
|
||||
namespace osu.Game.Screens.SelectV2
|
||||
{
|
||||
@ -10,9 +9,9 @@ namespace osu.Game.Screens.SelectV2
|
||||
/// 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 sealed class CarouselItem : IComparable<CarouselItem>
|
||||
{
|
||||
public readonly BindableBool Selected = new BindableBool();
|
||||
public const float DEFAULT_HEIGHT = 40;
|
||||
|
||||
/// <summary>
|
||||
/// The model this item is representing.
|
||||
@ -20,16 +19,27 @@ namespace osu.Game.Screens.SelectV2
|
||||
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.
|
||||
/// 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.
|
||||
/// The height this item will take when displayed. Defaults to <see cref="DEFAULT_HEIGHT"/>.
|
||||
/// </summary>
|
||||
public abstract float DrawHeight { get; }
|
||||
public float DrawHeight { get; set; } = DEFAULT_HEIGHT;
|
||||
|
||||
protected CarouselItem(object model)
|
||||
/// <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).
|
||||
/// </summary>
|
||||
public bool IsVisible { get; set; } = true;
|
||||
|
||||
public CarouselItem(object model)
|
||||
{
|
||||
Model = model;
|
||||
}
|
||||
|
@ -1,22 +1,40 @@
|
||||
// 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.Graphics;
|
||||
using osu.Framework.Graphics.Pooling;
|
||||
|
||||
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}"/>.
|
||||
/// Importantly, all properties in this interface are managed by <see cref="Carousel{T}"/> and should not be written to elsewhere.
|
||||
/// </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.
|
||||
/// Whether this item has selection. Should be read from to update the visual state.
|
||||
/// </summary>
|
||||
BindableBool Selected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this item has keyboard selection. Should be read from to update the visual state.
|
||||
/// </summary>
|
||||
BindableBool KeyboardSelected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Called when the panel is activated. Should be used to update the panel's visual state.
|
||||
/// </summary>
|
||||
void Activated();
|
||||
|
||||
/// <summary>
|
||||
/// The Y position used internally for positioning in the carousel.
|
||||
/// </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.
|
||||
/// The carousel item this drawable is representing. Will be set before <see cref="PoolableDrawable.PrepareForUse"/> is called.
|
||||
/// </summary>
|
||||
CarouselItem? Item { get; set; }
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ using JetBrains.Annotations;
|
||||
using osu.Framework.Audio.Sample;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
@ -110,15 +109,37 @@ namespace osu.Game.Skinning
|
||||
case GlobalSkinnableContainers.MainHUDComponents:
|
||||
if (containerLookup.Ruleset != null)
|
||||
{
|
||||
return new Container
|
||||
return new DefaultSkinComponentsContainer(container =>
|
||||
{
|
||||
var comboCounter = container.OfType<ArgonComboCounter>().FirstOrDefault();
|
||||
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
|
||||
|
||||
Vector2 pos = new Vector2(36, -66);
|
||||
|
||||
if (comboCounter != null)
|
||||
{
|
||||
comboCounter.Position = pos;
|
||||
pos -= new Vector2(0, comboCounter.DrawHeight * 1.4f + 20);
|
||||
}
|
||||
|
||||
if (spectatorList != null)
|
||||
spectatorList.Position = pos;
|
||||
})
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Child = new ArgonComboCounter
|
||||
Children = new Drawable[]
|
||||
{
|
||||
Anchor = Anchor.BottomLeft,
|
||||
Origin = Anchor.BottomLeft,
|
||||
Position = new Vector2(36, -66),
|
||||
Scale = new Vector2(1.3f),
|
||||
new ArgonComboCounter
|
||||
{
|
||||
Anchor = Anchor.BottomLeft,
|
||||
Origin = Anchor.BottomLeft,
|
||||
Scale = new Vector2(1.3f),
|
||||
},
|
||||
new SpectatorList
|
||||
{
|
||||
Anchor = Anchor.BottomLeft,
|
||||
Origin = Anchor.BottomLeft,
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -367,16 +367,29 @@ namespace osu.Game.Skinning
|
||||
return new DefaultSkinComponentsContainer(container =>
|
||||
{
|
||||
var combo = container.OfType<LegacyDefaultComboCounter>().FirstOrDefault();
|
||||
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
|
||||
|
||||
Vector2 pos = new Vector2();
|
||||
|
||||
if (combo != null)
|
||||
{
|
||||
combo.Anchor = Anchor.BottomLeft;
|
||||
combo.Origin = Anchor.BottomLeft;
|
||||
combo.Scale = new Vector2(1.28f);
|
||||
|
||||
pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X);
|
||||
}
|
||||
|
||||
if (spectatorList != null)
|
||||
{
|
||||
spectatorList.Anchor = Anchor.BottomLeft;
|
||||
spectatorList.Origin = Anchor.BottomLeft;
|
||||
spectatorList.Position = pos;
|
||||
}
|
||||
})
|
||||
{
|
||||
new LegacyDefaultComboCounter()
|
||||
new LegacyDefaultComboCounter(),
|
||||
new SpectatorList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
using osu.Game.Extensions;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.Screens.Play.HUD;
|
||||
using osu.Game.Screens.Play.HUD.HitErrorMeters;
|
||||
@ -90,6 +91,7 @@ namespace osu.Game.Skinning
|
||||
var ppCounter = container.OfType<PerformancePointsCounter>().FirstOrDefault();
|
||||
var songProgress = container.OfType<DefaultSongProgress>().FirstOrDefault();
|
||||
var keyCounter = container.OfType<DefaultKeyCounterDisplay>().FirstOrDefault();
|
||||
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();
|
||||
|
||||
if (score != null)
|
||||
{
|
||||
@ -142,17 +144,26 @@ namespace osu.Game.Skinning
|
||||
}
|
||||
}
|
||||
|
||||
const float padding = 10;
|
||||
|
||||
// Hard to find this at runtime, so taken from the most expanded state during replay.
|
||||
const float song_progress_offset_height = 73;
|
||||
|
||||
if (songProgress != null && keyCounter != null)
|
||||
{
|
||||
const float padding = 10;
|
||||
|
||||
// Hard to find this at runtime, so taken from the most expanded state during replay.
|
||||
const float song_progress_offset_height = 73;
|
||||
|
||||
keyCounter.Anchor = Anchor.BottomRight;
|
||||
keyCounter.Origin = Anchor.BottomRight;
|
||||
keyCounter.Position = new Vector2(-padding, -(song_progress_offset_height + padding));
|
||||
}
|
||||
|
||||
if (spectatorList != null)
|
||||
{
|
||||
spectatorList.Font.Value = Typeface.Venera;
|
||||
spectatorList.HeaderColour.Value = new OsuColour().BlueLighter;
|
||||
spectatorList.Anchor = Anchor.BottomLeft;
|
||||
spectatorList.Origin = Anchor.BottomLeft;
|
||||
spectatorList.Position = new Vector2(padding, -(song_progress_offset_height + padding));
|
||||
}
|
||||
})
|
||||
{
|
||||
Children = new Drawable[]
|
||||
@ -165,7 +176,8 @@ namespace osu.Game.Skinning
|
||||
new DefaultKeyCounterDisplay(),
|
||||
new BarHitErrorMeter(),
|
||||
new BarHitErrorMeter(),
|
||||
new TrianglesPerformancePointsCounter()
|
||||
new TrianglesPerformancePointsCounter(),
|
||||
new SpectatorList(),
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -11,6 +11,7 @@ using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Configuration;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Framework.Statistics;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Configuration;
|
||||
@ -36,7 +37,7 @@ namespace osu.Game.Utils
|
||||
|
||||
private readonly OsuGame game;
|
||||
|
||||
public SentryLogger(OsuGame game)
|
||||
public SentryLogger(OsuGame game, Storage? storage = null)
|
||||
{
|
||||
this.game = game;
|
||||
|
||||
@ -49,6 +50,7 @@ namespace osu.Game.Utils
|
||||
options.AutoSessionTracking = true;
|
||||
options.IsEnvironmentUser = false;
|
||||
options.IsGlobalModeEnabled = true;
|
||||
options.CacheDirectoryPath = storage?.GetFullPath(string.Empty);
|
||||
// The reported release needs to match version as reported to Sentry in .github/workflows/sentry-release.yml
|
||||
options.Release = $"osu@{game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty)}";
|
||||
});
|
||||
|
@ -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.117.0" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2025.129.1" />
|
||||
<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.117.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.129.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
@ -153,6 +153,8 @@
|
||||
<string>Editor</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music-games</string>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
|
Loading…
Reference in New Issue
Block a user