1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-07 17:13:24 +08:00

Merge branch 'master' into mobile-fix-mania

This commit is contained in:
Dean Herbert 2025-01-24 17:03:00 +09:00
commit 56a611b47e
No known key found for this signature in database
103 changed files with 3050 additions and 487 deletions

View File

@ -9,7 +9,7 @@
]
},
"nvika": {
"version": "3.0.0",
"version": "4.0.0",
"commands": [
"nvika"
]

View File

@ -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

View File

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

View File

@ -51,12 +51,9 @@ namespace osu.Desktop
[Resolved]
private LocalUserStatisticsProvider statisticsProvider { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private readonly IBindable<UserStatus?> status = new Bindable<UserStatus?>();
private readonly IBindable<UserActivity> activity = new Bindable<UserActivity>();
private readonly Bindable<DiscordRichPresenceMode> privacyMode = new Bindable<DiscordRichPresenceMode>();
private IBindable<DiscordRichPresenceMode> privacyMode = null!;
private IBindable<UserStatus> userStatus = null!;
private IBindable<UserActivity?> userActivity = null!;
private readonly RichPresence presence = new RichPresence
{
@ -71,8 +68,12 @@ namespace osu.Desktop
private IBindable<APIUser>? user;
[BackgroundDependencyLoader]
private void load()
private void load(OsuConfigManager config, SessionStatics session)
{
privacyMode = config.GetBindable<DiscordRichPresenceMode>(OsuSetting.DiscordRichPresence);
userStatus = config.GetBindable<UserStatus>(OsuSetting.UserOnlineStatus);
userActivity = session.GetBindable<UserActivity?>(Static.UserOnlineActivity);
client = new DiscordRpcClient(client_id)
{
// SkipIdenticalPresence allows us to fire SetPresence at any point and leave it to the underlying implementation
@ -81,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
{
@ -105,21 +106,11 @@ namespace osu.Desktop
{
base.LoadComplete();
config.BindWith(OsuSetting.DiscordRichPresence, privacyMode);
user = api.LocalUser.GetBoundCopy();
user.BindValueChanged(u =>
{
status.UnbindBindings();
status.BindTo(u.NewValue.Status);
activity.UnbindBindings();
activity.BindTo(u.NewValue.Activity);
}, true);
ruleset.BindValueChanged(_ => schedulePresenceUpdate());
status.BindValueChanged(_ => schedulePresenceUpdate());
activity.BindValueChanged(_ => schedulePresenceUpdate());
userStatus.BindValueChanged(_ => schedulePresenceUpdate());
userActivity.BindValueChanged(_ => schedulePresenceUpdate());
privacyMode.BindValueChanged(_ => schedulePresenceUpdate());
multiplayerClient.RoomUpdated += onRoomUpdated;
@ -151,13 +142,13 @@ namespace osu.Desktop
if (!client.IsInitialized)
return;
if (status.Value == UserStatus.Offline || privacyMode.Value == DiscordRichPresenceMode.Off)
if (!api.IsLoggedIn || userStatus.Value == UserStatus.Offline || privacyMode.Value == DiscordRichPresenceMode.Off)
{
client.ClearPresence();
return;
}
bool hideIdentifiableInformation = privacyMode.Value == DiscordRichPresenceMode.Limited || status.Value == UserStatus.DoNotDisturb;
bool hideIdentifiableInformation = privacyMode.Value == DiscordRichPresenceMode.Limited || userStatus.Value == UserStatus.DoNotDisturb;
updatePresence(hideIdentifiableInformation);
client.SetPresence(presence);
@ -170,12 +161,12 @@ namespace osu.Desktop
return;
// user activity
if (activity.Value != null)
if (userActivity.Value != null)
{
presence.State = clampLength(activity.Value.GetStatus(hideIdentifiableInformation));
presence.Details = clampLength(activity.Value.GetDetails(hideIdentifiableInformation) ?? string.Empty);
presence.State = clampLength(userActivity.Value.GetStatus(hideIdentifiableInformation));
presence.Details = clampLength(userActivity.Value.GetDetails(hideIdentifiableInformation) ?? string.Empty);
if (activity.Value.GetBeatmapID(hideIdentifiableInformation) is int beatmapId && beatmapId > 0)
if (userActivity.Value.GetBeatmapID(hideIdentifiableInformation) is int beatmapId && beatmapId > 0)
{
presence.Buttons = new[]
{

View File

@ -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.";

View File

@ -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(),
}
};
}

View File

@ -64,11 +64,11 @@ namespace osu.Game.Rulesets.Mania.Edit
return;
List<ManiaHitObject> remainingHitObjects = EditorBeatmap.HitObjects.Cast<ManiaHitObject>().Where(h => h.StartTime >= timestamp).ToList();
string[] objectDescriptions = objectDescription.Split(',').ToArray();
string[] objectDescriptions = objectDescription.Split(',');
for (int i = 0; i < objectDescriptions.Length; i++)
{
string[] split = objectDescriptions[i].Split('|').ToArray();
string[] split = objectDescriptions[i].Split('|');
if (split.Length != 2)
continue;

View File

@ -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,
}
};
}

View File

@ -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(),
};
}

View File

@ -178,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Edit
return;
List<OsuHitObject> remainingHitObjects = EditorBeatmap.HitObjects.Cast<OsuHitObject>().Where(h => h.StartTime >= timestamp).ToList();
string[] splitDescription = objectDescription.Split(',').ToArray();
string[] splitDescription = objectDescription.Split(',');
for (int i = 0; i < splitDescription.Length; i++)
{

View 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);
}
}
}

View File

@ -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 =>

View File

@ -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(_ =>

View File

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

View File

@ -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(),
}
};
}

View File

@ -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>

View File

@ -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);

View File

@ -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);
});
}
}
}

View File

@ -0,0 +1,79 @@
// 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.Threading;
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 int counter;
[Test]
public void TestBasics()
{
SpectatorList list = null!;
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", () =>
{
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", () => playingState.Value = LocalUserPlayingState.Playing);
AddRepeatStep("add a user", () =>
{
int id = Interlocked.Increment(ref counter);
((ISpectatorClient)client).UserStartedWatching([
new SpectatorUser
{
OnlineID = id,
Username = $"User {id}"
}
]);
}, 10);
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", () => playingState.Value = LocalUserPlayingState.Break);
AddStep("stop playing", () => playingState.Value = LocalUserPlayingState.NotPlaying);
}
}
}

View File

@ -29,9 +29,7 @@ namespace osu.Game.Tests.Visual.Menus
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private LoginOverlay loginOverlay = null!;
[Resolved]
private OsuConfigManager configManager { get; set; } = null!;
private OsuConfigManager localConfig = null!;
[Cached(typeof(LocalUserStatisticsProvider))]
private readonly TestSceneUserPanel.TestUserStatisticsProvider statisticsProvider = new TestSceneUserPanel.TestUserStatisticsProvider();
@ -39,6 +37,8 @@ namespace osu.Game.Tests.Visual.Menus
[BackgroundDependencyLoader]
private void load()
{
Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage));
Child = loginOverlay = new LoginOverlay
{
Anchor = Anchor.Centre,
@ -49,6 +49,7 @@ namespace osu.Game.Tests.Visual.Menus
[SetUpSteps]
public void SetUpSteps()
{
AddStep("reset online state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.Online));
AddStep("show login overlay", () => loginOverlay.Show());
}
@ -89,7 +90,7 @@ namespace osu.Game.Tests.Visual.Menus
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
assertDropdownState(UserAction.Online);
AddStep("change user state", () => dummyAPI.LocalUser.Value.Status.Value = UserStatus.DoNotDisturb);
AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb));
assertDropdownState(UserAction.DoNotDisturb);
}
@ -188,31 +189,31 @@ namespace osu.Game.Tests.Visual.Menus
public void TestUncheckingRememberUsernameClearsIt()
{
AddStep("logout", () => API.Logout());
AddStep("set username", () => configManager.SetValue(OsuSetting.Username, "test_user"));
AddStep("set remember password", () => configManager.SetValue(OsuSetting.SavePassword, true));
AddStep("set username", () => localConfig.SetValue(OsuSetting.Username, "test_user"));
AddStep("set remember password", () => localConfig.SetValue(OsuSetting.SavePassword, true));
AddStep("uncheck remember username", () =>
{
InputManager.MoveMouseTo(loginOverlay.ChildrenOfType<SettingsCheckbox>().First());
InputManager.Click(MouseButton.Left);
});
AddAssert("remember username off", () => configManager.Get<bool>(OsuSetting.SaveUsername), () => Is.False);
AddAssert("remember password off", () => configManager.Get<bool>(OsuSetting.SavePassword), () => Is.False);
AddAssert("username cleared", () => configManager.Get<string>(OsuSetting.Username), () => Is.Empty);
AddAssert("remember username off", () => localConfig.Get<bool>(OsuSetting.SaveUsername), () => Is.False);
AddAssert("remember password off", () => localConfig.Get<bool>(OsuSetting.SavePassword), () => Is.False);
AddAssert("username cleared", () => localConfig.Get<string>(OsuSetting.Username), () => Is.Empty);
}
[Test]
public void TestUncheckingRememberPasswordClearsToken()
{
AddStep("logout", () => API.Logout());
AddStep("set token", () => configManager.SetValue(OsuSetting.Token, "test_token"));
AddStep("set remember password", () => configManager.SetValue(OsuSetting.SavePassword, true));
AddStep("set token", () => localConfig.SetValue(OsuSetting.Token, "test_token"));
AddStep("set remember password", () => localConfig.SetValue(OsuSetting.SavePassword, true));
AddStep("uncheck remember token", () =>
{
InputManager.MoveMouseTo(loginOverlay.ChildrenOfType<SettingsCheckbox>().Last());
InputManager.Click(MouseButton.Left);
});
AddAssert("remember password off", () => configManager.Get<bool>(OsuSetting.SavePassword), () => Is.False);
AddAssert("token cleared", () => configManager.Get<string>(OsuSetting.Token), () => Is.Empty);
AddAssert("remember password off", () => localConfig.Get<bool>(OsuSetting.SavePassword), () => Is.False);
AddAssert("token cleared", () => localConfig.Get<string>(OsuSetting.Token), () => Is.Empty);
}
}
}

View File

