1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 16:07:24 +08:00

Merge branch 'master' into master

This commit is contained in:
Dean Herbert 2022-06-02 13:26:13 +09:00 committed by GitHub
commit 624bb43eaf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 687 additions and 48 deletions

View File

@ -23,4 +23,4 @@ jobs:
SENTRY_URL: https://sentry.ppy.sh/ SENTRY_URL: https://sentry.ppy.sh/
with: with:
environment: production environment: production
version: ${{ github.ref }} version: osu@${{ github.ref_name }}

View File

@ -77,6 +77,12 @@ namespace osu.Game.Tests.Visual.Editing
timingInfo.Text = $"offset: {selectedGroup.Value.Time:N2} bpm: {selectedGroup.Value.ControlPoints.OfType<TimingControlPoint>().First().BPM:N2}"; timingInfo.Text = $"offset: {selectedGroup.Value.Time:N2} bpm: {selectedGroup.Value.ControlPoints.OfType<TimingControlPoint>().First().BPM:N2}";
} }
[Test]
public void TestNoop()
{
AddStep("do nothing", () => { });
}
[Test] [Test]
public void TestTapThenReset() public void TestTapThenReset()
{ {

View File

@ -1,14 +1,18 @@
// 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.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Edit.Timing.RowAttributes;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing namespace osu.Game.Tests.Visual.Editing
{ {
@ -22,6 +26,8 @@ namespace osu.Game.Tests.Visual.Editing
[Cached] [Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
private TimingScreen timingScreen;
protected override bool ScrollUsingMouseWheel => false; protected override bool ScrollUsingMouseWheel => false;
public TestSceneTimingScreen() public TestSceneTimingScreen()
@ -36,12 +42,54 @@ namespace osu.Game.Tests.Visual.Editing
Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap);
Beatmap.Disabled = true; Beatmap.Disabled = true;
Child = new TimingScreen Child = timingScreen = new TimingScreen
{ {
State = { Value = Visibility.Visible }, State = { Value = Visibility.Visible },
}; };
} }
[SetUpSteps]
public void SetUpSteps()
{
AddStep("Stop clock", () => Clock.Stop());
AddUntilStep("wait for rows to load", () => Child.ChildrenOfType<EffectRowAttribute>().Any());
}
[Test]
public void TestTrackingCurrentTimeWhileRunning()
{
AddStep("Select first effect point", () =>
{
InputManager.MoveMouseTo(Child.ChildrenOfType<EffectRowAttribute>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670);
AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670);
AddStep("Seek to just before next point", () => Clock.Seek(69000));
AddStep("Start clock", () => Clock.Start());
AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670);
}
[Test]
public void TestTrackingCurrentTimeWhilePaused()
{
AddStep("Select first effect point", () =>
{
InputManager.MoveMouseTo(Child.ChildrenOfType<EffectRowAttribute>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670);
AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670);
AddStep("Seek to later", () => Clock.Seek(80000));
AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670);
}
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
Beatmap.Disabled = false; Beatmap.Disabled = false;

View File

@ -469,6 +469,8 @@ namespace osu.Game.Tests.Visual.Online
chatOverlay.Show(); chatOverlay.Show();
}); });
AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1)));
waitForChannel1Visible(); waitForChannel1Visible();
AddStep("Press document next keys", () => InputManager.Keys(PlatformAction.DocumentNext)); AddStep("Press document next keys", () => InputManager.Keys(PlatformAction.DocumentNext));
waitForChannel2Visible(); waitForChannel2Visible();

View File

