mirror of
https://github.com/ppy/osu.git
synced 2024-12-14 18:42:56 +08:00
Merge pull request #18796 from bdach/mod-overlay/legacy-key-bindings
Add setting option to toggle between mod overlay hotkey styles
This commit is contained in:
commit
b660119de7
@ -9,9 +9,11 @@ using osu.Framework.Extensions.IEnumerableExtensions;
|
|||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
using osu.Framework.Graphics.Containers;
|
using osu.Framework.Graphics.Containers;
|
||||||
using osu.Framework.Testing;
|
using osu.Framework.Testing;
|
||||||
|
using osu.Game.Configuration;
|
||||||
using osu.Game.Graphics.UserInterface;
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osu.Game.Overlays;
|
using osu.Game.Overlays;
|
||||||
using osu.Game.Overlays.Mods;
|
using osu.Game.Overlays.Mods;
|
||||||
|
using osu.Game.Overlays.Mods.Input;
|
||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
using osu.Game.Rulesets.Osu;
|
using osu.Game.Rulesets.Osu;
|
||||||
using osu.Game.Utils;
|
using osu.Game.Utils;
|
||||||
@ -25,6 +27,9 @@ namespace osu.Game.Tests.Visual.UserInterface
|
|||||||
[Cached]
|
[Cached]
|
||||||
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
|
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
|
||||||
|
|
||||||
|
[Resolved]
|
||||||
|
private OsuConfigManager configManager { get; set; } = null!;
|
||||||
|
|
||||||
[TestCase(ModType.DifficultyReduction)]
|
[TestCase(ModType.DifficultyReduction)]
|
||||||
[TestCase(ModType.DifficultyIncrease)]
|
[TestCase(ModType.DifficultyIncrease)]
|
||||||
[TestCase(ModType.Conversion)]
|
[TestCase(ModType.Conversion)]
|
||||||
@ -132,14 +137,16 @@ namespace osu.Game.Tests.Visual.UserInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void TestKeyboardSelection()
|
public void TestSequentialKeyboardSelection()
|
||||||
{
|
{
|
||||||
|
AddStep("set sequential hotkey mode", () => configManager.SetValue(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Sequential));
|
||||||
|
|
||||||
ModColumn column = null!;
|
ModColumn column = null!;
|
||||||
AddStep("create content", () => Child = new Container
|
AddStep("create content", () => Child = new Container
|
||||||
{
|
{
|
||||||
RelativeSizeAxes = Axes.Both,
|
RelativeSizeAxes = Axes.Both,
|
||||||
Padding = new MarginPadding(30),
|
Padding = new MarginPadding(30),
|
||||||
Child = column = new ModColumn(ModType.DifficultyReduction, true, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P })
|
Child = column = new ModColumn(ModType.DifficultyReduction, true)
|
||||||
{
|
{
|
||||||
Anchor = Anchor.Centre,
|
Anchor = Anchor.Centre,
|
||||||
Origin = Anchor.Centre,
|
Origin = Anchor.Centre,
|
||||||
@ -158,9 +165,12 @@ namespace osu.Game.Tests.Visual.UserInterface
|
|||||||
AddStep("set filter to NF", () => setFilter(mod => mod.Acronym == "NF"));
|
AddStep("set filter to NF", () => setFilter(mod => mod.Acronym == "NF"));
|
||||||
|
|
||||||
AddStep("press W", () => InputManager.Key(Key.W));
|
AddStep("press W", () => InputManager.Key(Key.W));
|
||||||
|
AddAssert("NF panel not selected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press Q", () => InputManager.Key(Key.Q));
|
||||||
AddAssert("NF panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
|
AddAssert("NF panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
|
||||||
|
|
||||||
AddStep("press W again", () => InputManager.Key(Key.W));
|
AddStep("press Q again", () => InputManager.Key(Key.Q));
|
||||||
AddAssert("NF panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
|
AddAssert("NF panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
|
||||||
|
|
||||||
AddStep("filter out everything", () => setFilter(_ => false));
|
AddStep("filter out everything", () => setFilter(_ => false));
|
||||||
@ -171,6 +181,113 @@ namespace osu.Game.Tests.Visual.UserInterface
|
|||||||
AddStep("clear filter", () => setFilter(null));
|
AddStep("clear filter", () => setFilter(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestClassicKeyboardExclusiveSelection()
|
||||||
|
{
|
||||||
|
AddStep("set classic hotkey mode", () => configManager.SetValue(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Classic));
|
||||||
|
|
||||||
|
ModColumn column = null!;
|
||||||
|
AddStep("create content", () => Child = new Container
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Padding = new MarginPadding(30),
|
||||||
|
Child = column = new ModColumn(ModType.DifficultyIncrease, false)
|
||||||
|
{
|
||||||
|
Anchor = Anchor.Centre,
|
||||||
|
Origin = Anchor.Centre,
|
||||||
|
AvailableMods = getExampleModsFor(ModType.DifficultyIncrease)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AddUntilStep("wait for panel load", () => column.IsLoaded && column.ItemsLoaded);
|
||||||
|
|
||||||
|
AddStep("press A", () => InputManager.Key(Key.A));
|
||||||
|
AddAssert("HR panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "HR").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press A again", () => InputManager.Key(Key.A));
|
||||||
|
AddAssert("HR panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "HR").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press D", () => InputManager.Key(Key.D));
|
||||||
|
AddAssert("DT panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "DT").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press D again", () => InputManager.Key(Key.D));
|
||||||
|
AddAssert("DT panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "DT").Active.Value);
|
||||||
|
AddAssert("NC panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NC").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press D again", () => InputManager.Key(Key.D));
|
||||||
|
AddAssert("DT panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "DT").Active.Value);
|
||||||
|
AddAssert("NC panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NC").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press Shift-D", () =>
|
||||||
|
{
|
||||||
|
InputManager.PressKey(Key.ShiftLeft);
|
||||||
|
InputManager.Key(Key.D);
|
||||||
|
InputManager.ReleaseKey(Key.ShiftLeft);
|
||||||
|
});
|
||||||
|
AddAssert("DT panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "DT").Active.Value);
|
||||||
|
AddAssert("NC panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NC").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press J", () => InputManager.Key(Key.J));
|
||||||
|
AddAssert("no change", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Active.Value).Mod.Acronym == "NC");
|
||||||
|
|
||||||
|
AddStep("filter everything but NC", () => setFilter(mod => mod.Acronym == "NC"));
|
||||||
|
|
||||||
|
AddStep("press A", () => InputManager.Key(Key.A));
|
||||||
|
AddAssert("no change", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Active.Value).Mod.Acronym == "NC");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestClassicKeyboardIncompatibleSelection()
|
||||||
|
{
|
||||||
|
AddStep("set classic hotkey mode", () => configManager.SetValue(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Classic));
|
||||||
|
|
||||||
|
ModColumn column = null!;
|
||||||
|
AddStep("create content", () => Child = new Container
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Padding = new MarginPadding(30),
|
||||||
|
Child = column = new ModColumn(ModType.DifficultyIncrease, true)
|
||||||
|
{
|
||||||
|
Anchor = Anchor.Centre,
|
||||||
|
Origin = Anchor.Centre,
|
||||||
|
AvailableMods = getExampleModsFor(ModType.DifficultyIncrease)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AddUntilStep("wait for panel load", () => column.IsLoaded && column.ItemsLoaded);
|
||||||
|
|
||||||
|
AddStep("press A", () => InputManager.Key(Key.A));
|
||||||
|
AddAssert("HR panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "HR").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press A again", () => InputManager.Key(Key.A));
|
||||||
|
AddAssert("HR panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "HR").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press D", () => InputManager.Key(Key.D));
|
||||||
|
AddAssert("DT panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "DT").Active.Value);
|
||||||
|
AddAssert("NC panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NC").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press D again", () => InputManager.Key(Key.D));
|
||||||
|
AddAssert("DT panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "DT").Active.Value);
|
||||||
|
AddAssert("NC panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NC").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press Shift-D", () =>
|
||||||
|
{
|
||||||
|
InputManager.PressKey(Key.ShiftLeft);
|
||||||
|
InputManager.Key(Key.D);
|
||||||
|
InputManager.ReleaseKey(Key.ShiftLeft);
|
||||||
|
});
|
||||||
|
AddAssert("DT panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "DT").Active.Value);
|
||||||
|
AddAssert("NC panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NC").Active.Value);
|
||||||
|
|
||||||
|
AddStep("press J", () => InputManager.Key(Key.J));
|
||||||
|
AddAssert("no change", () => this.ChildrenOfType<ModPanel>().Count(panel => panel.Active.Value) == 2);
|
||||||
|
|
||||||
|
AddStep("filter everything but NC", () => setFilter(mod => mod.Acronym == "NC"));
|
||||||
|
|
||||||
|
AddStep("press A", () => InputManager.Key(Key.A));
|
||||||
|
AddAssert("no change", () => this.ChildrenOfType<ModPanel>().Count(panel => panel.Active.Value) == 2);
|
||||||
|
}
|
||||||
|
|
||||||
private void setFilter(Func<Mod, bool>? filter)
|
private void setFilter(Func<Mod, bool>? filter)
|
||||||
{
|
{
|
||||||
foreach (var modState in this.ChildrenOfType<ModColumn>().Single().AvailableMods)
|
foreach (var modState in this.ChildrenOfType<ModColumn>().Single().AvailableMods)
|
||||||
@ -181,8 +298,8 @@ namespace osu.Game.Tests.Visual.UserInterface
|
|||||||
{
|
{
|
||||||
public new bool SelectionAnimationRunning => base.SelectionAnimationRunning;
|
public new bool SelectionAnimationRunning => base.SelectionAnimationRunning;
|
||||||
|
|
||||||
public TestModColumn(ModType modType, bool allowBulkSelection)
|
public TestModColumn(ModType modType, bool allowIncompatibleSelection)
|
||||||
: base(modType, allowBulkSelection)
|
: base(modType, allowIncompatibleSelection)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ using osu.Game.Input;
|
|||||||
using osu.Game.Input.Bindings;
|
using osu.Game.Input.Bindings;
|
||||||
using osu.Game.Localisation;
|
using osu.Game.Localisation;
|
||||||
using osu.Game.Overlays;
|
using osu.Game.Overlays;
|
||||||
|
using osu.Game.Overlays.Mods.Input;
|
||||||
using osu.Game.Rulesets.Scoring;
|
using osu.Game.Rulesets.Scoring;
|
||||||
using osu.Game.Screens.Select;
|
using osu.Game.Screens.Select;
|
||||||
using osu.Game.Screens.Select.Filter;
|
using osu.Game.Screens.Select.Filter;
|
||||||
@ -47,6 +48,7 @@ namespace osu.Game.Configuration
|
|||||||
SetDefault(OsuSetting.SongSelectSortingMode, SortMode.Title);
|
SetDefault(OsuSetting.SongSelectSortingMode, SortMode.Title);
|
||||||
|
|
||||||
SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation);
|
SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation);
|
||||||
|
SetDefault(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Sequential);
|
||||||
|
|
||||||
SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f);
|
SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f);
|
||||||
|
|
||||||
@ -324,6 +326,7 @@ namespace osu.Game.Configuration
|
|||||||
SongSelectGroupingMode,
|
SongSelectGroupingMode,
|
||||||
SongSelectSortingMode,
|
SongSelectSortingMode,
|
||||||
RandomSelectAlgorithm,
|
RandomSelectAlgorithm,
|
||||||
|
ModSelectHotkeyStyle,
|
||||||
ShowFpsDisplay,
|
ShowFpsDisplay,
|
||||||
ChatDisplayHeight,
|
ChatDisplayHeight,
|
||||||
BeatmapListingCardSize,
|
BeatmapListingCardSize,
|
||||||
|
@ -106,6 +106,11 @@ namespace osu.Game.Localisation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static LocalisableString RandomSelectionAlgorithm => new TranslatableString(getKey(@"random_selection_algorithm"), @"Random selection algorithm");
|
public static LocalisableString RandomSelectionAlgorithm => new TranslatableString(getKey(@"random_selection_algorithm"), @"Random selection algorithm");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Mod select hotkey style"
|
||||||
|
/// </summary>
|
||||||
|
public static LocalisableString ModSelectHotkeyStyle => new TranslatableString(getKey(@"mod_select_hotkey_style"), @"Mod select hotkey style");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// "no limit"
|
/// "no limit"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
98
osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs
Normal file
98
osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs
Normal 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.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using osu.Framework.Input.Events;
|
||||||
|
using osu.Game.Rulesets.Mods;
|
||||||
|
using osuTK.Input;
|
||||||
|
|
||||||
|
namespace osu.Game.Overlays.Mods.Input
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Uses bindings from stable 1:1.
|
||||||
|
/// </summary>
|
||||||
|
public class ClassicModHotkeyHandler : IModHotkeyHandler
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Key, Type[]> mod_type_lookup = new Dictionary<Key, Type[]>
|
||||||
|
{
|
||||||
|
[Key.Q] = new[] { typeof(ModEasy) },
|
||||||
|
[Key.W] = new[] { typeof(ModNoFail) },
|
||||||
|
[Key.E] = new[] { typeof(ModHalfTime) },
|
||||||
|
[Key.A] = new[] { typeof(ModHardRock) },
|
||||||
|
[Key.S] = new[] { typeof(ModSuddenDeath), typeof(ModPerfect) },
|
||||||
|
[Key.D] = new[] { typeof(ModDoubleTime), typeof(ModNightcore) },
|
||||||
|
[Key.F] = new[] { typeof(ModHidden) },
|
||||||
|
[Key.G] = new[] { typeof(ModFlashlight) },
|
||||||
|
[Key.Z] = new[] { typeof(ModRelax) },
|
||||||
|
[Key.V] = new[] { typeof(ModAutoplay), typeof(ModCinema) }
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly bool allowIncompatibleSelection;
|
||||||
|
|
||||||
|
public ClassicModHotkeyHandler(bool allowIncompatibleSelection)
|
||||||
|
{
|
||||||
|
this.allowIncompatibleSelection = allowIncompatibleSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable<ModState> availableMods)
|
||||||
|
{
|
||||||
|
if (!mod_type_lookup.TryGetValue(e.Key, out var typesToMatch))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var matchingMods = availableMods.Where(modState => matches(modState, typesToMatch) && !modState.Filtered.Value).ToArray();
|
||||||
|
|
||||||
|
if (matchingMods.Length == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (matchingMods.Length == 1)
|
||||||
|
{
|
||||||
|
matchingMods.Single().Active.Toggle();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowIncompatibleSelection)
|
||||||
|
{
|
||||||
|
// easier path - multiple incompatible mods can be active at a time.
|
||||||
|
// this is used in the free mod select overlay.
|
||||||
|
// in this case, just toggle everything.
|
||||||
|
bool anyActive = matchingMods.Any(mod => mod.Active.Value);
|
||||||
|
foreach (var mod in matchingMods)
|
||||||
|
mod.Active.Value = !anyActive;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we now know there are multiple possible mods to handle, and only one of them can be active at a time.
|
||||||
|
// let's make sure of this just in case.
|
||||||
|
Debug.Assert(matchingMods.Count(modState => modState.Active.Value) <= 1);
|
||||||
|
int currentSelectedIndex = Array.FindIndex(matchingMods, modState => modState.Active.Value);
|
||||||
|
|
||||||
|
// `FindIndex` will return -1 if it doesn't find the item.
|
||||||
|
// this is convenient in the forward direction, since if we add 1 then we'll end up at the first item,
|
||||||
|
// but less so in the backwards direction.
|
||||||
|
// for convenience, detect this situation and set the index to one index past the last item.
|
||||||
|
// this makes it so that if we subtract 1 then we'll end up at the last item again.
|
||||||
|
if (currentSelectedIndex < 0 && e.ShiftPressed)
|
||||||
|
currentSelectedIndex = matchingMods.Length;
|
||||||
|
|
||||||
|
int indexToSelect = e.ShiftPressed ? currentSelectedIndex - 1 : currentSelectedIndex + 1;
|
||||||
|
|
||||||
|
// `currentSelectedIndex` and `indexToSelect` can both be equal to -1 or `matchingMods.Length`.
|
||||||
|
// if the former is beyond array range, it means nothing was previously selected and so there's nothing to deselect.
|
||||||
|
// if the latter is beyond array range, it means that either the previous selection was first and we're going backwards,
|
||||||
|
// or it was last and we're going forwards.
|
||||||
|
// in either case there is nothing to select.
|
||||||
|
if (currentSelectedIndex >= 0 && currentSelectedIndex <= matchingMods.Length - 1)
|
||||||
|
matchingMods[currentSelectedIndex].Active.Value = false;
|
||||||
|
if (indexToSelect >= 0 && indexToSelect <= matchingMods.Length - 1)
|
||||||
|
matchingMods[indexToSelect].Active.Value = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool matches(ModState modState, Type[] typesToMatch)
|
||||||
|
=> typesToMatch.Any(typeToMatch => typeToMatch.IsInstanceOfType(modState.Mod));
|
||||||
|
}
|
||||||
|
}
|
22
osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs
Normal file
22
osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using osu.Framework.Input.Events;
|
||||||
|
|
||||||
|
namespace osu.Game.Overlays.Mods.Input
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Encapsulates strategies of handling mod hotkeys on the <see cref="ModSelectOverlay"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IModHotkeyHandler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attempt to handle the supplied <see cref="KeyDownEvent"/> as a selection of one of the mods in <paramref name="availableMods"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="e">The event representing the user's keypress.</param>
|
||||||
|
/// <param name="availableMods">The list of currently available mods.</param>
|
||||||
|
/// <returns>Whether the supplied event was handled as a mod selection/deselection.</returns>
|
||||||
|
bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable<ModState> availableMods);
|
||||||
|
}
|
||||||
|
}
|
27
osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs
Normal file
27
osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// 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.Game.Rulesets.Mods;
|
||||||
|
|
||||||
|
namespace osu.Game.Overlays.Mods.Input
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The style of hotkey handling to use on the mod select screen.
|
||||||
|
/// </summary>
|
||||||
|
public enum ModSelectHotkeyStyle
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Each letter row on the keyboard controls one of the three first <see cref="ModColumn"/>s.
|
||||||
|
/// Individual letters in a row trigger the mods in a sequential fashion.
|
||||||
|
/// Uses <see cref="SequentialModHotkeyHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
Sequential,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches keybindings from stable 1:1.
|
||||||
|
/// One keybinding can toggle between what used to be <see cref="MultiMod"/>s on stable,
|
||||||
|
/// and some mods in a column may not have any hotkeys at all.
|
||||||
|
/// </summary>
|
||||||
|
Classic
|
||||||
|
}
|
||||||
|
}
|
17
osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs
Normal file
17
osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using osu.Framework.Input.Events;
|
||||||
|
|
||||||
|
namespace osu.Game.Overlays.Mods.Input
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A no-op implementation of <see cref="IModHotkeyHandler"/>.
|
||||||
|
/// Used when a column is not handling any hotkeys at all.
|
||||||
|
/// </summary>
|
||||||
|
public class NoopModHotkeyHandler : IModHotkeyHandler
|
||||||
|
{
|
||||||
|
public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable<ModState> availableMods) => false;
|
||||||
|
}
|
||||||
|
}
|
59
osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs
Normal file
59
osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using osu.Framework.Input.Events;
|
||||||
|
using osu.Game.Rulesets.Mods;
|
||||||
|
using osuTK.Input;
|
||||||
|
|
||||||
|
namespace osu.Game.Overlays.Mods.Input
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This implementation of <see cref="IModHotkeyHandler"/> receives a sequence of <see cref="Key"/>s,
|
||||||
|
/// and maps the sequence of keys onto the items it is provided in <see cref="HandleHotkeyPressed"/>.
|
||||||
|
/// In this case, particular mods are not bound to particular keys, the hotkeys are a byproduct of mod ordering.
|
||||||
|
/// </summary>
|
||||||
|
public class SequentialModHotkeyHandler : IModHotkeyHandler
|
||||||
|
{
|
||||||
|
public static SequentialModHotkeyHandler Create(ModType modType)
|
||||||
|
{
|
||||||
|
switch (modType)
|
||||||
|
{
|
||||||
|
case ModType.DifficultyReduction:
|
||||||
|
return new SequentialModHotkeyHandler(new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P });
|
||||||
|
|
||||||
|
case ModType.DifficultyIncrease:
|
||||||
|
return new SequentialModHotkeyHandler(new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L });
|
||||||
|
|
||||||
|
case ModType.Automation:
|
||||||
|
return new SequentialModHotkeyHandler(new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M });
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(modType), modType, $"Cannot create {nameof(SequentialModHotkeyHandler)} for provided mod type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Key[] toggleKeys;
|
||||||
|
|
||||||
|
private SequentialModHotkeyHandler(Key[] keys)
|
||||||
|
{
|
||||||
|
toggleKeys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable<ModState> availableMods)
|
||||||
|
{
|
||||||
|
int index = Array.IndexOf(toggleKeys, e.Key);
|
||||||
|
if (index < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var modState = availableMods.Where(modState => !modState.Filtered.Value).ElementAtOrDefault(index);
|
||||||
|
if (modState == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
modState.Active.Toggle();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -17,14 +17,15 @@ using osu.Framework.Graphics.Containers;
|
|||||||
using osu.Framework.Graphics.Shapes;
|
using osu.Framework.Graphics.Shapes;
|
||||||
using osu.Framework.Graphics.Sprites;
|
using osu.Framework.Graphics.Sprites;
|
||||||
using osu.Framework.Input.Events;
|
using osu.Framework.Input.Events;
|
||||||
|
using osu.Game.Configuration;
|
||||||
using osu.Game.Graphics;
|
using osu.Game.Graphics;
|
||||||
using osu.Game.Graphics.Containers;
|
using osu.Game.Graphics.Containers;
|
||||||
using osu.Game.Graphics.UserInterface;
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osu.Game.Localisation;
|
using osu.Game.Localisation;
|
||||||
|
using osu.Game.Overlays.Mods.Input;
|
||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
using osuTK;
|
using osuTK;
|
||||||
using osuTK.Graphics;
|
using osuTK.Graphics;
|
||||||
using osuTK.Input;
|
|
||||||
|
|
||||||
namespace osu.Game.Overlays.Mods
|
namespace osu.Game.Overlays.Mods
|
||||||
{
|
{
|
||||||
@ -70,7 +71,7 @@ namespace osu.Game.Overlays.Mods
|
|||||||
|
|
||||||
protected virtual ModPanel CreateModPanel(ModState mod) => new ModPanel(mod);
|
protected virtual ModPanel CreateModPanel(ModState mod) => new ModPanel(mod);
|
||||||
|
|
||||||
private readonly Key[]? toggleKeys;
|
private readonly bool allowIncompatibleSelection;
|
||||||
|
|
||||||
private readonly TextFlowContainer headerText;
|
private readonly TextFlowContainer headerText;
|
||||||
private readonly Box headerBackground;
|
private readonly Box headerBackground;
|
||||||
@ -81,15 +82,18 @@ namespace osu.Game.Overlays.Mods
|
|||||||
|
|
||||||
private Colour4 accentColour;
|
private Colour4 accentColour;
|
||||||
|
|
||||||
|
private Bindable<ModSelectHotkeyStyle> hotkeyStyle = null!;
|
||||||
|
private IModHotkeyHandler hotkeyHandler = null!;
|
||||||
|
|
||||||
private Task? latestLoadTask;
|
private Task? latestLoadTask;
|
||||||
internal bool ItemsLoaded => latestLoadTask == null;
|
internal bool ItemsLoaded => latestLoadTask == null;
|
||||||
|
|
||||||
private const float header_height = 42;
|
private const float header_height = 42;
|
||||||
|
|
||||||
public ModColumn(ModType modType, bool allowBulkSelection, Key[]? toggleKeys = null)
|
public ModColumn(ModType modType, bool allowIncompatibleSelection)
|
||||||
{
|
{
|
||||||
ModType = modType;
|
ModType = modType;
|
||||||
this.toggleKeys = toggleKeys;
|
this.allowIncompatibleSelection = allowIncompatibleSelection;
|
||||||
|
|
||||||
Width = 320;
|
Width = 320;
|
||||||
RelativeSizeAxes = Axes.Y;
|
RelativeSizeAxes = Axes.Y;
|
||||||
@ -197,7 +201,7 @@ namespace osu.Game.Overlays.Mods
|
|||||||
|
|
||||||
createHeaderText();
|
createHeaderText();
|
||||||
|
|
||||||
if (allowBulkSelection)
|
if (allowIncompatibleSelection)
|
||||||
{
|
{
|
||||||
controlContainer.Height = 35;
|
controlContainer.Height = 35;
|
||||||
controlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this)
|
controlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this)
|
||||||
@ -231,7 +235,7 @@ namespace osu.Game.Overlays.Mods
|
|||||||
}
|
}
|
||||||
|
|
||||||
[BackgroundDependencyLoader]
|
[BackgroundDependencyLoader]
|
||||||
private void load(OverlayColourProvider colourProvider, OsuColour colours)
|
private void load(OverlayColourProvider colourProvider, OsuColour colours, OsuConfigManager configManager)
|
||||||
{
|
{
|
||||||
headerBackground.Colour = accentColour = colours.ForModType(ModType);
|
headerBackground.Colour = accentColour = colours.ForModType(ModType);
|
||||||
|
|
||||||
@ -243,6 +247,8 @@ namespace osu.Game.Overlays.Mods
|
|||||||
|
|
||||||
contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background3);
|
contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background3);
|
||||||
contentBackground.Colour = colourProvider.Background4;
|
contentBackground.Colour = colourProvider.Background4;
|
||||||
|
|
||||||
|
hotkeyStyle = configManager.GetBindable<ModSelectHotkeyStyle>(OsuSetting.ModSelectHotkeyStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void LoadComplete()
|
protected override void LoadComplete()
|
||||||
@ -250,6 +256,7 @@ namespace osu.Game.Overlays.Mods
|
|||||||
base.LoadComplete();
|
base.LoadComplete();
|
||||||
|
|
||||||
toggleAllCheckbox?.Current.BindValueChanged(_ => updateToggleAllText(), true);
|
toggleAllCheckbox?.Current.BindValueChanged(_ => updateToggleAllText(), true);
|
||||||
|
hotkeyStyle.BindValueChanged(val => hotkeyHandler = createHotkeyHandler(val.NewValue), true);
|
||||||
asyncLoadPanels();
|
asyncLoadPanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,19 +430,32 @@ namespace osu.Game.Overlays.Mods
|
|||||||
|
|
||||||
#region Keyboard selection support
|
#region Keyboard selection support
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an appropriate <see cref="IModHotkeyHandler"/> for this column's <see cref="ModType"/> and
|
||||||
|
/// the supplied <paramref name="hotkeyStyle"/>.
|
||||||
|
/// </summary>
|
||||||
|
private IModHotkeyHandler createHotkeyHandler(ModSelectHotkeyStyle hotkeyStyle)
|
||||||
|
{
|
||||||
|
switch (ModType)
|
||||||
|
{
|
||||||
|
case ModType.DifficultyReduction:
|
||||||
|
case ModType.DifficultyIncrease:
|
||||||
|
case ModType.Automation:
|
||||||
|
return hotkeyStyle == ModSelectHotkeyStyle.Sequential
|
||||||
|
? (IModHotkeyHandler)SequentialModHotkeyHandler.Create(ModType)
|
||||||
|
: new ClassicModHotkeyHandler(allowIncompatibleSelection);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new NoopModHotkeyHandler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override bool OnKeyDown(KeyDownEvent e)
|
protected override bool OnKeyDown(KeyDownEvent e)
|
||||||
{
|
{
|
||||||
if (e.ControlPressed || e.AltPressed || e.SuperPressed) return false;
|
if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.Repeat)
|
||||||
if (toggleKeys == null) return false;
|
return false;
|
||||||
|
|
||||||
int index = Array.IndexOf(toggleKeys, e.Key);
|
return hotkeyHandler.HandleHotkeyPressed(e, availableMods);
|
||||||
if (index < 0) return false;
|
|
||||||
|
|
||||||
var modState = availableMods.ElementAtOrDefault(index);
|
|
||||||
if (modState == null || modState.Filtered.Value) return false;
|
|
||||||
|
|
||||||
modState.Active.Toggle();
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
@ -21,7 +21,6 @@ using osu.Game.Localisation;
|
|||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
using osu.Game.Utils;
|
using osu.Game.Utils;
|
||||||
using osuTK;
|
using osuTK;
|
||||||
using osuTK.Input;
|
|
||||||
|
|
||||||
namespace osu.Game.Overlays.Mods
|
namespace osu.Game.Overlays.Mods
|
||||||
{
|
{
|
||||||
@ -68,7 +67,7 @@ namespace osu.Game.Overlays.Mods
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
protected virtual bool AllowCustomisation => true;
|
protected virtual bool AllowCustomisation => true;
|
||||||
|
|
||||||
protected virtual ModColumn CreateModColumn(ModType modType, Key[]? toggleKeys = null) => new ModColumn(modType, false, toggleKeys);
|
protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false);
|
||||||
|
|
||||||
protected virtual IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection) => newSelection;
|
protected virtual IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection) => newSelection;
|
||||||
|
|
||||||
@ -160,9 +159,9 @@ namespace osu.Game.Overlays.Mods
|
|||||||
Padding = new MarginPadding { Bottom = 10 },
|
Padding = new MarginPadding { Bottom = 10 },
|
||||||
Children = new[]
|
Children = new[]
|
||||||
{
|
{
|
||||||
createModColumnContent(ModType.DifficultyReduction, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }),
|
createModColumnContent(ModType.DifficultyReduction),
|
||||||
createModColumnContent(ModType.DifficultyIncrease, new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }),
|
createModColumnContent(ModType.DifficultyIncrease),
|
||||||
createModColumnContent(ModType.Automation, new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }),
|
createModColumnContent(ModType.Automation),
|
||||||
createModColumnContent(ModType.Conversion),
|
createModColumnContent(ModType.Conversion),
|
||||||
createModColumnContent(ModType.Fun)
|
createModColumnContent(ModType.Fun)
|
||||||
}
|
}
|
||||||
@ -264,9 +263,9 @@ namespace osu.Game.Overlays.Mods
|
|||||||
column.DeselectAll();
|
column.DeselectAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ColumnDimContainer createModColumnContent(ModType modType, Key[]? toggleKeys = null)
|
private ColumnDimContainer createModColumnContent(ModType modType)
|
||||||
{
|
{
|
||||||
var column = CreateModColumn(modType, toggleKeys).With(column =>
|
var column = CreateModColumn(modType).With(column =>
|
||||||
{
|
{
|
||||||
// spacing applied here rather than via `columnFlow.Spacing` to avoid uneven gaps when some of the columns are hidden.
|
// spacing applied here rather than via `columnFlow.Spacing` to avoid uneven gaps when some of the columns are hidden.
|
||||||
column.Margin = new MarginPadding { Right = 10 };
|
column.Margin = new MarginPadding { Right = 10 };
|
||||||
|
@ -1,14 +1,10 @@
|
|||||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
// See the LICENCE file in the repository root for full licence text.
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using JetBrains.Annotations;
|
|
||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
using osu.Game.Utils;
|
using osu.Game.Utils;
|
||||||
using osuTK.Input;
|
|
||||||
|
|
||||||
namespace osu.Game.Overlays.Mods
|
namespace osu.Game.Overlays.Mods
|
||||||
{
|
{
|
||||||
@ -19,7 +15,7 @@ namespace osu.Game.Overlays.Mods
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new UserModColumn(modType, false, toggleKeys);
|
protected override ModColumn CreateModColumn(ModType modType) => new UserModColumn(modType, false);
|
||||||
|
|
||||||
protected override IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection)
|
protected override IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection)
|
||||||
{
|
{
|
||||||
@ -44,8 +40,8 @@ namespace osu.Game.Overlays.Mods
|
|||||||
|
|
||||||
private class UserModColumn : ModColumn
|
private class UserModColumn : ModColumn
|
||||||
{
|
{
|
||||||
public UserModColumn(ModType modType, bool allowBulkSelection, [CanBeNull] Key[] toggleKeys = null)
|
public UserModColumn(ModType modType, bool allowIncompatibleSelection)
|
||||||
: base(modType, allowBulkSelection, toggleKeys)
|
: base(modType, allowIncompatibleSelection)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ using osu.Framework.Localisation;
|
|||||||
using osu.Game.Configuration;
|
using osu.Game.Configuration;
|
||||||
using osu.Game.Graphics.UserInterface;
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osu.Game.Localisation;
|
using osu.Game.Localisation;
|
||||||
|
using osu.Game.Overlays.Mods.Input;
|
||||||
|
|
||||||
namespace osu.Game.Overlays.Settings.Sections.UserInterface
|
namespace osu.Game.Overlays.Settings.Sections.UserInterface
|
||||||
{
|
{
|
||||||
@ -61,6 +62,12 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
|
|||||||
{
|
{
|
||||||
LabelText = UserInterfaceStrings.RandomSelectionAlgorithm,
|
LabelText = UserInterfaceStrings.RandomSelectionAlgorithm,
|
||||||
Current = config.GetBindable<RandomSelectAlgorithm>(OsuSetting.RandomSelectAlgorithm),
|
Current = config.GetBindable<RandomSelectAlgorithm>(OsuSetting.RandomSelectAlgorithm),
|
||||||
|
},
|
||||||
|
new SettingsEnumDropdown<ModSelectHotkeyStyle>
|
||||||
|
{
|
||||||
|
LabelText = UserInterfaceStrings.ModSelectHotkeyStyle,
|
||||||
|
Current = config.GetBindable<ModSelectHotkeyStyle>(OsuSetting.ModSelectHotkeyStyle),
|
||||||
|
ClassicDefault = ModSelectHotkeyStyle.Classic
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,6 @@ using osu.Framework.Graphics;
|
|||||||
using osu.Game.Graphics.UserInterface;
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osu.Game.Overlays.Mods;
|
using osu.Game.Overlays.Mods;
|
||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
using osuTK.Input;
|
|
||||||
|
|
||||||
namespace osu.Game.Screens.OnlinePlay
|
namespace osu.Game.Screens.OnlinePlay
|
||||||
{
|
{
|
||||||
@ -33,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay
|
|||||||
IsValidMod = _ => true;
|
IsValidMod = _ => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new ModColumn(modType, true, toggleKeys);
|
protected override ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, true);
|
||||||
|
|
||||||
protected override IEnumerable<ShearedButton> CreateFooterButtons() => base.CreateFooterButtons().Prepend(
|
protected override IEnumerable<ShearedButton> CreateFooterButtons() => base.CreateFooterButtons().Prepend(
|
||||||
new SelectAllModsButton(this)
|
new SelectAllModsButton(this)
|
||||||
|
Loading…
Reference in New Issue
Block a user