@ -41,12 +41,14 @@ using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Play.PlayerSettings;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Select;
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;
@ -201,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()
{
@ -317,6 +351,92 @@ namespace osu.Game.Tests.Visual.Navigation
AddUntilStep("wait for song select", () => songSelect.IsCurrentScreen());
}
[Test]
public void TestOffsetAdjustDuringPause()
{
Player player = null;
Screens.Select.SongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded);
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail() });
AddStep("press enter", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for player", () =>
{
DismissAnyNotifications();
player = Game.ScreenStack.CurrentScreen as Player;
return player?.IsLoaded == true;
});
AddUntilStep("wait for track playing", () => Game.Beatmap.Value.Track.IsRunning);
checkOffset(0);
AddStep("adjust offset via keyboard", () => InputManager.Key(Key.Minus));
checkOffset(-1);
AddStep("pause", () => player.ChildrenOfType<GameplayClockContainer>().First().Stop());
AddUntilStep("wait for pause", () => player.ChildrenOfType<GameplayClockContainer>().First().IsPaused.Value, () => Is.True);
AddStep("attempt adjust offset via keyboard", () => InputManager.Key(Key.Minus));
checkOffset(-1);
void checkOffset(double offset)
{
AddUntilStep($"control offset is {offset}", () => this.ChildrenOfType<GameplayOffsetControl>().Single().ChildrenOfType<BeatmapOffsetControl>().Single().Current.Value,
() => Is.EqualTo(offset));
AddUntilStep($"database offset is {offset}", () => Game.BeatmapManager.QueryBeatmap(b => b.ID == Game.Beatmap.Value.BeatmapInfo.ID)!.UserSettings.Offset,
() => Is.EqualTo(offset));
}
}
[Test]
public void TestOffsetAdjustDuringGameplay()
{
Player player = null;
Screens.Select.SongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded);
AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail() });
AddStep("press enter", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for player", () =>
{
DismissAnyNotifications();
player = Game.ScreenStack.CurrentScreen as Player;
return player?.IsLoaded == true;
});
AddUntilStep("wait for track playing", () => Game.Beatmap.Value.Track.IsRunning);
checkOffset(0);
AddStep("adjust offset via keyboard", () => InputManager.Key(Key.Minus));
checkOffset(-1);
AddStep("seek beyond 10 seconds", () => player.ChildrenOfType<GameplayClockContainer>().First().Seek(10500));
AddUntilStep("wait for seek", () => player.ChildrenOfType<GameplayClockContainer>().First().CurrentTime, () => Is.GreaterThan(10600));
AddStep("attempt adjust offset via keyboard", () => InputManager.Key(Key.Minus));
checkOffset(-1);
void checkOffset(double offset)
{
AddUntilStep($"control offset is {offset}", () => this.ChildrenOfType<GameplayOffsetControl>().Single().ChildrenOfType<BeatmapOffsetControl>().Single().Current.Value,
() => Is.EqualTo(offset));
AddUntilStep($"database offset is {offset}", () => Game.BeatmapManager.QueryBeatmap(b => b.ID == Game.Beatmap.Value.BeatmapInfo.ID)!.UserSettings.Offset,
() => Is.EqualTo(offset));
}
}
[Test]
public void TestRetryCountIncrements()
{

View File

@ -8,7 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Configuration;
using osu.Game.Online.Chat;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mods;
@ -23,17 +23,23 @@ namespace osu.Game.Tests.Visual.Online
[Cached(typeof(IChannelPostTarget))]
private PostTarget postTarget { get; set; }
private DummyAPIAccess api => (DummyAPIAccess)API;
private SessionStatics session = null!;
public TestSceneNowPlayingCommand()
{
Add(postTarget = new PostTarget());
}
[BackgroundDependencyLoader]
private void load()
{
Dependencies.Cache(session = new SessionStatics());
}
[Test]
public void TestGenericActivity()
{
AddStep("Set activity", () => api.Activity.Value = new UserActivity.InLobby(new Room()));
AddStep("Set activity", () => session.SetValue<UserActivity>(Static.UserOnlineActivity, new UserActivity.InLobby(new Room())));
AddStep("Run command", () => Add(new NowPlayingCommand(new Channel())));
@ -43,7 +49,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestEditActivity()
{
AddStep("Set activity", () => api.Activity.Value = new UserActivity.EditingBeatmap(new BeatmapInfo()));
AddStep("Set activity", () => session.SetValue<UserActivity>(Static.UserOnlineActivity, new UserActivity.EditingBeatmap(new BeatmapInfo())));
AddStep("Run command", () => Add(new NowPlayingCommand(new Channel())));
@ -53,7 +59,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestPlayActivity()
{
AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo));
AddStep("Set activity", () => session.SetValue<UserActivity>(Static.UserOnlineActivity, new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo)));
AddStep("Run command", () => Add(new NowPlayingCommand(new Channel())));
@ -64,7 +70,7 @@ namespace osu.Game.Tests.Visual.Online
[TestCase(false)]
public void TestLinkPresence(bool hasOnlineId)
{
AddStep("Set activity", () => api.Activity.Value = new UserActivity.InLobby(new Room()));
AddStep("Set activity", () => session.SetValue<UserActivity>(Static.UserOnlineActivity, new UserActivity.InLobby(new Room())));
AddStep("Set beatmap", () => Beatmap.Value = new DummyWorkingBeatmap(Audio, null)
{
@ -82,7 +88,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestModPresence()
{
AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo));
AddStep("Set activity", () => session.SetValue<UserActivity>(Static.UserOnlineActivity, new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo)));
AddStep("Add Hidden mod", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateMod<ModHidden>() });

View File

@ -62,10 +62,7 @@ namespace osu.Game.Tests.Visual.Online
CountryCode = countryCode,
CoverUrl = cover,
Colour = color ?? "000000",
Status =
{
Value = UserStatus.Online
},
IsOnline = true
};
return new ClickableAvatar(user, showPanel)

View File

@ -76,7 +76,7 @@ namespace osu.Game.Tests.Visual.Online
Id = 3103765,
CountryCode = CountryCode.JP,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
Status = { Value = UserStatus.Online }
IsOnline = true
}) { Width = 300 },
boundPanel1 = new UserGridPanel(new APIUser
{

View File

@ -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]

View File

@ -0,0 +1,286 @@
// 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;
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
{
AutoSizeAxes = Axes.Both,
Padding = new MarginPadding(10),
TextAnchor = 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.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);
});
}
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -71,6 +71,8 @@ namespace osu.Game.Tournament.Screens.Editors
[Resolved]
private LadderInfo ladderInfo { get; set; } = null!;
private readonly SettingsTextBox acronymTextBox;
public TeamRow(TournamentTeam team, TournamentScreen parent)
{
Model = team;
@ -112,7 +114,7 @@ namespace osu.Game.Tournament.Screens.Editors
Width = 0.2f,
Current = Model.FullName
},
new SettingsTextBox
acronymTextBox = new SettingsTextBox
{
LabelText = "Acronym",
Width = 0.2f,
@ -177,6 +179,27 @@ namespace osu.Game.Tournament.Screens.Editors
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Model.Acronym.BindValueChanged(acronym =>
{
var teamsWithSameAcronym = ladderInfo.Teams
.Where(t => t.Acronym.Value == acronym.NewValue && t != Model)
.ToList();
if (teamsWithSameAcronym.Count > 0)
{
acronymTextBox.SetNoticeText(
$"Acronym '{acronym.NewValue}' is already in use by team{(teamsWithSameAcronym.Count > 1 ? "s" : "")}:\n"
+ $"{string.Join(",\n", teamsWithSameAcronym)}", true);
}
else
acronymTextBox.ClearNoticeText();
}, true);
}
private partial class LastYearPlacementSlider : RoundedSliderBar<int>
{
public override LocalisableString TooltipText => Current.Value == 0 ? "N/A" : base.TooltipText;

View File

@ -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() =>

View File

@ -170,8 +170,6 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.ScreenshotFormat, ScreenshotFormat.Jpg);
SetDefault(OsuSetting.ScreenshotCaptureMenuCursor, false);
SetDefault(OsuSetting.SongSelectRightMouseScroll, false);
SetDefault(OsuSetting.Scaling, ScalingMode.Off);
SetDefault(OsuSetting.SafeAreaConsiderations, true);
SetDefault(OsuSetting.ScalingBackgroundDim, 0.9f, 0.5f, 1f, 0.01f);
@ -211,7 +209,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.LastProcessedMetadataId, -1);
SetDefault(OsuSetting.ComboColourNormalisationAmount, 0.2f, 0f, 1f, 0.01f);
SetDefault<UserStatus?>(OsuSetting.UserOnlineStatus, null);
SetDefault(OsuSetting.UserOnlineStatus, UserStatus.Online);
SetDefault(OsuSetting.EditorTimelineShowTimingChanges, true);
SetDefault(OsuSetting.EditorTimelineShowBreaks, true);
@ -401,7 +399,6 @@ namespace osu.Game.Configuration
Skin,
ScreenshotFormat,
ScreenshotCaptureMenuCursor,
SongSelectRightMouseScroll,
BeatmapSkins,
BeatmapColours,
BeatmapHitsounds,
@ -443,7 +440,12 @@ namespace osu.Game.Configuration
EditorShowSpeedChanges,
TouchDisableGameplayTaps,
ModSelectTextSearchStartsActive,
/// <summary>
/// The status for the current user to broadcast to other players.
/// </summary>
UserOnlineStatus,
MultiplayerRoomFilter,
HideCountryFlags,
EditorTimelineShowTimingChanges,

View File

@ -10,6 +10,7 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Configuration
{
@ -30,6 +31,7 @@ namespace osu.Game.Configuration
SetDefault(Static.TouchInputActive, RuntimeInfo.IsMobile);
SetDefault<ScoreInfo>(Static.LastLocalUserScore, null);
SetDefault<ScoreInfo>(Static.LastAppliedOffsetScore, null);
SetDefault<UserActivity>(Static.UserOnlineActivity, null);
}
/// <summary>
@ -92,5 +94,7 @@ namespace osu.Game.Configuration
/// This is reset when a new challenge is up.
/// </summary>
DailyChallengeIntroPlayed,
UserOnlineActivity,
}
}

View File

@ -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;
}
}
}
}

View File

@ -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");

View File

@ -15,9 +15,11 @@ namespace osu.Game.Graphics.Containers
{
protected const float FADE_DURATION = 500;
protected Color4 HoverColour;
public Color4? HoverColour { get; set; }
private Color4 fallbackHoverColour;
protected Color4 IdleColour = Color4.White;
public Color4? IdleColour { get; set; }
private Color4 fallbackIdleColour;
protected virtual IEnumerable<Drawable> EffectTargets => new[] { Content };
@ -67,18 +69,18 @@ namespace osu.Game.Graphics.Containers
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
if (HoverColour == default)
HoverColour = colours.Yellow;
fallbackHoverColour = colours.Yellow;
fallbackIdleColour = Color4.White;
}
protected override void LoadComplete()
{
base.LoadComplete();
EffectTargets.ForEach(d => d.FadeColour(IdleColour));
EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour));
}
private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour, FADE_DURATION, Easing.OutQuint));
private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour ?? fallbackHoverColour, FADE_DURATION, Easing.OutQuint));
private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour, FADE_DURATION, Easing.OutQuint));
private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour, FADE_DURATION, Easing.OutQuint));
}
}

View File

