1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 17:07:38 +08:00

Merge branch 'master' into fix-timeline-zooming

This commit is contained in:
Salman Ahmed 2022-06-03 02:00:09 +03:00
commit 8471b24659
84 changed files with 2199 additions and 3302 deletions

View File

@ -23,4 +23,4 @@ jobs:
SENTRY_URL: https://sentry.ppy.sh/ SENTRY_URL: https://sentry.ppy.sh/
with: with:
environment: production environment: production
version: ${{ github.ref }} version: osu@${{ github.ref_name }}

View File

@ -52,7 +52,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.527.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2022.527.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.528.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2022.530.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Transitive Dependencies"> <ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. --> <!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->

View File

@ -17,11 +17,11 @@ using osu.Framework.Testing.Input;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning;
using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Rulesets.Osu.UI.Cursor;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Skinning; using osu.Game.Skinning;
using osu.Game.Tests.Gameplay;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Osu.Tests namespace osu.Game.Rulesets.Osu.Tests
@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.Tests
public TestSceneGameplayCursor() public TestSceneGameplayCursor()
{ {
var ruleset = new OsuRuleset(); var ruleset = new OsuRuleset();
gameplayState = new GameplayState(CreateBeatmap(ruleset.RulesetInfo), ruleset, Array.Empty<Mod>()); gameplayState = TestGameplayState.Create(ruleset);
AddStep("change background colour", () => AddStep("change background colour", () =>
{ {

View File

@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Mods
if (positionInfo == positionInfos.First()) if (positionInfo == positionInfos.First())
{ {
positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2); positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2);
positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
} }
else else

View File

@ -116,6 +116,7 @@ namespace osu.Game.Rulesets.Osu.Utils
if (!(osuObject is Slider slider)) if (!(osuObject is Slider slider))
return; return;
// No need to update the head and tail circles, since slider handles that when the new slider path is set
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y));
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y));
@ -137,6 +138,7 @@ namespace osu.Game.Rulesets.Osu.Utils
if (!(osuObject is Slider slider)) if (!(osuObject is Slider slider))
return; return;
// No need to update the head and tail circles, since slider handles that when the new slider path is set
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
@ -146,5 +148,41 @@ namespace osu.Game.Rulesets.Osu.Utils
slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value);
} }
/// <summary>
/// Rotate a slider about its start position by the specified angle.
/// </summary>
/// <param name="slider">The slider to be rotated.</param>
/// <param name="rotation">The angle, measured in radians, to rotate the slider by.</param>
public static void RotateSlider(Slider slider, float rotation)
{
void rotateNestedObject(OsuHitObject nested) => nested.Position = rotateVector(nested.Position - slider.Position, rotation) + slider.Position;
// No need to update the head and tail circles, since slider handles that when the new slider path is set
slider.NestedHitObjects.OfType<SliderTick>().ForEach(rotateNestedObject);
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(rotateNestedObject);
var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray();
foreach (var point in controlPoints)
point.Position = rotateVector(point.Position, rotation);
slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value);
}
/// <summary>
/// Rotate a vector by the specified angle.
/// </summary>
/// <param name="vector">The vector to be rotated.</param>
/// <param name="rotation">The angle, measured in radians, to rotate the vector by.</param>
/// <returns>The rotated vector.</returns>
private static Vector2 rotateVector(Vector2 vector, float rotation)
{
float angle = MathF.Atan2(vector.Y, vector.X) + rotation;
float length = vector.Length;
return new Vector2(
length * MathF.Cos(angle),
length * MathF.Sin(angle)
);
}
} }
} }

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Primitives;
using osu.Framework.Utils;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
using osuTK; using osuTK;
@ -37,15 +38,23 @@ namespace osu.Game.Rulesets.Osu.Utils
foreach (OsuHitObject hitObject in hitObjects) foreach (OsuHitObject hitObject in hitObjects)
{ {
Vector2 relativePosition = hitObject.Position - previousPosition; Vector2 relativePosition = hitObject.Position - previousPosition;
float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); float absoluteAngle = MathF.Atan2(relativePosition.Y, relativePosition.X);
float relativeAngle = absoluteAngle - previousAngle; float relativeAngle = absoluteAngle - previousAngle;
positionInfos.Add(new ObjectPositionInfo(hitObject) ObjectPositionInfo positionInfo;
positionInfos.Add(positionInfo = new ObjectPositionInfo(hitObject)
{ {
RelativeAngle = relativeAngle, RelativeAngle = relativeAngle,
DistanceFromPrevious = relativePosition.Length DistanceFromPrevious = relativePosition.Length
}); });
if (hitObject is Slider slider)
{
float absoluteRotation = getSliderRotation(slider);
positionInfo.Rotation = absoluteRotation - absoluteAngle;
absoluteAngle = absoluteRotation;
}
previousPosition = hitObject.EndPosition; previousPosition = hitObject.EndPosition;
previousAngle = absoluteAngle; previousAngle = absoluteAngle;
} }
@ -70,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Utils
if (hitObject is Spinner) if (hitObject is Spinner)
{ {
previous = null; previous = current;
continue; continue;
} }
@ -124,16 +133,23 @@ namespace osu.Game.Rulesets.Osu.Utils
if (previous != null) if (previous != null)
{ {
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre; if (previous.HitObject is Slider s)
Vector2 relativePosition = previous.HitObject.Position - earliestPosition; {
previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); previousAbsoluteAngle = getSliderRotation(s);
}
else
{
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
previousAbsoluteAngle = MathF.Atan2(relativePosition.Y, relativePosition.X);
}
} }
float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle; float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle;
var posRelativeToPrev = new Vector2( var posRelativeToPrev = new Vector2(
current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle), current.PositionInfo.DistanceFromPrevious * MathF.Cos(absoluteAngle),
current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle) current.PositionInfo.DistanceFromPrevious * MathF.Sin(absoluteAngle)
); );
Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre; Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre;
@ -141,6 +157,19 @@ namespace osu.Game.Rulesets.Osu.Utils
posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev); posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
current.PositionModified = lastEndPosition + posRelativeToPrev; current.PositionModified = lastEndPosition + posRelativeToPrev;
if (!(current.HitObject is Slider slider))
return;
absoluteAngle = MathF.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X);
Vector2 centreOfMassOriginal = calculateCentreOfMass(slider);
Vector2 centreOfMassModified = rotateVector(centreOfMassOriginal, current.PositionInfo.Rotation + absoluteAngle - getSliderRotation(slider));
centreOfMassModified = RotateAwayFromEdge(current.PositionModified, centreOfMassModified);
float relativeRotation = MathF.Atan2(centreOfMassModified.Y, centreOfMassModified.X) - MathF.Atan2(centreOfMassOriginal.Y, centreOfMassOriginal.X);
if (!Precision.AlmostEquals(relativeRotation, 0))
RotateSlider(slider, relativeRotation);
} }
/// <summary> /// <summary>
@ -172,13 +201,13 @@ namespace osu.Game.Rulesets.Osu.Utils
var previousPosition = workingObject.PositionModified; var previousPosition = workingObject.PositionModified;
// Clamp slider position to the placement area // Clamp slider position to the placement area
// If the slider is larger than the playfield, force it to stay at the original position // If the slider is larger than the playfield, at least make sure that the head circle is inside the playfield
float newX = possibleMovementBounds.Width < 0 float newX = possibleMovementBounds.Width < 0
? workingObject.PositionOriginal.X ? Math.Clamp(possibleMovementBounds.Left, 0, OsuPlayfield.BASE_SIZE.X)
: Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right); : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
float newY = possibleMovementBounds.Height < 0 float newY = possibleMovementBounds.Height < 0
? workingObject.PositionOriginal.Y ? Math.Clamp(possibleMovementBounds.Top, 0, OsuPlayfield.BASE_SIZE.Y)
: Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom); : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
slider.Position = workingObject.PositionModified = new Vector2(newX, newY); slider.Position = workingObject.PositionModified = new Vector2(newX, newY);
@ -287,6 +316,45 @@ namespace osu.Game.Rulesets.Osu.Utils
); );
} }
/// <summary>
/// Estimate the centre of mass of a slider relative to its start position.
/// </summary>
/// <param name="slider">The slider to process.</param>
/// <returns>The centre of mass of the slider.</returns>
private static Vector2 calculateCentreOfMass(Slider slider)
{
const double sample_step = 50;
// just sample the start and end positions if the slider is too short
if (slider.Distance <= sample_step)
{
return Vector2.Divide(slider.Path.PositionAt(1), 2);
}
int count = 0;
Vector2 sum = Vector2.Zero;
double pathDistance = slider.Distance;
for (double i = 0; i < pathDistance; i += sample_step)
{
sum += slider.Path.PositionAt(i / pathDistance);
count++;
}
return sum / count;
}
/// <summary>
/// Get the absolute rotation of a slider, defined as the angle from its start position to the end of its path.
/// </summary>
/// <param name="slider">The slider to process.</param>
/// <returns>The angle in radians.</returns>
private static float getSliderRotation(Slider slider)
{
var endPositionVector = slider.Path.PositionAt(1);
return MathF.Atan2(endPositionVector.Y, endPositionVector.X);
}
public class ObjectPositionInfo public class ObjectPositionInfo
{ {
/// <summary> /// <summary>
@ -309,6 +377,13 @@ namespace osu.Game.Rulesets.Osu.Utils
/// </remarks> /// </remarks>
public float DistanceFromPrevious { get; set; } public float DistanceFromPrevious { get; set; }
/// <summary>
/// The rotation of the hit object, relative to its jump angle.
/// For sliders, this is defined as the angle from the slider's start position to the end of its path, relative to its jump angle.
/// For hit circles and spinners, this property is ignored.
/// </summary>
public float Rotation { get; set; }
/// <summary> /// <summary>
/// The hit object associated with this <see cref="ObjectPositionInfo"/>. /// The hit object associated with this <see cref="ObjectPositionInfo"/>.
/// </summary> /// </summary>

View File

@ -61,13 +61,13 @@ namespace osu.Game.Tests.Gameplay
Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1));
// No header shouldn't cause any change // No header shouldn't cause any change
scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame()); scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame());
Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(1_000_000)); Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(1_000_000));
Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1));
// Reset with a miss instead. // Reset with a miss instead.
scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame
{ {
Header = new FrameHeader(0, 0, 0, new Dictionary<HitResult, int> { { HitResult.Miss, 1 } }, DateTimeOffset.Now) Header = new FrameHeader(0, 0, 0, new Dictionary<HitResult, int> { { HitResult.Miss, 1 } }, DateTimeOffset.Now)
}); });
@ -76,7 +76,7 @@ namespace osu.Game.Tests.Gameplay
Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1));
// Reset with no judged hit. // Reset with no judged hit.
scoreProcessor.ResetFromReplayFrame(new OsuRuleset(), new OsuReplayFrame scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame
{ {
Header = new FrameHeader(0, 0, 0, new Dictionary<HitResult, int>(), DateTimeOffset.Now) Header = new FrameHeader(0, 0, 0, new Dictionary<HitResult, int>(), DateTimeOffset.Now)
}); });

View File

@ -77,6 +77,12 @@ namespace osu.Game.Tests.Visual.Editing
timingInfo.Text = $"offset: {selectedGroup.Value.Time:N2} bpm: {selectedGroup.Value.ControlPoints.OfType<TimingControlPoint>().First().BPM:N2}"; timingInfo.Text = $"offset: {selectedGroup.Value.Time:N2} bpm: {selectedGroup.Value.ControlPoints.OfType<TimingControlPoint>().First().BPM:N2}";
} }
[Test]
public void TestNoop()
{
AddStep("do nothing", () => { });
}
[Test] [Test]
public void TestTapThenReset() public void TestTapThenReset()
{ {

View File

@ -4,7 +4,9 @@
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Compose.Components.Timeline;
@ -18,6 +20,28 @@ namespace osu.Game.Tests.Visual.Editing
{ {
public override Drawable CreateTestComponent() => new TimelineBlueprintContainer(Composer); public override Drawable CreateTestComponent() => new TimelineBlueprintContainer(Composer);
[Test]
public void TestContextMenu()
{
TimelineHitObjectBlueprint blueprint;
AddStep("add object", () =>
{
EditorBeatmap.Clear();
EditorBeatmap.Add(new HitCircle { StartTime = 3000 });
});
AddStep("click object", () =>
{
blueprint = this.ChildrenOfType<TimelineHitObjectBlueprint>().Single();
InputManager.MoveMouseTo(blueprint);
InputManager.Click(MouseButton.Left);
});
AddStep("right click", () => InputManager.Click(MouseButton.Right));
AddAssert("context menu open", () => this.ChildrenOfType<OsuContextMenu>().SingleOrDefault()?.State == MenuState.Open);
}
[Test] [Test]
public void TestDisallowZeroDurationObjects() public void TestDisallowZeroDurationObjects()
{ {

View File

@ -1,14 +1,18 @@
// 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.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Edit.Timing.RowAttributes;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing namespace osu.Game.Tests.Visual.Editing
{ {
@ -22,6 +26,8 @@ namespace osu.Game.Tests.Visual.Editing
[Cached] [Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
private TimingScreen timingScreen;
protected override bool ScrollUsingMouseWheel => false; protected override bool ScrollUsingMouseWheel => false;
public TestSceneTimingScreen() public TestSceneTimingScreen()
@ -36,12 +42,54 @@ namespace osu.Game.Tests.Visual.Editing
Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap);
Beatmap.Disabled = true; Beatmap.Disabled = true;
Child = new TimingScreen Child = timingScreen = new TimingScreen
{ {
State = { Value = Visibility.Visible }, State = { Value = Visibility.Visible },
}; };
} }
[SetUpSteps]
public void SetUpSteps()
{
AddStep("Stop clock", () => Clock.Stop());
AddUntilStep("wait for rows to load", () => Child.ChildrenOfType<EffectRowAttribute>().Any());
}
[Test]
public void TestTrackingCurrentTimeWhileRunning()
{
AddStep("Select first effect point", () =>
{
InputManager.MoveMouseTo(Child.ChildrenOfType<EffectRowAttribute>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670);
AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670);
AddStep("Seek to just before next point", () => Clock.Seek(69000));
AddStep("Start clock", () => Clock.Start());
AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670);
}
[Test]
public void TestTrackingCurrentTimeWhilePaused()
{
AddStep("Select first effect point", () =>
{
InputManager.MoveMouseTo(Child.ChildrenOfType<EffectRowAttribute>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670);
AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670);
AddStep("Seek to later", () => Clock.Seek(80000));
AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670);
}
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
Beatmap.Disabled = false; Beatmap.Disabled = false;

View File

@ -8,6 +8,7 @@ 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.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
@ -38,25 +39,29 @@ namespace osu.Game.Tests.Visual.Editing
Composer = playable.BeatmapInfo.Ruleset.CreateInstance().CreateHitObjectComposer().With(d => d.Alpha = 0); Composer = playable.BeatmapInfo.Ruleset.CreateInstance().CreateHitObjectComposer().With(d => d.Alpha = 0);
AddRange(new Drawable[] Add(new OsuContextMenuContainer
{ {
EditorBeatmap, RelativeSizeAxes = Axes.Both,
Composer, Children = new Drawable[]
new FillFlowContainer
{ {
AutoSizeAxes = Axes.Both, EditorBeatmap,
Direction = FillDirection.Vertical, Composer,
Spacing = new Vector2(0, 5), new FillFlowContainer
Children = new Drawable[]
{ {
new StartStopButton(), AutoSizeAxes = Axes.Both,
new AudioVisualiser(), Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 5),
Children = new Drawable[]
{
new StartStopButton(),
new AudioVisualiser(),
}
},
TimelineArea = new TimelineArea(CreateTestComponent())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
} }
},
TimelineArea = new TimelineArea(CreateTestComponent())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
} }
}); });
} }

View File

@ -6,7 +6,6 @@ using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Graphics.Containers;
using osu.Framework.Lists; using osu.Framework.Lists;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Timing; using osu.Framework.Timing;
@ -22,7 +21,6 @@ using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning; using osu.Game.Skinning;
using osu.Game.Storyboards; using osu.Game.Storyboards;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
{ {
@ -33,18 +31,6 @@ namespace osu.Game.Tests.Visual.Gameplay
[Resolved] [Resolved]
private SkinManager skinManager { get; set; } private SkinManager skinManager { get; set; }
[Cached]
private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset());
[Cached(typeof(HealthProcessor))]
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
[Cached]
private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset());
[Cached]
private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock());
protected override bool HasCustomSteps => true; protected override bool HasCustomSteps => true;
[Test] [Test]
@ -81,11 +67,19 @@ namespace osu.Game.Tests.Visual.Gameplay
if (expectedComponentsContainer == null) if (expectedComponentsContainer == null)
return false; return false;
var expectedComponentsAdjustmentContainer = new Container var expectedComponentsAdjustmentContainer = new DependencyProvidingContainer
{ {
Position = actualComponentsContainer.Parent.ToSpaceOfOtherDrawable(actualComponentsContainer.DrawPosition, Content), Position = actualComponentsContainer.Parent.ToSpaceOfOtherDrawable(actualComponentsContainer.DrawPosition, Content),
Size = actualComponentsContainer.DrawSize, Size = actualComponentsContainer.DrawSize,
Child = expectedComponentsContainer, Child = expectedComponentsContainer,
// proxy the same required dependencies that `actualComponentsContainer` is using.
CachedDependencies = new (Type, object)[]
{
(typeof(ScoreProcessor), actualComponentsContainer.Dependencies.Get<ScoreProcessor>()),
(typeof(HealthProcessor), actualComponentsContainer.Dependencies.Get<HealthProcessor>()),
(typeof(GameplayState), actualComponentsContainer.Dependencies.Get<GameplayState>()),
(typeof(GameplayClock), actualComponentsContainer.Dependencies.Get<GameplayClock>())
},
}; };
Add(expectedComponentsAdjustmentContainer); Add(expectedComponentsAdjustmentContainer);

View File

@ -15,7 +15,7 @@ using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Skinning; using osu.Game.Skinning;
using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Gameplay;
using osuTK.Input; using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
@ -33,7 +33,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
[Cached] [Cached]
private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()); private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset());
[Cached] [Cached]
private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock());

View File

@ -1,7 +1,6 @@
// 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 NUnit.Framework; using NUnit.Framework;
@ -14,16 +13,15 @@ using osu.Framework.Input.Events;
using osu.Framework.Input.StateChanges; using osu.Framework.Input.StateChanges;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Tests.Gameplay;
using osu.Game.Tests.Mods; using osu.Game.Tests.Mods;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -41,7 +39,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private TestReplayRecorder recorder; private TestReplayRecorder recorder;
[Cached] [Cached]
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>()); private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset());
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public void SetUpSteps()

View File

@ -11,7 +11,7 @@ using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Skinning.Editor; using osu.Game.Skinning.Editor;
using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Gameplay;
using osuTK.Input; using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
[Cached] [Cached]
private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()); private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset());
[Cached] [Cached]
private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock());

View File

@ -16,7 +16,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Gameplay;
using osuTK.Input; using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
@ -32,7 +32,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
[Cached] [Cached]
private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()); private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset());
[Cached] [Cached]
private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock());

View File

@ -18,8 +18,8 @@ using osu.Game.Rulesets.UI;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens; using osu.Game.Screens;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Beatmaps.IO;
using osu.Game.Tests.Gameplay;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Tests.Visual.Spectator; using osu.Game.Tests.Visual.Spectator;
using osuTK; using osuTK;
@ -259,12 +259,15 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test] [Test]
public void TestFinalFramesPurgedBeforeEndingPlay() public void TestFinalFramesPurgedBeforeEndingPlay()
{ {
AddStep("begin playing", () => spectatorClient.BeginPlaying(new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()), new Score())); AddStep("begin playing", () => spectatorClient.BeginPlaying(TestGameplayState.Create(new OsuRuleset()), new Score()));
AddStep("send frames and finish play", () => AddStep("send frames and finish play", () =>
{ {
spectatorClient.HandleFrame(new OsuReplayFrame(1000, Vector2.Zero)); spectatorClient.HandleFrame(new OsuReplayFrame(1000, Vector2.Zero));
spectatorClient.EndPlaying(new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()) { HasPassed = true });
var completedGameplayState = TestGameplayState.Create(new OsuRuleset());
completedGameplayState.HasPassed = true;
spectatorClient.EndPlaying(completedGameplayState);
}); });
// We can't access API because we're an "online" test. // We can't access API because we're an "online" test.

View File

