1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-15 07:22:55 +08:00

Merge branch 'master' into exit-multiplayer-is-dangerous

This commit is contained in:
Dean Herbert 2022-05-06 14:25:18 +09:00
commit 0d8e42b941
69 changed files with 2025 additions and 330 deletions

View File

@ -89,9 +89,9 @@ namespace osu.Game.Rulesets.Catch.Edit
new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler })
}); });
public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition)
{ {
var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition); var result = base.FindSnappedPositionAndTime(screenSpacePosition);
result.ScreenSpacePosition.X = screenSpacePosition.X; result.ScreenSpacePosition.X = screenSpacePosition.X;
if (distanceSnapGrid.IsPresent && distanceSnapGrid.GetSnappedPosition(result.ScreenSpacePosition) is SnapResult snapResult && if (distanceSnapGrid.IsPresent && distanceSnapGrid.GetSnappedPosition(result.ScreenSpacePosition) is SnapResult snapResult &&

View File

@ -97,12 +97,12 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
set => InternalChild = value; set => InternalChild = value;
} }
public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition)
{ {
throw new System.NotImplementedException(); throw new System.NotImplementedException();
} }
public override SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) public override SnapResult FindSnappedPosition(Vector2 screenSpacePosition)
{ {
throw new System.NotImplementedException(); throw new System.NotImplementedException();
} }

View File

@ -56,9 +56,9 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) =>
Playfield.GetColumnByPosition(screenSpacePosition); Playfield.GetColumnByPosition(screenSpacePosition);
public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition)
{ {
var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition); var result = base.FindSnappedPositionAndTime(screenSpacePosition);
switch (ScrollingInfo.Direction.Value) switch (ScrollingInfo.Direction.Value)
{ {
@ -112,7 +112,7 @@ namespace osu.Game.Rulesets.Mania.Edit
} }
else else
{ {
var result = SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position); var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position);
if (result.Time is double time) if (result.Time is double time)
beatSnapGrid.SelectionTimeRange = (time, time); beatSnapGrid.SelectionTimeRange = (time, time);
else else

View File

@ -182,10 +182,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private class SnapProvider : IDistanceSnapProvider private class SnapProvider : IDistanceSnapProvider
{ {
public SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => public SnapResult FindSnappedPosition(Vector2 screenSpacePosition) =>
new SnapResult(screenSpacePosition, null); new SnapResult(screenSpacePosition, null);
public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0);
public IBindable<double> DistanceSpacingMultiplier { get; } = new BindableDouble(1); public IBindable<double> DistanceSpacingMultiplier { get; } = new BindableDouble(1);
@ -195,9 +195,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
public double DistanceToDuration(HitObject referenceObject, float distance) => distance; public double DistanceToDuration(HitObject referenceObject, float distance) => distance;
public double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) => 0; public double FindSnappedDuration(HitObject referenceObject, float distance) => 0;
public float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) => 0; public float FindSnappedDistance(HitObject referenceObject, float distance) => 0;
} }
} }
} }

View File

@ -38,7 +38,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
double difficulty = 0; double difficulty = 0;
double weight = 1; double weight = 1;
List<double> strains = GetCurrentStrainPeaks().OrderByDescending(d => d).ToList(); // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
var peaks = GetCurrentStrainPeaks().Where(p => p > 0);
List<double> strains = peaks.OrderByDescending(d => d).ToList();
// We are reducing the highest strains first to account for extreme difficulty spikes // We are reducing the highest strains first to account for extreme difficulty spikes
for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++) for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++)

View File

@ -255,7 +255,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{ {
// Special handling for selections containing head control point - the position of the slider changes which means the snapped position and time have to be taken into account // Special handling for selections containing head control point - the position of the slider changes which means the snapped position and time have to be taken into account
Vector2 newHeadPosition = Parent.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex])); Vector2 newHeadPosition = Parent.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex]));
var result = snapProvider?.SnapScreenSpacePositionToValidTime(newHeadPosition); var result = snapProvider?.FindSnappedPositionAndTime(newHeadPosition);
Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - slider.Position; Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - slider.Position;

View File

@ -220,7 +220,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void updateSlider() private void updateSlider()
{ {
HitObject.Path.ExpectedDistance.Value = snapProvider?.GetSnappedDistanceFromDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; HitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance;
bodyPiece.UpdateFrom(HitObject); bodyPiece.UpdateFrom(HitObject);
headCirclePiece.UpdateFrom(HitObject.HeadCircle); headCirclePiece.UpdateFrom(HitObject.HeadCircle);

View File

@ -59,11 +59,6 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
LayerBelowRuleset.AddRange(new Drawable[] LayerBelowRuleset.AddRange(new Drawable[]
{ {
new PlayfieldBorder
{
RelativeSizeAxes = Axes.Both,
PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners }
},
distanceSnapGridContainer = new Container distanceSnapGridContainer = new Container
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
@ -128,7 +123,7 @@ namespace osu.Game.Rulesets.Osu.Edit
} }
} }
public override SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) public override SnapResult FindSnappedPosition(Vector2 screenSpacePosition)
{ {
if (snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) if (snapToVisibleBlueprints(screenSpacePosition, out var snapResult))
return snapResult; return snapResult;
@ -136,9 +131,9 @@ namespace osu.Game.Rulesets.Osu.Edit
return new SnapResult(screenSpacePosition, null); return new SnapResult(screenSpacePosition, null);
} }
public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition)
{ {
var positionSnap = SnapScreenSpacePositionToValidPosition(screenSpacePosition); var positionSnap = FindSnappedPosition(screenSpacePosition);
if (positionSnap.ScreenSpacePosition != screenSpacePosition) if (positionSnap.ScreenSpacePosition != screenSpacePosition)
return positionSnap; return positionSnap;
@ -154,7 +149,7 @@ namespace osu.Game.Rulesets.Osu.Edit
return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition)); return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition));
} }
return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); return base.FindSnappedPositionAndTime(screenSpacePosition);
} }
private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult)

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override string Acronym => @"AL"; public override string Acronym => @"AL";
public override string Description => @"Don't use the same key twice in a row!"; public override string Description => @"Don't use the same key twice in a row!";
public override double ScoreMultiplier => 1.0; public override double ScoreMultiplier => 1.0;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay) }; public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax) };
public override ModType Type => ModType.Conversion; public override ModType Type => ModType.Conversion;
public override IconUsage? Icon => FontAwesome.Solid.Keyboard; public override IconUsage? Icon => FontAwesome.Solid.Keyboard;

View File

@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{ {
public class OsuModAutoplay : ModAutoplay public class OsuModAutoplay : ModAutoplay
{ {
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray(); public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods) public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });

View File

@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{ {
public class OsuModCinema : ModCinema<OsuHitObject> public class OsuModCinema : ModCinema<OsuHitObject>
{ {
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray(); public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods) public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToPlayer public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToPlayer
{ {
public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things.";
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised) }).ToArray(); public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate) }).ToArray();
/// <summary> /// <summary>
/// How early before a hitobject's start time to trigger a hit. /// How early before a hitobject's start time to trigger a hit.

View File

@ -4,7 +4,6 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@ -23,9 +22,8 @@ namespace osu.Game.Rulesets.Osu.Mods
{ {
public override string Name => @"Strict Tracking"; public override string Name => @"Strict Tracking";
public override string Acronym => @"ST"; public override string Acronym => @"ST";
public override IconUsage? Icon => FontAwesome.Solid.PenFancy;
public override ModType Type => ModType.DifficultyIncrease; public override ModType Type => ModType.DifficultyIncrease;
public override string Description => @"Follow circles just got serious..."; public override string Description => @"Once you start a slider, follow precisely or get a miss.";
public override double ScoreMultiplier => 1.0; public override double ScoreMultiplier => 1.0;
public override Type[] IncompatibleMods => new[] { typeof(ModClassic), typeof(OsuModTarget) }; public override Type[] IncompatibleMods => new[] { typeof(ModClassic), typeof(OsuModTarget) };

View File

@ -141,7 +141,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
double colourPeak = colourPeaks[i] * colour_skill_multiplier; double colourPeak = colourPeaks[i] * colour_skill_multiplier;
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier;
double staminaPeak = (staminaRightPeaks[i] + staminaLeftPeaks[i]) * stamina_skill_multiplier * staminaPenalty; double staminaPeak = (staminaRightPeaks[i] + staminaLeftPeaks[i]) * stamina_skill_multiplier * staminaPenalty;
peaks.Add(norm(2, colourPeak, rhythmPeak, staminaPeak));
double peak = norm(2, colourPeak, rhythmPeak, staminaPeak);
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
if (peak > 0)
peaks.Add(peak);
} }
double difficulty = 0; double difficulty = 0;

View File

@ -213,10 +213,10 @@ namespace osu.Game.Tests.Editing
=> AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(new HitObject(), distance) == expectedDuration); => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(new HitObject(), distance) == expectedDuration);
private void assertSnappedDuration(float distance, double expectedDuration) private void assertSnappedDuration(float distance, double expectedDuration)
=> AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.GetSnappedDurationFromDistance(new HitObject(), distance) == expectedDuration); => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.FindSnappedDuration(new HitObject(), distance) == expectedDuration);
private void assertSnappedDistance(float distance, float expectedDistance) private void assertSnappedDistance(float distance, float expectedDistance)
=> AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.GetSnappedDistanceFromDistance(new HitObject(), distance) == expectedDistance); => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.FindSnappedDistance(new HitObject(), distance) == expectedDistance);
private class TestHitObjectComposer : OsuHitObjectComposer private class TestHitObjectComposer : OsuHitObjectComposer
{ {

View File

@ -162,10 +162,10 @@ namespace osu.Game.Tests.Visual.Editing
private class SnapProvider : IDistanceSnapProvider private class SnapProvider : IDistanceSnapProvider
{ {
public SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => public SnapResult FindSnappedPosition(Vector2 screenSpacePosition) =>
new SnapResult(screenSpacePosition, null); new SnapResult(screenSpacePosition, null);
public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0);
public IBindable<double> DistanceSpacingMultiplier { get; } = new BindableDouble(1); public IBindable<double> DistanceSpacingMultiplier { get; } = new BindableDouble(1);
@ -175,9 +175,9 @@ namespace osu.Game.Tests.Visual.Editing
public double DistanceToDuration(HitObject referenceObject, float distance) => distance; public double DistanceToDuration(HitObject referenceObject, float distance) => distance;
public double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) => 0; public double FindSnappedDuration(HitObject referenceObject, float distance) => 0;
public float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) => 0; public float FindSnappedDistance(HitObject referenceObject, float distance) => 0;
} }
} }
} }

View File