@ -26,26 +26,12 @@ namespace osu.Game.Graphics.Containers
}
}
public partial class OsuScrollContainer<T> : ScrollContainer<T> where T : Drawable
public partial class OsuScrollContainer<T> : ScrollContainer<T>
where T : Drawable
{
public const float SCROLL_BAR_WIDTH = 10;
public const float SCROLL_BAR_PADDING = 3;
/// <summary>
/// Allows controlling the scroll bar from any position in the container using the right mouse button.
/// Uses the value of <see cref="DistanceDecayOnRightMouseScrollbar"/> to smoothly scroll to the dragged location.
/// </summary>
public bool RightMouseScrollbar;
/// <summary>
/// Controls the rate with which the target position is approached when performing a relative drag. Default is 0.02.
/// </summary>
public double DistanceDecayOnRightMouseScrollbar = 0.02;
private bool rightMouseDragging;
protected override bool IsDragging => base.IsDragging || rightMouseDragging;
public OsuScrollContainer(Direction scrollDirection = Direction.Vertical)
: base(scrollDirection)
{
@ -71,50 +57,6 @@ namespace osu.Game.Graphics.Containers
ScrollTo(maxPos - DisplayableContent + extraScroll, animated);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (shouldPerformRightMouseScroll(e))
{
ScrollFromMouseEvent(e);
return true;
}
return base.OnMouseDown(e);
}
protected override void OnDrag(DragEvent e)
{
if (rightMouseDragging)
{
ScrollFromMouseEvent(e);
return;
}
base.OnDrag(e);
}
protected override bool OnDragStart(DragStartEvent e)
{
if (shouldPerformRightMouseScroll(e))
{
rightMouseDragging = true;
return true;
}
return base.OnDragStart(e);
}
protected override void OnDragEnd(DragEndEvent e)
{
if (rightMouseDragging)
{
rightMouseDragging = false;
return;
}
base.OnDragEnd(e);
}
protected override bool OnScroll(ScrollEvent e)
{
// allow for controlling volume when alt is held.
@ -124,15 +66,22 @@ namespace osu.Game.Graphics.Containers
return base.OnScroll(e);
}
protected virtual void ScrollFromMouseEvent(MouseEvent e)
#region Absolute scrolling
/// <summary>
/// Controls the rate with which the target position is approached when performing a relative drag. Default is 0.02.
/// </summary>
public double DistanceDecayOnAbsoluteScroll = 0.02;
protected virtual void ScrollToAbsolutePosition(Vector2 screenSpacePosition)
{
float fromScrollbarPosition = FromScrollbarPosition(ToLocalSpace(e.ScreenSpaceMousePosition)[ScrollDim]);
float fromScrollbarPosition = FromScrollbarPosition(ToLocalSpace(screenSpacePosition)[ScrollDim]);
float scrollbarCentreOffset = FromScrollbarPosition(Scrollbar.DrawHeight) * 0.5f;
ScrollTo(Clamp(fromScrollbarPosition - scrollbarCentreOffset), true, DistanceDecayOnRightMouseScrollbar);
ScrollTo(Clamp(fromScrollbarPosition - scrollbarCentreOffset), true, DistanceDecayOnAbsoluteScroll);
}
private bool shouldPerformRightMouseScroll(MouseButtonEvent e) => RightMouseScrollbar && e.Button == MouseButton.Right;
#endregion
protected override ScrollbarContainer CreateScrollbar(Direction direction) => new OsuScrollbar(direction);

View File

@ -2,7 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osuTK;
namespace osu.Game.Graphics.Containers
{
@ -47,10 +47,10 @@ namespace osu.Game.Graphics.Containers
base.ScrollIntoView(target, animated);
}
protected override void ScrollFromMouseEvent(MouseEvent e)
protected override void ScrollToAbsolutePosition(Vector2 screenSpacePosition)
{
UserScrolling = true;
base.ScrollFromMouseEvent(e);
base.ScrollToAbsolutePosition(screenSpacePosition);
}
public new void ScrollTo(double value, bool animated = true, double? distanceDecay = null)

View File

@ -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)

View File

@ -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)

View File

@ -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,6 +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(InputKey.None, GlobalAction.AbsoluteScrollSongList),
};
private static IEnumerable<KeyBinding> audioControlKeyBindings => new[]
@ -490,6 +492,12 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextBookmark))]
EditorSeekToNextBookmark,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.AbsoluteScrollSongList))]
AbsoluteScrollSongList,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleMoveControl))]
EditorToggleMoveControl,
}
public enum GlobalActionCategory

View File

@ -449,6 +449,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString EditorSeekToNextBookmark => new TranslatableString(getKey(@"editor_seek_to_next_bookmark"), @"Seek to next bookmark");
/// <summary>
/// "Absolute scroll song list"
/// </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}";
}
}

View File

@ -0,0 +1,19 @@
// 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.Localisation;
namespace osu.Game.Localisation.HUD
{
public static class SpectatorListStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.SpectatorList";
/// <summary>
/// "Spectators ({0})"
/// </summary>
public static LocalisableString SpectatorCount(int arg0) => new TranslatableString(getKey(@"spectator_count"), @"Spectators ({0})", arg0);
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -60,7 +60,6 @@ namespace osu.Game.Online.API
public IBindable<APIUser> LocalUser => localUser;
public IBindableList<APIRelation> Friends => friends;
public IBindable<UserActivity> Activity => activity;
public INotificationsClient NotificationsClient { get; }
@ -70,15 +69,10 @@ namespace osu.Game.Online.API
private BindableList<APIRelation> friends { get; } = new BindableList<APIRelation>();
private Bindable<UserActivity> activity { get; } = new Bindable<UserActivity>();
private Bindable<UserStatus?> configStatus { get; } = new Bindable<UserStatus?>();
private Bindable<UserStatus?> localUserStatus { get; } = new Bindable<UserStatus?>();
protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password));
private readonly Bindable<UserStatus> configStatus = new Bindable<UserStatus>();
private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource();
private readonly Logger log;
public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash)
@ -121,17 +115,6 @@ namespace osu.Game.Online.API
state.Value = APIState.Connecting;
}
localUser.BindValueChanged(u =>
{
u.OldValue?.Activity.UnbindFrom(activity);
u.NewValue.Activity.BindTo(activity);
u.OldValue?.Status.UnbindFrom(localUserStatus);
u.NewValue.Status.BindTo(localUserStatus);
}, true);
localUserStatus.BindTo(configStatus);
var thread = new Thread(run)
{
Name = "APIAccess",
@ -342,10 +325,7 @@ namespace osu.Game.Online.API
{
Debug.Assert(ThreadSafety.IsUpdateThread);
me.Status.Value = configStatus.Value ?? UserStatus.Online;
localUser.Value = me;
state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth;
failureCount = 0;
};
@ -381,8 +361,7 @@ namespace osu.Game.Online.API
localUser.Value = new APIUser
{
Username = ProvidedUsername,
Status = { Value = configStatus.Value ?? UserStatus.Online }
Username = ProvidedUsername
};
}
@ -608,6 +587,8 @@ namespace osu.Game.Online.API
password = null;
SecondFactorCode = null;
authentication.Clear();
// Reset the status to be broadcast on the next login, in case multiple players share the same system.
configStatus.Value = UserStatus.Online;
// Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present

View File