@ -20,13 +20,13 @@ using osu.Game.Online.Spectator;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Replays.Legacy; using osu.Game.Replays.Legacy;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Tests.Gameplay;
using osu.Game.Tests.Mods; using osu.Game.Tests.Mods;
using osu.Game.Tests.Visual.Spectator; using osu.Game.Tests.Visual.Spectator;
using osuTK; using osuTK;
@ -65,7 +65,7 @@ namespace osu.Game.Tests.Visual.Gameplay
CachedDependencies = new[] CachedDependencies = new[]
{ {
(typeof(SpectatorClient), (object)(spectatorClient = new TestSpectatorClient())), (typeof(SpectatorClient), (object)(spectatorClient = new TestSpectatorClient())),
(typeof(GameplayState), new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>())) (typeof(GameplayState), TestGameplayState.Create(new OsuRuleset()))
}, },
Children = new Drawable[] Children = new Drawable[]
{ {

View File

@ -40,7 +40,7 @@ namespace osu.Game.Tests.Visual.Navigation
typeof(DashboardOverlay), typeof(DashboardOverlay),
typeof(NewsOverlay), typeof(NewsOverlay),
typeof(ChannelManager), typeof(ChannelManager),
typeof(ChatOverlayV2), typeof(ChatOverlay),
typeof(SettingsOverlay), typeof(SettingsOverlay),
typeof(UserProfileOverlay), typeof(UserProfileOverlay),
typeof(BeatmapSetOverlay), typeof(BeatmapSetOverlay),

View File

@ -86,9 +86,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestOverlaysAlwaysClosed() public void TestOverlaysAlwaysClosed()
{ {
ChatOverlayV2 chat = null; ChatOverlay chat = null;
AddUntilStep("is at menu", () => Game.ScreenStack.CurrentScreen is MainMenu); AddUntilStep("is at menu", () => Game.ScreenStack.CurrentScreen is MainMenu);
AddUntilStep("wait for chat load", () => (chat = Game.ChildrenOfType<ChatOverlayV2>().SingleOrDefault()) != null); AddUntilStep("wait for chat load", () => (chat = Game.ChildrenOfType<ChatOverlay>().SingleOrDefault()) != null);
AddStep("show chat", () => InputManager.Key(Key.F8)); AddStep("show chat", () => InputManager.Key(Key.F8));

View File

@ -87,7 +87,7 @@ namespace osu.Game.Tests.Visual.Online
{ {
leaveText.Text = $"OnRequestLeave: {channel.Name}"; leaveText.Text = $"OnRequestLeave: {channel.Name}";
leaveText.FadeOutFromOne(1000, Easing.InQuint); leaveText.FadeOutFromOne(1000, Easing.InQuint);
selected.Value = null; selected.Value = channelList.ChannelListingChannel;
channelList.RemoveChannel(channel); channelList.RemoveChannel(channel);
}; };
@ -112,6 +112,12 @@ namespace osu.Game.Tests.Visual.Online
for (int i = 0; i < 10; i++) for (int i = 0; i < 10; i++)
channelList.AddChannel(createRandomPrivateChannel()); channelList.AddChannel(createRandomPrivateChannel());
}); });
AddStep("Add Announce Channels", () =>
{
for (int i = 0; i < 2; i++)
channelList.AddChannel(createRandomAnnounceChannel());
});
} }
[Test] [Test]
@ -170,5 +176,16 @@ namespace osu.Game.Tests.Visual.Online
Username = $"test user {id}", Username = $"test user {id}",
}); });
} }
private Channel createRandomAnnounceChannel()
{
int id = RNG.Next(0, 10000);
return new Channel
{
Name = $"Announce {id}",
Type = ChannelType.Announce,
Id = id,
};
}
} }
} }

View File

@ -1,129 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Utils;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Overlays.Chat.Tabs;
using osuTK.Graphics;
namespace osu.Game.Tests.Visual.Online
{
public class TestSceneChannelTabControl : OsuTestScene
{
private readonly TestTabControl channelTabControl;
public TestSceneChannelTabControl()
{
SpriteText currentText;
Add(new Container
{
RelativeSizeAxes = Axes.X,
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Children = new Drawable[]
{
channelTabControl = new TestTabControl
{
RelativeSizeAxes = Axes.X,
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Height = 50
},
new Box
{
Colour = Color4.Black.Opacity(0.1f),
RelativeSizeAxes = Axes.X,
Height = 50,
Depth = -1,
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
}
}
});
Add(new Container
{
Origin = Anchor.TopLeft,
Anchor = Anchor.TopLeft,
Children = new Drawable[]
{
currentText = new OsuSpriteText
{
Text = "Currently selected channel:"
}
}
});
channelTabControl.OnRequestLeave += channel => channelTabControl.RemoveChannel(channel);
channelTabControl.Current.ValueChanged += channel => currentText.Text = "Currently selected channel: " + channel.NewValue;
AddStep("Add random private channel", addRandomPrivateChannel);
AddAssert("There is only one channels", () => channelTabControl.Items.Count == 2);
AddRepeatStep("Add 3 random private channels", addRandomPrivateChannel, 3);
AddAssert("There are four channels", () => channelTabControl.Items.Count == 5);
AddStep("Add random public channel", () => addChannel(RNG.Next().ToString()));
AddRepeatStep("Select a random channel", () =>
{
List<Channel> validChannels = channelTabControl.Items.Where(c => !(c is ChannelSelectorTabItem.ChannelSelectorTabChannel)).ToList();
channelTabControl.SelectChannel(validChannels[RNG.Next(0, validChannels.Count)]);
}, 20);
Channel channelBefore = null;
AddStep("set first channel", () => channelTabControl.SelectChannel(channelBefore = channelTabControl.Items.First(c => !(c is ChannelSelectorTabItem.ChannelSelectorTabChannel))));
AddStep("select selector tab", () => channelTabControl.SelectChannel(channelTabControl.Items.Single(c => c is ChannelSelectorTabItem.ChannelSelectorTabChannel)));
AddAssert("selector tab is active", () => channelTabControl.ChannelSelectorActive.Value);
AddAssert("check channel unchanged", () => channelBefore == channelTabControl.Current.Value);
AddStep("set second channel", () => channelTabControl.SelectChannel(channelTabControl.Items.GetNext(channelBefore)));
AddAssert("selector tab is inactive", () => !channelTabControl.ChannelSelectorActive.Value);
AddUntilStep("remove all channels", () =>
{
foreach (var item in channelTabControl.Items.ToList())
{
if (item is ChannelSelectorTabItem.ChannelSelectorTabChannel)
continue;
channelTabControl.RemoveChannel(item);
return false;
}
return true;
});
AddAssert("selector tab is active", () => channelTabControl.ChannelSelectorActive.Value);
}
private void addRandomPrivateChannel() =>
channelTabControl.AddChannel(new Channel(new APIUser
{
Id = RNG.Next(1000, 10000000),
Username = "Test User " + RNG.Next(1000)
}));
private void addChannel(string name) =>
channelTabControl.AddChannel(new Channel
{
Type = ChannelType.Public,
Name = name
});
private class TestTabControl : ChannelTabControl
{
public void SelectChannel(Channel channel) => base.SelectTab(TabMap[channel]);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,572 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using JetBrains.Annotations;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
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 TestChatOverlayV2 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 TestChatOverlayV2(),
},
};
});
[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 TestBasic()
{
AddStep("Show overlay with channel", () =>
{
chatOverlay.Show();
Channel joinedChannel = channelManager.JoinChannel(testChannel1);
channelManager.CurrentChannel.Value = joinedChannel;
});
AddAssert("Overlay is visible", () => chatOverlay.State.Value == Visibility.Visible);
AddUntilStep("Channel is visible", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
}
[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", () => listingIsVisible);
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddUntilStep("Channel 1 is visible", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
}
[Test]
public void TestSearchInListing()
{
AddStep("Show overlay", () => chatOverlay.Show());
AddAssert("Listing is visible", () => listingIsVisible);
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));
AddUntilStep("Channel 1 is visible", () => channelIsVisible && currentDrawableChannel.Channel == 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));
AddUntilStep("Channel 2 is visible", () => channelIsVisible && 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));
AddUntilStep("Channel 2 is visible", () => channelIsVisible && 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));
AddUntilStep("Channel 1 is visible", () => channelIsVisible && currentDrawableChannel.Channel == 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));
AddUntilStep("Channel 1 is visible", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
}
[Test]
public void TestTextBoxRetainsFocus()
{
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 drawable channel", () => clickDrawable(currentDrawableChannel));
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Click selector", () => clickDrawable(channelSelectorButton));
AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox);
AddStep("Click listing", () => clickDrawable(chatOverlay.ChildrenOfType<ChannelListing>().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);
}
[Test]
public void TestSlowLoadingChannel()
{
AddStep("Show overlay (slow-loading)", () =>
{
chatOverlay.Show();
chatOverlay.SlowLoading = true;
});
AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1));
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddAssert("Channel 1 loading", () => !channelIsVisible && chatOverlay.GetSlowLoadingChannel(testChannel1).LoadState == LoadState.Loading);
AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2));
AddStep("Select channel 2", () => clickDrawable(getChannelListItem(testChannel2)));
AddAssert("Channel 2 loading", () => !channelIsVisible && chatOverlay.GetSlowLoadingChannel(testChannel2).LoadState == LoadState.Loading);
AddStep("Finish channel 1 load", () => chatOverlay.GetSlowLoadingChannel(testChannel1).LoadEvent.Set());
AddAssert("Channel 1 ready", () => chatOverlay.GetSlowLoadingChannel(testChannel1).LoadState == LoadState.Ready);
AddAssert("Channel 1 not displayed", () => !channelIsVisible);
AddStep("Finish channel 2 load", () => chatOverlay.GetSlowLoadingChannel(testChannel2).LoadEvent.Set());
AddAssert("Channel 2 loaded", () => chatOverlay.GetSlowLoadingChannel(testChannel2).IsLoaded);
AddAssert("Channel 2 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel2);
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
AddAssert("Channel 1 loaded", () => chatOverlay.GetSlowLoadingChannel(testChannel1).IsLoaded);
AddAssert("Channel 1 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
}
[Test]
public void TestKeyboardCloseAndRestoreChannel()
{
AddStep("Show overlay with channel 1", () =>
{
channelManager.JoinChannel(testChannel1);
chatOverlay.Show();
});
AddAssert("Channel 1 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
AddStep("Press document close keys", () => InputManager.Keys(PlatformAction.DocumentClose));
AddAssert("Listing is visible", () => listingIsVisible);
AddStep("Press tab restore keys", () => InputManager.Keys(PlatformAction.TabRestore));
AddAssert("Channel 1 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
}
[Test]
public void TestKeyboardNewChannel()
{
AddStep("Show overlay with channel 1", () =>
{
channelManager.JoinChannel(testChannel1);
chatOverlay.Show();
});
AddAssert("Channel 1 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
AddStep("Press tab new keys", () => InputManager.Keys(PlatformAction.TabNew));
AddAssert("Listing is visible", () => listingIsVisible);
}
[Test]
public void TestKeyboardNextChannel()
{
Channel pmChannel1 = createPrivateChannel();
Channel pmChannel2 = createPrivateChannel();
AddStep("Show overlay with channels", () =>
{
channelManager.JoinChannel(testChannel1);
channelManager.JoinChannel(testChannel2);
channelManager.JoinChannel(pmChannel1);
channelManager.JoinChannel(pmChannel2);
chatOverlay.Show();
});
AddAssert("Channel 1 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
AddStep("Press document next keys", () => InputManager.Keys(PlatformAction.DocumentNext));
AddAssert("Channel 2 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel2);
AddStep("Press document next keys", () => InputManager.Keys(PlatformAction.DocumentNext));
AddAssert("PM Channel 1 displayed", () => channelIsVisible && currentDrawableChannel.Channel == pmChannel1);
AddStep("Press document next keys", () => InputManager.Keys(PlatformAction.DocumentNext));
AddAssert("PM Channel 2 displayed", () => channelIsVisible && currentDrawableChannel.Channel == pmChannel2);
AddStep("Press document next keys", () => InputManager.Keys(PlatformAction.DocumentNext));
AddAssert("Channel 1 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1);
}
private bool listingIsVisible =>
chatOverlay.ChildrenOfType<ChannelListing>().Single().State.Value == Visibility.Visible;
private bool loadingIsVisible =>
chatOverlay.ChildrenOfType<LoadingLayer>().Single().State.Value == Visibility.Visible;
private bool channelIsVisible =>
!listingIsVisible && !loadingIsVisible;
private DrawableChannel currentDrawableChannel =>
chatOverlay.ChildrenOfType<DrawableChannel>().Single();
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 ChannelListItem channelSelectorButton =>
chatOverlay.ChildrenOfType<ChannelListItem>().Single(item => item.Channel is ChannelListing.ChannelListingChannel);
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,
};
private Channel createPrivateChannel()
{
int id = RNG.Next(0, 10000);
return new Channel(new APIUser
{
Id = id,
Username = $"test user {id}",
});
}
private class TestChatOverlayV2 : ChatOverlayV2
{
public bool SlowLoading { get; set; }
public SlowLoadingDrawableChannel GetSlowLoadingChannel(Channel channel) => DrawableChannels.OfType<SlowLoadingDrawableChannel>().Single(c => c.Channel == channel);
protected override ChatOverlayDrawableChannel CreateDrawableChannel(Channel newChannel)
{
return SlowLoading
? new SlowLoadingDrawableChannel(newChannel)
: new ChatOverlayDrawableChannel(newChannel);
}
}
private class SlowLoadingDrawableChannel : ChatOverlayDrawableChannel
{
public readonly ManualResetEventSlim LoadEvent = new ManualResetEventSlim();
public SlowLoadingDrawableChannel([NotNull] Channel channel)
: base(channel)
{
}
[BackgroundDependencyLoader]
private void load()
{
LoadEvent.Wait(10000);
}
}
}
}

View File

@ -11,6 +11,7 @@ using osu.Framework.Testing;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Overlays;
using osu.Game.Overlays.Dashboard; using osu.Game.Overlays.Dashboard;
using osu.Game.Tests.Visual.Spectator; using osu.Game.Tests.Visual.Spectator;
using osu.Game.Users; using osu.Game.Users;
@ -42,7 +43,8 @@ namespace osu.Game.Tests.Visual.Online
CachedDependencies = new (Type, object)[] CachedDependencies = new (Type, object)[]
{ {
(typeof(SpectatorClient), spectatorClient), (typeof(SpectatorClient), spectatorClient),
(typeof(UserLookupCache), lookupCache) (typeof(UserLookupCache), lookupCache),
(typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Purple)),
}, },
Child = currentlyPlaying = new CurrentlyPlayingDisplay Child = currentlyPlaying = new CurrentlyPlayingDisplay
{ {

View File

@ -208,7 +208,7 @@ namespace osu.Game.Tests.Visual.Online
}; };
[Cached] [Cached]
public ChatOverlayV2 ChatOverlay { get; } = new ChatOverlayV2(); public ChatOverlay ChatOverlay { get; } = new ChatOverlay();
private readonly MessageNotifier messageNotifier = new MessageNotifier(); private readonly MessageNotifier messageNotifier = new MessageNotifier();

View File

@ -173,6 +173,8 @@ namespace osu.Game.Tests.Visual.Playlists
{ {
AddUntilStep("wait for scores loaded", () => AddUntilStep("wait for scores loaded", () =>
requestComplete requestComplete
// request handler may need to fire more than once to get scores.
&& totalCount > 0
&& resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount && resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount
&& resultsScreen.ScorePanelList.AllPanelsVisible); && resultsScreen.ScorePanelList.AllPanelsVisible);
AddWaitStep("wait for display", 5); AddWaitStep("wait for display", 5);

View File

@ -6,6 +6,7 @@ using NUnit.Framework;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
@ -83,7 +84,7 @@ namespace osu.Game.Tests.Visual.Settings
AddStep("clear label", () => textBox.LabelText = default); AddStep("clear label", () => textBox.LabelText = default);
AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1));
AddStep("set warning text", () => textBox.WarningText = "This is some very important warning text! Hopefully it doesn't break the alignment of the default value indicator..."); AddStep("set warning text", () => textBox.SetNoticeText("This is some very important warning text! Hopefully it doesn't break the alignment of the default value indicator...", true));
AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1));
} }
@ -129,16 +130,18 @@ namespace osu.Game.Tests.Visual.Settings
SettingsNumberBox numberBox = null; SettingsNumberBox numberBox = null;
AddStep("create settings item", () => Child = numberBox = new SettingsNumberBox()); AddStep("create settings item", () => Child = numberBox = new SettingsNumberBox());
AddAssert("warning text not created", () => !numberBox.ChildrenOfType<SettingsNoticeText>().Any()); AddAssert("warning text not created", () => !numberBox.ChildrenOfType<LinkFlowContainer>().Any());
AddStep("set warning text", () => numberBox.WarningText = "this is a warning!"); AddStep("set warning text", () => numberBox.SetNoticeText("this is a warning!", true));
AddAssert("warning text created", () => numberBox.ChildrenOfType<SettingsNoticeText>().Single().Alpha == 1); AddAssert("warning text created", () => numberBox.ChildrenOfType<LinkFlowContainer>().Single().Alpha == 1);
AddStep("unset warning text", () => numberBox.WarningText = default); AddStep("unset warning text", () => numberBox.ClearNoticeText());
AddAssert("warning text hidden", () => numberBox.ChildrenOfType<SettingsNoticeText>().Single().Alpha == 0); AddAssert("warning text hidden", () => !numberBox.ChildrenOfType<LinkFlowContainer>().Any());
AddStep("set warning text again", () => numberBox.WarningText = "another warning!"); AddStep("set warning text again", () => numberBox.SetNoticeText("another warning!", true));
AddAssert("warning text shown again", () => numberBox.ChildrenOfType<SettingsNoticeText>().Single().Alpha == 1); AddAssert("warning text shown again", () => numberBox.ChildrenOfType<LinkFlowContainer>().Single().Alpha == 1);
AddStep("set non warning text", () => numberBox.SetNoticeText("you did good!"));
} }
} }
} }

View File

@ -85,6 +85,8 @@ namespace osu.Game.Beatmaps.Drawables
downloadTrackers.Add(beatmapDownloadTracker); downloadTrackers.Add(beatmapDownloadTracker);
AddInternal(beatmapDownloadTracker); AddInternal(beatmapDownloadTracker);
// Note that this is downloading the beatmaps even if they are already downloaded.
// We could rely more on `BeatmapDownloadTracker`'s exposed state to avoid this.
beatmapDownloader.Download(beatmapSet); beatmapDownloader.Download(beatmapSet);
} }
} }

View File

@ -46,7 +46,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation);
SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlayV2.DEFAULT_HEIGHT, 0.2f, 1f); SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f);
SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal); SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal);

View File

@ -91,7 +91,7 @@ namespace osu.Game.Database
} }
} }
private void performLookup() private async Task performLookup()
{ {
// contains at most 50 unique IDs from tasks, which is used to perform the lookup. // contains at most 50 unique IDs from tasks, which is used to perform the lookup.
var nextTaskBatch = new Dictionary<TLookup, List<TaskCompletionSource<TValue>>>(); var nextTaskBatch = new Dictionary<TLookup, List<TaskCompletionSource<TValue>>>();
@ -127,7 +127,7 @@ namespace osu.Game.Database
// rather than queueing, we maintain our own single-threaded request stream. // rather than queueing, we maintain our own single-threaded request stream.
// todo: we probably want retry logic here. // todo: we probably want retry logic here.
api.Perform(request); await api.PerformAsync(request).ConfigureAwait(false);
finishPendingTask(); finishPendingTask();

View File

@ -1,11 +1,15 @@
// 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.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Microsoft.Toolkit.HighPerformance;
using osu.Framework.Extensions;
using osu.Framework.IO.Stores; using osu.Framework.IO.Stores;
using SharpCompress.Archives.Zip; using SharpCompress.Archives.Zip;
using SixLabors.ImageSharp.Memory;
namespace osu.Game.IO.Archives namespace osu.Game.IO.Archives
{ {
@ -27,15 +31,12 @@ namespace osu.Game.IO.Archives
if (entry == null) if (entry == null)
throw new FileNotFoundException(); throw new FileNotFoundException();
// allow seeking var owner = MemoryAllocator.Default.Allocate<byte>((int)entry.Size);
MemoryStream copy = new MemoryStream();
using (Stream s = entry.OpenEntryStream()) using (Stream s = entry.OpenEntryStream())
s.CopyTo(copy); s.ReadToFill(owner.Memory.Span);
copy.Position = 0; return new MemoryOwnerMemoryStream(owner);
return copy;
} }
public override void Dispose() public override void Dispose()
@ -45,5 +46,48 @@ namespace osu.Game.IO.Archives
} }
public override IEnumerable<string> Filenames => archive.Entries.Select(e => e.Key).ExcludeSystemFileNames(); public override IEnumerable<string> Filenames => archive.Entries.Select(e => e.Key).ExcludeSystemFileNames();
private class MemoryOwnerMemoryStream : Stream
{
private readonly IMemoryOwner<byte> owner;
private readonly Stream stream;
public MemoryOwnerMemoryStream(IMemoryOwner<byte> owner)
{
this.owner = owner;
stream = owner.Memory.AsStream();
}
protected override void Dispose(bool disposing)
{
owner?.Dispose();
base.Dispose(disposing);
}
public override void Flush() => stream.Flush();
public override int Read(byte[] buffer, int offset, int count) => stream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => stream.Seek(offset, origin);
public override void SetLength(long value) => stream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => stream.Write(buffer, offset, count);
public override bool CanRead => stream.CanRead;
public override bool CanSeek => stream.CanSeek;
public override bool CanWrite => stream.CanWrite;
public override long Length => stream.Length;
public override long Position
{
get => stream.Position;
set => stream.Position = value;
}
}
} }
} }