@ -173,6 +173,8 @@ namespace osu.Game.Tests.Visual.Playlists
{ {
AddUntilStep("wait for scores loaded", () => AddUntilStep("wait for scores loaded", () =>
requestComplete requestComplete
// request handler may need to fire more than once to get scores.
&& totalCount > 0
&& resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount && resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount
&& resultsScreen.ScorePanelList.AllPanelsVisible); && resultsScreen.ScorePanelList.AllPanelsVisible);
AddWaitStep("wait for display", 5); AddWaitStep("wait for display", 5);

View File

@ -91,7 +91,7 @@ namespace osu.Game.Database
} }
} }
private void performLookup() private async Task performLookup()
{ {
// contains at most 50 unique IDs from tasks, which is used to perform the lookup. // contains at most 50 unique IDs from tasks, which is used to perform the lookup.
var nextTaskBatch = new Dictionary<TLookup, List<TaskCompletionSource<TValue>>>(); var nextTaskBatch = new Dictionary<TLookup, List<TaskCompletionSource<TValue>>>();
@ -127,7 +127,7 @@ namespace osu.Game.Database
// rather than queueing, we maintain our own single-threaded request stream. // rather than queueing, we maintain our own single-threaded request stream.
// todo: we probably want retry logic here. // todo: we probably want retry logic here.
api.Perform(request); await api.PerformAsync(request).ConfigureAwait(false);
finishPendingTask(); finishPendingTask();

View File

@ -63,12 +63,13 @@ namespace osu.Game.Online.API
public virtual void Queue(APIRequest request) public virtual void Queue(APIRequest request)
{ {
if (HandleRequest?.Invoke(request) != true) Schedule(() =>
{ {
// this will fail due to not receiving an APIAccess, and trigger a failure on the request. if (HandleRequest?.Invoke(request) != true)
// this is intended - any request in testing that needs non-failures should use HandleRequest. {
request.Perform(this); request.Fail(new InvalidOperationException($@"{nameof(DummyAPIAccess)} cannot process this request."));
} }
});
} }
public void Perform(APIRequest request) => HandleRequest?.Invoke(request); public void Perform(APIRequest request) => HandleRequest?.Invoke(request);

View File

@ -2,13 +2,16 @@
// 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 osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Edit.Components.Timelines.Summary; using osu.Game.Screens.Edit.Components.Timelines.Summary;
using osuTK; using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Edit namespace osu.Game.Screens.Edit
{ {
@ -26,6 +29,14 @@ namespace osu.Game.Screens.Edit
Height = 60; Height = 60;
Masking = true;
EdgeEffect = new EdgeEffectParameters
{
Colour = Color4.Black.Opacity(0.2f),
Type = EdgeEffectType.Shadow,
Radius = 10f,
};
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
new Box new Box

View File

@ -99,6 +99,15 @@ namespace osu.Game.Screens.Edit
colourSelected = colours.Colour3; colourSelected = colours.Colour3;
} }
protected override void LoadComplete()
{
base.LoadComplete();
// Reduce flicker of rows when offset is being changed rapidly.
// Probably need to reconsider this.
FinishTransforms(true);
}
private bool selected; private bool selected;
public bool Selected public bool Selected

View File

@ -61,6 +61,7 @@ namespace osu.Game.Screens.Edit.Timing
selectedGroup.BindValueChanged(group => selectedGroup.BindValueChanged(group =>
{ {
// TODO: This should scroll the selected row into view.
foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue; foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue;
}, true); }, true);
} }

View File

@ -31,18 +31,33 @@ namespace osu.Game.Screens.Edit.Timing
}); });
} }
protected override void LoadComplete()
{
base.LoadComplete();
kiai.Current.BindValueChanged(_ => saveChanges());
omitBarLine.Current.BindValueChanged(_ => saveChanges());
scrollSpeedSlider.Current.BindValueChanged(_ => saveChanges());
void saveChanges()
{
if (!isRebinding) ChangeHandler?.SaveState();
}
}
private bool isRebinding;
protected override void OnControlPointChanged(ValueChangedEvent<EffectControlPoint> point) protected override void OnControlPointChanged(ValueChangedEvent<EffectControlPoint> point)
{ {
if (point.NewValue != null) if (point.NewValue != null)
{ {
isRebinding = true;
kiai.Current = point.NewValue.KiaiModeBindable; kiai.Current = point.NewValue.KiaiModeBindable;
kiai.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable; omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable;
omitBarLine.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
scrollSpeedSlider.Current = point.NewValue.ScrollSpeedBindable; scrollSpeedSlider.Current = point.NewValue.ScrollSpeedBindable;
scrollSpeedSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
isRebinding = false;
} }
} }

