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() + .ToArray(); + + foreach (var type in Enum.GetValues(typeof(BeatmapAttribute)).Cast()) + { + numberedTemplate = numberedTemplate.Replace($"{{{{{type}}}}}", $"{{{1 + (int)type}}}"); + } + + text.Text = LocalisableString.Format(numberedTemplate, args); + } + } + + public enum BeatmapAttribute + { + CircleSize, + HPDrain, + Accuracy, + ApproachRate, + StarRating, + Title, + Artist, + DifficultyName, + Creator, + Length, + RankedStatus, + BPM, + } +} diff --git a/osu.Game/Skinning/Components/TextElement.cs b/osu.Game/Skinning/Components/TextElement.cs new file mode 100644 index 0000000000..1e618a7f82 --- /dev/null +++ b/osu.Game/Skinning/Components/TextElement.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Skinning.Components +{ + [UsedImplicitly] + public class TextElement : Container, ISkinnableDrawable + { + public bool UsesFixedAnchor { get; set; } + + [SettingSource("Text", "The text to be displayed.")] + public Bindable Text { get; } = new Bindable("Circles!"); + + public TextElement() + { + AutoSizeAxes = Axes.Both; + OsuSpriteText text; + InternalChildren = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 40) + } + }; + text.Current.BindTo(Text); + } + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 858cac1dac..5c4b5642b5 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ - + diff --git a/osu.iOS.props b/osu.iOS.props index 25217b872b..19f4248055 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,7 +61,7 @@ - +