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

Merge pull request #25371 from RatinFX/open-editor-timestamp

Implement opening editor timestamp links
This commit is contained in:
Bartłomiej Dach 2023-11-21 15:48:36 +09:00 committed by GitHub
commit d83b2e24e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 558 additions and 5 deletions

View File

@ -0,0 +1,94 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Editor
{
public partial class TestSceneOpenEditorTimestampInMania : EditorTestScene
{
protected override Ruleset CreateEditorRuleset() => new ManiaRuleset();
[Test]
public void TestNormalSelection()
{
addStepClickLink("00:05:920 (5920|3,6623|3,6857|2,7326|1)");
AddAssert("selected group", () => checkSnapAndSelectColumn(5_920, new List<(int, int)>
{ (5_920, 3), (6_623, 3), (6_857, 2), (7_326, 1) }
));
addReset();
addStepClickLink("00:42:716 (42716|3,43420|2,44123|0,44357|1,45295|1)");
AddAssert("selected ungrouped", () => checkSnapAndSelectColumn(42_716, new List<(int, int)>
{ (42_716, 3), (43_420, 2), (44_123, 0), (44_357, 1), (45_295, 1) }
));
addReset();
AddStep("add notes to row", () =>
{
if (EditorBeatmap.HitObjects.Any(x => x is ManiaHitObject m && m.StartTime == 11_545 && m.Column is 1 or 2 or 3))
return;
ManiaHitObject first = (ManiaHitObject)EditorBeatmap.HitObjects.First(x => x is ManiaHitObject m && m.StartTime == 11_545 && m.Column == 0);
ManiaHitObject second = new Note { Column = 1, StartTime = first.StartTime };
ManiaHitObject third = new Note { Column = 2, StartTime = first.StartTime };
ManiaHitObject forth = new Note { Column = 3, StartTime = first.StartTime };
EditorBeatmap.AddRange(new[] { second, third, forth });
});
addStepClickLink("00:11:545 (11545|0,11545|1,11545|2,11545|3)");
AddAssert("selected in row", () => checkSnapAndSelectColumn(11_545, new List<(int, int)>
{ (11_545, 0), (11_545, 1), (11_545, 2), (11_545, 3) }
));
addReset();
addStepClickLink("01:36:623 (96623|1,97560|1,97677|1,97795|1,98966|1)");
AddAssert("selected in column", () => checkSnapAndSelectColumn(96_623, new List<(int, int)>
{ (96_623, 1), (97_560, 1), (97_677, 1), (97_795, 1), (98_966, 1) }
));
}
[Test]
public void TestUnusualSelection()
{
addStepClickLink("00:00:000 (0|1)", "wrong offset");
AddAssert("snap to 1, select none", () => checkSnapAndSelectColumn(2_170));
addReset();
addStepClickLink("00:00:000 (2)", "std link");
AddAssert("snap to 1, select none", () => checkSnapAndSelectColumn(2_170));
addReset();
addStepClickLink("00:00:000 (1,2)", "std link");
AddAssert("snap to 1, select none", () => checkSnapAndSelectColumn(2_170));
}
private void addStepClickLink(string timestamp, string step = "", bool displayTimestamp = true)
{
AddStep(displayTimestamp ? $"{step} {timestamp}" : step, () => Editor.HandleTimestamp(timestamp));
AddUntilStep("wait for seek", () => EditorClock.SeekingOrStopped.Value);
}
private void addReset() => addStepClickLink("00:00:000", "reset", false);
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)))
: !EditorBeatmap.SelectedHitObjects.Any();
return EditorClock.CurrentTime == startTime
&& EditorBeatmap.SelectedHitObjects.Count == (columnPairs?.Count ?? 0)
&& checkColumns;
}
private bool isNoteAt(HitObject hitObject, double time, int column) =>
hitObject is ManiaHitObject maniaHitObject
&& maniaHitObject.StartTime == time
&& maniaHitObject.Column == column;
}
}

View File