View File

@ -3,6 +3,8 @@
using System; using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Audio.Track; using osu.Framework.Audio.Track;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Extensions.LocalisationExtensions;
@ -31,12 +33,16 @@ namespace osu.Game.Screens.Edit.Timing
private IAdjustableClock metronomeClock; private IAdjustableClock metronomeClock;
private Sample clunk;
[Resolved] [Resolved]
private OverlayColourProvider overlayColourProvider { get; set; } private OverlayColourProvider overlayColourProvider { get; set; }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(AudioManager audio)
{ {
clunk = audio.Samples.Get(@"Multiplayer/countdown-tick");
const float taper = 25; const float taper = 25;
const float swing_vertical_offset = -23; const float swing_vertical_offset = -23;
const float lower_cover_height = 32; const float lower_cover_height = 32;
@ -269,8 +275,21 @@ namespace osu.Game.Screens.Edit.Timing
if (currentAngle != 0 && Math.Abs(currentAngle - targetAngle) > angle * 1.8f && isSwinging) if (currentAngle != 0 && Math.Abs(currentAngle - targetAngle) > angle * 1.8f && isSwinging)
{ {
using (stick.BeginDelayedSequence(beatLength / 2)) using (BeginDelayedSequence(beatLength / 2))
{
stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint); stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint);
Schedule(() =>
{
var channel = clunk?.GetChannel();
if (channel != null)
{
channel.Frequency.Value = RNG.NextDouble(0.98f, 1.02f);
channel.Play();
}
});
}
} }
} }
} }

View File