@ -12,7 +12,6 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Tests;
using osu.Game.Users;
namespace osu.Game.Online.API
{
@ -28,8 +27,6 @@ namespace osu.Game.Online.API
public BindableList<APIRelation> Friends { get; } = new BindableList<APIRelation>();
public Bindable<UserActivity> Activity { get; } = new Bindable<UserActivity>();
public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient();
INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient;
@ -69,15 +66,6 @@ namespace osu.Game.Online.API
/// </summary>
public IBindable<APIState> State => state;
public DummyAPIAccess()
{
LocalUser.BindValueChanged(u =>
{
u.OldValue?.Activity.UnbindFrom(Activity);
u.NewValue.Activity.BindTo(Activity);
}, true);
}
public virtual void Queue(APIRequest request)
{
request.AttachAPI(this);
@ -204,7 +192,6 @@ namespace osu.Game.Online.API
IBindable<APIUser> IAPIProvider.LocalUser => LocalUser;
IBindableList<APIRelation> IAPIProvider.Friends => Friends;
IBindable<UserActivity> IAPIProvider.Activity => Activity;
/// <summary>
/// Skip 2FA requirement for next login.

View File

@ -8,7 +8,6 @@ using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Users;
namespace osu.Game.Online.API
{
@ -24,11 +23,6 @@ namespace osu.Game.Online.API
/// </summary>
IBindableList<APIRelation> Friends { get; }
/// <summary>
/// The current user's activity.
/// </summary>
IBindable<UserActivity> Activity { get; }
/// <summary>
/// The language supplied by this provider to API requests.
/// </summary>

View File

@ -8,7 +8,6 @@ using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Game.Extensions;
using osu.Game.Users;
@ -56,10 +55,6 @@ namespace osu.Game.Online.API.Requests.Responses
set => countryCodeString = value.ToString();
}
public readonly Bindable<UserStatus?> Status = new Bindable<UserStatus?>();
public readonly Bindable<UserActivity> Activity = new Bindable<UserActivity>();
[JsonProperty(@"profile_colour")]
public string Colour;

View File

@ -56,7 +56,7 @@ namespace osu.Game.Online.Chat
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
IdleColour = overlayColourProvider?.Light2 ?? colours.Blue;
IdleColour ??= overlayColourProvider?.Light2 ?? colours.Blue;
}
protected override IEnumerable<Drawable> EffectTargets => Parts;

View File

@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Online.API;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
@ -33,6 +34,7 @@ namespace osu.Game.Online.Chat
private IBindable<RulesetInfo> currentRuleset { get; set; } = null!;
private readonly Channel? target;
private IBindable<UserActivity?> userActivity = null!;
/// <summary>
/// Creates a new <see cref="NowPlayingCommand"/> to post the currently-playing beatmap to a parenting <see cref="IChannelPostTarget"/>.
@ -43,6 +45,12 @@ namespace osu.Game.Online.Chat
this.target = target;
}
[BackgroundDependencyLoader]
private void load(SessionStatics session)
{
userActivity = session.GetBindable<UserActivity?>(Static.UserOnlineActivity);
}
protected override void LoadComplete()
{
base.LoadComplete();
@ -52,7 +60,7 @@ namespace osu.Game.Online.Chat
int beatmapOnlineID;
string beatmapDisplayTitle;
switch (api.Activity.Value)
switch (userActivity.Value)
{
case UserActivity.InGame game:
verb = "playing";
@ -92,14 +100,14 @@ namespace osu.Game.Online.Chat
string getRulesetPart()
{
if (api.Activity.Value is not UserActivity.InGame) return string.Empty;
if (userActivity.Value is not UserActivity.InGame) return string.Empty;
return $"<{currentRuleset.Value.Name}>";
}
string getModPart()
{
if (api.Activity.Value is not UserActivity.InGame) return string.Empty;
if (userActivity.Value is not UserActivity.InGame) return string.Empty;
if (selectedMods.Value.Count == 0)
{

View File

@ -46,7 +46,7 @@ namespace osu.Game.Online
private readonly Bindable<bool> notifyOnFriendPresenceChange = new BindableBool();
private readonly IBindableList<APIRelation> friends = new BindableList<APIRelation>();
private readonly IBindableDictionary<int, UserPresence> friendStates = new BindableDictionary<int, UserPresence>();
private readonly IBindableDictionary<int, UserPresence> friendPresences = new BindableDictionary<int, UserPresence>();
private readonly HashSet<APIUser> onlineAlertQueue = new HashSet<APIUser>();
private readonly HashSet<APIUser> offlineAlertQueue = new HashSet<APIUser>();
@ -63,8 +63,8 @@ namespace osu.Game.Online
friends.BindTo(api.Friends);
friends.BindCollectionChanged(onFriendsChanged, true);
friendStates.BindTo(metadataClient.FriendStates);
friendStates.BindCollectionChanged(onFriendStatesChanged, true);
friendPresences.BindTo(metadataClient.FriendPresences);
friendPresences.BindCollectionChanged(onFriendPresenceChanged, true);
}
protected override void Update()
@ -85,7 +85,7 @@ namespace osu.Game.Online
if (friend.TargetUser is not APIUser user)
continue;
if (friendStates.TryGetValue(friend.TargetID, out _))
if (friendPresences.TryGetValue(friend.TargetID, out _))
markUserOnline(user);
}
@ -105,7 +105,7 @@ namespace osu.Game.Online
}
}
private void onFriendStatesChanged(object? sender, NotifyDictionaryChangedEventArgs<int, UserPresence> e)
private void onFriendPresenceChanged(object? sender, NotifyDictionaryChangedEventArgs<int, UserPresence> e)
{
switch (e.Action)
{
@ -169,6 +169,8 @@ namespace osu.Game.Online
notifications.Post(new SimpleNotification
{
Transient = true,
IsImportant = false,
Icon = FontAwesome.Solid.UserPlus,
Text = $"Online: {string.Join(@", ", onlineAlertQueue.Select(u => u.Username))}",
IconColour = colours.Green,
@ -204,6 +206,8 @@ namespace osu.Game.Online
notifications.Post(new SimpleNotification
{
Transient = true,
IsImportant = false,
Icon = FontAwesome.Solid.UserMinus,
Text = $"Offline: {string.Join(@", ", offlineAlertQueue.Select(u => u.Username))}",
IconColour = colours.Red

View File

@ -54,6 +54,7 @@ namespace osu.Game.Online.Leaderboards
private readonly int? rank;
private readonly bool isOnlineScope;
private readonly bool highlightFriend;
private Box background;
private Container content;
@ -86,12 +87,13 @@ namespace osu.Game.Online.Leaderboards
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true)
public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true, bool highlightFriend = true)
{
Score = score;
this.rank = rank;
this.isOnlineScope = isOnlineScope;
this.highlightFriend = highlightFriend;
RelativeSizeAxes = Axes.X;
Height = HEIGHT;
@ -130,7 +132,7 @@ namespace osu.Game.Online.Leaderboards
background = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = isUserFriend ? colour.Yellow : (user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black),
Colour = (highlightFriend && isUserFriend) ? colour.Yellow : (user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black),
Alpha = background_alpha,
},
},

View File

@ -37,15 +37,20 @@ namespace osu.Game.Online.Metadata
/// </summary>
public abstract IBindable<bool> IsWatchingUserPresence { get; }
/// <summary>
/// The <see cref="UserPresence"/> information about the current user.
/// </summary>
public abstract UserPresence LocalUserPresence { get; }
/// <summary>
/// Dictionary keyed by user ID containing all of the <see cref="UserPresence"/> information about currently online users received from the server.
/// </summary>
public abstract IBindableDictionary<int, UserPresence> UserStates { get; }
public abstract IBindableDictionary<int, UserPresence> UserPresences { get; }
/// <summary>
/// Dictionary keyed by user ID containing all of the <see cref="UserPresence"/> information about currently online friends received from the server.
/// </summary>
public abstract IBindableDictionary<int, UserPresence> FriendStates { get; }
public abstract IBindableDictionary<int, UserPresence> FriendPresences { get; }
/// <inheritdoc/>
public abstract Task UpdateActivity(UserActivity? activity);

View File

@ -23,22 +23,28 @@ namespace osu.Game.Online.Metadata
public override IBindable<bool> IsWatchingUserPresence => isWatchingUserPresence;
private readonly BindableBool isWatchingUserPresence = new BindableBool();
public override IBindableDictionary<int, UserPresence> UserStates => userStates;
private readonly BindableDictionary<int, UserPresence> userStates = new BindableDictionary<int, UserPresence>();
public override UserPresence LocalUserPresence => localUserPresence;
private UserPresence localUserPresence;
public override IBindableDictionary<int, UserPresence> FriendStates => friendStates;
private readonly BindableDictionary<int, UserPresence> friendStates = new BindableDictionary<int, UserPresence>();
public override IBindableDictionary<int, UserPresence> UserPresences => userPresences;
private readonly BindableDictionary<int, UserPresence> userPresences = new BindableDictionary<int, UserPresence>();
public override IBindableDictionary<int, UserPresence> FriendPresences => friendPresences;
private readonly BindableDictionary<int, UserPresence> friendPresences = new BindableDictionary<int, UserPresence>();
public override IBindable<DailyChallengeInfo?> DailyChallengeInfo => dailyChallengeInfo;
private readonly Bindable<DailyChallengeInfo?> dailyChallengeInfo = new Bindable<DailyChallengeInfo?>();
private readonly string endpoint;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private IHubClientConnector? connector;
private Bindable<int> lastQueueId = null!;
private IBindable<APIUser> localUser = null!;
private IBindable<UserStatus> userStatus = null!;
private IBindable<UserActivity?> userActivity = null!;
private IBindable<UserStatus?>? userStatus;
private HubConnection? connection => connector?.CurrentConnection;
@ -48,7 +54,7 @@ namespace osu.Game.Online.Metadata
}
[BackgroundDependencyLoader]
private void load(IAPIProvider api, OsuConfigManager config)
private void load(OsuConfigManager config, SessionStatics session)
{
// Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization.
// More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code.
@ -72,25 +78,22 @@ namespace osu.Game.Online.Metadata
IsConnected.BindValueChanged(isConnectedChanged, true);
}
lastQueueId = config.GetBindable<int>(OsuSetting.LastProcessedMetadataId);
localUser = api.LocalUser.GetBoundCopy();
userActivity = api.Activity.GetBoundCopy()!;
lastQueueId = config.GetBindable<int>(OsuSetting.LastProcessedMetadataId);
userStatus = config.GetBindable<UserStatus>(OsuSetting.UserOnlineStatus);
userActivity = session.GetBindable<UserActivity?>(Static.UserOnlineActivity);
}
protected override void LoadComplete()
{
base.LoadComplete();
localUser.BindValueChanged(_ =>
userStatus.BindValueChanged(status =>
{
if (localUser.Value is not GuestUser)
{
userStatus = localUser.Value.Status.GetBoundCopy();
userStatus.BindValueChanged(status => UpdateStatus(status.NewValue), true);
}
else
userStatus = null;
UpdateStatus(status.NewValue);
}, true);
userActivity.BindValueChanged(activity =>
{
if (localUser.Value is not GuestUser)
@ -107,9 +110,10 @@ namespace osu.Game.Online.Metadata
Schedule(() =>
{
isWatchingUserPresence.Value = false;
userStates.Clear();
friendStates.Clear();
userPresences.Clear();
friendPresences.Clear();
dailyChallengeInfo.Value = null;
localUserPresence = default;
});
return;
}
@ -117,7 +121,7 @@ namespace osu.Game.Online.Metadata
if (localUser.Value is not GuestUser)
{
UpdateActivity(userActivity.Value);
UpdateStatus(userStatus?.Value);
UpdateStatus(userStatus.Value);
}
if (lastQueueId.Value >= 0)
@ -202,9 +206,19 @@ namespace osu.Game.Online.Metadata
Schedule(() =>
{
if (presence?.Status != null)
userStates[userId] = presence.Value;
{
if (userId == api.LocalUser.Value.OnlineID)
localUserPresence = presence.Value;
else
userPresences[userId] = presence.Value;
}
else
userStates.Remove(userId);
{
if (userId == api.LocalUser.Value.OnlineID)
localUserPresence = default;
else
userPresences.Remove(userId);
}
});
return Task.CompletedTask;
@ -215,9 +229,9 @@ namespace osu.Game.Online.Metadata
Schedule(() =>
{
if (presence?.Status != null)
friendStates[userId] = presence.Value;
friendPresences[userId] = presence.Value;
else
friendStates.Remove(userId);
friendPresences.Remove(userId);
});
return Task.CompletedTask;
@ -242,7 +256,7 @@ namespace osu.Game.Online.Metadata
throw new OperationCanceledException();
// must be scheduled before any remote calls to avoid mis-ordering.
Schedule(() => userStates.Clear());
Schedule(() => userPresences.Clear());
Debug.Assert(connection != null);
await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false);
Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network);

View File

@ -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);
}
}

View File

@ -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);
};

View File

@ -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());

View 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;
}
}

View File

@ -222,6 +222,8 @@ namespace osu.Game
private Bindable<float> uiScale;
private Bindable<UserActivity> configUserActivity;
private Bindable<string> configSkin;
private readonly string[] args;
@ -402,6 +404,8 @@ namespace osu.Game
Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName;
configUserActivity = SessionStatics.GetBindable<UserActivity>(Static.UserOnlineActivity);
configSkin = LocalConfig.GetBindable<string>(OsuSetting.Skin);
// Transfer skin from config to realm instance once on startup.
@ -1599,14 +1603,14 @@ namespace osu.Game
{
backButtonVisibility.UnbindFrom(currentOsuScreen.BackButtonVisibility);
OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode);
API.Activity.UnbindFrom(currentOsuScreen.Activity);
configUserActivity.UnbindFrom(currentOsuScreen.Activity);
}
if (newScreen is IOsuScreen newOsuScreen)
{
backButtonVisibility.BindTo(newOsuScreen.BackButtonVisibility);
OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode);
API.Activity.BindTo(newOsuScreen.Activity);
configUserActivity.BindTo(newOsuScreen.Activity);
GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newOsuScreen.HideMenuCursorOnNonMouseInput;

View File