@ -19,6 +19,7 @@ using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Components.RadioButtons;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
using osuTK; using osuTK;
using osuTK.Input; using osuTK.Input;
@ -70,6 +71,11 @@ namespace osu.Game.Tests.Visual.Editing
Child = editorBeatmapContainer = new EditorBeatmapContainer(Beatmap.Value) Child = editorBeatmapContainer = new EditorBeatmapContainer(Beatmap.Value)
{ {
Child = hitObjectComposer = new OsuHitObjectComposer(new OsuRuleset()) Child = hitObjectComposer = new OsuHitObjectComposer(new OsuRuleset())
{
// force the composer to fully overlap the playfield area by setting a 4:3 aspect ratio.
FillMode = FillMode.Fit,
FillAspectRatio = 4 / 3f
}
}; };
}); });
} }
@ -87,6 +93,65 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("Tool changed", () => hitObjectComposer.ChildrenOfType<ComposeBlueprintContainer>().First().CurrentTool is HitCircleCompositionTool); AddAssert("Tool changed", () => hitObjectComposer.ChildrenOfType<ComposeBlueprintContainer>().First().CurrentTool is HitCircleCompositionTool);
} }
[Test]
public void TestPlacementFailsWhenClickingButton()
{
AddStep("clear all control points and hitobjects", () =>
{
editorBeatmap.ControlPointInfo.Clear();
editorBeatmap.Clear();
});
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").TriggerClick());
AddStep("move mouse to overlapping toggle button", () =>
{
var playfield = hitObjectComposer.Playfield.ScreenSpaceDrawQuad;
var button = hitObjectComposer
.ChildrenOfType<ExpandingToolboxContainer>().First()
.ChildrenOfType<DrawableTernaryButton>().First(b => playfield.Contains(b.ScreenSpaceDrawQuad.Centre));
InputManager.MoveMouseTo(button);
});
AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0);
AddStep("attempt place circle", () => InputManager.Click(MouseButton.Left));
AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0);
}
[Test]
public void TestPlacementWithinToolboxScrollArea()
{
AddStep("clear all control points and hitobjects", () =>
{
editorBeatmap.ControlPointInfo.Clear();
editorBeatmap.Clear();
});
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").TriggerClick());
AddStep("move mouse to scroll area", () =>
{
// Specifically wanting to test the area of overlap between the left expanding toolbox container
// and the playfield/composer.
var scrollArea = hitObjectComposer.ChildrenOfType<ExpandingToolboxContainer>().First().ScreenSpaceDrawQuad;
var playfield = hitObjectComposer.Playfield.ScreenSpaceDrawQuad;
InputManager.MoveMouseTo(new Vector2(scrollArea.TopLeft.X + 1, playfield.Centre.Y));
});
AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0);
AddStep("place circle", () => InputManager.Click(MouseButton.Left));
AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1);
}
[Test] [Test]
public void TestDistanceSpacingHotkeys() public void TestDistanceSpacingHotkeys()
{ {

View File

@ -56,6 +56,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("click", () => InputManager.Click(MouseButton.Left)); AddStep("click", () => InputManager.Click(MouseButton.Left));
AddAssert("no item selected", () => playlist.SelectedItem.Value == null); AddAssert("no item selected", () => playlist.SelectedItem.Value == null);
AddStep("press down", () => InputManager.Key(Key.Down));
AddAssert("no item selected", () => playlist.SelectedItem.Value == null);
} }
[Test] [Test]
@ -73,6 +76,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("click", () => InputManager.Click(MouseButton.Left)); AddStep("click", () => InputManager.Click(MouseButton.Left));
AddAssert("no item selected", () => playlist.SelectedItem.Value == null); AddAssert("no item selected", () => playlist.SelectedItem.Value == null);
AddStep("press down", () => InputManager.Key(Key.Down));
AddAssert("no item selected", () => playlist.SelectedItem.Value == null);
} }
[Test] [Test]
@ -91,6 +97,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("click", () => InputManager.Click(MouseButton.Left)); AddStep("click", () => InputManager.Click(MouseButton.Left));
AddAssert("no item selected", () => playlist.SelectedItem.Value == null); AddAssert("no item selected", () => playlist.SelectedItem.Value == null);
AddStep("press down", () => InputManager.Key(Key.Down));
AddAssert("no item selected", () => playlist.SelectedItem.Value == null);
} }
[Test] [Test]
@ -147,6 +156,40 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("item 1 is selected", () => playlist.SelectedItem.Value == playlist.Items[1]); AddAssert("item 1 is selected", () => playlist.SelectedItem.Value == playlist.Items[1]);
} }
[Test]
public void TestKeyboardSelection()
{
createPlaylist(p => p.AllowSelection = true);
AddStep("press down", () => InputManager.Key(Key.Down));
AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]);
AddStep("press down", () => InputManager.Key(Key.Down));
AddAssert("item 1 is selected", () => playlist.SelectedItem.Value == playlist.Items[1]);
AddStep("press up", () => InputManager.Key(Key.Up));
AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]);
AddUntilStep("navigate to last item via keyboard", () =>
{
InputManager.Key(Key.Down);
return playlist.SelectedItem.Value == playlist.Items.Last();
});
AddAssert("last item is selected", () => playlist.SelectedItem.Value == playlist.Items.Last());
AddUntilStep("last item is scrolled into view", () =>
{
var drawableItem = playlist.ItemMap[playlist.Items.Last()];
return playlist.ScreenSpaceDrawQuad.Contains(drawableItem.ScreenSpaceDrawQuad.TopLeft)
&& playlist.ScreenSpaceDrawQuad.Contains(drawableItem.ScreenSpaceDrawQuad.BottomRight);
});
AddStep("press down", () => InputManager.Key(Key.Down));
AddAssert("last item is selected", () => playlist.SelectedItem.Value == playlist.Items.Last());
AddStep("press up", () => InputManager.Key(Key.Up));
AddAssert("second last item is selected", () => playlist.SelectedItem.Value == playlist.Items.Reverse().ElementAt(1));
}
[Test] [Test]
public void TestDownloadButtonHiddenWhenBeatmapExists() public void TestDownloadButtonHiddenWhenBeatmapExists()
{ {

View File

@ -0,0 +1,422 @@
// 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 System.Collections.Generic;
using System.Net;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Overlays;
using osu.Game.Overlays.Chat;
using osu.Game.Overlays.Chat.Listing;
using osu.Game.Overlays.Chat.ChannelList;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Online
{
[TestFixture]
public class TestSceneChatOverlayV2 : OsuManualInputManagerTestScene
{
private ChatOverlayV2 chatOverlay;
private ChannelManager channelManager;
private APIUser testUser;
private Channel testPMChannel;
private Channel[] testChannels;
private Channel testChannel1 => testChannels[0];
private Channel testChannel2 => testChannels[1];
[Resolved]
private OsuConfigManager config { get; set; } = null!;
[SetUp]
public void SetUp() => Schedule(() =>
{
testUser = new APIUser { Username = "test user", Id = 5071479 };
testPMChannel = new Channel(testUser);
testChannels = Enumerable.Range(1, 10).Select(createPublicChannel).ToArray();
Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies = new (Type, object)[]
{
(typeof(ChannelManager), channelManager = new ChannelManager()),
},
Children = new Drawable[]
{
channelManager,
chatOverlay = new ChatOverlayV2 { RelativeSizeAxes = Axes.Both },
},
};
});
[SetUpSteps]
public void SetUpSteps()
{
AddStep("Setup request handler", () =>
{
((DummyAPIAccess)API).HandleRequest = req =>
{
switch (req)
{
case GetUpdatesRequest getUpdates:
getUpdates.TriggerFailure(new WebException());
return true;
case JoinChannelRequest joinChannel:
joinChannel.TriggerSuccess();
return true;
case LeaveChannelRequest leaveChannel:
leaveChannel.TriggerSuccess();
return true;
case GetMessagesRequest getMessages:
getMessages.TriggerSuccess(createChannelMessages(getMessages.Channel));
return true;
case GetUserRequest getUser:
if (getUser.Lookup == testUser.Username)
getUser.TriggerSuccess(testUser);
else
getUser.TriggerFailure(new WebException());
return true;
case PostMessageRequest postMessage:
postMessage.TriggerSuccess(new Message(RNG.Next(0, 10000000))
{
Content = postMessage.Message.Content,
ChannelId = postMessage.Message.ChannelId,
Sender = postMessage.Message.Sender,
Timestamp = new DateTimeOffset(DateTime.Now),
});
return true;
default:
Logger.Log($"Unhandled Request Type: {req.GetType()}");
return false;
}
};
});
AddStep("Add test channels", () =>
{
(channelManager.AvailableChannels as BindableList<Channel>)?.AddRange(testChannels);
});
}
[Test]
public void TestShowHide()
{
AddStep("Show overlay", () => chatOverlay.Show());
AddAssert("Overlay is visible", () => chatOverlay.State.Value == Visibility.Visible);
AddStep("Hide overlay", () => chatOverlay.Hide());
AddAssert("Overlay is hidden", () => chatOverlay.State.Value == Visibility.Hidden);
}
[Test]
public void TestChatHeight()
{
BindableFloat configChatHeight = new BindableFloat();
config.BindWith(OsuSetting.ChatDisplayHeight, configChatHeight);
float newHeight = 0;
AddStep("Reset config chat height", () => configChatHeight.SetDefault());
AddStep("Show overlay", () => chatOverlay.Show());
AddAssert("Overlay uses config height", () => chatOverlay.Height == configChatHeight.Default);
AddStep("Click top bar", () =>
{
InputManager.MoveMouseTo(chatOverlayTopBar);
InputManager.PressButton(MouseButton.Left);
});
AddStep("Drag overlay to new height", () => InputManager.MoveMouseTo(chatOverlayTopBar, new Vector2(0, -300)));
AddStep("Stop dragging", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("Store new height", () => newHeight = chatOverlay.Height);
AddAssert("Config height changed", () => !configChatHeight.IsDefault && configChatHeight.Value == newHeight);
AddStep("Hide overlay", () => chatOverlay.Hide());
AddStep("Show overlay", () => chatOverlay.Show());
AddAssert("Overlay uses new height", () => chatOverlay.Height == newHeight);
}
[Test]
public void TestChannelSelection()
{
AddStep("Show overlay", () => chatOverlay.Show());
AddAssert("Listing is visible", () => listingVisibility == Visibility.Visible);
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddAssert("Listing is hidden", () => listingVisibility == Visibility.Hidden);
AddAssert("Loading is hidden", () => loadingVisibility == Visibility.Hidden);
AddAssert("Current channel is correct", () => channelManager.CurrentChannel.Value == testChannel1);
AddAssert("DrawableChannel is correct", () => currentDrawableChannel.Channel == testChannel1);
}
[Test]
public void TestSearchInListing()
{
AddStep("Show overlay", () => chatOverlay.Show());
AddAssert("Listing is visible", () => listingVisibility == Visibility.Visible);
AddStep("Search for 'number 2'", () => chatOverlayTextBox.Text = "number 2");
AddUntilStep("Only channel 2 visibile", () =>
{
IEnumerable<ChannelListingItem> listingItems = chatOverlay.ChildrenOfType<ChannelListingItem>()
.Where(item => item.IsPresent);
return listingItems.Count() == 1 && listingItems.Single().Channel == testChannel2;
});
}
[Test]
public void TestChannelCloseButton()
{
AddStep("Show overlay", () => chatOverlay.Show());
AddStep("Join PM and public channels", () =>
{
channelManager.JoinChannel(testChannel1);
channelManager.JoinChannel(testPMChannel);
});
AddStep("Select PM channel", () => clickDrawable(getChannelListItem(testPMChannel)));
AddStep("Click close button", () =>
{
ChannelListItemCloseButton closeButton = getChannelListItem(testPMChannel).ChildrenOfType<ChannelListItemCloseButton>().Single();
clickDrawable(closeButton);
});
AddAssert("PM channel closed", () => !channelManager.JoinedChannels.Contains(testPMChannel));
AddStep("Select normal channel", () => clickDrawable(getChannelListItem(testChannel1)));
AddStep("Click close button", () =>
{
ChannelListItemCloseButton closeButton = getChannelListItem(testChannel1).ChildrenOfType<ChannelListItemCloseButton>().Single();
clickDrawable(closeButton);
});
AddAssert("Normal channel closed", () => !channelManager.JoinedChannels.Contains(testChannel1));
}
[Test]
public void TestChatCommand()
{
AddStep("Show overlay", () => chatOverlay.Show());
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddStep("Open chat with user", () => channelManager.PostCommand($"chat {testUser.Username}"));
AddAssert("PM channel is selected", () =>
channelManager.CurrentChannel.Value.Type == ChannelType.PM && channelManager.CurrentChannel.Value.Users.Single() == testUser);
AddStep("Open chat with non-existent user", () => channelManager.PostCommand("chat user_doesnt_exist"));
AddAssert("Last message is error", () => channelManager.CurrentChannel.Value.Messages.Last() is ErrorMessage);
// Make sure no unnecessary requests are made when the PM channel is already open.
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddStep("Unregister request handling", () => ((DummyAPIAccess)API).HandleRequest = null);
AddStep("Open chat with user", () => channelManager.PostCommand($"chat {testUser.Username}"));
AddAssert("PM channel is selected", () =>
channelManager.CurrentChannel.Value.Type == ChannelType.PM && channelManager.CurrentChannel.Value.Users.Single() == testUser);
}
[Test]
public void TestMultiplayerChannelIsNotShown()
{
Channel multiplayerChannel = null;
AddStep("Show overlay", () => chatOverlay.Show());
AddStep("Join multiplayer channel", () => channelManager.JoinChannel(multiplayerChannel = new Channel(new APIUser())
{
Name = "#mp_1",
Type = ChannelType.Multiplayer,
}));
AddAssert("Channel is joined", () => channelManager.JoinedChannels.Contains(multiplayerChannel));
AddUntilStep("Channel not present in listing", () => !chatOverlay.ChildrenOfType<ChannelListingItem>()
.Where(item => item.IsPresent)
.Select(item => item.Channel)
.Contains(multiplayerChannel));
}
[Test]
public void TestHighlightOnCurrentChannel()
{
Message message = null;
AddStep("Show overlay", () => chatOverlay.Show());
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddStep("Send message in channel 1", () =>
{
testChannel1.AddNewMessages(message = new Message
{
ChannelId = testChannel1.Id,
Content = "Message to highlight!",
Timestamp = DateTimeOffset.Now,
Sender = testUser,
});
});
AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel1));
}
[Test]
public void TestHighlightOnAnotherChannel()
{
Message message = null;
AddStep("Show overlay", () => chatOverlay.Show());
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2));
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddStep("Send message in channel 2", () =>
{
testChannel2.AddNewMessages(message = new Message
{
ChannelId = testChannel2.Id,
Content = "Message to highlight!",
Timestamp = DateTimeOffset.Now,
Sender = testUser,
});
});
AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel2));
AddAssert("Channel 2 is selected", () => channelManager.CurrentChannel.Value == testChannel2);
AddAssert("Channel 2 is visible", () => currentDrawableChannel.Channel == testChannel2);
}
[Test]
public void TestHighlightOnLeftChannel()
{
Message message = null;
AddStep("Show overlay", () => chatOverlay.Show());
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2));
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddStep("Send message in channel 2", () =>
{
testChannel2.AddNewMessages(message = new Message
{
ChannelId = testChannel2.Id,
Content = "Message to highlight!",
Timestamp = DateTimeOffset.Now,
Sender = testUser,
});
});
AddStep("Leave channel 2", () => channelManager.LeaveChannel(testChannel2));
AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel2));
AddAssert("Channel 2 is selected", () => channelManager.CurrentChannel.Value == testChannel2);
AddAssert("Channel 2 is visible", () => currentDrawableChannel.Channel == testChannel2);
}
[Test]
public void TestHighlightWhileChatNeverOpen()
{
Message message = null;
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Send message in channel 1", () =>
{
testChannel1.AddNewMessages(message = new Message
{
ChannelId = testChannel1.Id,
Content = "Message to highlight!",
Timestamp = DateTimeOffset.Now,
Sender = testUser,
});
});
AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel1));
}
[Test]
public void TestHighlightWithNullChannel()
{
Message message = null;
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Send message in channel 1", () =>
{
testChannel1.AddNewMessages(message = new Message
{
ChannelId = testChannel1.Id,
Content = "Message to highlight!",
Timestamp = DateTimeOffset.Now,
Sender = testUser,
});
});
AddStep("Set null channel", () => channelManager.CurrentChannel.Value = null);
AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, testChannel1));
}
[Test]
public void TextBoxRetainsFocus()
{
AddStep("Show overlay", () => chatOverlay.Show());
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Click selector", () => clickDrawable(chatOverlay.ChildrenOfType<ChannelListSelector>().Single()));
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Click listing", () => clickDrawable(chatOverlay.ChildrenOfType<ChannelListing>().Single()));
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Click drawable channel", () => clickDrawable(chatOverlay.ChildrenOfType<DrawableChannel>().Single()));
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Click channel list", () => clickDrawable(chatOverlay.ChildrenOfType<ChannelList>().Single()));
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Click top bar", () => clickDrawable(chatOverlay.ChildrenOfType<ChatOverlayTopBar>().Single()));
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Hide overlay", () => chatOverlay.Hide());
AddAssert("TextBox is not focused", () => InputManager.FocusedDrawable == null);
}
private Visibility listingVisibility =>
chatOverlay.ChildrenOfType<ChannelListing>().Single().State.Value;
private Visibility loadingVisibility =>
chatOverlay.ChildrenOfType<LoadingLayer>().Single().State.Value;
private DrawableChannel currentDrawableChannel =>
chatOverlay.ChildrenOfType<Container<DrawableChannel>>().Single().Child;
private ChannelListItem getChannelListItem(Channel channel) =>
chatOverlay.ChildrenOfType<ChannelListItem>().Single(item => item.Channel == channel);
private ChatTextBox chatOverlayTextBox =>
chatOverlay.ChildrenOfType<ChatTextBox>().Single();
private ChatOverlayTopBar chatOverlayTopBar =>
chatOverlay.ChildrenOfType<ChatOverlayTopBar>().Single();
private void clickDrawable(Drawable d)
{
InputManager.MoveMouseTo(d);
InputManager.Click(MouseButton.Left);
}
private List<Message> createChannelMessages(Channel channel)
{
var message = new Message
{
ChannelId = channel.Id,
Content = $"Hello, this is a message in {channel.Name}",
Sender = testUser,
Timestamp = new DateTimeOffset(DateTime.Now),
};
return new List<Message> { message };
}
private Channel createPublicChannel(int id) => new Channel
{
Id = id,
Name = $"#channel-{id}",
Topic = $"We talk about the number {id} here",
Type = ChannelType.Public,
};
}
}

View File