@ -1,6 +1,7 @@
// 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.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -18,6 +19,9 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved] [Resolved]
private EditorClock editorClock { get; set; } private EditorClock editorClock { get; set; }
[Resolved]
private EditorBeatmap beatmap { get; set; }
[Resolved] [Resolved]
private Bindable<ControlPointGroup> selectedGroup { get; set; } private Bindable<ControlPointGroup> selectedGroup { get; set; }
@ -45,6 +49,7 @@ namespace osu.Game.Screens.Edit.Timing
{ {
new Dimension(GridSizeMode.Absolute, 200), new Dimension(GridSizeMode.Absolute, 200),
new Dimension(GridSizeMode.Absolute, 60), new Dimension(GridSizeMode.Absolute, 60),
new Dimension(GridSizeMode.Absolute, 60),
}, },
Content = new[] Content = new[]
{ {
@ -77,7 +82,36 @@ namespace osu.Game.Screens.Edit.Timing
}, },
} }
} }
} },
},
new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(10),
Children = new Drawable[]
{
new TimingAdjustButton(1)
{
Text = "Offset",
RelativeSizeAxes = Axes.X,
Width = 0.48f,
Height = 50,
Action = adjustOffset,
},
new TimingAdjustButton(0.1)
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Text = "BPM",
RelativeSizeAxes = Axes.X,
Width = 0.48f,
Height = 50,
Action = adjustBpm,
}
}
},
}, },
new Drawable[] new Drawable[]
{ {
@ -113,6 +147,35 @@ namespace osu.Game.Screens.Edit.Timing
}; };
} }
private void adjustOffset(double adjust)
{
// VERY TEMPORARY
var currentGroupItems = selectedGroup.Value.ControlPoints.ToArray();
beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value);
double newOffset = selectedGroup.Value.Time + adjust;
foreach (var cp in currentGroupItems)
beatmap.ControlPointInfo.Add(newOffset, cp);
// the control point might not necessarily exist yet, if currentGroupItems was empty.
selectedGroup.Value = beatmap.ControlPointInfo.GroupAt(newOffset, true);
if (!editorClock.IsRunning)
editorClock.Seek(newOffset);
}
private void adjustBpm(double adjust)
{
var timing = selectedGroup.Value.ControlPoints.OfType<TimingControlPoint>().FirstOrDefault();
if (timing == null)
return;
timing.BeatLength = 60000 / (timing.BPM + adjust);
}
private void tap() private void tap()
{ {
editorClock.Seek(selectedGroup.Value.Time); editorClock.Seek(selectedGroup.Value.Time);

View File

@ -0,0 +1,254 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
namespace osu.Game.Screens.Edit.Timing
{
/// <summary>
/// A button with variable constant output based on hold position and length.
/// </summary>
public class TimingAdjustButton : CompositeDrawable
{
public Action<double> Action;
private readonly double adjustAmount;
private ScheduledDelegate adjustDelegate;
private const int max_multiplier = 10;
private const int adjust_levels = 4;
private const double initial_delay = 300;
private const double minimum_delay = 80;
public Container Content { get; set; }
private double adjustDelay = initial_delay;
private readonly Box background;
private readonly OsuSpriteText text;
private Sample sample;
public LocalisableString Text
{
get => text.Text;
set => text.Text = value;
}
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
public TimingAdjustButton(double adjustAmount)
{
this.adjustAmount = adjustAmount;
CornerRadius = 5;
Masking = true;
AddInternal(Content = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Depth = float.MaxValue
},
text = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Default.With(weight: FontWeight.SemiBold),
Padding = new MarginPadding(5),
Depth = float.MinValue
}
}
});
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sample = audio.Samples.Get(@"UI/notch-tick");
background.Colour = colourProvider.Background3;
for (int i = 1; i <= adjust_levels; i++)
{
Content.Add(new IncrementBox(i, adjustAmount));
Content.Add(new IncrementBox(-i, adjustAmount));
}
}
protected override bool OnMouseDown(MouseDownEvent e)
{
beginRepeat();
return true;
}
protected override void OnMouseUp(MouseUpEvent e)
{
adjustDelegate?.Cancel();
base.OnMouseUp(e);
}
private void beginRepeat()
{
adjustDelegate?.Cancel();
adjustDelay = initial_delay;
adjustNext();
void adjustNext()
{
var hoveredBox = Content.OfType<IncrementBox>().FirstOrDefault(d => d.IsHovered);
if (hoveredBox != null)
{
Action(adjustAmount * hoveredBox.Multiplier);
adjustDelay = Math.Max(minimum_delay, adjustDelay * 0.9f);
hoveredBox.Flash();
var channel = sample?.GetChannel();
if (channel != null)
{
double repeatModifier = 0.05f * (Math.Abs(adjustDelay - initial_delay) / minimum_delay);
double multiplierModifier = (hoveredBox.Multiplier / max_multiplier) * 0.2f;
channel.Frequency.Value = 1 + multiplierModifier + repeatModifier;
channel.Play();
}
}
else
{
adjustDelay = initial_delay;
}
adjustDelegate = Scheduler.AddDelayed(adjustNext, adjustDelay);
}
}
private class IncrementBox : CompositeDrawable
{
public readonly float Multiplier;
private readonly Box box;
private readonly OsuSpriteText text;
public IncrementBox(int index, double amount)
{
Multiplier = Math.Sign(index) * convertMultiplier(index);
float ratio = (float)index / adjust_levels;
RelativeSizeAxes = Axes.Both;
Width = 0.5f * Math.Abs(ratio);
Anchor direction = index < 0 ? Anchor.x2 : Anchor.x0;
Origin |= direction;
Depth = Math.Abs(index);
Anchor = Anchor.TopCentre;
InternalChildren = new Drawable[]
{
box = new Box
{
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive
},
text = new OsuSpriteText
{
Anchor = direction,
Origin = direction,
Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold),
Text = $"{(index > 0 ? "+" : "-")}{Math.Abs(Multiplier * amount)}",
Padding = new MarginPadding(5),
Alpha = 0,
}
};
}
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
protected override void LoadComplete()
{
base.LoadComplete();
box.Colour = colourProvider.Background1;
box.Alpha = 0.1f;
}
private float convertMultiplier(int m)
{
switch (Math.Abs(m))
{
default: return 1;
case 2: return 2;
case 3: return 5;
case 4:
return max_multiplier;
}
}
protected override bool OnHover(HoverEvent e)
{
box.Colour = colourProvider.Colour0;
box.FadeTo(0.2f, 100, Easing.OutQuint);
text.FadeIn(100, Easing.OutQuint);
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
box.Colour = colourProvider.Background1;
box.FadeTo(0.1f, 500, Easing.OutQuint);
text.FadeOut(100, Easing.OutQuint);
base.OnHoverLost(e);
}
public void Flash()
{
box
.FadeTo(0.4f, 20, Easing.OutQuint)
.Then()
.FadeTo(0.2f, 400, Easing.OutQuint);
text
.MoveToY(-5, 20, Easing.OutQuint)
.Then()
.MoveToY(0, 400, Easing.OutQuint);
}
}
}
}

View File

@ -19,7 +19,7 @@ namespace osu.Game.Screens.Edit.Timing
public class TimingScreen : EditorScreenWithTimeline public class TimingScreen : EditorScreenWithTimeline
{ {
[Cached] [Cached]
private Bindable<ControlPointGroup> selectedGroup = new Bindable<ControlPointGroup>(); public readonly Bindable<ControlPointGroup> SelectedGroup = new Bindable<ControlPointGroup>();
public TimingScreen() public TimingScreen()
: base(EditorScreenMode.Timing) : base(EditorScreenMode.Timing)
@ -132,6 +132,40 @@ namespace osu.Game.Screens.Edit.Timing
}, true); }, true);
} }
protected override void Update()
{
base.Update();
trackActivePoint();
}
/// <summary>
/// Given the user has selected a control point group, we want to track any group which is
/// active at the current point in time which matches the type the user has selected.
///
/// So if the user is currently looking at a timing point and seeks into the future, a
/// future timing point would be automatically selected if it is now the new "current" point.
/// </summary>
private void trackActivePoint()
{
// For simplicity only match on the first type of the active control point.
var selectedPointType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType();
if (selectedPointType != null)
{
// We don't have an efficient way of looking up groups currently, only individual point types.
// To improve the efficiency of this in the future, we should reconsider the overall structure of ControlPointInfo.
// Find the next group which has the same type as the selected one.
var found = Beatmap.ControlPointInfo.Groups
.Where(g => g.ControlPoints.Any(cp => cp.GetType() == selectedPointType))
.LastOrDefault(g => g.Time <= clock.CurrentTimeAccurate);
if (found != null)
selectedGroup.Value = found;
}
}
private void delete() private void delete()
{ {
if (selectedGroup.Value == null) if (selectedGroup.Value == null)
@ -144,7 +178,27 @@ namespace osu.Game.Screens.Edit.Timing
private void addNew() private void addNew()
{ {
selectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); bool isFirstControlPoint = !Beatmap.ControlPointInfo.TimingPoints.Any();
var group = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true);
if (isFirstControlPoint)
group.Add(new TimingControlPoint());
else
{
// Try and create matching types from the currently selected control point.
var selected = selectedGroup.Value;
if (selected != null)
{
foreach (var controlPoint in selected.ControlPoints)
{
group.Add(controlPoint.DeepClone());
}
}
}
selectedGroup.Value = group;
} }
} }
} }

View File

