1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-15 15:17:44 +08:00

Merge pull request #28382 from Hecatia-Lapislazuli/move-already-placed-objects-when-adjusting-offset-bpm

Implemented ability to adjust already-placed objects when changing timing offsets
This commit is contained in:
Dean Herbert 2024-11-11 18:56:11 +09:00 committed by GitHub
commit 8605639e67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 289 additions and 2 deletions

View File

@ -0,0 +1,161 @@
// 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.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Timing;
namespace osu.Game.Tests.Editing
{
[TestFixture]
public class TimingSectionAdjustmentsTest
{
[Test]
public void TestOffsetAdjustment()
{
var controlPoints = new ControlPointInfo();
controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 });
controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 });
controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 });
var beatmap = new Beatmap
{
ControlPointInfo = controlPoints,
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 200 },
new HitCircle { StartTime = 49_900 },
new HitCircle { StartTime = 50_000 },
new HitCircle { StartTime = 50_200 },
new HitCircle { StartTime = 99_800 },
new HitCircle { StartTime = 100_000 },
new HitCircle { StartTime = 100_050 },
new HitCircle { StartTime = 100_550 },
}
};
moveTimingPoint(beatmap, 100, -50);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(-50));
Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150));
Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850));
Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(50_000));
});
moveTimingPoint(beatmap, 50_000, 1_000);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850));
Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(51_000));
Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200));
Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(100_800));
Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(100_000));
});
moveTimingPoint(beatmap, 100_000, 10_000);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200));
Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(110_800));
Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(110_000));
Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(110_050));
Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(110_550));
});
}
[Test]
public void TestBPMAdjustment()
{
var controlPoints = new ControlPointInfo();
controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 });
controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 });
controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 });
var beatmap = new Beatmap
{
ControlPointInfo = controlPoints,
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 200 },
new Spinner { StartTime = 500, EndTime = 1000 },
new HitCircle { StartTime = 49_900 },
new HitCircle { StartTime = 50_000 },
new HitCircle { StartTime = 50_200 },
new HitCircle { StartTime = 99_800 },
new HitCircle { StartTime = 100_000 },
new HitCircle { StartTime = 100_050 },
new HitCircle { StartTime = 100_550 },
}
};
adjustBeatLength(beatmap, 100, 50);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(50));
Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150));
Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300));
Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550));
Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000));
Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000));
});
adjustBeatLength(beatmap, 50_000, 400);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300));
Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550));
Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000));
Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000));
Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400));
Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(149_600));
Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000));
});
adjustBeatLength(beatmap, 100_000, 100);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400));
Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(199_200));
Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000));
Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(100_100));
Assert.That(beatmap.HitObjects[9].StartTime, Is.EqualTo(101_100));
});
}
private static void moveTimingPoint(IBeatmap beatmap, double originalTime, double adjustment)
{
var controlPoints = beatmap.ControlPointInfo;
var controlPointGroup = controlPoints.GroupAt(originalTime);
var timingPoint = controlPointGroup.ControlPoints.OfType<TimingControlPoint>().Single();
controlPoints.RemoveGroup(controlPointGroup);
TimingSectionAdjustments.AdjustHitObjectOffset(beatmap, timingPoint, adjustment);
controlPoints.Add(originalTime - adjustment, timingPoint);
}
private static void adjustBeatLength(IBeatmap beatmap, double groupTime, double newBeatLength)
{
var controlPoints = beatmap.ControlPointInfo;
var controlPointGroup = controlPoints.GroupAt(groupTime);
var timingPoint = controlPointGroup.ControlPoints.OfType<TimingControlPoint>().Single();
double oldBeatLength = timingPoint.BeatLength;
timingPoint.BeatLength = newBeatLength;
TimingSectionAdjustments.SetHitObjectBPM(beatmap, timingPoint, oldBeatLength);
}
}
}

View File

@ -196,6 +196,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.EditorShowSpeedChanges, false); SetDefault(OsuSetting.EditorShowSpeedChanges, false);
SetDefault(OsuSetting.EditorScaleOrigin, EditorOrigin.GridCentre); SetDefault(OsuSetting.EditorScaleOrigin, EditorOrigin.GridCentre);
SetDefault(OsuSetting.EditorRotationOrigin, EditorOrigin.GridCentre); SetDefault(OsuSetting.EditorRotationOrigin, EditorOrigin.GridCentre);
SetDefault(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges, true);
SetDefault(OsuSetting.HideCountryFlags, false); SetDefault(OsuSetting.HideCountryFlags, false);
@ -442,5 +443,6 @@ namespace osu.Game.Configuration
EditorScaleOrigin, EditorScaleOrigin,
EditorRotationOrigin, EditorRotationOrigin,
EditorTimelineShowBreaks, EditorTimelineShowBreaks,
EditorAdjustExistingObjectsOnTimingChanges,
} }
} }

View File

@ -39,6 +39,11 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString SetPreviewPointToCurrent => new TranslatableString(getKey(@"set_preview_point_to_current"), @"Set preview point to current time"); public static LocalisableString SetPreviewPointToCurrent => new TranslatableString(getKey(@"set_preview_point_to_current"), @"Set preview point to current time");
/// <summary>
/// "Move already placed objects when changing timing"
/// </summary>
public static LocalisableString AdjustExistingObjectsOnTimingChanges => new TranslatableString(getKey(@"adjust_existing_objects_on_timing_changes"), @"Move already placed objects when changing timing");
/// <summary> /// <summary>
/// "For editing (.olz)" /// "For editing (.olz)"
/// </summary> /// </summary>

View File

@ -421,7 +421,7 @@ namespace osu.Game.Screens.Edit
{ {
Items = new MenuItem[] Items = new MenuItem[]
{ {
new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime) new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime),
} }
} }
} }

View File

@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osuTK; using osuTK;
@ -25,6 +26,9 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved] [Resolved]
protected EditorBeatmap Beatmap { get; private set; } = null!; protected EditorBeatmap Beatmap { get; private set; } = null!;
[Resolved]
private OsuConfigManager configManager { get; set; } = null!;
[Resolved] [Resolved]
private EditorClock clock { get; set; } = null!; private EditorClock clock { get; set; } = null!;
@ -110,7 +114,16 @@ namespace osu.Game.Screens.Edit.Timing
Beatmap.ControlPointInfo.RemoveGroup(SelectedGroup.Value); Beatmap.ControlPointInfo.RemoveGroup(SelectedGroup.Value);
foreach (var cp in currentGroupItems) foreach (var cp in currentGroupItems)
{
// Only adjust hit object offsets if the group contains a timing control point
if (cp is TimingControlPoint tp && configManager.Get<bool>(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges))
{
TimingSectionAdjustments.AdjustHitObjectOffset(Beatmap, tp, time - SelectedGroup.Value.Time);
Beatmap.UpdateAllHitObjects();
}
Beatmap.ControlPointInfo.Add(time, cp); Beatmap.ControlPointInfo.Add(time, cp);
}
// the control point might not necessarily exist yet, if currentGroupItems was empty. // the control point might not necessarily exist yet, if currentGroupItems was empty.
SelectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(time, true); SelectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(time, true);

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
@ -26,6 +27,9 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved] [Resolved]
private EditorBeatmap beatmap { get; set; } = null!; private EditorBeatmap beatmap { get; set; } = null!;
[Resolved]
private OsuConfigManager configManager { get; set; } = null!;
[Resolved] [Resolved]
private Bindable<ControlPointGroup> selectedGroup { get; set; } = null!; private Bindable<ControlPointGroup> selectedGroup { get; set; } = null!;
@ -202,15 +206,25 @@ namespace osu.Game.Screens.Edit.Timing
// VERY TEMPORARY // VERY TEMPORARY
var currentGroupItems = selectedGroup.Value.ControlPoints.ToArray(); var currentGroupItems = selectedGroup.Value.ControlPoints.ToArray();
beatmap.BeginChange();
beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value); beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value);
double newOffset = selectedGroup.Value.Time + adjust; double newOffset = selectedGroup.Value.Time + adjust;
foreach (var cp in currentGroupItems) foreach (var cp in currentGroupItems)
{
if (cp is TimingControlPoint tp)
{
TimingSectionAdjustments.AdjustHitObjectOffset(beatmap, tp, adjust);
beatmap.UpdateAllHitObjects();
}
beatmap.ControlPointInfo.Add(newOffset, cp); beatmap.ControlPointInfo.Add(newOffset, cp);
}
// the control point might not necessarily exist yet, if currentGroupItems was empty. // the control point might not necessarily exist yet, if currentGroupItems was empty.
selectedGroup.Value = beatmap.ControlPointInfo.GroupAt(newOffset, true); selectedGroup.Value = beatmap.ControlPointInfo.GroupAt(newOffset, true);
beatmap.EndChange();
if (!editorClock.IsRunning && wasAtStart) if (!editorClock.IsRunning && wasAtStart)
editorClock.Seek(newOffset); editorClock.Seek(newOffset);
@ -223,7 +237,16 @@ namespace osu.Game.Screens.Edit.Timing
if (timing == null) if (timing == null)
return; return;
double oldBeatLength = timing.BeatLength;
timing.BeatLength = 60000 / (timing.BPM + adjust); timing.BeatLength = 60000 / (timing.BPM + adjust);
if (configManager.Get<bool>(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges))
{
beatmap.BeginChange();
TimingSectionAdjustments.SetHitObjectBPM(beatmap, timing, oldBeatLength);
beatmap.UpdateAllHitObjects();
beatmap.EndChange();
}
} }
private partial class InlineButton : OsuButton private partial class InlineButton : OsuButton

View File