@ -1,10 +1,12 @@
// 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.
using System;
using System.Threading;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Dialog;
@ -15,15 +17,11 @@ namespace osu.Game.Tests.Visual.UserInterface
{ {
private DialogOverlay overlay; private DialogOverlay overlay;
[SetUpSteps]
public void SetUpSteps()
{
AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay());
}
[Test] [Test]
public void TestBasic() public void TestBasic()
{ {
AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay());
TestPopupDialog firstDialog = null; TestPopupDialog firstDialog = null;
TestPopupDialog secondDialog = null; TestPopupDialog secondDialog = null;
@ -37,12 +35,12 @@ namespace osu.Game.Tests.Visual.UserInterface
new PopupDialogOkButton new PopupDialogOkButton
{ {
Text = @"I never want to see this again.", Text = @"I never want to see this again.",
Action = () => System.Console.WriteLine(@"OK"), Action = () => Console.WriteLine(@"OK"),
}, },
new PopupDialogCancelButton new PopupDialogCancelButton
{ {
Text = @"Firetruck, I still want quick ranks!", Text = @"Firetruck, I still want quick ranks!",
Action = () => System.Console.WriteLine(@"Cancel"), Action = () => Console.WriteLine(@"Cancel"),
}, },
}, },
})); }));
@ -87,9 +85,49 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert("first dialog is not part of hierarchy", () => firstDialog.Parent == null); AddAssert("first dialog is not part of hierarchy", () => firstDialog.Parent == null);
} }
[Test]
public void TestPushBeforeLoad()
{
PopupDialog dialog = null;
AddStep("create dialog overlay", () => overlay = new SlowLoadingDialogOverlay());
AddStep("start loading overlay", () => LoadComponentAsync(overlay, Add));
AddStep("push dialog before loaded", () =>
{
overlay.Push(dialog = new TestPopupDialog
{
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton { Text = @"OK" },
},
});
});
AddStep("complete load", () => ((SlowLoadingDialogOverlay)overlay).LoadEvent.Set());
AddUntilStep("wait for load", () => overlay.IsLoaded);
AddAssert("dialog displayed", () => overlay.CurrentDialog == dialog);
}
public class SlowLoadingDialogOverlay : DialogOverlay
{
public ManualResetEventSlim LoadEvent = new ManualResetEventSlim();
[BackgroundDependencyLoader]
private void load()
{
LoadEvent.Wait(10000);
}
}
[Test] [Test]
public void TestDismissBeforePush() public void TestDismissBeforePush()
{ {
AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay());
TestPopupDialog testDialog = null; TestPopupDialog testDialog = null;
AddStep("dismissed dialog push", () => AddStep("dismissed dialog push", () =>
{ {
@ -106,6 +144,8 @@ namespace osu.Game.Tests.Visual.UserInterface
[Test] [Test]
public void TestDismissBeforePushViaButtonPress() public void TestDismissBeforePushViaButtonPress()
{ {
AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay());
TestPopupDialog testDialog = null; TestPopupDialog testDialog = null;
AddStep("dismissed dialog push", () => AddStep("dismissed dialog push", () =>
{ {

View File

@ -2,17 +2,21 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
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.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Mods; using osu.Game.Overlays.Mods;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osuTK.Input; using osuTK.Input;
@ -64,6 +68,7 @@ namespace osu.Game.Tests.Visual.UserInterface
return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType<DifficultyMultiplierDisplay>().Single().Current.Value); return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType<DifficultyMultiplierDisplay>().Single().Current.Value);
}); });
assertCustomisationToggleState(disabled: false, active: false); assertCustomisationToggleState(disabled: false, active: false);
AddAssert("setting items created", () => modSelectScreen.ChildrenOfType<ISettingsItem>().Any());
} }
[Test] [Test]
@ -78,6 +83,7 @@ namespace osu.Game.Tests.Visual.UserInterface
return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType<DifficultyMultiplierDisplay>().Single().Current.Value); return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType<DifficultyMultiplierDisplay>().Single().Current.Value);
}); });
assertCustomisationToggleState(disabled: false, active: false); assertCustomisationToggleState(disabled: false, active: false);
AddAssert("setting items created", () => modSelectScreen.ChildrenOfType<ISettingsItem>().Any());
} }
[Test] [Test]
@ -98,17 +104,25 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("activate DT", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick()); AddStep("activate DT", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick());
AddAssert("DT active", () => SelectedMods.Value.Single().GetType() == typeof(OsuModDoubleTime)); AddAssert("DT active", () => SelectedMods.Value.Single().GetType() == typeof(OsuModDoubleTime));
AddAssert("DT panel active", () => getPanelForMod(typeof(OsuModDoubleTime)).Active.Value);
AddStep("activate NC", () => getPanelForMod(typeof(OsuModNightcore)).TriggerClick()); AddStep("activate NC", () => getPanelForMod(typeof(OsuModNightcore)).TriggerClick());
AddAssert("only NC active", () => SelectedMods.Value.Single().GetType() == typeof(OsuModNightcore)); AddAssert("only NC active", () => SelectedMods.Value.Single().GetType() == typeof(OsuModNightcore));
AddAssert("DT panel not active", () => !getPanelForMod(typeof(OsuModDoubleTime)).Active.Value);
AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value);
AddStep("activate HR", () => getPanelForMod(typeof(OsuModHardRock)).TriggerClick()); AddStep("activate HR", () => getPanelForMod(typeof(OsuModHardRock)).TriggerClick());
AddAssert("NC+HR active", () => SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModNightcore)) AddAssert("NC+HR active", () => SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModNightcore))
&& SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModHardRock))); && SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModHardRock)));
AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value);
AddAssert("HR panel active", () => getPanelForMod(typeof(OsuModHardRock)).Active.Value);
AddStep("activate MR", () => getPanelForMod(typeof(OsuModMirror)).TriggerClick()); AddStep("activate MR", () => getPanelForMod(typeof(OsuModMirror)).TriggerClick());
AddAssert("NC+MR active", () => SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModNightcore)) AddAssert("NC+MR active", () => SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModNightcore))
&& SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModMirror))); && SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModMirror)));
AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value);
AddAssert("HR panel not active", () => !getPanelForMod(typeof(OsuModHardRock)).Active.Value);
AddAssert("MR panel active", () => getPanelForMod(typeof(OsuModMirror)).Active.Value);
} }
[Test] [Test]
@ -150,11 +164,19 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() });
assertCustomisationToggleState(disabled: false, active: true); assertCustomisationToggleState(disabled: false, active: true);
AddStep("dismiss mod customisation", () => AddStep("dismiss mod customisation via mouse", () =>
{ {
InputManager.MoveMouseTo(modSelectScreen.ChildrenOfType<ShearedToggleButton>().Single()); InputManager.MoveMouseTo(modSelectScreen.ChildrenOfType<ShearedToggleButton>().Single());
InputManager.Click(MouseButton.Left); InputManager.Click(MouseButton.Left);
}); });
assertCustomisationToggleState(disabled: false, active: false);
AddStep("reset mods", () => SelectedMods.SetDefault());
AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() });
assertCustomisationToggleState(disabled: false, active: true);
AddStep("dismiss mod customisation via keyboard", () => InputManager.Key(Key.Escape));
assertCustomisationToggleState(disabled: false, active: false);
AddStep("append another mod not requiring config", () => SelectedMods.Value = SelectedMods.Value.Append(new OsuModFlashlight()).ToArray()); AddStep("append another mod not requiring config", () => SelectedMods.Value = SelectedMods.Value.Append(new OsuModFlashlight()).ToArray());
assertCustomisationToggleState(disabled: false, active: false); assertCustomisationToggleState(disabled: false, active: false);
@ -169,6 +191,206 @@ namespace osu.Game.Tests.Visual.UserInterface
assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action. assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action.
} }
/// <summary>
/// Ensure that two mod overlays are not cross polluting via central settings instances.
/// </summary>
[Test]
public void TestSettingsNotCrossPolluting()
{
Bindable<IReadOnlyList<Mod>> selectedMods2 = null;
ModSelectScreen modSelectScreen2 = null;
createScreen();
AddStep("select diff adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() });
AddStep("set setting", () => modSelectScreen.ChildrenOfType<SettingsSlider<float>>().First().Current.Value = 8);
AddAssert("ensure setting is propagated", () => SelectedMods.Value.OfType<OsuModDifficultyAdjust>().Single().CircleSize.Value == 8);
AddStep("create second bindable", () => selectedMods2 = new Bindable<IReadOnlyList<Mod>>(new Mod[] { new OsuModDifficultyAdjust() }));
AddStep("create second overlay", () =>
{
Add(modSelectScreen2 = new UserModSelectScreen().With(d =>
{
d.Origin = Anchor.TopCentre;
d.Anchor = Anchor.TopCentre;
d.SelectedMods.BindTarget = selectedMods2;
}));
});
AddStep("show", () => modSelectScreen2.Show());
AddAssert("ensure first is unchanged", () => SelectedMods.Value.OfType<OsuModDifficultyAdjust>().Single().CircleSize.Value == 8);
AddAssert("ensure second is default", () => selectedMods2.Value.OfType<OsuModDifficultyAdjust>().Single().CircleSize.Value == null);
}
[Test]
public void TestSettingsResetOnDeselection()
{
var osuModDoubleTime = new OsuModDoubleTime { SpeedChange = { Value = 1.2 } };
createScreen();
changeRuleset(0);
AddStep("set dt mod with custom rate", () => { SelectedMods.Value = new[] { osuModDoubleTime }; });
AddAssert("selected mod matches", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.Value == 1.2);
AddStep("deselect", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick());
AddAssert("selected mods empty", () => SelectedMods.Value.Count == 0);
AddStep("reselect", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick());
AddAssert("selected mod has default value", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.IsDefault == true);
}
[Test]
public void TestAnimationFlushOnClose()
{
createScreen();
changeRuleset(0);
AddStep("Select all fun mods", () =>
{
modSelectScreen.ChildrenOfType<ModColumn>()
.Single(c => c.ModType == ModType.DifficultyIncrease)
.SelectAll();
});
AddUntilStep("many mods selected", () => SelectedMods.Value.Count >= 5);
AddStep("trigger deselect and close overlay", () =>
{
modSelectScreen.ChildrenOfType<ModColumn>()
.Single(c => c.ModType == ModType.DifficultyIncrease)
.DeselectAll();
modSelectScreen.Hide();
});
AddAssert("all mods deselected", () => SelectedMods.Value.Count == 0);
}
[Test]
public void TestRulesetChanges()
{
createScreen();
changeRuleset(0);
var noFailMod = new OsuRuleset().GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail);
AddStep("set mods externally", () => { SelectedMods.Value = new[] { noFailMod }; });
changeRuleset(0);
AddAssert("ensure mods still selected", () => SelectedMods.Value.SingleOrDefault(m => m is OsuModNoFail) != null);
changeRuleset(3);
AddAssert("ensure mods not selected", () => SelectedMods.Value.Count == 0);
changeRuleset(0);
AddAssert("ensure mods not selected", () => SelectedMods.Value.Count == 0);
}
[Test]
public void TestExternallySetCustomizedMod()
{
createScreen();
changeRuleset(0);
AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } });
AddAssert("ensure button is selected and customized accordingly", () =>
{
var button = getPanelForMod(SelectedMods.Value.Single().GetType());
return ((OsuModDoubleTime)button.Mod).SpeedChange.Value == 1.01;
});
}
[Test]
public void TestSettingsAreRetainedOnReload()
{
createScreen();
changeRuleset(0);
AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } });
AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01);
createScreen();
AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01);
}
[Test]
public void TestExternallySetModIsReplacedByOverlayInstance()
{
Mod external = new OsuModDoubleTime();
Mod overlayButtonMod = null;
createScreen();
changeRuleset(0);
AddStep("set mod externally", () => { SelectedMods.Value = new[] { external }; });
AddAssert("ensure button is selected", () =>
{
var button = getPanelForMod(SelectedMods.Value.Single().GetType());
overlayButtonMod = button.Mod;
return button.Active.Value;
});
// Right now, when an external change occurs, the ModSelectOverlay will replace the global instance with its own
AddAssert("mod instance doesn't match", () => external != overlayButtonMod);
AddAssert("one mod present in global selected", () => SelectedMods.Value.Count == 1);
AddAssert("globally selected matches button's mod instance", () => SelectedMods.Value.Any(mod => ReferenceEquals(mod, overlayButtonMod)));
AddAssert("globally selected doesn't contain original external change", () => !SelectedMods.Value.Any(mod => ReferenceEquals(mod, external)));
}
[Test]
public void TestChangeIsValidChangesButtonVisibility()
{
createScreen();
changeRuleset(0);
AddAssert("double time visible", () => modSelectScreen.ChildrenOfType<ModPanel>().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value));
AddStep("make double time invalid", () => modSelectScreen.IsValidMod = m => !(m is OsuModDoubleTime));
AddUntilStep("double time not visible", () => modSelectScreen.ChildrenOfType<ModPanel>().Where(panel => panel.Mod is OsuModDoubleTime).All(panel => panel.Filtered.Value));
AddAssert("nightcore still visible", () => modSelectScreen.ChildrenOfType<ModPanel>().Where(panel => panel.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value));
AddStep("make double time valid again", () => modSelectScreen.IsValidMod = m => true);
AddUntilStep("double time visible", () => modSelectScreen.ChildrenOfType<ModPanel>().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value));
AddAssert("nightcore still visible", () => modSelectScreen.ChildrenOfType<ModPanel>().Where(b => b.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value));
}
[Test]
public void TestChangeIsValidPreservesSelection()
{
createScreen();
changeRuleset(0);
AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() });
AddAssert("DT + HD selected", () => modSelectScreen.ChildrenOfType<ModPanel>().Count(panel => panel.Active.Value) == 2);
AddStep("make NF invalid", () => modSelectScreen.IsValidMod = m => !(m is ModNoFail));
AddAssert("DT + HD still selected", () => modSelectScreen.ChildrenOfType<ModPanel>().Count(panel => panel.Active.Value) == 2);
}
[Test]
public void TestUnimplementedModIsUnselectable()
{
var testRuleset = new TestUnimplementedModOsuRuleset();
createScreen();
AddStep("set ruleset", () => Ruleset.Value = testRuleset.RulesetInfo);
waitForColumnLoad();
AddAssert("unimplemented mod panel is filtered", () => getPanelForMod(typeof(TestUnimplementedMod)).Filtered.Value);
}
private void waitForColumnLoad() => AddUntilStep("all column content loaded", private void waitForColumnLoad() => AddUntilStep("all column content loaded",
() => modSelectScreen.ChildrenOfType<ModColumn>().Any() && modSelectScreen.ChildrenOfType<ModColumn>().All(column => column.IsLoaded && column.ItemsLoaded)); () => modSelectScreen.ChildrenOfType<ModColumn>().Any() && modSelectScreen.ChildrenOfType<ModColumn>().All(column => column.IsLoaded && column.ItemsLoaded));
@ -188,5 +410,26 @@ namespace osu.Game.Tests.Visual.UserInterface
private ModPanel getPanelForMod(Type modType) private ModPanel getPanelForMod(Type modType)
=> modSelectScreen.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.GetType() == modType); => modSelectScreen.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.GetType() == modType);
private class TestUnimplementedMod : Mod
{
public override string Name => "Unimplemented mod";
public override string Acronym => "UM";
public override string Description => "A mod that is not implemented.";
public override double ScoreMultiplier => 1;
public override ModType Type => ModType.Conversion;
}
private class TestUnimplementedModOsuRuleset : OsuRuleset
{
public override string ShortName => "unimplemented";
public override IEnumerable<Mod> GetModsFor(ModType type)
{
if (type == ModType.Conversion) return base.GetModsFor(type).Concat(new[] { new TestUnimplementedMod() });
return base.GetModsFor(type);
}
}
} }
} }

View File

@ -0,0 +1,46 @@
// 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 NUnit.Framework;
using osu.Framework.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
namespace osu.Game.Tests.Visual.UserInterface
{
public class TestSceneShearedSearchTextBox : OsuTestScene
{
[Test]
public void TestAllColourSchemes()
{
foreach (var scheme in Enum.GetValues(typeof(OverlayColourScheme)).Cast<OverlayColourScheme>())
AddStep($"set {scheme} scheme", () => Child = createContent(scheme));
}
private Drawable createContent(OverlayColourScheme colourScheme)
{
var colourProvider = new OverlayColourProvider(colourScheme);
return new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies = new (Type, object)[]
{
(typeof(OverlayColourProvider), colourProvider)
},
Children = new Drawable[]
{
new ShearedSearchTextBox
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
RelativeSizeAxes = Axes.X,
Width = 0.5f
}
}
};
}
}
}

View File