@ -28,15 +28,31 @@ namespace osu.Game.Screens.Edit.Timing
}); });
} }
protected override void LoadComplete()
{
base.LoadComplete();
bpmTextEntry.Current.BindValueChanged(_ => saveChanges());
timeSignature.Current.BindValueChanged(_ => saveChanges());
void saveChanges()
{
if (!isRebinding) ChangeHandler?.SaveState();
}
}
private bool isRebinding;
protected override void OnControlPointChanged(ValueChangedEvent<TimingControlPoint> point) protected override void OnControlPointChanged(ValueChangedEvent<TimingControlPoint> point)
{ {
if (point.NewValue != null) if (point.NewValue != null)
{ {
bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; isRebinding = true;
bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable;
timeSignature.Current = point.NewValue.TimeSignatureBindable; timeSignature.Current = point.NewValue.TimeSignatureBindable;
timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
isRebinding = false;
} }
} }

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays; using osu.Game.Overlays;
@ -26,6 +27,8 @@ namespace osu.Game.Screens.Edit.Timing
{ {
private const int total_waveforms = 8; private const int total_waveforms = 8;
private const float corner_radius = LabelledDrawable<Drawable>.CORNER_RADIUS;
private readonly BindableNumber<double> beatLength = new BindableDouble(); private readonly BindableNumber<double> beatLength = new BindableDouble();
[Resolved] [Resolved]
@ -42,18 +45,22 @@ namespace osu.Game.Screens.Edit.Timing
private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT; private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT;
private int lastDisplayedBeatIndex; private double displayedTime;
private double selectedGroupStartTime; private double selectedGroupStartTime;
private double selectedGroupEndTime; private double selectedGroupEndTime;
private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>(); private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>();
private readonly BindableBool displayLocked = new BindableBool();
private LockedOverlay lockedOverlay = null!;
public WaveformComparisonDisplay() public WaveformComparisonDisplay()
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
CornerRadius = LabelledDrawable<Drawable>.CORNER_RADIUS; CornerRadius = corner_radius;
Masking = true; Masking = true;
} }
@ -81,72 +88,112 @@ namespace osu.Game.Screens.Edit.Timing
Width = 3, Width = 3,
}); });
AddInternal(lockedOverlay = new LockedOverlay());
selectedGroup.BindValueChanged(_ => updateTimingGroup(), true); selectedGroup.BindValueChanged(_ => updateTimingGroup(), true);
controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups); controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups);
controlPointGroups.BindCollectionChanged((_, __) => updateTimingGroup()); controlPointGroups.BindCollectionChanged((_, __) => updateTimingGroup());
beatLength.BindValueChanged(_ => showFrom(lastDisplayedBeatIndex), true); beatLength.BindValueChanged(_ => regenerateDisplay(true), true);
displayLocked.BindValueChanged(locked =>
{
if (locked.NewValue)
lockedOverlay.Show();
else
lockedOverlay.Hide();
}, true);
} }
private void updateTimingGroup() private void updateTimingGroup()
{ {
beatLength.UnbindBindings(); beatLength.UnbindBindings();
selectedGroupStartTime = 0;
selectedGroupEndTime = beatmap.Value.Track.Length;
var tcp = selectedGroup.Value?.ControlPoints.OfType<TimingControlPoint>().FirstOrDefault(); var tcp = selectedGroup.Value?.ControlPoints.OfType<TimingControlPoint>().FirstOrDefault();
if (tcp == null) if (tcp == null)
{ {
timingPoint = new TimingControlPoint(); timingPoint = new TimingControlPoint();
// During movement of a control point's offset, this clause can be hit momentarily,
// as moving a control point is implemented by removing it and inserting it at the new time.
// We don't want to reset the `selectedGroupStartTime` here as we rely on having the
// last value to update the waveform display below.
selectedGroupEndTime = beatmap.Value.Track.Length;
return; return;
} }
timingPoint = tcp; timingPoint = tcp;
beatLength.BindTo(timingPoint.BeatLengthBindable); beatLength.BindTo(timingPoint.BeatLengthBindable);
selectedGroupStartTime = selectedGroup.Value?.Time ?? 0; double? newStartTime = selectedGroup.Value?.Time;
double? offsetChange = newStartTime - selectedGroupStartTime;
var nextGroup = editorBeatmap.ControlPointInfo.TimingPoints var nextGroup = editorBeatmap.ControlPointInfo.TimingPoints
.SkipWhile(g => g != tcp) .SkipWhile(g => g != tcp)
.Skip(1) .Skip(1)
.FirstOrDefault(); .FirstOrDefault();
if (nextGroup != null) selectedGroupStartTime = newStartTime ?? 0;
selectedGroupEndTime = nextGroup.Time; selectedGroupEndTime = nextGroup?.Time ?? beatmap.Value.Track.Length;
if (newStartTime.HasValue && offsetChange.HasValue)
{
// The offset of the selected point may have changed.
// This handles the case the user has locked the view and expects the display to update with this change.
showFromTime(displayedTime + offsetChange.Value, true);
}
} }
protected override bool OnHover(HoverEvent e) => true; protected override bool OnHover(HoverEvent e) => true;
protected override bool OnMouseMove(MouseMoveEvent e) protected override bool OnMouseMove(MouseMoveEvent e)
{ {
float trackLength = (float)beatmap.Value.Track.Length; if (!displayLocked.Value)
int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength); {
float trackLength = (float)beatmap.Value.Track.Length;
int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength);
Scheduler.AddOnce(showFrom, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable)); Scheduler.AddOnce(showFromBeat, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable));
}
return base.OnMouseMove(e); return base.OnMouseMove(e);
} }
protected override bool OnClick(ClickEvent e)
{
displayLocked.Toggle();
return true;
}
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
if (!IsHovered) if (!IsHovered && !displayLocked.Value)
{ {
int currentBeat = (int)Math.Floor((editorClock.CurrentTimeAccurate - selectedGroupStartTime) / timingPoint.BeatLength); int currentBeat = (int)Math.Floor((editorClock.CurrentTimeAccurate - selectedGroupStartTime) / timingPoint.BeatLength);
showFrom(currentBeat); showFromBeat(currentBeat);
} }
} }
private void showFrom(int beatIndex) private void showFromBeat(int beatIndex) =>
showFromTime(selectedGroupStartTime + beatIndex * timingPoint.BeatLength, false);
private void showFromTime(double time, bool animated)
{ {
if (lastDisplayedBeatIndex == beatIndex) if (displayedTime == time)
return; return;
displayedTime = time;
regenerateDisplay(animated);
}
private void regenerateDisplay(bool animated)
{
double index = (displayedTime - selectedGroupStartTime) / timingPoint.BeatLength;
// Chosen as a pretty usable number across all BPMs. // Chosen as a pretty usable number across all BPMs.
// Optimally we'd want this to scale with the BPM in question, but performing // Optimally we'd want this to scale with the BPM in question, but performing
// scaling of the display is both expensive in resampling, and decreases usability // scaling of the display is both expensive in resampling, and decreases usability
@ -156,23 +203,88 @@ namespace osu.Game.Screens.Edit.Timing
float trackLength = (float)beatmap.Value.Track.Length; float trackLength = (float)beatmap.Value.Track.Length;
float scale = trackLength / visible_width; float scale = trackLength / visible_width;
const int start_offset = total_waveforms / 2;
// Start displaying from before the current beat // Start displaying from before the current beat
beatIndex -= total_waveforms / 2; index -= start_offset;
foreach (var row in InternalChildren.OfType<WaveformRow>()) foreach (var row in InternalChildren.OfType<WaveformRow>())
{ {
// offset to the required beat index. // offset to the required beat index.
double time = selectedGroupStartTime + beatIndex * timingPoint.BeatLength; double time = selectedGroupStartTime + index * timingPoint.BeatLength;
float offset = (float)(time - visible_width / 2) / trackLength * scale; float offset = (float)(time - visible_width / 2) / trackLength * scale;
row.Alpha = time < selectedGroupStartTime || time > selectedGroupEndTime ? 0.2f : 1; row.Alpha = time < selectedGroupStartTime || time > selectedGroupEndTime ? 0.2f : 1;
row.WaveformOffset = -offset; row.WaveformOffsetTo(-offset, animated);
row.WaveformScale = new Vector2(scale, 1); row.WaveformScale = new Vector2(scale, 1);
row.BeatIndex = beatIndex++; row.BeatIndex = (int)Math.Floor(index);
index++;
}
}
internal class LockedOverlay : CompositeDrawable
{
private OsuSpriteText text = null!;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
RelativeSizeAxes = Axes.Both;
Masking = true;
CornerRadius = corner_radius;
BorderColour = colours.Red;
BorderThickness = 3;
Alpha = 0;
InternalChildren = new Drawable[]
{
new Box
{
AlwaysPresent = true,
RelativeSizeAxes = Axes.Both,
Alpha = 0,
},
new Container
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
AutoSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = colours.Red,
RelativeSizeAxes = Axes.Both,
},
text = new OsuSpriteText
{
Colour = colours.GrayF,
Text = "Locked",
Margin = new MarginPadding(5),
Shadow = false,
Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold),
}
}
},
};
} }
lastDisplayedBeatIndex = beatIndex; public override void Show()
{
this.FadeIn(100, Easing.OutQuint);
text
.FadeIn().Then().Delay(600)
.FadeOut().Then().Delay(600)
.Loop();
}
public override void Hide()
{
this.FadeOut(100, Easing.OutQuint);
}
} }
internal class WaveformRow : CompositeDrawable internal class WaveformRow : CompositeDrawable
@ -224,7 +336,15 @@ namespace osu.Game.Screens.Edit.Timing
public int BeatIndex { set => beatIndexText.Text = value.ToString(); } public int BeatIndex { set => beatIndexText.Text = value.ToString(); }
public Vector2 WaveformScale { set => waveformGraph.Scale = value; } public Vector2 WaveformScale { set => waveformGraph.Scale = value; }
public float WaveformOffset { set => waveformGraph.X = value; }
public void WaveformOffsetTo(float value, bool animated) =>
this.TransformTo(nameof(waveformOffset), value, animated ? 300 : 0, Easing.OutQuint);
private float waveformOffset
{
get => waveformGraph.X;
set => waveformGraph.X = value;
}
} }
} }
} }

View File

@ -2,6 +2,7 @@
// 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 System;
using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -67,7 +68,25 @@ namespace osu.Game.Tests.Visual.OnlinePlay
// To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead. // To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead.
var beatmapManager = dependencies.Get<BeatmapManager>(); var beatmapManager = dependencies.Get<BeatmapManager>();
((DummyAPIAccess)API).HandleRequest = request => handler.HandleRequest(request, API.LocalUser.Value, beatmapManager); ((DummyAPIAccess)API).HandleRequest = request =>
{
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
// Because some of the handlers use realm, we need to ensure the game is still alive when firing.
// If we don't, a stray `PerformAsync` could hit an `ObjectDisposedException` if running too late.
Scheduler.Add(() =>
{
bool result = handler.HandleRequest(request, API.LocalUser.Value, beatmapManager);
tcs.SetResult(result);
}, false);
#pragma warning disable RS0030
// We can't GetResultSafely() here (will fail with "Can't use GetResultSafely from inside an async operation."), but Wait is safe enough due to
// the task being a TaskCompletionSource.
// Importantly, this doesn't deadlock because of the scheduler call above running inline where feasible (see the `false` argument).
return tcs.Task.Result;
#pragma warning restore RS0030
};
}); });
/// <summary> /// <summary>

View File

@ -48,9 +48,8 @@ namespace osu.Game.Utils
options.AutoSessionTracking = true; options.AutoSessionTracking = true;
options.IsEnvironmentUser = false; options.IsEnvironmentUser = false;
// The reported release needs to match release tags on github in order for sentry // The reported release needs to match version as reported to Sentry in .github/workflows/sentry-release.yml
// to automatically associate and track against releases. options.Release = $"osu@{game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty)}";
options.Release = game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty);
}); });
Logger.NewEntry += processLogEntry; Logger.NewEntry += processLogEntry;