@ -1,11 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
namespace osu.Game.Screens.Edit.Timing namespace osu.Game.Screens.Edit.Timing
{ {
@ -15,11 +18,20 @@ namespace osu.Game.Screens.Edit.Timing
private LabelledSwitchButton omitBarLine = null!; private LabelledSwitchButton omitBarLine = null!;
private BPMTextBox bpmTextEntry = null!; private BPMTextBox bpmTextEntry = null!;
[Resolved]
private OsuConfigManager configManager { get; set; } = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
Flow.AddRange(new Drawable[] Flow.AddRange(new Drawable[]
{ {
new LabelledSwitchButton
{
Label = EditorStrings.AdjustExistingObjectsOnTimingChanges,
FixedLabelWidth = 220,
Current = configManager.GetBindable<bool>(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges),
},
new TapTimingControl(), new TapTimingControl(),
bpmTextEntry = new BPMTextBox(), bpmTextEntry = new BPMTextBox(),
timeSignature = new LabelledTimeSignature timeSignature = new LabelledTimeSignature
@ -42,6 +54,17 @@ namespace osu.Game.Screens.Edit.Timing
{ {
if (!isRebinding) ChangeHandler?.SaveState(); if (!isRebinding) ChangeHandler?.SaveState();
} }
bpmTextEntry.OnCommit = (oldBeatLength, _) =>
{
if (!configManager.Get<bool>(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges) || ControlPoint.Value == null)
return;
Beatmap.BeginChange();
TimingSectionAdjustments.SetHitObjectBPM(Beatmap, ControlPoint.Value, oldBeatLength);
Beatmap.UpdateAllHitObjects();
Beatmap.EndChange();
};
} }
private bool isRebinding; private bool isRebinding;
@ -74,6 +97,8 @@ namespace osu.Game.Screens.Edit.Timing
private partial class BPMTextBox : LabelledTextBox private partial class BPMTextBox : LabelledTextBox
{ {
public new Action<double, double>? OnCommit { get; set; }
private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable; private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable;
public BPMTextBox() public BPMTextBox()
@ -81,10 +106,12 @@ namespace osu.Game.Screens.Edit.Timing
Label = "BPM"; Label = "BPM";
SelectAllOnFocus = true; SelectAllOnFocus = true;
OnCommit += (_, isNew) => base.OnCommit += (_, isNew) =>
{ {
if (!isNew) return; if (!isNew) return;
double oldBeatLength = beatLengthBindable.Value;
try try
{ {
if (double.TryParse(Current.Value, out double doubleVal) && doubleVal > 0) if (double.TryParse(Current.Value, out double doubleVal) && doubleVal > 0)
@ -98,6 +125,7 @@ namespace osu.Game.Screens.Edit.Timing
// This is run regardless of parsing success as the parsed number may not actually trigger a change // This is run regardless of parsing success as the parsed number may not actually trigger a change
// due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state. // due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state.
beatLengthBindable.TriggerChange(); beatLengthBindable.TriggerChange();
OnCommit?.Invoke(oldBeatLength, beatLengthBindable.Value);
}; };
beatLengthBindable.BindValueChanged(val => beatLengthBindable.BindValueChanged(val =>

View File

@ -0,0 +1,55 @@
// 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 osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Screens.Edit.Timing
{
public static class TimingSectionAdjustments
{
/// <summary>
/// Returns all objects from <paramref name="beatmap"/> which are affected by the supplied <paramref name="timingControlPoint"/>.
/// </summary>
public static List<HitObject> HitObjectsInTimingRange(IBeatmap beatmap, TimingControlPoint timingControlPoint)
{
// If the first group, we grab all hitobjects prior to the next, if the last group, we grab all remaining hitobjects
double startTime = beatmap.ControlPointInfo.TimingPoints.Any(x => x.Time < timingControlPoint.Time) ? timingControlPoint.Time : double.MinValue;
double endTime = beatmap.ControlPointInfo.TimingPoints.FirstOrDefault(x => x.Time > timingControlPoint.Time)?.Time ?? double.MaxValue;
return beatmap.HitObjects.Where(x => Precision.AlmostBigger(x.StartTime, startTime) && Precision.DefinitelyBigger(endTime, x.StartTime)).ToList();
}
/// <summary>
/// Moves all relevant objects after <paramref name="timingControlPoint"/>'s offset has been changed by <paramref name="adjustment"/>.
/// </summary>
public static void AdjustHitObjectOffset(IBeatmap beatmap, TimingControlPoint timingControlPoint, double adjustment)
{
foreach (HitObject hitObject in HitObjectsInTimingRange(beatmap, timingControlPoint))
{
hitObject.StartTime += adjustment;
}
}
/// <summary>
/// Ensures all relevant objects are still snapped to the same beats after <paramref name="timingControlPoint"/>'s beat length / BPM has been changed.
/// </summary>
public static void SetHitObjectBPM(IBeatmap beatmap, TimingControlPoint timingControlPoint, double oldBeatLength)
{
foreach (HitObject hitObject in HitObjectsInTimingRange(beatmap, timingControlPoint))
{
double beat = (hitObject.StartTime - timingControlPoint.Time) / oldBeatLength;
hitObject.StartTime = (beat * timingControlPoint.BeatLength) + timingControlPoint.Time;
if (hitObject is not IHasRepeats && hitObject is IHasDuration hitObjectWithDuration)
hitObjectWithDuration.Duration *= timingControlPoint.BeatLength / oldBeatLength;
}
}
}
}