@ -1,21 +1,23 @@
// 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 enable
using System; using System;
using System.Linq; using System.Linq;
using JetBrains.Annotations; using JetBrains.Annotations;
using Newtonsoft.Json; using Newtonsoft.Json;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Models; using osu.Game.Models;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Overlays.BeatmapSet.Scores;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Scoring; using osu.Game.Scoring;
using Realms; using Realms;
#nullable enable
namespace osu.Game.Beatmaps namespace osu.Game.Beatmaps
{ {
/// <summary> /// <summary>
@ -109,6 +111,16 @@ namespace osu.Game.Beatmaps
public bool SamplesMatchPlaybackRate { get; set; } = true; public bool SamplesMatchPlaybackRate { get; set; } = true;
/// <summary>
/// The ratio of distance travelled per time unit.
/// Generally used to decouple the spacing between hit objects from the enforced "velocity" of the beatmap (see <see cref="DifficultyControlPoint.SliderVelocity"/>).
/// </summary>
/// <remarks>
/// The most common method of understanding is that at a default value of 1.0, the time-to-distance ratio will match the slider velocity of the beatmap
/// at the current point in time. Increasing this value will make hit objects more spaced apart when compared to the cursor movement required to track a slider.
///
/// This is only a hint property, used by the editor in <see cref="IDistanceSnapProvider"/> implementations. It does not directly affect the beatmap or gameplay.
/// </remarks>
public double DistanceSpacing { get; set; } = 1.0; public double DistanceSpacing { get; set; } = 1.0;
public int BeatDivisor { get; set; } public int BeatDivisor { get; set; }

View File

@ -153,7 +153,7 @@ namespace osu.Game.Beatmaps
} }
}; };
cacheDownloadRequest.PerformAsync(); Task.Run(() => cacheDownloadRequest.PerformAsync());
} }
private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmapInfo) private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmapInfo)

View File

@ -0,0 +1,26 @@
// 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;
using osu.Framework.Graphics.Sprites;
using osuTK;
namespace osu.Game.Graphics.UserInterface
{
public class BasicSearchTextBox : SearchTextBox
{
public BasicSearchTextBox()
{
Add(new SpriteIcon
{
Icon = FontAwesome.Solid.Search,
Origin = Anchor.CentreRight,
Anchor = Anchor.CentreRight,
Margin = new MarginPadding { Right = 10 },
Size = new Vector2(20),
});
TextFlow.Padding = new MarginPadding { Right = 35 };
}
}
}

View File

@ -1,12 +1,9 @@
// 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.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Resources.Localisation.Web; using osu.Game.Resources.Localisation.Web;
using osuTK;
using osuTK.Input; using osuTK.Input;
namespace osu.Game.Graphics.UserInterface namespace osu.Game.Graphics.UserInterface
@ -18,16 +15,6 @@ namespace osu.Game.Graphics.UserInterface
public SearchTextBox() public SearchTextBox()
{ {
Height = 35; Height = 35;
Add(new SpriteIcon
{
Icon = FontAwesome.Solid.Search,
Origin = Anchor.CentreRight,
Anchor = Anchor.CentreRight,
Margin = new MarginPadding { Right = 10 },
Size = new Vector2(20),
});
TextFlow.Padding = new MarginPadding { Right = 35 };
PlaceholderText = HomeStrings.SearchPlaceholder; PlaceholderText = HomeStrings.SearchPlaceholder;
} }

View File

@ -6,7 +6,7 @@ namespace osu.Game.Graphics.UserInterface
/// <summary> /// <summary>
/// A <see cref="SearchTextBox"/> which does not handle left/right arrow keys for seeking. /// A <see cref="SearchTextBox"/> which does not handle left/right arrow keys for seeking.
/// </summary> /// </summary>
public class SeekLimitedSearchTextBox : SearchTextBox public class SeekLimitedSearchTextBox : BasicSearchTextBox
{ {
public override bool HandleLeftRightArrows => false; public override bool HandleLeftRightArrows => false;
} }

View File

@ -0,0 +1,137 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Graphics.UserInterface
{
public class ShearedSearchTextBox : CompositeDrawable, IHasCurrentValue<string>
{
private const float corner_radius = 7;
private readonly Box background;
private readonly SearchTextBox textBox;
public Bindable<string> Current
{
get => textBox.Current;
set => textBox.Current = value;
}
public bool HoldFocus
{
get => textBox.HoldFocus;
set => textBox.HoldFocus = value;
}
public void TakeFocus() => textBox.TakeFocus();
public void KillFocus() => textBox.KillFocus();
public ShearedSearchTextBox()
{
Height = 42;
Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0);
Masking = true;
CornerRadius = corner_radius;
InternalChildren = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both
},
new GridContainer
{
RelativeSizeAxes = Axes.Both,
Content = new[]
{
new Drawable[]
{
textBox = new InnerSearchTextBox
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.Both,
Size = Vector2.One
},
new SpriteIcon
{
Icon = FontAwesome.Solid.Search,
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Size = new Vector2(16),
Shear = -Shear
}
}
},
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.Absolute, 50),
}
}
};
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
background.Colour = colourProvider.Background3;
}
public override bool HandleNonPositionalInput => textBox.HandleNonPositionalInput;
private class InnerSearchTextBox : SearchTextBox
{
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
BackgroundFocused = colourProvider.Background4;
BackgroundUnfocused = colourProvider.Background4;
Placeholder.Font = OsuFont.GetFont(size: CalculatedTextSize, weight: FontWeight.SemiBold);
PlaceholderText = CommonStrings.InputSearch;
CornerRadius = corner_radius;
TextContainer.Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0);
}
protected override SpriteText CreatePlaceholder() => new SearchPlaceholder();
internal class SearchPlaceholder : SpriteText
{
public override void Show()
{
this
.MoveToY(0, 250, Easing.OutQuint)
.FadeIn(250, Easing.OutQuint);
}
public override void Hide()
{
this
.MoveToY(3, 250, Easing.OutQuint)
.FadeOut(250, Easing.OutQuint);
}
}
protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer
{
AutoSizeAxes = Axes.Both,
Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold) },
};
}
}
}

View File

@ -59,6 +59,9 @@ namespace osu.Game.Input.Bindings
new KeyBinding(InputKey.Up, GlobalAction.SelectPrevious), new KeyBinding(InputKey.Up, GlobalAction.SelectPrevious),
new KeyBinding(InputKey.Down, GlobalAction.SelectNext), new KeyBinding(InputKey.Down, GlobalAction.SelectNext),
new KeyBinding(InputKey.Left, GlobalAction.SelectPreviousGroup),
new KeyBinding(InputKey.Right, GlobalAction.SelectNextGroup),
new KeyBinding(InputKey.Space, GlobalAction.Select), new KeyBinding(InputKey.Space, GlobalAction.Select),
new KeyBinding(InputKey.Enter, GlobalAction.Select), new KeyBinding(InputKey.Enter, GlobalAction.Select),
new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select), new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select),
@ -79,6 +82,8 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.F5 }, GlobalAction.EditorTestGameplay), new KeyBinding(new[] { InputKey.F5 }, GlobalAction.EditorTestGameplay),
new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.EditorFlipHorizontally), new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.EditorFlipHorizontally),
new KeyBinding(new[] { InputKey.Control, InputKey.J }, GlobalAction.EditorFlipVertically), new KeyBinding(new[] { InputKey.Control, InputKey.J }, GlobalAction.EditorFlipVertically),
new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.EditorDecreaseDistanceSpacing),
new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing),
}; };
public IEnumerable<KeyBinding> InGameKeyBindings => new[] public IEnumerable<KeyBinding> InGameKeyBindings => new[]
@ -103,7 +108,7 @@ namespace osu.Game.Input.Bindings
new KeyBinding(InputKey.F1, GlobalAction.ToggleModSelection), new KeyBinding(InputKey.F1, GlobalAction.ToggleModSelection),
new KeyBinding(InputKey.F2, GlobalAction.SelectNextRandom), new KeyBinding(InputKey.F2, GlobalAction.SelectNextRandom),
new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.SelectPreviousRandom), new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.SelectPreviousRandom),
new KeyBinding(InputKey.F3, GlobalAction.ToggleBeatmapOptions) new KeyBinding(InputKey.F3, GlobalAction.ToggleBeatmapOptions),
}; };
public IEnumerable<KeyBinding> AudioControlKeyBindings => new[] public IEnumerable<KeyBinding> AudioControlKeyBindings => new[]
@ -301,5 +306,17 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorFlipVertically))] [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorFlipVertically))]
EditorFlipVertically, EditorFlipVertically,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorIncreaseDistanceSpacing))]
EditorIncreaseDistanceSpacing,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorDecreaseDistanceSpacing))]
EditorDecreaseDistanceSpacing,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectPreviousGroup))]
SelectPreviousGroup,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectNextGroup))]
SelectNextGroup,
} }
} }

View File

@ -129,6 +129,16 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString SelectNext => new TranslatableString(getKey(@"select_next"), @"Next selection"); public static LocalisableString SelectNext => new TranslatableString(getKey(@"select_next"), @"Next selection");
/// <summary>
/// "Select previous group"
/// </summary>
public static LocalisableString SelectPreviousGroup => new TranslatableString(getKey(@"select_previous_group"), @"Select previous group");
/// <summary>
/// "Select next group"
/// </summary>
public static LocalisableString SelectNextGroup => new TranslatableString(getKey(@"select_next_group"), @"Select next group");
/// <summary> /// <summary>
/// "Home" /// "Home"
/// </summary> /// </summary>
@ -239,6 +249,16 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString EditorFlipVertically => new TranslatableString(getKey(@"editor_flip_vertically"), @"Flip selection vertically"); public static LocalisableString EditorFlipVertically => new TranslatableString(getKey(@"editor_flip_vertically"), @"Flip selection vertically");
/// <summary>
/// "Increase distance spacing"
/// </summary>
public static LocalisableString EditorIncreaseDistanceSpacing => new TranslatableString(getKey(@"editor_increase_distance_spacing"), @"Increase distance spacing");
/// <summary>
/// "Decrease distance spacing"
/// </summary>
public static LocalisableString EditorDecreaseDistanceSpacing => new TranslatableString(getKey(@"editor_decrease_distance_spacing"), @"Decrease distance spacing");
/// <summary> /// <summary>
/// "Toggle skin editor" /// "Toggle skin editor"
/// </summary> /// </summary>

View File

@ -14,6 +14,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Chat; using osu.Game.Overlays.Chat;
using osu.Game.Resources.Localisation.Web; using osu.Game.Resources.Localisation.Web;
using osuTK.Graphics; using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Online.Chat namespace osu.Game.Online.Chat
{ {
@ -119,6 +120,20 @@ namespace osu.Game.Online.Chat
public class ChatTextBox : FocusedTextBox public class ChatTextBox : FocusedTextBox
{ {
protected override bool OnKeyDown(KeyDownEvent e)
{
// Chat text boxes are generally used in places where they retain focus, but shouldn't block interaction with other
// elements on the same screen.
switch (e.Key)
{
case Key.Up:
case Key.Down:
return false;
}
return base.OnKeyDown(e);
}
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();

View File

@ -163,7 +163,7 @@ namespace osu.Game.Overlays.BeatmapListing
public void TakeFocus() => textBox.TakeFocus(); public void TakeFocus() => textBox.TakeFocus();
private class BeatmapSearchTextBox : SearchTextBox private class BeatmapSearchTextBox : BasicSearchTextBox
{ {
/// <summary> /// <summary>
/// Any time the text box receives key events (even while masked). /// Any time the text box receives key events (even while masked).

View File

@ -25,14 +25,14 @@ namespace osu.Game.Overlays.Chat.ChannelList
public event Action<Channel>? OnRequestSelect; public event Action<Channel>? OnRequestSelect;
public event Action<Channel>? OnRequestLeave; public event Action<Channel>? OnRequestLeave;
public readonly Channel Channel;
public readonly BindableInt Mentions = new BindableInt(); public readonly BindableInt Mentions = new BindableInt();
public readonly BindableBool Unread = new BindableBool(); public readonly BindableBool Unread = new BindableBool();
public readonly BindableBool SelectorActive = new BindableBool(); public readonly BindableBool SelectorActive = new BindableBool();
private readonly Channel channel;
private Box hoverBox = null!; private Box hoverBox = null!;
private Box selectBox = null!; private Box selectBox = null!;
private OsuSpriteText text = null!; private OsuSpriteText text = null!;
@ -46,7 +46,7 @@ namespace osu.Game.Overlays.Chat.ChannelList
public ChannelListItem(Channel channel) public ChannelListItem(Channel channel)
{ {
this.channel = channel; Channel = channel;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -92,7 +92,7 @@ namespace osu.Game.Overlays.Chat.ChannelList
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Text = channel.Name, Text = Channel.Name,
Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold), Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold),
Colour = colourProvider.Light3, Colour = colourProvider.Light3,
Margin = new MarginPadding { Bottom = 2 }, Margin = new MarginPadding { Bottom = 2 },
@ -111,7 +111,7 @@ namespace osu.Game.Overlays.Chat.ChannelList
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Margin = new MarginPadding { Right = 3 }, Margin = new MarginPadding { Right = 3 },
Action = () => OnRequestLeave?.Invoke(channel), Action = () => OnRequestLeave?.Invoke(Channel),
} }
} }
}, },
@ -119,20 +119,16 @@ namespace osu.Game.Overlays.Chat.ChannelList
}, },
}; };
Action = () => OnRequestSelect?.Invoke(channel); Action = () => OnRequestSelect?.Invoke(Channel);
} }
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
selectedChannel.BindValueChanged(_ => updateSelectState(), true); selectedChannel.BindValueChanged(_ => updateState(), true);
SelectorActive.BindValueChanged(_ => updateSelectState(), true); SelectorActive.BindValueChanged(_ => updateState(), true);
Unread.BindValueChanged(_ => updateState(), true);
Unread.BindValueChanged(change =>
{
text.FadeColour(change.NewValue ? colourProvider.Content1 : colourProvider.Light3, 300, Easing.OutQuint);
}, true);
} }
protected override bool OnHover(HoverEvent e) protected override bool OnHover(HoverEvent e)
@ -151,10 +147,10 @@ namespace osu.Game.Overlays.Chat.ChannelList
private Drawable createIcon() private Drawable createIcon()
{ {
if (channel.Type != ChannelType.PM) if (Channel.Type != ChannelType.PM)
return Drawable.Empty(); return Drawable.Empty();
return new UpdateableAvatar(channel.Users.First(), isInteractive: false) return new UpdateableAvatar(Channel.Users.First(), isInteractive: false)
{ {
Size = new Vector2(20), Size = new Vector2(20),
Margin = new MarginPadding { Right = 5 }, Margin = new MarginPadding { Right = 5 },
@ -165,12 +161,19 @@ namespace osu.Game.Overlays.Chat.ChannelList
}; };
} }
private void updateSelectState() private void updateState()
{ {
if (selectedChannel.Value == channel && !SelectorActive.Value) bool selected = selectedChannel.Value == Channel && !SelectorActive.Value;
if (selected)
selectBox.FadeIn(300, Easing.OutQuint); selectBox.FadeIn(300, Easing.OutQuint);
else else
selectBox.FadeOut(200, Easing.OutQuint); selectBox.FadeOut(200, Easing.OutQuint);
if (Unread.Value || selected)
text.FadeColour(colourProvider.Content1, 300, Easing.OutQuint);
else
text.FadeColour(colourProvider.Light3, 200, Easing.OutQuint);
} }
} }
} }

View File

@ -21,6 +21,10 @@ namespace osu.Game.Overlays.Chat.ChannelList
private Box hoverBox = null!; private Box hoverBox = null!;
private Box selectBox = null!; private Box selectBox = null!;
private OsuSpriteText text = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider) private void load(OverlayColourProvider colourProvider)
@ -46,11 +50,11 @@ namespace osu.Game.Overlays.Chat.ChannelList
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Left = 18, Right = 10 }, Padding = new MarginPadding { Left = 18, Right = 10 },
Child = new OsuSpriteText Child = text = new OsuSpriteText
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Text = "Add More Channels", Text = "Add more channels",
Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold), Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold),
Colour = colourProvider.Light3, Colour = colourProvider.Light3,
Margin = new MarginPadding { Bottom = 2 }, Margin = new MarginPadding { Bottom = 2 },
@ -68,9 +72,15 @@ namespace osu.Game.Overlays.Chat.ChannelList
SelectorActive.BindValueChanged(selector => SelectorActive.BindValueChanged(selector =>
{ {
if (selector.NewValue) if (selector.NewValue)
{
text.FadeColour(colourProvider.Content1, 300, Easing.OutQuint);
selectBox.FadeIn(300, Easing.OutQuint); selectBox.FadeIn(300, Easing.OutQuint);
}
else else
{
text.FadeColour(colourProvider.Light3, 200, Easing.OutQuint);
selectBox.FadeOut(200, Easing.OutQuint); selectBox.FadeOut(200, Easing.OutQuint);
}
}, true); }, true);
Action = () => SelectorActive.Value = true; Action = () => SelectorActive.Value = true;

View File

@ -0,0 +1,83 @@
// 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.
#nullable enable
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Overlays.Chat
{
public class ChatOverlayTopBar : Container
{
private Box background = null!;
private Color4 backgroundColour;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, TextureStore textures)
{
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = backgroundColour = colourProvider.Background3,
},
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.Absolute, 50),
new Dimension(),
},
Content = new[]
{
new Drawable[]
{
new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = textures.Get("Icons/Hexacons/messaging"),
Size = new Vector2(18),
},
// Placeholder text
new OsuSpriteText
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Text = "osu!chat",
Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold),
Margin = new MarginPadding { Bottom = 2f },
},
},
},
},
};
}
protected override bool OnHover(HoverEvent e)
{
background.FadeColour(backgroundColour.Lighten(0.1f), 300, Easing.OutQuint);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
background.FadeColour(backgroundColour, 300, Easing.OutQuint);
base.OnHoverLost(e);
}
}
}

View File

@ -11,7 +11,8 @@ 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.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.Containers; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Chat; using osu.Game.Online.Chat;
using osuTK; using osuTK;
@ -25,14 +26,19 @@ namespace osu.Game.Overlays.Chat
public event Action<string>? OnSearchTermsChanged; public event Action<string>? OnSearchTermsChanged;
public void TextBoxTakeFocus() => chatTextBox.TakeFocus();
public void TextBoxKillFocus() => chatTextBox.KillFocus();
[Resolved] [Resolved]
private Bindable<Channel> currentChannel { get; set; } = null!; private Bindable<Channel> currentChannel { get; set; } = null!;
private OsuTextFlowContainer chattingTextContainer = null!; private Container chattingTextContainer = null!;
private OsuSpriteText chattingText = null!;
private Container searchIconContainer = null!; private Container searchIconContainer = null!;
private ChatTextBox chatTextBox = null!; private ChatTextBox chatTextBox = null!;
private const float chatting_text_width = 180; private const float chatting_text_width = 240;
private const float search_icon_width = 40; private const float search_icon_width = 40;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -61,16 +67,20 @@ namespace osu.Game.Overlays.Chat
{ {
new Drawable[] new Drawable[]
{ {
chattingTextContainer = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 20)) chattingTextContainer = new Container
{ {
Masking = true,
Width = chatting_text_width,
Padding = new MarginPadding { Left = 10 },
RelativeSizeAxes = Axes.Y, RelativeSizeAxes = Axes.Y,
TextAnchor = Anchor.CentreRight, Width = chatting_text_width,
Anchor = Anchor.CentreLeft, Masking = true,
Origin = Anchor.CentreLeft, Padding = new MarginPadding { Right = 5 },
Colour = colourProvider.Background1, Child = chattingText = new OsuSpriteText
{
Font = OsuFont.Torus.With(size: 20),
Colour = colourProvider.Background1,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Truncate = true,
},
}, },
searchIconContainer = new Container searchIconContainer = new Container
{ {
@ -131,15 +141,15 @@ namespace osu.Game.Overlays.Chat
switch (newChannel?.Type) switch (newChannel?.Type)
{ {
case ChannelType.Public: case ChannelType.Public:
chattingTextContainer.Text = $"chatting in {newChannel.Name}"; chattingText.Text = $"chatting in {newChannel.Name}";
break; break;
case ChannelType.PM: case ChannelType.PM:
chattingTextContainer.Text = $"chatting with {newChannel.Name}"; chattingText.Text = $"chatting with {newChannel.Name}";
break; break;
default: default:
chattingTextContainer.Text = string.Empty; chattingText.Text = string.Empty;
break; break;
} }
}, true); }, true);

View File

@ -25,11 +25,11 @@ namespace osu.Game.Overlays.Chat.Listing
public event Action<Channel>? OnRequestJoin; public event Action<Channel>? OnRequestJoin;
public event Action<Channel>? OnRequestLeave; public event Action<Channel>? OnRequestLeave;
public bool FilteringActive { get; set; } public readonly Channel Channel;
public IEnumerable<string> FilterTerms => new[] { channel.Name, channel.Topic ?? string.Empty };
public bool MatchingFilter { set => this.FadeTo(value ? 1f : 0f, 100); }
private readonly Channel channel; public bool FilteringActive { get; set; }
public IEnumerable<string> FilterTerms => new[] { Channel.Name, Channel.Topic ?? string.Empty };
public bool MatchingFilter { set => this.FadeTo(value ? 1f : 0f, 100); }
private Box hoverBox = null!; private Box hoverBox = null!;
private SpriteIcon checkbox = null!; private SpriteIcon checkbox = null!;
@ -47,7 +47,7 @@ namespace osu.Game.Overlays.Chat.Listing
public ChannelListingItem(Channel channel) public ChannelListingItem(Channel channel)
{ {
this.channel = channel; Channel = channel;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -94,7 +94,7 @@ namespace osu.Game.Overlays.Chat.Listing
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Text = channel.Name, Text = Channel.Name,
Font = OsuFont.Torus.With(size: text_size, weight: FontWeight.SemiBold), Font = OsuFont.Torus.With(size: text_size, weight: FontWeight.SemiBold),
Margin = new MarginPadding { Bottom = 2 }, Margin = new MarginPadding { Bottom = 2 },
}, },
@ -102,7 +102,7 @@ namespace osu.Game.Overlays.Chat.Listing
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Text = channel.Topic, Text = Channel.Topic,
Font = OsuFont.Torus.With(size: text_size), Font = OsuFont.Torus.With(size: text_size),
Margin = new MarginPadding { Bottom = 2 }, Margin = new MarginPadding { Bottom = 2 },
}, },
@ -134,7 +134,7 @@ namespace osu.Game.Overlays.Chat.Listing
{ {
base.LoadComplete(); base.LoadComplete();
channelJoined = channel.Joined.GetBoundCopy(); channelJoined = Channel.Joined.GetBoundCopy();
channelJoined.BindValueChanged(change => channelJoined.BindValueChanged(change =>
{ {
const double duration = 500; const double duration = 500;
@ -155,7 +155,7 @@ namespace osu.Game.Overlays.Chat.Listing
} }
}, true); }, true);
Action = () => (channelJoined.Value ? OnRequestLeave : OnRequestJoin)?.Invoke(channel); Action = () => (channelJoined.Value ? OnRequestLeave : OnRequestJoin)?.Invoke(Channel);
} }
protected override bool OnHover(HoverEvent e) protected override bool OnHover(HoverEvent e)

View File

@ -181,7 +181,7 @@ namespace osu.Game.Overlays.Chat.Selection
base.PopOut(); base.PopOut();
} }
private class HeaderSearchTextBox : SearchTextBox private class HeaderSearchTextBox : BasicSearchTextBox
{ {
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()

View File

@ -0,0 +1,314 @@
// 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.
#nullable enable
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Online.Chat;
using osu.Game.Overlays.Chat;
using osu.Game.Overlays.Chat.ChannelList;
using osu.Game.Overlays.Chat.Listing;
namespace osu.Game.Overlays
{
public class ChatOverlayV2 : OsuFocusedOverlayContainer, INamedOverlayComponent
{
public string IconTexture => "Icons/Hexacons/messaging";
public LocalisableString Title => ChatStrings.HeaderTitle;
public LocalisableString Description => ChatStrings.HeaderDescription;
private ChatOverlayTopBar topBar = null!;
private ChannelList channelList = null!;
private LoadingLayer loading = null!;
private ChannelListing channelListing = null!;
private ChatTextBar textBar = null!;
private Container<DrawableChannel> currentChannelContainer = null!;
private readonly BindableFloat chatHeight = new BindableFloat();
private bool isDraggingTopBar;
private float dragStartChatHeight;
private const int transition_length = 500;
private const float default_chat_height = 0.4f;
private const float top_bar_height = 40;
private const float side_bar_width = 190;
private const float chat_bar_height = 60;
private readonly BindableBool selectorActive = new BindableBool();
[Resolved]
private OsuConfigManager config { get; set; } = null!;
[Resolved]
private ChannelManager channelManager { get; set; } = null!;
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink);
[Cached]
private readonly Bindable<Channel> currentChannel = new Bindable<Channel>();
public ChatOverlayV2()
{
Height = default_chat_height;
Masking = true;
const float corner_radius = 7f;
CornerRadius = corner_radius;
// Hack to hide the bottom edge corner radius off-screen.
Margin = new MarginPadding { Bottom = -corner_radius };
Padding = new MarginPadding { Bottom = corner_radius };
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
}
[BackgroundDependencyLoader]
private void load()
{
// Required for the pop in/out animation
RelativePositionAxes = Axes.Both;
Children = new Drawable[]
{
topBar = new ChatOverlayTopBar
{
RelativeSizeAxes = Axes.X,
Height = top_bar_height,
},
channelList = new ChannelList
{
RelativeSizeAxes = Axes.Y,
Width = side_bar_width,
Padding = new MarginPadding { Top = top_bar_height },
SelectorActive = { BindTarget = selectorActive },
},
new Container
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Padding = new MarginPadding
{
Top = top_bar_height,
Left = side_bar_width,
Bottom = chat_bar_height,
},
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4,
},
currentChannelContainer = new Container<DrawableChannel>
{
RelativeSizeAxes = Axes.Both,
},
loading = new LoadingLayer(true),
channelListing = new ChannelListing
{
RelativeSizeAxes = Axes.Both,
},
},
},
textBar = new ChatTextBar
{
RelativeSizeAxes = Axes.X,
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Padding = new MarginPadding { Left = side_bar_width },
ShowSearch = { BindTarget = selectorActive },
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
loading.Show();
config.BindWith(OsuSetting.ChatDisplayHeight, chatHeight);
chatHeight.BindValueChanged(height => { Height = height.NewValue; }, true);
currentChannel.BindTo(channelManager.CurrentChannel);
channelManager.CurrentChannel.BindValueChanged(currentChannelChanged, true);
channelManager.JoinedChannels.BindCollectionChanged(joinedChannelsChanged, true);
channelManager.AvailableChannels.BindCollectionChanged(availableChannelsChanged, true);
channelList.OnRequestSelect += channel =>
{
// Manually selecting a channel should dismiss the selector
selectorActive.Value = false;
channelManager.CurrentChannel.Value = channel;
};
channelList.OnRequestLeave += channel => channelManager.LeaveChannel(channel);
channelListing.OnRequestJoin += channel => channelManager.JoinChannel(channel);
channelListing.OnRequestLeave += channel => channelManager.LeaveChannel(channel);
textBar.OnSearchTermsChanged += searchTerms => channelListing.SearchTerm = searchTerms;
textBar.OnChatMessageCommitted += handleChatMessage;
selectorActive.BindValueChanged(v => channelListing.State.Value = v.NewValue ? Visibility.Visible : Visibility.Hidden, true);
}
/// <summary>
/// Highlights a certain message in the specified channel.
/// </summary>
/// <param name="message">The message to highlight.</param>
/// <param name="channel">The channel containing the message.</param>
public void HighlightMessage(Message message, Channel channel)
{
Debug.Assert(channel.Id == message.ChannelId);
if (currentChannel.Value?.Id != channel.Id)
{
if (!channel.Joined.Value)
channel = channelManager.JoinChannel(channel);
channelManager.CurrentChannel.Value = channel;
}
selectorActive.Value = false;
channel.HighlightedMessage.Value = message;
Show();
}
protected override bool OnDragStart(DragStartEvent e)
{
isDraggingTopBar = topBar.IsHovered;
if (!isDraggingTopBar)
return base.OnDragStart(e);
dragStartChatHeight = chatHeight.Value;
return true;
}
protected override void OnDrag(DragEvent e)
{
if (!isDraggingTopBar)
return;
float targetChatHeight = dragStartChatHeight - (e.MousePosition.Y - e.MouseDownPosition.Y) / Parent.DrawSize.Y;
chatHeight.Value = targetChatHeight;
}
protected override void OnDragEnd(DragEndEvent e)
{
isDraggingTopBar = false;
base.OnDragEnd(e);
}
protected override void PopIn()
{
base.PopIn();
this.MoveToY(0, transition_length, Easing.OutQuint);
this.FadeIn(transition_length, Easing.OutQuint);
}
protected override void PopOut()
{
base.PopOut();
this.MoveToY(Height, transition_length, Easing.InSine);
this.FadeOut(transition_length, Easing.InSine);
textBar.TextBoxKillFocus();
}
protected override void OnFocus(FocusEvent e)
{
textBar.TextBoxTakeFocus();
base.OnFocus(e);
}
private void currentChannelChanged(ValueChangedEvent<Channel> channel)
{
Channel? newChannel = channel.NewValue;
loading.Show();
// Channel is null when leaving the currently selected channel
if (newChannel == null)
{
// Find another channel to switch to
newChannel = channelManager.JoinedChannels.FirstOrDefault(c => c != channel.OldValue);
if (newChannel == null)
selectorActive.Value = true;
else
currentChannel.Value = newChannel;
return;
}
LoadComponentAsync(new DrawableChannel(newChannel), loaded =>
{
currentChannelContainer.Clear();
currentChannelContainer.Add(loaded);
loading.Hide();
});
}
private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args)
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
IEnumerable<Channel> joinedChannels = filterChannels(args.NewItems);
foreach (var channel in joinedChannels)
channelList.AddChannel(channel);
break;
case NotifyCollectionChangedAction.Remove:
IEnumerable<Channel> leftChannels = filterChannels(args.OldItems);
foreach (var channel in leftChannels)
channelList.RemoveChannel(channel);
break;
}
}
private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args)
=> channelListing.UpdateAvailableChannels(channelManager.AvailableChannels);
private IEnumerable<Channel> filterChannels(IList channels)
=> channels.Cast<Channel>().Where(c => c.Type == ChannelType.Public || c.Type == ChannelType.PM);
private void handleChatMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
return;
if (message[0] == '/')
channelManager.PostCommand(message.Substring(1));
else
channelManager.PostMessage(message);
}
}
}

View File

@ -49,18 +49,24 @@ namespace osu.Game.Overlays
{ {
if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return; if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return;
// if any existing dialog is being displayed, dismiss it before showing a new one. var lastDialog = CurrentDialog;
CurrentDialog?.Hide();
// Immediately update the externally accessible property as this may be used for checks even before
// a DialogOverlay instance has finished loading.
CurrentDialog = dialog; CurrentDialog = dialog;
CurrentDialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue);
dialogContainer.Add(CurrentDialog); Scheduler.Add(() =>
{
// if any existing dialog is being displayed, dismiss it before showing a new one.
lastDialog?.Hide();
dialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue);
dialogContainer.Add(dialog);
Show(); Show();
}, false);
} }
public override bool IsPresent => dialogContainer.Children.Count > 0; public override bool IsPresent => Scheduler.HasPendingTasks || dialogContainer.Children.Count > 0;
protected override bool BlockNonPositionalInput => true; protected override bool BlockNonPositionalInput => true;
@ -81,23 +87,16 @@ namespace osu.Game.Overlays
protected override void PopIn() protected override void PopIn()
{ {
base.PopIn(); base.PopIn();
this.FadeIn(PopupDialog.ENTER_DURATION, Easing.OutQuint);
lowPassFilter.CutoffTo(300, 100, Easing.OutCubic); lowPassFilter.CutoffTo(300, 100, Easing.OutCubic);
} }
protected override void PopOut() protected override void PopOut()
{ {
base.PopOut(); base.PopOut();
lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic);
if (CurrentDialog?.State.Value == Visibility.Visible) if (CurrentDialog?.State.Value == Visibility.Visible)
{
CurrentDialog.Hide(); CurrentDialog.Hide();
return;
}
this.FadeOut(PopupDialog.EXIT_DURATION, Easing.InSine);
} }
public override bool OnPressed(KeyBindingPressEvent<GlobalAction> e) public override bool OnPressed(KeyBindingPressEvent<GlobalAction> e)

View File

@ -1,6 +1,8 @@
// 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 enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@ -10,6 +12,7 @@ using Humanizer;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -25,8 +28,6 @@ using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
using osuTK.Input; using osuTK.Input;
#nullable enable
namespace osu.Game.Overlays.Mods namespace osu.Game.Overlays.Mods
{ {
public class ModColumn : CompositeDrawable public class ModColumn : CompositeDrawable
@ -52,9 +53,22 @@ namespace osu.Game.Overlays.Mods
} }
} }
public Bindable<IReadOnlyList<Mod>> SelectedMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
public Bindable<bool> Active = new BindableBool(true); public Bindable<bool> Active = new BindableBool(true);
/// <summary>
/// List of mods marked as selected in this column.
/// </summary>
/// <remarks>
/// Note that the mod instances returned by this property are owned solely by this column
/// (as in, they are locally-managed clones, to ensure proper isolation from any other external instances).
/// </remarks>
public IReadOnlyList<Mod> SelectedMods { get; private set; } = Array.Empty<Mod>();
/// <summary>
/// Invoked when a mod panel has been selected interactively by the user.
/// </summary>
public event Action? SelectionChangedByUser;
protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && Active.Value; protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && Active.Value;
protected virtual ModPanel CreateModPanel(Mod mod) => new ModPanel(mod); protected virtual ModPanel CreateModPanel(Mod mod) => new ModPanel(mod);
@ -63,6 +77,15 @@ namespace osu.Game.Overlays.Mods
private readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> availableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>(); private readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> availableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>();
/// <summary>
/// All mods that are available for the current ruleset in this particular column.
/// </summary>
/// <remarks>
/// Note that the mod instances in this list are owned solely by this column
/// (as in, they are locally-managed clones, to ensure proper isolation from any other external instances).
/// </remarks>
private IReadOnlyList<Mod> localAvailableMods = Array.Empty<Mod>();
private readonly TextFlowContainer headerText; private readonly TextFlowContainer headerText;
private readonly Box headerBackground; private readonly Box headerBackground;
private readonly Container contentContainer; private readonly Container contentContainer;
@ -226,6 +249,9 @@ namespace osu.Game.Overlays.Mods
private void load(OsuGameBase game, OverlayColourProvider colourProvider, OsuColour colours) private void load(OsuGameBase game, OverlayColourProvider colourProvider, OsuColour colours)
{ {
availableMods.BindTo(game.AvailableMods); availableMods.BindTo(game.AvailableMods);
// this `BindValueChanged` callback is intentionally here, to ensure that local available mods are constructed as early as possible.
// this is needed to make sure no external changes to mods are dropped while mod panels are asynchronously loading.
availableMods.BindValueChanged(_ => updateLocalAvailableMods(), true);
headerBackground.Colour = accentColour = colours.ForModType(ModType); headerBackground.Colour = accentColour = colours.ForModType(ModType);
@ -239,31 +265,26 @@ namespace osu.Game.Overlays.Mods
contentBackground.Colour = colourProvider.Background4; contentBackground.Colour = colourProvider.Background4;
} }
protected override void LoadComplete() private void updateLocalAvailableMods()
{ {
base.LoadComplete(); var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(ModType) ?? Array.Empty<Mod>())
availableMods.BindValueChanged(_ => Scheduler.AddOnce(updateMods)); .Select(m => m.DeepClone())
SelectedMods.BindValueChanged(_ => .ToList();
{
// if a load is in progress, don't try to update the selection - the load flow will do so. if (newMods.SequenceEqual(localAvailableMods))
if (latestLoadTask == null) return;
updateActiveState();
}); localAvailableMods = newMods;
updateMods(); Scheduler.AddOnce(loadPanels);
} }
private CancellationTokenSource? cancellationTokenSource; private CancellationTokenSource? cancellationTokenSource;
private void updateMods() private void loadPanels()
{ {
var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(ModType) ?? Array.Empty<Mod>()).ToList();
if (newMods.SequenceEqual(panelFlow.Children.Select(p => p.Mod)))
return;
cancellationTokenSource?.Cancel(); cancellationTokenSource?.Cancel();
var panels = newMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0))); var panels = localAvailableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0)));
Task? loadTask; Task? loadTask;
@ -277,13 +298,7 @@ namespace osu.Game.Overlays.Mods
foreach (var panel in panelFlow) foreach (var panel in panelFlow)
{ {
panel.Active.BindValueChanged(_ => panel.Active.BindValueChanged(_ => panelStateChanged(panel));
{
updateToggleAllState();
SelectedMods.Value = panel.Active.Value
? SelectedMods.Value.Append(panel.Mod).ToArray()
: SelectedMods.Value.Except(new[] { panel.Mod }).ToArray();
});
} }
}, (cancellationTokenSource = new CancellationTokenSource()).Token); }, (cancellationTokenSource = new CancellationTokenSource()).Token);
loadTask.ContinueWith(_ => loadTask.ContinueWith(_ =>
@ -296,7 +311,62 @@ namespace osu.Game.Overlays.Mods
private void updateActiveState() private void updateActiveState()
{ {
foreach (var panel in panelFlow) foreach (var panel in panelFlow)
panel.Active.Value = SelectedMods.Value.Contains(panel.Mod, EqualityComparer<Mod>.Default); panel.Active.Value = SelectedMods.Contains(panel.Mod);
}
/// <summary>
/// This flag helps to determine the source of changes to <see cref="SelectedMods"/>.
/// If the value is false, then <see cref="SelectedMods"/> are changing due to a user selection on the UI.
/// If the value is true, then <see cref="SelectedMods"/> are changing due to an external <see cref="SetSelection"/> call.
/// </summary>
private bool externalSelectionUpdateInProgress;
private void panelStateChanged(ModPanel panel)
{
updateToggleAllState();
var newSelectedMods = panel.Active.Value
? SelectedMods.Append(panel.Mod)
: SelectedMods.Except(panel.Mod.Yield());
SelectedMods = newSelectedMods.ToArray();
if (!externalSelectionUpdateInProgress)
SelectionChangedByUser?.Invoke();
}
/// <summary>
/// Adjusts the set of selected mods in this column to match the passed in <paramref name="mods"/>.
/// </summary>
/// <remarks>
/// This method exists to be able to receive mod instances that come from potentially-external sources and to copy the changes across to this column's state.
/// <see cref="ModSelectScreen"/> uses this to substitute any external mod references in <see cref="ModSelectScreen.SelectedMods"/>
/// to references that are owned by this column.
/// </remarks>
internal void SetSelection(IReadOnlyList<Mod> mods)
{
externalSelectionUpdateInProgress = true;
var newSelection = new List<Mod>();
foreach (var mod in localAvailableMods)
{
var matchingSelectedMod = mods.SingleOrDefault(selected => selected.GetType() == mod.GetType());
if (matchingSelectedMod != null)
{
mod.CopyFrom(matchingSelectedMod);
newSelection.Add(mod);
}
else
{
mod.ResetSettingsToDefaults();
}
}
SelectedMods = newSelection;
updateActiveState();
externalSelectionUpdateInProgress = false;
} }
#region Bulk select / deselect #region Bulk select / deselect
@ -364,6 +434,15 @@ namespace osu.Game.Overlays.Mods
pendingSelectionOperations.Enqueue(() => button.Active.Value = false); pendingSelectionOperations.Enqueue(() => button.Active.Value = false);
} }
/// <summary>
/// Run any delayed selections (due to animation) immediately to leave mods in a good (final) state.
/// </summary>
public void FlushPendingSelections()
{
while (pendingSelectionOperations.TryDequeue(out var dequeuedAction))
dequeuedAction();
}
private class ToggleAllCheckbox : OsuCheckbox private class ToggleAllCheckbox : OsuCheckbox
{ {
private Color4 accentColour; private Color4 accentColour;

View File

@ -250,9 +250,9 @@ namespace osu.Game.Overlays.Mods
protected virtual ModButton CreateModButton(Mod mod) => new ModButton(mod); protected virtual ModButton CreateModButton(Mod mod) => new ModButton(mod);
/// <summary> /// <summary>
/// Play out all remaining animations immediately to leave mods in a good (final) state. /// Run any delayed selections (due to animation) immediately to leave mods in a good (final) state.
/// </summary> /// </summary>
public void FlushAnimation() public void FlushPendingSelections()
{ {
while (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) while (pendingSelectionOperations.TryDequeue(out var dequeuedAction))
dequeuedAction(); dequeuedAction();

View File

@ -369,7 +369,7 @@ namespace osu.Game.Overlays.Mods
foreach (var section in ModSectionsContainer) foreach (var section in ModSectionsContainer)
{ {
section.FlushAnimation(); section.FlushPendingSelections();
} }
FooterContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); FooterContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine);

View File

@ -18,6 +18,7 @@ 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.Input.Bindings;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osuTK; using osuTK;
using osuTK.Input; using osuTK.Input;
@ -179,7 +180,7 @@ namespace osu.Game.Overlays.Mods
foreach (var column in columnFlow.Columns) foreach (var column in columnFlow.Columns)
{ {
column.SelectedMods.BindValueChanged(updateBindableFromSelection); column.SelectionChangedByUser += updateBindableFromSelection;
} }
customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true);
@ -203,7 +204,7 @@ namespace osu.Game.Overlays.Mods
private void updateAvailableMods() private void updateAvailableMods()
{ {
foreach (var column in columnFlow.Columns) foreach (var column in columnFlow.Columns)
column.Filter = isValidMod; column.Filter = m => m.HasImplementation && isValidMod.Invoke(m);
} }
private void updateCustomisation(ValueChangedEvent<IReadOnlyList<Mod>> valueChangedEvent) private void updateCustomisation(ValueChangedEvent<IReadOnlyList<Mod>> valueChangedEvent)
@ -217,7 +218,7 @@ namespace osu.Game.Overlays.Mods
foreach (var mod in SelectedMods.Value) foreach (var mod in SelectedMods.Value)
{ {
anyCustomisableMod |= mod.GetSettingsSourceProperties().Any(); anyCustomisableMod |= mod.GetSettingsSourceProperties().Any();
anyModWithRequiredCustomisationAdded |= !valueChangedEvent.OldValue.Contains(mod) && mod.RequiresConfiguration; anyModWithRequiredCustomisationAdded |= valueChangedEvent.OldValue.All(m => m.GetType() != mod.GetType()) && mod.RequiresConfiguration;
} }
if (anyCustomisableMod) if (anyCustomisableMod)
@ -250,33 +251,26 @@ namespace osu.Game.Overlays.Mods
private void updateSelectionFromBindable() private void updateSelectionFromBindable()
{ {
// note that selectionBindableSyncInProgress is purposefully not checked here. // `SelectedMods` may contain mod references that come from external sources.
// this is because in the case of mod selection in solo gameplay, a user selection of a mod can actually lead to deselection of other incompatible mods. // to ensure isolation, first pull in the potentially-external change into the mod columns...
// to synchronise state correctly, updateBindableFromSelection() computes the final mods (including incompatibility rules) and updates SelectedMods,
// and this method then runs unconditionally again to make sure the new visual selection accurately reflects the final set of selected mods.
// selectionBindableSyncInProgress ensures that mutual infinite recursion does not happen after that unconditional call.
foreach (var column in columnFlow.Columns) foreach (var column in columnFlow.Columns)
column.SelectedMods.Value = SelectedMods.Value.Where(mod => mod.Type == column.ModType).ToArray(); column.SetSelection(SelectedMods.Value);
// and then, when done, replace the potentially-external mod references in `SelectedMods` with ones we own.
updateBindableFromSelection();
} }
private bool selectionBindableSyncInProgress; private void updateBindableFromSelection()
private void updateBindableFromSelection(ValueChangedEvent<IReadOnlyList<Mod>> modSelectionChange)
{ {
if (selectionBindableSyncInProgress) var candidateSelection = columnFlow.Columns.SelectMany(column => column.SelectedMods).ToArray();
if (candidateSelection.SequenceEqual(SelectedMods.Value))
return; return;
selectionBindableSyncInProgress = true; SelectedMods.Value = ComputeNewModsFromSelection(SelectedMods.Value, candidateSelection);
SelectedMods.Value = ComputeNewModsFromSelection(
modSelectionChange.NewValue.Except(modSelectionChange.OldValue),
modSelectionChange.OldValue.Except(modSelectionChange.NewValue));
selectionBindableSyncInProgress = false;
} }
protected virtual IReadOnlyList<Mod> ComputeNewModsFromSelection(IEnumerable<Mod> addedMods, IEnumerable<Mod> removedMods) protected virtual IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection) => newSelection;
=> columnFlow.Columns.SelectMany(column => column.SelectedMods.Value).ToArray();
protected override void PopIn() protected override void PopIn()
{ {
@ -313,13 +307,26 @@ namespace osu.Game.Overlays.Mods
{ {
const float distance = 700; const float distance = 700;
columnFlow[i].Column var column = columnFlow[i].Column;
.TopLevelContent
.MoveToY(i % 2 == 0 ? -distance : distance, fade_out_duration, Easing.OutQuint) column.FlushPendingSelections();
.FadeOut(fade_out_duration, Easing.OutQuint); column.TopLevelContent
.MoveToY(i % 2 == 0 ? -distance : distance, fade_out_duration, Easing.OutQuint)
.FadeOut(fade_out_duration, Easing.OutQuint);
} }
} }
public override bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (e.Action == GlobalAction.Back && customisationVisible.Value)
{
customisationVisible.Value = false;
return true;
}
return base.OnPressed(e);
}
internal class ColumnScrollContainer : OsuScrollContainer<ColumnFlowContainer> internal class ColumnScrollContainer : OsuScrollContainer<ColumnFlowContainer>
{ {
public ColumnScrollContainer() public ColumnScrollContainer()

View File

@ -1,6 +1,7 @@
// 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.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
@ -21,7 +22,7 @@ namespace osu.Game.Overlays.Mods
{ {
public class ModSettingsArea : CompositeDrawable public class ModSettingsArea : CompositeDrawable
{ {
public Bindable<IReadOnlyList<Mod>> SelectedMods { get; } = new Bindable<IReadOnlyList<Mod>>(); public Bindable<IReadOnlyList<Mod>> SelectedMods { get; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
public const float HEIGHT = 250; public const float HEIGHT = 250;
@ -77,7 +78,7 @@ namespace osu.Game.Overlays.Mods
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
SelectedMods.BindValueChanged(_ => updateMods()); SelectedMods.BindValueChanged(_ => updateMods(), true);
} }
private void updateMods() private void updateMods()

View File

@ -14,9 +14,12 @@ 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, Key[] toggleKeys = null) => new UserModColumn(modType, false, toggleKeys);
protected override IReadOnlyList<Mod> ComputeNewModsFromSelection(IEnumerable<Mod> addedMods, IEnumerable<Mod> removedMods) protected override IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection)
{ {
IEnumerable<Mod> modsAfterRemoval = SelectedMods.Value.Except(removedMods).ToList(); var addedMods = newSelection.Except(oldSelection);
var removedMods = oldSelection.Except(newSelection);
IEnumerable<Mod> modsAfterRemoval = newSelection.Except(removedMods).ToList();
// the preference is that all new mods should override potential incompatible old mods. // the preference is that all new mods should override potential incompatible old mods.
// in general that's a bit difficult to compute if more than one mod is added at a time, // in general that's a bit difficult to compute if more than one mod is added at a time,

View File

@ -56,7 +56,7 @@ namespace osu.Game.Overlays.Music
Collection = collectionDropdown.Current.Value?.Collection Collection = collectionDropdown.Current.Value?.Collection
}; };
public class FilterTextBox : SearchTextBox public class FilterTextBox : BasicSearchTextBox
{ {
protected override bool AllowCommit => true; protected override bool AllowCommit => true;

View File

@ -372,12 +372,12 @@ namespace osu.Game.Overlays.Volume
switch (e.Action) switch (e.Action)
{ {
case GlobalAction.SelectPrevious: case GlobalAction.SelectPreviousGroup:
State = SelectionState.Selected; State = SelectionState.Selected;
adjust(1, false); adjust(1, false);
return true; return true;
case GlobalAction.SelectNext: case GlobalAction.SelectNextGroup:
State = SelectionState.Selected; State = SelectionState.Selected;
adjust(-1, false); adjust(-1, false);
return true; return true;

View File

@ -100,9 +100,13 @@ namespace osu.Game.Rulesets.Difficulty.Skills
double difficulty = 0; double difficulty = 0;
double weight = 1; double weight = 1;
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
var peaks = GetCurrentStrainPeaks().Where(p => p > 0);
// Difficulty is the weighted sum of the highest strains from every section. // Difficulty is the weighted sum of the highest strains from every section.
// We're sorting from highest to lowest strain. // We're sorting from highest to lowest strain.
foreach (double strain in GetCurrentStrainPeaks().OrderByDescending(d => d)) foreach (double strain in peaks.OrderByDescending(d => d))
{ {
difficulty += strain * weight; difficulty += strain * weight;
weight *= DecayWeight; weight *= DecayWeight;

View File

@ -3,13 +3,19 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers; using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Overlays;
using osu.Game.Overlays.OSD;
using osu.Game.Overlays.Settings.Sections; using osu.Game.Overlays.Settings.Sections;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osuTK;
namespace osu.Game.Rulesets.Edit namespace osu.Game.Rulesets.Edit
{ {
@ -17,8 +23,7 @@ namespace osu.Game.Rulesets.Edit
/// Represents a <see cref="HitObjectComposer{TObject}"/> for rulesets with the concept of distances between objects. /// Represents a <see cref="HitObjectComposer{TObject}"/> for rulesets with the concept of distances between objects.
/// </summary> /// </summary>
/// <typeparam name="TObject">The base type of supported objects.</typeparam> /// <typeparam name="TObject">The base type of supported objects.</typeparam>
[Cached(typeof(IDistanceSnapProvider))] public abstract class DistancedHitObjectComposer<TObject> : HitObjectComposer<TObject>, IDistanceSnapProvider, IScrollBindingHandler<GlobalAction>
public abstract class DistancedHitObjectComposer<TObject> : HitObjectComposer<TObject>, IDistanceSnapProvider
where TObject : HitObject where TObject : HitObject
{ {
protected Bindable<double> DistanceSpacingMultiplier { get; } = new BindableDouble(1.0) protected Bindable<double> DistanceSpacingMultiplier { get; } = new BindableDouble(1.0)
@ -33,7 +38,9 @@ namespace osu.Game.Rulesets.Edit
protected ExpandingToolboxContainer RightSideToolboxContainer { get; private set; } protected ExpandingToolboxContainer RightSideToolboxContainer { get; private set; }
private ExpandableSlider<double, SizeSlider<double>> distanceSpacingSlider; private ExpandableSlider<double, SizeSlider<double>> distanceSpacingSlider;
private bool distanceSpacingScrollActive;
[Resolved(canBeNull: true)]
private OnScreenDisplay onScreenDisplay { get; set; }
protected DistancedHitObjectComposer(Ruleset ruleset) protected DistancedHitObjectComposer(Ruleset ruleset)
: base(ruleset) : base(ruleset)
@ -43,8 +50,9 @@ namespace osu.Game.Rulesets.Edit
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
AddInternal(RightSideToolboxContainer = new ExpandingToolboxContainer AddInternal(RightSideToolboxContainer = new ExpandingToolboxContainer(130, 250)
{ {
Padding = new MarginPadding { Right = 10 },
Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1,
Anchor = Anchor.TopRight, Anchor = Anchor.TopRight,
Origin = Anchor.TopRight, Origin = Anchor.TopRight,
@ -66,45 +74,58 @@ namespace osu.Game.Rulesets.Edit
if (!DistanceSpacingMultiplier.Disabled) if (!DistanceSpacingMultiplier.Disabled)
{ {
DistanceSpacingMultiplier.Value = EditorBeatmap.BeatmapInfo.DistanceSpacing; DistanceSpacingMultiplier.Value = EditorBeatmap.BeatmapInfo.DistanceSpacing;
DistanceSpacingMultiplier.BindValueChanged(v => DistanceSpacingMultiplier.BindValueChanged(multiplier =>
{ {
distanceSpacingSlider.ContractedLabelText = $"D. S. ({v.NewValue:0.##x})"; distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})";
distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({v.NewValue:0.##x})"; distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({multiplier.NewValue:0.##x})";
EditorBeatmap.BeatmapInfo.DistanceSpacing = v.NewValue;
if (multiplier.NewValue != multiplier.OldValue)
onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier));
EditorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue;
}, true); }, true);
} }
} }
protected override bool OnKeyDown(KeyDownEvent e) public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{ {
if (!DistanceSpacingMultiplier.Disabled && e.ControlPressed && e.AltPressed && !e.Repeat) switch (e.Action)
{ {
RightSideToolboxContainer.Expanded.Value = true; case GlobalAction.EditorIncreaseDistanceSpacing:
distanceSpacingScrollActive = true; case GlobalAction.EditorDecreaseDistanceSpacing:
return true; return adjustDistanceSpacing(e.Action, 0.1f);
} }
return base.OnKeyDown(e); return false;
} }
protected override void OnKeyUp(KeyUpEvent e) public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{ {
if (!DistanceSpacingMultiplier.Disabled && distanceSpacingScrollActive && (!e.AltPressed || !e.ControlPressed))
{
RightSideToolboxContainer.Expanded.Value = false;
distanceSpacingScrollActive = false;
}
} }
protected override bool OnScroll(ScrollEvent e) public bool OnScroll(KeyBindingScrollEvent<GlobalAction> e)
{ {
if (distanceSpacingScrollActive) switch (e.Action)
{ {
DistanceSpacingMultiplier.Value += e.ScrollDelta.Y * (e.IsPrecise ? 0.01f : 0.1f); case GlobalAction.EditorIncreaseDistanceSpacing:
return true; case GlobalAction.EditorDecreaseDistanceSpacing:
return adjustDistanceSpacing(e.Action, e.ScrollAmount * (e.IsPrecise ? 0.01f : 0.1f));
} }
return base.OnScroll(e); return false;
}
private bool adjustDistanceSpacing(GlobalAction action, float amount)
{
if (DistanceSpacingMultiplier.Disabled)
return false;
if (action == GlobalAction.EditorIncreaseDistanceSpacing)
DistanceSpacingMultiplier.Value += amount;
else if (action == GlobalAction.EditorDecreaseDistanceSpacing)
DistanceSpacingMultiplier.Value -= amount;
return true;
} }
public virtual float GetBeatSnapDistanceAt(HitObject referenceObject) public virtual float GetBeatSnapDistanceAt(HitObject referenceObject)
@ -124,10 +145,10 @@ namespace osu.Game.Rulesets.Edit
return distance / GetBeatSnapDistanceAt(referenceObject) * beatLength; return distance / GetBeatSnapDistanceAt(referenceObject) * beatLength;
} }
public virtual double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) public virtual double FindSnappedDuration(HitObject referenceObject, float distance)
=> BeatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; => BeatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime;
public virtual float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) public virtual float FindSnappedDistance(HitObject referenceObject, float distance)
{ {
double startTime = referenceObject.StartTime; double startTime = referenceObject.StartTime;
@ -145,18 +166,25 @@ namespace osu.Game.Rulesets.Edit
return DurationToDistance(referenceObject, snappedEndTime - startTime); return DurationToDistance(referenceObject, snappedEndTime - startTime);
} }
protected class ExpandingToolboxContainer : ExpandingContainer private class DistanceSpacingToast : Toast
{ {
protected override double HoverExpansionDelay => 250; private readonly ValueChangedEvent<double> change;
public ExpandingToolboxContainer() public DistanceSpacingToast(LocalisableString value, ValueChangedEvent<double> change)
: base(130, 250) : base(getAction(change).GetLocalisableDescription(), value, string.Empty)
{ {
RelativeSizeAxes = Axes.Y; this.change = change;
Padding = new MarginPadding { Left = 10 };
FillFlow.Spacing = new Vector2(10);
} }
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
ShortcutText.Text = config.LookupKeyBindings(getAction(change)).ToUpper();
}
private static GlobalAction getAction(ValueChangedEvent<double> change) => change.NewValue - change.OldValue > 0
? GlobalAction.EditorIncreaseDistanceSpacing
: GlobalAction.EditorDecreaseDistanceSpacing;
} }
} }
} }

View File

@ -0,0 +1,34 @@
// 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.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
using osuTK;
namespace osu.Game.Rulesets.Edit
{
public class ExpandingToolboxContainer : ExpandingContainer
{
protected override double HoverExpansionDelay => 250;
public ExpandingToolboxContainer(float contractedWidth, float expandedWidth)
: base(contractedWidth, expandedWidth)
{
RelativeSizeAxes = Axes.Y;
FillFlow.Spacing = new Vector2(10);
}
protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos);
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && anyToolboxHovered(screenSpacePos);
private bool anyToolboxHovered(Vector2 screenSpacePos) => FillFlow.Children.Any(d => d.ScreenSpaceDrawQuad.Contains(screenSpacePos));
protected override bool OnMouseDown(MouseDownEvent e) => true;
protected override bool OnClick(ClickEvent e) => true;
}
}

View File

@ -13,7 +13,6 @@ using osu.Framework.Input;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.Containers;
using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Configuration;
using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@ -36,7 +35,6 @@ namespace osu.Game.Rulesets.Edit
/// Responsible for providing snapping and generally gluing components together. /// Responsible for providing snapping and generally gluing components together.
/// </summary> /// </summary>
/// <typeparam name="TObject">The base type of supported objects.</typeparam> /// <typeparam name="TObject">The base type of supported objects.</typeparam>
[Cached(Type = typeof(IPlacementHandler))]
public abstract class HitObjectComposer<TObject> : HitObjectComposer, IPlacementHandler public abstract class HitObjectComposer<TObject> : HitObjectComposer, IPlacementHandler
where TObject : HitObject where TObject : HitObject
{ {
@ -115,8 +113,9 @@ namespace osu.Game.Rulesets.Edit
.WithChild(BlueprintContainer = CreateBlueprintContainer()) .WithChild(BlueprintContainer = CreateBlueprintContainer())
} }
}, },
new LeftToolboxFlow new ExpandingToolboxContainer(80, 200)
{ {
Padding = new MarginPadding { Left = 10 },
Children = new Drawable[] Children = new Drawable[]
{ {
new EditorToolboxGroup("toolbox (1-9)") new EditorToolboxGroup("toolbox (1-9)")
@ -362,7 +361,7 @@ namespace osu.Game.Rulesets.Edit
/// <returns>The most relevant <see cref="Playfield"/>.</returns> /// <returns>The most relevant <see cref="Playfield"/>.</returns>
protected virtual Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => drawableRulesetWrapper.Playfield; protected virtual Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => drawableRulesetWrapper.Playfield;
public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition)
{ {
var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition);
double? targetTime = null; double? targetTime = null;
@ -382,26 +381,13 @@ namespace osu.Game.Rulesets.Edit
} }
#endregion #endregion
private class LeftToolboxFlow : ExpandingButtonContainer
{
public LeftToolboxFlow()
: base(80, 200)
{
RelativeSizeAxes = Axes.Y;
Padding = new MarginPadding { Right = 10 };
FillFlow.Spacing = new Vector2(10);
}
}
} }
/// <summary> /// <summary>
/// A non-generic definition of a HitObject composer class. /// A non-generic definition of a HitObject composer class.
/// Generally used to access certain methods without requiring a generic type for <see cref="HitObjectComposer{T}" />. /// Generally used to access certain methods without requiring a generic type for <see cref="HitObjectComposer{T}" />.
/// </summary> /// </summary>
[Cached(typeof(HitObjectComposer))] [Cached]
[Cached(typeof(IPositionSnapProvider))]
public abstract class HitObjectComposer : CompositeDrawable, IPositionSnapProvider public abstract class HitObjectComposer : CompositeDrawable, IPositionSnapProvider
{ {
protected HitObjectComposer() protected HitObjectComposer()
@ -428,9 +414,9 @@ namespace osu.Game.Rulesets.Edit
#region IPositionSnapProvider #region IPositionSnapProvider
public abstract SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); public abstract SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition);
public virtual SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => public virtual SnapResult FindSnappedPosition(Vector2 screenSpacePosition) =>
new SnapResult(screenSpacePosition, null); new SnapResult(screenSpacePosition, null);
#endregion #endregion

View File

@ -1,16 +1,21 @@
// 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.
using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Edit namespace osu.Game.Rulesets.Edit
{ {
/// <summary>
/// A snap provider which given a reference hit object and proposed distance from it, offers a more correct duration or distance value.
/// </summary>
[Cached]
public interface IDistanceSnapProvider : IPositionSnapProvider public interface IDistanceSnapProvider : IPositionSnapProvider
{ {
/// <summary> /// <summary>
/// The spacing multiplier applied to beat snap distances. /// A multiplier which changes the ratio of distance travelled per time unit.
/// </summary> /// </summary>
/// <seealso cref="BeatmapInfo.DistanceSpacing"/> /// <seealso cref="BeatmapInfo.DistanceSpacing"/>
IBindable<double> DistanceSpacingMultiplier { get; } IBindable<double> DistanceSpacingMultiplier { get; }
@ -23,7 +28,7 @@ namespace osu.Game.Rulesets.Edit
float GetBeatSnapDistanceAt(HitObject referenceObject); float GetBeatSnapDistanceAt(HitObject referenceObject);
/// <summary> /// <summary>
/// Converts a duration to a distance. /// Converts a duration to a distance without applying any snapping.
/// </summary> /// </summary>
/// <param name="referenceObject">An object to be used as a reference point for this operation.</param> /// <param name="referenceObject">An object to be used as a reference point for this operation.</param>
/// <param name="duration">The duration to convert.</param> /// <param name="duration">The duration to convert.</param>
@ -31,7 +36,7 @@ namespace osu.Game.Rulesets.Edit
float DurationToDistance(HitObject referenceObject, double duration); float DurationToDistance(HitObject referenceObject, double duration);
/// <summary> /// <summary>
/// Converts a distance to a duration. /// Converts a distance to a duration without applying any snapping.
/// </summary> /// </summary>
/// <param name="referenceObject">An object to be used as a reference point for this operation.</param> /// <param name="referenceObject">An object to be used as a reference point for this operation.</param>
/// <param name="distance">The distance to convert.</param> /// <param name="distance">The distance to convert.</param>
@ -39,20 +44,22 @@ namespace osu.Game.Rulesets.Edit
double DistanceToDuration(HitObject referenceObject, float distance); double DistanceToDuration(HitObject referenceObject, float distance);
/// <summary> /// <summary>
/// Converts a distance to a snapped duration. /// Given a distance from the provided hit object, find the valid snapped duration.
/// </summary> /// </summary>
/// <param name="referenceObject">An object to be used as a reference point for this operation.</param> /// <param name="referenceObject">An object to be used as a reference point for this operation.</param>
/// <param name="distance">The distance to convert.</param> /// <param name="distance">The distance to convert.</param>
/// <returns>A value that represents <paramref name="distance"/> as a duration snapped to the closest beat of the timing point.</returns> /// <returns>A value that represents <paramref name="distance"/> as a duration snapped to the closest beat of the timing point.</returns>
double GetSnappedDurationFromDistance(HitObject referenceObject, float distance); double FindSnappedDuration(HitObject referenceObject, float distance);
/// <summary> /// <summary>
/// Converts an unsnapped distance to a snapped distance. /// Given a distance from the provided hit object, find the valid snapped distance.
/// The returned distance will always be floored (as to never exceed the provided <paramref name="distance"/>.
/// </summary> /// </summary>
/// <param name="referenceObject">An object to be used as a reference point for this operation.</param> /// <param name="referenceObject">An object to be used as a reference point for this operation.</param>
/// <param name="distance">The distance to convert.</param> /// <param name="distance">The distance to convert.</param>
/// <returns>A value that represents <paramref name="distance"/> snapped to the closest beat of the timing point.</returns> /// <returns>
float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance); /// A value that represents <paramref name="distance"/> snapped to the closest beat of the timing point.
/// The distance will always be less than or equal to the provided <paramref name="distance"/>.
/// </returns>
float FindSnappedDistance(HitObject referenceObject, float distance);
} }
} }

View File

@ -1,27 +1,33 @@
// 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.
using osu.Framework.Allocation;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Edit namespace osu.Game.Rulesets.Edit
{ {
/// <summary>
/// A snap provider which given a proposed position for a hit object, potentially offers a more correct position and time value inferred from the context of the beatmap.
/// Provided values are inferred in an isolated context, without consideration of other nearby hit objects.
/// </summary>
[Cached]
public interface IPositionSnapProvider public interface IPositionSnapProvider
{ {
/// <summary> /// <summary>
/// Given a position, find a valid time and position snap. /// Given a position, find a valid time and position snap.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This call should be equivalent to running <see cref="SnapScreenSpacePositionToValidPosition"/> with any additional logic that can be performed without the time immutability restriction. /// This call should be equivalent to running <see cref="FindSnappedPosition"/> with any additional logic that can be performed without the time immutability restriction.
/// </remarks> /// </remarks>
/// <param name="screenSpacePosition">The screen-space position to be snapped.</param> /// <param name="screenSpacePosition">The screen-space position to be snapped.</param>
/// <returns>The time and position post-snapping.</returns> /// <returns>The time and position post-snapping.</returns>
SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition);
/// <summary> /// <summary>
/// Given a position, find a value position snap, restricting time to its input value. /// Given a position, find a valid position snap, without changing the time value.
/// </summary> /// </summary>
/// <param name="screenSpacePosition">The screen-space position to be snapped.</param> /// <param name="screenSpacePosition">The screen-space position to be snapped.</param>
/// <returns>The position post-snapping. Time will always be null.</returns> /// <returns>The position post-snapping. Time will always be null.</returns>
SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition); SnapResult FindSnappedPosition(Vector2 screenSpacePosition);
} }
} }

View File

@ -1,34 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
namespace osu.Game.Rulesets.Edit
{
public class ScrollingToolboxGroup : EditorToolboxGroup
{
protected readonly OsuScrollContainer Scroll;
protected readonly FillFlowContainer FillFlow;
protected override Container<Drawable> Content { get; }
public ScrollingToolboxGroup(string title, float scrollAreaHeight)
: base(title)
{
base.Content.Add(Scroll = new OsuScrollContainer
{
RelativeSizeAxes = Axes.X,
Height = scrollAreaHeight,
Child = Content = FillFlow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
},
});
}
}
}

View File

@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Objects
public static void SnapTo<THitObject>(this THitObject hitObject, IDistanceSnapProvider? snapProvider) public static void SnapTo<THitObject>(this THitObject hitObject, IDistanceSnapProvider? snapProvider)
where THitObject : HitObject, IHasPath where THitObject : HitObject, IHasPath
{ {
hitObject.Path.ExpectedDistance.Value = snapProvider?.GetSnappedDistanceFromDistance(hitObject, (float)hitObject.Path.CalculatedDistance) ?? hitObject.Path.CalculatedDistance; hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(hitObject, (float)hitObject.Path.CalculatedDistance) ?? hitObject.Path.CalculatedDistance;
} }
/// <summary> /// <summary>

View File

@ -486,7 +486,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
Vector2 originalPosition = movementBlueprintOriginalPositions[i]; Vector2 originalPosition = movementBlueprintOriginalPositions[i];
var testPosition = originalPosition + distanceTravelled; var testPosition = originalPosition + distanceTravelled;
var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition); var positionalResult = snapProvider.FindSnappedPosition(testPosition);
if (positionalResult.ScreenSpacePosition == testPosition) continue; if (positionalResult.ScreenSpacePosition == testPosition) continue;
@ -505,7 +505,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled; Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled;
// Retrieve a snapped position. // Retrieve a snapped position.
var result = snapProvider?.SnapScreenSpacePositionToValidTime(movePosition); var result = snapProvider?.FindSnappedPositionAndTime(movePosition);
if (result == null) if (result == null)
{ {

View File

@ -80,7 +80,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
Vector2 normalisedDirection = direction * new Vector2(1f / distance); Vector2 normalisedDirection = direction * new Vector2(1f / distance);
Vector2 snappedPosition = StartPosition + normalisedDirection * radialCount * radius; Vector2 snappedPosition = StartPosition + normalisedDirection * radialCount * radius;
return (snappedPosition, StartTime + SnapProvider.GetSnappedDurationFromDistance(ReferenceObject, (snappedPosition - StartPosition).Length)); return (snappedPosition, StartTime + SnapProvider.FindSnappedDuration(ReferenceObject, (snappedPosition - StartPosition).Length));
} }
} }
} }

View File

@ -214,7 +214,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
private void updatePlacementPosition() private void updatePlacementPosition()
{ {
var snapResult = Composer.SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position); var snapResult = Composer.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position);
// if no time was found from positional snapping, we should still quantize to the beat. // if no time was found from positional snapping, we should still quantize to the beat.
snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null);

View File

@ -2,10 +2,13 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Layout; using osu.Framework.Layout;
using osu.Framework.Utils;
using osuTK; using osuTK;
namespace osu.Game.Screens.Edit.Compose.Components namespace osu.Game.Screens.Edit.Compose.Components
@ -72,33 +75,47 @@ namespace osu.Game.Screens.Edit.Compose.Components
int index = 0; int index = 0;
float currentPosition = startPosition; float currentPosition = startPosition;
while ((endPosition - currentPosition) * Math.Sign(step) > 0) // Make lines the same width independent of display resolution.
float lineWidth = DrawWidth / ScreenSpaceDrawQuad.Width;
List<Box> generatedLines = new List<Box>();
while (Precision.AlmostBigger((endPosition - currentPosition) * Math.Sign(step), 0))
{ {
var gridLine = new Box var gridLine = new Box
{ {
Colour = Colour4.White, Colour = Colour4.White,
Alpha = index == 0 ? 0.3f : 0.1f, Alpha = 0.1f,
EdgeSmoothness = new Vector2(0.2f)
}; };
if (direction == Direction.Horizontal) if (direction == Direction.Horizontal)
{ {
gridLine.Origin = Anchor.CentreLeft;
gridLine.RelativeSizeAxes = Axes.X; gridLine.RelativeSizeAxes = Axes.X;
gridLine.Height = 1; gridLine.Height = lineWidth;
gridLine.Y = currentPosition; gridLine.Y = currentPosition;
} }
else else
{ {
gridLine.Origin = Anchor.TopCentre;
gridLine.RelativeSizeAxes = Axes.Y; gridLine.RelativeSizeAxes = Axes.Y;
gridLine.Width = 1; gridLine.Width = lineWidth;
gridLine.X = currentPosition; gridLine.X = currentPosition;
} }
AddInternal(gridLine); generatedLines.Add(gridLine);
index += 1; index += 1;
currentPosition = startPosition + index * step; currentPosition = startPosition + index * step;
} }
if (generatedLines.Count == 0)
return;
generatedLines.First().Alpha = 0.3f;
generatedLines.Last().Alpha = 0.3f;
AddRangeInternal(generatedLines);
} }
public Vector2 GetSnappedPosition(Vector2 original) public Vector2 GetSnappedPosition(Vector2 original)

View File

@ -19,7 +19,6 @@ using osuTK;
namespace osu.Game.Screens.Edit.Compose.Components.Timeline namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{ {
[Cached(typeof(IPositionSnapProvider))]
[Cached] [Cached]
public class Timeline : ZoomableScrollContainer, IPositionSnapProvider public class Timeline : ZoomableScrollContainer, IPositionSnapProvider
{ {
@ -307,10 +306,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
/// </summary> /// </summary>
public double VisibleRange => track.Length / Zoom; public double VisibleRange => track.Length / Zoom;
public SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => public SnapResult FindSnappedPosition(Vector2 screenSpacePosition) =>
new SnapResult(screenSpacePosition, null); new SnapResult(screenSpacePosition, null);
public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) =>
new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition)))); new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition))));
private double getTimeFromPosition(Vector2 localPosition) => private double getTimeFromPosition(Vector2 localPosition) =>

View File

@ -382,7 +382,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{ {
OnDragHandled?.Invoke(e); OnDragHandled?.Invoke(e);
if (timeline.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition).Time is double time) if (timeline.FindSnappedPositionAndTime(e.ScreenSpaceMousePosition).Time is double time)
{ {
switch (hitObject) switch (hitObject)
{ {

View File

@ -1,10 +1,12 @@
// 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.
using osu.Framework.Allocation;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit.Compose namespace osu.Game.Screens.Edit.Compose
{ {
[Cached]
public interface IPlacementHandler public interface IPlacementHandler
{ {
/// <summary> /// <summary>

View File

@ -6,7 +6,10 @@ using System.Linq;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Input.Bindings;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osuTK; using osuTK;
@ -15,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay
/// <summary> /// <summary>
/// A scrollable list which displays the <see cref="PlaylistItem"/>s in a <see cref="Room"/>. /// A scrollable list which displays the <see cref="PlaylistItem"/>s in a <see cref="Room"/>.
/// </summary> /// </summary>
public class DrawableRoomPlaylist : OsuRearrangeableListContainer<PlaylistItem> public class DrawableRoomPlaylist : OsuRearrangeableListContainer<PlaylistItem>, IKeyBindingHandler<GlobalAction>
{ {
/// <summary> /// <summary>
/// The currently-selected item. Selection is visually represented with a border. /// The currently-selected item. Selection is visually represented with a border.
@ -169,5 +172,78 @@ namespace osu.Game.Screens.OnlinePlay
}); });
protected virtual DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new DrawableRoomPlaylistItem(item); protected virtual DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new DrawableRoomPlaylistItem(item);
protected override void LoadComplete()
{
base.LoadComplete();
// schedules added as the properties may change value while the drawable items haven't been created yet.
SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(scrollToSelection));
Items.BindCollectionChanged((_, __) => Scheduler.AddOnce(scrollToSelection), true);
}
private void scrollToSelection()
{
// SelectedItem and ItemMap/drawable items are managed separately,
// so if the item can't be unmapped to a drawable, don't try to scroll to it.
// best effort is made to not drop any updates, by subscribing to both sources.
if (SelectedItem.Value == null || !ItemMap.TryGetValue(SelectedItem.Value, out var drawableItem))
return;
// ScrollIntoView does not handle non-loaded items appropriately, delay scroll until the item finishes loading.
// see: https://github.com/ppy/osu-framework/issues/5158
if (!drawableItem.IsLoaded)
drawableItem.OnLoadComplete += _ => ScrollContainer.ScrollIntoView(drawableItem);
else
ScrollContainer.ScrollIntoView(drawableItem);
}
#region Key selection logic (shared with BeatmapCarousel and RoomsContainer)
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.SelectNext:
selectNext(1);
return true;
case GlobalAction.SelectPrevious:
selectNext(-1);
return true;
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
private void selectNext(int direction)
{
if (!AllowSelection)
return;
var visibleItems = ListContainer.AsEnumerable().Where(r => r.IsPresent);
PlaylistItem item;
if (SelectedItem.Value == null)
item = visibleItems.FirstOrDefault()?.Model;
else
{
if (direction < 0)
visibleItems = visibleItems.Reverse();
item = visibleItems.SkipWhile(r => r.Model != SelectedItem.Value).Skip(1).FirstOrDefault()?.Model;
}
// we already have a valid selection only change selection if we still have a room to switch to.
if (item != null)
SelectedItem.Value = item;
}
#endregion
} }
} }

View File

@ -16,7 +16,7 @@ namespace osu.Game.Screens.OnlinePlay
public new Func<Mod, bool> IsValidMod public new Func<Mod, bool> IsValidMod
{ {
get => base.IsValidMod; get => base.IsValidMod;
set => base.IsValidMod = m => m.HasImplementation && m.UserPlayable && value.Invoke(m); set => base.IsValidMod = m => m.UserPlayable && value.Invoke(m);
} }
public FreeModSelectScreen() public FreeModSelectScreen()

View File

@ -139,7 +139,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
return base.OnClick(e); return base.OnClick(e);
} }
#region Key selection logic (shared with BeatmapCarousel) #region Key selection logic (shared with BeatmapCarousel and DrawableRoomPlaylist)
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e) public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{ {

View File

@ -129,7 +129,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Height = Header.HEIGHT, Height = Header.HEIGHT,
Child = searchTextBox = new SearchTextBox Child = searchTextBox = new BasicSearchTextBox
{ {
Anchor = Anchor.CentreRight, Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight, Origin = Anchor.CentreRight,

View File

@ -604,34 +604,20 @@ namespace osu.Game.Screens.Select
public void ScrollToSelected(bool immediate = false) => public void ScrollToSelected(bool immediate = false) =>
pendingScrollOperation = immediate ? PendingScrollOperation.Immediate : PendingScrollOperation.Standard; pendingScrollOperation = immediate ? PendingScrollOperation.Immediate : PendingScrollOperation.Standard;
#region Key / button selection logic #region Button selection logic
protected override bool OnKeyDown(KeyDownEvent e)
{
switch (e.Key)
{
case Key.Left:
SelectNext(-1);
return true;
case Key.Right:
SelectNext();
return true;
}
return false;
}
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e) public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{ {
switch (e.Action) switch (e.Action)
{ {
case GlobalAction.SelectNext: case GlobalAction.SelectNext:
SelectNext(1, false); case GlobalAction.SelectNextGroup:
SelectNext(1, e.Action == GlobalAction.SelectNextGroup);
return true; return true;
case GlobalAction.SelectPrevious: case GlobalAction.SelectPrevious:
SelectNext(-1, false); case GlobalAction.SelectPreviousGroup:
SelectNext(-1, e.Action == GlobalAction.SelectPreviousGroup);
return true; return true;
} }

View File

@ -14,7 +14,6 @@ using osu.Game.Screens.Edit.Compose;
namespace osu.Game.Tests.Visual namespace osu.Game.Tests.Visual
{ {
[Cached(Type = typeof(IPlacementHandler))]
public abstract class PlacementBlueprintTestScene : OsuManualInputManagerTestScene, IPlacementHandler public abstract class PlacementBlueprintTestScene : OsuManualInputManagerTestScene, IPlacementHandler
{ {
protected readonly Container HitObjectContainer; protected readonly Container HitObjectContainer;