@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
@ -50,5 +51,37 @@ namespace osu.Game.Rulesets.Mania.Edit
public override string ConvertSelectionToString()
=> string.Join(',', EditorBeatmap.SelectedHitObjects.Cast<ManiaHitObject>().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}"));
// 123|0,456|1,789|2 ...
private static readonly Regex selection_regex = new Regex(@"^\d+\|\d+(,\d+\|\d+)*$", RegexOptions.Compiled);
public override void SelectFromTimestamp(double timestamp, string objectDescription)
{
if (!selection_regex.IsMatch(objectDescription))
return;
List<ManiaHitObject> remainingHitObjects = EditorBeatmap.HitObjects.Cast<ManiaHitObject>().Where(h => h.StartTime >= timestamp).ToList();
string[] objectDescriptions = objectDescription.Split(',').ToArray();
for (int i = 0; i < objectDescriptions.Length; i++)
{
string[] split = objectDescriptions[i].Split('|').ToArray();
if (split.Length != 2)
continue;
if (!double.TryParse(split[0], out double time) || !int.TryParse(split[1], out int column))
continue;
ManiaHitObject current = remainingHitObjects.FirstOrDefault(h => h.StartTime == time && h.Column == column);
if (current == null)
continue;
EditorBeatmap.SelectedHitObjects.Add(current);
if (i < objectDescriptions.Length - 1)
remainingHitObjects = remainingHitObjects.Where(h => h != current && h.StartTime >= current.StartTime).ToList();
}
}
}
}

View File

@ -0,0 +1,91 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneOpenEditorTimestampInOsu : EditorTestScene
{
protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
[Test]
public void TestNormalSelection()
{
addStepClickLink("00:02:170 (1,2,3)");
checkSelection(() => 2_170, 1, 2, 3);
addReset();
addStepClickLink("00:04:748 (2,3,4,1,2)");
checkSelection(() => 4_748, 2, 3, 4, 1, 2);
addReset();
addStepClickLink("00:02:170 (1,1,1)");
checkSelection(() => 2_170, 1, 1, 1);
addReset();
addStepClickLink("00:02:873 (2,2,2,2)");
checkSelection(() => 2_873, 2, 2, 2, 2);
}
[Test]
public void TestUnusualSelection()
{
HitObject firstObject = null!;
AddStep("retrieve first object", () => firstObject = EditorBeatmap.HitObjects.First());
addStepClickLink("00:00:000 (0)", "invalid combo");
checkSelection(() => firstObject.StartTime);
addReset();
addStepClickLink("00:00:000 (1)", "wrong offset");
checkSelection(() => firstObject.StartTime, 1);
addReset();
addStepClickLink("00:00:956 (2,3,4)", "wrong offset");
checkSelection(() => firstObject.StartTime, 2, 3, 4);
addReset();
addStepClickLink("00:00:956 (956|1,956|2)", "mania link");
checkSelection(() => firstObject.StartTime);
}
private void addReset() => addStepClickLink("00:00:000", "reset", false);
private void addStepClickLink(string timestamp, string step = "", bool displayTimestamp = true)
{
AddStep(displayTimestamp ? $"{step} {timestamp}" : step, () => Editor.HandleTimestamp(timestamp));
AddUntilStep("wait for seek", () => EditorClock.SeekingOrStopped.Value);
}
private void checkSelection(Func<double> startTime, params int[] comboNumbers)
=> AddUntilStep($"seeked & selected {(comboNumbers.Any() ? string.Join(",", comboNumbers) : "nothing")}", () =>
{
bool checkCombos = comboNumbers.Any()
? hasCombosInOrder(EditorBeatmap.SelectedHitObjects, comboNumbers)
: !EditorBeatmap.SelectedHitObjects.Any();
return EditorClock.CurrentTime == startTime()
&& EditorBeatmap.SelectedHitObjects.Count == comboNumbers.Length
&& checkCombos;
});
private bool hasCombosInOrder(IEnumerable<HitObject> selected, params int[] comboNumbers)
{
List<HitObject> hitObjects = selected.ToList();
if (hitObjects.Count != comboNumbers.Length)
return false;
return !hitObjects.Select(x => (OsuHitObject)x)
.Where((x, i) => x.IndexInCurrentCombo + 1 != comboNumbers[i])
.Any();
}
}
}

View File