View File

@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class LayoutSettingsStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.LayoutSettings";
/// <summary>
/// "Checking for fullscreen capabilities..."
/// </summary>
public static LocalisableString CheckingForFullscreenCapabilities => new TranslatableString(getKey(@"checking_for_fullscreen_capabilities"), @"Checking for fullscreen capabilities...");
/// <summary>
/// "osu! is running exclusive fullscreen, guaranteeing low latency!"
/// </summary>
public static LocalisableString OsuIsRunningExclusiveFullscreen => new TranslatableString(getKey(@"osu_is_running_exclusive_fullscreen"), @"osu! is running exclusive fullscreen, guaranteeing low latency!");
/// <summary>
/// "Unable to run exclusive fullscreen. You&#39;ll still experience some input latency."
/// </summary>
public static LocalisableString UnableToRunExclusiveFullscreen => new TranslatableString(getKey(@"unable_to_run_exclusive_fullscreen"), @"Unable to run exclusive fullscreen. You'll still experience some input latency.");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -63,12 +63,13 @@ namespace osu.Game.Online.API
public virtual void Queue(APIRequest request) public virtual void Queue(APIRequest request)
{ {
if (HandleRequest?.Invoke(request) != true) Schedule(() =>
{ {
// this will fail due to not receiving an APIAccess, and trigger a failure on the request. if (HandleRequest?.Invoke(request) != true)
// this is intended - any request in testing that needs non-failures should use HandleRequest. {
request.Perform(this); request.Fail(new InvalidOperationException($@"{nameof(DummyAPIAccess)} cannot process this request."));
} }
});
} }
public void Perform(APIRequest request) => HandleRequest?.Invoke(request); public void Perform(APIRequest request) => HandleRequest?.Invoke(request);

View File

@ -15,7 +15,6 @@ using osu.Game.Online.API;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.Chat.Listing; using osu.Game.Overlays.Chat.Listing;
using osu.Game.Overlays.Chat.Tabs;
namespace osu.Game.Online.Chat namespace osu.Game.Online.Chat
{ {
@ -134,7 +133,7 @@ namespace osu.Game.Online.Chat
private void currentChannelChanged(ValueChangedEvent<Channel> e) private void currentChannelChanged(ValueChangedEvent<Channel> e)
{ {
bool isSelectorChannel = e.NewValue is ChannelSelectorTabItem.ChannelSelectorTabChannel || e.NewValue is ChannelListing.ChannelListingChannel; bool isSelectorChannel = e.NewValue is ChannelListing.ChannelListingChannel;
if (!isSelectorChannel) if (!isSelectorChannel)
JoinChannel(e.NewValue); JoinChannel(e.NewValue);

View File

@ -27,7 +27,7 @@ namespace osu.Game.Online.Chat
private INotificationOverlay notifications { get; set; } private INotificationOverlay notifications { get; set; }
[Resolved] [Resolved]
private ChatOverlayV2 chatOverlay { get; set; } private ChatOverlay chatOverlay { get; set; }
[Resolved] [Resolved]
private ChannelManager channelManager { get; set; } private ChannelManager channelManager { get; set; }
@ -170,7 +170,7 @@ namespace osu.Game.Online.Chat
public override bool IsImportant => false; public override bool IsImportant => false;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuColour colours, ChatOverlayV2 chatOverlay, INotificationOverlay notificationOverlay) private void load(OsuColour colours, ChatOverlay chatOverlay, INotificationOverlay notificationOverlay)
{ {
IconBackground.Colour = colours.PurpleDark; IconBackground.Colour = colours.PurpleDark;

View File

@ -75,7 +75,7 @@ namespace osu.Game
public Toolbar Toolbar; public Toolbar Toolbar;
private ChatOverlayV2 chatOverlay; private ChatOverlay chatOverlay;
private ChannelManager channelManager; private ChannelManager channelManager;
@ -848,7 +848,7 @@ namespace osu.Game
loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true); loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true);
var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true); var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true);
loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true); loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true);
loadComponentSingleFile(chatOverlay = new ChatOverlayV2(), overlayContent.Add, true); loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true);
loadComponentSingleFile(new MessageNotifier(), AddInternal, true); loadComponentSingleFile(new MessageNotifier(), AddInternal, true);
loadComponentSingleFile(Settings = new SettingsOverlay(), leftFloatingOverlayContent.Add, true); loadComponentSingleFile(Settings = new SettingsOverlay(), leftFloatingOverlayContent.Add, true);
loadComponentSingleFile(changelogOverlay = new ChangelogOverlay(), overlayContent.Add, true); loadComponentSingleFile(changelogOverlay = new ChangelogOverlay(), overlayContent.Add, true);

View File

@ -26,15 +26,20 @@ namespace osu.Game.Overlays.Chat.ChannelList
public Action<Channel>? OnRequestSelect; public Action<Channel>? OnRequestSelect;
public Action<Channel>? OnRequestLeave; public Action<Channel>? OnRequestLeave;
public IEnumerable<Channel> Channels => publicChannelFlow.Channels.Concat(privateChannelFlow.Channels); public IEnumerable<Channel> Channels => groupFlow.Children
.OfType<ChannelGroup>()
.SelectMany(channelGroup => channelGroup.ItemFlow)
.Select(item => item.Channel);
public readonly ChannelListing.ChannelListingChannel ChannelListingChannel = new ChannelListing.ChannelListingChannel(); public readonly ChannelListing.ChannelListingChannel ChannelListingChannel = new ChannelListing.ChannelListingChannel();
private readonly Dictionary<Channel, ChannelListItem> channelMap = new Dictionary<Channel, ChannelListItem>(); private readonly Dictionary<Channel, ChannelListItem> channelMap = new Dictionary<Channel, ChannelListItem>();
private OsuScrollContainer scroll = null!; private OsuScrollContainer scroll = null!;
private ChannelListItemFlow publicChannelFlow = null!; private FillFlowContainer groupFlow = null!;
private ChannelListItemFlow privateChannelFlow = null!; private ChannelGroup announceChannelGroup = null!;
private ChannelGroup publicChannelGroup = null!;
private ChannelGroup privateChannelGroup = null!;
private ChannelListItem selector = null!; private ChannelListItem selector = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -49,25 +54,20 @@ namespace osu.Game.Overlays.Chat.ChannelList
}, },
scroll = new OsuScrollContainer scroll = new OsuScrollContainer
{ {
Padding = new MarginPadding { Vertical = 7 },
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
ScrollbarAnchor = Anchor.TopRight, ScrollbarAnchor = Anchor.TopRight,
ScrollDistance = 35f, ScrollDistance = 35f,
Child = new FillFlowContainer Child = groupFlow = new FillFlowContainer
{ {
Direction = FillDirection.Vertical, Direction = FillDirection.Vertical,
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
Children = new Drawable[] Children = new Drawable[]
{ {
new ChannelListLabel(ChatStrings.ChannelsListTitlePUBLIC.ToUpper()), announceChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleANNOUNCE.ToUpper()),
publicChannelFlow = new ChannelListItemFlow(), publicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper()),
selector = new ChannelListItem(ChannelListingChannel) selector = new ChannelListItem(ChannelListingChannel),
{ privateChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePM.ToUpper()),
Margin = new MarginPadding { Bottom = 10 },
},
new ChannelListLabel(ChatStrings.ChannelsListTitlePM.ToUpper()),
privateChannelFlow = new ChannelListItemFlow(),
}, },
}, },
}, },
@ -85,9 +85,11 @@ namespace osu.Game.Overlays.Chat.ChannelList
item.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan); item.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan);
item.OnRequestLeave += chan => OnRequestLeave?.Invoke(chan); item.OnRequestLeave += chan => OnRequestLeave?.Invoke(chan);
ChannelListItemFlow flow = getFlowForChannel(channel); FillFlowContainer<ChannelListItem> flow = getFlowForChannel(channel);
channelMap.Add(channel, item); channelMap.Add(channel, item);
flow.Add(item); flow.Add(item);
updateVisibility();
} }
public void RemoveChannel(Channel channel) public void RemoveChannel(Channel channel)
@ -96,10 +98,12 @@ namespace osu.Game.Overlays.Chat.ChannelList
return; return;
ChannelListItem item = channelMap[channel]; ChannelListItem item = channelMap[channel];
ChannelListItemFlow flow = getFlowForChannel(channel); FillFlowContainer<ChannelListItem> flow = getFlowForChannel(channel);
channelMap.Remove(channel); channelMap.Remove(channel);
flow.Remove(item); flow.Remove(item);
updateVisibility();
} }
public ChannelListItem GetItem(Channel channel) public ChannelListItem GetItem(Channel channel)
@ -112,40 +116,58 @@ namespace osu.Game.Overlays.Chat.ChannelList
public void ScrollChannelIntoView(Channel channel) => scroll.ScrollIntoView(GetItem(channel)); public void ScrollChannelIntoView(Channel channel) => scroll.ScrollIntoView(GetItem(channel));
private ChannelListItemFlow getFlowForChannel(Channel channel) private FillFlowContainer<ChannelListItem> getFlowForChannel(Channel channel)
{ {
switch (channel.Type) switch (channel.Type)
{ {
case ChannelType.Public: case ChannelType.Public:
return publicChannelFlow; return publicChannelGroup.ItemFlow;
case ChannelType.PM: case ChannelType.PM:
return privateChannelFlow; return privateChannelGroup.ItemFlow;
case ChannelType.Announce:
return announceChannelGroup.ItemFlow;
default: default:
return publicChannelFlow; return publicChannelGroup.ItemFlow;
} }
} }
private class ChannelListLabel : OsuSpriteText private void updateVisibility()
{ {
public ChannelListLabel(LocalisableString label) if (announceChannelGroup.ItemFlow.Children.Count == 0)
{ announceChannelGroup.Hide();
Text = label; else
Margin = new MarginPadding { Left = 18, Bottom = 5 }; announceChannelGroup.Show();
Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold);
}
} }
private class ChannelListItemFlow : FillFlowContainer<ChannelListItem> private class ChannelGroup : FillFlowContainer
{ {
public IEnumerable<Channel> Channels => Children.Select(c => c.Channel); public readonly FillFlowContainer<ChannelListItem> ItemFlow;
public ChannelListItemFlow() public ChannelGroup(LocalisableString label)
{ {
Direction = FillDirection.Vertical; Direction = FillDirection.Vertical;
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y; AutoSizeAxes = Axes.Y;
Padding = new MarginPadding { Top = 8 };
Children = new Drawable[]
{
new OsuSpriteText
{
Text = label,
Margin = new MarginPadding { Left = 18, Bottom = 5 },
Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold),
},
ItemFlow = new FillFlowContainer<ChannelListItem>
{
Direction = FillDirection.Vertical,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
};
} }
} }
} }

View File

@ -141,8 +141,8 @@ namespace osu.Game.Overlays.Chat
switch (newChannel?.Type) switch (newChannel?.Type)
{ {
case ChannelType.Public: case null:
chattingText.Text = ChatStrings.TalkingIn(newChannel.Name); chattingText.Text = string.Empty;
break; break;
case ChannelType.PM: case ChannelType.PM:
@ -150,7 +150,7 @@ namespace osu.Game.Overlays.Chat
break; break;
default: default:
chattingText.Text = string.Empty; chattingText.Text = ChatStrings.TalkingIn(newChannel.Name);
break; break;
} }
}, true); }, true);

View File

@ -1,191 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osuTK;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Chat;
using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.Chat.Selection
{
public class ChannelListItem : OsuClickableContainer, IFilterable
{
private const float width_padding = 5;
private const float channel_width = 150;
private const float text_size = 15;
private const float transition_duration = 100;
public readonly Channel Channel;
private readonly Bindable<bool> joinedBind = new Bindable<bool>();
private readonly OsuSpriteText name;
private readonly OsuSpriteText topic;
private readonly SpriteIcon joinedCheckmark;
private Color4 joinedColour;
private Color4 topicColour;
private Color4 hoverColour;
public IEnumerable<LocalisableString> FilterTerms => new LocalisableString[] { Channel.Name, Channel.Topic ?? string.Empty };
public bool MatchingFilter
{
set => this.FadeTo(value ? 1f : 0f, 100);
}
public bool FilteringActive { get; set; }
public Action<Channel> OnRequestJoin;
public Action<Channel> OnRequestLeave;
public ChannelListItem(Channel channel)
{
Channel = channel;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Action = () => { (channel.Joined.Value ? OnRequestLeave : OnRequestJoin)?.Invoke(channel); };
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
new Container
{
Children = new[]
{
joinedCheckmark = new SpriteIcon
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Icon = FontAwesome.Solid.CheckCircle,
Size = new Vector2(text_size),
Shadow = false,
Margin = new MarginPadding { Right = 10f },
},
},
},
new Container
{
Width = channel_width,
AutoSizeAxes = Axes.Y,
Children = new[]
{
name = new OsuSpriteText
{
Text = channel.ToString(),
Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold),
Shadow = false,
},
},
},
new Container
{
RelativeSizeAxes = Axes.X,
Width = 0.7f,
AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Left = width_padding },
Children = new[]
{
topic = new OsuSpriteText
{
Text = channel.Topic,
Font = OsuFont.GetFont(size: text_size, weight: FontWeight.SemiBold),
Shadow = false,
},
},
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Margin = new MarginPadding { Left = width_padding },
Spacing = new Vector2(3f, 0f),
Children = new Drawable[]
{
new SpriteIcon
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Icon = FontAwesome.Solid.User,
Size = new Vector2(text_size - 2),
Shadow = false,
},
new OsuSpriteText
{
Text = @"0",
Font = OsuFont.GetFont(size: text_size, weight: FontWeight.SemiBold),
Shadow = false,
},
},
},
},
},
};
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
topicColour = colours.Gray9;
joinedColour = colours.Blue;
hoverColour = colours.Yellow;
joinedBind.ValueChanged += joined => updateColour(joined.NewValue);
joinedBind.BindTo(Channel.Joined);
joinedBind.TriggerChange();
FinishTransforms(true);
}
protected override bool OnHover(HoverEvent e)
{
if (!Channel.Joined.Value)
name.FadeColour(hoverColour, 50, Easing.OutQuint);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
if (!Channel.Joined.Value)
name.FadeColour(Color4.White, transition_duration);
}
private void updateColour(bool joined)
{
if (joined)
{
name.FadeColour(Color4.White, transition_duration);
joinedCheckmark.FadeTo(1f, transition_duration);
topic.FadeTo(0.8f, transition_duration);
topic.FadeColour(Color4.White, transition_duration);
this.FadeColour(joinedColour, transition_duration);
}
else
{
joinedCheckmark.FadeTo(0f, transition_duration);
topic.FadeTo(1f, transition_duration);
topic.FadeColour(topicColour, transition_duration);
this.FadeColour(Color4.White, transition_duration);
}
}
}
}

View File

@ -1,58 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Chat;
using osuTK;
namespace osu.Game.Overlays.Chat.Selection
{
public class ChannelSection : Container, IHasFilterableChildren
{
public readonly FillFlowContainer<ChannelListItem> ChannelFlow;
public IEnumerable<IFilterable> FilterableChildren => ChannelFlow.Children;
public IEnumerable<LocalisableString> FilterTerms => Array.Empty<LocalisableString>();
public bool MatchingFilter
{
set => this.FadeTo(value ? 1f : 0f, 100);
}
public bool FilteringActive { get; set; }
public IEnumerable<Channel> Channels
{
set => ChannelFlow.ChildrenEnumerable = value.Select(c => new ChannelListItem(c));
}
public ChannelSection()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Children = new Drawable[]
{
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold),
Text = "All Channels".ToUpperInvariant()
},
ChannelFlow = new FillFlowContainer<ChannelListItem>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Top = 25 },
Spacing = new Vector2(0f, 5f),
},
};
}
}
}

View File

@ -1,194 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osuTK;
using osuTK.Graphics;
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.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Chat;
using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.Chat.Selection
{
public class ChannelSelectionOverlay : WaveOverlayContainer
{
public new const float WIDTH_PADDING = 170;
private const float transition_duration = 500;
private readonly Box bg;
private readonly Triangles triangles;
private readonly Box headerBg;
private readonly SearchTextBox search;
private readonly SearchContainer<ChannelSection> sectionsFlow;
protected override bool DimMainContent => false;
public Action<Channel> OnRequestJoin;
public Action<Channel> OnRequestLeave;
public ChannelSelectionOverlay()
{
RelativeSizeAxes = Axes.X;
Waves.FirstWaveColour = Color4Extensions.FromHex("353535");
Waves.SecondWaveColour = Color4Extensions.FromHex("434343");
Waves.ThirdWaveColour = Color4Extensions.FromHex("515151");
Waves.FourthWaveColour = Color4Extensions.FromHex("595959");
Children = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
Children = new Drawable[]
{
bg = new Box
{
RelativeSizeAxes = Axes.Both,
},
triangles = new Triangles
{
RelativeSizeAxes = Axes.Both,
TriangleScale = 5,
},
},
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Top = 85, Right = WIDTH_PADDING },
Children = new[]
{
new OsuScrollContainer
{
RelativeSizeAxes = Axes.Both,
Children = new[]
{
sectionsFlow = new SearchContainer<ChannelSection>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
LayoutDuration = 200,
LayoutEasing = Easing.OutQuint,
Spacing = new Vector2(0f, 20f),
Padding = new MarginPadding { Vertical = 20, Left = WIDTH_PADDING },
},
},
},
},
},
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Children = new Drawable[]
{
headerBg = new Box
{
RelativeSizeAxes = Axes.Both,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, 10f),
Padding = new MarginPadding { Top = 10f, Bottom = 10f, Left = WIDTH_PADDING, Right = WIDTH_PADDING },
Children = new Drawable[]
{
new OsuSpriteText
{
Text = @"Chat Channels",
Font = OsuFont.GetFont(size: 20),
Shadow = false,
},
search = new HeaderSearchTextBox { RelativeSizeAxes = Axes.X },
},
},
},
},
};
search.Current.ValueChanged += term => sectionsFlow.SearchTerm = term.NewValue;
}
public void UpdateAvailableChannels(IEnumerable<Channel> channels)
{
Scheduler.Add(() =>
{
sectionsFlow.ChildrenEnumerable = new[]
{
new ChannelSection { Channels = channels, },
};
foreach (ChannelSection s in sectionsFlow.Children)
{
foreach (ChannelListItem c in s.ChannelFlow.Children)
{
c.OnRequestJoin = channel => { OnRequestJoin?.Invoke(channel); };
c.OnRequestLeave = channel => { OnRequestLeave?.Invoke(channel); };
}
}
});
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
bg.Colour = colours.Gray3;
triangles.ColourDark = colours.Gray3;
triangles.ColourLight = Color4Extensions.FromHex(@"353535");
headerBg.Colour = colours.Gray2.Opacity(0.75f);
}
protected override void OnFocus(FocusEvent e)
{
search.TakeFocus();
base.OnFocus(e);
}
protected override void PopIn()
{
if (Alpha == 0) this.MoveToY(DrawHeight);
this.FadeIn(transition_duration, Easing.OutQuint);
this.MoveToY(0, transition_duration, Easing.OutQuint);
search.HoldFocus = true;
base.PopIn();
}
protected override void PopOut()
{
this.FadeOut(transition_duration, Easing.InSine);
this.MoveToY(DrawHeight, transition_duration, Easing.InSine);
search.HoldFocus = false;
base.PopOut();
}
private class HeaderSearchTextBox : BasicSearchTextBox
{
[BackgroundDependencyLoader]
private void load()
{
BackgroundFocused = Color4.Black.Opacity(0.2f);
BackgroundUnfocused = Color4.Black.Opacity(0.2f);
}
}
}
}

View File

@ -1,46 +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.Allocation;
using osu.Game.Graphics;
using osu.Game.Online.Chat;
namespace osu.Game.Overlays.Chat.Tabs
{
public class ChannelSelectorTabItem : ChannelTabItem
{
public override bool IsRemovable => false;
public override bool IsSwitchable => false;
protected override bool IsBoldWhenActive => false;
public ChannelSelectorTabItem()
: base(new ChannelSelectorTabChannel())
{
Depth = float.MaxValue;
Width = 45;
Icon.Alpha = 0;
Text.Font = Text.Font.With(size: 45);
Text.Truncate = false;
}
[BackgroundDependencyLoader]
private void load(OsuColour colour)
{
BackgroundInactive = colour.Gray2;
BackgroundActive = colour.Gray3;
}
public class ChannelSelectorTabChannel : Channel
{
public ChannelSelectorTabChannel()
{
Name = "+";
Type = ChannelType.System;
}
}
}
}

View File

@ -1,114 +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.UserInterface;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Chat;
using osuTK;
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
namespace osu.Game.Overlays.Chat.Tabs
{
public class ChannelTabControl : OsuTabControl<Channel>
{
public const float SHEAR_WIDTH = 10;
public Action<Channel> OnRequestLeave;
public readonly Bindable<bool> ChannelSelectorActive = new Bindable<bool>();
private readonly ChannelSelectorTabItem selectorTab;
public ChannelTabControl()
{
Padding = new MarginPadding { Left = 50 };
TabContainer.Spacing = new Vector2(-SHEAR_WIDTH, 0);
TabContainer.Masking = false;
AddTabItem(selectorTab = new ChannelSelectorTabItem());
ChannelSelectorActive.BindTo(selectorTab.Active);
}
protected override void AddTabItem(TabItem<Channel> item, bool addToDropdown = true)
{
if (item != selectorTab && TabContainer.GetLayoutPosition(selectorTab) < float.MaxValue)
// performTabSort might've made selectorTab's position wonky, fix it
TabContainer.SetLayoutPosition(selectorTab, float.MaxValue);
((ChannelTabItem)item).OnRequestClose += channelItem => OnRequestLeave?.Invoke(channelItem.Value);
base.AddTabItem(item, addToDropdown);
}
protected override TabItem<Channel> CreateTabItem(Channel value)
{
switch (value.Type)
{
default:
return new ChannelTabItem(value);
case ChannelType.PM:
return new PrivateChannelTabItem(value);
}
}
/// <summary>
/// Adds a channel to the ChannelTabControl.
/// The first channel added will automaticly selected.
/// </summary>
/// <param name="channel">The channel that is going to be added.</param>
public void AddChannel(Channel channel)
{
if (!Items.Contains(channel))
AddItem(channel);
Current.Value ??= channel;
}
/// <summary>
/// Removes a channel from the ChannelTabControl.
/// If the selected channel is the one that is being removed, the next available channel will be selected.
/// </summary>
/// <param name="channel">The channel that is going to be removed.</param>
public void RemoveChannel(Channel channel)
{
RemoveItem(channel);
if (SelectedTab == null)
SelectChannelSelectorTab();
}
public void SelectChannelSelectorTab() => SelectTab(selectorTab);
protected override void SelectTab(TabItem<Channel> tab)
{
if (tab is ChannelSelectorTabItem)
{
tab.Active.Value = true;
return;
}
base.SelectTab(tab);
selectorTab.Active.Value = false;
}
protected override TabFillFlowContainer CreateTabFlow() => new ChannelTabFillFlowContainer
{
Direction = FillDirection.Full,
RelativeSizeAxes = Axes.Both,
Depth = -1,
Masking = true
};
private class ChannelTabFillFlowContainer : TabFillFlowContainer
{
protected override int Compare(Drawable x, Drawable y) => CompareReverseChildID(x, y);
}
}
}

View File

@ -1,238 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Chat;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Overlays.Chat.Tabs
{
public class ChannelTabItem : TabItem<Channel>
{
protected Color4 BackgroundInactive;
private Color4 backgroundHover;
protected Color4 BackgroundActive;
public override bool IsRemovable => !Pinned;
protected readonly SpriteText Text;
protected readonly ClickableContainer CloseButton;
private readonly Box box;
private readonly Box highlightBox;
protected readonly SpriteIcon Icon;
public Action<ChannelTabItem> OnRequestClose;
private readonly Container content;
protected override Container<Drawable> Content => content;
private Sample sampleTabSwitched;
public ChannelTabItem(Channel value)
: base(value)
{
Width = 150;
RelativeSizeAxes = Axes.Y;
Anchor = Anchor.BottomLeft;
Origin = Anchor.BottomLeft;
Shear = new Vector2(ChannelTabControl.SHEAR_WIDTH / ChatOverlay.TAB_AREA_HEIGHT, 0);
Masking = true;
InternalChildren = new Drawable[]
{
box = new Box
{
EdgeSmoothness = new Vector2(1, 0),
RelativeSizeAxes = Axes.Both,
},
highlightBox = new Box
{
Width = 5,
Alpha = 0,
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
EdgeSmoothness = new Vector2(1, 0),
RelativeSizeAxes = Axes.Y,
},
content = new Container
{
Shear = new Vector2(-ChannelTabControl.SHEAR_WIDTH / ChatOverlay.TAB_AREA_HEIGHT, 0),
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
Icon = new SpriteIcon
{
Icon = DisplayIcon,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Colour = Color4.Black,
X = -10,
Alpha = 0.2f,
Size = new Vector2(ChatOverlay.TAB_AREA_HEIGHT),
},
Text = new OsuSpriteText
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Text = value.ToString(),
Font = OsuFont.GetFont(size: 18),
Padding = new MarginPadding(5)
{
Left = LeftTextPadding,
Right = RightTextPadding,
},
RelativeSizeAxes = Axes.X,
Truncate = true,
},
CloseButton = new TabCloseButton
{
Alpha = 0,
Margin = new MarginPadding { Right = 20 },
Origin = Anchor.CentreRight,
Anchor = Anchor.CentreRight,
Action = delegate
{
if (IsRemovable) OnRequestClose?.Invoke(this);
},
},
},
},
new HoverSounds()
};
}
protected virtual float LeftTextPadding => 5;
protected virtual float RightTextPadding => IsRemovable ? 40 : 5;
protected virtual IconUsage DisplayIcon => FontAwesome.Solid.Hashtag;
protected virtual bool ShowCloseOnHover => true;
protected virtual bool IsBoldWhenActive => true;
protected override bool OnHover(HoverEvent e)
{
if (IsRemovable && ShowCloseOnHover)
CloseButton.FadeIn(200, Easing.OutQuint);
if (!Active.Value)
box.FadeColour(backgroundHover, TRANSITION_LENGTH, Easing.OutQuint);
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
CloseButton.FadeOut(200, Easing.OutQuint);
updateState();
}
protected override void OnMouseUp(MouseUpEvent e)
{
switch (e.Button)
{
case MouseButton.Middle:
CloseButton.TriggerClick();
break;
}
}
[BackgroundDependencyLoader]
private void load(OsuColour colours, AudioManager audio)
{
BackgroundActive = colours.ChatBlue;
BackgroundInactive = colours.Gray4;
backgroundHover = colours.Gray7;
sampleTabSwitched = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select");
highlightBox.Colour = colours.Yellow;
}
protected override void LoadComplete()
{
base.LoadComplete();
updateState();
FinishTransforms(true);
}
private void updateState()
{
if (Active.Value)
FadeActive();
else
FadeInactive();
}
protected const float TRANSITION_LENGTH = 400;
private readonly EdgeEffectParameters activateEdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Radius = 15,
Colour = Color4.Black.Opacity(0.4f),
};
private readonly EdgeEffectParameters deactivateEdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Radius = 10,
Colour = Color4.Black.Opacity(0.2f),
};
protected virtual void FadeActive()
{
this.ResizeHeightTo(1.1f, TRANSITION_LENGTH, Easing.OutQuint);
TweenEdgeEffectTo(activateEdgeEffect, TRANSITION_LENGTH);
box.FadeColour(BackgroundActive, TRANSITION_LENGTH, Easing.OutQuint);
highlightBox.FadeIn(TRANSITION_LENGTH, Easing.OutQuint);
if (IsBoldWhenActive) Text.Font = Text.Font.With(weight: FontWeight.Bold);
}
protected virtual void FadeInactive()
{
this.ResizeHeightTo(1, TRANSITION_LENGTH, Easing.OutQuint);
TweenEdgeEffectTo(deactivateEdgeEffect, TRANSITION_LENGTH);
box.FadeColour(IsHovered ? backgroundHover : BackgroundInactive, TRANSITION_LENGTH, Easing.OutQuint);
highlightBox.FadeOut(TRANSITION_LENGTH, Easing.OutQuint);
Text.Font = Text.Font.With(weight: FontWeight.Medium);
}
protected override void OnActivated()
{
if (IsLoaded)
sampleTabSwitched?.Play();
updateState();
}
protected override void OnDeactivated() => updateState();
}
}

View File

@ -1,95 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Online.Chat;
using osu.Game.Users.Drawables;
using osuTK;
namespace osu.Game.Overlays.Chat.Tabs
{
public class PrivateChannelTabItem : ChannelTabItem
{
protected override IconUsage DisplayIcon => FontAwesome.Solid.At;
public PrivateChannelTabItem(Channel value)
: base(value)
{
if (value.Type != ChannelType.PM)
throw new ArgumentException("Argument value needs to have the targettype user!");
DrawableAvatar avatar;
AddRange(new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Margin = new MarginPadding
{
Horizontal = 3
},
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
Children = new Drawable[]
{
new CircularContainer
{
Scale = new Vector2(0.95f),
Size = new Vector2(ChatOverlay.TAB_AREA_HEIGHT),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Masking = true,
Child = new DelayedLoadWrapper(avatar = new DrawableAvatar(value.Users.First())
{
RelativeSizeAxes = Axes.Both
})
{
RelativeSizeAxes = Axes.Both,
}
},
}
},
});
avatar.OnLoadComplete += d => d.FadeInFromZero(300, Easing.OutQuint);
}
protected override float LeftTextPadding => base.LeftTextPadding + ChatOverlay.TAB_AREA_HEIGHT;
protected override bool ShowCloseOnHover => false;
protected override void FadeActive()
{
base.FadeActive();
this.ResizeWidthTo(200, TRANSITION_LENGTH, Easing.OutQuint);
CloseButton.FadeIn(TRANSITION_LENGTH, Easing.OutQuint);
}
protected override void FadeInactive()
{
base.FadeInactive();
this.ResizeWidthTo(ChatOverlay.TAB_AREA_HEIGHT + 10, TRANSITION_LENGTH, Easing.OutQuint);
CloseButton.FadeOut(TRANSITION_LENGTH, Easing.OutQuint);
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
var user = Value.Users.First();
BackgroundActive = user.Colour != null ? Color4Extensions.FromHex(user.Colour) : colours.BlueDark;
BackgroundInactive = BackgroundActive.Darken(0.5f);
}
}
}

View File

@ -1,55 +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.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Overlays.Chat.Tabs
{
public class TabCloseButton : OsuClickableContainer
{
private readonly SpriteIcon icon;
public TabCloseButton()
{
Size = new Vector2(20);
Child = icon = new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(0.75f),
Icon = FontAwesome.Solid.TimesCircle,
RelativeSizeAxes = Axes.Both,
};
}
protected override bool OnMouseDown(MouseDownEvent e)
{
icon.ScaleTo(0.5f, 1000, Easing.OutQuint);
return base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseUpEvent e)
{
icon.ScaleTo(0.75f, 1000, Easing.OutElastic);
base.OnMouseUp(e);
}
protected override bool OnHover(HoverEvent e)
{
icon.FadeColour(Color4.Red, 200, Easing.OutQuint);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
icon.FadeColour(Color4.White, 200, Easing.OutQuint);
base.OnHoverLost(e);
}
}
}

View File

