mirror of
https://github.com/ppy/osu.git
synced 2025-02-21 03:02:54 +08:00
Merge branch 'master' into fix-dummmy-api-request-firing-2
This commit is contained in:
commit
24ce10ed6f
2
.github/workflows/sentry-release.yml
vendored
2
.github/workflows/sentry-release.yml
vendored
@ -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 }}
|
||||||
|
@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
|
|
||||||
if (positionInfo == positionInfos.First())
|
if (positionInfo == positionInfos.First())
|
||||||
{
|
{
|
||||||
positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2);
|
positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2);
|
||||||
positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
|
positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
@ -116,6 +116,7 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
if (!(osuObject is Slider slider))
|
if (!(osuObject is Slider slider))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// No need to update the head and tail circles, since slider handles that when the new slider path is set
|
||||||
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y));
|
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y));
|
||||||
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y));
|
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y));
|
||||||
|
|
||||||
@ -137,6 +138,7 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
if (!(osuObject is Slider slider))
|
if (!(osuObject is Slider slider))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// No need to update the head and tail circles, since slider handles that when the new slider path is set
|
||||||
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
|
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
|
||||||
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
|
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
|
||||||
|
|
||||||
@ -146,5 +148,41 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
|
|
||||||
slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value);
|
slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rotate a slider about its start position by the specified angle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slider">The slider to be rotated.</param>
|
||||||
|
/// <param name="rotation">The angle, measured in radians, to rotate the slider by.</param>
|
||||||
|
public static void RotateSlider(Slider slider, float rotation)
|
||||||
|
{
|
||||||
|
void rotateNestedObject(OsuHitObject nested) => nested.Position = rotateVector(nested.Position - slider.Position, rotation) + slider.Position;
|
||||||
|
|
||||||
|
// No need to update the head and tail circles, since slider handles that when the new slider path is set
|
||||||
|
slider.NestedHitObjects.OfType<SliderTick>().ForEach(rotateNestedObject);
|
||||||
|
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(rotateNestedObject);
|
||||||
|
|
||||||
|
var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray();
|
||||||
|
foreach (var point in controlPoints)
|
||||||
|
point.Position = rotateVector(point.Position, rotation);
|
||||||
|
|
||||||
|
slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rotate a vector by the specified angle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="vector">The vector to be rotated.</param>
|
||||||
|
/// <param name="rotation">The angle, measured in radians, to rotate the vector by.</param>
|
||||||
|
/// <returns>The rotated vector.</returns>
|
||||||
|
private static Vector2 rotateVector(Vector2 vector, float rotation)
|
||||||
|
{
|
||||||
|
float angle = MathF.Atan2(vector.Y, vector.X) + rotation;
|
||||||
|
float length = vector.Length;
|
||||||
|
return new Vector2(
|
||||||
|
length * MathF.Cos(angle),
|
||||||
|
length * MathF.Sin(angle)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using osu.Framework.Graphics.Primitives;
|
using osu.Framework.Graphics.Primitives;
|
||||||
|
using osu.Framework.Utils;
|
||||||
using osu.Game.Rulesets.Osu.Objects;
|
using osu.Game.Rulesets.Osu.Objects;
|
||||||
using osu.Game.Rulesets.Osu.UI;
|
using osu.Game.Rulesets.Osu.UI;
|
||||||
using osuTK;
|
using osuTK;
|
||||||
@ -37,15 +38,23 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
foreach (OsuHitObject hitObject in hitObjects)
|
foreach (OsuHitObject hitObject in hitObjects)
|
||||||
{
|
{
|
||||||
Vector2 relativePosition = hitObject.Position - previousPosition;
|
Vector2 relativePosition = hitObject.Position - previousPosition;
|
||||||
float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
|
float absoluteAngle = MathF.Atan2(relativePosition.Y, relativePosition.X);
|
||||||
float relativeAngle = absoluteAngle - previousAngle;
|
float relativeAngle = absoluteAngle - previousAngle;
|
||||||
|
|
||||||
positionInfos.Add(new ObjectPositionInfo(hitObject)
|
ObjectPositionInfo positionInfo;
|
||||||
|
positionInfos.Add(positionInfo = new ObjectPositionInfo(hitObject)
|
||||||
{
|
{
|
||||||
RelativeAngle = relativeAngle,
|
RelativeAngle = relativeAngle,
|
||||||
DistanceFromPrevious = relativePosition.Length
|
DistanceFromPrevious = relativePosition.Length
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hitObject is Slider slider)
|
||||||
|
{
|
||||||
|
float absoluteRotation = getSliderRotation(slider);
|
||||||
|
positionInfo.Rotation = absoluteRotation - absoluteAngle;
|
||||||
|
absoluteAngle = absoluteRotation;
|
||||||
|
}
|
||||||
|
|
||||||
previousPosition = hitObject.EndPosition;
|
previousPosition = hitObject.EndPosition;
|
||||||
previousAngle = absoluteAngle;
|
previousAngle = absoluteAngle;
|
||||||
}
|
}
|
||||||
@ -70,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
|
|
||||||
if (hitObject is Spinner)
|
if (hitObject is Spinner)
|
||||||
{
|
{
|
||||||
previous = null;
|
previous = current;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,16 +133,23 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
|
|
||||||
if (previous != null)
|
if (previous != null)
|
||||||
{
|
{
|
||||||
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
|
if (previous.HitObject is Slider s)
|
||||||
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
|
{
|
||||||
previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
|
previousAbsoluteAngle = getSliderRotation(s);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
|
||||||
|
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
|
||||||
|
previousAbsoluteAngle = MathF.Atan2(relativePosition.Y, relativePosition.X);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle;
|
float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle;
|
||||||
|
|
||||||
var posRelativeToPrev = new Vector2(
|
var posRelativeToPrev = new Vector2(
|
||||||
current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
|
current.PositionInfo.DistanceFromPrevious * MathF.Cos(absoluteAngle),
|
||||||
current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
|
current.PositionInfo.DistanceFromPrevious * MathF.Sin(absoluteAngle)
|
||||||
);
|
);
|
||||||
|
|
||||||
Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre;
|
Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre;
|
||||||
@ -141,6 +157,19 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
|
posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
|
||||||
|
|
||||||
current.PositionModified = lastEndPosition + posRelativeToPrev;
|
current.PositionModified = lastEndPosition + posRelativeToPrev;
|
||||||
|
|
||||||
|
if (!(current.HitObject is Slider slider))
|
||||||
|
return;
|
||||||
|
|
||||||
|
absoluteAngle = MathF.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X);
|
||||||
|
|
||||||
|
Vector2 centreOfMassOriginal = calculateCentreOfMass(slider);
|
||||||
|
Vector2 centreOfMassModified = rotateVector(centreOfMassOriginal, current.PositionInfo.Rotation + absoluteAngle - getSliderRotation(slider));
|
||||||
|
centreOfMassModified = RotateAwayFromEdge(current.PositionModified, centreOfMassModified);
|
||||||
|
|
||||||
|
float relativeRotation = MathF.Atan2(centreOfMassModified.Y, centreOfMassModified.X) - MathF.Atan2(centreOfMassOriginal.Y, centreOfMassOriginal.X);
|
||||||
|
if (!Precision.AlmostEquals(relativeRotation, 0))
|
||||||
|
RotateSlider(slider, relativeRotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -172,13 +201,13 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
var previousPosition = workingObject.PositionModified;
|
var previousPosition = workingObject.PositionModified;
|
||||||
|
|
||||||
// Clamp slider position to the placement area
|
// Clamp slider position to the placement area
|
||||||
// If the slider is larger than the playfield, force it to stay at the original position
|
// If the slider is larger than the playfield, at least make sure that the head circle is inside the playfield
|
||||||
float newX = possibleMovementBounds.Width < 0
|
float newX = possibleMovementBounds.Width < 0
|
||||||
? workingObject.PositionOriginal.X
|
? Math.Clamp(possibleMovementBounds.Left, 0, OsuPlayfield.BASE_SIZE.X)
|
||||||
: Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
|
: Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
|
||||||
|
|
||||||
float newY = possibleMovementBounds.Height < 0
|
float newY = possibleMovementBounds.Height < 0
|
||||||
? workingObject.PositionOriginal.Y
|
? Math.Clamp(possibleMovementBounds.Top, 0, OsuPlayfield.BASE_SIZE.Y)
|
||||||
: Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
|
: Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
|
||||||
|
|
||||||
slider.Position = workingObject.PositionModified = new Vector2(newX, newY);
|
slider.Position = workingObject.PositionModified = new Vector2(newX, newY);
|
||||||
@ -287,6 +316,45 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Estimate the centre of mass of a slider relative to its start position.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slider">The slider to process.</param>
|
||||||
|
/// <returns>The centre of mass of the slider.</returns>
|
||||||
|
private static Vector2 calculateCentreOfMass(Slider slider)
|
||||||
|
{
|
||||||
|
const double sample_step = 50;
|
||||||
|
|
||||||
|
// just sample the start and end positions if the slider is too short
|
||||||
|
if (slider.Distance <= sample_step)
|
||||||
|
{
|
||||||
|
return Vector2.Divide(slider.Path.PositionAt(1), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
Vector2 sum = Vector2.Zero;
|
||||||
|
double pathDistance = slider.Distance;
|
||||||
|
|
||||||
|
for (double i = 0; i < pathDistance; i += sample_step)
|
||||||
|
{
|
||||||
|
sum += slider.Path.PositionAt(i / pathDistance);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum / count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the absolute rotation of a slider, defined as the angle from its start position to the end of its path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slider">The slider to process.</param>
|
||||||
|
/// <returns>The angle in radians.</returns>
|
||||||
|
private static float getSliderRotation(Slider slider)
|
||||||
|
{
|
||||||
|
var endPositionVector = slider.Path.PositionAt(1);
|
||||||
|
return MathF.Atan2(endPositionVector.Y, endPositionVector.X);
|
||||||
|
}
|
||||||
|
|
||||||
public class ObjectPositionInfo
|
public class ObjectPositionInfo
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -309,6 +377,13 @@ namespace osu.Game.Rulesets.Osu.Utils
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public float DistanceFromPrevious { get; set; }
|
public float DistanceFromPrevious { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The rotation of the hit object, relative to its jump angle.
|
||||||
|
/// For sliders, this is defined as the angle from the slider's start position to the end of its path, relative to its jump angle.
|
||||||
|
/// For hit circles and spinners, this property is ignored.
|
||||||
|
/// </summary>
|
||||||
|
public float Rotation { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The hit object associated with this <see cref="ObjectPositionInfo"/>.
|
/// The hit object associated with this <see cref="ObjectPositionInfo"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -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()
|
||||||
{
|
{
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
254
osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs
Normal file
254
osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +70,7 @@ namespace osu.Game.Screens.Edit.Timing
|
|||||||
|
|
||||||
for (int i = 0; i < total_waveforms; i++)
|
for (int i = 0; i < total_waveforms; i++)
|
||||||
{
|
{
|
||||||
AddInternal(new WaveformRow
|
AddInternal(new WaveformRow(i == total_waveforms / 2)
|
||||||
{
|
{
|
||||||
RelativeSizeAxes = Axes.Both,
|
RelativeSizeAxes = Axes.Both,
|
||||||
RelativePositionAxes = Axes.Both,
|
RelativePositionAxes = Axes.Both,
|
||||||
@ -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,38 +203,115 @@ 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
|
||||||
{
|
{
|
||||||
|
private readonly bool isMainRow;
|
||||||
private OsuSpriteText beatIndexText = null!;
|
private OsuSpriteText beatIndexText = null!;
|
||||||
private WaveformGraph waveformGraph = null!;
|
private WaveformGraph waveformGraph = null!;
|
||||||
|
|
||||||
[Resolved]
|
[Resolved]
|
||||||
private OverlayColourProvider colourProvider { get; set; } = null!;
|
private OverlayColourProvider colourProvider { get; set; } = null!;
|
||||||
|
|
||||||
|
public WaveformRow(bool isMainRow)
|
||||||
|
{
|
||||||
|
this.isMainRow = isMainRow;
|
||||||
|
}
|
||||||
|
|
||||||
[BackgroundDependencyLoader]
|
[BackgroundDependencyLoader]
|
||||||
private void load(IBindable<WorkingBeatmap> beatmap)
|
private void load(IBindable<WorkingBeatmap> beatmap)
|
||||||
{
|
{
|
||||||
InternalChildren = new Drawable[]
|
InternalChildren = new Drawable[]
|
||||||
{
|
{
|
||||||
|
new Box
|
||||||
|
{
|
||||||
|
Colour = colourProvider.Background3,
|
||||||
|
Alpha = isMainRow ? 1 : 0,
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
},
|
||||||
waveformGraph = new WaveformGraph
|
waveformGraph = new WaveformGraph
|
||||||
{
|
{
|
||||||
RelativeSizeAxes = Axes.Both,
|
RelativeSizeAxes = Axes.Both,
|
||||||
@ -212,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user