diff --git a/osu-framework b/osu-framework index 2610a31337..90bf49a2df 160000 --- a/osu-framework +++ b/osu-framework @@ -1 +1 @@ -Subproject commit 2610a3133721b0bc4af852342aa2a179d0e66497 +Subproject commit 90bf49a2df3dbad5994d922df63e4891c622dbc3 diff --git a/osu-resources b/osu-resources index 266965f0d7..92ec3d10b1 160000 --- a/osu-resources +++ b/osu-resources @@ -1 +1 @@ -Subproject commit 266965f0d795b94a126e2da302bd2c10eadd642a +Subproject commit 92ec3d10b12c5e9bfc1d3b05d3db174a506efd6d diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs new file mode 100644 index 0000000000..a6e67ea979 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Judgements +{ + public class OsuSliderTailJudgement : OsuJudgement + { + public override bool AffectsCombo => false; + protected override int NumericResultFor(HitResult result) => 0; + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index af947817c0..5f464402d0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -18,14 +18,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public class DrawableSlider : DrawableOsuHitObject, IDrawableHitObjectWithProxiedApproach { private readonly Slider slider; - - public readonly DrawableHitCircle InitialCircle; - private readonly List components = new List(); - private readonly Container ticks; - private readonly Container repeatPoints; - + public readonly DrawableHitCircle HeadCircle; public readonly SliderBody Body; public readonly SliderBall Ball; @@ -34,6 +29,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { slider = s; + DrawableSliderTail tail; + Container ticks; + Container repeatPoints; + Children = new Drawable[] { Body = new SliderBody(s) @@ -51,27 +50,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables AlwaysPresent = true, Alpha = 0 }, - InitialCircle = new DrawableHitCircle(new HitCircle - { - StartTime = s.StartTime, - Position = s.StackedPosition, - IndexInCurrentCombo = s.IndexInCurrentCombo, - Scale = s.Scale, - ComboColour = s.ComboColour, - Samples = s.Samples, - SampleControlPoint = s.SampleControlPoint, - TimePreempt = s.TimePreempt, - TimeFadein = s.TimeFadein, - HitWindow300 = s.HitWindow300, - HitWindow100 = s.HitWindow100, - HitWindow50 = s.HitWindow50 - }) + HeadCircle = new DrawableHitCircle(s.HeadCircle), + tail = new DrawableSliderTail(s.TailCircle) }; components.Add(Body); components.Add(Ball); - AddNested(InitialCircle); + AddNested(HeadCircle); + + AddNested(tail); + components.Add(tail); foreach (var tick in s.NestedHitObjects.OfType()) { @@ -87,6 +76,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }; ticks.Add(drawableTick); + components.Add(drawableTick); AddNested(drawableTick); } @@ -121,27 +111,25 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables currentSpan = span; //todo: we probably want to reconsider this before adding scoring, but it looks and feels nice. - if (!InitialCircle.Judgements.Any(j => j.IsHit)) - InitialCircle.Position = slider.Curve.PositionAt(progress); + if (!HeadCircle.IsHit) + HeadCircle.Position = slider.Curve.PositionAt(progress); foreach (var c in components.OfType()) c.UpdateProgress(progress, span); foreach (var c in components.OfType()) c.UpdateSnakingPosition(slider.Curve.PositionAt(Body.SnakedStart ?? 0), slider.Curve.PositionAt(Body.SnakedEnd ?? 0)); - foreach (var t in ticks.Children) t.Tracking = Ball.Tracking; + foreach (var t in components.OfType()) t.Tracking = Ball.Tracking; } protected override void CheckForJudgements(bool userTriggered, double timeOffset) { if (!userTriggered && Time.Current >= slider.EndTime) { - var judgementsCount = ticks.Children.Count + repeatPoints.Children.Count + 1; - var judgementsHit = ticks.Children.Count(t => t.Judgements.Any(j => j.IsHit)) + repeatPoints.Children.Count(t => t.Judgements.Any(j => j.IsHit)); - if (InitialCircle.Judgements.Any(j => j.IsHit)) - judgementsHit++; + var judgementsCount = NestedHitObjects.Count; + var judgementsHit = NestedHitObjects.Count(h => h.IsHit); var hitFraction = (double)judgementsHit / judgementsCount; - if (hitFraction == 1 && InitialCircle.Judgements.Any(j => j.Result == HitResult.Great)) + if (hitFraction == 1 && HeadCircle.Judgements.Any(j => j.Result == HitResult.Great)) AddJudgement(new OsuJudgement { Result = HitResult.Great }); - else if (hitFraction >= 0.5 && InitialCircle.Judgements.Any(j => j.Result >= HitResult.Good)) + else if (hitFraction >= 0.5 && HeadCircle.Judgements.Any(j => j.Result >= HitResult.Good)) AddJudgement(new OsuJudgement { Result = HitResult.Good }); else if (hitFraction > 0) AddJudgement(new OsuJudgement { Result = HitResult.Meh }); @@ -173,7 +161,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } } - public Drawable ProxiedLayer => InitialCircle.ApproachCircle; + public Drawable ProxiedLayer => HeadCircle.ApproachCircle; public override Vector2 SelectionPoint => ToScreenSpace(Body.Position); public override Quad SelectionQuad => Body.PathDrawQuad; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs new file mode 100644 index 0000000000..8835fc2b29 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking + { + /// + /// The judgement text is provided by the . + /// + public override bool DisplayJudgement => false; + + public bool Tracking { get; set; } + + public DrawableSliderTail(HitCircle hitCircle) + : base(hitCircle) + { + AlwaysPresent = true; + RelativeSizeAxes = Axes.Both; + } + + protected override void CheckForJudgements(bool userTriggered, double timeOffset) + { + if (!userTriggered && timeOffset >= 0) + AddJudgement(new OsuSliderTailJudgement { Result = Tracking ? HitResult.Great : HitResult.Miss }); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index 09985752a4..ae76f1e0e1 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -12,14 +12,14 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderTick : DrawableOsuHitObject + public class DrawableSliderTick : DrawableOsuHitObject, IRequireTracking { private readonly SliderTick sliderTick; public double FadeInTime; public double FadeOutTime; - public bool Tracking; + public bool Tracking { get; set; } public override bool DisplayJudgement => false; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs new file mode 100644 index 0000000000..98fc686dd3 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public interface IRequireTracking + { + /// + /// Whether the is currently being tracked by the user. + /// + bool Tracking { get; set; } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 79bb14a475..d4444c5c5d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -80,6 +80,9 @@ namespace osu.Game.Rulesets.Osu.Objects public double Velocity; public double TickDistance; + public HitCircle HeadCircle; + public HitCircle TailCircle; + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); @@ -97,10 +100,37 @@ namespace osu.Game.Rulesets.Osu.Objects { base.CreateNestedHitObjects(); + createSliderEnds(); createTicks(); createRepeatPoints(); } + private void createSliderEnds() + { + HeadCircle = new HitCircle + { + StartTime = StartTime, + Position = StackedPosition, + IndexInCurrentCombo = IndexInCurrentCombo, + ComboColour = ComboColour, + Samples = Samples, + SampleControlPoint = SampleControlPoint + }; + + TailCircle = new HitCircle + { + StartTime = EndTime, + Position = StackedEndPosition, + IndexInCurrentCombo = IndexInCurrentCombo, + ComboColour = ComboColour, + Samples = Samples, + SampleControlPoint = SampleControlPoint + }; + + AddNested(HeadCircle); + AddNested(TailCircle); + } + private void createTicks() { if (TickDistance == 0) return; diff --git a/osu.Game.Rulesets.Osu/Tests/TestCaseSlider.cs b/osu.Game.Rulesets.Osu/Tests/TestCaseSlider.cs index 5060137ec6..2d26b74d01 100644 --- a/osu.Game.Rulesets.Osu/Tests/TestCaseSlider.cs +++ b/osu.Game.Rulesets.Osu/Tests/TestCaseSlider.cs @@ -16,6 +16,9 @@ using OpenTK; using OpenTK.Graphics; using osu.Game.Rulesets.Mods; using System.Linq; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; namespace osu.Game.Rulesets.Osu.Tests @@ -142,7 +145,34 @@ namespace osu.Game.Rulesets.Osu.Tests foreach (var mod in Mods.OfType()) mod.ApplyToDrawableHitObjects(new[] { drawable }); + drawable.OnJudgement += onJudgement; + Add(drawable); } + + private float judgementOffsetDirection = 1; + private void onJudgement(DrawableHitObject judgedObject, Judgement judgement) + { + var osuObject = judgedObject as DrawableOsuHitObject; + if (osuObject == null) + return; + + OsuSpriteText text; + Add(text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = judgement.IsHit ? "Hit!" : "Miss!", + Colour = judgement.IsHit ? Color4.Green : Color4.Red, + TextSize = 30, + Position = osuObject.HitObject.StackedEndPosition + judgementOffsetDirection * new Vector2(0, 45) + }); + + text.Delay(150) + .Then().FadeOut(200) + .Then().Expire(); + + judgementOffsetDirection *= -1; + } } } diff --git a/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj b/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj index 74a3883f0a..97a003513f 100644 --- a/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj +++ b/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj @@ -55,6 +55,7 @@ + @@ -75,6 +76,8 @@ + + diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs index c1fe2c13a8..002159439d 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs @@ -35,8 +35,8 @@ namespace osu.Game.Rulesets.Taiko.Replays { bool hitButton = true; - Frames.Add(new ReplayFrame(-100000, null, null, ReplayButtonState.None)); - Frames.Add(new ReplayFrame(Beatmap.HitObjects[0].StartTime - 1000, null, null, ReplayButtonState.None)); + Frames.Add(new TaikoReplayFrame(-100000, ReplayButtonState.None)); + Frames.Add(new TaikoReplayFrame(Beatmap.HitObjects[0].StartTime - 1000, ReplayButtonState.None)); for (int i = 0; i < Beatmap.HitObjects.Count; i++) { @@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Taiko.Replays break; } - Frames.Add(new ReplayFrame(j, null, null, button)); + Frames.Add(new TaikoReplayFrame(j, button)); d = (d + 1) % 4; if (++count == req) break; @@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.Taiko.Replays { foreach (var tick in drumRoll.NestedHitObjects.OfType()) { - Frames.Add(new ReplayFrame(tick.StartTime, null, null, hitButton ? ReplayButtonState.Right1 : ReplayButtonState.Right2)); + Frames.Add(new TaikoReplayFrame(tick.StartTime, hitButton ? ReplayButtonState.Right1 : ReplayButtonState.Right2)); hitButton = !hitButton; } } @@ -107,18 +107,18 @@ namespace osu.Game.Rulesets.Taiko.Replays button = hitButton ? ReplayButtonState.Left1 : ReplayButtonState.Left2; } - Frames.Add(new ReplayFrame(h.StartTime, null, null, button)); + Frames.Add(new TaikoReplayFrame(h.StartTime, button)); } else throw new InvalidOperationException("Unknown hit object type."); - Frames.Add(new ReplayFrame(endTime + KEY_UP_DELAY, null, null, ReplayButtonState.None)); + Frames.Add(new TaikoReplayFrame(endTime + KEY_UP_DELAY, ReplayButtonState.None)); if (i < Beatmap.HitObjects.Count - 1) { double waitTime = Beatmap.HitObjects[i + 1].StartTime - 1000; if (waitTime > endTime) - Frames.Add(new ReplayFrame(waitTime, null, null, ReplayButtonState.None)); + Frames.Add(new TaikoReplayFrame(waitTime, ReplayButtonState.None)); } hitButton = !hitButton; diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs new file mode 100644 index 0000000000..0c60cdc109 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.Taiko.Replays +{ + public class TaikoReplayFrame : ReplayFrame + { + public override bool IsImportant => MouseLeft || MouseRight; + + public TaikoReplayFrame(double time, ReplayButtonState buttons) + : base(time, null, null, buttons) + { + } + } +} diff --git a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj index 90256c7d63..5795048322 100644 --- a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj +++ b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj @@ -95,6 +95,7 @@ + diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs new file mode 100644 index 0000000000..f102e4c59f --- /dev/null +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -0,0 +1,247 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using NUnit.Framework; +using osu.Game.Online.Chat; + +namespace osu.Game.Tests.Chat +{ + [TestFixture] + public class MessageFormatterTests + { + [Test] + public void TestBareLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a http://www.basic-link.com/?test=test." }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("http://www.basic-link.com/?test=test", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(36, result.Links[0].Length); + } + + [Test] + public void TestMultipleComplexLinks() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a http://test.io/link#fragment. (see https://twitter.com). Also, This string should not be altered. http://example.com/" }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(3, result.Links.Count); + + Assert.AreEqual("http://test.io/link#fragment", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(28, result.Links[0].Length); + + Assert.AreEqual("https://twitter.com", result.Links[1].Url); + Assert.AreEqual(45, result.Links[1].Index); + Assert.AreEqual(19, result.Links[1].Length); + + Assert.AreEqual("http://example.com/", result.Links[2].Url); + Assert.AreEqual(108, result.Links[2].Index); + Assert.AreEqual(19, result.Links[2].Length); + } + + [Test] + public void TestAjaxLinks() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "https://twitter.com/#!/hashbanglinks" }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(result.Content, result.Links[0].Url); + Assert.AreEqual(0, result.Links[0].Index); + Assert.AreEqual(36, result.Links[0].Length); + } + + [Test] + public void TestUnixHomeLinks() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "http://www.chiark.greenend.org.uk/~sgtatham/putty/" }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(result.Content, result.Links[0].Url); + Assert.AreEqual(0, result.Links[0].Index); + Assert.AreEqual(50, result.Links[0].Length); + } + + [Test] + public void TestCaseInsensitiveLinks() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "look: http://puu.sh/7Ggh8xcC6/asf0asd9876.NEF" }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(6, result.Links[0].Index); + Assert.AreEqual(39, result.Links[0].Length); + } + + [Test] + public void TestWikiLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [[Wiki Link]]." }); + + Assert.AreEqual("This is a Wiki Link.", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh/wiki/Wiki Link", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(9, result.Links[0].Length); + } + + [Test] + public void TestMultiWikiLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [[Wiki Link]] [[Wiki:Link]][[Wiki.Link]]." }); + + Assert.AreEqual("This is a Wiki Link Wiki:LinkWiki.Link.", result.DisplayContent); + Assert.AreEqual(3, result.Links.Count); + + Assert.AreEqual("https://osu.ppy.sh/wiki/Wiki Link", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(9, result.Links[0].Length); + + Assert.AreEqual("https://osu.ppy.sh/wiki/Wiki:Link", result.Links[1].Url); + Assert.AreEqual(20, result.Links[1].Index); + Assert.AreEqual(9, result.Links[1].Length); + + Assert.AreEqual("https://osu.ppy.sh/wiki/Wiki.Link", result.Links[2].Url); + Assert.AreEqual(29, result.Links[2].Index); + Assert.AreEqual(9, result.Links[2].Length); + } + + [Test] + public void TestOldFormatLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a (simple test)[https://osu.ppy.sh] of links." }); + + Assert.AreEqual("This is a simple test of links.", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(11, result.Links[0].Length); + } + + [Test] + public void TestNewFormatLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh simple test]." }); + + Assert.AreEqual("This is a simple test.", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(11, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [simple test](https://osu.ppy.sh)." }); + + Assert.AreEqual("This is a simple test.", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(11, result.Links[0].Length); + } + + [Test] + public void TestChannelLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is an #english and #japanese." }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(2, result.Links.Count); + Assert.AreEqual("osu://chan/#english", result.Links[0].Url); + Assert.AreEqual("osu://chan/#japanese", result.Links[1].Url); + } + + [Test] + public void TestOsuProtocol() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a custom protocol osu://chan/#english." }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("osu://chan/#english", result.Links[0].Url); + Assert.AreEqual(26, result.Links[0].Index); + Assert.AreEqual(19, result.Links[0].Length); + + result = MessageFormatter.FormatMessage(new Message { Content = "This is a [custom protocol](osu://chan/#english)." }); + + Assert.AreEqual("This is a custom protocol.", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("osu://chan/#english", result.Links[0].Url); + Assert.AreEqual("#english", result.Links[0].Argument); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(15, result.Links[0].Length); + } + + [Test] + public void TestOsuMpProtocol() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "Join my multiplayer game osump://12346." }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("osump://12346", result.Links[0].Url); + Assert.AreEqual(25, result.Links[0].Index); + Assert.AreEqual(13, result.Links[0].Length); + } + + [Test] + public void TestRecursiveBreaking() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh [[simple test]]]." }); + + Assert.AreEqual("This is a [[simple test]].", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(15, result.Links[0].Length); + } + + [Test] + public void TestLinkComplex() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [http://www.simple-test.com simple test] with some [traps] and [[wiki links]]. Don't forget to visit https://osu.ppy.sh (now!)[http://google.com]\uD83D\uDE12" }); + + Assert.AreEqual("This is a simple test with some [traps] and wiki links. Don't forget to visit https://osu.ppy.sh now!\0\0\0", result.DisplayContent); + Assert.AreEqual(5, result.Links.Count); + + Link f = result.Links.Find(l => l.Url == "https://osu.ppy.sh/wiki/wiki links"); + Assert.AreEqual(44, f.Index); + Assert.AreEqual(10, f.Length); + + f = result.Links.Find(l => l.Url == "http://www.simple-test.com"); + Assert.AreEqual(10, f.Index); + Assert.AreEqual(11, f.Length); + + f = result.Links.Find(l => l.Url == "http://google.com"); + Assert.AreEqual(97, f.Index); + Assert.AreEqual(4, f.Length); + + f = result.Links.Find(l => l.Url == "https://osu.ppy.sh"); + Assert.AreEqual(78, f.Index); + Assert.AreEqual(18, f.Length); + + f = result.Links.Find(l => l.Url == "\uD83D\uDE12"); + Assert.AreEqual(101, f.Index); + Assert.AreEqual(3, f.Length); + } + + [Test] + public void TestEmoji() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "Hello world\uD83D\uDE12<--This is an emoji,There are more:\uD83D\uDE10\uD83D\uDE00,\uD83D\uDE20" }); + Assert.AreEqual("Hello world\0\0\0<--This is an emoji,There are more:\0\0\0\0\0\0,\0\0\0", result.DisplayContent); + Assert.AreEqual(result.Links.Count, 4); + Assert.AreEqual(result.Links[0].Index, 11); + Assert.AreEqual(result.Links[1].Index, 49); + Assert.AreEqual(result.Links[2].Index, 52); + Assert.AreEqual(result.Links[3].Index, 56); + Assert.AreEqual(result.Links[0].Url, "\uD83D\uDE12"); + Assert.AreEqual(result.Links[1].Url, "\uD83D\uDE10"); + Assert.AreEqual(result.Links[2].Url, "\uD83D\uDE00"); + Assert.AreEqual(result.Links[3].Url, "\uD83D\uDE20"); + } + } +} diff --git a/osu.Game.Tests/Visual/TestCaseChatLink.cs b/osu.Game.Tests/Visual/TestCaseChatLink.cs new file mode 100644 index 0000000000..3a7be686e1 --- /dev/null +++ b/osu.Game.Tests/Visual/TestCaseChatLink.cs @@ -0,0 +1,217 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using OpenTK.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Online.Chat; +using osu.Game.Overlays.Chat; +using osu.Game.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual +{ + public class TestCaseChatLink : OsuTestCase + { + private readonly TestChatLineContainer textContainer; + private Color4 linkColour; + + public override IReadOnlyList RequiredTypes => new[] + { + typeof(ChatLine), + typeof(Message), + typeof(LinkFlowContainer), + typeof(DummyEchoMessage), + typeof(LocalEchoMessage), + typeof(MessageFormatter) + }; + + private DependencyContainer dependencies; + protected override IReadOnlyDependencyContainer CreateLocalDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(parent); + + public TestCaseChatLink() + { + Add(textContainer = new TestChatLineContainer + { + Padding = new MarginPadding { Left = 20, Right = 20 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + linkColour = colours.Blue; + dependencies.Cache(new ChatOverlay + { + AvailableChannels = + { + new Channel { Name = "#english" }, + new Channel { Name = "#japanese" } + } + }); + + testLinksGeneral(); + testEcho(); + } + + private void clear() => AddStep("clear messages", textContainer.Clear); + + private void addMessageWithChecks(string text, int linkAmount = 0, bool isAction = false, bool isImportant = false, params LinkAction[] expectedActions) + { + int index = textContainer.Count + 1; + var newLine = new ChatLine(new DummyMessage(text, isAction, isImportant, index)); + textContainer.Add(newLine); + + AddAssert($"msg #{index} has {linkAmount} link(s)", () => newLine.Message.Links.Count == linkAmount); + AddAssert($"msg #{index} has the right action", hasExpectedActions); + AddAssert($"msg #{index} is " + (isAction ? "italic" : "not italic"), () => newLine.ContentFlow.Any() && isAction == isItalic()); + AddAssert($"msg #{index} shows {linkAmount} link(s)", isShowingLinks); + + bool hasExpectedActions() + { + var expectedActionsList = expectedActions.ToList(); + + if (expectedActionsList.Count != newLine.Message.Links.Count) + return false; + + for (int i = 0; i < newLine.Message.Links.Count; i++) + { + var action = newLine.Message.Links[i].Action; + if (action != expectedActions[i]) return false; + } + + return true; + } + + bool isItalic() => newLine.ContentFlow.Where(d => d is OsuSpriteText).Cast().All(sprite => sprite.Font == "Exo2.0-MediumItalic"); + + bool isShowingLinks() + { + bool hasBackground = !string.IsNullOrEmpty(newLine.Message.Sender.Colour); + + Color4 textColour = isAction && hasBackground ? OsuColour.FromHex(newLine.Message.Sender.Colour) : Color4.White; + + var linkCompilers = newLine.ContentFlow.Where(d => d is DrawableLinkCompiler).ToList(); + var linkSprites = linkCompilers.SelectMany(comp => ((DrawableLinkCompiler)comp).Parts); + + return linkSprites.All(d => d.Colour == linkColour) + && newLine.ContentFlow.Except(linkSprites.Concat(linkCompilers)).All(d => d.Colour == textColour); + } + } + + private void testLinksGeneral() + { + addMessageWithChecks("test!"); + addMessageWithChecks("osu.ppy.sh!"); + addMessageWithChecks("https://osu.ppy.sh!", 1, expectedActions: LinkAction.External); + addMessageWithChecks("00:12:345 (1,2) - Test?", 1, expectedActions: LinkAction.OpenEditorTimestamp); + addMessageWithChecks("Wiki link for tasty [[Performance Points]]", 1, expectedActions: LinkAction.External); + addMessageWithChecks("(osu forums)[https://osu.ppy.sh/forum] (old link format)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[https://osu.ppy.sh/home New site] (new link format)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[osu forums](https://osu.ppy.sh/forum) (new link format 2)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[https://osu.ppy.sh/home This is only a link to the new osu webpage but this is supposed to test word wrap.]", 1, expectedActions: LinkAction.External); + addMessageWithChecks("is now listening to [https://osu.ppy.sh/s/93523 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmapSet); + addMessageWithChecks("is now playing [https://osu.ppy.sh/b/252238 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmap); + addMessageWithChecks("Let's (try)[https://osu.ppy.sh/home] [https://osu.ppy.sh/b/252238 multiple links] https://osu.ppy.sh/home", 3, + expectedActions: new[] { LinkAction.External, LinkAction.OpenBeatmap, LinkAction.External }); + // note that there's 0 links here (they get removed if a channel is not found) + addMessageWithChecks("#lobby or #osu would be blue (and work) in the ChatDisplay test (when a proper ChatOverlay is present)."); + addMessageWithChecks("I am important!", 0, false, true); + addMessageWithChecks("feels important", 0, true, true); + addMessageWithChecks("likes to post this [https://osu.ppy.sh/home link].", 1, true, true, expectedActions: LinkAction.External); + addMessageWithChecks("Join my multiplayer game osump://12346.", 1, expectedActions: LinkAction.JoinMultiplayerMatch); + addMessageWithChecks("Join my [multiplayer game](osump://12346).", 1, expectedActions: LinkAction.JoinMultiplayerMatch); + addMessageWithChecks("Join my [#english](osu://chan/#english).", 1, expectedActions: LinkAction.OpenChannel); + addMessageWithChecks("Join my osu://chan/#english.", 1, expectedActions: LinkAction.OpenChannel); + addMessageWithChecks("Join my #english or #japanese channels.", 2, expectedActions: new[] { LinkAction.OpenChannel, LinkAction.OpenChannel }); + addMessageWithChecks("Join my #english or #nonexistent #hashtag channels.", 1, expectedActions: LinkAction.OpenChannel); + } + + private void testEcho() + { + int echoCounter = 0; + + addEchoWithWait("sent!", "received!"); + addEchoWithWait("https://osu.ppy.sh/home", null, 500); + addEchoWithWait("[https://osu.ppy.sh/forum let's try multiple words too!]"); + addEchoWithWait("(long loading times! clickable while loading?)[https://osu.ppy.sh/home]", null, 5000); + + void addEchoWithWait(string text, string completeText = null, double delay = 250) + { + var newLine = new ChatLine(new DummyEchoMessage(text)); + + AddStep($"send msg #{++echoCounter} after {delay}ms", () => + { + textContainer.Add(newLine); + Scheduler.AddDelayed(() => newLine.Message = new DummyMessage(completeText ?? text), delay); + }); + + AddUntilStep(() => textContainer.All(line => line.Message is DummyMessage), $"wait for msg #{echoCounter}"); + } + } + + private class DummyEchoMessage : LocalEchoMessage + { + public DummyEchoMessage(string text) + { + Content = text; + Timestamp = DateTimeOffset.Now; + Sender = DummyMessage.TEST_SENDER; + } + } + + private class DummyMessage : Message + { + private static long messageCounter; + + internal static readonly User TEST_SENDER_BACKGROUND = new User + { + Username = @"i-am-important", + Id = 42, + Colour = "#250cc9", + }; + + internal static readonly User TEST_SENDER = new User + { + Username = @"Somebody", + Id = 1, + }; + + public new DateTimeOffset Timestamp = DateTimeOffset.Now; + + public DummyMessage(string text, bool isAction = false, bool isImportant = false, int number = 0) + : base(messageCounter++) + { + Content = text; + IsAction = isAction; + Sender = new User + { + Username = $"User {number}", + Id = number, + Colour = isImportant ? "#250cc9" : null, + }; + } + } + + private class TestChatLineContainer : FillFlowContainer + { + protected override int Compare(Drawable x, Drawable y) + { + var xC = (ChatLine)x; + var yC = (ChatLine)y; + + return xC.Message.CompareTo(yC.Message); + } + } + } +} diff --git a/osu.Game.Tests/Visual/TestCaseEditorSelectionLayer.cs b/osu.Game.Tests/Visual/TestCaseEditorSelectionLayer.cs index f236182939..755800c4e1 100644 --- a/osu.Game.Tests/Visual/TestCaseEditorSelectionLayer.cs +++ b/osu.Game.Tests/Visual/TestCaseEditorSelectionLayer.cs @@ -8,6 +8,8 @@ using OpenTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit.Layers.Selection; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; @@ -35,9 +37,9 @@ namespace osu.Game.Tests.Visual new SelectionLayer(playfield) }; - playfield.Add(new DrawableHitCircle(new HitCircle { Position = new Vector2(256, 192), Scale = 0.5f })); - playfield.Add(new DrawableHitCircle(new HitCircle { Position = new Vector2(344, 148), Scale = 0.5f })); - playfield.Add(new DrawableSlider(new Slider + var hitCircle1 = new HitCircle { Position = new Vector2(256, 192), Scale = 0.5f }; + var hitCircle2 = new HitCircle { Position = new Vector2(344, 148), Scale = 0.5f }; + var slider = new Slider { ControlPoints = new List { @@ -48,8 +50,16 @@ namespace osu.Game.Tests.Visual Position = new Vector2(128, 256), Velocity = 1, TickDistance = 100, - Scale = 0.5f - })); + Scale = 0.5f, + }; + + hitCircle1.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + hitCircle2.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + playfield.Add(new DrawableHitCircle(hitCircle1)); + playfield.Add(new DrawableHitCircle(hitCircle2)); + playfield.Add(new DrawableSlider(slider)); } } } diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index d30241fae4..8301f1f734 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -94,6 +94,7 @@ + @@ -137,6 +138,7 @@ + diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs new file mode 100644 index 0000000000..9f1b44af44 --- /dev/null +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using osu.Game.Online.Chat; +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using System.Collections.Generic; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Graphics.Containers +{ + public class LinkFlowContainer : OsuTextFlowContainer + { + public LinkFlowContainer(Action defaultCreationParameters = null) + : base(defaultCreationParameters) + { + } + + public override bool HandleMouseInput => true; + + private OsuGame game; + + private Action showNotImplementedError; + + [BackgroundDependencyLoader(true)] + private void load(OsuGame game, NotificationOverlay notifications) + { + // will be null in tests + this.game = game; + + showNotImplementedError = () => notifications?.Post(new SimpleNotification + { + Text = @"This link type is not yet supported!", + Icon = FontAwesome.fa_life_saver, + }); + } + + public void AddLinks(string text, List links) + { + if (string.IsNullOrEmpty(text) || links == null) + return; + + if (links.Count == 0) + { + AddText(text); + return; + } + + int previousLinkEnd = 0; + foreach (var link in links) + { + AddText(text.Substring(previousLinkEnd, link.Index - previousLinkEnd)); + AddLink(text.Substring(link.Index, link.Length), link.Url, link.Action, link.Argument); + previousLinkEnd = link.Index + link.Length; + } + + AddText(text.Substring(previousLinkEnd)); + } + + public void AddLink(string text, string url, LinkAction linkType = LinkAction.External, string linkArgument = null, string tooltipText = null) + { + AddInternal(new DrawableLinkCompiler(AddText(text).ToList()) + { + TooltipText = tooltipText ?? (url != text ? url : string.Empty), + Action = () => + { + switch (linkType) + { + case LinkAction.OpenBeatmap: + // todo: replace this with overlay.ShowBeatmap(id) once an appropriate API call is implemented. + if (int.TryParse(linkArgument, out int beatmapId)) + Process.Start($"https://osu.ppy.sh/b/{beatmapId}"); + break; + case LinkAction.OpenBeatmapSet: + if (int.TryParse(linkArgument, out int setId)) + game?.ShowBeatmapSet(setId); + break; + case LinkAction.OpenChannel: + game?.OpenChannel(linkArgument); + break; + case LinkAction.OpenEditorTimestamp: + case LinkAction.JoinMultiplayerMatch: + case LinkAction.Spectate: + showNotImplementedError?.Invoke(); + break; + case LinkAction.External: + Process.Start(url); + break; + default: + throw new NotImplementedException($"This {nameof(LinkAction)} ({linkType.ToString()}) is missing an associated action."); + } + }, + }); + } + } +} diff --git a/osu.Game/Graphics/Containers/OsuClickableContainer.cs b/osu.Game/Graphics/Containers/OsuClickableContainer.cs index 4e95050bda..b9ee1f4463 100644 --- a/osu.Game/Graphics/Containers/OsuClickableContainer.cs +++ b/osu.Game/Graphics/Containers/OsuClickableContainer.cs @@ -16,6 +16,8 @@ namespace osu.Game.Graphics.Containers protected override Container Content => content; + protected virtual HoverClickSounds CreateHoverClickSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet); + public OsuClickableContainer(HoverSampleSet sampleSet = HoverSampleSet.Normal) { this.sampleSet = sampleSet; @@ -33,7 +35,7 @@ namespace osu.Game.Graphics.Containers InternalChildren = new Drawable[] { content, - new HoverClickSounds(sampleSet) + CreateHoverClickSounds(sampleSet) }; } } diff --git a/osu.Game/Graphics/Containers/OsuHoverContainer.cs b/osu.Game/Graphics/Containers/OsuHoverContainer.cs index 7c62e90f56..fd1742871b 100644 --- a/osu.Game/Graphics/Containers/OsuHoverContainer.cs +++ b/osu.Game/Graphics/Containers/OsuHoverContainer.cs @@ -1,8 +1,10 @@ // Copyright (c) 2007-2018 ppy Pty Ltd . // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE +using System.Collections.Generic; using OpenTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Input; @@ -10,24 +12,34 @@ namespace osu.Game.Graphics.Containers { public class OsuHoverContainer : OsuClickableContainer { - private Color4 hoverColour; + protected Color4 HoverColour; + + protected Color4 IdleColour = Color4.White; + + protected virtual IEnumerable EffectTargets => new[] { Content }; protected override bool OnHover(InputState state) { - this.FadeColour(hoverColour, 500, Easing.OutQuint); + EffectTargets.ForEach(d => d.FadeColour(HoverColour, 500, Easing.OutQuint)); return base.OnHover(state); } protected override void OnHoverLost(InputState state) { - this.FadeColour(Color4.White, 500, Easing.OutQuint); + EffectTargets.ForEach(d => d.FadeColour(IdleColour, 500, Easing.OutQuint)); base.OnHoverLost(state); } [BackgroundDependencyLoader] private void load(OsuColour colours) { - hoverColour = colours.Yellow; + HoverColour = colours.Yellow; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + EffectTargets.ForEach(d => d.FadeColour(IdleColour)); } } } diff --git a/osu.Game/Graphics/UserInterface/IconButton.cs b/osu.Game/Graphics/UserInterface/IconButton.cs index bcf6ab92b6..5b266d9a59 100644 --- a/osu.Game/Graphics/UserInterface/IconButton.cs +++ b/osu.Game/Graphics/UserInterface/IconButton.cs @@ -15,7 +15,7 @@ namespace osu.Game.Graphics.UserInterface { public class IconButton : OsuClickableContainer { - private const float button_size = 30; + public const float BUTTON_SIZE = 30; private Color4? flashColour; /// @@ -106,7 +106,7 @@ namespace osu.Game.Graphics.UserInterface { Origin = Anchor.Centre, Anchor = Anchor.Centre, - Size = new Vector2(button_size), + Size = new Vector2(BUTTON_SIZE), CornerRadius = 5, Masking = true, EdgeEffect = new EdgeEffectParameters diff --git a/osu.Game/Graphics/UserInterface/Volume/VolumeControl.cs b/osu.Game/Graphics/UserInterface/Volume/VolumeControl.cs index 33888e57e0..ccf70af6ed 100644 --- a/osu.Game/Graphics/UserInterface/Volume/VolumeControl.cs +++ b/osu.Game/Graphics/UserInterface/Volume/VolumeControl.cs @@ -7,6 +7,7 @@ using osu.Framework.Threading; using OpenTK; using osu.Framework.Audio; using osu.Framework.Allocation; +using osu.Framework.Configuration; using osu.Game.Input.Bindings; namespace osu.Game.Graphics.UserInterface.Volume @@ -14,6 +15,7 @@ namespace osu.Game.Graphics.UserInterface.Volume public class VolumeControl : OverlayContainer { private readonly VolumeMeter volumeMeterMaster; + private readonly IconButton muteIcon; protected override bool BlockPassThroughMouse => false; @@ -34,6 +36,17 @@ namespace osu.Game.Graphics.UserInterface.Volume Spacing = new Vector2(15, 0), Children = new Drawable[] { + new Container + { + Size = new Vector2(IconButton.BUTTON_SIZE), + Child = muteIcon = new IconButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.fa_volume_up, + Action = () => Adjust(GlobalAction.ToggleMute), + } + }, volumeMeterMaster = new VolumeMeter("Master"), volumeMeterEffect = new VolumeMeter("Effects"), volumeMeterMusic = new VolumeMeter("Music") @@ -46,18 +59,10 @@ namespace osu.Game.Graphics.UserInterface.Volume { base.LoadComplete(); - volumeMeterMaster.Bindable.ValueChanged += volumeChanged; - volumeMeterEffect.Bindable.ValueChanged += volumeChanged; - volumeMeterMusic.Bindable.ValueChanged += volumeChanged; - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - volumeMeterMaster.Bindable.ValueChanged -= volumeChanged; - volumeMeterEffect.Bindable.ValueChanged -= volumeChanged; - volumeMeterMusic.Bindable.ValueChanged -= volumeChanged; + volumeMeterMaster.Bindable.ValueChanged += _ => settingChanged(); + volumeMeterEffect.Bindable.ValueChanged += _ => settingChanged(); + volumeMeterMusic.Bindable.ValueChanged += _ => settingChanged(); + muted.ValueChanged += _ => settingChanged(); } public bool Adjust(GlobalAction action) @@ -76,23 +81,45 @@ namespace osu.Game.Graphics.UserInterface.Volume else volumeMeterMaster.Increase(); return true; + case GlobalAction.ToggleMute: + Show(); + muted.Toggle(); + return true; } return false; } - private void volumeChanged(double newVolume) + private void settingChanged() { Show(); schedulePopOut(); } + private readonly BindableDouble muteAdjustment = new BindableDouble(); + + private readonly BindableBool muted = new BindableBool(); + [BackgroundDependencyLoader] private void load(AudioManager audio) { volumeMeterMaster.Bindable.BindTo(audio.Volume); volumeMeterEffect.Bindable.BindTo(audio.VolumeSample); volumeMeterMusic.Bindable.BindTo(audio.VolumeTrack); + + muted.ValueChanged += mute => + { + if (mute) + { + audio.AddAdjustment(AdjustableProperty.Volume, muteAdjustment); + muteIcon.Icon = FontAwesome.fa_volume_off; + } + else + { + audio.RemoveAdjustment(AdjustableProperty.Volume, muteAdjustment); + muteIcon.Icon = FontAwesome.fa_volume_up; + } + }; } private ScheduledDelegate popOutDelegate; diff --git a/osu.Game/Graphics/UserInterface/Volume/VolumeMeter.cs b/osu.Game/Graphics/UserInterface/Volume/VolumeMeter.cs index 8323dade44..ef3702fdf3 100644 --- a/osu.Game/Graphics/UserInterface/Volume/VolumeMeter.cs +++ b/osu.Game/Graphics/UserInterface/Volume/VolumeMeter.cs @@ -70,11 +70,8 @@ namespace osu.Game.Graphics.UserInterface.Volume public double Volume { - get { return Bindable.Value; } - private set - { - Bindable.Value = value; - } + get => Bindable.Value; + private set => Bindable.Value = value; } public void Increase() diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 46cda845aa..17ec2af4b9 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -29,10 +29,11 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings), new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), - new KeyBinding(new[] { InputKey.Up }, GlobalAction.IncreaseVolume), - new KeyBinding(new[] { InputKey.MouseWheelUp }, GlobalAction.IncreaseVolume), - new KeyBinding(new[] { InputKey.Down }, GlobalAction.DecreaseVolume), - new KeyBinding(new[] { InputKey.MouseWheelDown }, GlobalAction.DecreaseVolume), + new KeyBinding(InputKey.Up, GlobalAction.IncreaseVolume), + new KeyBinding(InputKey.MouseWheelUp, GlobalAction.IncreaseVolume), + new KeyBinding(InputKey.Down, GlobalAction.DecreaseVolume), + new KeyBinding(InputKey.MouseWheelDown, GlobalAction.DecreaseVolume), + new KeyBinding(InputKey.F4, GlobalAction.ToggleMute), }; public IEnumerable InGameKeyBindings => new[] @@ -63,6 +64,8 @@ namespace osu.Game.Input.Bindings IncreaseVolume, [Description("Decrease Volume")] DecreaseVolume, + [Description("Toggle mute")] + ToggleMute, // In-Game Keybindings [Description("Skip Cutscene")] diff --git a/osu.Game/Migrations/20180131154205_AddMuteBinding.cs b/osu.Game/Migrations/20180131154205_AddMuteBinding.cs new file mode 100644 index 0000000000..fc1f2fff55 --- /dev/null +++ b/osu.Game/Migrations/20180131154205_AddMuteBinding.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Infrastructure; +using osu.Game.Database; +using osu.Game.Input.Bindings; + +namespace osu.Game.Migrations +{ + [DbContext(typeof(OsuDbContext))] + [Migration("20180131154205_AddMuteBinding")] + public partial class AddMuteBinding : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql($"UPDATE KeyBinding SET Action = Action + 1 WHERE RulesetID IS NULL AND Variant IS NULL AND Action >= {(int)GlobalAction.ToggleMute}"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql($"DELETE FROM KeyBinding WHERE RulesetID IS NULL AND Variant IS NULL AND Action = {(int)GlobalAction.ToggleMute}"); + migrationBuilder.Sql($"UPDATE KeyBinding SET Action = Action - 1 WHERE RulesetID IS NULL AND Variant IS NULL AND Action > {(int)GlobalAction.ToggleMute}"); + } + } +} diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs new file mode 100644 index 0000000000..234781fb52 --- /dev/null +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using osu.Framework.Graphics.Cursor; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using OpenTK; + +namespace osu.Game.Online.Chat +{ + /// + /// An invisible drawable that brings multiple pieces together to form a consumable clickable link. + /// + public class DrawableLinkCompiler : OsuHoverContainer, IHasTooltip + { + /// + /// Each word part of a chat link (split for word-wrap support). + /// + public List Parts; + + public override bool ReceiveMouseInputAt(Vector2 screenSpacePos) => Parts.Any(d => d.ReceiveMouseInputAt(screenSpacePos)); + + protected override HoverClickSounds CreateHoverClickSounds(HoverSampleSet sampleSet) => new LinkHoverSounds(sampleSet, Parts); + + public DrawableLinkCompiler(IEnumerable parts) + { + Parts = parts.ToList(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + IdleColour = colours.Blue; + } + + protected override IEnumerable EffectTargets => Parts; + + public string TooltipText { get; set; } + + private class LinkHoverSounds : HoverClickSounds + { + private readonly List parts; + + public LinkHoverSounds(HoverSampleSet sampleSet, List parts) + : base(sampleSet) + { + this.parts = parts; + } + + public override bool ReceiveMouseInputAt(Vector2 screenSpacePos) => parts.Any(d => d.ReceiveMouseInputAt(screenSpacePos)); + } + } +} diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index bc79469ce0..99735c4d65 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -2,6 +2,7 @@ // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System; +using System.Collections.Generic; using System.ComponentModel; using Newtonsoft.Json; using osu.Game.Users; @@ -40,6 +41,17 @@ namespace osu.Game.Online.Chat { } + /// + /// The text that is displayed in chat. + /// + public string DisplayContent { get; set; } + + /// + /// The links found in this message. + /// + /// The s' and s are according to + public List Links; + public Message(long? id) { Id = id; diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs new file mode 100644 index 0000000000..906f42d50e --- /dev/null +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -0,0 +1,263 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace osu.Game.Online.Chat +{ + public static class MessageFormatter + { + // [[Performance Points]] -> wiki:Performance Points (https://osu.ppy.sh/wiki/Performance_Points) + private static readonly Regex wiki_regex = new Regex(@"\[\[([^\]]+)\]\]"); + + // (test)[https://osu.ppy.sh/b/1234] -> test (https://osu.ppy.sh/b/1234) + private static readonly Regex old_link_regex = new Regex(@"\(([^\)]*)\)\[([a-z]+://[^ ]+)\]"); + + // [https://osu.ppy.sh/b/1234 Beatmap [Hard] (poop)] -> Beatmap [hard] (poop) (https://osu.ppy.sh/b/1234) + private static readonly Regex new_link_regex = new Regex(@"\[([a-z]+://[^ ]+) ([^\[\]]*(((?\[)[^\[\]]*)+((?\])[^\[\]]*)+)*(?(open)(?!)))\]"); + + // [test](https://osu.ppy.sh/b/1234) -> test (https://osu.ppy.sh/b/1234) aka correct markdown format + private static readonly Regex markdown_link_regex = new Regex(@"\[([^\]]*)\]\(([a-z]+://[^ ]+)\)"); + + // advanced, RFC-compatible regular expression that matches any possible URL, *but* allows certain invalid characters that are widely used + // This is in the format (, [optional]): + // http[s]://.[:port][/path][?query][#fragment] + private static readonly Regex advanced_link_regex = new Regex( + // protocol + @"(?[a-z]*?:\/\/" + + // domain + tld + @"(?(?:[a-z0-9]\.|[a-z0-9][a-z0-9-]*[a-z0-9]\.)*[a-z0-9-]*[a-z0-9]" + + // port (optional) + @"(?::\d+)?)" + + // path (optional) + @"(?(?:(?:\/+(?:[a-z0-9$_\.\+!\*\',;:\(\)@&~=-]|%[0-9a-f]{2})*)*" + + // query (optional) + @"(?:\?(?:[a-z0-9$_\+!\*\',;:\(\)@&=\/~-]|%[0-9a-f]{2})*)?)?" + + // fragment (optional) + @"(?:#(?:[a-z0-9$_\+!\*\',;:\(\)@&=\/~-]|%[0-9a-f]{2})*)?)?)", + RegexOptions.IgnoreCase); + + // 00:00:000 (1,2,3) - test + private static readonly Regex time_regex = new Regex(@"\d\d:\d\d:\d\d\d? [^-]*"); + + // #osu + private static readonly Regex channel_regex = new Regex(@"(#[a-zA-Z]+[a-zA-Z0-9]+)"); + + // Unicode emojis + private static readonly Regex emoji_regex = new Regex(@"(\uD83D[\uDC00-\uDE4F])"); + + private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0, LinkAction? linkActionOverride = null) + { + int captureOffset = 0; + foreach (Match m in regex.Matches(result.Text, startIndex)) + { + var index = m.Index - captureOffset; + + var displayText = string.Format(display, + m.Groups[0], + m.Groups.Count > 1 ? m.Groups[1].Value : "", + m.Groups.Count > 2 ? m.Groups[2].Value : "").Trim(); + + var linkText = string.Format(link, + m.Groups[0], + m.Groups.Count > 1 ? m.Groups[1].Value : "", + m.Groups.Count > 2 ? m.Groups[2].Value : "").Trim(); + + if (displayText.Length == 0 || linkText.Length == 0) continue; + + // Check for encapsulated links + if (result.Links.Find(l => l.Index <= index && l.Index + l.Length >= index + m.Length || index <= l.Index && index + m.Length >= l.Index + l.Length) == null) + { + result.Text = result.Text.Remove(index, m.Length).Insert(index, displayText); + + //since we just changed the line display text, offset any already processed links. + result.Links.ForEach(l => l.Index -= l.Index > index ? m.Length - displayText.Length : 0); + + var details = getLinkDetails(linkText); + result.Links.Add(new Link(linkText, index, displayText.Length, linkActionOverride ?? details.Action, details.Argument)); + + //adjust the offset for processing the current matches group. + captureOffset += m.Length - displayText.Length; + } + } + } + + private static void handleAdvanced(Regex regex, MessageFormatterResult result, int startIndex = 0) + { + foreach (Match m in regex.Matches(result.Text, startIndex)) + { + var index = m.Index; + var link = m.Groups["link"].Value; + var indexLength = link.Length; + + var details = getLinkDetails(link); + result.Links.Add(new Link(link, index, indexLength, details.Action, details.Argument)); + } + } + + private static LinkDetails getLinkDetails(string url) + { + var args = url.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + args[0] = args[0].TrimEnd(':'); + + switch (args[0]) + { + case "http": + case "https": + // length > 3 since all these links need another argument to work + if (args.Length > 3 && (args[1] == "osu.ppy.sh" || args[1] == "new.ppy.sh")) + { + switch (args[2]) + { + case "b": + case "beatmaps": + return new LinkDetails(LinkAction.OpenBeatmap, args[3]); + case "s": + case "beatmapsets": + case "d": + return new LinkDetails(LinkAction.OpenBeatmapSet, args[3]); + } + } + + return new LinkDetails(LinkAction.External, null); + case "osu": + // every internal link also needs some kind of argument + if (args.Length < 3) + return new LinkDetails(LinkAction.External, null); + + LinkAction linkType; + switch (args[1]) + { + case "chan": + linkType = LinkAction.OpenChannel; + break; + case "edit": + linkType = LinkAction.OpenEditorTimestamp; + break; + case "b": + linkType = LinkAction.OpenBeatmap; + break; + case "s": + case "dl": + linkType = LinkAction.OpenBeatmapSet; + break; + case "spectate": + linkType = LinkAction.Spectate; + break; + default: + linkType = LinkAction.External; + break; + } + + return new LinkDetails(linkType, args[2]); + case "osump": + return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]); + default: + return new LinkDetails(LinkAction.External, null); + } + } + + private static MessageFormatterResult format(string toFormat, int startIndex = 0, int space = 3) + { + var result = new MessageFormatterResult(toFormat); + + // handle the [link display] format + handleMatches(new_link_regex, "{2}", "{1}", result, startIndex); + + // handle the standard markdown []() format + handleMatches(markdown_link_regex, "{1}", "{2}", result, startIndex); + + // handle the ()[] link format + handleMatches(old_link_regex, "{1}", "{2}", result, startIndex); + + // handle wiki links + handleMatches(wiki_regex, "{1}", "https://osu.ppy.sh/wiki/{1}", result, startIndex); + + // handle bare links + handleAdvanced(advanced_link_regex, result, startIndex); + + // handle editor times + handleMatches(time_regex, "{0}", "osu://edit/{0}", result, startIndex, LinkAction.OpenEditorTimestamp); + + // handle channels + handleMatches(channel_regex, "{0}", "osu://chan/{0}", result, startIndex, LinkAction.OpenChannel); + + var empty = ""; + while (space-- > 0) + empty += "\0"; + + handleMatches(emoji_regex, empty, "{0}", result, startIndex); + + return result; + } + + public static Message FormatMessage(Message inputMessage) + { + var result = format(inputMessage.Content); + + inputMessage.DisplayContent = result.Text; + + // Sometimes, regex matches are not in order + result.Links.Sort(); + inputMessage.Links = result.Links; + return inputMessage; + } + + public class MessageFormatterResult + { + public List Links = new List(); + public string Text; + public string OriginalText; + + public MessageFormatterResult(string text) + { + OriginalText = Text = text; + } + } + + public class LinkDetails + { + public LinkAction Action; + public string Argument; + + public LinkDetails(LinkAction action, string argument) + { + Action = action; + Argument = argument; + } + } + } + + public enum LinkAction + { + External, + OpenBeatmap, + OpenBeatmapSet, + OpenChannel, + OpenEditorTimestamp, + JoinMultiplayerMatch, + Spectate, + } + + public class Link : IComparable + { + public string Url; + public int Index; + public int Length; + public LinkAction Action; + public string Argument; + + public Link(string url, int startIndex, int length, LinkAction action, string argument) + { + Url = url; + Index = startIndex; + Length = length; + Action = action; + Argument = argument; + } + + public int CompareTo(Link otherLink) => Index > otherLink.Index ? 1 : -1; + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index b667bc6876..624179cfe1 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -128,6 +128,18 @@ namespace osu.Game private ScheduledDelegate scoreLoad; + /// + /// Open chat to a channel matching the provided name, if present. + /// + /// The name of the channel. + public void OpenChannel(string channelName) => chat.OpenChannel(chat.AvailableChannels.Find(c => c.Name == channelName)); + + /// + /// Show a beatmap set as an overlay. + /// + /// The set to display. + public void ShowBeatmapSet(int setId) => beatmapSetOverlay.ShowBeatmapSet(setId); + protected void LoadScore(Score s) { scoreLoad?.Cancel(); diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 4895c3a37c..dd41dd5428 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -1,6 +1,7 @@ // Copyright (c) 2007-2018 ppy Pty Ltd . // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE +using System.Linq; using OpenTK; using OpenTK.Graphics; using osu.Framework.Allocation; @@ -82,16 +83,18 @@ namespace osu.Game.Overlays.Chat private Message message; private OsuSpriteText username; - private OsuTextFlowContainer contentFlow; + private LinkFlowContainer contentFlow; + + public LinkFlowContainer ContentFlow => contentFlow; public Message Message { - get { return message; } + get => message; set { if (message == value) return; - message = value; + message = MessageFormatter.FormatMessage(value); if (!IsLoaded) return; @@ -101,8 +104,9 @@ namespace osu.Game.Overlays.Chat } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours) + private void load(OsuColour colours, ChatOverlay chat) { + this.chat = chat; customUsernameColour = colours.ChatBlue; } @@ -187,7 +191,18 @@ namespace osu.Game.Overlays.Chat Padding = new MarginPadding { Left = message_padding + padding }, Children = new Drawable[] { - contentFlow = new OsuTextFlowContainer(t => { t.TextSize = text_size; }) + contentFlow = new LinkFlowContainer(t => + { + if (Message.IsAction) + { + t.Font = @"Exo2.0-MediumItalic"; + + if (senderHasBackground) + t.Colour = OsuColour.FromHex(message.Sender.Colour); + } + + t.TextSize = text_size; + }) { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -195,30 +210,26 @@ namespace osu.Game.Overlays.Chat } } }; - if (message.IsAction && senderHasBackground) - contentFlow.Colour = OsuColour.FromHex(message.Sender.Colour); updateMessageContent(); FinishTransforms(true); } + private ChatOverlay chat; + private void updateMessageContent() { this.FadeTo(message is LocalEchoMessage ? 0.4f : 1.0f, 500, Easing.OutQuint); timestamp.FadeTo(message is LocalEchoMessage ? 0 : 1, 500, Easing.OutQuint); timestamp.Text = $@"{message.Timestamp.LocalDateTime:HH:mm:ss}"; - username.Text = $@"{message.Sender.Username}" + (senderHasBackground ? "" : ":"); + username.Text = $@"{message.Sender.Username}" + (senderHasBackground || message.IsAction ? "" : ":"); - if (message.IsAction) - { - contentFlow.Clear(); - contentFlow.AddText("[", sprite => sprite.Padding = new MarginPadding { Right = action_padding }); - contentFlow.AddText(message.Content, sprite => sprite.Font = @"Exo2.0-MediumItalic"); - contentFlow.AddText("]", sprite => sprite.Padding = new MarginPadding { Left = action_padding }); - } - else - contentFlow.Text = message.Content; + // remove non-existent channels from the link list + message.Links.RemoveAll(link => link.Action == LinkAction.OpenChannel && chat?.AvailableChannels.Any(c => c.Name == link.Argument) != true); + + contentFlow.Clear(); + contentFlow.AddLinks(message.DisplayContent, message.Links); } private class MessageSender : OsuClickableContainer, IHasContextMenu diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 15c373356f..beb2b3b746 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -60,6 +60,7 @@ namespace osu.Game.Overlays public Bindable ChatHeight { get; set; } + public List AvailableChannels { get; private set; } = new List(); private readonly Container channelSelectionContainer; private readonly ChannelSelectionOverlay channelSelection; @@ -190,6 +191,8 @@ namespace osu.Game.Overlays private double startDragChatHeight; private bool isDragging; + public void OpenChannel(Channel channel) => addChannel(channel); + protected override bool OnDragStart(InputState state) { isDragging = tabsArea.IsHovered; @@ -298,6 +301,8 @@ namespace osu.Game.Overlays ListChannelsRequest req = new ListChannelsRequest(); req.Success += delegate (List channels) { + AvailableChannels = channels; + Scheduler.Add(delegate { addChannel(channels.Find(c => c.Name == @"#lazer")); diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index 924b5d6c9d..937b4efcad 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -2,6 +2,7 @@ // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System; +using System.Diagnostics; using OpenTK; using OpenTK.Graphics; using osu.Framework.Allocation; @@ -9,6 +10,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; @@ -16,9 +18,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Users; -using System.Diagnostics; -using System.Collections.Generic; -using osu.Framework.Graphics.Cursor; namespace osu.Game.Overlays.Profile { @@ -103,7 +102,7 @@ namespace osu.Game.Overlays.Profile Y = -75, Size = new Vector2(25, 25) }, - new LinkFlowContainer.ProfileLink(user) + new ProfileLink(user) { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, @@ -329,12 +328,14 @@ namespace osu.Game.Overlays.Profile { infoTextLeft.AddText($"{user.Age} years old ", boldItalic); } + if (user.Country != null) { infoTextLeft.AddText("from "); infoTextLeft.AddText(user.Country.FullName, boldItalic); countryFlag.Country = user.Country; } + infoTextLeft.NewParagraph(); if (user.JoinDate.ToUniversalTime().Year < 2008) @@ -346,6 +347,7 @@ namespace osu.Game.Overlays.Profile infoTextLeft.AddText("Joined "); infoTextLeft.AddText(user.JoinDate.LocalDateTime.ToShortDateString(), boldItalic); } + infoTextLeft.NewLine(); infoTextLeft.AddText("Last seen "); infoTextLeft.AddText(user.LastVisit.LocalDateTime.ToShortDateString(), boldItalic); @@ -434,6 +436,28 @@ namespace osu.Game.Overlays.Profile infoTextRight.NewLine(); } + private class ProfileLink : OsuHoverContainer, IHasTooltip + { + public string TooltipText => "View Profile in Browser"; + + public override bool HandleMouseInput => true; + + public ProfileLink(User user) + { + Action = () => Process.Start($@"https://osu.ppy.sh/users/{user.Id}"); + + AutoSizeAxes = Axes.Both; + + Child = new OsuSpriteText + { + Text = user.Username, + Font = @"Exo2.0-RegularItalic", + TextSize = 30, + }; + } + } + + private class GradeBadge : Container { private const float width = 50; @@ -471,61 +495,5 @@ namespace osu.Game.Overlays.Profile badge.Texture = textures.Get($"Grades/{grade}"); } } - - private class LinkFlowContainer : OsuTextFlowContainer - { - public override bool HandleKeyboardInput => true; - public override bool HandleMouseInput => true; - - public LinkFlowContainer(Action defaultCreationParameters = null) : base(defaultCreationParameters) - { - } - - protected override SpriteText CreateSpriteText() => new LinkText(); - - public void AddLink(string text, string url) => AddText(text, link => ((LinkText)link).Url = url); - - public class LinkText : OsuSpriteText - { - private readonly OsuHoverContainer content; - - public override bool HandleKeyboardInput => content.Action != null; - public override bool HandleMouseInput => content.Action != null; - - protected override Container Content => content ?? (Container)this; - - protected override IEnumerable FlowingChildren => Children; - - public string Url - { - set - { - if (value != null) - content.Action = () => Process.Start(value); - } - } - - public LinkText() - { - AddInternal(content = new OsuHoverContainer - { - AutoSizeAxes = Axes.Both, - }); - } - } - - public class ProfileLink : LinkText, IHasTooltip - { - public string TooltipText => "View Profile in Browser"; - - public ProfileLink(User user) - { - Text = user.Username; - Url = $@"https://osu.ppy.sh/users/{user.Id}"; - Font = @"Exo2.0-RegularItalic"; - TextSize = 30; - } - } - } } } diff --git a/osu.Game/Rulesets/UI/RulesetContainer.cs b/osu.Game/Rulesets/UI/RulesetContainer.cs index 8f72644b28..231250e858 100644 --- a/osu.Game/Rulesets/UI/RulesetContainer.cs +++ b/osu.Game/Rulesets/UI/RulesetContainer.cs @@ -240,13 +240,13 @@ namespace osu.Game.Rulesets.UI foreach (var mod in Mods.OfType()) mod.ApplyToDifficulty(Beatmap.BeatmapInfo.BaseDifficulty); + // Post-process the beatmap + processor.PostProcess(Beatmap); + // Apply defaults foreach (var h in Beatmap.HitObjects) h.ApplyDefaults(Beatmap.ControlPointInfo, Beatmap.BeatmapInfo.BaseDifficulty); - // Post-process the beatmap - processor.PostProcess(Beatmap); - KeyBindingInputManager = CreateInputManager(); KeyBindingInputManager.RelativeSizeAxes = Axes.Both; diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 6e06ca6903..037f9136a8 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -136,9 +136,20 @@ namespace osu.Game.Rulesets.UI int loops = 0; while (validState && requireMoreUpdateLoops && loops++ < max_catch_up_updates_per_frame) + { if (!base.UpdateSubTree()) return false; + if (isAttached) + { + // When handling replay input, we need to consider the possibility of fast-forwarding, which may cause the clock to be updated + // to a point very far into the future, then playing a frame at that time. In such a case, lifetime MUST be updated before + // input is handled. This is why base.Update is not called from the derived Update when handling replay input, and is instead + // called manually at the correct time here. + base.Update(); + } + } + return true; } @@ -173,8 +184,11 @@ namespace osu.Game.Rulesets.UI // to ensure that the its time is valid for our children before input is processed Clock.ProcessFrame(); - // Process input - base.Update(); + if (!isAttached) + { + // For non-replay input handling, this provides equivalent input ordering as if Update was not overridden + base.Update(); + } } #endregion diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 4e048d60b9..f937497fa0 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -275,11 +275,14 @@ + + 20180125143340_Settings.cs + @@ -305,6 +308,8 @@ + + @@ -470,7 +475,6 @@ -