@ -37,7 +37,7 @@ namespace osu.Game.Overlays.Dashboard
private const float padding = 10;
private readonly IBindableList<int> playingUsers = new BindableList<int>();
private readonly IBindableDictionary<int, UserPresence> onlineUsers = new BindableDictionary<int, UserPresence>();
private readonly IBindableDictionary<int, UserPresence> onlineUserPresences = new BindableDictionary<int, UserPresence>();
private readonly Dictionary<int, OnlineUserPanel> userPanels = new Dictionary<int, OnlineUserPanel>();
private SearchContainer<OnlineUserPanel> userFlow;
@ -106,8 +106,8 @@ namespace osu.Game.Overlays.Dashboard
{
base.LoadComplete();
onlineUsers.BindTo(metadataClient.UserStates);
onlineUsers.BindCollectionChanged(onUserUpdated, true);
onlineUserPresences.BindTo(metadataClient.UserPresences);
onlineUserPresences.BindCollectionChanged(onUserPresenceUpdated, true);
playingUsers.BindTo(spectatorClient.PlayingUsers);
playingUsers.BindCollectionChanged(onPlayingUsersChanged, true);
@ -120,7 +120,7 @@ namespace osu.Game.Overlays.Dashboard
searchTextBox.TakeFocus();
}
private void onUserUpdated(object sender, NotifyDictionaryChangedEventArgs<int, UserPresence> e) => Schedule(() =>
private void onUserPresenceUpdated(object sender, NotifyDictionaryChangedEventArgs<int, UserPresence> e) => Schedule(() =>
{
switch (e.Action)
{
@ -140,15 +140,13 @@ namespace osu.Game.Overlays.Dashboard
Schedule(() =>
{
// explicitly refetch the user's status.
// things may have changed in between the time of scheduling and the time of actual execution.
if (onlineUsers.TryGetValue(userId, out var updatedStatus))
userFlow.Add(userPanels[userId] = createUserPanel(user).With(p =>
{
user.Activity.Value = updatedStatus.Activity;
user.Status.Value = updatedStatus.Status;
}
var presence = onlineUserPresences.GetValueOrDefault(userId);
userFlow.Add(userPanels[userId] = createUserPanel(user));
p.Status.Value = presence.Status;
p.Activity.Value = presence.Activity;
}));
});
});
}
@ -162,8 +160,8 @@ namespace osu.Game.Overlays.Dashboard
{
if (userPanels.TryGetValue(kvp.Key, out var panel))
{
panel.User.Activity.Value = kvp.Value.Activity;
panel.User.Status.Value = kvp.Value.Status;
panel.Activity.Value = kvp.Value.Activity;
panel.Status.Value = kvp.Value.Status;
}
}
@ -223,6 +221,9 @@ namespace osu.Game.Overlays.Dashboard
{
public readonly APIUser User;
public readonly Bindable<UserStatus?> Status = new Bindable<UserStatus?>();
public readonly Bindable<UserActivity> Activity = new Bindable<UserActivity>();
public BindableBool CanSpectate { get; } = new BindableBool();
public IEnumerable<LocalisableString> FilterTerms { get; }
@ -271,8 +272,8 @@ namespace osu.Game.Overlays.Dashboard
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
// this is SHOCKING
Activity = { BindTarget = User.Activity },
Status = { BindTarget = User.Status },
Activity = { BindTarget = Activity },
Status = { BindTarget = Status },
},
new PurpleRoundedButton
{

View File

@ -9,13 +9,13 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.Settings;
using osu.Game.Users;
using osuTK;
@ -38,14 +38,15 @@ namespace osu.Game.Overlays.Login
/// </summary>
public Action? RequestHide;
private IBindable<APIUser> user = null!;
private readonly Bindable<UserStatus?> status = new Bindable<UserStatus?>();
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
private readonly Bindable<UserStatus> configUserStatus = new Bindable<UserStatus>();
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
public override RectangleF BoundingBox => bounding ? base.BoundingBox : RectangleF.Empty;
public bool Bounding
@ -68,17 +69,11 @@ namespace osu.Game.Overlays.Login
{
base.LoadComplete();
config.BindWith(OsuSetting.UserOnlineStatus, configUserStatus);
configUserStatus.BindValueChanged(e => updateDropdownCurrent(e.NewValue), true);
apiState.BindTo(api.State);
apiState.BindValueChanged(onlineStateChanged, true);
user = api.LocalUser.GetBoundCopy();
user.BindValueChanged(u =>
{
status.UnbindBindings();
status.BindTo(u.NewValue.Status);
}, true);
status.BindValueChanged(e => updateDropdownCurrent(e.NewValue), true);
}
private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() =>
@ -157,23 +152,23 @@ namespace osu.Game.Overlays.Login
},
};
updateDropdownCurrent(status.Value);
updateDropdownCurrent(configUserStatus.Value);
dropdown.Current.BindValueChanged(action =>
{
switch (action.NewValue)
{
case UserAction.Online:
api.LocalUser.Value.Status.Value = UserStatus.Online;
configUserStatus.Value = UserStatus.Online;
dropdown.StatusColour = colours.Green;
break;
case UserAction.DoNotDisturb:
api.LocalUser.Value.Status.Value = UserStatus.DoNotDisturb;
configUserStatus.Value = UserStatus.DoNotDisturb;
dropdown.StatusColour = colours.Red;
break;
case UserAction.AppearOffline:
api.LocalUser.Value.Status.Value = UserStatus.Offline;
configUserStatus.Value = UserStatus.Offline;
dropdown.StatusColour = colours.Gray7;
break;

View File

@ -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);
});

View File

@ -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.

View File

@ -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,

View File

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

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -200,16 +201,19 @@ namespace osu.Game.Overlays.Profile.Header.Components
case FriendStatus.NotMutual:
IdleColour = colour.Green.Opacity(0.7f);
HoverColour = IdleColour.Lighten(0.1f);
HoverColour = IdleColour.Value.Lighten(0.1f);
break;
case FriendStatus.Mutual:
IdleColour = colour.Pink.Opacity(0.7f);
HoverColour = IdleColour.Lighten(0.1f);
HoverColour = IdleColour.Value.Lighten(0.1f);
break;
default:
throw new ArgumentOutOfRangeException();
}
EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour : IdleColour, FADE_DURATION, Easing.OutQuint));
EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour.Value : IdleColour.Value, FADE_DURATION, Easing.OutQuint));
}
private enum FriendStatus

View File

@ -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[]

View File

@ -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()))
}
};
});
}
}
}

View File

@ -19,12 +19,6 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
{
Children = new Drawable[]
{
new SettingsCheckbox
{
ClassicDefault = true,
LabelText = UserInterfaceStrings.RightMouseScroll,
Current = config.GetBindable<bool>(OsuSetting.SongSelectRightMouseScroll),
},
new SettingsCheckbox
{
LabelText = UserInterfaceStrings.ShowConvertedBeatmaps,

View File

@ -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);

View File

@ -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;

View File

@ -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()

View File

@ -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;

View File

@ -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)

View File

@ -85,7 +85,7 @@ namespace osu.Game.Screens
/// <summary>
/// The current <see cref="UserActivity"/> for this screen.
/// </summary>
IBindable<UserActivity> Activity { get; }
Bindable<UserActivity> Activity { get; }
/// <summary>
/// The amount of parallax to be applied while this screen is displayed.

View File

@ -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;

View File

@ -83,7 +83,7 @@ namespace osu.Game.Screens
/// </summary>
protected readonly Bindable<UserActivity> Activity = new Bindable<UserActivity>();
IBindable<UserActivity> IOsuScreen.Activity => Activity;
Bindable<UserActivity> IOsuScreen.Activity => Activity;
/// <summary>
/// Whether to disallow changes to game-wise Beatmap/Ruleset bindables for this screen (and all children).

View File

@ -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

View File

@ -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>

View File

@ -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)

View File

@ -0,0 +1,251 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Specialized;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Chat;
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, ISerialisableDrawable
{
private const int max_spectators_displayed = 10;
public BindableList<SpectatorUser> Spectators { get; } = new BindableList<SpectatorUser>();
public Bindable<LocalUserPlayingState> UserPlayingState { get; } = new Bindable<LocalUserPlayingState>();
[SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))]
public Bindable<Typeface> Font { get; } = new Bindable<Typeface>(Typeface.Torus);
[SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))]
public BindableColour4 HeaderColour { get; } = new BindableColour4(Colour4.White);
protected OsuSpriteText Header { get; private set; } = null!;
private FillFlowContainer mainFlow = null!;
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[]
{
Empty().With(t => t.Size = new Vector2(100, 50)),
mainFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
Header = new OsuSpriteText
{
Colour = colours.Blue0,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
},
spectatorsFlow = new FillFlowContainer<SpectatorListEntry>
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
}
}
},
pool = new DrawablePool<SpectatorListEntry>(max_spectators_displayed),
};
HeaderColour.Value = Header.Colour;
}
protected override void LoadComplete()
{
base.LoadComplete();
((IBindableList<SpectatorUser>)Spectators).BindTo(client.WatchingUsers);
((IBindable<LocalUserPlayingState>)UserPlayingState).BindTo(gameplayState.PlayingState);
Spectators.BindCollectionChanged(onSpectatorsChanged, true);
UserPlayingState.BindValueChanged(_ => updateVisibility());
Font.BindValueChanged(_ => updateAppearance());
HeaderColour.BindValueChanged(_ => updateAppearance(), true);
FinishTransforms(true);
this.FadeInFromZero(200, Easing.OutQuint);
}
private void onSpectatorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
{
for (int i = 0; i < e.NewItems!.Count; i++)
{
var spectator = (SpectatorUser)e.NewItems![i]!;
int index = Math.Max(e.NewStartingIndex, 0) + i;
if (index >= max_spectators_displayed)
break;
addNewSpectatorToList(index, spectator);
}
break;
}
case NotifyCollectionChangedAction.Remove:
{
spectatorsFlow.RemoveAll(entry => e.OldItems!.Contains(entry.Current.Value), false);
for (int i = 0; i < spectatorsFlow.Count; i++)
spectatorsFlow.SetLayoutPosition(spectatorsFlow[i], i);
if (Spectators.Count >= max_spectators_displayed && spectatorsFlow.Count < max_spectators_displayed)
{
for (int i = spectatorsFlow.Count; i < max_spectators_displayed; i++)
addNewSpectatorToList(i, Spectators[i]);
}
break;
}
case NotifyCollectionChangedAction.Reset:
{
spectatorsFlow.Clear(false);
break;
}
default:
throw new NotSupportedException();
}
Header.Text = SpectatorListStrings.SpectatorCount(Spectators.Count).ToUpper();
updateVisibility();
for (int i = 0; i < spectatorsFlow.Count; i++)
{
spectatorsFlow[i].Colour = i < max_spectators_displayed - 1
? Color4.White
: ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0));
}
}
private void addNewSpectatorToList(int i, SpectatorUser spectator)
{
var entry = pool.Get(entry =>
{
entry.Current.Value = spectator;
entry.UserPlayingState = UserPlayingState;
});
spectatorsFlow.Insert(i, entry);
}
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);
}
private void updateAppearance()
{
Header.Font = OsuFont.GetFont(Font.Value, 12, FontWeight.Bold);
Header.Colour = HeaderColour.Value;
Width = Header.DrawWidth;
}
private partial class SpectatorListEntry : PoolableDrawable
{
public Bindable<SpectatorUser> Current { get; } = new Bindable<SpectatorUser>();
private readonly BindableWithCurrent<LocalUserPlayingState> current = new BindableWithCurrent<LocalUserPlayingState>();
public Bindable<LocalUserPlayingState> UserPlayingState
{
get => current.Current;
set => current.Current = value;
}
private OsuSpriteText username = null!;
private DrawableLinkCompiler? linkCompiler;
[Resolved]
private OsuGame? game { get; set; }
[BackgroundDependencyLoader]
private void load()
{
AutoSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
username = new OsuSpriteText(),
};
}
protected override void LoadComplete()
{
base.LoadComplete();
UserPlayingState.BindValueChanged(_ => updateEnabledState());
Current.BindValueChanged(_ => updateState(), true);
}
protected override void PrepareForUse()
{
base.PrepareForUse();
username.MoveToX(10)
.Then()
.MoveToX(0, 400, Easing.OutQuint);
this.FadeInFromZero(400, Easing.OutQuint);
}
private void updateState()
{
username.Text = Current.Value.Username;
linkCompiler?.Expire();
AddInternal(linkCompiler = new DrawableLinkCompiler([username])
{
IdleColour = Colour4.White,
Action = () => game?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, Current.Value)),
});
updateEnabledState();
}
private void updateEnabledState()
{
if (linkCompiler != null)
linkCompiler.Enabled.Value = UserPlayingState.Value != LocalUserPlayingState.Playing;
}
}
public bool UsesFixedAnchor { get; set; }
}
}