@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
@ -106,6 +107,34 @@ namespace osu.Game.Rulesets.Osu.Edit
public override string ConvertSelectionToString()
=> string.Join(',', selectedHitObjects.Cast<OsuHitObject>().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString()));
// 1,2,3,4 ...
private static readonly Regex selection_regex = new Regex(@"^\d+(,\d+)*$", RegexOptions.Compiled);
public override void SelectFromTimestamp(double timestamp, string objectDescription)
{
if (!selection_regex.IsMatch(objectDescription))
return;
List<OsuHitObject> remainingHitObjects = EditorBeatmap.HitObjects.Cast<OsuHitObject>().Where(h => h.StartTime >= timestamp).ToList();
string[] splitDescription = objectDescription.Split(',').ToArray();
for (int i = 0; i < splitDescription.Length; i++)
{
if (!int.TryParse(splitDescription[i], out int combo) || combo < 1)
continue;
OsuHitObject current = remainingHitObjects.FirstOrDefault(h => h.IndexInCurrentCombo + 1 == combo);
if (current == null)
continue;
EditorBeatmap.SelectedHitObjects.Add(current);
if (i < splitDescription.Length - 1)
remainingHitObjects = remainingHitObjects.Where(h => h != current && h.StartTime >= current.StartTime).ToList();
}
}
private DistanceSnapGrid distanceSnapGrid;
private Container distanceSnapGridContainer;

View File

@ -0,0 +1,151 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Localisation;
using osu.Game.Online.Chat;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Select;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.Editing
{
public partial class TestSceneOpenEditorTimestamp : OsuGameTestScene
{
private Editor editor => (Editor)Game.ScreenStack.CurrentScreen;
private EditorBeatmap editorBeatmap => editor.ChildrenOfType<EditorBeatmap>().Single();
private EditorClock editorClock => editor.ChildrenOfType<EditorClock>().Single();
[Test]
public void TestErrorNotifications()
{
RulesetInfo rulesetInfo = new OsuRuleset().RulesetInfo;
addStepClickLink("00:00:000", waitForSeek: false);
AddAssert("received 'must be in edit'",
() => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.MustBeInEditorToHandleLinks),
() => Is.EqualTo(1));
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo.Invoke());
AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect);
addStepClickLink("00:00:000 (1)", waitForSeek: false);
AddAssert("received 'must be in edit'",
() => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.MustBeInEditorToHandleLinks),
() => Is.EqualTo(2));
setUpEditor(rulesetInfo);
AddAssert("ruleset is osu!", () => editorBeatmap.BeatmapInfo.Ruleset.Equals(rulesetInfo));
addStepClickLink("00:000", "invalid link", waitForSeek: false);
AddAssert("received 'failed to process'",
() => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.FailedToParseEditorLink),
() => Is.EqualTo(1));
addStepClickLink("50000:00:000", "too long link", waitForSeek: false);
AddAssert("received 'failed to process'",
() => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.FailedToParseEditorLink),
() => Is.EqualTo(2));
}
[Test]
public void TestHandleCurrentScreenChanges()
{
RulesetInfo rulesetInfo = new OsuRuleset().RulesetInfo;
setUpEditor(rulesetInfo);
AddAssert("is osu! ruleset", () => editorBeatmap.BeatmapInfo.Ruleset.Equals(rulesetInfo));
addStepClickLink("100:00:000", "long link");
AddUntilStep("moved to end of track", () => editorClock.CurrentTime, () => Is.EqualTo(editorClock.TrackLength));
addStepScreenModeTo(EditorScreenMode.SongSetup);
addStepClickLink("00:00:000");
assertOnScreenAt(EditorScreenMode.SongSetup, 0);
addStepClickLink("00:05:000 (0|0)");
assertMovedScreenTo(EditorScreenMode.Compose);
addStepScreenModeTo(EditorScreenMode.Design);
addStepClickLink("00:10:000");
assertOnScreenAt(EditorScreenMode.Design, 10_000);
addStepClickLink("00:15:000 (1)");
assertMovedScreenTo(EditorScreenMode.Compose);
addStepScreenModeTo(EditorScreenMode.Timing);
addStepClickLink("00:20:000");
assertOnScreenAt(EditorScreenMode.Timing, 20_000);
addStepClickLink("00:25:000 (0,1)");
assertMovedScreenTo(EditorScreenMode.Compose);
addStepScreenModeTo(EditorScreenMode.Verify);
addStepClickLink("00:30:000");
assertOnScreenAt(EditorScreenMode.Verify, 30_000);
addStepClickLink("00:35:000 (0,1)");
assertMovedScreenTo(EditorScreenMode.Compose);
addStepClickLink("00:00:000");
assertOnScreenAt(EditorScreenMode.Compose, 0);
}
private 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);
}
private void addStepScreenModeTo(EditorScreenMode screenMode) =>
AddStep("change screen to " + screenMode, () => editor.Mode.Value = screenMode);
private void assertOnScreenAt(EditorScreenMode screen, double time)
{
AddAssert($"stayed on {screen} at {time}", () =>
editor.Mode.Value == screen
&& editorClock.CurrentTime == time
);
}
private void assertMovedScreenTo(EditorScreenMode screen, string text = "moved to") =>
AddAssert($"{text} {screen}", () => editor.Mode.Value == screen);
private void setUpEditor(RulesetInfo ruleset)
{
BeatmapSetInfo beatmapSet = null!;
AddStep("Import test beatmap", () =>
Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()
);
AddStep("Retrieve beatmap", () =>
beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()
);
AddStep("Present beatmap", () => Game.PresentBeatmap(beatmapSet));
AddUntilStep("Wait for song select", () =>
Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet)
&& Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect
&& songSelect.IsLoaded
);
AddStep("Switch ruleset", () => Game.Ruleset.Value = ruleset);
AddStep("Open editor for ruleset", () =>
((PlaySongSelect)Game.ScreenStack.CurrentScreen)
.Edit(beatmapSet.Beatmaps.Last(beatmap => beatmap.Ruleset.Name == ruleset.Name))
);
AddUntilStep("Wait for editor open", () => editor.ReadyForUse);
}
}
}

View File

@ -119,6 +119,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString LimitedDistanceSnap => new TranslatableString(getKey(@"limited_distance_snap_grid"), @"Limit distance snap placement to current time");
/// <summary>
/// "Must be in edit mode to handle editor links"
/// </summary>
public static LocalisableString MustBeInEditorToHandleLinks => new TranslatableString(getKey(@"must_be_in_editor_to_handle_links"), @"Must be in edit mode to handle editor links");
/// <summary>
/// "Failed to parse editor link"
/// </summary>
public static LocalisableString FailedToParseEditorLink => new TranslatableString(getKey(@"failed_to_parse_edtior_link"), @"Failed to parse editor link");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
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 @@ namespace osu.Game.Online.Chat
@"(?:#(?:[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 @@ namespace osu.Game.Online.Chat
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);

View File

@ -58,6 +58,7 @@ using osu.Game.Performance;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Screens;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Menu;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.Play;
@ -433,6 +434,9 @@ namespace osu.Game
break;
case LinkAction.OpenEditorTimestamp:
HandleTimestamp(argString);
break;
case LinkAction.JoinMultiplayerMatch:
case LinkAction.Spectate:
waitForReady(() => Notifications, _ => Notifications.Post(new SimpleNotification
@ -550,6 +554,25 @@ namespace osu.Game
/// <param name="version">The build version of the update stream</param>
public void ShowChangelogBuild(string updateStream, string version) => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowBuild(updateStream, version));
/// <summary>
/// Seeks to the provided <paramref name="timestamp"/> if the editor is currently open.
/// Can also select objects as indicated by the <paramref name="timestamp"/> (depends on ruleset implementation).
/// </summary>
public void HandleTimestamp(string timestamp)
{
if (ScreenStack.CurrentScreen is not Editor editor)
{
Schedule(() => Notifications.Post(new SimpleErrorNotification
{
Icon = FontAwesome.Solid.ExclamationTriangle,
Text = EditorStrings.MustBeInEditorToHandleLinks
}));
return;
}
editor.HandleTimestamp(timestamp);
}
/// <summary>
/// Present a skin select immediately.
/// </summary>

View File

@ -0,0 +1,50 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace osu.Game.Rulesets.Edit
{
public static class EditorTimestampParser
{
// 00:00:000 (...) - test
// original osu-web regex: https://github.com/ppy/osu-web/blob/3b1698639244cfdaf0b41c68bfd651ea729ec2e3/resources/js/utils/beatmapset-discussion-helper.ts#L78
public static readonly Regex TIME_REGEX = new Regex(@"\b(((?<minutes>\d{2,}):(?<seconds>[0-5]\d)[:.](?<milliseconds>\d{3}))(?<selection>\s\([^)]+\))?)", RegexOptions.Compiled);
public static bool TryParse(string timestamp, [NotNullWhen(true)] out TimeSpan? parsedTime, out string? parsedSelection)
{
Match match = TIME_REGEX.Match(timestamp);
if (!match.Success)
{
parsedTime = null;
parsedSelection = null;
return false;
}
bool result = true;
result &= int.TryParse(match.Groups[@"minutes"].Value, out int timeMin);
result &= int.TryParse(match.Groups[@"seconds"].Value, out int timeSec);
result &= int.TryParse(match.Groups[@"milliseconds"].Value, out int timeMsec);
// somewhat sane limit for timestamp duration (10 hours).
result &= timeMin < 600;
if (!result)
{
parsedTime = null;
parsedSelection = null;
return false;
}
parsedTime = TimeSpan.FromMinutes(timeMin) + TimeSpan.FromSeconds(timeSec) + TimeSpan.FromMilliseconds(timeMsec);
parsedSelection = match.Groups[@"selection"].Value.Trim();
if (!string.IsNullOrEmpty(parsedSelection))
parsedSelection = parsedSelection[1..^1];
return true;
}
}
}

View File

@ -526,8 +526,20 @@ namespace osu.Game.Rulesets.Edit
/// </summary>
public abstract bool CursorInPlacementArea { get; }
/// <summary>
/// Returns a string representing the current selection.
/// The inverse method to <see cref="SelectFromTimestamp"/>.
/// </summary>
public virtual string ConvertSelectionToString() => string.Empty;
/// <summary>
/// Selects objects based on the supplied <paramref name="timestamp"/> and <paramref name="objectDescription"/>.
/// The inverse method to <see cref="ConvertSelectionToString"/>.
/// </summary>
/// <param name="timestamp">The time instant to seek to, in milliseconds.</param>
/// <param name="objectDescription">The ruleset-specific description of objects to select at the given timestamp.</param>
public virtual void SelectFromTimestamp(double timestamp, string objectDescription) { }
#region IPositionSnapProvider
public abstract SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All);