@ -1,35 +1,29 @@
// 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.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using osuTK;
using osuTK.Graphics;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Chat;
using osu.Game.Overlays.Chat;
using osu.Game.Overlays.Chat.Selection;
using osu.Game.Overlays.Chat.Tabs;
using osuTK.Input;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Localisation; 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.Localisation;
using osu.Game.Online; 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 namespace osu.Game.Overlays
{ {
@ -39,271 +33,147 @@ namespace osu.Game.Overlays
public LocalisableString Title => ChatStrings.HeaderTitle; public LocalisableString Title => ChatStrings.HeaderTitle;
public LocalisableString Description => ChatStrings.HeaderDescription; public LocalisableString Description => ChatStrings.HeaderDescription;
private const float text_box_height = 60; private ChatOverlayTopBar topBar = null!;
private const float channel_selection_min_height = 0.3f; private ChannelList channelList = null!;
private LoadingLayer loading = null!;
private ChannelListing channelListing = null!;
private ChatTextBar textBar = null!;
private Container<ChatOverlayDrawableChannel> currentChannelContainer = null!;
[Resolved] private readonly Dictionary<Channel, ChatOverlayDrawableChannel> loadedChannels = new Dictionary<Channel, ChatOverlayDrawableChannel>();
private ChannelManager channelManager { get; set; }
private Container<DrawableChannel> currentChannelContainer; protected IEnumerable<DrawableChannel> DrawableChannels => loadedChannels.Values;
private readonly List<DrawableChannel> loadedChannels = new List<DrawableChannel>(); private readonly BindableFloat chatHeight = new BindableFloat();
private bool isDraggingTopBar;
private LoadingSpinner loading; private float dragStartChatHeight;
private FocusedTextBox textBox;
private const int transition_length = 500;
public const float DEFAULT_HEIGHT = 0.4f; public const float DEFAULT_HEIGHT = 0.4f;
public const float TAB_AREA_HEIGHT = 50; private const int transition_length = 500;
private const float top_bar_height = 40;
private const float side_bar_width = 190;
private const float chat_bar_height = 60;
protected ChannelTabControl ChannelTabControl; [Resolved]
private OsuConfigManager config { get; set; } = null!;
protected virtual ChannelTabControl CreateChannelTabControl() => new ChannelTabControl(); [Resolved]
private ChannelManager channelManager { get; set; } = null!;
private Container chatContainer; [Cached]
private TabsArea tabsArea; private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink);
private Box chatBackground;
private Box tabBackground;
public Bindable<float> ChatHeight { get; set; } [Cached]
private readonly Bindable<Channel> currentChannel = new Bindable<Channel>();
private Container channelSelectionContainer;
protected ChannelSelectionOverlay ChannelSelectionOverlay;
private readonly IBindableList<Channel> availableChannels = new BindableList<Channel>(); private readonly IBindableList<Channel> availableChannels = new BindableList<Channel>();
private readonly IBindableList<Channel> joinedChannels = new BindableList<Channel>(); private readonly IBindableList<Channel> joinedChannels = new BindableList<Channel>();
private readonly Bindable<Channel> currentChannel = new Bindable<Channel>();
public override bool Contains(Vector2 screenSpacePos) => chatContainer.ReceivePositionalInputAt(screenSpacePos)
|| (ChannelSelectionOverlay.State.Value == Visibility.Visible && ChannelSelectionOverlay.ReceivePositionalInputAt(screenSpacePos));
public ChatOverlay() public ChatOverlay()
{ {
Height = DEFAULT_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 };
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
RelativePositionAxes = Axes.Both; Anchor = Anchor.BottomCentre;
Anchor = Anchor.BottomLeft; Origin = Anchor.BottomCentre;
Origin = Anchor.BottomLeft;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuConfigManager config, OsuColour colours, TextureStore textures) private void load()
{ {
const float padding = 5; // Required for the pop in/out animation
RelativePositionAxes = Axes.Both;
Children = new Drawable[] Children = new Drawable[]
{ {
channelSelectionContainer = new Container 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 },
},
new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Height = 1f - DEFAULT_HEIGHT, Anchor = Anchor.TopRight,
Masking = true, Origin = Anchor.TopRight,
Children = new[] Padding = new MarginPadding
{ {
ChannelSelectionOverlay = new ChannelSelectionOverlay 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<ChatOverlayDrawableChannel>
{
RelativeSizeAxes = Axes.Both,
},
loading = new LoadingLayer(true),
channelListing = new ChannelListing
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
}, },
}, },
}, },
chatContainer = new Container textBar = new ChatTextBar
{ {
Name = @"chat container", RelativeSizeAxes = Axes.X,
Anchor = Anchor.BottomLeft, Anchor = Anchor.BottomRight,
Origin = Anchor.BottomLeft, Origin = Anchor.BottomRight,
RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Left = side_bar_width },
Height = DEFAULT_HEIGHT,
Children = new[]
{
new Container
{
Name = @"chat area",
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Top = TAB_AREA_HEIGHT },
Children = new Drawable[]
{
chatBackground = new Box
{
RelativeSizeAxes = Axes.Both,
},
new OnlineViewContainer("Sign in to chat")
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
currentChannelContainer = new Container<DrawableChannel>
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Bottom = text_box_height
},
},
new Container
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
RelativeSizeAxes = Axes.X,
Height = text_box_height,
Padding = new MarginPadding
{
Top = padding * 2,
Bottom = padding * 2,
Left = ChatLine.LEFT_PADDING + padding * 2,
Right = padding * 2,
},
Children = new Drawable[]
{
textBox = new FocusedTextBox
{
RelativeSizeAxes = Axes.Both,
Height = 1,
PlaceholderText = Resources.Localisation.Web.ChatStrings.InputPlaceholder,
ReleaseFocusOnCommit = false,
HoldFocus = true,
}
}
},
loading = new LoadingSpinner(),
},
}
}
},
tabsArea = new TabsArea
{
Children = new Drawable[]
{
tabBackground = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Black,
},
new Sprite
{
Texture = textures.Get(IconTexture),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(OverlayTitle.ICON_SIZE),
Margin = new MarginPadding { Left = 10 },
},
ChannelTabControl = CreateChannelTabControl().With(d =>
{
d.Anchor = Anchor.BottomLeft;
d.Origin = Anchor.BottomLeft;
d.RelativeSizeAxes = Axes.Both;
d.OnRequestLeave = channelManager.LeaveChannel;
d.IsSwitchable = true;
}),
}
},
},
}, },
}; };
availableChannels.BindTo(channelManager.AvailableChannels);
joinedChannels.BindTo(channelManager.JoinedChannels);
currentChannel.BindTo(channelManager.CurrentChannel);
textBox.OnCommit += postMessage;
ChannelTabControl.Current.ValueChanged += current => currentChannel.Value = current.NewValue;
ChannelTabControl.ChannelSelectorActive.ValueChanged += active => ChannelSelectionOverlay.State.Value = active.NewValue ? Visibility.Visible : Visibility.Hidden;
ChannelSelectionOverlay.State.ValueChanged += state =>
{
// Propagate the visibility state to ChannelSelectorActive
ChannelTabControl.ChannelSelectorActive.Value = state.NewValue == Visibility.Visible;
if (state.NewValue == Visibility.Visible)
{
textBox.HoldFocus = false;
if (1f - ChatHeight.Value < channel_selection_min_height)
this.TransformBindableTo(ChatHeight, 1f - channel_selection_min_height, 800, Easing.OutQuint);
}
else
textBox.HoldFocus = true;
};
ChannelSelectionOverlay.OnRequestJoin = channel => channelManager.JoinChannel(channel);
ChannelSelectionOverlay.OnRequestLeave = channelManager.LeaveChannel;
ChatHeight = config.GetBindable<float>(OsuSetting.ChatDisplayHeight);
ChatHeight.BindValueChanged(height =>
{
chatContainer.Height = height.NewValue;
channelSelectionContainer.Height = 1f - height.NewValue;
tabBackground.FadeTo(height.NewValue == 1f ? 1f : 0.8f, 200);
}, true);
chatBackground.Colour = colours.ChatBlue;
loading.Show();
// This is a relatively expensive (and blocking) operation.
// Scheduling it ensures that it won't be performed unless the user decides to open chat.
// TODO: Refactor OsuFocusedOverlayContainer / OverlayContainer to support delayed content loading.
Schedule(() =>
{
// TODO: consider scheduling bindable callbacks to not perform when overlay is not present.
joinedChannels.BindCollectionChanged(joinedChannelsChanged, true);
availableChannels.BindCollectionChanged(availableChannelsChanged, true);
currentChannel.BindValueChanged(currentChannelChanged, true);
});
} }
private void currentChannelChanged(ValueChangedEvent<Channel> e) protected override void LoadComplete()
{ {
if (e.NewValue == null) base.LoadComplete();
config.BindWith(OsuSetting.ChatDisplayHeight, chatHeight);
chatHeight.BindValueChanged(height => { Height = height.NewValue; }, true);
currentChannel.BindTo(channelManager.CurrentChannel);
joinedChannels.BindTo(channelManager.JoinedChannels);
availableChannels.BindTo(channelManager.AvailableChannels);
Schedule(() =>
{ {
textBox.Current.Disabled = true; currentChannel.BindValueChanged(currentChannelChanged, true);
currentChannelContainer.Clear(false); joinedChannels.BindCollectionChanged(joinedChannelsChanged, true);
ChannelSelectionOverlay.Show(); availableChannels.BindCollectionChanged(availableChannelsChanged, true);
return; });
}
if (e.NewValue is ChannelSelectorTabItem.ChannelSelectorTabChannel) channelList.OnRequestSelect += channel => channelManager.CurrentChannel.Value = channel;
return; channelList.OnRequestLeave += channel => channelManager.LeaveChannel(channel);
textBox.Current.Disabled = e.NewValue.ReadOnly; channelListing.OnRequestJoin += channel => channelManager.JoinChannel(channel);
channelListing.OnRequestLeave += channel => channelManager.LeaveChannel(channel);
if (ChannelTabControl.Current.Value != e.NewValue) textBar.OnSearchTermsChanged += searchTerms => channelListing.SearchTerm = searchTerms;
Scheduler.Add(() => ChannelTabControl.Current.Value = e.NewValue); textBar.OnChatMessageCommitted += handleChatMessage;
var loaded = loadedChannels.Find(d => d.Channel == e.NewValue);
if (loaded == null)
{
currentChannelContainer.FadeOut(500, Easing.OutQuint);
loading.Show();
loaded = new DrawableChannel(e.NewValue);
loadedChannels.Add(loaded);
LoadComponentAsync(loaded, l =>
{
if (currentChannel.Value != e.NewValue)
return;
// check once more to ensure the channel hasn't since been removed from the loaded channels list (may have been left by some automated means).
if (!loadedChannels.Contains(loaded))
return;
loading.Hide();
currentChannelContainer.Clear(false);
currentChannelContainer.Add(loaded);
currentChannelContainer.FadeIn(500, Easing.OutQuint);
});
}
else
{
currentChannelContainer.Clear(false);
currentChannelContainer.Add(loaded);
}
// mark channel as read when channel switched
if (e.NewValue.Messages.Any())
channelManager.MarkChannelAsRead(e.NewValue);
} }
/// <summary> /// <summary>
@ -320,7 +190,7 @@ namespace osu.Game.Overlays
if (!channel.Joined.Value) if (!channel.Joined.Value)
channel = channelManager.JoinChannel(channel); channel = channelManager.JoinChannel(channel);
currentChannel.Value = channel; channelManager.CurrentChannel.Value = channel;
} }
channel.HighlightedMessage.Value = message; channel.HighlightedMessage.Value = message;
@ -328,159 +198,172 @@ namespace osu.Game.Overlays
Show(); Show();
} }
private float startDragChatHeight;
private bool isDragging;
protected override bool OnDragStart(DragStartEvent e)
{
isDragging = tabsArea.IsHovered;
if (!isDragging)
return base.OnDragStart(e);
startDragChatHeight = ChatHeight.Value;
return true;
}
protected override void OnDrag(DragEvent e)
{
if (isDragging)
{
float targetChatHeight = startDragChatHeight - (e.MousePosition.Y - e.MouseDownPosition.Y) / Parent.DrawSize.Y;
// If the channel selection screen is shown, mind its minimum height
if (ChannelSelectionOverlay.State.Value == Visibility.Visible && targetChatHeight > 1f - channel_selection_min_height)
targetChatHeight = 1f - channel_selection_min_height;
ChatHeight.Value = targetChatHeight;
}
}
protected override void OnDragEnd(DragEndEvent e)
{
isDragging = false;
base.OnDragEnd(e);
}
private void selectTab(int index)
{
var channel = ChannelTabControl.Items
.Where(tab => !(tab is ChannelSelectorTabItem.ChannelSelectorTabChannel))
.ElementAtOrDefault(index);
if (channel != null)
ChannelTabControl.Current.Value = channel;
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.AltPressed)
{
switch (e.Key)
{
case Key.Number1:
case Key.Number2:
case Key.Number3:
case Key.Number4:
case Key.Number5:
case Key.Number6:
case Key.Number7:
case Key.Number8:
case Key.Number9:
selectTab((int)e.Key - (int)Key.Number1);
return true;
case Key.Number0:
selectTab(9);
return true;
}
}
return base.OnKeyDown(e);
}
public bool OnPressed(KeyBindingPressEvent<PlatformAction> e) public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
{ {
switch (e.Action) switch (e.Action)
{ {
case PlatformAction.TabNew: case PlatformAction.TabNew:
ChannelTabControl.SelectChannelSelectorTab(); currentChannel.Value = channelList.ChannelListingChannel;
return true;
case PlatformAction.DocumentClose:
channelManager.LeaveChannel(currentChannel.Value);
return true; return true;
case PlatformAction.TabRestore: case PlatformAction.TabRestore:
channelManager.JoinLastClosedChannel(); channelManager.JoinLastClosedChannel();
return true; return true;
case PlatformAction.DocumentClose: case PlatformAction.DocumentPrevious:
channelManager.LeaveChannel(currentChannel.Value); cycleChannel(-1);
return true; return true;
}
return false; case PlatformAction.DocumentNext:
cycleChannel(1);
return true;
default:
return false;
}
} }
public void OnReleased(KeyBindingReleaseEvent<PlatformAction> e) public void OnReleased(KeyBindingReleaseEvent<PlatformAction> e)
{ {
} }
public override bool AcceptsFocus => true; protected override bool OnDragStart(DragStartEvent e)
protected override void OnFocus(FocusEvent e)
{ {
// this is necessary as textbox is masked away and therefore can't get focus :( isDraggingTopBar = topBar.IsHovered;
textBox.TakeFocus();
base.OnFocus(e); 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() protected override void PopIn()
{ {
base.PopIn();
this.MoveToY(0, transition_length, Easing.OutQuint); this.MoveToY(0, transition_length, Easing.OutQuint);
this.FadeIn(transition_length, Easing.OutQuint); this.FadeIn(transition_length, Easing.OutQuint);
textBox.HoldFocus = true;
base.PopIn();
} }
protected override void PopOut() protected override void PopOut()
{ {
base.PopOut();
this.MoveToY(Height, transition_length, Easing.InSine); this.MoveToY(Height, transition_length, Easing.InSine);
this.FadeOut(transition_length, Easing.InSine); this.FadeOut(transition_length, Easing.InSine);
ChannelSelectionOverlay.Hide(); textBar.TextBoxKillFocus();
textBox.HoldFocus = false;
base.PopOut();
} }
protected override void OnFocus(FocusEvent e)
{
textBar.TextBoxTakeFocus();
base.OnFocus(e);
}
private void currentChannelChanged(ValueChangedEvent<Channel> channel)
{
Channel? newChannel = channel.NewValue;
// null channel denotes that we should be showing the listing.
if (newChannel == null)
{
currentChannel.Value = channelList.ChannelListingChannel;
return;
}
if (newChannel is ChannelListing.ChannelListingChannel)
{
currentChannelContainer.Clear(false);
channelListing.Show();
textBar.ShowSearch.Value = true;
}
else
{
channelListing.Hide();
textBar.ShowSearch.Value = false;
if (loadedChannels.ContainsKey(newChannel))
{
currentChannelContainer.Clear(false);
currentChannelContainer.Add(loadedChannels[newChannel]);
}
else
{
loading.Show();
// Ensure the drawable channel is stored before async load to prevent double loading
ChatOverlayDrawableChannel drawableChannel = CreateDrawableChannel(newChannel);
loadedChannels.Add(newChannel, drawableChannel);
LoadComponentAsync(drawableChannel, loadedDrawable =>
{
// Ensure the current channel hasn't changed by the time the load completes
if (currentChannel.Value != loadedDrawable.Channel)
return;
// Ensure the cached reference hasn't been removed from leaving the channel
if (!loadedChannels.ContainsKey(loadedDrawable.Channel))
return;
currentChannelContainer.Clear(false);
currentChannelContainer.Add(loadedDrawable);
loading.Hide();
});
}
}
// Mark channel as read when channel switched
if (newChannel.Messages.Any())
channelManager.MarkChannelAsRead(newChannel);
}
protected virtual ChatOverlayDrawableChannel CreateDrawableChannel(Channel newChannel) => new ChatOverlayDrawableChannel(newChannel);
private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args)
{ {
switch (args.Action) switch (args.Action)
{ {
case NotifyCollectionChangedAction.Add: case NotifyCollectionChangedAction.Add:
foreach (Channel channel in args.NewItems.Cast<Channel>()) IEnumerable<Channel> newChannels = args.NewItems.OfType<Channel>().Where(isChatChannel);
{
if (channel.Type != ChannelType.Multiplayer) foreach (var channel in newChannels)
ChannelTabControl.AddChannel(channel); channelList.AddChannel(channel);
}
break; break;
case NotifyCollectionChangedAction.Remove: case NotifyCollectionChangedAction.Remove:
foreach (Channel channel in args.OldItems.Cast<Channel>()) IEnumerable<Channel> leftChannels = args.OldItems.OfType<Channel>().Where(isChatChannel);
foreach (var channel in leftChannels)
{ {
if (!ChannelTabControl.Items.Contains(channel)) channelList.RemoveChannel(channel);
continue;
ChannelTabControl.RemoveChannel(channel); if (loadedChannels.ContainsKey(channel))
var loaded = loadedChannels.Find(c => c.Channel == channel);
if (loaded != null)
{ {
// Because the container is only cleared in the async load callback of a new channel, it is forcefully cleared ChatOverlayDrawableChannel loaded = loadedChannels[channel];
// to ensure that the previous channel doesn't get updated after it's disposed loadedChannels.Remove(channel);
loadedChannels.Remove(loaded); // DrawableChannel removed from cache must be manually disposed
currentChannelContainer.Remove(loaded);
loaded.Dispose(); loaded.Dispose();
} }
} }
@ -490,35 +373,47 @@ namespace osu.Game.Overlays
} }
private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args)
{ => channelListing.UpdateAvailableChannels(channelManager.AvailableChannels);
ChannelSelectionOverlay.UpdateAvailableChannels(availableChannels);
}
private void postMessage(TextBox textBox, bool newText) private void handleChatMessage(string message)
{ {
string text = textBox.Text.Trim(); if (string.IsNullOrWhiteSpace(message))
if (string.IsNullOrWhiteSpace(text))
return; return;
if (text[0] == '/') if (message[0] == '/')
channelManager.PostCommand(text.Substring(1)); channelManager.PostCommand(message.Substring(1));
else else
channelManager.PostMessage(text); channelManager.PostMessage(message);
textBox.Text = string.Empty;
} }
private class TabsArea : Container private void cycleChannel(int direction)
{ {
// IsHovered is used List<Channel> overlayChannels = channelList.Channels.ToList();
public override bool HandlePositionalInput => true;
public TabsArea() if (overlayChannels.Count < 2)
return;
int currentIndex = overlayChannels.IndexOf(currentChannel.Value);
currentChannel.Value = overlayChannels[(currentIndex + direction + overlayChannels.Count) % overlayChannels.Count];
channelList.ScrollChannelIntoView(currentChannel.Value);
}
/// <summary>
/// Whether a channel should be displayed in this overlay, based on its type.
/// </summary>
private static bool isChatChannel(Channel channel)
{
switch (channel.Type)
{ {
Name = @"tabs area"; case ChannelType.Multiplayer:
RelativeSizeAxes = Axes.X; case ChannelType.Spectator:
Height = TAB_AREA_HEIGHT; case ChannelType.Temporary:
return false;
default:
return true;
} }
} }
} }

View File

@ -1,420 +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.
#nullable enable
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;
using osu.Framework.Input.Bindings;
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, IKeyBindingHandler<PlatformAction>
{
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<ChatOverlayDrawableChannel> currentChannelContainer = null!;
private readonly Dictionary<Channel, ChatOverlayDrawableChannel> loadedChannels = new Dictionary<Channel, ChatOverlayDrawableChannel>();
protected IEnumerable<DrawableChannel> DrawableChannels => loadedChannels.Values;
private readonly BindableFloat chatHeight = new BindableFloat();
private bool isDraggingTopBar;
private float dragStartChatHeight;
public const float DEFAULT_HEIGHT = 0.4f;
private const int transition_length = 500;
private const float top_bar_height = 40;
private const float side_bar_width = 190;
private const float chat_bar_height = 60;
[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>();
private readonly IBindableList<Channel> availableChannels = new BindableList<Channel>();
private readonly IBindableList<Channel> joinedChannels = new BindableList<Channel>();
public ChatOverlayV2()
{
Height = DEFAULT_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 };
RelativeSizeAxes = Axes.Both;
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 },
},
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<ChatOverlayDrawableChannel>
{
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 },
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
config.BindWith(OsuSetting.ChatDisplayHeight, chatHeight);
chatHeight.BindValueChanged(height => { Height = height.NewValue; }, true);
currentChannel.BindTo(channelManager.CurrentChannel);
joinedChannels.BindTo(channelManager.JoinedChannels);
availableChannels.BindTo(channelManager.AvailableChannels);
Schedule(() =>
{
currentChannel.BindValueChanged(currentChannelChanged, true);
joinedChannels.BindCollectionChanged(joinedChannelsChanged, true);
availableChannels.BindCollectionChanged(availableChannelsChanged, true);
});
channelList.OnRequestSelect += channel => 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;
}
/// <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;
}
channel.HighlightedMessage.Value = message;
Show();
}
public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
{
switch (e.Action)
{
case PlatformAction.TabNew:
currentChannel.Value = channelList.ChannelListingChannel;
return true;
case PlatformAction.DocumentClose:
channelManager.LeaveChannel(currentChannel.Value);
return true;
case PlatformAction.TabRestore:
channelManager.JoinLastClosedChannel();
return true;
case PlatformAction.DocumentPrevious:
cycleChannel(-1);
return true;
case PlatformAction.DocumentNext:
cycleChannel(1);
return true;
default:
return false;
}
}
public void OnReleased(KeyBindingReleaseEvent<PlatformAction> e)
{
}
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;
// null channel denotes that we should be showing the listing.
if (newChannel == null)
{
currentChannel.Value = channelList.ChannelListingChannel;
return;
}
if (newChannel is ChannelListing.ChannelListingChannel)
{
currentChannelContainer.Clear(false);
channelListing.Show();
textBar.ShowSearch.Value = true;
}
else
{
channelListing.Hide();
textBar.ShowSearch.Value = false;
if (loadedChannels.ContainsKey(newChannel))
{
currentChannelContainer.Clear(false);
currentChannelContainer.Add(loadedChannels[newChannel]);
}
else
{
loading.Show();
// Ensure the drawable channel is stored before async load to prevent double loading
ChatOverlayDrawableChannel drawableChannel = CreateDrawableChannel(newChannel);
loadedChannels.Add(newChannel, drawableChannel);
LoadComponentAsync(drawableChannel, loadedDrawable =>
{
// Ensure the current channel hasn't changed by the time the load completes
if (currentChannel.Value != loadedDrawable.Channel)
return;
// Ensure the cached reference hasn't been removed from leaving the channel
if (!loadedChannels.ContainsKey(loadedDrawable.Channel))
return;
currentChannelContainer.Clear(false);
currentChannelContainer.Add(loadedDrawable);
loading.Hide();
});
}
}
// Mark channel as read when channel switched
if (newChannel.Messages.Any())
channelManager.MarkChannelAsRead(newChannel);
}
protected virtual ChatOverlayDrawableChannel CreateDrawableChannel(Channel newChannel) => new ChatOverlayDrawableChannel(newChannel);
private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args)
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
IEnumerable<Channel> newChannels = args.NewItems.OfType<Channel>().Where(isChatChannel);
foreach (var channel in newChannels)
channelList.AddChannel(channel);
break;
case NotifyCollectionChangedAction.Remove:
IEnumerable<Channel> leftChannels = args.OldItems.OfType<Channel>().Where(isChatChannel);
foreach (var channel in leftChannels)
{
channelList.RemoveChannel(channel);
if (loadedChannels.ContainsKey(channel))
{
ChatOverlayDrawableChannel loaded = loadedChannels[channel];
loadedChannels.Remove(channel);
// DrawableChannel removed from cache must be manually disposed
loaded.Dispose();
}
}
break;
}
}
private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args)
=> channelListing.UpdateAvailableChannels(channelManager.AvailableChannels);
private void handleChatMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
return;
if (message[0] == '/')
channelManager.PostCommand(message.Substring(1));
else
channelManager.PostMessage(message);
}
private void cycleChannel(int direction)
{
List<Channel> overlayChannels = channelList.Channels.ToList();
if (overlayChannels.Count < 2)
return;
int currentIndex = overlayChannels.IndexOf(currentChannel.Value);
currentChannel.Value = overlayChannels[(currentIndex + direction + overlayChannels.Count) % overlayChannels.Count];
channelList.ScrollChannelIntoView(currentChannel.Value);
}
/// <summary>
/// Whether a channel should be displayed in this overlay, based on its type.
/// </summary>
private static bool isChatChannel(Channel channel)
{
switch (channel.Type)
{
case ChannelType.Multiplayer:
case ChannelType.Spectator:
case ChannelType.Temporary:
return false;
default:
return true;
}
}
}
}

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.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
@ -9,11 +10,16 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions; using osu.Framework.Extensions;
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.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Screens; using osu.Game.Screens;
using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
@ -24,26 +30,62 @@ namespace osu.Game.Overlays.Dashboard
{ {
internal class CurrentlyPlayingDisplay : CompositeDrawable internal class CurrentlyPlayingDisplay : CompositeDrawable
{ {
private const float search_textbox_height = 40;
private const float padding = 10;
private readonly IBindableList<int> playingUsers = new BindableList<int>(); private readonly IBindableList<int> playingUsers = new BindableList<int>();
private FillFlowContainer<PlayingUserPanel> userFlow; private SearchContainer<PlayingUserPanel> userFlow;
private BasicSearchTextBox searchTextBox;
[Resolved] [Resolved]
private SpectatorClient spectatorClient { get; set; } private SpectatorClient spectatorClient { get; set; }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(OverlayColourProvider colourProvider)
{ {
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y; AutoSizeAxes = Axes.Y;
InternalChild = userFlow = new FillFlowContainer<PlayingUserPanel> InternalChildren = new Drawable[]
{ {
RelativeSizeAxes = Axes.X, new Box
AutoSizeAxes = Axes.Y, {
Padding = new MarginPadding(10), RelativeSizeAxes = Axes.X,
Spacing = new Vector2(10), Height = padding * 2 + search_textbox_height,
Colour = colourProvider.Background4,
},
new Container<BasicSearchTextBox>
{
RelativeSizeAxes = Axes.X,
Padding = new MarginPadding(padding),
Child = searchTextBox = new BasicSearchTextBox
{
RelativeSizeAxes = Axes.X,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Height = search_textbox_height,
ReleaseFocusOnCommit = false,
HoldFocus = true,
PlaceholderText = HomeStrings.SearchPlaceholder,
},
},
userFlow = new SearchContainer<PlayingUserPanel>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(10),
Padding = new MarginPadding
{
Top = padding * 3 + search_textbox_height,
Bottom = padding,
Right = padding,
Left = padding,
},
},
}; };
searchTextBox.Current.ValueChanged += text => userFlow.SearchTerm = text.NewValue;
} }
[Resolved] [Resolved]
@ -57,6 +99,13 @@ namespace osu.Game.Overlays.Dashboard
playingUsers.BindCollectionChanged(onPlayingUsersChanged, true); playingUsers.BindCollectionChanged(onPlayingUsersChanged, true);
} }
protected override void OnFocus(FocusEvent e)
{
base.OnFocus(e);
searchTextBox.TakeFocus();
}
private void onPlayingUsersChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() => private void onPlayingUsersChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() =>
{ {
switch (e.Action) switch (e.Action)
@ -102,17 +151,34 @@ namespace osu.Game.Overlays.Dashboard
panel.Origin = Anchor.TopCentre; panel.Origin = Anchor.TopCentre;
}); });
private class PlayingUserPanel : CompositeDrawable public class PlayingUserPanel : CompositeDrawable, IFilterable
{ {
public readonly APIUser User; public readonly APIUser User;
public IEnumerable<LocalisableString> FilterTerms { get; }
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private IPerformFromScreenRunner performer { get; set; } private IPerformFromScreenRunner performer { get; set; }
public bool FilteringActive { set; get; }
public bool MatchingFilter
{
set
{
if (value)
Show();
else
Hide();
}
}
public PlayingUserPanel(APIUser user) public PlayingUserPanel(APIUser user)
{ {
User = user; User = user;
FilterTerms = new LocalisableString[] { User.Username };
AutoSizeAxes = Axes.Both; AutoSizeAxes = Axes.Both;
} }

View File

@ -16,6 +16,8 @@ namespace osu.Game.Overlays
protected override DashboardOverlayHeader CreateHeader() => new DashboardOverlayHeader(); protected override DashboardOverlayHeader CreateHeader() => new DashboardOverlayHeader();
public override bool AcceptsFocus => false;
protected override void CreateDisplayToLoad(DashboardOverlayTabs tab) protected override void CreateDisplayToLoad(DashboardOverlayTabs tab)
{ {
switch (tab) switch (tab)

View File

@ -154,12 +154,15 @@ namespace osu.Game.Overlays.FirstRunSetup
var downloadTracker = tutorialDownloader.DownloadTrackers.First(); var downloadTracker = tutorialDownloader.DownloadTrackers.First();
downloadTracker.State.BindValueChanged(state =>
{
if (state.NewValue == DownloadState.LocallyAvailable)
downloadTutorialButton.Complete();
}, true);
downloadTracker.Progress.BindValueChanged(progress => downloadTracker.Progress.BindValueChanged(progress =>
{ {
downloadTutorialButton.SetProgress(progress.NewValue, false); downloadTutorialButton.SetProgress(progress.NewValue, false);
if (progress.NewValue == 1)
downloadTutorialButton.Complete();
}, true); }, true);
} }

View File

@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
private UserProfileOverlay userOverlay { get; set; } private UserProfileOverlay userOverlay { get; set; }
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private ChatOverlayV2 chatOverlay { get; set; } private ChatOverlay chatOverlay { get; set; }
[Resolved] [Resolved]
private IAPIProvider apiProvider { get; set; } private IAPIProvider apiProvider { get; set; }

View File

@ -13,6 +13,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Platform.Windows;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -34,10 +35,14 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
private Bindable<Size> sizeFullscreen; private Bindable<Size> sizeFullscreen;
private readonly BindableList<Size> resolutions = new BindableList<Size>(new[] { new Size(9999, 9999) }); private readonly BindableList<Size> resolutions = new BindableList<Size>(new[] { new Size(9999, 9999) });
private readonly IBindable<FullscreenCapability> fullscreenCapability = new Bindable<FullscreenCapability>(FullscreenCapability.Capable);
[Resolved] [Resolved]
private OsuGameBase game { get; set; } private OsuGameBase game { get; set; }
[Resolved]
private GameHost host { get; set; }
private SettingsDropdown<Size> resolutionDropdown; private SettingsDropdown<Size> resolutionDropdown;
private SettingsDropdown<Display> displayDropdown; private SettingsDropdown<Display> displayDropdown;
private SettingsDropdown<WindowMode> windowModeDropdown; private SettingsDropdown<WindowMode> windowModeDropdown;
@ -65,6 +70,9 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
windowModes.BindTo(host.Window.SupportedWindowModes); windowModes.BindTo(host.Window.SupportedWindowModes);
} }
if (host.Window is WindowsWindow windowsWindow)
fullscreenCapability.BindTo(windowsWindow.FullscreenCapability);
Children = new Drawable[] Children = new Drawable[]
{ {
windowModeDropdown = new SettingsDropdown<WindowMode> windowModeDropdown = new SettingsDropdown<WindowMode>
@ -139,6 +147,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
} }
}, },
}; };
fullscreenCapability.BindValueChanged(_ => Schedule(updateScreenModeWarning), true);
} }
protected override void LoadComplete() protected override void LoadComplete()
@ -150,8 +160,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
windowModeDropdown.Current.BindValueChanged(mode => windowModeDropdown.Current.BindValueChanged(mode =>
{ {
updateDisplayModeDropdowns(); updateDisplayModeDropdowns();
updateScreenModeWarning();
windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? GraphicsSettingsStrings.NotFullscreenNote : default;
}, true); }, true);
windowModes.BindCollectionChanged((sender, args) => windowModes.BindCollectionChanged((sender, args) =>
@ -213,6 +222,38 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
} }
} }
private void updateScreenModeWarning()
{
if (windowModeDropdown.Current.Value != WindowMode.Fullscreen)
{
windowModeDropdown.SetNoticeText(GraphicsSettingsStrings.NotFullscreenNote, true);
return;
}
if (host.Window is WindowsWindow)
{
switch (fullscreenCapability.Value)
{
case FullscreenCapability.Unknown:
windowModeDropdown.SetNoticeText(LayoutSettingsStrings.CheckingForFullscreenCapabilities, true);
break;
case FullscreenCapability.Capable:
windowModeDropdown.SetNoticeText(LayoutSettingsStrings.OsuIsRunningExclusiveFullscreen);
break;
case FullscreenCapability.Incapable:
windowModeDropdown.SetNoticeText(LayoutSettingsStrings.UnableToRunExclusiveFullscreen, true);
break;
}
}
else
{
// We can only detect exclusive fullscreen status on windows currently.
windowModeDropdown.ClearNoticeText();
}
}
private void bindPreviewEvent(Bindable<float> bindable) private void bindPreviewEvent(Bindable<float> bindable)
{ {
bindable.ValueChanged += _ => bindable.ValueChanged += _ =>

View File

@ -48,7 +48,16 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
frameLimiterDropdown.Current.BindValueChanged(limit => frameLimiterDropdown.Current.BindValueChanged(limit =>
{ {
frameLimiterDropdown.WarningText = limit.NewValue == FrameSync.Unlimited ? GraphicsSettingsStrings.UnlimitedFramesNote : default; switch (limit.NewValue)
{
case FrameSync.Unlimited:
frameLimiterDropdown.SetNoticeText(GraphicsSettingsStrings.UnlimitedFramesNote, true);
break;
default:
frameLimiterDropdown.ClearNoticeText();
break;
}
}, true); }, true);
} }
} }