View File

@ -263,7 +263,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());
@ -324,6 +324,7 @@ namespace osu.Game.Screens.Play
}
dependencies.CacheAs(DrawableRuleset.FrameStableClock);
dependencies.CacheAs<IGameplayClock>(DrawableRuleset.FrameStableClock);
// add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components.
// also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.)

View File

@ -666,8 +666,6 @@ namespace osu.Game.Screens.Play
private partial class MutedNotification : SimpleNotification
{
public override bool IsImportant => true;
public MutedNotification()
{
Text = NotificationsStrings.GameVolumeTooLow;
@ -719,8 +717,6 @@ namespace osu.Game.Screens.Play
private partial class BatteryWarningNotification : SimpleNotification
{
public override bool IsImportant => true;
public BatteryWarningNotification()
{
Text = NotificationsStrings.BatteryLow;

View File

@ -274,20 +274,36 @@ namespace osu.Game.Screens.Play.PlayerSettings
beatmapOffsetSubscription?.Dispose();
}
protected override void Update()
{
base.Update();
Current.Disabled = !allowOffsetAdjust;
}
private bool allowOffsetAdjust
{
get
{
// General limitations to ensure players don't do anything too weird.
// These match stable for now.
if (player is SubmittingPlayer)
{
Debug.Assert(gameplayClock != null);
// TODO: the blocking conditions should probably display a message.
if (!player.IsBreakTime.Value && gameplayClock.CurrentTime - gameplayClock.StartTime > 10000)
return false;
if (gameplayClock.IsPaused.Value)
return false;
}
return true;
}
}
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
// General limitations to ensure players don't do anything too weird.
// These match stable for now.
if (player is SubmittingPlayer)
{
// TODO: the blocking conditions should probably display a message.
if (player?.IsBreakTime.Value == false && gameplayClock?.CurrentTime - gameplayClock?.StartTime > 10000)
return false;
if (gameplayClock?.IsPaused.Value == true)
return false;
}
// To match stable, this should adjust by 5 ms, or 1 ms when holding alt.
// But that is hard to make work with global actions due to the operating mode.
// Let's use the more precise as a default for now.
@ -296,11 +312,13 @@ namespace osu.Game.Screens.Play.PlayerSettings
switch (e.Action)
{
case GlobalAction.IncreaseOffset:
Current.Value += amount;
if (!Current.Disabled)
Current.Value += amount;
return true;
case GlobalAction.DecreaseOffset:
Current.Value -= amount;
if (!Current.Disabled)
Current.Value -= amount;
return true;
}

View File

@ -32,6 +32,8 @@ namespace osu.Game.Screens.Play
private readonly bool replayIsFailedScore;
private PlaybackSettings playbackSettings;
protected override UserActivity InitialActivity => new UserActivity.WatchingReplay(Score.ScoreInfo);
private bool isAutoplayPlayback => GameplayState.Mods.OfType<ModAutoplay>().Any();
@ -73,7 +75,7 @@ namespace osu.Game.Screens.Play
if (!LoadedBeatmapSuccessfully)
return;
var playbackSettings = new PlaybackSettings
playbackSettings = new PlaybackSettings
{
Depth = float.MaxValue,
Expanded = { BindTarget = config.GetBindable<bool>(OsuSetting.ReplayPlaybackControlsExpanded) }
@ -124,11 +126,11 @@ namespace osu.Game.Screens.Play
return true;
case GlobalAction.SeekReplayBackward:
SeekInDirection(-5);
SeekInDirection(-5 * (float)playbackSettings.UserPlaybackRate.Value);
return true;
case GlobalAction.SeekReplayForward:
SeekInDirection(5);
SeekInDirection(5 * (float)playbackSettings.UserPlaybackRate.Value);
return true;
case GlobalAction.TogglePauseReplay:

View File

@ -14,6 +14,7 @@ using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
@ -184,8 +185,6 @@ namespace osu.Game.Screens.Select
private readonly Cached itemsCache = new Cached();
private PendingScrollOperation pendingScrollOperation = PendingScrollOperation.None;
public Bindable<bool> RightClickScrollingEnabled = new Bindable<bool>();
public Bindable<RandomSelectAlgorithm> RandomAlgorithm = new Bindable<RandomSelectAlgorithm>();
private readonly List<CarouselBeatmapSet> previouslyVisitedRandomSets = new List<CarouselBeatmapSet>();
private readonly List<CarouselBeatmap> randomSelectedBeatmaps = new List<CarouselBeatmap>();
@ -226,9 +225,6 @@ namespace osu.Game.Screens.Select
randomSelectSample = audio.Samples.Get(@"SongSelect/select-random");
config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm);
config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled);
RightClickScrollingEnabled.BindValueChanged(enabled => Scroll.RightMouseScrollbar = enabled.NewValue, true);
detachedBeatmapSets = beatmaps.GetBeatmapSets(cancellationToken);
detachedBeatmapSets.BindCollectionChanged(beatmapSetsChanged);
@ -1161,10 +1157,8 @@ namespace osu.Game.Screens.Select
}
}
public partial class CarouselScrollContainer : UserTrackingScrollContainer<DrawableCarouselItem>
public partial class CarouselScrollContainer : UserTrackingScrollContainer<DrawableCarouselItem>, IKeyBindingHandler<GlobalAction>
{
private bool rightMouseScrollBlocked;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
public CarouselScrollContainer()
@ -1176,31 +1170,76 @@ namespace osu.Game.Screens.Select
Masking = false;
}
#region Absolute scrolling
private bool absoluteScrolling;
protected override bool IsDragging => base.IsDragging || absoluteScrolling;
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.AbsoluteScrollSongList:
beginAbsoluteScrolling(e);
return true;
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.AbsoluteScrollSongList:
endAbsoluteScrolling();
break;
}
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (e.Button == MouseButton.Right)
{
// we need to block right click absolute scrolling when hovering a carousel item so context menus can display.
// this can be reconsidered when we have an alternative to right click scrolling.
if (GetContainingInputManager()!.HoveredDrawables.OfType<DrawableCarouselItem>().Any())
{
rightMouseScrollBlocked = true;
// 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);
}
rightMouseScrollBlocked = false;
return base.OnMouseDown(e);
}
protected override bool OnDragStart(DragStartEvent e)
protected override void OnMouseUp(MouseUpEvent e)
{
if (rightMouseScrollBlocked)
return false;
return base.OnDragStart(e);
if (e.Button == MouseButton.Right)
endAbsoluteScrolling();
base.OnMouseUp(e);
}
protected override bool OnMouseMove(MouseMoveEvent e)
{
if (absoluteScrolling)
{
ScrollToAbsolutePosition(e.CurrentState.Mouse.Position);
return true;
}
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)
{
return new PaddedScrollbar();

View File

@ -169,12 +169,12 @@ namespace osu.Game.Screens.Select.Leaderboards
return scoreRetrievalRequest = newRequest;
}
protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope)
protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope, Scope != BeatmapLeaderboardScope.Friend)
{
Action = () => ScoreSelected?.Invoke(model)
};
protected override LeaderboardScore CreateDrawableTopScore(ScoreInfo model) => new LeaderboardScore(model, model.Position, false)
protected override LeaderboardScore CreateDrawableTopScore(ScoreInfo model) => new LeaderboardScore(model, model.Position, false, Scope != BeatmapLeaderboardScope.Friend)
{
Action = () => ScoreSelected?.Invoke(model)
};

View File

@ -0,0 +1,98 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Pooling;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Select;
namespace osu.Game.Screens.SelectV2
{
[Cached]
public partial class BeatmapCarousel : Carousel<BeatmapInfo>
{
private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
private readonly DrawablePool<BeatmapCarouselPanel> carouselPanelPool = new DrawablePool<BeatmapCarouselPanel>(100);
private readonly LoadingLayer loading;
public BeatmapCarousel()
{
DebounceDelay = 100;
DistanceOffscreenToPreload = 100;
Filters = new ICarouselFilter[]
{
new BeatmapCarouselFilterSorting(() => Criteria),
new BeatmapCarouselFilterGrouping(() => Criteria),
};
AddInternal(carouselPanelPool);
AddInternal(loading = new LoadingLayer(dimBackground: true));
}
[BackgroundDependencyLoader]
private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken)
{
detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true);
}
protected override Drawable GetDrawableForDisplay(CarouselItem item) => carouselPanelPool.Get();
protected override CarouselItem CreateCarouselItemForModel(BeatmapInfo model) => new BeatmapCarouselItem(model);
private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed)
{
// TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider.
// right now we are managing this locally which is a bit of added overhead.
IEnumerable<BeatmapSetInfo>? newBeatmapSets = changed.NewItems?.Cast<BeatmapSetInfo>();
IEnumerable<BeatmapSetInfo>? beatmapSetInfos = changed.OldItems?.Cast<BeatmapSetInfo>();
switch (changed.Action)
{
case NotifyCollectionChangedAction.Add:
Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps));
break;
case NotifyCollectionChangedAction.Remove:
foreach (var set in beatmapSetInfos!)
{
foreach (var beatmap in set.Beatmaps)
Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi));
}
break;
case NotifyCollectionChangedAction.Move:
case NotifyCollectionChangedAction.Replace:
throw new NotImplementedException();
case NotifyCollectionChangedAction.Reset:
Items.Clear();
break;
}
}
public FilterCriteria Criteria { get; private set; } = new FilterCriteria();
public void Filter(FilterCriteria criteria)
{
Criteria = criteria;
loading.Show();
FilterAsync().ContinueWith(_ => Schedule(() => loading.Hide()));
}
}
}

View File

@ -0,0 +1,60 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select;
namespace osu.Game.Screens.SelectV2
{
public class BeatmapCarouselFilterGrouping : ICarouselFilter
{
private readonly Func<FilterCriteria> getCriteria;
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
{
this.getCriteria = getCriteria;
}
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
{
var criteria = getCriteria();
if (criteria.SplitOutDifficulties)
{
foreach (var item in items)
((BeatmapCarouselItem)item).HasGroupHeader = false;
return items;
}
CarouselItem? lastItem = null;
var newItems = new List<CarouselItem>(items.Count());
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
if (item.Model is BeatmapInfo b)
{
// Add set header
if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID))
newItems.Add(new BeatmapCarouselItem(b.BeatmapSet!) { IsGroupHeader = true });
}
newItems.Add(item);
lastItem = item;
var beatmapCarouselItem = (BeatmapCarouselItem)item;
beatmapCarouselItem.HasGroupHeader = true;
}
return newItems;
}, cancellationToken).ConfigureAwait(false);
}
}

View File

@ -0,0 +1,65 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Utils;
namespace osu.Game.Screens.SelectV2
{
public class BeatmapCarouselFilterSorting : ICarouselFilter
{
private readonly Func<FilterCriteria> getCriteria;
public BeatmapCarouselFilterSorting(Func<FilterCriteria> getCriteria)
{
this.getCriteria = getCriteria;
}
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
{
var criteria = getCriteria();
return items.OrderDescending(Comparer<CarouselItem>.Create((a, b) =>
{
int comparison = 0;
if (a.Model is BeatmapInfo ab && b.Model is BeatmapInfo bb)
{
switch (criteria.Sort)
{
case SortMode.Artist:
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist);
if (comparison == 0)
goto case SortMode.Title;
break;
case SortMode.Difficulty:
comparison = ab.StarRating.CompareTo(bb.StarRating);
break;
case SortMode.Title:
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
if (comparison != 0) return comparison;
if (a is BeatmapCarouselItem aItem && b is BeatmapCarouselItem bItem)
return aItem.ID.CompareTo(bItem.ID);
return 0;
}));
}, cancellationToken).ConfigureAwait(false);
}
}

View File

@ -0,0 +1,48 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Beatmaps;
using osu.Game.Database;
namespace osu.Game.Screens.SelectV2
{
public class BeatmapCarouselItem : CarouselItem
{
public readonly Guid ID;
/// <summary>
/// Whether this item has a header providing extra information for it.
/// When displaying items which don't have header, we should make sure enough information is included inline.
/// </summary>
public bool HasGroupHeader { get; set; }
/// <summary>
/// Whether this item is a group header.
/// Group headers are generally larger in display. Setting this will account for the size difference.
/// </summary>
public bool IsGroupHeader { get; set; }
public override float DrawHeight => IsGroupHeader ? 80 : 40;
public BeatmapCarouselItem(object model)
: base(model)
{
ID = (Model as IHasGuidPrimaryKey)?.ID ?? Guid.NewGuid();
}
public override string? ToString()
{
switch (Model)
{
case BeatmapInfo bi:
return $"Difficulty: {bi.DifficultyName} ({bi.StarRating:N1}*)";
case BeatmapSetInfo si:
return $"{si.Metadata}";
}
return Model.ToString();
}
}
}

View File

@ -0,0 +1,102 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.SelectV2
{
public partial class BeatmapCarouselPanel : PoolableDrawable, ICarouselPanel
{
[Resolved]
private BeatmapCarousel carousel { get; set; } = null!;
public CarouselItem? Item
{
get => item;
set
{
item = value;
selected.UnbindBindings();
if (item != null)
selected.BindTo(item.Selected);
}
}
private readonly BindableBool selected = new BindableBool();
private CarouselItem? item;
[BackgroundDependencyLoader]
private void load()
{
selected.BindValueChanged(value =>
{
if (value.NewValue)
{
BorderThickness = 5;
BorderColour = Color4.Pink;
}
else
{
BorderThickness = 0;
}
});
}
protected override void FreeAfterUse()
{
base.FreeAfterUse();
Item = null;
}
protected override void PrepareForUse()
{
base.PrepareForUse();
Debug.Assert(Item != null);
DrawYPosition = Item.CarouselYPosition;
Size = new Vector2(500, Item.DrawHeight);
Masking = true;
InternalChildren = new Drawable[]
{
new Box
{
Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5),
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = Item.ToString() ?? string.Empty,
Padding = new MarginPadding(5),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
};
this.FadeInFromZero(500, Easing.OutQuint);
}
protected override bool OnClick(ClickEvent e)
{
carousel.CurrentSelection = Item!.Model;
return true;
}
public double DrawYPosition { get; set; }
}
}

View File

@ -0,0 +1,588 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Framework.Utils;
using osu.Game.Graphics.Containers;
using osu.Game.Input.Bindings;
using osuTK;
using osuTK.Input;
namespace osu.Game.Screens.SelectV2
{
/// <summary>
/// A highly efficient vertical list display that is used primarily for the song select screen,
/// but flexible enough to be used for other use cases.
/// </summary>
public abstract partial class Carousel<T> : CompositeDrawable
{
#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.
/// </summary>
public float BleedTop { get; set; } = 0;
/// <summary>
/// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it.
/// </summary>
public float BleedBottom { get; set; } = 0;
/// <summary>
/// The number of pixels outside the carousel's vertical bounds to manifest drawables.
/// This allows preloading content before it scrolls into view.
/// </summary>
public float DistanceOffscreenToPreload { get; set; }
/// <summary>
/// Vertical space between panel layout. Negative value can be used to create an overlapping effect.
/// </summary>
protected float SpacingBetweenPanels { get; set; } = -5;
/// <summary>
/// When a new request arrives to change filtering, the number of milliseconds to wait before performing the filter.
/// Regardless of any external debouncing, this is a safety measure to avoid triggering too many threaded operations.
/// </summary>
public int DebounceDelay { get; set; }
/// <summary>
/// Whether an asynchronous filter / group operation is currently underway.
/// </summary>
public bool IsFiltering => !filterTask.IsCompleted;
/// <summary>
/// The number of displayable items currently being tracked (before filtering).
/// </summary>
public int ItemsTracked => Items.Count;
/// <summary>
/// The number of carousel items currently in rotation for display.
/// </summary>
public int DisplayableItems => carouselItems?.Count ?? 0;
/// <summary>
/// The number of items currently actualised into drawables.
/// </summary>
public int VisibleItems => scroll.Panels.Count;
/// <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();
}
}
#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"/>.
/// </summary>
/// <remarks>
/// Note that an <see cref="ICarouselFilter"/> may add new items which are displayed but not tracked in this list.
/// </remarks>
protected readonly BindableList<T> Items = new BindableList<T>();
/// <summary>
/// Queue an asynchronous filter operation.
/// </summary>
protected virtual Task FilterAsync() => filterTask = performFilter();
/// <summary>
/// Create a drawable for the given carousel item so it can be displayed.
/// </summary>
/// <remarks>
/// For efficiency, it is recommended the drawables are retrieved from a <see cref="DrawablePool{T}"/>.
/// </remarks>
/// <param name="item">The item which should be represented by the returned drawable.</param>
/// <returns>The manifested drawable.</returns>
protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
/// <summary>
/// Create an internal carousel representation for the provided model object.
/// </summary>
/// <param name="model">The model.</param>
/// <returns>A <see cref="CarouselItem"/> representing the model.</returns>
protected abstract CarouselItem CreateCarouselItemForModel(T model);
#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();
private async Task performFilter()
{
Debug.Assert(SynchronizationContext.Current != null);
Stopwatch stopwatch = Stopwatch.StartNew();
var cts = new CancellationTokenSource();
lock (this)
{
cancellationSource.Cancel();
cancellationSource = cts;
}
if (DebounceDelay > 0)
{
log($"Filter operation queued, waiting for {DebounceDelay} ms debounce");
await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(true);
}
// Copy must be performed on update thread for now (see ConfigureAwait above).
// Could potentially be optimised in the future if it becomes an issue.
IEnumerable<CarouselItem> items = new List<CarouselItem>(Items.Select(CreateCarouselItemForModel));
await Task.Run(async () =>
{
try
{
foreach (var filter in Filters)
{
log($"Performing {filter.GetType().ReadableName()}");
items = await filter.Run(items, cts.Token).ConfigureAwait(false);
}
log("Updating Y positions");
await updateYPositions(items, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
log("Cancelled due to newer request arriving");
}
}, cts.Token).ConfigureAwait(true);
if (cts.Token.IsCancellationRequested)
return;
log("Items ready for display");
carouselItems = items.ToList();
displayedRange = null;
updateSelection();
void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}");
}
private async Task updateYPositions(IEnumerable<CarouselItem> carouselItems, CancellationToken cancellationToken) => await Task.Run(() =>
{
float yPos = visibleHalfHeight;
foreach (var item in carouselItems)
{
item.CarouselYPosition = yPos;
yPos += item.DrawHeight + SpacingBetweenPanels;
}
}, cancellationToken).ConfigureAwait(false);
#endregion
#region Selection handling
private object? currentSelection;
private CarouselItem? currentSelectionCarouselItem;
private double? currentSelectionYPosition;
private void updateSelection()
{
currentSelectionCarouselItem = null;
if (carouselItems == null) return;
foreach (var item in carouselItems)
{
bool isSelected = item.Model == currentSelection;
if (isSelected)
{
currentSelectionCarouselItem = item;
if (currentSelectionYPosition != item.CarouselYPosition)
{
if (currentSelectionYPosition != null)
{
float adjustment = (float)(item.CarouselYPosition - currentSelectionYPosition.Value);
scroll.OffsetScrollPosition(adjustment);
}
currentSelectionYPosition = item.CarouselYPosition;
}
}
item.Selected.Value = isSelected;
}
}
#endregion
#region Display handling
private DisplayRange? displayedRange;
private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem();
/// <summary>
/// The position of the lower visible bound with respect to the current scroll position.
/// </summary>
private float visibleBottomBound => (float)(scroll.Current + DrawHeight + BleedBottom);
/// <summary>
/// The position of the upper visible bound with respect to the current scroll position.
/// </summary>
private float visibleUpperBound => (float)(scroll.Current - BleedTop);
/// <summary>
/// Half the height of the visible content.
/// </summary>
private float visibleHalfHeight => (DrawHeight + BleedBottom + BleedTop) / 2;
protected override void Update()
{
base.Update();
if (carouselItems == null)
return;
var range = getDisplayRange();
if (range != displayedRange)
{
Logger.Log($"Updating displayed range of carousel from {displayedRange} to {range}");
displayedRange = range;
updateDisplayedRange(range);
}
foreach (var panel in scroll.Panels)
{
var c = (ICarouselPanel)panel;
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);
Vector2 posInScroll = scroll.ToLocalSpace(panel.ScreenSpaceDrawQuad.Centre);
float dist = Math.Abs(1f - posInScroll.Y / visibleHalfHeight);
panel.X = offsetX(dist, visibleHalfHeight);
}
}
/// <summary>
/// Computes the x-offset of currently visible items. Makes the carousel appear round.
/// </summary>
/// <param name="dist">
/// Vertical distance from the center of the carousel container
/// ranging from -1 to 1.
/// </param>
/// <param name="halfHeight">Half the height of the carousel container.</param>
private static float offsetX(float dist, float halfHeight)
{
// The radius of the circle the carousel moves on.
const float circle_radius = 3;
float discriminant = MathF.Max(0, circle_radius * circle_radius - dist * dist);
return (circle_radius - MathF.Sqrt(discriminant)) * halfHeight;
}
private DisplayRange getDisplayRange()
{
Debug.Assert(carouselItems != null);
// Find index range of all items that should be on-screen
carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload;
int firstIndex = carouselItems.BinarySearch(carouselBoundsItem);
if (firstIndex < 0) firstIndex = ~firstIndex;
carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload;
int lastIndex = carouselItems.BinarySearch(carouselBoundsItem);
if (lastIndex < 0) lastIndex = ~lastIndex;
firstIndex = Math.Max(0, firstIndex - 1);
lastIndex = Math.Max(0, lastIndex - 1);
return new DisplayRange(firstIndex, lastIndex);
}
private void updateDisplayedRange(DisplayRange range)
{
Debug.Assert(carouselItems != null);
List<CarouselItem> toDisplay = range.Last - range.First == 0
? new List<CarouselItem>()
: carouselItems.GetRange(range.First, range.Last - range.First + 1);
// Iterate over all panels which are already displayed and figure which need to be displayed / removed.
foreach (var panel in scroll.Panels)
{
var carouselPanel = (ICarouselPanel)panel;
// The case where we're intending to display this panel, but it's already displayed.
// Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation.
var existing = toDisplay.FirstOrDefault(i => i.Model == carouselPanel.Item!.Model);
if (existing != null)
{
carouselPanel.Item = existing;
toDisplay.Remove(existing);
continue;
}
// If the new display range doesn't contain the panel, it's no longer required for display.
expirePanelImmediately(panel);
}
// Add any new items which need to be displayed and haven't yet.
foreach (var item in toDisplay)
{
var drawable = GetDrawableForDisplay(item);
if (drawable is not ICarouselPanel carouselPanel)
throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
carouselPanel.Item = item;
scroll.Add(drawable);
}
// Update the total height of all items (to make the scroll container scrollable through the full height even though
// most items are not displayed / loaded).
if (carouselItems.Count > 0)
{
var lastItem = carouselItems[^1];
scroll.SetLayoutHeight((float)(lastItem.CarouselYPosition + lastItem.DrawHeight + visibleHalfHeight));
}
else
scroll.SetLayoutHeight(0);
}
private static void expirePanelImmediately(Drawable panel)
{
panel.FinishTransforms();
panel.Expire();
}
#endregion
#region Internal helper classes
private record DisplayRange(int First, int Last);
/// <summary>
/// Implementation of scroll container which handles very large vertical lists by internally using <c>double</c> precision
/// for pre-display Y values.
/// </summary>
private partial class CarouselScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler<GlobalAction>
{
public readonly Container Panels;
public void SetLayoutHeight(float height) => Panels.Height = height;
public CarouselScrollContainer()
{
// Managing our own custom layout within ScrollContent causes feedback with public ScrollContainer calculations,
// so we must maintain one level of separation from ScrollContent.
base.Add(Panels = new Container
{
Name = "Layout content",
RelativeSizeAxes = Axes.X,
});
}
public override void OffsetScrollPosition(double offset)
{
base.OffsetScrollPosition(offset);
foreach (var panel in Panels)
{
var c = (ICarouselPanel)panel;
Debug.Assert(c.Item != null);
c.DrawYPosition += offset;
}
}
public override void Clear(bool disposeChildren)
{
Panels.Height = 0;
Panels.Clear(disposeChildren);
}
public override void Add(Drawable drawable)
{
if (drawable is not ICarouselPanel)
throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
Panels.Add(drawable);
}
public override double GetChildPosInContent(Drawable d, Vector2 offset)
{
if (d is not ICarouselPanel panel)
return base.GetChildPosInContent(d, offset);
return panel.DrawYPosition + offset.X;
}
protected override void ApplyCurrentToContent()
{
Debug.Assert(ScrollDirection == Direction.Vertical);
double scrollableExtent = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y;
foreach (var d in Panels)
d.Y = (float)(((ICarouselPanel)d).DrawYPosition + scrollableExtent);
}
#region Absolute scrolling
private bool absoluteScrolling;
protected override bool IsDragging => base.IsDragging || absoluteScrolling;
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.AbsoluteScrollSongList:
beginAbsoluteScrolling(e);
return true;
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.AbsoluteScrollSongList:
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)
{
ScrollToAbsolutePosition(e.CurrentState.Mouse.Position);
return true;
}
return base.OnMouseMove(e);
}
private void beginAbsoluteScrolling(UIEvent e)
{
ScrollToAbsolutePosition(e.CurrentState.Mouse.Position);
absoluteScrolling = true;
}
private void endAbsoluteScrolling() => absoluteScrolling = false;
#endregion
}
private class BoundsCarouselItem : CarouselItem
{
public override float DrawHeight => 0;
public BoundsCarouselItem()
: base(new object())
{
}
}
#endregion
}
}

View File

@ -0,0 +1,44 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
namespace osu.Game.Screens.SelectV2
{
/// <summary>
/// Represents a single display item for display in a <see cref="Carousel{T}"/>.
/// This is used to house information related to the attached model that helps with display and tracking.
/// </summary>
public abstract class CarouselItem : IComparable<CarouselItem>
{
public readonly BindableBool Selected = new BindableBool();
/// <summary>
/// The model this item is representing.
/// </summary>
public readonly object Model;
/// <summary>
/// The current Y position in the carousel. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
/// </summary>
public double CarouselYPosition { get; set; }
/// <summary>
/// The height this item will take when displayed.
/// </summary>
public abstract float DrawHeight { get; }
protected CarouselItem(object model)
{
Model = model;
}
public int CompareTo(CarouselItem? other)
{
if (other == null) return 1;
return CarouselYPosition.CompareTo(other.CarouselYPosition);
}
}
}

View File

@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace osu.Game.Screens.SelectV2
{
/// <summary>
/// An interface representing a filter operation which can be run on a <see cref="Carousel{T}"/>.
/// </summary>
public interface ICarouselFilter
{
/// <summary>
/// Execute the filter operation.
/// </summary>
/// <param name="items">The items to be filtered.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The post-filtered items.</returns>
Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken);
}
}

View File

@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
namespace osu.Game.Screens.SelectV2
{
/// <summary>
/// An interface to be attached to any <see cref="Drawable"/>s which are used for display inside a <see cref="Carousel{T}"/>.
/// </summary>
public interface ICarouselPanel
{
/// <summary>
/// The Y position which should be used for displaying this item within the carousel. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
/// </summary>
double DrawYPosition { get; set; }
/// <summary>
/// The carousel item this drawable is representing. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
/// </summary>
CarouselItem? Item { get; set; }
}
}

View File

@ -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,
}
},
};
}

View File

@ -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(),
};
}

View File

@ -33,7 +33,7 @@ namespace osu.Game.Skinning
public ISample? GetSample(ISampleInfo sampleInfo)
{
foreach (string? lookup in sampleInfo.LookupNames)
foreach (string lookup in sampleInfo.LookupNames)
{
ISample? sample = samples.Get(lookup);
if (sample != null)

View File

@ -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(),
}
};

View File

@ -11,6 +11,6 @@ namespace osu.Game.Tests.Beatmaps
internal partial class TestBeatmapStore : BeatmapStore
{
public readonly BindableList<BeatmapSetInfo> BeatmapSets = new BindableList<BeatmapSetInfo>();
public override IBindableList<BeatmapSetInfo> GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets;
public override IBindableList<BeatmapSetInfo> GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets.GetBoundCopy();
}
}

View File

@ -19,11 +19,14 @@ namespace osu.Game.Tests.Visual.Metadata
public override IBindable<bool> IsWatchingUserPresence => isWatchingUserPresence;
private readonly BindableBool isWatchingUserPresence = new BindableBool();
public override IBindableDictionary<int, UserPresence> UserStates => userStates;
private readonly BindableDictionary<int, UserPresence> userStates = new BindableDictionary<int, UserPresence>();
public override UserPresence LocalUserPresence => localUserPresence;
private UserPresence localUserPresence;
public override IBindableDictionary<int, UserPresence> FriendStates => friendStates;
private readonly BindableDictionary<int, UserPresence> friendStates = new BindableDictionary<int, UserPresence>();
public override IBindableDictionary<int, UserPresence> UserPresences => userPresences;
private readonly BindableDictionary<int, UserPresence> userPresences = new BindableDictionary<int, UserPresence>();
public override IBindableDictionary<int, UserPresence> FriendPresences => friendPresences;
private readonly BindableDictionary<int, UserPresence> friendPresences = new BindableDictionary<int, UserPresence>();
public override Bindable<DailyChallengeInfo?> DailyChallengeInfo => dailyChallengeInfo;
private readonly Bindable<DailyChallengeInfo?> dailyChallengeInfo = new Bindable<DailyChallengeInfo?>();
@ -45,11 +48,12 @@ namespace osu.Game.Tests.Visual.Metadata
public override Task UpdateActivity(UserActivity? activity)
{
localUserPresence = localUserPresence with { Activity = activity };
if (isWatchingUserPresence.Value)
{
userStates.TryGetValue(api.LocalUser.Value.Id, out var localUserPresence);
localUserPresence = localUserPresence with { Activity = activity };
userStates[api.LocalUser.Value.Id] = localUserPresence;
if (userPresences.ContainsKey(api.LocalUser.Value.Id))
userPresences[api.LocalUser.Value.Id] = localUserPresence;
}
return Task.CompletedTask;
@ -57,11 +61,12 @@ namespace osu.Game.Tests.Visual.Metadata
public override Task UpdateStatus(UserStatus? status)
{
localUserPresence = localUserPresence with { Status = status };
if (isWatchingUserPresence.Value)
{
userStates.TryGetValue(api.LocalUser.Value.Id, out var localUserPresence);
localUserPresence = localUserPresence with { Status = status };
userStates[api.LocalUser.Value.Id] = localUserPresence;
if (userPresences.ContainsKey(api.LocalUser.Value.Id))
userPresences[api.LocalUser.Value.Id] = localUserPresence;
}
return Task.CompletedTask;
@ -71,10 +76,20 @@ namespace osu.Game.Tests.Visual.Metadata
{
if (isWatchingUserPresence.Value)
{
if (presence.HasValue)
userStates[userId] = presence.Value;
if (presence?.Status != null)
{
if (userId == api.LocalUser.Value.OnlineID)
localUserPresence = presence.Value;
else
userPresences[userId] = presence.Value;
}
else
userStates.Remove(userId);
{
if (userId == api.LocalUser.Value.OnlineID)
localUserPresence = default;
else
userPresences.Remove(userId);
}
}
return Task.CompletedTask;
@ -83,9 +98,9 @@ namespace osu.Game.Tests.Visual.Metadata
public override Task FriendPresenceUpdated(int userId, UserPresence? presence)
{
if (presence.HasValue)
friendStates[userId] = presence.Value;
friendPresences[userId] = presence.Value;
else
friendStates.Remove(userId);
friendPresences.Remove(userId);
return Task.CompletedTask;
}

Some files were not shown because too many files have changed in this diff Show More