View File

@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -50,6 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
Beatmap.HitObjectAdded += AddBlueprintFor;
Beatmap.HitObjectRemoved += RemoveBlueprintFor;
Beatmap.SelectedHitObjects.CollectionChanged += updateSelectionLifetime;
if (Composer != null)
{
@ -144,6 +146,25 @@ namespace osu.Game.Screens.Edit.Compose.Components
SelectedItems.AddRange(Beatmap.HitObjects.Except(SelectedItems).ToArray());
}
/// <summary>
/// Ensures that newly-selected hitobjects are kept alive
/// and drops that keep-alive from newly-deselected objects.
/// </summary>
private void updateSelectionLifetime(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
{
foreach (HitObject newSelection in e.NewItems)
Composer.Playfield.SetKeepAlive(newSelection, true);
}
if (e.OldItems != null)
{
foreach (HitObject oldSelection in e.OldItems)
Composer.Playfield.SetKeepAlive(oldSelection, false);
}
}
protected override void OnBlueprintSelected(SelectionBlueprint<HitObject> blueprint)
{
base.OnBlueprintSelected(blueprint);
@ -166,6 +187,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
Beatmap.HitObjectAdded -= AddBlueprintFor;
Beatmap.HitObjectRemoved -= RemoveBlueprintFor;
Beatmap.SelectedHitObjects.CollectionChanged -= updateSelectionLifetime;
}
usageEventBuffer?.Dispose();

View File

@ -14,6 +14,7 @@ using osu.Framework.Audio.Track;
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;
@ -39,6 +40,7 @@ using osu.Game.Overlays.Notifications;
using osu.Game.Overlays.OSD;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Screens.Edit.Compose;
using osu.Game.Screens.Edit.Compose.Components.Timeline;
@ -1150,6 +1152,45 @@ namespace osu.Game.Screens.Edit
loader?.CancelPendingDifficultySwitch();
}
public void HandleTimestamp(string timestamp)
{
if (!EditorTimestampParser.TryParse(timestamp, out var timeSpan, out string selection))
{
Schedule(() => notifications?.Post(new SimpleErrorNotification
{
Icon = FontAwesome.Solid.ExclamationTriangle,
Text = EditorStrings.FailedToParseEditorLink
}));
return;
}
editorBeatmap.SelectedHitObjects.Clear();
if (clock.IsRunning)
clock.Stop();
double position = timeSpan.Value.TotalMilliseconds;
if (string.IsNullOrEmpty(selection))
{
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);
Mode.Value = EditorScreenMode.Compose;
// Delegate handling the selection to the ruleset.
currentScreen.Dependencies.Get<HitObjectComposer>().SelectFromTimestamp(position, selection);
}
public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime);
public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime);