diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index b9db4168f4..d217f04651 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; @@ -11,6 +12,7 @@ using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; @@ -49,6 +51,21 @@ protected override ComposeBlueprintContainer CreateBlueprintContainer() }; public override string ConvertSelectionToString() - => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}")); + => string.Join(ObjectSeparator, EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}")); + + public override bool HandleHitObjectSelection(HitObject hitObject, string objectInfo) + { + if (hitObject is not ManiaHitObject maniaHitObject) + return false; + + double[] split = objectInfo.Split('|').Select(double.Parse).ToArray(); + if (split.Length != 2) + return false; + + double timeValue = split[0]; + double columnValue = split[1]; + return Math.Abs(maniaHitObject.StartTime - timeValue) < 0.5 + && Math.Abs(maniaHitObject.Column - columnValue) < 0.5; + } } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 0f8c960b65..0c63cf71d8 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -104,7 +104,18 @@ protected override ComposeBlueprintContainer CreateBlueprintContainer() => new OsuBlueprintContainer(this); public override string ConvertSelectionToString() - => string.Join(',', selectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString())); + => string.Join(ObjectSeparator, selectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString())); + + public override bool HandleHitObjectSelection(HitObject hitObject, string objectInfo) + { + if (hitObject is not OsuHitObject osuHitObject) + return false; + + if (!int.TryParse(objectInfo, out int comboValue) || comboValue < 1) + return false; + + return osuHitObject.IndexInCurrentCombo + 1 == comboValue; + } private DistanceSnapGrid distanceSnapGrid; private Container distanceSnapGridContainer; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs index 77dcbf069b..f7b976702a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs @@ -29,11 +29,14 @@ public partial class TestSceneOpenEditorTimestamp : OsuGameTestScene protected EditorBeatmap EditorBeatmap => Editor.ChildrenOfType().Single(); protected EditorClock EditorClock => Editor.ChildrenOfType().Single(); - protected void AddStepClickLink(string timestamp, string step = "") + protected void AddStepClickLink(string timestamp, string step = "", bool waitForSeek = true) { AddStep($"{step} {timestamp}", () => Game.HandleLink(new LinkDetails(LinkAction.OpenEditorTimestamp, timestamp)) ); + + if (waitForSeek) + AddUntilStep("wait for seek", () => EditorClock.SeekingOrStopped.Value); } protected void AddStepScreenModeTo(EditorScreenMode screenMode) @@ -76,7 +79,7 @@ private bool hasCombosInOrder(IEnumerable selected, params int[] comb .Any(); } - private bool checkSnapAndSelectColumn(double startTime, IReadOnlyCollection<(int, int)> columnPairs = null) + private bool checkSnapAndSelectColumn(double startTime, IReadOnlyCollection<(int, int)>? columnPairs = null) { bool checkColumns = columnPairs != null ? EditorBeatmap.SelectedHitObjects.All(x => columnPairs.Any(col => isNoteAt(x, col.Item1, col.Item2))) @@ -123,7 +126,7 @@ public void TestErrorNotifications() { RulesetInfo rulesetInfo = new OsuRuleset().RulesetInfo; - AddStepClickLink("00:00:000"); + AddStepClickLink("00:00:000", waitForSeek: false); AddAssert("recieved 'must be in edit'", () => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.MustBeInEdit) == 1 ); @@ -131,7 +134,7 @@ public void TestErrorNotifications() AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo.Invoke()); AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); - AddStepClickLink("00:00:000 (1)"); + AddStepClickLink("00:00:000 (1)", waitForSeek: false); AddAssert("recieved 'must be in edit'", () => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.MustBeInEdit) == 2 ); @@ -139,27 +142,12 @@ public void TestErrorNotifications() SetUpEditor(rulesetInfo); AddAssert("is editor Osu", () => EditorBeatmap.BeatmapInfo.Ruleset.Equals(rulesetInfo)); - AddStepClickLink("00:000", "invalid link"); + AddStepClickLink("00:000", "invalid link", waitForSeek: false); AddAssert("recieved 'failed to process'", () => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.FailedToProcessTimestamp) == 1 ); - AddStepClickLink("00:00:00:000", "invalid link"); - AddAssert("recieved 'failed to process'", () => - Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.FailedToProcessTimestamp) == 2 - ); - - AddStepClickLink("00:00:000 ()", "invalid link"); - AddAssert("recieved 'failed to process'", () => - Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.FailedToProcessTimestamp) == 3 - ); - - AddStepClickLink("00:00:000 (-1)", "invalid link"); - AddAssert("recieved 'failed to process'", () => - Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.FailedToProcessTimestamp) == 4 - ); - - AddStepClickLink("50000:00:000", "too long link"); + AddStepClickLink("50000:00:000", "too long link", waitForSeek: false); AddAssert("recieved 'too long'", () => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.TooLongTimestamp) == 1 ); diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 667175117f..9a194dba47 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.RegularExpressions; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Edit; namespace osu.Game.Online.Chat { @@ -41,10 +42,6 @@ public static class MessageFormatter @"(?:#(?:[a-z0-9$_\+!\*\',;:\(\)@&=\/~-]|%[0-9a-f]{2})*)?)?)", RegexOptions.IgnoreCase); - // 00:00:000 (1,2,3) - test - // regex from https://github.com/ppy/osu-web/blob/651a9bac2b60d031edd7e33b8073a469bf11edaa/resources/assets/coffee/_classes/beatmap-discussion-helper.coffee#L10 - private static readonly Regex time_regex = new Regex(@"\b(((\d{2,}):([0-5]\d)[:.](\d{3}))(\s\((?:\d+[,|])*\d+\))?)"); - // #osu private static readonly Regex channel_regex = new Regex(@"(#[a-zA-Z]+[a-zA-Z0-9]+)"); @@ -274,7 +271,7 @@ private static MessageFormatterResult format(string toFormat, int startIndex = 0 handleAdvanced(advanced_link_regex, result, startIndex); // handle editor times - handleMatches(time_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}edit/{{0}}", result, startIndex, LinkAction.OpenEditorTimestamp); + handleMatches(EditorTimestampParser.TIME_REGEX, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}edit/{{0}}", result, startIndex, LinkAction.OpenEditorTimestamp); // handle channels handleMatches(channel_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}chan/{{0}}", result, startIndex, LinkAction.OpenChannel); diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index be1776a330..cde8ee1457 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -563,10 +563,10 @@ public void SeekToTimestamp(string timestamp) if (ScreenStack.CurrentScreen is not Editor editor) { Schedule(() => Notifications.Post(new SimpleNotification - { - Icon = FontAwesome.Solid.ExclamationTriangle, + { + Icon = FontAwesome.Solid.ExclamationTriangle, Text = EditorStrings.MustBeInEdit - })); + })); return; } diff --git a/osu.Game/Screens/Edit/EditorTimestampParser.cs b/osu.Game/Rulesets/Edit/EditorTimestampParser.cs similarity index 50% rename from osu.Game/Screens/Edit/EditorTimestampParser.cs rename to osu.Game/Rulesets/Edit/EditorTimestampParser.cs index 2d8f8a8f4c..4e5a696102 100644 --- a/osu.Game/Screens/Edit/EditorTimestampParser.cs +++ b/osu.Game/Rulesets/Edit/EditorTimestampParser.cs @@ -7,41 +7,43 @@ using System.Linq; using System.Text.RegularExpressions; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; -namespace osu.Game.Screens.Edit +namespace osu.Game.Rulesets.Edit { public static class EditorTimestampParser { - private static readonly Regex timestamp_regex = new Regex(@"^(\d+:\d+:\d+)(?: \((\d+(?:[|,]\d+)*)\))?$", RegexOptions.Compiled); + // 00:00:000 (1,2,3) - test + // regex from https://github.com/ppy/osu-web/blob/651a9bac2b60d031edd7e33b8073a469bf11edaa/resources/assets/coffee/_classes/beatmap-discussion-helper.coffee#L10 + public static readonly Regex TIME_REGEX = new Regex(@"\b(((\d{2,}):([0-5]\d)[:.](\d{3}))(\s\((?:\d+[,|])*\d+\))?)"); public static string[] GetRegexGroups(string timestamp) { - Match match = timestamp_regex.Match(timestamp); - return match.Success - ? match.Groups.Values.Where(x => x is not Match).Select(x => x.Value).ToArray() + Match match = TIME_REGEX.Match(timestamp); + string[] result = match.Success + ? match.Groups.Values.Where(x => x is not Match && !x.Value.Contains(':')).Select(x => x.Value).ToArray() : Array.Empty(); + return result; } - public static double GetTotalMilliseconds(string timeGroup) + public static double GetTotalMilliseconds(params string[] timesGroup) { - int[] times = timeGroup.Split(':').Select(int.Parse).ToArray(); + int[] times = timesGroup.Select(int.Parse).ToArray(); Debug.Assert(times.Length == 3); return (times[0] * 60 + times[1]) * 1_000 + times[2]; } - public static List GetSelectedHitObjects(IReadOnlyList editorHitObjects, string objectsGroup, double position) + public static List GetSelectedHitObjects(HitObjectComposer composer, IReadOnlyList editorHitObjects, string objectsGroup, double position) { List hitObjects = editorHitObjects.Where(x => x.StartTime >= position).ToList(); List selectedObjects = new List(); - string[] objectsToSelect = objectsGroup.Split(',').ToArray(); + string[] objectsToSelect = objectsGroup.Split(composer.ObjectSeparator).ToArray(); foreach (string objectInfo in objectsToSelect) { - HitObject? current = hitObjects.FirstOrDefault(x => shouldHitObjectBeSelected(x, objectInfo)); + HitObject? current = hitObjects.FirstOrDefault(x => composer.HandleHitObjectSelection(x, objectInfo)); if (current == null) continue; @@ -67,35 +69,5 @@ public static List GetSelectedHitObjects(IReadOnlyList edi return selectedObjects; } - - private static bool shouldHitObjectBeSelected(HitObject hitObject, string objectInfo) - { - switch (hitObject) - { - // (combo) - case IHasComboInformation comboInfo: - { - if (!double.TryParse(objectInfo, out double comboValue) || comboValue < 1) - return false; - - return comboInfo.IndexInCurrentCombo + 1 == comboValue; - } - - // (time|column) - case IHasColumn column: - { - double[] split = objectInfo.Split('|').Select(double.Parse).ToArray(); - if (split.Length != 2) - return false; - - double timeValue = split[0]; - double columnValue = split[1]; - return hitObject.StartTime == timeValue && column.Column == columnValue; - } - - default: - return false; - } - } } } diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 07e5869e28..f6cddcc0d2 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -528,6 +528,19 @@ protected HitObjectComposer(Ruleset ruleset) public virtual string ConvertSelectionToString() => string.Empty; + /// + /// The custom logic that decides whether a HitObject should be selected when clicking an editor timestamp link + /// + /// The hitObject being checked + /// A single hitObject's information created with + /// Whether a HitObject should be selected or not + public virtual bool HandleHitObjectSelection(HitObject hitObject, string objectInfo) => false; + + /// + /// A character that separates the selection in + /// + public virtual char ObjectSeparator => ','; + #region IPositionSnapProvider public abstract SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All); diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 9e0671e91d..592e6625cc 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable @@ -14,6 +14,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Input.Bindings; @@ -1142,7 +1143,7 @@ public void HandleTimestamp(string timestamp) { string[] groups = EditorTimestampParser.GetRegexGroups(timestamp); - if (groups.Length != 2 || string.IsNullOrEmpty(groups[0])) + if (groups.Length != 4 || string.IsNullOrEmpty(groups[0])) { Schedule(() => notifications.Post(new SimpleNotification { @@ -1152,13 +1153,14 @@ public void HandleTimestamp(string timestamp) return; } - string timeGroup = groups[0]; - string objectsGroup = groups[1]; - string timeMinutes = timeGroup.Split(':').FirstOrDefault() ?? string.Empty; + string timeMin = groups[0]; + string timeSec = groups[1]; + string timeMss = groups[2]; + string objectsGroup = groups[3].Replace("(", "").Replace(")", "").Trim(); // Currently, lazer chat highlights infinite-long editor links like `10000000000:00:000 (1)` // Limit timestamp link length at 30000 min (50 hr) to avoid parsing issues - if (timeMinutes.Length > 5 || double.Parse(timeMinutes) > 30_000) + if (string.IsNullOrEmpty(timeMin) || timeMin.Length > 5 || double.Parse(timeMin) > 30_000) { Schedule(() => notifications.Post(new SimpleNotification { @@ -1168,38 +1170,36 @@ public void HandleTimestamp(string timestamp) return; } - double position = EditorTimestampParser.GetTotalMilliseconds(timeGroup); - editorBeatmap.SelectedHitObjects.Clear(); - // Only seeking is necessary + double position = EditorTimestampParser.GetTotalMilliseconds(timeMin, timeSec, timeMss); + if (string.IsNullOrEmpty(objectsGroup)) { - if (clock.IsRunning) - clock.Stop(); - - clock.Seek(position); + clock.SeekSmoothlyTo(position); return; } + // Seek to the next closest HitObject instead + HitObject nextObject = editorBeatmap.HitObjects.FirstOrDefault(x => x.StartTime >= position); + + if (nextObject != null) + position = nextObject.StartTime; + + clock.SeekSmoothlyTo(position); + if (Mode.Value != EditorScreenMode.Compose) Mode.Value = EditorScreenMode.Compose; - // Seek to the next closest HitObject - HitObject nextObject = editorBeatmap.HitObjects.FirstOrDefault(x => x.StartTime >= position); - - if (nextObject != null && nextObject.StartTime > 0) - position = nextObject.StartTime; - - List selected = EditorTimestampParser.GetSelectedHitObjects(editorBeatmap.HitObjects.ToList(), objectsGroup, position); + List selected = EditorTimestampParser.GetSelectedHitObjects( + currentScreen.Dependencies.Get(), + editorBeatmap.HitObjects.ToList(), + objectsGroup, + position + ); if (selected.Any()) editorBeatmap.SelectedHitObjects.AddRange(selected); - - if (clock.IsRunning) - clock.Stop(); - - clock.Seek(position); } public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime);