diff --git a/osu.Android.props b/osu.Android.props
index 5f3fb858ee..647c7b179c 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,7 +51,7 @@
-
+
diff --git a/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs b/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs
index e6736d6c93..12d695393f 100644
--- a/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs
@@ -35,6 +35,8 @@ namespace osu.Game.Rulesets.Catch.UI
private void load(CatchInputManager catchInputManager, OsuColour colours)
{
const float width = 0.15f;
+ // Ratio between normal move area height and total input height
+ const float normal_area_height_ratio = 0.45f;
keyBindingContainer = catchInputManager.KeyBindingContainer;
@@ -54,18 +56,18 @@ namespace osu.Game.Rulesets.Catch.UI
Width = width,
Children = new Drawable[]
{
- leftDashBox = new InputArea(TouchCatchAction.DashLeft, trackedActionSources)
- {
- RelativeSizeAxes = Axes.Both,
- Width = 0.5f,
- },
leftBox = new InputArea(TouchCatchAction.MoveLeft, trackedActionSources)
{
RelativeSizeAxes = Axes.Both,
- Width = 0.5f,
+ Height = normal_area_height_ratio,
Colour = colours.Gray9,
- Anchor = Anchor.TopRight,
- Origin = Anchor.TopRight,
+ Anchor = Anchor.BottomRight,
+ Origin = Anchor.BottomRight,
+ },
+ leftDashBox = new InputArea(TouchCatchAction.DashLeft, trackedActionSources)
+ {
+ RelativeSizeAxes = Axes.Both,
+ Height = 1 - normal_area_height_ratio,
},
}
},
@@ -80,15 +82,15 @@ namespace osu.Game.Rulesets.Catch.UI
rightBox = new InputArea(TouchCatchAction.MoveRight, trackedActionSources)
{
RelativeSizeAxes = Axes.Both,
- Width = 0.5f,
+ Height = normal_area_height_ratio,
Colour = colours.Gray9,
+ Anchor = Anchor.BottomRight,
+ Origin = Anchor.BottomRight,
},
rightDashBox = new InputArea(TouchCatchAction.DashRight, trackedActionSources)
{
RelativeSizeAxes = Axes.Both,
- Width = 0.5f,
- Anchor = Anchor.TopRight,
- Origin = Anchor.TopRight,
+ Height = 1 - normal_area_height_ratio,
},
}
},
diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs
index 095fddc33f..bd52af7615 100644
--- a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs
@@ -83,5 +83,41 @@ namespace osu.Game.Rulesets.Taiko.Tests
AddAssert("first barline ommited", () => barlines.All(b => b.StartTime != start_time));
AddAssert("second barline generated", () => barlines.Any(b => b.StartTime == start_time + (beat_length * time_signature_numerator)));
}
+
+ [Test]
+ public void TestNegativeStartTimeTimingPoint()
+ {
+ const double beat_length = 250;
+
+ const int time_signature_numerator = 4;
+
+ var beatmap = new Beatmap
+ {
+ HitObjects =
+ {
+ new Hit
+ {
+ Type = HitType.Centre,
+ StartTime = 1000
+ }
+ },
+ BeatmapInfo =
+ {
+ Difficulty = new BeatmapDifficulty { SliderTickRate = 4 },
+ Ruleset = new TaikoRuleset().RulesetInfo
+ },
+ };
+
+ beatmap.ControlPointInfo.Add(-100, new TimingControlPoint
+ {
+ BeatLength = beat_length,
+ TimeSignature = new TimeSignature(time_signature_numerator)
+ });
+
+ var barlines = new BarLineGenerator(beatmap).BarLines;
+
+ AddAssert("bar line generated at t=900", () => barlines.Any(line => line.StartTime == 900));
+ AddAssert("bar line generated at t=1900", () => barlines.Any(line => line.StartTime == 1900));
+ }
}
}
diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20221102.osk b/osu.Game.Tests/Resources/Archives/modified-default-20221102.osk
new file mode 100644
index 0000000000..c1333acd13
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20221102.osk differ
diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
index cd6895b176..ff665499ae 100644
--- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
+++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
@@ -40,7 +40,9 @@ namespace osu.Game.Tests.Skins
// Covers clicks/s counter
"Archives/modified-default-20220818.osk",
// Covers longest combo counter
- "Archives/modified-default-20221012.osk"
+ "Archives/modified-default-20221012.osk",
+ // Covers TextElement and BeatmapInfoDrawable
+ "Archives/modified-default-20221102.osk"
};
///
diff --git a/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs b/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs
new file mode 100644
index 0000000000..64512bc651
--- /dev/null
+++ b/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs
@@ -0,0 +1,96 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics.Shaders;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics;
+using osuTK;
+using osuTK.Graphics;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.Rendering;
+
+namespace osu.Game.Tests.Visual.Background
+{
+ public class TestSceneTriangleBorderShader : OsuTestScene
+ {
+ private readonly TriangleBorder border;
+
+ public TestSceneTriangleBorderShader()
+ {
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Color4.DarkGreen
+ },
+ border = new TriangleBorder
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(100)
+ }
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ AddSliderStep("Thickness", 0f, 1f, 0.02f, t => border.Thickness = t);
+ }
+
+ private class TriangleBorder : Sprite
+ {
+ private float thickness = 0.02f;
+
+ public float Thickness
+ {
+ get => thickness;
+ set
+ {
+ thickness = value;
+ Invalidate(Invalidation.DrawNode);
+ }
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(ShaderManager shaders, IRenderer renderer)
+ {
+ TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder");
+ Texture = renderer.WhitePixel;
+ }
+
+ protected override DrawNode CreateDrawNode() => new TriangleBorderDrawNode(this);
+
+ private class TriangleBorderDrawNode : SpriteDrawNode
+ {
+ public new TriangleBorder Source => (TriangleBorder)base.Source;
+
+ public TriangleBorderDrawNode(TriangleBorder source)
+ : base(source)
+ {
+ }
+
+ private float thickness;
+
+ public override void ApplyState()
+ {
+ base.ApplyState();
+
+ thickness = Source.thickness;
+ }
+
+ public override void Draw(IRenderer renderer)
+ {
+ TextureShader.GetUniform("thickness").UpdateValue(ref thickness);
+
+ base.Draw(renderer);
+ }
+
+ protected override bool CanDrawOpaqueInterior => false;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Background/TestSceneTrianglesV2Background.cs b/osu.Game.Tests/Visual/Background/TestSceneTrianglesV2Background.cs
new file mode 100644
index 0000000000..0c3a21d510
--- /dev/null
+++ b/osu.Game.Tests/Visual/Background/TestSceneTrianglesV2Background.cs
@@ -0,0 +1,61 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osuTK;
+using osuTK.Graphics;
+using osu.Game.Graphics.Backgrounds;
+
+namespace osu.Game.Tests.Visual.Background
+{
+ public class TestSceneTrianglesV2Background : OsuTestScene
+ {
+ private readonly TrianglesV2 triangles;
+
+ public TestSceneTrianglesV2Background()
+ {
+ AddRange(new Drawable[]
+ {
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Color4.Gray
+ },
+ new Container
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(500, 100),
+ Masking = true,
+ CornerRadius = 40,
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Color4.Red
+ },
+ triangles = new TrianglesV2
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ RelativeSizeAxes = Axes.Both,
+ ColourTop = Color4.White,
+ ColourBottom = Color4.Red
+ }
+ }
+ }
+ });
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ AddSliderStep("Spawn ratio", 0f, 2f, 1f, s => triangles.SpawnRatio = s);
+ AddSliderStep("Thickness", 0f, 1f, 0.02f, t => triangles.Thickness = t);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs
index 10e1206b53..6ed63515e9 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs
@@ -18,6 +18,7 @@ using osu.Game.Overlays;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Timing;
using osuTK;
+using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing
{
@@ -125,6 +126,41 @@ namespace osu.Game.Tests.Visual.Editing
AddUntilStep("wait for track stopped", () => !EditorClock.IsRunning);
}
+ [Test]
+ public void TestNoCrashesWhenNoGroupSelected()
+ {
+ AddStep("unset selected group", () => selectedGroup.Value = null);
+ AddStep("press T to tap", () => InputManager.Key(Key.T));
+
+ AddStep("click tap button", () =>
+ {
+ control.ChildrenOfType()
+ .Last()
+ .TriggerClick();
+ });
+
+ AddStep("click reset button", () =>
+ {
+ control.ChildrenOfType()
+ .First()
+ .TriggerClick();
+ });
+
+ AddStep("adjust offset", () =>
+ {
+ var adjustOffsetButton = control.ChildrenOfType().First();
+ InputManager.MoveMouseTo(adjustOffsetButton);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddStep("adjust BPM", () =>
+ {
+ var adjustBPMButton = control.ChildrenOfType().Last();
+ InputManager.MoveMouseTo(adjustBPMButton);
+ InputManager.Click(MouseButton.Left);
+ });
+ }
+
protected override void Dispose(bool isDisposing)
{
Beatmap.Disabled = false;
diff --git a/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs b/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs
new file mode 100644
index 0000000000..dc1e00ee8f
--- /dev/null
+++ b/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs
@@ -0,0 +1,87 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Chat;
+using osu.Game.Overlays.Chat;
+
+namespace osu.Game.Tests.Visual.Online
+{
+ [TestFixture]
+ public class TestSceneDrawableChannel : OsuTestScene
+ {
+ private Channel channel = null!;
+ private DrawableChannel drawableChannel = null!;
+
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddStep("create channel", () => channel = new Channel
+ {
+ Id = 1,
+ Name = "Test channel"
+ });
+ AddStep("create drawable channel", () => Child = drawableChannel = new DrawableChannel(channel)
+ {
+ RelativeSizeAxes = Axes.Both
+ });
+ }
+
+ [Test]
+ public void TestDaySeparators()
+ {
+ var localUser = new APIUser
+ {
+ Id = 3,
+ Username = "LocalUser"
+ };
+ string uuid = Guid.NewGuid().ToString();
+ AddStep("add local echo message", () => channel.AddLocalEcho(new LocalEchoMessage
+ {
+ Sender = localUser,
+ Content = "Hi there all!",
+ Timestamp = new DateTimeOffset(2022, 11, 21, 20, 11, 13, TimeSpan.Zero),
+ Uuid = uuid
+ }));
+ AddUntilStep("one day separator present", () => drawableChannel.ChildrenOfType().Count() == 1);
+
+ AddStep("add two prior messages to channel", () => channel.AddNewMessages(
+ new Message(1)
+ {
+ Sender = new APIUser
+ {
+ Id = 1,
+ Username = "TestUser"
+ },
+ Content = "This is a message",
+ Timestamp = new DateTimeOffset(2021, 10, 10, 13, 33, 23, TimeSpan.Zero),
+ },
+ new Message(2)
+ {
+ Sender = new APIUser
+ {
+ Id = 2,
+ Username = "TestUser2"
+ },
+ Content = "This is another message",
+ Timestamp = new DateTimeOffset(2021, 10, 11, 13, 33, 23, TimeSpan.Zero)
+ }));
+ AddUntilStep("three day separators present", () => drawableChannel.ChildrenOfType().Count() == 3);
+
+ AddStep("resolve pending message", () => channel.ReplaceMessage(channel.Messages.OfType().Single(), new Message(3)
+ {
+ Sender = localUser,
+ Content = "Hi there all!",
+ Timestamp = new DateTimeOffset(2022, 11, 22, 20, 11, 16, TimeSpan.Zero),
+ Uuid = uuid
+ }));
+ AddUntilStep("three day separators present", () => drawableChannel.ChildrenOfType().Count() == 3);
+ AddAssert("last day separator is from correct day", () => drawableChannel.ChildrenOfType().Last().Date.Date == new DateTime(2022, 11, 22));
+ }
+ }
+}
diff --git a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs
new file mode 100644
index 0000000000..5fc32ff704
--- /dev/null
+++ b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs
@@ -0,0 +1,319 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Utils;
+using osuTK;
+using System;
+using osu.Framework.Graphics.Shaders;
+using osu.Framework.Graphics.Textures;
+using osu.Framework.Graphics.Primitives;
+using osu.Framework.Allocation;
+using System.Collections.Generic;
+using osu.Framework.Graphics.Rendering;
+using osu.Framework.Graphics.Rendering.Vertices;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp;
+using osuTK.Graphics;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+
+namespace osu.Game.Graphics.Backgrounds
+{
+ public class TrianglesV2 : Drawable
+ {
+ private const float triangle_size = 100;
+ private const float base_velocity = 50;
+ private const int texture_height = 128;
+
+ ///
+ /// sqrt(3) / 2
+ ///
+ private const float equilateral_triangle_ratio = 0.866f;
+
+ private readonly Bindable colourTop = new Bindable(Color4.White);
+ private readonly Bindable colourBottom = new Bindable(Color4.Black);
+
+ public Color4 ColourTop
+ {
+ get => colourTop.Value;
+ set => colourTop.Value = value;
+ }
+
+ public Color4 ColourBottom
+ {
+ get => colourBottom.Value;
+ set => colourBottom.Value = value;
+ }
+
+ public float Thickness { get; set; } = 0.02f; // No need for invalidation since it's happening in Update()
+
+ ///
+ /// Whether we should create new triangles as others expire.
+ ///
+ protected virtual bool CreateNewTriangles => true;
+
+ private readonly BindableFloat spawnRatio = new BindableFloat(1f);
+
+ ///
+ /// The amount of triangles we want compared to the default distribution.
+ ///
+ public float SpawnRatio
+ {
+ get => spawnRatio.Value;
+ set => spawnRatio.Value = value;
+ }
+
+ ///
+ /// The relative velocity of the triangles. Default is 1.
+ ///
+ public float Velocity = 1;
+
+ private readonly List parts = new List();
+
+ [Resolved]
+ private IRenderer renderer { get; set; } = null!;
+
+ private Random? stableRandom;
+
+ private IShader shader = null!;
+ private Texture texture = null!;
+
+ ///
+ /// Construct a new triangle visualisation.
+ ///
+ /// An optional seed to stabilise random positions / attributes. Note that this does not guarantee stable playback when seeking in time.
+ public TrianglesV2(int? seed = null)
+ {
+ if (seed != null)
+ stableRandom = new Random(seed.Value);
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(ShaderManager shaders)
+ {
+ shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder");
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ colourTop.BindValueChanged(_ => updateTexture());
+ colourBottom.BindValueChanged(_ => updateTexture(), true);
+
+ spawnRatio.BindValueChanged(_ => Reset(), true);
+ }
+
+ private void updateTexture()
+ {
+ var image = new Image(texture_height, 1);
+
+ texture = renderer.CreateTexture(1, texture_height, true);
+
+ for (int i = 0; i < texture_height; i++)
+ {
+ float ratio = (float)i / texture_height;
+
+ image[i, 0] = new Rgba32(
+ colourBottom.Value.R * ratio + colourTop.Value.R * (1f - ratio),
+ colourBottom.Value.G * ratio + colourTop.Value.G * (1f - ratio),
+ colourBottom.Value.B * ratio + colourTop.Value.B * (1f - ratio)
+ );
+ }
+
+ texture.SetData(new TextureUpload(image));
+ Invalidate(Invalidation.DrawNode);
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ Invalidate(Invalidation.DrawNode);
+
+ if (CreateNewTriangles)
+ addTriangles(false);
+
+ float elapsedSeconds = (float)Time.Elapsed / 1000;
+ // Since position is relative, the velocity needs to scale inversely with DrawHeight.
+ float movedDistance = -elapsedSeconds * Velocity * base_velocity / DrawHeight;
+
+ for (int i = 0; i < parts.Count; i++)
+ {
+ TriangleParticle newParticle = parts[i];
+
+ newParticle.Position.Y += Math.Max(0.5f, parts[i].SpeedMultiplier) * movedDistance;
+
+ parts[i] = newParticle;
+
+ float bottomPos = parts[i].Position.Y + triangle_size * equilateral_triangle_ratio / DrawHeight;
+ if (bottomPos < 0)
+ parts.RemoveAt(i);
+ }
+ }
+
+ ///
+ /// Clears and re-initialises triangles according to a given seed.
+ ///
+ /// An optional seed to stabilise random positions / attributes. Note that this does not guarantee stable playback when seeking in time.
+ public void Reset(int? seed = null)
+ {
+ if (seed != null)
+ stableRandom = new Random(seed.Value);
+
+ parts.Clear();
+ addTriangles(true);
+ }
+
+ protected int AimCount { get; private set; }
+
+ private void addTriangles(bool randomY)
+ {
+ // Limited by the maximum size of QuadVertexBuffer for safety.
+ const int max_triangles = ushort.MaxValue / (IRenderer.VERTICES_PER_QUAD + 2);
+
+ AimCount = (int)Math.Min(max_triangles, DrawWidth * DrawHeight * 0.0005f * SpawnRatio);
+
+ int currentCount = parts.Count;
+
+ for (int i = 0; i < AimCount - currentCount; i++)
+ parts.Add(createTriangle(randomY));
+ }
+
+ private TriangleParticle createTriangle(bool randomY)
+ {
+ TriangleParticle particle = CreateTriangle();
+
+ float y = 1;
+
+ if (randomY)
+ {
+ // since triangles are drawn from the top - allow them to be positioned a bit above the screen
+ float maxOffset = triangle_size * equilateral_triangle_ratio / DrawHeight;
+ y = Interpolation.ValueAt(nextRandom(), -maxOffset, 1f, 0f, 1f);
+ }
+
+ particle.Position = new Vector2(nextRandom(), y);
+
+ return particle;
+ }
+
+ ///
+ /// Creates a triangle particle with a random speed multiplier.
+ ///
+ /// The triangle particle.
+ protected virtual TriangleParticle CreateTriangle()
+ {
+ const float std_dev = 0.16f;
+ const float mean = 0.5f;
+
+ float u1 = 1 - nextRandom(); //uniform(0,1] random floats
+ float u2 = 1 - nextRandom();
+ float randStdNormal = (float)(Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2)); // random normal(0,1)
+ float speedMultiplier = Math.Max(mean + std_dev * randStdNormal, 0.1f); // random normal(mean,stdDev^2)
+
+ return new TriangleParticle { SpeedMultiplier = speedMultiplier };
+ }
+
+ private float nextRandom() => (float)(stableRandom?.NextDouble() ?? RNG.NextSingle());
+
+ protected override DrawNode CreateDrawNode() => new TrianglesDrawNode(this);
+
+ private class TrianglesDrawNode : DrawNode
+ {
+ protected new TrianglesV2 Source => (TrianglesV2)base.Source;
+
+ private IShader shader = null!;
+ private Texture texture = null!;
+
+ private readonly List parts = new List();
+ private Vector2 size;
+ private float thickness;
+
+ private IVertexBatch? vertexBatch;
+
+ public TrianglesDrawNode(TrianglesV2 source)
+ : base(source)
+ {
+ }
+
+ public override void ApplyState()
+ {
+ base.ApplyState();
+
+ shader = Source.shader;
+ texture = Source.texture;
+ size = Source.DrawSize;
+ thickness = Source.Thickness;
+
+ parts.Clear();
+ parts.AddRange(Source.parts);
+ }
+
+ public override void Draw(IRenderer renderer)
+ {
+ base.Draw(renderer);
+
+ if (Source.AimCount == 0)
+ return;
+
+ if (vertexBatch == null || vertexBatch.Size != Source.AimCount)
+ {
+ vertexBatch?.Dispose();
+ vertexBatch = renderer.CreateQuadBatch(Source.AimCount, 1);
+ }
+
+ shader.Bind();
+ shader.GetUniform("thickness").UpdateValue(ref thickness);
+
+ foreach (TriangleParticle particle in parts)
+ {
+ var offset = triangle_size * new Vector2(0.5f, equilateral_triangle_ratio);
+
+ Vector2 topLeft = particle.Position * size + new Vector2(-offset.X, 0f);
+ Vector2 topRight = particle.Position * size + new Vector2(offset.X, 0);
+ Vector2 bottomLeft = particle.Position * size + new Vector2(-offset.X, offset.Y);
+ Vector2 bottomRight = particle.Position * size + new Vector2(offset.X, offset.Y);
+
+ var drawQuad = new Quad(
+ Vector2Extensions.Transform(topLeft, DrawInfo.Matrix),
+ Vector2Extensions.Transform(topRight, DrawInfo.Matrix),
+ Vector2Extensions.Transform(bottomLeft, DrawInfo.Matrix),
+ Vector2Extensions.Transform(bottomRight, DrawInfo.Matrix)
+ );
+
+ var tRect = new Quad(
+ topLeft.X / size.X,
+ topLeft.Y / size.Y * texture_height,
+ (topRight.X - topLeft.X) / size.X,
+ (bottomRight.Y - topRight.Y) / size.Y * texture_height
+ ).AABBFloat;
+
+ renderer.DrawQuad(texture, drawQuad, DrawColourInfo.Colour, tRect, vertexBatch.AddAction, textureCoords: tRect);
+ }
+
+ shader.Unbind();
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ vertexBatch?.Dispose();
+ }
+ }
+
+ protected struct TriangleParticle
+ {
+ ///
+ /// The position of the top vertex of the triangle.
+ ///
+ public Vector2 Position;
+
+ ///
+ /// The speed multiplier of the triangle.
+ ///
+ public float SpeedMultiplier;
+ }
+ }
+}
diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs
index 03728b427f..de68dd231f 100644
--- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs
+++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs
@@ -177,8 +177,8 @@ namespace osu.Game.Online.Chat
protected override float Spacing => 5;
protected override float DateAlign => 125;
- public StandAloneDaySeparator(DateTimeOffset time)
- : base(time)
+ public StandAloneDaySeparator(DateTimeOffset date)
+ : base(date)
{
}
diff --git a/osu.Game/Overlays/Chat/DaySeparator.cs b/osu.Game/Overlays/Chat/DaySeparator.cs
index be0b53785c..d68f325738 100644
--- a/osu.Game/Overlays/Chat/DaySeparator.cs
+++ b/osu.Game/Overlays/Chat/DaySeparator.cs
@@ -22,14 +22,14 @@ namespace osu.Game.Overlays.Chat
protected virtual float Spacing => 15;
- private readonly DateTimeOffset time;
+ public readonly DateTimeOffset Date;
[Resolved(CanBeNull = true)]
private OverlayColourProvider? colourProvider { get; set; }
- public DaySeparator(DateTimeOffset time)
+ public DaySeparator(DateTimeOffset date)
{
- this.time = time;
+ Date = date;
Height = 40;
}
@@ -80,7 +80,7 @@ namespace osu.Game.Overlays.Chat
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
- Text = time.ToLocalTime().ToLocalisableString(@"dd MMMM yyyy").ToUpper(),
+ Text = Date.ToLocalTime().ToLocalisableString(@"dd MMMM yyyy").ToUpper(),
Font = OsuFont.Torus.With(size: TextSize, weight: FontWeight.SemiBold),
Colour = colourProvider?.Content1 ?? Colour4.White,
},
diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs
index 544daf7d2c..65876fd7c5 100644
--- a/osu.Game/Overlays/Chat/DrawableChannel.cs
+++ b/osu.Game/Overlays/Chat/DrawableChannel.cs
@@ -134,35 +134,22 @@ namespace osu.Game.Overlays.Chat
foreach (var message in displayMessages)
{
- if (lastMessage == null || lastMessage.Timestamp.ToLocalTime().Date != message.Timestamp.ToLocalTime().Date)
- ChatLineFlow.Add(CreateDaySeparator(message.Timestamp));
+ addDaySeparatorIfRequired(lastMessage, message);
ChatLineFlow.Add(CreateChatLine(message));
lastMessage = message;
}
var staleMessages = chatLines.Where(c => c.LifetimeEnd == double.MaxValue).ToArray();
+
int count = staleMessages.Length - Channel.MAX_HISTORY;
if (count > 0)
{
- void expireAndAdjustScroll(Drawable d)
- {
- scroll.OffsetScrollPosition(-d.DrawHeight);
- d.Expire();
- }
-
for (int i = 0; i < count; i++)
expireAndAdjustScroll(staleMessages[i]);
- // remove all adjacent day separators after stale message removal
- for (int i = 0; i < ChatLineFlow.Count - 1; i++)
- {
- if (!(ChatLineFlow[i] is DaySeparator)) break;
- if (!(ChatLineFlow[i + 1] is DaySeparator)) break;
-
- expireAndAdjustScroll(ChatLineFlow[i]);
- }
+ removeAdjacentDaySeparators();
}
// due to the scroll adjusts from old messages removal above, a scroll-to-end must be enforced,
@@ -183,10 +170,46 @@ namespace osu.Game.Overlays.Chat
ChatLineFlow.Remove(found, false);
found.Message = updated;
+
+ addDaySeparatorIfRequired(chatLines.LastOrDefault()?.Message, updated);
ChatLineFlow.Add(found);
}
});
+ private void addDaySeparatorIfRequired(Message lastMessage, Message message)
+ {
+ if (lastMessage == null || lastMessage.Timestamp.ToLocalTime().Date != message.Timestamp.ToLocalTime().Date)
+ {
+ // A day separator is displayed even if no messages are in the channel.
+ // If there are no messages after it, the simplest way to ensure it is fresh is to remove it
+ // and add a new one instead.
+ if (ChatLineFlow.LastOrDefault() is DaySeparator ds)
+ ChatLineFlow.Remove(ds, true);
+
+ ChatLineFlow.Add(CreateDaySeparator(message.Timestamp));
+
+ removeAdjacentDaySeparators();
+ }
+ }
+
+ private void removeAdjacentDaySeparators()
+ {
+ // remove all adjacent day separators after stale message removal
+ for (int i = 0; i < ChatLineFlow.Count - 1; i++)
+ {
+ if (!(ChatLineFlow[i] is DaySeparator)) break;
+ if (!(ChatLineFlow[i + 1] is DaySeparator)) break;
+
+ expireAndAdjustScroll(ChatLineFlow[i]);
+ }
+ }
+
+ private void expireAndAdjustScroll(Drawable d)
+ {
+ scroll.OffsetScrollPosition(-d.DrawHeight);
+ d.Expire();
+ }
+
private void messageRemoved(Message removed) => Schedule(() =>
{
chatLines.FirstOrDefault(c => c.Message == removed)?.FadeColour(Color4.Red, 400).FadeOut(600).Expire();
diff --git a/osu.Game/Rulesets/Objects/BarLineGenerator.cs b/osu.Game/Rulesets/Objects/BarLineGenerator.cs
index c2709db747..5c76c43f20 100644
--- a/osu.Game/Rulesets/Objects/BarLineGenerator.cs
+++ b/osu.Game/Rulesets/Objects/BarLineGenerator.cs
@@ -27,7 +27,10 @@ namespace osu.Game.Rulesets.Objects
if (beatmap.HitObjects.Count == 0)
return;
+ HitObject firstObject = beatmap.HitObjects.First();
HitObject lastObject = beatmap.HitObjects.Last();
+
+ double firstHitTime = firstObject.StartTime;
double lastHitTime = 1 + lastObject.GetEndTime();
var timingPoints = beatmap.ControlPointInfo.TimingPoints;
@@ -41,12 +44,31 @@ namespace osu.Game.Rulesets.Objects
EffectControlPoint currentEffectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTimingPoint.Time);
int currentBeat = 0;
+ // Don't generate barlines before the hit object or t=0 (whichever is earliest). Some beatmaps use very unrealistic values here (although none are ranked).
+ // I'm not sure we ever want barlines to appear before the first hitobject, but let's keep some degree of compatibility for now.
+ // Of note, this will still differ from stable if the first timing control point is t<0 and is not near the first hitobject.
+ double generationStartTime = Math.Min(0, firstHitTime);
+
// Stop on the next timing point, or if there is no next timing point stop slightly past the last object
double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time : lastHitTime + currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator;
- double startTime = currentTimingPoint.Time;
double barLength = currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator;
+ double startTime;
+
+ if (currentTimingPoint.Time > generationStartTime)
+ {
+ startTime = currentTimingPoint.Time;
+ }
+ else
+ {
+ // If the timing point starts before the minimum allowable time for bar lines,
+ // we still need to compute a start time for generation that is actually properly aligned with the timing point.
+ int barCount = (int)Math.Ceiling((generationStartTime - currentTimingPoint.Time) / barLength);
+
+ startTime = currentTimingPoint.Time + barCount * barLength;
+ }
+
if (currentEffectPoint.OmitFirstBarLine)
{
startTime += barLength;
diff --git a/osu.Game/Screens/Edit/Timing/TapButton.cs b/osu.Game/Screens/Edit/Timing/TapButton.cs
index 151c3cea2e..2944eea4fe 100644
--- a/osu.Game/Screens/Edit/Timing/TapButton.cs
+++ b/osu.Game/Screens/Edit/Timing/TapButton.cs
@@ -295,6 +295,9 @@ namespace osu.Game.Screens.Edit.Timing
private void handleTap()
{
+ if (selectedGroup?.Value == null)
+ return;
+
tapTimings.Add(Clock.CurrentTime);
if (tapTimings.Count > initial_taps_to_ignore + max_taps_to_consider)
diff --git a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs
index abc73851e4..3b26e335d9 100644
--- a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs
+++ b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs
@@ -183,18 +183,27 @@ namespace osu.Game.Screens.Edit.Timing
private void start()
{
+ if (selectedGroup.Value == null)
+ return;
+
editorClock.Seek(selectedGroup.Value.Time);
editorClock.Start();
}
private void reset()
{
+ if (selectedGroup.Value == null)
+ return;
+
editorClock.Stop();
editorClock.Seek(selectedGroup.Value.Time);
}
private void adjustOffset(double adjust)
{
+ if (selectedGroup.Value == null)
+ return;
+
bool wasAtStart = editorClock.CurrentTimeAccurate == selectedGroup.Value.Time;
// VERY TEMPORARY
@@ -216,7 +225,7 @@ namespace osu.Game.Screens.Edit.Timing
private void adjustBpm(double adjust)
{
- var timing = selectedGroup.Value.ControlPoints.OfType().FirstOrDefault();
+ var timing = selectedGroup.Value?.ControlPoints.OfType().FirstOrDefault();
if (timing == null)
return;
diff --git a/osu.Game/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs
new file mode 100644
index 0000000000..ec84831fb4
--- /dev/null
+++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs
@@ -0,0 +1,142 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using JetBrains.Annotations;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions;
+using osu.Framework.Extensions.LocalisationExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Localisation;
+using osu.Game.Beatmaps;
+using osu.Game.Configuration;
+using osu.Game.Extensions;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Localisation;
+using osu.Game.Resources.Localisation.Web;
+
+namespace osu.Game.Skinning.Components
+{
+ [UsedImplicitly]
+ public class BeatmapAttributeText : Container, ISkinnableDrawable
+ {
+ public bool UsesFixedAnchor { get; set; }
+
+ [SettingSource("Attribute", "The attribute to be displayed.")]
+ public Bindable Attribute { get; } = new Bindable(BeatmapAttribute.StarRating);
+
+ [SettingSource("Template", "Supports {Label} and {Value}, but also including arbitrary attributes like {StarRating} (see attribute list for supported values).")]
+ public Bindable Template { get; set; } = new Bindable("{Label}: {Value}");
+
+ [Resolved]
+ private IBindable beatmap { get; set; } = null!;
+
+ private readonly Dictionary valueDictionary = new Dictionary();
+
+ private static readonly ImmutableDictionary label_dictionary = new Dictionary
+ {
+ [BeatmapAttribute.CircleSize] = BeatmapsetsStrings.ShowStatsCs,
+ [BeatmapAttribute.Accuracy] = BeatmapsetsStrings.ShowStatsAccuracy,
+ [BeatmapAttribute.HPDrain] = BeatmapsetsStrings.ShowStatsDrain,
+ [BeatmapAttribute.ApproachRate] = BeatmapsetsStrings.ShowStatsAr,
+ [BeatmapAttribute.StarRating] = BeatmapsetsStrings.ShowStatsStars,
+ [BeatmapAttribute.Title] = EditorSetupStrings.Title,
+ [BeatmapAttribute.Artist] = EditorSetupStrings.Artist,
+ [BeatmapAttribute.DifficultyName] = EditorSetupStrings.DifficultyHeader,
+ [BeatmapAttribute.Creator] = EditorSetupStrings.Creator,
+ [BeatmapAttribute.Length] = ArtistStrings.TracklistLength.ToTitle(),
+ [BeatmapAttribute.RankedStatus] = BeatmapDiscussionsStrings.IndexFormBeatmapsetStatusDefault,
+ [BeatmapAttribute.BPM] = BeatmapsetsStrings.ShowStatsBpm,
+ }.ToImmutableDictionary();
+
+ private readonly OsuSpriteText text;
+
+ public BeatmapAttributeText()
+ {
+ AutoSizeAxes = Axes.Both;
+
+ InternalChildren = new Drawable[]
+ {
+ text = new OsuSpriteText
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Font = OsuFont.Default.With(size: 40)
+ }
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ Attribute.BindValueChanged(_ => updateLabel());
+ Template.BindValueChanged(_ => updateLabel());
+ beatmap.BindValueChanged(b =>
+ {
+ updateBeatmapContent(b.NewValue);
+ updateLabel();
+ }, true);
+ }
+
+ private void updateBeatmapContent(WorkingBeatmap workingBeatmap)
+ {
+ valueDictionary[BeatmapAttribute.Title] = workingBeatmap.BeatmapInfo.Metadata.Title;
+ valueDictionary[BeatmapAttribute.Artist] = workingBeatmap.BeatmapInfo.Metadata.Artist;
+ valueDictionary[BeatmapAttribute.DifficultyName] = workingBeatmap.BeatmapInfo.DifficultyName;
+ valueDictionary[BeatmapAttribute.Creator] = workingBeatmap.BeatmapInfo.Metadata.Author.Username;
+ valueDictionary[BeatmapAttribute.Length] = TimeSpan.FromMilliseconds(workingBeatmap.BeatmapInfo.Length).ToFormattedDuration();
+ valueDictionary[BeatmapAttribute.RankedStatus] = workingBeatmap.BeatmapInfo.Status.GetLocalisableDescription();
+ valueDictionary[BeatmapAttribute.BPM] = workingBeatmap.BeatmapInfo.BPM.ToLocalisableString(@"F2");
+ valueDictionary[BeatmapAttribute.CircleSize] = ((double)workingBeatmap.BeatmapInfo.Difficulty.CircleSize).ToLocalisableString(@"F2");
+ valueDictionary[BeatmapAttribute.HPDrain] = ((double)workingBeatmap.BeatmapInfo.Difficulty.DrainRate).ToLocalisableString(@"F2");
+ valueDictionary[BeatmapAttribute.Accuracy] = ((double)workingBeatmap.BeatmapInfo.Difficulty.OverallDifficulty).ToLocalisableString(@"F2");
+ valueDictionary[BeatmapAttribute.ApproachRate] = ((double)workingBeatmap.BeatmapInfo.Difficulty.ApproachRate).ToLocalisableString(@"F2");
+ valueDictionary[BeatmapAttribute.StarRating] = workingBeatmap.BeatmapInfo.StarRating.ToLocalisableString(@"F2");
+ }
+
+ private void updateLabel()
+ {
+ string numberedTemplate = Template.Value
+ .Replace("{", "{{")
+ .Replace("}", "}}")
+ .Replace(@"{{Label}}", "{0}")
+ .Replace(@"{{Value}}", $"{{{1 + (int)Attribute.Value}}}");
+
+ object?[] args = valueDictionary.OrderBy(pair => pair.Key)
+ .Select(pair => pair.Value)
+ .Prepend(label_dictionary[Attribute.Value])
+ .Cast
-
+