1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-14 16:37:26 +08:00

Merge pull request #11666 from smoogipoo/freemod-select-overlay

Implement the freemod selection overlay
This commit is contained in:
Dean Herbert 2021-02-05 00:25:43 +09:00 committed by GitHub
commit 4730cf02d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 332 additions and 35 deletions

View File

@ -0,0 +1,21 @@
// 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 NUnit.Framework;
using osu.Framework.Graphics.Containers;
using osu.Game.Screens.OnlinePlay;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneFreeModSelectOverlay : MultiplayerTestScene
{
[SetUp]
public new void Setup() => Schedule(() =>
{
Child = new FreeModSelectOverlay
{
State = { Value = Visibility.Visible }
};
});
}
}

View File

@ -8,6 +8,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -46,6 +47,32 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("show", () => modSelect.Show()); AddStep("show", () => modSelect.Show());
} }
[Test]
public void TestAnimationFlushOnClose()
{
changeRuleset(0);
AddStep("Select all fun mods", () =>
{
modSelect.ModSectionsContainer
.Single(c => c.ModType == ModType.DifficultyIncrease)
.SelectAll();
});
AddUntilStep("many mods selected", () => modDisplay.Current.Value.Count >= 5);
AddStep("trigger deselect and close overlay", () =>
{
modSelect.ModSectionsContainer
.Single(c => c.ModType == ModType.DifficultyIncrease)
.DeselectAll();
modSelect.Hide();
});
AddAssert("all mods deselected", () => modDisplay.Current.Value.Count == 0);
}
[Test] [Test]
public void TestOsuMods() public void TestOsuMods()
{ {
@ -145,11 +172,11 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert("double time visible", () => modSelect.ChildrenOfType<ModButton>().Any(b => b.Mods.Any(m => m is OsuModDoubleTime))); AddAssert("double time visible", () => modSelect.ChildrenOfType<ModButton>().Any(b => b.Mods.Any(m => m is OsuModDoubleTime)));
AddStep("make double time invalid", () => modSelect.IsValidMod = m => !(m is OsuModDoubleTime)); AddStep("make double time invalid", () => modSelect.IsValidMod = m => !(m is OsuModDoubleTime));
AddAssert("double time not visible", () => modSelect.ChildrenOfType<ModButton>().All(b => !b.Mods.Any(m => m is OsuModDoubleTime))); AddUntilStep("double time not visible", () => modSelect.ChildrenOfType<ModButton>().All(b => !b.Mods.Any(m => m is OsuModDoubleTime)));
AddAssert("nightcore still visible", () => modSelect.ChildrenOfType<ModButton>().Any(b => b.Mods.Any(m => m is OsuModNightcore))); AddAssert("nightcore still visible", () => modSelect.ChildrenOfType<ModButton>().Any(b => b.Mods.Any(m => m is OsuModNightcore)));
AddStep("make double time valid again", () => modSelect.IsValidMod = m => true); AddStep("make double time valid again", () => modSelect.IsValidMod = m => true);
AddAssert("double time visible", () => modSelect.ChildrenOfType<ModButton>().Any(b => b.Mods.Any(m => m is OsuModDoubleTime))); AddUntilStep("double time visible", () => modSelect.ChildrenOfType<ModButton>().Any(b => b.Mods.Any(m => m is OsuModDoubleTime)));
AddAssert("nightcore still visible", () => modSelect.ChildrenOfType<ModButton>().Any(b => b.Mods.Any(m => m is OsuModNightcore))); AddAssert("nightcore still visible", () => modSelect.ChildrenOfType<ModButton>().Any(b => b.Mods.Any(m => m is OsuModNightcore)));
} }
@ -312,6 +339,9 @@ namespace osu.Game.Tests.Visual.UserInterface
public bool AllLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded); public bool AllLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded);
public new FillFlowContainer<ModSection> ModSectionsContainer =>
base.ModSectionsContainer;
public ModButton GetModButton(Mod mod) public ModButton GetModButton(Mod mod)
{ {
var section = ModSectionsContainer.Children.Single(s => s.ModType == mod.Type); var section = ModSectionsContainer.Children.Single(s => s.ModType == mod.Type);

View File

@ -5,6 +5,7 @@ using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Audio.Sample; using osu.Framework.Audio.Sample;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
@ -18,6 +19,11 @@ namespace osu.Game.Graphics.UserInterface
public Color4 UncheckedColor { get; set; } = Color4.White; public Color4 UncheckedColor { get; set; } = Color4.White;
public int FadeDuration { get; set; } public int FadeDuration { get; set; }
/// <summary>
/// Whether to play sounds when the state changes as a result of user interaction.
/// </summary>
protected virtual bool PlaySoundsOnUserChange => true;
public string LabelText public string LabelText
{ {
set set
@ -43,7 +49,7 @@ namespace osu.Game.Graphics.UserInterface
private SampleChannel sampleChecked; private SampleChannel sampleChecked;
private SampleChannel sampleUnchecked; private SampleChannel sampleUnchecked;
public OsuCheckbox() public OsuCheckbox(bool nubOnRight = true)
{ {
AutoSizeAxes = Axes.Y; AutoSizeAxes = Axes.Y;
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
@ -52,26 +58,42 @@ namespace osu.Game.Graphics.UserInterface
Children = new Drawable[] Children = new Drawable[]
{ {
labelText = new OsuTextFlowContainer labelText = new OsuTextFlowContainer(ApplyLabelParameters)
{ {
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding }
},
Nub = new Nub
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Margin = new MarginPadding { Right = nub_padding },
}, },
Nub = new Nub(),
new HoverClickSounds() new HoverClickSounds()
}; };
if (nubOnRight)
{
Nub.Anchor = Anchor.CentreRight;
Nub.Origin = Anchor.CentreRight;
Nub.Margin = new MarginPadding { Right = nub_padding };
labelText.Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding * 2 };
}
else
{
Nub.Anchor = Anchor.CentreLeft;
Nub.Origin = Anchor.CentreLeft;
Nub.Margin = new MarginPadding { Left = nub_padding };
labelText.Padding = new MarginPadding { Left = Nub.EXPANDED_SIZE + nub_padding * 2 };
}
Nub.Current.BindTo(Current); Nub.Current.BindTo(Current);
Current.DisabledChanged += disabled => labelText.Alpha = Nub.Alpha = disabled ? 0.3f : 1; Current.DisabledChanged += disabled => labelText.Alpha = Nub.Alpha = disabled ? 0.3f : 1;
} }
/// <summary>
/// A function which can be overridden to change the parameters of the label's text.
/// </summary>
protected virtual void ApplyLabelParameters(SpriteText text)
{
}
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(AudioManager audio) private void load(AudioManager audio)
{ {
@ -96,6 +118,9 @@ namespace osu.Game.Graphics.UserInterface
protected override void OnUserChange(bool value) protected override void OnUserChange(bool value)
{ {
base.OnUserChange(value); base.OnUserChange(value);
if (PlaySoundsOnUserChange)
{
if (value) if (value)
sampleChecked?.Play(); sampleChecked?.Play();
else else
@ -103,3 +128,4 @@ namespace osu.Game.Graphics.UserInterface
} }
} }
} }
}

View File

@ -33,6 +33,8 @@ namespace osu.Game.Overlays.Mods
private CancellationTokenSource modsLoadCts; private CancellationTokenSource modsLoadCts;
protected bool SelectionAnimationRunning => pendingSelectionOperations.Count > 0;
/// <summary> /// <summary>
/// True when all mod icons have completed loading. /// True when all mod icons have completed loading.
/// </summary> /// </summary>
@ -49,7 +51,11 @@ namespace osu.Game.Overlays.Mods
return new ModButton(m) return new ModButton(m)
{ {
SelectionChanged = Action, SelectionChanged = mod =>
{
ModButtonStateChanged(mod);
Action?.Invoke(mod);
},
}; };
}).ToArray(); }).ToArray();
@ -78,6 +84,10 @@ namespace osu.Game.Overlays.Mods
} }
} }
protected virtual void ModButtonStateChanged(Mod mod)
{
}
private ModButton[] buttons = Array.Empty<ModButton>(); private ModButton[] buttons = Array.Empty<ModButton>();
protected override bool OnKeyDown(KeyDownEvent e) protected override bool OnKeyDown(KeyDownEvent e)
@ -94,30 +104,75 @@ namespace osu.Game.Overlays.Mods
return base.OnKeyDown(e); return base.OnKeyDown(e);
} }
public void DeselectAll() => DeselectTypes(buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null)); private const double initial_multiple_selection_delay = 120;
private double selectionDelay = initial_multiple_selection_delay;
private double lastSelection;
private readonly Queue<Action> pendingSelectionOperations = new Queue<Action>();
protected override void Update()
{
base.Update();
if (selectionDelay == initial_multiple_selection_delay || Time.Current - lastSelection >= selectionDelay)
{
if (pendingSelectionOperations.TryDequeue(out var dequeuedAction))
{
dequeuedAction();
// each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements).
selectionDelay = Math.Max(30, selectionDelay * 0.8f);
lastSelection = Time.Current;
}
else
{
// reset the selection delay after all animations have been completed.
// this will cause the next action to be immediately performed.
selectionDelay = initial_multiple_selection_delay;
}
}
}
/// <summary>
/// Selects all mods.
/// </summary>
public void SelectAll()
{
pendingSelectionOperations.Clear();
foreach (var button in buttons.Where(b => !b.Selected))
pendingSelectionOperations.Enqueue(() => button.SelectAt(0));
}
/// <summary>
/// Deselects all mods.
/// </summary>
public void DeselectAll()
{
pendingSelectionOperations.Clear();
DeselectTypes(buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null));
}
/// <summary> /// <summary>
/// Deselect one or more mods in this section. /// Deselect one or more mods in this section.
/// </summary> /// </summary>
/// <param name="modTypes">The types of <see cref="Mod"/>s which should be deselected.</param> /// <param name="modTypes">The types of <see cref="Mod"/>s which should be deselected.</param>
/// <param name="immediate">Set to true to bypass animations and update selections immediately.</param> /// <param name="immediate">Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow.</param>
public void DeselectTypes(IEnumerable<Type> modTypes, bool immediate = false) public void DeselectTypes(IEnumerable<Type> modTypes, bool immediate = false)
{ {
int delay = 0;
foreach (var button in buttons) foreach (var button in buttons)
{ {
Mod selected = button.SelectedMod; if (button.SelectedMod == null) continue;
if (selected == null) continue;
foreach (var type in modTypes) foreach (var type in modTypes)
{ {
if (type.IsInstanceOfType(selected)) if (type.IsInstanceOfType(button.SelectedMod))
{ {
if (immediate) if (immediate)
button.Deselect(); button.Deselect();
else else
Scheduler.AddDelayed(button.Deselect, delay += 50); pendingSelectionOperations.Enqueue(button.Deselect);
} }
} }
} }
@ -184,5 +239,14 @@ namespace osu.Game.Overlays.Mods
Font = OsuFont.GetFont(weight: FontWeight.Bold), Font = OsuFont.GetFont(weight: FontWeight.Bold),
Text = text Text = text
}; };
/// <summary>
/// Play out all remaining animations immediately to leave mods in a good (final) state.
/// </summary>
public void FlushAnimation()
{
while (pendingSelectionOperations.TryDequeue(out var dequeuedAction))
dequeuedAction();
}
} }
} }

View File

@ -37,8 +37,11 @@ namespace osu.Game.Overlays.Mods
protected readonly TriangleButton CustomiseButton; protected readonly TriangleButton CustomiseButton;
protected readonly TriangleButton CloseButton; protected readonly TriangleButton CloseButton;
protected readonly Drawable MultiplierSection;
protected readonly OsuSpriteText MultiplierLabel; protected readonly OsuSpriteText MultiplierLabel;
protected readonly FillFlowContainer FooterContainer;
protected override bool BlockNonPositionalInput => false; protected override bool BlockNonPositionalInput => false;
protected override bool DimMainContent => false; protected override bool DimMainContent => false;
@ -79,8 +82,6 @@ namespace osu.Game.Overlays.Mods
private const float content_width = 0.8f; private const float content_width = 0.8f;
private const float footer_button_spacing = 20; private const float footer_button_spacing = 20;
private readonly FillFlowContainer footerContainer;
private SampleChannel sampleOn, sampleOff; private SampleChannel sampleOn, sampleOff;
protected ModSelectOverlay() protected ModSelectOverlay()
@ -269,7 +270,7 @@ namespace osu.Game.Overlays.Mods
Colour = new Color4(172, 20, 116, 255), Colour = new Color4(172, 20, 116, 255),
Alpha = 0.5f, Alpha = 0.5f,
}, },
footerContainer = new FillFlowContainer FooterContainer = new FillFlowContainer
{ {
Origin = Anchor.BottomCentre, Origin = Anchor.BottomCentre,
Anchor = Anchor.BottomCentre, Anchor = Anchor.BottomCentre,
@ -283,7 +284,7 @@ namespace osu.Game.Overlays.Mods
Vertical = 15, Vertical = 15,
Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING
}, },
Children = new Drawable[] Children = new[]
{ {
DeselectAllButton = new TriangleButton DeselectAllButton = new TriangleButton
{ {
@ -310,7 +311,7 @@ namespace osu.Game.Overlays.Mods
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
}, },
new FillFlowContainer MultiplierSection = new FillFlowContainer
{ {
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Spacing = new Vector2(footer_button_spacing / 2, 0), Spacing = new Vector2(footer_button_spacing / 2, 0),
@ -378,8 +379,13 @@ namespace osu.Game.Overlays.Mods
{ {
base.PopOut(); base.PopOut();
footerContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); foreach (var section in ModSectionsContainer)
footerContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine); {
section.FlushAnimation();
}
FooterContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine);
FooterContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine);
foreach (var section in ModSectionsContainer.Children) foreach (var section in ModSectionsContainer.Children)
{ {
@ -393,8 +399,8 @@ namespace osu.Game.Overlays.Mods
{ {
base.PopIn(); base.PopIn();
footerContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); FooterContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint);
footerContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); FooterContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint);
foreach (var section in ModSectionsContainer.Children) foreach (var section in ModSectionsContainer.Children)
{ {
@ -498,7 +504,8 @@ namespace osu.Game.Overlays.Mods
{ {
if (selectedMod != null) if (selectedMod != null)
{ {
if (State.Value == Visibility.Visible) sampleOn?.Play(); if (State.Value == Visibility.Visible)
Scheduler.AddOnce(playSelectedSound);
OnModSelected(selectedMod); OnModSelected(selectedMod);
@ -506,12 +513,16 @@ namespace osu.Game.Overlays.Mods
} }
else else
{ {
if (State.Value == Visibility.Visible) sampleOff?.Play(); if (State.Value == Visibility.Visible)
Scheduler.AddOnce(playDeselectedSound);
} }
refreshSelectedMods(); refreshSelectedMods();
} }
private void playSelectedSound() => sampleOn?.Play();
private void playDeselectedSound() => sampleOff?.Play();
/// <summary> /// <summary>
/// Invoked when a new <see cref="Mod"/> has been selected. /// Invoked when a new <see cref="Mod"/> has been selected.
/// </summary> /// </summary>

View File

@ -0,0 +1,145 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Screens.OnlinePlay
{
/// <summary>
/// A <see cref="ModSelectOverlay"/> used for free-mod selection in online play.
/// </summary>
public class FreeModSelectOverlay : ModSelectOverlay
{
protected override bool Stacked => false;
public new Func<Mod, bool> IsValidMod
{
get => base.IsValidMod;
set => base.IsValidMod = m => m.HasImplementation && !m.RequiresConfiguration && !(m is ModAutoplay) && value(m);
}
public FreeModSelectOverlay()
{
IsValidMod = m => true;
CustomiseButton.Alpha = 0;
MultiplierSection.Alpha = 0;
DeselectAllButton.Alpha = 0;
Drawable selectAllButton;
Drawable deselectAllButton;
FooterContainer.AddRange(new[]
{
selectAllButton = new TriangleButton
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Width = 180,
Text = "Select All",
Action = selectAll,
},
// Unlike the base mod select overlay, this button deselects mods instantaneously.
deselectAllButton = new TriangleButton
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Width = 180,
Text = "Deselect All",
Action = deselectAll,
},
});
FooterContainer.SetLayoutPosition(selectAllButton, -2);
FooterContainer.SetLayoutPosition(deselectAllButton, -1);
}
private void selectAll()
{
foreach (var section in ModSectionsContainer.Children)
section.SelectAll();
}
private void deselectAll()
{
foreach (var section in ModSectionsContainer.Children)
section.DeselectAll();
}
protected override ModSection CreateModSection(ModType type) => new FreeModSection(type);
private class FreeModSection : ModSection
{
private HeaderCheckbox checkbox;
public FreeModSection(ModType type)
: base(type)
{
}
protected override Drawable CreateHeader(string text) => new Container
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Child = checkbox = new HeaderCheckbox
{
LabelText = text,
Changed = onCheckboxChanged
}
};
private void onCheckboxChanged(bool value)
{
if (value)
SelectAll();
else
DeselectAll();
}
protected override void ModButtonStateChanged(Mod mod)
{
base.ModButtonStateChanged(mod);
if (!SelectionAnimationRunning)
{
var validButtons = ButtonsContainer.OfType<ModButton>().Where(b => b.Mod.HasImplementation);
checkbox.Current.Value = validButtons.All(b => b.Selected);
}
}
}
private class HeaderCheckbox : OsuCheckbox
{
public Action<bool> Changed;
protected override bool PlaySoundsOnUserChange => false;
public HeaderCheckbox()
: base(false)
{
}
protected override void ApplyLabelParameters(SpriteText text)
{
base.ApplyLabelParameters(text);
text.Font = OsuFont.GetFont(weight: FontWeight.Bold);
}
protected override void OnUserChange(bool value)
{
base.OnUserChange(value);
Changed?.Invoke(value);
}
}
}
}