View File

@ -117,9 +117,9 @@ namespace osu.Game.Overlays.Settings.Sections.Input
if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows)
{ {
if (highPrecision.NewValue) if (highPrecision.NewValue)
highPrecisionMouse.WarningText = MouseSettingsStrings.HighPrecisionPlatformWarning; highPrecisionMouse.SetNoticeText(MouseSettingsStrings.HighPrecisionPlatformWarning, true);
else else
highPrecisionMouse.WarningText = null; highPrecisionMouse.ClearNoticeText();
} }
}, true); }, true);
} }

View File

@ -11,6 +11,7 @@ using osu.Framework.Localisation;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osuTK; using osuTK;
using osu.Game.Localisation; using osu.Game.Localisation;
@ -95,11 +96,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input
Origin = Anchor.TopCentre, Origin = Anchor.TopCentre,
Text = TabletSettingsStrings.NoTabletDetected, Text = TabletSettingsStrings.NoTabletDetected,
}, },
new SettingsNoticeText(colours) new LinkFlowContainer(cp => cp.Colour = colours.Yellow)
{ {
TextAnchor = Anchor.TopCentre, TextAnchor = Anchor.TopCentre,
Anchor = Anchor.TopCentre, Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre, Origin = Anchor.TopCentre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
}.With(t => }.With(t =>
{ {
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux)

View File

@ -127,9 +127,12 @@ namespace osu.Game.Overlays.Settings.Sections
dropdownItems.Add(skin.ToLive(realm)); dropdownItems.Add(skin.ToLive(realm));
dropdownItems.Insert(protectedCount, random_skin_info); dropdownItems.Insert(protectedCount, random_skin_info);
skinDropdown.Items = dropdownItems; Schedule(() =>
{
skinDropdown.Items = dropdownItems;
updateSelectedSkinFromConfig(); updateSelectedSkinFromConfig();
});
} }
private void updateSelectedSkinFromConfig() private void updateSelectedSkinFromConfig()

View File

@ -61,7 +61,10 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
user.BindValueChanged(u => user.BindValueChanged(u =>
{ {
backgroundSourceDropdown.WarningText = u.NewValue?.IsSupporter != true ? UserInterfaceStrings.NotSupporterNote : default; if (u.NewValue?.IsSupporter != true)
backgroundSourceDropdown.SetNoticeText(UserInterfaceStrings.NotSupporterNote, true);
else
backgroundSourceDropdown.ClearNoticeText();
}, true); }, true);
} }
} }

View File

@ -41,7 +41,7 @@ namespace osu.Game.Overlays.Settings
private SpriteText labelText; private SpriteText labelText;
private OsuTextFlowContainer warningText; private OsuTextFlowContainer noticeText;
public bool ShowsDefaultIndicator = true; public bool ShowsDefaultIndicator = true;
private readonly Container defaultValueIndicatorContainer; private readonly Container defaultValueIndicatorContainer;
@ -70,27 +70,32 @@ namespace osu.Game.Overlays.Settings
} }
/// <summary> /// <summary>
/// Text to be displayed at the bottom of this <see cref="SettingsItem{T}"/>. /// Clear any warning text.
/// Generally used to recommend the user change their setting as the current one is considered sub-optimal.
/// </summary> /// </summary>
public LocalisableString? WarningText public void ClearNoticeText()
{ {
set noticeText?.Expire();
noticeText = null;
}
/// <summary>
/// Set the text to be displayed at the bottom of this <see cref="SettingsItem{T}"/>.
/// Generally used to provide feedback to a user about a sub-optimal setting.
/// </summary>
/// <param name="text">The text to display.</param>
/// <param name="isWarning">Whether the text is in a warning state. Will decide how this is visually represented.</param>
public void SetNoticeText(LocalisableString text, bool isWarning = false)
{
ClearNoticeText();
// construct lazily for cases where the label is not needed (may be provided by the Control).
FlowContent.Add(noticeText = new LinkFlowContainer(cp => cp.Colour = isWarning ? colours.Yellow : colours.Green)
{ {
bool hasValue = value != default; RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
if (warningText == null) Margin = new MarginPadding { Bottom = 5 },
{ Text = text,
if (!hasValue) });
return;
// construct lazily for cases where the label is not needed (may be provided by the Control).
FlowContent.Add(warningText = new SettingsNoticeText(colours) { Margin = new MarginPadding { Bottom = 5 } });
}
warningText.Alpha = hasValue ? 1 : 0;
warningText.Text = value ?? default;
}
} }
public virtual Bindable<T> Current public virtual Bindable<T> Current

View File

@ -1,19 +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.Game.Graphics;
using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.Settings
{
public class SettingsNoticeText : LinkFlowContainer
{
public SettingsNoticeText(OsuColour colours)
: base(s => s.Colour = colours.Yellow)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
}
}
}

View File

@ -17,7 +17,7 @@ namespace osu.Game.Overlays.Toolbar
} }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(ChatOverlayV2 chat) private void load(ChatOverlay chat)
{ {
StateContainer = chat; StateContainer = chat;
} }

View File

@ -117,9 +117,8 @@ namespace osu.Game.Rulesets.Scoring
/// <remarks> /// <remarks>
/// If the provided replay frame does not have any header information, this will be a noop. /// If the provided replay frame does not have any header information, this will be a noop.
/// </remarks> /// </remarks>
/// <param name="ruleset">The ruleset to be used for retrieving statistics.</param>
/// <param name="frame">The replay frame to read header statistics from.</param> /// <param name="frame">The replay frame to read header statistics from.</param>
public virtual void ResetFromReplayFrame(Ruleset ruleset, ReplayFrame frame) public virtual void ResetFromReplayFrame(ReplayFrame frame)
{ {
if (frame.Header == null) if (frame.Header == null)
return; return;

View File

@ -6,11 +6,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.Contracts;
using System.Linq; using System.Linq;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -88,17 +90,34 @@ namespace osu.Game.Rulesets.Scoring
private readonly double accuracyPortion; private readonly double accuracyPortion;
private readonly double comboPortion; private readonly double comboPortion;
private int maxAchievableCombo; /// <summary>
/// Scoring values for a perfect play.
/// </summary>
public ScoringValues MaximumScoringValues
{
get
{
if (!beatmapApplied)
throw new InvalidOperationException($"Cannot access maximum scoring values before calling {nameof(ApplyBeatmap)}.");
return maximumScoringValues;
}
}
private ScoringValues maximumScoringValues;
/// <summary> /// <summary>
/// The maximum achievable base score. /// Scoring values for the current play assuming all perfect hits.
/// </summary> /// </summary>
private double maxBaseScore; /// <remarks>
/// This is only used to determine the accuracy with respect to the current point in time for an ongoing play session.
/// </remarks>
private ScoringValues currentMaximumScoringValues;
/// <summary> /// <summary>
/// The maximum number of basic (non-tick and non-bonus) hitobjects. /// Scoring values for the current play.
/// </summary> /// </summary>
private int maxBasicHitObjects; private ScoringValues currentScoringValues;
/// <summary> /// <summary>
/// The maximum <see cref="HitResult"/> of a basic (non-tick and non-bonus) hitobject. /// The maximum <see cref="HitResult"/> of a basic (non-tick and non-bonus) hitobject.
@ -106,9 +125,6 @@ namespace osu.Game.Rulesets.Scoring
/// </summary> /// </summary>
private HitResult? maxBasicResult; private HitResult? maxBasicResult;
private double rollingMaxBaseScore;
private double baseScore;
private int basicHitObjects;
private bool beatmapApplied; private bool beatmapApplied;
private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>(); private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>();
@ -163,6 +179,10 @@ namespace osu.Game.Rulesets.Scoring
scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1;
// Always update the maximum scoring values.
applyResult(result.Judgement.MaxResult, ref currentMaximumScoringValues);
currentMaximumScoringValues.MaxCombo += result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0;
if (!result.Type.IsScorable()) if (!result.Type.IsScorable())
return; return;
@ -171,16 +191,8 @@ namespace osu.Game.Rulesets.Scoring
else if (result.Type.BreaksCombo()) else if (result.Type.BreaksCombo())
Combo.Value = 0; Combo.Value = 0;
double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; applyResult(result.Type, ref currentScoringValues);
currentScoringValues.MaxCombo = HighestCombo.Value;
if (!result.Type.IsBonus())
{
baseScore += scoreIncrease;
rollingMaxBaseScore += result.Judgement.MaxNumericResult;
}
if (result.Type.IsBasic())
basicHitObjects++;
hitEvents.Add(CreateHitEvent(result)); hitEvents.Add(CreateHitEvent(result));
lastHitObject = result.HitObject; lastHitObject = result.HitObject;
@ -188,6 +200,20 @@ namespace osu.Game.Rulesets.Scoring
updateScore(); updateScore();
} }
private static void applyResult(HitResult result, ref ScoringValues scoringValues)
{
if (!result.IsScorable())
return;
if (result.IsBonus())
scoringValues.BonusScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0;
else
scoringValues.BaseScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0;
if (result.IsBasic())
scoringValues.CountBasicHitObjects++;
}
/// <summary> /// <summary>
/// Creates the <see cref="HitEvent"/> that describes a <see cref="JudgementResult"/>. /// Creates the <see cref="HitEvent"/> that describes a <see cref="JudgementResult"/>.
/// </summary> /// </summary>
@ -206,19 +232,15 @@ namespace osu.Game.Rulesets.Scoring
scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1;
// Always update the maximum scoring values.
revertResult(result.Judgement.MaxResult, ref currentMaximumScoringValues);
currentMaximumScoringValues.MaxCombo -= result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0;
if (!result.Type.IsScorable()) if (!result.Type.IsScorable())
return; return;
double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; revertResult(result.Type, ref currentScoringValues);
currentScoringValues.MaxCombo = HighestCombo.Value;
if (!result.Type.IsBonus())
{
baseScore -= scoreIncrease;
rollingMaxBaseScore -= result.Judgement.MaxNumericResult;
}
if (result.Type.IsBasic())
basicHitObjects--;
Debug.Assert(hitEvents.Count > 0); Debug.Assert(hitEvents.Count > 0);
lastHitObject = hitEvents[^1].LastHitObject; lastHitObject = hitEvents[^1].LastHitObject;
@ -227,14 +249,24 @@ namespace osu.Game.Rulesets.Scoring
updateScore(); updateScore();
} }
private static void revertResult(HitResult result, ref ScoringValues scoringValues)
{
if (!result.IsScorable())
return;
if (result.IsBonus())
scoringValues.BonusScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0;
else
scoringValues.BaseScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0;
if (result.IsBasic())
scoringValues.CountBasicHitObjects--;
}
private void updateScore() private void updateScore()
{ {
double rollingAccuracyRatio = rollingMaxBaseScore > 0 ? baseScore / rollingMaxBaseScore : 1; Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1;
double accuracyRatio = maxBaseScore > 0 ? baseScore / maxBaseScore : 1; TotalScore.Value = ComputeScore(Mode.Value, currentScoringValues, maximumScoringValues);
double comboRatio = maxAchievableCombo > 0 ? (double)HighestCombo.Value / maxAchievableCombo : 1;
Accuracy.Value = rollingAccuracyRatio;
TotalScore.Value = ComputeScore(Mode.Value, accuracyRatio, comboRatio, getBonusScore(scoreResultCounts), maxBasicHitObjects);
} }
/// <summary> /// <summary>
@ -246,22 +278,15 @@ namespace osu.Game.Rulesets.Scoring
/// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param> /// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param>
/// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param> /// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param>
/// <returns>The total score in the given <see cref="ScoringMode"/>.</returns> /// <returns>The total score in the given <see cref="ScoringMode"/>.</returns>
[Pure]
public double ComputeFinalScore(ScoringMode mode, ScoreInfo scoreInfo) public double ComputeFinalScore(ScoringMode mode, ScoreInfo scoreInfo)
{ {
if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset))
throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\".");
extractFromStatistics(ruleset, ExtractScoringValues(scoreInfo, out var current, out var maximum);
scoreInfo.Statistics,
out double extractedBaseScore,
out double extractedMaxBaseScore,
out int extractedMaxCombo,
out int extractedBasicHitObjects);
double accuracyRatio = extractedMaxBaseScore > 0 ? extractedBaseScore / extractedMaxBaseScore : 1; return ComputeScore(mode, current, maximum);
double comboRatio = extractedMaxCombo > 0 ? (double)scoreInfo.MaxCombo / extractedMaxCombo : 1;
return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), extractedBasicHitObjects);
} }
/// <summary> /// <summary>
@ -273,6 +298,7 @@ namespace osu.Game.Rulesets.Scoring
/// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param> /// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param>
/// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param> /// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param>
/// <returns>The total score in the given <see cref="ScoringMode"/>.</returns> /// <returns>The total score in the given <see cref="ScoringMode"/>.</returns>
[Pure]
public double ComputePartialScore(ScoringMode mode, ScoreInfo scoreInfo) public double ComputePartialScore(ScoringMode mode, ScoreInfo scoreInfo)
{ {
if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset))
@ -281,17 +307,9 @@ namespace osu.Game.Rulesets.Scoring
if (!beatmapApplied) if (!beatmapApplied)
throw new InvalidOperationException($"Cannot compute partial score without calling {nameof(ApplyBeatmap)}."); throw new InvalidOperationException($"Cannot compute partial score without calling {nameof(ApplyBeatmap)}.");
extractFromStatistics(ruleset, ExtractScoringValues(scoreInfo, out var current, out _);
scoreInfo.Statistics,
out double extractedBaseScore,
out _,
out _,
out _);
double accuracyRatio = maxBaseScore > 0 ? extractedBaseScore / maxBaseScore : 1; return ComputeScore(mode, current, MaximumScoringValues);
double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1;
return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), maxBasicHitObjects);
} }
/// <summary> /// <summary>
@ -305,6 +323,7 @@ namespace osu.Game.Rulesets.Scoring
/// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param> /// <param name="scoreInfo">The <see cref="ScoreInfo"/> to compute the total score of.</param>
/// <param name="maxAchievableCombo">The maximum achievable combo for the provided beatmap.</param> /// <param name="maxAchievableCombo">The maximum achievable combo for the provided beatmap.</param>
/// <returns>The total score in the given <see cref="ScoringMode"/>.</returns> /// <returns>The total score in the given <see cref="ScoringMode"/>.</returns>
[Pure]
public double ComputeFinalLegacyScore(ScoringMode mode, ScoreInfo scoreInfo, int maxAchievableCombo) public double ComputeFinalLegacyScore(ScoringMode mode, ScoreInfo scoreInfo, int maxAchievableCombo)
{ {
if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset))
@ -313,26 +332,30 @@ namespace osu.Game.Rulesets.Scoring
double accuracyRatio = scoreInfo.Accuracy; double accuracyRatio = scoreInfo.Accuracy;
double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1;
ExtractScoringValues(scoreInfo, out var current, out var maximum);
// For legacy osu!mania scores, a full-GREAT score has 100% accuracy. If combined with a full-combo, the score becomes indistinguishable from a full-PERFECT score. // For legacy osu!mania scores, a full-GREAT score has 100% accuracy. If combined with a full-combo, the score becomes indistinguishable from a full-PERFECT score.
// To get around this, the accuracy ratio is always recalculated based on the hit statistics rather than trusting the score. // To get around this, the accuracy ratio is always recalculated based on the hit statistics rather than trusting the score.
// Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together.
if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3) if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3 && maximum.BaseScore > 0)
{ accuracyRatio = current.BaseScore / maximum.BaseScore;
extractFromStatistics(
ruleset,
scoreInfo.Statistics,
out double computedBaseScore,
out double computedMaxBaseScore,
out _,
out _);
if (computedMaxBaseScore > 0) return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects);
accuracyRatio = computedBaseScore / computedMaxBaseScore; }
}
int computedBasicHitObjects = scoreInfo.Statistics.Where(kvp => kvp.Key.IsBasic()).Select(kvp => kvp.Value).Sum(); /// <summary>
/// Computes the total score from scoring values.
return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), computedBasicHitObjects); /// </summary>
/// <param name="mode">The <see cref="ScoringMode"/> to represent the score as.</param>
/// <param name="current">The current scoring values.</param>
/// <param name="maximum">The maximum scoring values.</param>
/// <returns>The total score computed from the given scoring values.</returns>
[Pure]
public double ComputeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum)
{
double accuracyRatio = maximum.BaseScore > 0 ? current.BaseScore / maximum.BaseScore : 1;
double comboRatio = maximum.MaxCombo > 0 ? (double)current.MaxCombo / maximum.MaxCombo : 1;
return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects);
} }
/// <summary> /// <summary>
@ -344,6 +367,7 @@ namespace osu.Game.Rulesets.Scoring
/// <param name="bonusScore">The total bonus score.</param> /// <param name="bonusScore">The total bonus score.</param>
/// <param name="totalBasicHitObjects">The total number of basic (non-tick and non-bonus) hitobjects in the beatmap.</param> /// <param name="totalBasicHitObjects">The total number of basic (non-tick and non-bonus) hitobjects in the beatmap.</param>
/// <returns>The total score computed from the given scoring component ratios.</returns> /// <returns>The total score computed from the given scoring component ratios.</returns>
[Pure]
public double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects) public double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects)
{ {
switch (mode) switch (mode)
@ -362,15 +386,6 @@ namespace osu.Game.Rulesets.Scoring
} }
} }
/// <summary>
/// Calculates the total bonus score from score statistics.
/// </summary>
/// <param name="statistics">The score statistics.</param>
/// <returns>The total bonus score.</returns>
private double getBonusScore(IReadOnlyDictionary<HitResult, int> statistics)
=> statistics.GetValueOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE
+ statistics.GetValueOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE;
private ScoreRank rankFrom(double acc) private ScoreRank rankFrom(double acc)
{ {
if (acc == 1) if (acc == 1)
@ -402,15 +417,10 @@ namespace osu.Game.Rulesets.Scoring
lastHitObject = null; lastHitObject = null;
if (storeResults) if (storeResults)
{ maximumScoringValues = currentScoringValues;
maxAchievableCombo = HighestCombo.Value;
maxBaseScore = baseScore;
maxBasicHitObjects = basicHitObjects;
}
baseScore = 0; currentScoringValues = default;
rollingMaxBaseScore = 0; currentMaximumScoringValues = default;
basicHitObjects = 0;
TotalScore.Value = 0; TotalScore.Value = 0;
Accuracy.Value = 1; Accuracy.Value = 1;
@ -437,14 +447,19 @@ namespace osu.Game.Rulesets.Scoring
score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score)); score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score));
} }
public override void ResetFromReplayFrame(Ruleset ruleset, ReplayFrame frame) public override void ResetFromReplayFrame(ReplayFrame frame)
{ {
base.ResetFromReplayFrame(ruleset, frame); base.ResetFromReplayFrame(frame);
if (frame.Header == null) if (frame.Header == null)
return; return;
extractFromStatistics(ruleset, frame.Header.Statistics, out baseScore, out rollingMaxBaseScore, out _, out _); extractScoringValues(frame.Header.Statistics, out var current, out var maximum);
currentScoringValues.BaseScore = current.BaseScore;
currentScoringValues.MaxCombo = frame.Header.MaxCombo;
currentMaximumScoringValues.BaseScore = maximum.BaseScore;
currentMaximumScoringValues.MaxCombo = maximum.MaxCombo;
HighestCombo.Value = frame.Header.MaxCombo; HighestCombo.Value = frame.Header.MaxCombo;
scoreResultCounts.Clear(); scoreResultCounts.Clear();
@ -455,52 +470,126 @@ namespace osu.Game.Rulesets.Scoring
OnResetFromReplayFrame?.Invoke(); OnResetFromReplayFrame?.Invoke();
} }
private void extractFromStatistics(Ruleset ruleset, IReadOnlyDictionary<HitResult, int> statistics, out double baseScore, out double maxBaseScore, out int maxCombo, #region ScoringValue extraction
out int basicHitObjects)
/// <summary>
/// Applies a best-effort extraction of hit statistics into <see cref="ScoringValues"/>.
/// </summary>
/// <remarks>
/// This method is useful in a variety of situations, with a few drawbacks that need to be considered:
/// <list type="bullet">
/// <item>The maximum <see cref="ScoringValues.BonusScore"/> will always be 0.</item>
/// <item>The current and maximum <see cref="ScoringValues.CountBasicHitObjects"/> will always be the same value.</item>
/// </list>
/// Consumers are expected to more accurately fill in the above values through external means.
/// <para>
/// <b>Ensure</b> to fill in the maximum <see cref="ScoringValues.CountBasicHitObjects"/> for use in
/// <see cref="ComputeScore(osu.Game.Rulesets.Scoring.ScoringMode,osu.Game.Scoring.ScoringValues,osu.Game.Scoring.ScoringValues)"/>.
/// </para>
/// </remarks>
/// <param name="scoreInfo">The score to extract scoring values from.</param>
/// <param name="current">The "current" scoring values, representing the hit statistics as they appear.</param>
/// <param name="maximum">The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time.</param>
[Pure]
internal void ExtractScoringValues(ScoreInfo scoreInfo, out ScoringValues current, out ScoringValues maximum)
{ {
baseScore = 0; extractScoringValues(scoreInfo.Statistics, out current, out maximum);
maxBaseScore = 0; current.MaxCombo = scoreInfo.MaxCombo;
maxCombo = 0; }
basicHitObjects = 0;
/// <summary>
/// Applies a best-effort extraction of hit statistics into <see cref="ScoringValues"/>.
/// </summary>
/// <remarks>
/// This method is useful in a variety of situations, with a few drawbacks that need to be considered:
/// <list type="bullet">
/// <item>The maximum <see cref="ScoringValues.BonusScore"/> will always be 0.</item>
/// <item>The current and maximum <see cref="ScoringValues.CountBasicHitObjects"/> will always be the same value.</item>
/// </list>
/// Consumers are expected to more accurately fill in the above values through external means.
/// <para>
/// <b>Ensure</b> to fill in the maximum <see cref="ScoringValues.CountBasicHitObjects"/> for use in
/// <see cref="ComputeScore(osu.Game.Rulesets.Scoring.ScoringMode,osu.Game.Scoring.ScoringValues,osu.Game.Scoring.ScoringValues)"/>.
/// </para>
/// </remarks>
/// <param name="header">The replay frame header to extract scoring values from.</param>
/// <param name="current">The "current" scoring values, representing the hit statistics as they appear.</param>
/// <param name="maximum">The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time.</param>
[Pure]
internal void ExtractScoringValues(FrameHeader header, out ScoringValues current, out ScoringValues maximum)
{
extractScoringValues(header.Statistics, out current, out maximum);
current.MaxCombo = header.MaxCombo;
}
/// <summary>
/// Applies a best-effort extraction of hit statistics into <see cref="ScoringValues"/>.
/// </summary>
/// <remarks>
/// This method is useful in a variety of situations, with a few drawbacks that need to be considered:
/// <list type="bullet">
/// <item>The current <see cref="ScoringValues.MaxCombo"/> will always be 0.</item>
/// <item>The maximum <see cref="ScoringValues.BonusScore"/> will always be 0.</item>
/// <item>The current and maximum <see cref="ScoringValues.CountBasicHitObjects"/> will always be the same value.</item>
/// </list>
/// Consumers are expected to more accurately fill in the above values (especially the current <see cref="ScoringValues.MaxCombo"/>) via external means (e.g. <see cref="ScoreInfo"/>).
/// </remarks>
/// <param name="statistics">The hit statistics to extract scoring values from.</param>
/// <param name="current">The "current" scoring values, representing the hit statistics as they appear.</param>
/// <param name="maximum">The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time.</param>
[Pure]
private void extractScoringValues(IReadOnlyDictionary<HitResult, int> statistics, out ScoringValues current, out ScoringValues maximum)
{
current = default;
maximum = default;
foreach ((HitResult result, int count) in statistics) foreach ((HitResult result, int count) in statistics)
{ {
// Bonus scores are counted separately directly from the statistics dictionary later on. if (!result.IsScorable())
if (!result.IsScorable() || result.IsBonus())
continue; continue;
// The maximum result of this judgement if it wasn't a miss. if (result.IsBonus())
// E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). current.BonusScore += count * Judgement.ToNumericResult(result);
HitResult maxResult; else
switch (result)
{ {
case HitResult.LargeTickHit: // The maximum result of this judgement if it wasn't a miss.
case HitResult.LargeTickMiss: // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT).
maxResult = HitResult.LargeTickHit; HitResult maxResult;
break;
case HitResult.SmallTickHit: switch (result)
case HitResult.SmallTickMiss: {
maxResult = HitResult.SmallTickHit; case HitResult.LargeTickHit:
break; case HitResult.LargeTickMiss:
maxResult = HitResult.LargeTickHit;
break;
default: case HitResult.SmallTickHit:
maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result; case HitResult.SmallTickMiss:
break; maxResult = HitResult.SmallTickHit;
break;
default:
maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result;
break;
}
current.BaseScore += count * Judgement.ToNumericResult(result);
maximum.BaseScore += count * Judgement.ToNumericResult(maxResult);
} }
baseScore += count * Judgement.ToNumericResult(result);
maxBaseScore += count * Judgement.ToNumericResult(maxResult);
if (result.AffectsCombo()) if (result.AffectsCombo())
maxCombo += count; maximum.MaxCombo += count;
if (result.IsBasic()) if (result.IsBasic())
basicHitObjects += count; {
current.CountBasicHitObjects += count;
maximum.CountBasicHitObjects += count;
}
} }
} }
#endregion
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);

View File

@ -27,8 +27,6 @@ namespace osu.Game.Rulesets.UI
{ {
public readonly KeyBindingContainer<T> KeyBindingContainer; public readonly KeyBindingContainer<T> KeyBindingContainer;
private readonly Ruleset ruleset;
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private ScoreProcessor scoreProcessor { get; set; } private ScoreProcessor scoreProcessor { get; set; }
@ -57,8 +55,6 @@ namespace osu.Game.Rulesets.UI
protected RulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) protected RulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique)
{ {
this.ruleset = ruleset.CreateInstance();
InternalChild = KeyBindingContainer = InternalChild = KeyBindingContainer =
CreateKeyBindingContainer(ruleset, variant, unique) CreateKeyBindingContainer(ruleset, variant, unique)
.WithChild(content = new Container { RelativeSizeAxes = Axes.Both }); .WithChild(content = new Container { RelativeSizeAxes = Axes.Both });
@ -85,7 +81,7 @@ namespace osu.Game.Rulesets.UI
break; break;
case ReplayStatisticsFrameEvent statisticsStateChangeEvent: case ReplayStatisticsFrameEvent statisticsStateChangeEvent:
scoreProcessor?.ResetFromReplayFrame(ruleset, statisticsStateChangeEvent.Frame); scoreProcessor?.ResetFromReplayFrame(statisticsStateChangeEvent.Frame);
break; break;
default: default:

View File

@ -0,0 +1,41 @@
// 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 MessagePack;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Scoring
{
/// <summary>
/// Stores the required scoring data that fulfils the minimum requirements for a <see cref="ScoreProcessor"/> to calculate score.
/// </summary>
[MessagePackObject]
public struct ScoringValues
{
/// <summary>
/// The sum of all "basic" <see cref="HitObject"/> scoring values. See: <see cref="HitResultExtensions.IsBasic"/> and <see cref="Judgement.ToNumericResult"/>.
/// </summary>
[Key(0)]
public double BaseScore;
/// <summary>
/// The sum of all "bonus" <see cref="HitObject"/> scoring values. See: <see cref="HitResultExtensions.IsBonus"/> and <see cref="Judgement.ToNumericResult"/>.
/// </summary>
[Key(1)]
public double BonusScore;
/// <summary>
/// The highest achieved combo.
/// </summary>
[Key(2)]
public int MaxCombo;
/// <summary>
/// The count of "basic" <see cref="HitObject"/>s. See: <see cref="HitResultExtensions.IsBasic"/>.
/// </summary>
[Key(3)]
public int CountBasicHitObjects;
}
}

View File

@ -2,13 +2,16 @@
// 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.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Edit.Components.Timelines.Summary; using osu.Game.Screens.Edit.Components.Timelines.Summary;
using osuTK; using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Edit namespace osu.Game.Screens.Edit
{ {
@ -26,6 +29,14 @@ namespace osu.Game.Screens.Edit
Height = 60; Height = 60;
Masking = true;
EdgeEffect = new EdgeEffectParameters
{
Colour = Color4.Black.Opacity(0.2f),
Type = EdgeEffectType.Shadow,
Radius = 10f,
};
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
new Box new Box

View File

@ -16,6 +16,7 @@ using osu.Game.Configuration;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osuTK; using osuTK;
using osuTK.Input;
namespace osu.Game.Screens.Edit.Compose.Components.Timeline namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{ {
@ -273,7 +274,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
if (base.OnMouseDown(e)) if (base.OnMouseDown(e))
beginUserDrag(); beginUserDrag();
return true; // handling right button as well breaks context menus inside the timeline, only handle left button for now.
return e.Button == MouseButton.Left;
} }
protected override void OnMouseUp(MouseUpEvent e) protected override void OnMouseUp(MouseUpEvent e)

View File

@ -135,7 +135,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
Vector2 size = Vector2.One; Vector2 size = Vector2.One;
if (indexInBar != 1) if (indexInBar != 0)
size = BindableBeatDivisor.GetSize(divisor); size = BindableBeatDivisor.GetSize(divisor);
var line = getNextUsableLine(); var line = getNextUsableLine();

View File

@ -99,6 +99,15 @@ namespace osu.Game.Screens.Edit
colourSelected = colours.Colour3; colourSelected = colours.Colour3;
} }
protected override void LoadComplete()
{
base.LoadComplete();
// Reduce flicker of rows when offset is being changed rapidly.
// Probably need to reconsider this.
FinishTransforms(true);
}
private bool selected; private bool selected;
public bool Selected public bool Selected

View File

@ -61,6 +61,7 @@ namespace osu.Game.Screens.Edit.Timing
selectedGroup.BindValueChanged(group => selectedGroup.BindValueChanged(group =>
{ {
// TODO: This should scroll the selected row into view.
foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue; foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue;
}, true); }, true);
} }

View File

@ -31,18 +31,33 @@ namespace osu.Game.Screens.Edit.Timing
}); });
} }
protected override void LoadComplete()
{
base.LoadComplete();
kiai.Current.BindValueChanged(_ => saveChanges());
omitBarLine.Current.BindValueChanged(_ => saveChanges());
scrollSpeedSlider.Current.BindValueChanged(_ => saveChanges());
void saveChanges()
{
if (!isRebinding) ChangeHandler?.SaveState();
}
}
private bool isRebinding;
protected override void OnControlPointChanged(ValueChangedEvent<EffectControlPoint> point) protected override void OnControlPointChanged(ValueChangedEvent<EffectControlPoint> point)
{ {
if (point.NewValue != null) if (point.NewValue != null)
{ {
isRebinding = true;
kiai.Current = point.NewValue.KiaiModeBindable; kiai.Current = point.NewValue.KiaiModeBindable;
kiai.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable; omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable;
omitBarLine.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
scrollSpeedSlider.Current = point.NewValue.ScrollSpeedBindable; scrollSpeedSlider.Current = point.NewValue.ScrollSpeedBindable;
scrollSpeedSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
isRebinding = false;
} }
} }

View File

@ -3,6 +3,8 @@
using System; using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Audio.Track; using osu.Framework.Audio.Track;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Extensions.LocalisationExtensions;
@ -31,12 +33,16 @@ namespace osu.Game.Screens.Edit.Timing
private IAdjustableClock metronomeClock; private IAdjustableClock metronomeClock;
private Sample clunk;
[Resolved] [Resolved]
private OverlayColourProvider overlayColourProvider { get; set; } private OverlayColourProvider overlayColourProvider { get; set; }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(AudioManager audio)
{ {
clunk = audio.Samples.Get(@"Multiplayer/countdown-tick");
const float taper = 25; const float taper = 25;
const float swing_vertical_offset = -23; const float swing_vertical_offset = -23;
const float lower_cover_height = 32; const float lower_cover_height = 32;
@ -269,8 +275,21 @@ namespace osu.Game.Screens.Edit.Timing
if (currentAngle != 0 && Math.Abs(currentAngle - targetAngle) > angle * 1.8f && isSwinging) if (currentAngle != 0 && Math.Abs(currentAngle - targetAngle) > angle * 1.8f && isSwinging)
{ {
using (stick.BeginDelayedSequence(beatLength / 2)) using (BeginDelayedSequence(beatLength / 2))
{
stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint); stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint);
Schedule(() =>
{
var channel = clunk?.GetChannel();
if (channel != null)
{
channel.Frequency.Value = RNG.NextDouble(0.98f, 1.02f);
channel.Play();
}
});
}
} }
} }
} }

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.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -18,6 +19,9 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved] [Resolved]
private EditorClock editorClock { get; set; } private EditorClock editorClock { get; set; }
[Resolved]
private EditorBeatmap beatmap { get; set; }
[Resolved] [Resolved]
private Bindable<ControlPointGroup> selectedGroup { get; set; } private Bindable<ControlPointGroup> selectedGroup { get; set; }
@ -45,6 +49,7 @@ namespace osu.Game.Screens.Edit.Timing
{ {
new Dimension(GridSizeMode.Absolute, 200), new Dimension(GridSizeMode.Absolute, 200),
new Dimension(GridSizeMode.Absolute, 60), new Dimension(GridSizeMode.Absolute, 60),
new Dimension(GridSizeMode.Absolute, 60),
}, },
Content = new[] Content = new[]
{ {
@ -77,7 +82,36 @@ namespace osu.Game.Screens.Edit.Timing
}, },
} }
} }
} },
},
new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(10),
Children = new Drawable[]
{
new TimingAdjustButton(1)
{
Text = "Offset",
RelativeSizeAxes = Axes.X,
Width = 0.48f,
Height = 50,
Action = adjustOffset,
},
new TimingAdjustButton(0.1)
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Text = "BPM",
RelativeSizeAxes = Axes.X,
Width = 0.48f,
Height = 50,
Action = adjustBpm,
}
}
},
}, },
new Drawable[] new Drawable[]
{ {
@ -113,6 +147,35 @@ namespace osu.Game.Screens.Edit.Timing
}; };
} }
private void adjustOffset(double adjust)
{
// VERY TEMPORARY
var currentGroupItems = selectedGroup.Value.ControlPoints.ToArray();
beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value);
double newOffset = selectedGroup.Value.Time + adjust;
foreach (var cp in currentGroupItems)
beatmap.ControlPointInfo.Add(newOffset, cp);
// the control point might not necessarily exist yet, if currentGroupItems was empty.
selectedGroup.Value = beatmap.ControlPointInfo.GroupAt(newOffset, true);
if (!editorClock.IsRunning)
editorClock.Seek(newOffset);
}
private void adjustBpm(double adjust)
{
var timing = selectedGroup.Value.ControlPoints.OfType<TimingControlPoint>().FirstOrDefault();
if (timing == null)
return;
timing.BeatLength = 60000 / (timing.BPM + adjust);
}
private void tap() private void tap()
{ {
editorClock.Seek(selectedGroup.Value.Time); editorClock.Seek(selectedGroup.Value.Time);

View File

@ -0,0 +1,254 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
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.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
namespace osu.Game.Screens.Edit.Timing
{
/// <summary>
/// A button with variable constant output based on hold position and length.
/// </summary>
public class TimingAdjustButton : CompositeDrawable
{
public Action<double> Action;
private readonly double adjustAmount;
private ScheduledDelegate adjustDelegate;
private const int max_multiplier = 10;
private const int adjust_levels = 4;
private const double initial_delay = 300;
private const double minimum_delay = 80;
public Container Content { get; set; }
private double adjustDelay = initial_delay;
private readonly Box background;
private readonly OsuSpriteText text;
private Sample sample;
public LocalisableString Text
{
get => text.Text;
set => text.Text = value;
}
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
public TimingAdjustButton(double adjustAmount)
{
this.adjustAmount = adjustAmount;
CornerRadius = 5;
Masking = true;
AddInternal(Content = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Depth = float.MaxValue
},
text = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Default.With(weight: FontWeight.SemiBold),
Padding = new MarginPadding(5),
Depth = float.MinValue
}
}
});
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sample = audio.Samples.Get(@"UI/notch-tick");
background.Colour = colourProvider.Background3;
for (int i = 1; i <= adjust_levels; i++)
{
Content.Add(new IncrementBox(i, adjustAmount));
Content.Add(new IncrementBox(-i, adjustAmount));
}
}
protected override bool OnMouseDown(MouseDownEvent e)
{
beginRepeat();
return true;
}
protected override void OnMouseUp(MouseUpEvent e)
{
adjustDelegate?.Cancel();
base.OnMouseUp(e);
}
private void beginRepeat()
{
adjustDelegate?.Cancel();
adjustDelay = initial_delay;
adjustNext();
void adjustNext()
{
var hoveredBox = Content.OfType<IncrementBox>().FirstOrDefault(d => d.IsHovered);
if (hoveredBox != null)
{
Action(adjustAmount * hoveredBox.Multiplier);
adjustDelay = Math.Max(minimum_delay, adjustDelay * 0.9f);
hoveredBox.Flash();
var channel = sample?.GetChannel();
if (channel != null)
{
double repeatModifier = 0.05f * (Math.Abs(adjustDelay - initial_delay) / minimum_delay);
double multiplierModifier = (hoveredBox.Multiplier / max_multiplier) * 0.2f;
channel.Frequency.Value = 1 + multiplierModifier + repeatModifier;
channel.Play();
}
}
else
{
adjustDelay = initial_delay;
}
adjustDelegate = Scheduler.AddDelayed(adjustNext, adjustDelay);
}
}
private class IncrementBox : CompositeDrawable
{
public readonly float Multiplier;
private readonly Box box;
private readonly OsuSpriteText text;
public IncrementBox(int index, double amount)
{
Multiplier = Math.Sign(index) * convertMultiplier(index);
float ratio = (float)index / adjust_levels;
RelativeSizeAxes = Axes.Both;
Width = 0.5f * Math.Abs(ratio);
Anchor direction = index < 0 ? Anchor.x2 : Anchor.x0;
Origin |= direction;
Depth = Math.Abs(index);
Anchor = Anchor.TopCentre;
InternalChildren = new Drawable[]
{
box = new Box
{
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive
},
text = new OsuSpriteText
{
Anchor = direction,
Origin = direction,
Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold),
Text = $"{(index > 0 ? "+" : "-")}{Math.Abs(Multiplier * amount)}",
Padding = new MarginPadding(5),
Alpha = 0,
}
};
}
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
protected override void LoadComplete()
{
base.LoadComplete();
box.Colour = colourProvider.Background1;
box.Alpha = 0.1f;
}
private float convertMultiplier(int m)
{
switch (Math.Abs(m))
{
default: return 1;
case 2: return 2;
case 3: return 5;
case 4:
return max_multiplier;
}
}
protected override bool OnHover(HoverEvent e)
{
box.Colour = colourProvider.Colour0;
box.FadeTo(0.2f, 100, Easing.OutQuint);
text.FadeIn(100, Easing.OutQuint);
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
box.Colour = colourProvider.Background1;
box.FadeTo(0.1f, 500, Easing.OutQuint);
text.FadeOut(100, Easing.OutQuint);
base.OnHoverLost(e);
}
public void Flash()
{
box
.FadeTo(0.4f, 20, Easing.OutQuint)
.Then()
.FadeTo(0.2f, 400, Easing.OutQuint);
text
.MoveToY(-5, 20, Easing.OutQuint)
.Then()
.MoveToY(0, 400, Easing.OutQuint);
}
}
}
}

View File

@ -19,7 +19,7 @@ namespace osu.Game.Screens.Edit.Timing
public class TimingScreen : EditorScreenWithTimeline public class TimingScreen : EditorScreenWithTimeline
{ {
[Cached] [Cached]
private Bindable<ControlPointGroup> selectedGroup = new Bindable<ControlPointGroup>(); public readonly Bindable<ControlPointGroup> SelectedGroup = new Bindable<ControlPointGroup>();
public TimingScreen() public TimingScreen()
: base(EditorScreenMode.Timing) : base(EditorScreenMode.Timing)
@ -132,6 +132,40 @@ namespace osu.Game.Screens.Edit.Timing
}, true); }, true);
} }
protected override void Update()
{
base.Update();
trackActivePoint();
}
/// <summary>
/// Given the user has selected a control point group, we want to track any group which is
/// active at the current point in time which matches the type the user has selected.
///
/// So if the user is currently looking at a timing point and seeks into the future, a
/// future timing point would be automatically selected if it is now the new "current" point.
/// </summary>
private void trackActivePoint()
{
// For simplicity only match on the first type of the active control point.
var selectedPointType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType();
if (selectedPointType != null)
{
// We don't have an efficient way of looking up groups currently, only individual point types.
// To improve the efficiency of this in the future, we should reconsider the overall structure of ControlPointInfo.
// Find the next group which has the same type as the selected one.
var found = Beatmap.ControlPointInfo.Groups
.Where(g => g.ControlPoints.Any(cp => cp.GetType() == selectedPointType))
.LastOrDefault(g => g.Time <= clock.CurrentTimeAccurate);
if (found != null)
selectedGroup.Value = found;
}
}
private void delete() private void delete()
{ {
if (selectedGroup.Value == null) if (selectedGroup.Value == null)
@ -144,7 +178,27 @@ namespace osu.Game.Screens.Edit.Timing
private void addNew() private void addNew()
{ {
selectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); bool isFirstControlPoint = !Beatmap.ControlPointInfo.TimingPoints.Any();
var group = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true);
if (isFirstControlPoint)
group.Add(new TimingControlPoint());
else
{
// Try and create matching types from the currently selected control point.
var selected = selectedGroup.Value;
if (selected != null && selected != group)
{
foreach (var controlPoint in selected.ControlPoints)
{
group.Add(controlPoint.DeepClone());
}
}
}
selectedGroup.Value = group;
} }
} }
} }

View File

@ -28,15 +28,31 @@ namespace osu.Game.Screens.Edit.Timing
}); });
} }
protected override void LoadComplete()
{
base.LoadComplete();
bpmTextEntry.Current.BindValueChanged(_ => saveChanges());
timeSignature.Current.BindValueChanged(_ => saveChanges());
void saveChanges()
{
if (!isRebinding) ChangeHandler?.SaveState();
}
}
private bool isRebinding;
protected override void OnControlPointChanged(ValueChangedEvent<TimingControlPoint> point) protected override void OnControlPointChanged(ValueChangedEvent<TimingControlPoint> point)
{ {
if (point.NewValue != null) if (point.NewValue != null)
{ {
bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; isRebinding = true;
bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable;
timeSignature.Current = point.NewValue.TimeSignatureBindable; timeSignature.Current = point.NewValue.TimeSignatureBindable;
timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
isRebinding = false;
} }
} }

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays; using osu.Game.Overlays;
@ -26,6 +27,8 @@ namespace osu.Game.Screens.Edit.Timing
{ {
private const int total_waveforms = 8; private const int total_waveforms = 8;
private const float corner_radius = LabelledDrawable<Drawable>.CORNER_RADIUS;
private readonly BindableNumber<double> beatLength = new BindableDouble(); private readonly BindableNumber<double> beatLength = new BindableDouble();
[Resolved] [Resolved]
@ -42,18 +45,22 @@ namespace osu.Game.Screens.Edit.Timing
private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT; private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT;
private int lastDisplayedBeatIndex; private double displayedTime;
private double selectedGroupStartTime; private double selectedGroupStartTime;
private double selectedGroupEndTime; private double selectedGroupEndTime;
private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>(); private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>();
private readonly BindableBool displayLocked = new BindableBool();
private LockedOverlay lockedOverlay = null!;
public WaveformComparisonDisplay() public WaveformComparisonDisplay()
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
CornerRadius = LabelledDrawable<Drawable>.CORNER_RADIUS; CornerRadius = corner_radius;
Masking = true; Masking = true;
} }
@ -63,7 +70,7 @@ namespace osu.Game.Screens.Edit.Timing
for (int i = 0; i < total_waveforms; i++) for (int i = 0; i < total_waveforms; i++)
{ {
AddInternal(new WaveformRow AddInternal(new WaveformRow(i == total_waveforms / 2)
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Both, RelativePositionAxes = Axes.Both,
@ -81,72 +88,112 @@ namespace osu.Game.Screens.Edit.Timing
Width = 3, Width = 3,
}); });
AddInternal(lockedOverlay = new LockedOverlay());
selectedGroup.BindValueChanged(_ => updateTimingGroup(), true); selectedGroup.BindValueChanged(_ => updateTimingGroup(), true);
controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups); controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups);
controlPointGroups.BindCollectionChanged((_, __) => updateTimingGroup()); controlPointGroups.BindCollectionChanged((_, __) => updateTimingGroup());
beatLength.BindValueChanged(_ => showFrom(lastDisplayedBeatIndex), true); beatLength.BindValueChanged(_ => regenerateDisplay(true), true);
displayLocked.BindValueChanged(locked =>
{
if (locked.NewValue)
lockedOverlay.Show();
else
lockedOverlay.Hide();
}, true);
} }
private void updateTimingGroup() private void updateTimingGroup()
{ {
beatLength.UnbindBindings(); beatLength.UnbindBindings();
selectedGroupStartTime = 0;
selectedGroupEndTime = beatmap.Value.Track.Length;
var tcp = selectedGroup.Value?.ControlPoints.OfType<TimingControlPoint>().FirstOrDefault(); var tcp = selectedGroup.Value?.ControlPoints.OfType<TimingControlPoint>().FirstOrDefault();
if (tcp == null) if (tcp == null)
{ {
timingPoint = new TimingControlPoint(); timingPoint = new TimingControlPoint();
// During movement of a control point's offset, this clause can be hit momentarily,
// as moving a control point is implemented by removing it and inserting it at the new time.
// We don't want to reset the `selectedGroupStartTime` here as we rely on having the
// last value to update the waveform display below.
selectedGroupEndTime = beatmap.Value.Track.Length;
return; return;
} }
timingPoint = tcp; timingPoint = tcp;
beatLength.BindTo(timingPoint.BeatLengthBindable); beatLength.BindTo(timingPoint.BeatLengthBindable);
selectedGroupStartTime = selectedGroup.Value?.Time ?? 0; double? newStartTime = selectedGroup.Value?.Time;
double? offsetChange = newStartTime - selectedGroupStartTime;
var nextGroup = editorBeatmap.ControlPointInfo.TimingPoints var nextGroup = editorBeatmap.ControlPointInfo.TimingPoints
.SkipWhile(g => g != tcp) .SkipWhile(g => g != tcp)
.Skip(1) .Skip(1)
.FirstOrDefault(); .FirstOrDefault();
if (nextGroup != null) selectedGroupStartTime = newStartTime ?? 0;
selectedGroupEndTime = nextGroup.Time; selectedGroupEndTime = nextGroup?.Time ?? beatmap.Value.Track.Length;
if (newStartTime.HasValue && offsetChange.HasValue)
{
// The offset of the selected point may have changed.
// This handles the case the user has locked the view and expects the display to update with this change.
showFromTime(displayedTime + offsetChange.Value, true);
}
} }
protected override bool OnHover(HoverEvent e) => true; protected override bool OnHover(HoverEvent e) => true;
protected override bool OnMouseMove(MouseMoveEvent e) protected override bool OnMouseMove(MouseMoveEvent e)
{ {
float trackLength = (float)beatmap.Value.Track.Length; if (!displayLocked.Value)
int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength); {
float trackLength = (float)beatmap.Value.Track.Length;
int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength);
Scheduler.AddOnce(showFrom, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable)); Scheduler.AddOnce(showFromBeat, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable));
}
return base.OnMouseMove(e); return base.OnMouseMove(e);
} }
protected override bool OnClick(ClickEvent e)
{
displayLocked.Toggle();
return true;
}
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
if (!IsHovered) if (!IsHovered && !displayLocked.Value)
{ {
int currentBeat = (int)Math.Floor((editorClock.CurrentTimeAccurate - selectedGroupStartTime) / timingPoint.BeatLength); int currentBeat = (int)Math.Floor((editorClock.CurrentTimeAccurate - selectedGroupStartTime) / timingPoint.BeatLength);
showFrom(currentBeat); showFromBeat(currentBeat);
} }
} }
private void showFrom(int beatIndex) private void showFromBeat(int beatIndex) =>
showFromTime(selectedGroupStartTime + beatIndex * timingPoint.BeatLength, false);
private void showFromTime(double time, bool animated)
{ {
if (lastDisplayedBeatIndex == beatIndex) if (displayedTime == time)
return; return;
displayedTime = time;
regenerateDisplay(animated);
}
private void regenerateDisplay(bool animated)
{
double index = (displayedTime - selectedGroupStartTime) / timingPoint.BeatLength;
// Chosen as a pretty usable number across all BPMs. // Chosen as a pretty usable number across all BPMs.
// Optimally we'd want this to scale with the BPM in question, but performing // Optimally we'd want this to scale with the BPM in question, but performing
// scaling of the display is both expensive in resampling, and decreases usability // scaling of the display is both expensive in resampling, and decreases usability
@ -156,38 +203,115 @@ namespace osu.Game.Screens.Edit.Timing
float trackLength = (float)beatmap.Value.Track.Length; float trackLength = (float)beatmap.Value.Track.Length;
float scale = trackLength / visible_width; float scale = trackLength / visible_width;
const int start_offset = total_waveforms / 2;
// Start displaying from before the current beat // Start displaying from before the current beat
beatIndex -= total_waveforms / 2; index -= start_offset;
foreach (var row in InternalChildren.OfType<WaveformRow>()) foreach (var row in InternalChildren.OfType<WaveformRow>())
{ {
// offset to the required beat index. // offset to the required beat index.
double time = selectedGroupStartTime + beatIndex * timingPoint.BeatLength; double time = selectedGroupStartTime + index * timingPoint.BeatLength;
float offset = (float)(time - visible_width / 2) / trackLength * scale; float offset = (float)(time - visible_width / 2) / trackLength * scale;
row.Alpha = time < selectedGroupStartTime || time > selectedGroupEndTime ? 0.2f : 1; row.Alpha = time < selectedGroupStartTime || time > selectedGroupEndTime ? 0.2f : 1;
row.WaveformOffset = -offset; row.WaveformOffsetTo(-offset, animated);
row.WaveformScale = new Vector2(scale, 1); row.WaveformScale = new Vector2(scale, 1);
row.BeatIndex = beatIndex++; row.BeatIndex = (int)Math.Floor(index);
index++;
}
}
internal class LockedOverlay : CompositeDrawable
{
private OsuSpriteText text = null!;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
RelativeSizeAxes = Axes.Both;
Masking = true;
CornerRadius = corner_radius;
BorderColour = colours.Red;
BorderThickness = 3;
Alpha = 0;
InternalChildren = new Drawable[]
{
new Box
{
AlwaysPresent = true,
RelativeSizeAxes = Axes.Both,
Alpha = 0,
},
new Container
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
AutoSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = colours.Red,
RelativeSizeAxes = Axes.Both,
},
text = new OsuSpriteText
{
Colour = colours.GrayF,
Text = "Locked",
Margin = new MarginPadding(5),
Shadow = false,
Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold),
}
}
},
};
} }
lastDisplayedBeatIndex = beatIndex; public override void Show()
{
this.FadeIn(100, Easing.OutQuint);
text
.FadeIn().Then().Delay(600)
.FadeOut().Then().Delay(600)
.Loop();
}
public override void Hide()
{
this.FadeOut(100, Easing.OutQuint);
}
} }
internal class WaveformRow : CompositeDrawable internal class WaveformRow : CompositeDrawable
{ {
private readonly bool isMainRow;
private OsuSpriteText beatIndexText = null!; private OsuSpriteText beatIndexText = null!;
private WaveformGraph waveformGraph = null!; private WaveformGraph waveformGraph = null!;
[Resolved] [Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!; private OverlayColourProvider colourProvider { get; set; } = null!;
public WaveformRow(bool isMainRow)
{
this.isMainRow = isMainRow;
}
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap) private void load(IBindable<WorkingBeatmap> beatmap)
{ {
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
new Box
{
Colour = colourProvider.Background3,
Alpha = isMainRow ? 1 : 0,
RelativeSizeAxes = Axes.Both,
},
waveformGraph = new WaveformGraph waveformGraph = new WaveformGraph
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
@ -212,7 +336,15 @@ namespace osu.Game.Screens.Edit.Timing
public int BeatIndex { set => beatIndexText.Text = value.ToString(); } public int BeatIndex { set => beatIndexText.Text = value.ToString(); }
public Vector2 WaveformScale { set => waveformGraph.Scale = value; } public Vector2 WaveformScale { set => waveformGraph.Scale = value; }
public float WaveformOffset { set => waveformGraph.X = value; }
public void WaveformOffsetTo(float value, bool animated) =>
this.TransformTo(nameof(waveformOffset), value, animated ? 300 : 0, Easing.OutQuint);
private float waveformOffset
{
get => waveformGraph.X;
set => waveformGraph.X = value;
}
} }
} }
} }

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations; using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio.Track; using osu.Framework.Audio.Track;
@ -80,16 +81,13 @@ namespace osu.Game.Screens.Play.HUD
difficultyCache.GetTimedDifficultyAttributesAsync(gameplayWorkingBeatmap, gameplayState.Ruleset, clonedMods, loadCancellationSource.Token) difficultyCache.GetTimedDifficultyAttributesAsync(gameplayWorkingBeatmap, gameplayState.Ruleset, clonedMods, loadCancellationSource.Token)
.ContinueWith(task => Schedule(() => .ContinueWith(task => Schedule(() =>
{ {
if (task.Exception != null)
return;
timedAttributes = task.GetResultSafely(); timedAttributes = task.GetResultSafely();
IsValid = true; IsValid = true;
if (lastJudgement != null) if (lastJudgement != null)
onJudgementChanged(lastJudgement); onJudgementChanged(lastJudgement);
})); }), TaskContinuationOptions.OnlyOnRanToCompletion);
} }
} }

View File

@ -0,0 +1,32 @@
// 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.Generic;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Gameplay
{
/// <summary>
/// Static class providing a <see cref="Create"/> convenience method to retrieve a correctly-initialised <see cref="GameplayState"/> instance in testing scenarios.
/// </summary>
public static class TestGameplayState
{
/// <summary>
/// Creates a correctly-initialised <see cref="GameplayState"/> instance for use in testing.
/// </summary>
public static GameplayState Create(Ruleset ruleset, IReadOnlyList<Mod>? mods = null, Score? score = null)
{
var beatmap = new TestBeatmap(ruleset.RulesetInfo);
var workingBeatmap = new TestWorkingBeatmap(beatmap);
var playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods);
return new GameplayState(playableBeatmap, ruleset, mods, score);
}
}
}

View File

@ -2,6 +2,7 @@
// 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.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -67,7 +68,25 @@ namespace osu.Game.Tests.Visual.OnlinePlay
// To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead. // To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead.
var beatmapManager = dependencies.Get<BeatmapManager>(); var beatmapManager = dependencies.Get<BeatmapManager>();
((DummyAPIAccess)API).HandleRequest = request => handler.HandleRequest(request, API.LocalUser.Value, beatmapManager); ((DummyAPIAccess)API).HandleRequest = request =>
{
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
// Because some of the handlers use realm, we need to ensure the game is still alive when firing.
// If we don't, a stray `PerformAsync` could hit an `ObjectDisposedException` if running too late.
Scheduler.Add(() =>
{
bool result = handler.HandleRequest(request, API.LocalUser.Value, beatmapManager);
tcs.SetResult(result);
}, false);
#pragma warning disable RS0030
// We can't GetResultSafely() here (will fail with "Can't use GetResultSafely from inside an async operation."), but Wait is safe enough due to
// the task being a TaskCompletionSource.
// Importantly, this doesn't deadlock because of the scheduler call above running inline where feasible (see the `false` argument).
return tcs.Task.Result;
#pragma warning restore RS0030
};
}); });
/// <summary> /// <summary>

View File

@ -48,9 +48,8 @@ namespace osu.Game.Utils
options.AutoSessionTracking = true; options.AutoSessionTracking = true;
options.IsEnvironmentUser = false; options.IsEnvironmentUser = false;
// The reported release needs to match release tags on github in order for sentry // The reported release needs to match version as reported to Sentry in .github/workflows/sentry-release.yml
// to automatically associate and track against releases. options.Release = $"osu@{game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty)}";
options.Release = game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty);
}); });
Logger.NewEntry += processLogEntry; Logger.NewEntry += processLogEntry;

View File

@ -29,13 +29,14 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Toolkit.HighPerformance" Version="7.1.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.LocalisationAnalyser" Version="2022.417.0"> <PackageReference Include="ppy.LocalisationAnalyser" Version="2022.417.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Realm" Version="10.11.2" /> <PackageReference Include="Realm" Version="10.11.2" />
<PackageReference Include="ppy.osu.Framework" Version="2022.528.0" /> <PackageReference Include="ppy.osu.Framework" Version="2022.530.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.527.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2022.527.0" />
<PackageReference Include="Sentry" Version="3.17.1" /> <PackageReference Include="Sentry" Version="3.17.1" />
<PackageReference Include="SharpCompress" Version="0.31.0" /> <PackageReference Include="SharpCompress" Version="0.31.0" />

View File

@ -61,7 +61,7 @@
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.528.0" /> <PackageReference Include="ppy.osu.Framework.iOS" Version="2022.530.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.527.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2022.527.0" />
</ItemGroup> </ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) --> <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
@ -84,7 +84,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2022.528.0" /> <PackageReference Include="ppy.osu.Framework" Version="2022.530.0" />
<PackageReference Include="SharpCompress" Version="0.31.0" /> <PackageReference Include="SharpCompress" Version="0.31.0" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />