mirror of
https://github.com/ppy/osu.git
synced 2025-01-28 05:22:54 +08:00
Merge branch 'master' into fix-playlist-overlay-test-failures
This commit is contained in:
commit
6dc859973b
@ -52,7 +52,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
||||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.819.0" />
|
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.825.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Label="Transitive Dependencies">
|
<ItemGroup Label="Transitive Dependencies">
|
||||||
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
|
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
|
||||||
|
@ -77,6 +77,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
|||||||
return sliderCreatedFor(args);
|
return sliderCreatedFor(args);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
AddAssert("samples exist", sliderSampleExist);
|
||||||
|
|
||||||
AddStep("undo", () => Editor.Undo());
|
AddStep("undo", () => Editor.Undo());
|
||||||
AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && slider is not null && objectsRestored(circle1, slider, circle2));
|
AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && slider is not null && objectsRestored(circle1, slider, circle2));
|
||||||
}
|
}
|
||||||
@ -122,6 +124,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
|||||||
return sliderCreatedFor(args);
|
return sliderCreatedFor(args);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
AddAssert("samples exist", sliderSampleExist);
|
||||||
|
|
||||||
AddAssert("merged slider matches first slider", () =>
|
AddAssert("merged slider matches first slider", () =>
|
||||||
{
|
{
|
||||||
var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
|
var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
|
||||||
@ -165,6 +169,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
|||||||
(pos: circle1.Position, pathType: PathType.Linear),
|
(pos: circle1.Position, pathType: PathType.Linear),
|
||||||
(pos: circle2.Position, pathType: null)));
|
(pos: circle2.Position, pathType: null)));
|
||||||
|
|
||||||
|
AddAssert("samples exist", sliderSampleExist);
|
||||||
|
|
||||||
AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner));
|
AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,5 +215,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool sliderSampleExist()
|
||||||
|
{
|
||||||
|
if (EditorBeatmap.SelectedHitObjects.Count != 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
|
||||||
|
|
||||||
|
return mergedSlider.Samples[0] is not null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -182,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
|||||||
{
|
{
|
||||||
AddStep($"click context menu item \"{contextMenuText}\"", () =>
|
AddStep($"click context menu item \"{contextMenuText}\"", () =>
|
||||||
{
|
{
|
||||||
MenuItem item = visualiser.ContextMenuItems[1].Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
|
MenuItem item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == "Curve type")?.Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
|
||||||
|
|
||||||
item?.Action?.Value();
|
item?.Action?.Value();
|
||||||
});
|
});
|
||||||
|
255
osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs
Normal file
255
osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System.Linq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using osu.Framework.Graphics.UserInterface;
|
||||||
|
using osu.Framework.Testing;
|
||||||
|
using osu.Framework.Utils;
|
||||||
|
using osu.Game.Audio;
|
||||||
|
using osu.Game.Beatmaps;
|
||||||
|
using osu.Game.Rulesets.Objects;
|
||||||
|
using osu.Game.Rulesets.Objects.Types;
|
||||||
|
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
|
||||||
|
using osu.Game.Rulesets.Osu.Objects;
|
||||||
|
using osu.Game.Screens.Edit.Compose.Components;
|
||||||
|
using osu.Game.Tests.Beatmaps;
|
||||||
|
using osu.Game.Tests.Visual;
|
||||||
|
using osuTK;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||||
|
{
|
||||||
|
public class TestSceneSliderSplitting : EditorTestScene
|
||||||
|
{
|
||||||
|
protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
|
||||||
|
|
||||||
|
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
|
||||||
|
|
||||||
|
private ComposeBlueprintContainer blueprintContainer
|
||||||
|
=> Editor.ChildrenOfType<ComposeBlueprintContainer>().First();
|
||||||
|
|
||||||
|
private Slider? slider;
|
||||||
|
private PathControlPointVisualiser? visualiser;
|
||||||
|
|
||||||
|
private const double split_gap = 100;
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestBasicSplit()
|
||||||
|
{
|
||||||
|
double endTime = 0;
|
||||||
|
|
||||||
|
AddStep("add slider", () =>
|
||||||
|
{
|
||||||
|
slider = new Slider
|
||||||
|
{
|
||||||
|
Position = new Vector2(0, 50),
|
||||||
|
Path = new SliderPath(new[]
|
||||||
|
{
|
||||||
|
new PathControlPoint(Vector2.Zero, PathType.PerfectCurve),
|
||||||
|
new PathControlPoint(new Vector2(150, 150)),
|
||||||
|
new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve),
|
||||||
|
new PathControlPoint(new Vector2(400, 0)),
|
||||||
|
new PathControlPoint(new Vector2(400, 150))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorBeatmap.Add(slider);
|
||||||
|
|
||||||
|
endTime = slider.EndTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
AddStep("select added slider", () =>
|
||||||
|
{
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(slider);
|
||||||
|
visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType<PathControlPointVisualiser>().First();
|
||||||
|
});
|
||||||
|
|
||||||
|
moveMouseToControlPoint(2);
|
||||||
|
AddStep("select control point", () =>
|
||||||
|
{
|
||||||
|
if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true;
|
||||||
|
});
|
||||||
|
addContextMenuItemStep("Split control point");
|
||||||
|
|
||||||
|
AddAssert("slider split", () => slider is not null && EditorBeatmap.HitObjects.Count == 2 &&
|
||||||
|
sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, EditorBeatmap.HitObjects[1].StartTime - split_gap,
|
||||||
|
(new Vector2(0, 50), PathType.PerfectCurve),
|
||||||
|
(new Vector2(150, 200), null),
|
||||||
|
(new Vector2(300, 50), null)
|
||||||
|
) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[1], slider.StartTime, endTime + split_gap,
|
||||||
|
(new Vector2(300, 50), PathType.PerfectCurve),
|
||||||
|
(new Vector2(400, 50), null),
|
||||||
|
(new Vector2(400, 200), null)
|
||||||
|
));
|
||||||
|
|
||||||
|
AddStep("undo", () => Editor.Undo());
|
||||||
|
AddAssert("original slider restored", () => EditorBeatmap.HitObjects.Count == 1 && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, endTime,
|
||||||
|
(new Vector2(0, 50), PathType.PerfectCurve),
|
||||||
|
(new Vector2(150, 200), null),
|
||||||
|
(new Vector2(300, 50), PathType.PerfectCurve),
|
||||||
|
(new Vector2(400, 50), null),
|
||||||
|
(new Vector2(400, 200), null)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestDoubleSplit()
|
||||||
|
{
|
||||||
|
double endTime = 0;
|
||||||
|
|
||||||
|
AddStep("add slider", () =>
|
||||||
|
{
|
||||||
|
slider = new Slider
|
||||||
|
{
|
||||||
|
Position = new Vector2(0, 50),
|
||||||
|
Path = new SliderPath(new[]
|
||||||
|
{
|
||||||
|
new PathControlPoint(Vector2.Zero, PathType.PerfectCurve),
|
||||||
|
new PathControlPoint(new Vector2(150, 150)),
|
||||||
|
new PathControlPoint(new Vector2(300, 0), PathType.Bezier),
|
||||||
|
new PathControlPoint(new Vector2(400, 0)),
|
||||||
|
new PathControlPoint(new Vector2(400, 150), PathType.Catmull),
|
||||||
|
new PathControlPoint(new Vector2(300, 200)),
|
||||||
|
new PathControlPoint(new Vector2(400, 250))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorBeatmap.Add(slider);
|
||||||
|
|
||||||
|
endTime = slider.EndTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
AddStep("select added slider", () =>
|
||||||
|
{
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(slider);
|
||||||
|
visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType<PathControlPointVisualiser>().First();
|
||||||
|
});
|
||||||
|
|
||||||
|
moveMouseToControlPoint(2);
|
||||||
|
AddStep("select first control point", () =>
|
||||||
|
{
|
||||||
|
if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true;
|
||||||
|
});
|
||||||
|
moveMouseToControlPoint(4);
|
||||||
|
AddStep("select second control point", () =>
|
||||||
|
{
|
||||||
|
if (visualiser is not null) visualiser.Pieces[4].IsSelected.Value = true;
|
||||||
|
});
|
||||||
|
addContextMenuItemStep("Split 2 control points");
|
||||||
|
|
||||||
|
AddAssert("slider split", () => slider is not null && EditorBeatmap.HitObjects.Count == 3 &&
|
||||||
|
sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, EditorBeatmap.HitObjects[1].StartTime - split_gap,
|
||||||
|
(new Vector2(0, 50), PathType.PerfectCurve),
|
||||||
|
(new Vector2(150, 200), null),
|
||||||
|
(new Vector2(300, 50), null)
|
||||||
|
) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[1], EditorBeatmap.HitObjects[0].GetEndTime() + split_gap, slider.StartTime - split_gap,
|
||||||
|
(new Vector2(300, 50), PathType.Bezier),
|
||||||
|
(new Vector2(400, 50), null),
|
||||||
|
(new Vector2(400, 200), null)
|
||||||
|
) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[2], EditorBeatmap.HitObjects[1].GetEndTime() + split_gap, endTime + split_gap * 2,
|
||||||
|
(new Vector2(400, 200), PathType.Catmull),
|
||||||
|
(new Vector2(300, 250), null),
|
||||||
|
(new Vector2(400, 300), null)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestSplitRetainsHitsounds()
|
||||||
|
{
|
||||||
|
HitSampleInfo? sample = null;
|
||||||
|
|
||||||
|
AddStep("add slider", () =>
|
||||||
|
{
|
||||||
|
slider = new Slider
|
||||||
|
{
|
||||||
|
Position = new Vector2(0, 50),
|
||||||
|
LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point
|
||||||
|
Path = new SliderPath(new[]
|
||||||
|
{
|
||||||
|
new PathControlPoint(Vector2.Zero, PathType.PerfectCurve),
|
||||||
|
new PathControlPoint(new Vector2(150, 150)),
|
||||||
|
new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve),
|
||||||
|
new PathControlPoint(new Vector2(400, 0)),
|
||||||
|
new PathControlPoint(new Vector2(400, 150))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorBeatmap.Add(slider);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddStep("add hitsounds", () =>
|
||||||
|
{
|
||||||
|
if (slider is null) return;
|
||||||
|
|
||||||
|
slider.SampleControlPoint.SampleBank = "soft";
|
||||||
|
slider.SampleControlPoint.SampleVolume = 70;
|
||||||
|
sample = new HitSampleInfo("hitwhistle");
|
||||||
|
slider.Samples.Add(sample);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddStep("select added slider", () =>
|
||||||
|
{
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(slider);
|
||||||
|
visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType<PathControlPointVisualiser>().First();
|
||||||
|
});
|
||||||
|
|
||||||
|
moveMouseToControlPoint(2);
|
||||||
|
AddStep("select control point", () =>
|
||||||
|
{
|
||||||
|
if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true;
|
||||||
|
});
|
||||||
|
addContextMenuItemStep("Split control point");
|
||||||
|
AddAssert("sliders have hitsounds", hasHitsounds);
|
||||||
|
|
||||||
|
AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0]));
|
||||||
|
AddStep("remove first slider", () => EditorBeatmap.RemoveAt(0));
|
||||||
|
AddStep("undo", () => Editor.Undo());
|
||||||
|
AddAssert("sliders have hitsounds", hasHitsounds);
|
||||||
|
|
||||||
|
bool hasHitsounds() => sample is not null &&
|
||||||
|
EditorBeatmap.HitObjects.All(o => o.SampleControlPoint.SampleBank == "soft" &&
|
||||||
|
o.SampleControlPoint.SampleVolume == 70 &&
|
||||||
|
o.Samples.Contains(sample));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool sliderCreatedFor(Slider s, double startTime, double endTime, params (Vector2 pos, PathType? pathType)[] expectedControlPoints)
|
||||||
|
{
|
||||||
|
if (!Precision.AlmostEquals(s.StartTime, startTime, 1) || !Precision.AlmostEquals(s.EndTime, endTime, 1)) return false;
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
|
foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints)
|
||||||
|
{
|
||||||
|
var controlPoint = s.Path.ControlPoints[i++];
|
||||||
|
|
||||||
|
if (!Precision.AlmostEquals(controlPoint.Position + s.Position, pos) || controlPoint.Type != pathType)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void moveMouseToControlPoint(int index)
|
||||||
|
{
|
||||||
|
AddStep($"move mouse to control point {index}", () =>
|
||||||
|
{
|
||||||
|
if (slider is null || visualiser is null) return;
|
||||||
|
|
||||||
|
Vector2 position = slider.Path.ControlPoints[index].Position + slider.Position;
|
||||||
|
InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addContextMenuItemStep(string contextMenuText)
|
||||||
|
{
|
||||||
|
AddStep($"click context menu item \"{contextMenuText}\"", () =>
|
||||||
|
{
|
||||||
|
if (visualiser is null) return;
|
||||||
|
|
||||||
|
MenuItem? item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
|
||||||
|
|
||||||
|
item?.Action?.Value();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
|
|||||||
private InputManager inputManager;
|
private InputManager inputManager;
|
||||||
|
|
||||||
public Action<List<PathControlPoint>> RemoveControlPointsRequested;
|
public Action<List<PathControlPoint>> RemoveControlPointsRequested;
|
||||||
|
public Action<List<PathControlPoint>> SplitControlPointsRequested;
|
||||||
|
|
||||||
[Resolved(CanBeNull = true)]
|
[Resolved(CanBeNull = true)]
|
||||||
private IDistanceSnapProvider snapProvider { get; set; }
|
private IDistanceSnapProvider snapProvider { get; set; }
|
||||||
@ -104,6 +105,29 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool splitSelected()
|
||||||
|
{
|
||||||
|
List<PathControlPoint> controlPointsToSplitAt = Pieces.Where(p => p.IsSelected.Value && isSplittable(p)).Select(p => p.ControlPoint).ToList();
|
||||||
|
|
||||||
|
// Ensure that there are any points to be split
|
||||||
|
if (controlPointsToSplitAt.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
changeHandler?.BeginChange();
|
||||||
|
SplitControlPointsRequested?.Invoke(controlPointsToSplitAt);
|
||||||
|
changeHandler?.EndChange();
|
||||||
|
|
||||||
|
// Since pieces are re-used, they will not point to the deleted control points while remaining selected
|
||||||
|
foreach (var piece in Pieces)
|
||||||
|
piece.IsSelected.Value = false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool isSplittable(PathControlPointPiece p) =>
|
||||||
|
// A slider can only be split on control points which connect two different slider segments.
|
||||||
|
p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault();
|
||||||
|
|
||||||
private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e)
|
private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
switch (e.Action)
|
switch (e.Action)
|
||||||
@ -142,8 +166,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
|
|||||||
case NotifyCollectionChangedAction.Remove:
|
case NotifyCollectionChangedAction.Remove:
|
||||||
foreach (var point in e.OldItems.Cast<PathControlPoint>())
|
foreach (var point in e.OldItems.Cast<PathControlPoint>())
|
||||||
{
|
{
|
||||||
Pieces.RemoveAll(p => p.ControlPoint == point);
|
foreach (var piece in Pieces.Where(p => p.ControlPoint == point).ToArray())
|
||||||
Connections.RemoveAll(c => c.ControlPoint == point);
|
piece.RemoveAndDisposeImmediately();
|
||||||
|
foreach (var connection in Connections.Where(c => c.ControlPoint == point).ToArray())
|
||||||
|
connection.RemoveAndDisposeImmediately();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If removing before the end of the path,
|
// If removing before the end of the path,
|
||||||
@ -322,25 +348,42 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
|
|||||||
if (count == 0)
|
if (count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
List<MenuItem> items = new List<MenuItem>();
|
var splittablePieces = selectedPieces.Where(isSplittable).ToList();
|
||||||
|
int splittableCount = splittablePieces.Count;
|
||||||
|
|
||||||
|
List<MenuItem> curveTypeItems = new List<MenuItem>();
|
||||||
|
|
||||||
if (!selectedPieces.Contains(Pieces[0]))
|
if (!selectedPieces.Contains(Pieces[0]))
|
||||||
items.Add(createMenuItemForPathType(null));
|
curveTypeItems.Add(createMenuItemForPathType(null));
|
||||||
|
|
||||||
// todo: hide/disable items which aren't valid for selected points
|
// todo: hide/disable items which aren't valid for selected points
|
||||||
items.Add(createMenuItemForPathType(PathType.Linear));
|
curveTypeItems.Add(createMenuItemForPathType(PathType.Linear));
|
||||||
items.Add(createMenuItemForPathType(PathType.PerfectCurve));
|
curveTypeItems.Add(createMenuItemForPathType(PathType.PerfectCurve));
|
||||||
items.Add(createMenuItemForPathType(PathType.Bezier));
|
curveTypeItems.Add(createMenuItemForPathType(PathType.Bezier));
|
||||||
items.Add(createMenuItemForPathType(PathType.Catmull));
|
curveTypeItems.Add(createMenuItemForPathType(PathType.Catmull));
|
||||||
|
|
||||||
return new MenuItem[]
|
var menuItems = new List<MenuItem>
|
||||||
{
|
{
|
||||||
new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", MenuItemType.Destructive, () => DeleteSelected()),
|
|
||||||
new OsuMenuItem("Curve type")
|
new OsuMenuItem("Curve type")
|
||||||
{
|
{
|
||||||
Items = items
|
Items = curveTypeItems
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (splittableCount > 0)
|
||||||
|
{
|
||||||
|
menuItems.Add(new OsuMenuItem($"Split {"control point".ToQuantity(splittableCount, splittableCount > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}",
|
||||||
|
MenuItemType.Destructive,
|
||||||
|
() => splitSelected()));
|
||||||
|
}
|
||||||
|
|
||||||
|
menuItems.Add(
|
||||||
|
new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}",
|
||||||
|
MenuItemType.Destructive,
|
||||||
|
() => DeleteSelected())
|
||||||
|
);
|
||||||
|
|
||||||
|
return menuItems.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ using osu.Framework.Graphics.Primitives;
|
|||||||
using osu.Framework.Graphics.UserInterface;
|
using osu.Framework.Graphics.UserInterface;
|
||||||
using osu.Framework.Input.Events;
|
using osu.Framework.Input.Events;
|
||||||
using osu.Framework.Utils;
|
using osu.Framework.Utils;
|
||||||
|
using osu.Game.Audio;
|
||||||
using osu.Game.Beatmaps.ControlPoints;
|
using osu.Game.Beatmaps.ControlPoints;
|
||||||
using osu.Game.Graphics.UserInterface;
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osu.Game.Rulesets.Edit;
|
using osu.Game.Rulesets.Edit;
|
||||||
@ -111,7 +112,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
|||||||
{
|
{
|
||||||
AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true)
|
AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true)
|
||||||
{
|
{
|
||||||
RemoveControlPointsRequested = removeControlPoints
|
RemoveControlPointsRequested = removeControlPoints,
|
||||||
|
SplitControlPointsRequested = splitControlPoints
|
||||||
});
|
});
|
||||||
|
|
||||||
base.OnSelected();
|
base.OnSelected();
|
||||||
@ -249,6 +251,74 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
|||||||
HitObject.Position += first;
|
HitObject.Position += first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void splitControlPoints(List<PathControlPoint> controlPointsToSplitAt)
|
||||||
|
{
|
||||||
|
// Arbitrary gap in milliseconds to put between split slider pieces
|
||||||
|
const double split_gap = 100;
|
||||||
|
|
||||||
|
// Ensure that there are any points to be split
|
||||||
|
if (controlPointsToSplitAt.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
editorBeatmap.SelectedHitObjects.Clear();
|
||||||
|
|
||||||
|
foreach (var splitPoint in controlPointsToSplitAt)
|
||||||
|
{
|
||||||
|
if (splitPoint == controlPoints[0] || splitPoint == controlPoints[^1] || splitPoint.Type is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Split off the section of slider before this control point so the remaining control points to split are in the latter part of the slider.
|
||||||
|
int index = controlPoints.IndexOf(splitPoint);
|
||||||
|
|
||||||
|
if (index <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Extract the split portion and remove from the original slider.
|
||||||
|
var splitControlPoints = controlPoints.Take(index + 1).ToList();
|
||||||
|
controlPoints.RemoveRange(0, index);
|
||||||
|
|
||||||
|
// Turn the control points which were split off into a new slider.
|
||||||
|
var samplePoint = (SampleControlPoint)HitObject.SampleControlPoint.DeepClone();
|
||||||
|
var difficultyPoint = (DifficultyControlPoint)HitObject.DifficultyControlPoint.DeepClone();
|
||||||
|
|
||||||
|
var newSlider = new Slider
|
||||||
|
{
|
||||||
|
StartTime = HitObject.StartTime,
|
||||||
|
Position = HitObject.Position + splitControlPoints[0].Position,
|
||||||
|
NewCombo = HitObject.NewCombo,
|
||||||
|
SampleControlPoint = samplePoint,
|
||||||
|
DifficultyControlPoint = difficultyPoint,
|
||||||
|
LegacyLastTickOffset = HitObject.LegacyLastTickOffset,
|
||||||
|
Samples = HitObject.Samples.Select(s => s.With()).ToList(),
|
||||||
|
RepeatCount = HitObject.RepeatCount,
|
||||||
|
NodeSamples = HitObject.NodeSamples.Select(n => (IList<HitSampleInfo>)n.Select(s => s.With()).ToList()).ToList(),
|
||||||
|
Path = new SliderPath(splitControlPoints.Select(o => new PathControlPoint(o.Position - splitControlPoints[0].Position, o == splitControlPoints[^1] ? null : o.Type)).ToArray())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Increase the start time of the slider before adding the new slider so the new slider is immediately inserted at the correct index and internal state remains valid.
|
||||||
|
HitObject.StartTime += split_gap;
|
||||||
|
|
||||||
|
editorBeatmap.Add(newSlider);
|
||||||
|
|
||||||
|
HitObject.NewCombo = false;
|
||||||
|
HitObject.Path.ExpectedDistance.Value -= newSlider.Path.CalculatedDistance;
|
||||||
|
HitObject.StartTime += newSlider.SpanDuration;
|
||||||
|
|
||||||
|
// In case the remainder of the slider has no length left over, give it length anyways so we don't get a 0 length slider.
|
||||||
|
if (HitObject.Path.ExpectedDistance.Value <= Precision.DOUBLE_EPSILON)
|
||||||
|
{
|
||||||
|
HitObject.Path.ExpectedDistance.Value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once all required pieces have been split off, the original slider has the final split.
|
||||||
|
// As a final step, we must reset its control points to have an origin of (0,0).
|
||||||
|
Vector2 first = controlPoints[0].Position;
|
||||||
|
foreach (var c in controlPoints)
|
||||||
|
c.Position -= first;
|
||||||
|
HitObject.Position += first;
|
||||||
|
}
|
||||||
|
|
||||||
private void convertToStream()
|
private void convertToStream()
|
||||||
{
|
{
|
||||||
if (editorBeatmap == null || beatDivisor == null)
|
if (editorBeatmap == null || beatDivisor == null)
|
||||||
|
@ -371,6 +371,7 @@ namespace osu.Game.Rulesets.Osu.Edit
|
|||||||
Position = firstHitObject.Position,
|
Position = firstHitObject.Position,
|
||||||
NewCombo = firstHitObject.NewCombo,
|
NewCombo = firstHitObject.NewCombo,
|
||||||
SampleControlPoint = firstHitObject.SampleControlPoint,
|
SampleControlPoint = firstHitObject.SampleControlPoint,
|
||||||
|
Samples = firstHitObject.Samples,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mergedHitObject.Path.ControlPoints.Count == 0)
|
if (mergedHitObject.Path.ControlPoints.Count == 0)
|
||||||
|
@ -31,8 +31,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
|
|
||||||
private OsuInputManager inputManager = null!;
|
private OsuInputManager inputManager = null!;
|
||||||
|
|
||||||
private IFrameStableClock gameplayClock = null!;
|
|
||||||
|
|
||||||
private List<OsuReplayFrame> replayFrames = null!;
|
private List<OsuReplayFrame> replayFrames = null!;
|
||||||
|
|
||||||
private int currentFrame;
|
private int currentFrame;
|
||||||
@ -41,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
{
|
{
|
||||||
if (currentFrame == replayFrames.Count - 1) return;
|
if (currentFrame == replayFrames.Count - 1) return;
|
||||||
|
|
||||||
double time = gameplayClock.CurrentTime;
|
double time = playfield.Clock.CurrentTime;
|
||||||
|
|
||||||
// Very naive implementation of autopilot based on proximity to replay frames.
|
// Very naive implementation of autopilot based on proximity to replay frames.
|
||||||
// TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered).
|
// TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered).
|
||||||
@ -56,8 +54,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
|
|
||||||
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
||||||
{
|
{
|
||||||
gameplayClock = drawableRuleset.FrameStableClock;
|
|
||||||
|
|
||||||
// Grab the input manager to disable the user's cursor, and for future use
|
// Grab the input manager to disable the user's cursor, and for future use
|
||||||
inputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager;
|
inputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager;
|
||||||
inputManager.AllowUserCursorMovement = false;
|
inputManager.AllowUserCursorMovement = false;
|
||||||
|
@ -5,6 +5,7 @@ using System;
|
|||||||
using osu.Framework.Bindables;
|
using osu.Framework.Bindables;
|
||||||
using osu.Framework.Graphics.Sprites;
|
using osu.Framework.Graphics.Sprites;
|
||||||
using osu.Framework.Localisation;
|
using osu.Framework.Localisation;
|
||||||
|
using osu.Framework.Timing;
|
||||||
using osu.Framework.Utils;
|
using osu.Framework.Utils;
|
||||||
using osu.Game.Configuration;
|
using osu.Game.Configuration;
|
||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
@ -27,8 +28,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
public override double ScoreMultiplier => 0.5;
|
public override double ScoreMultiplier => 0.5;
|
||||||
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel) };
|
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel) };
|
||||||
|
|
||||||
private IFrameStableClock gameplayClock = null!;
|
|
||||||
|
|
||||||
[SettingSource("Attraction strength", "How strong the pull is.", 0)]
|
[SettingSource("Attraction strength", "How strong the pull is.", 0)]
|
||||||
public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f)
|
public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f)
|
||||||
{
|
{
|
||||||
@ -39,8 +38,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
|
|
||||||
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
||||||
{
|
{
|
||||||
gameplayClock = drawableRuleset.FrameStableClock;
|
|
||||||
|
|
||||||
// Hide judgment displays and follow points as they won't make any sense.
|
// Hide judgment displays and follow points as they won't make any sense.
|
||||||
// Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart.
|
// Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart.
|
||||||
drawableRuleset.Playfield.DisplayJudgements.Value = false;
|
drawableRuleset.Playfield.DisplayJudgements.Value = false;
|
||||||
@ -56,27 +53,27 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
switch (drawable)
|
switch (drawable)
|
||||||
{
|
{
|
||||||
case DrawableHitCircle circle:
|
case DrawableHitCircle circle:
|
||||||
easeTo(circle, cursorPos);
|
easeTo(playfield.Clock, circle, cursorPos);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case DrawableSlider slider:
|
case DrawableSlider slider:
|
||||||
|
|
||||||
if (!slider.HeadCircle.Result.HasResult)
|
if (!slider.HeadCircle.Result.HasResult)
|
||||||
easeTo(slider, cursorPos);
|
easeTo(playfield.Clock, slider, cursorPos);
|
||||||
else
|
else
|
||||||
easeTo(slider, cursorPos - slider.Ball.DrawPosition);
|
easeTo(playfield.Clock, slider, cursorPos - slider.Ball.DrawPosition);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void easeTo(DrawableHitObject hitObject, Vector2 destination)
|
private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination)
|
||||||
{
|
{
|
||||||
double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value);
|
double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value);
|
||||||
|
|
||||||
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime);
|
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, clock.ElapsedFrameTime);
|
||||||
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime);
|
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, clock.ElapsedFrameTime);
|
||||||
|
|
||||||
hitObject.Position = new Vector2(x, y);
|
hitObject.Position = new Vector2(x, y);
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
// 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.Diagnostics;
|
|
||||||
using osu.Framework.Bindables;
|
using osu.Framework.Bindables;
|
||||||
using osu.Framework.Localisation;
|
using osu.Framework.Localisation;
|
||||||
|
using osu.Framework.Timing;
|
||||||
using osu.Framework.Utils;
|
using osu.Framework.Utils;
|
||||||
using osu.Game.Configuration;
|
using osu.Game.Configuration;
|
||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
@ -27,8 +27,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
public override double ScoreMultiplier => 1;
|
public override double ScoreMultiplier => 1;
|
||||||
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModMagnetised) };
|
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModMagnetised) };
|
||||||
|
|
||||||
private IFrameStableClock? gameplayClock;
|
|
||||||
|
|
||||||
[SettingSource("Repulsion strength", "How strong the repulsion is.", 0)]
|
[SettingSource("Repulsion strength", "How strong the repulsion is.", 0)]
|
||||||
public BindableFloat RepulsionStrength { get; } = new BindableFloat(0.5f)
|
public BindableFloat RepulsionStrength { get; } = new BindableFloat(0.5f)
|
||||||
{
|
{
|
||||||
@ -39,8 +37,6 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
|
|
||||||
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
||||||
{
|
{
|
||||||
gameplayClock = drawableRuleset.FrameStableClock;
|
|
||||||
|
|
||||||
// Hide judgment displays and follow points as they won't make any sense.
|
// Hide judgment displays and follow points as they won't make any sense.
|
||||||
// Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart.
|
// Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart.
|
||||||
drawableRuleset.Playfield.DisplayJudgements.Value = false;
|
drawableRuleset.Playfield.DisplayJudgements.Value = false;
|
||||||
@ -69,29 +65,27 @@ namespace osu.Game.Rulesets.Osu.Mods
|
|||||||
switch (drawable)
|
switch (drawable)
|
||||||
{
|
{
|
||||||
case DrawableHitCircle circle:
|
case DrawableHitCircle circle:
|
||||||
easeTo(circle, destination, cursorPos);
|
easeTo(playfield.Clock, circle, destination, cursorPos);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case DrawableSlider slider:
|
case DrawableSlider slider:
|
||||||
|
|
||||||
if (!slider.HeadCircle.Result.HasResult)
|
if (!slider.HeadCircle.Result.HasResult)
|
||||||
easeTo(slider, destination, cursorPos);
|
easeTo(playfield.Clock, slider, destination, cursorPos);
|
||||||
else
|
else
|
||||||
easeTo(slider, destination - slider.Ball.DrawPosition, cursorPos);
|
easeTo(playfield.Clock, slider, destination - slider.Ball.DrawPosition, cursorPos);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void easeTo(DrawableHitObject hitObject, Vector2 destination, Vector2 cursorPos)
|
private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination, Vector2 cursorPos)
|
||||||
{
|
{
|
||||||
Debug.Assert(gameplayClock != null);
|
|
||||||
|
|
||||||
double dampLength = Vector2.Distance(hitObject.Position, cursorPos) / (0.04 * RepulsionStrength.Value + 0.04);
|
double dampLength = Vector2.Distance(hitObject.Position, cursorPos) / (0.04 * RepulsionStrength.Value + 0.04);
|
||||||
|
|
||||||
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime);
|
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, clock.ElapsedFrameTime);
|
||||||
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime);
|
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, clock.ElapsedFrameTime);
|
||||||
|
|
||||||
hitObject.Position = new Vector2(x, y);
|
hitObject.Position = new Vector2(x, y);
|
||||||
}
|
}
|
||||||
|
@ -16,13 +16,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
|
|||||||
{
|
{
|
||||||
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
|
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
|
||||||
|
|
||||||
[TestCase(1.9971301024093662d, 200, "diffcalc-test")]
|
[TestCase(3.1098944660126882d, 200, "diffcalc-test")]
|
||||||
[TestCase(1.9971301024093662d, 200, "diffcalc-test-strong")]
|
[TestCase(3.1098944660126882d, 200, "diffcalc-test-strong")]
|
||||||
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
|
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
|
||||||
=> base.Test(expectedStarRating, expectedMaxCombo, name);
|
=> base.Test(expectedStarRating, expectedMaxCombo, name);
|
||||||
|
|
||||||
[TestCase(3.1645810961313674d, 200, "diffcalc-test")]
|
[TestCase(4.0974106752474251d, 200, "diffcalc-test")]
|
||||||
[TestCase(3.1645810961313674d, 200, "diffcalc-test-strong")]
|
[TestCase(4.0974106752474251d, 200, "diffcalc-test-strong")]
|
||||||
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
|
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
|
||||||
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());
|
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());
|
||||||
|
|
||||||
|
@ -0,0 +1,67 @@
|
|||||||
|
// 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 osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||||
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||||
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
|
||||||
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
|
||||||
|
{
|
||||||
|
public class ColourEvaluator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A sigmoid function. It gives a value between (middle - height/2) and (middle + height/2).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="val">The input value.</param>
|
||||||
|
/// <param name="center">The center of the sigmoid, where the largest gradient occurs and value is equal to middle.</param>
|
||||||
|
/// <param name="width">The radius of the sigmoid, outside of which values are near the minimum/maximum.</param>
|
||||||
|
/// <param name="middle">The middle of the sigmoid output.</param>
|
||||||
|
/// <param name="height">The height of the sigmoid output. This will be equal to max value - min value.</param>
|
||||||
|
private static double sigmoid(double val, double center, double width, double middle, double height)
|
||||||
|
{
|
||||||
|
double sigmoid = Math.Tanh(Math.E * -(val - center) / width);
|
||||||
|
return sigmoid * (height / 2) + middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate the difficulty of the first note of a <see cref="MonoStreak"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static double EvaluateDifficultyOf(MonoStreak monoStreak)
|
||||||
|
{
|
||||||
|
return sigmoid(monoStreak.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate the difficulty of the first note of a <see cref="AlternatingMonoPattern"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern)
|
||||||
|
{
|
||||||
|
return sigmoid(alternatingMonoPattern.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(alternatingMonoPattern.Parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate the difficulty of the first note of a <see cref="RepeatingHitPatterns"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern)
|
||||||
|
{
|
||||||
|
return 2 * (1 - sigmoid(repeatingHitPattern.RepetitionInterval, 2, 2, 0.5, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject)
|
||||||
|
{
|
||||||
|
TaikoDifficultyHitObjectColour colour = ((TaikoDifficultyHitObject)hitObject).Colour;
|
||||||
|
double difficulty = 0.0d;
|
||||||
|
|
||||||
|
if (colour.MonoStreak != null) // Difficulty for MonoStreak
|
||||||
|
difficulty += EvaluateDifficultyOf(colour.MonoStreak);
|
||||||
|
if (colour.AlternatingMonoPattern != null) // Difficulty for AlternatingMonoPattern
|
||||||
|
difficulty += EvaluateDifficultyOf(colour.AlternatingMonoPattern);
|
||||||
|
if (colour.RepeatingHitPattern != null) // Difficulty for RepeatingHitPattern
|
||||||
|
difficulty += EvaluateDifficultyOf(colour.RepeatingHitPattern);
|
||||||
|
|
||||||
|
return difficulty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
// 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 osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||||
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||||
|
using osu.Game.Rulesets.Taiko.Objects;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
|
||||||
|
{
|
||||||
|
public class StaminaEvaluator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a speed bonus dependent on the time since the last hit performed using this key.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="interval">The interval between the current and previous note hit using the same key.</param>
|
||||||
|
private static double speedBonus(double interval)
|
||||||
|
{
|
||||||
|
// Cap to 600bpm 1/4, 25ms note interval, 50ms key interval
|
||||||
|
// Interval will be capped at a very small value to avoid infinite/negative speed bonuses.
|
||||||
|
// TODO - This is a temporary measure as we need to implement methods of detecting playstyle-abuse of SpeedBonus.
|
||||||
|
interval = Math.Max(interval, 50);
|
||||||
|
|
||||||
|
return 30 / interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates the minimum mechanical stamina required to play the current object. This is calculated using the
|
||||||
|
/// maximum possible interval between two hits using the same key, by alternating 2 keys for each colour.
|
||||||
|
/// </summary>
|
||||||
|
public static double EvaluateDifficultyOf(DifficultyHitObject current)
|
||||||
|
{
|
||||||
|
if (current.BaseObject is not Hit)
|
||||||
|
{
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the previous hit object hit by the current key, which is two notes of the same colour prior.
|
||||||
|
TaikoDifficultyHitObject taikoCurrent = (TaikoDifficultyHitObject)current;
|
||||||
|
TaikoDifficultyHitObject? keyPrevious = taikoCurrent.PreviousMono(1);
|
||||||
|
|
||||||
|
if (keyPrevious == null)
|
||||||
|
{
|
||||||
|
// There is no previous hit object hit by the current key
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double objectStrain = 0.5; // Add a base strain to all objects
|
||||||
|
objectStrain += speedBonus(taikoCurrent.StartTime - keyPrevious.StartTime);
|
||||||
|
return objectStrain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes a list of <see cref="MonoStreak"/>s.
|
||||||
|
/// <see cref="MonoStreak"/>s with the same <see cref="MonoStreak.RunLength"/> are grouped together.
|
||||||
|
/// </summary>
|
||||||
|
public class AlternatingMonoPattern
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="MonoStreak"/>s that are grouped together within this <see cref="AlternatingMonoPattern"/>.
|
||||||
|
/// </summary>
|
||||||
|
public readonly List<MonoStreak> MonoStreaks = new List<MonoStreak>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The parent <see cref="RepeatingHitPatterns"/> that contains this <see cref="AlternatingMonoPattern"/>
|
||||||
|
/// </summary>
|
||||||
|
public RepeatingHitPatterns Parent = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index of this <see cref="AlternatingMonoPattern"/> within it's parent <see cref="RepeatingHitPatterns"/>
|
||||||
|
/// </summary>
|
||||||
|
public int Index;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The first <see cref="TaikoDifficultyHitObject"/> in this <see cref="AlternatingMonoPattern"/>.
|
||||||
|
/// </summary>
|
||||||
|
public TaikoDifficultyHitObject FirstHitObject => MonoStreaks[0].FirstHitObject;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determine if this <see cref="AlternatingMonoPattern"/> is a repetition of another <see cref="AlternatingMonoPattern"/>. This
|
||||||
|
/// is a strict comparison and is true if and only if the colour sequence is exactly the same.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRepetitionOf(AlternatingMonoPattern other)
|
||||||
|
{
|
||||||
|
return HasIdenticalMonoLength(other) &&
|
||||||
|
other.MonoStreaks.Count == MonoStreaks.Count &&
|
||||||
|
other.MonoStreaks[0].HitType == MonoStreaks[0].HitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determine if this <see cref="AlternatingMonoPattern"/> has the same mono length of another <see cref="AlternatingMonoPattern"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasIdenticalMonoLength(AlternatingMonoPattern other)
|
||||||
|
{
|
||||||
|
return other.MonoStreaks[0].RunLength == MonoStreaks[0].RunLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
// 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 osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||||
|
using osu.Game.Rulesets.Taiko.Objects;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Encode colour information for a sequence of <see cref="TaikoDifficultyHitObject"/>s. Consecutive <see cref="TaikoDifficultyHitObject"/>s
|
||||||
|
/// of the same <see cref="Objects.HitType"/> are encoded within the same <see cref="MonoStreak"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class MonoStreak
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// List of <see cref="DifficultyHitObject"/>s that are encoded within this <see cref="MonoStreak"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<TaikoDifficultyHitObject> HitObjects { get; private set; } = new List<TaikoDifficultyHitObject>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The parent <see cref="AlternatingMonoPattern"/> that contains this <see cref="MonoStreak"/>
|
||||||
|
/// </summary>
|
||||||
|
public AlternatingMonoPattern Parent = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index of this <see cref="MonoStreak"/> within it's parent <see cref="AlternatingMonoPattern"/>
|
||||||
|
/// </summary>
|
||||||
|
public int Index;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The first <see cref="TaikoDifficultyHitObject"/> in this <see cref="MonoStreak"/>.
|
||||||
|
/// </summary>
|
||||||
|
public TaikoDifficultyHitObject FirstHitObject => HitObjects[0];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The hit type of all objects encoded within this <see cref="MonoStreak"/>
|
||||||
|
/// </summary>
|
||||||
|
public HitType? HitType => (HitObjects[0].BaseObject as Hit)?.Type;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the mono pattern encoded within is
|
||||||
|
/// </summary>
|
||||||
|
public int RunLength => HitObjects.Count;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes a list of <see cref="AlternatingMonoPattern"/>s, grouped together by back and forth repetition of the same
|
||||||
|
/// <see cref="AlternatingMonoPattern"/>. Also stores the repetition interval between this and the previous <see cref="RepeatingHitPatterns"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class RepeatingHitPatterns
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum amount of <see cref="RepeatingHitPatterns"/>s to look back to find a repetition.
|
||||||
|
/// </summary>
|
||||||
|
private const int max_repetition_interval = 16;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="AlternatingMonoPattern"/>s that are grouped together within this <see cref="RepeatingHitPatterns"/>.
|
||||||
|
/// </summary>
|
||||||
|
public readonly List<AlternatingMonoPattern> AlternatingMonoPatterns = new List<AlternatingMonoPattern>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The parent <see cref="TaikoDifficultyHitObject"/> in this <see cref="RepeatingHitPatterns"/>
|
||||||
|
/// </summary>
|
||||||
|
public TaikoDifficultyHitObject FirstHitObject => AlternatingMonoPatterns[0].FirstHitObject;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The previous <see cref="RepeatingHitPatterns"/>. This is used to determine the repetition interval.
|
||||||
|
/// </summary>
|
||||||
|
public readonly RepeatingHitPatterns? Previous;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many <see cref="RepeatingHitPatterns"/> between the current and previous identical <see cref="RepeatingHitPatterns"/>.
|
||||||
|
/// If no repetition is found this will have a value of <see cref="max_repetition_interval"/> + 1.
|
||||||
|
/// </summary>
|
||||||
|
public int RepetitionInterval { get; private set; } = max_repetition_interval + 1;
|
||||||
|
|
||||||
|
public RepeatingHitPatterns(RepeatingHitPatterns? previous)
|
||||||
|
{
|
||||||
|
Previous = previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if other is considered a repetition of this pattern. This is true if other's first two payloads
|
||||||
|
/// have identical mono lengths.
|
||||||
|
/// </summary>
|
||||||
|
private bool isRepetitionOf(RepeatingHitPatterns other)
|
||||||
|
{
|
||||||
|
if (AlternatingMonoPatterns.Count != other.AlternatingMonoPatterns.Count) return false;
|
||||||
|
|
||||||
|
for (int i = 0; i < Math.Min(AlternatingMonoPatterns.Count, 2); i++)
|
||||||
|
{
|
||||||
|
if (!AlternatingMonoPatterns[i].HasIdenticalMonoLength(other.AlternatingMonoPatterns[i])) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the closest previous <see cref="RepeatingHitPatterns"/> that has the identical <see cref="AlternatingMonoPatterns"/>.
|
||||||
|
/// Interval is defined as the amount of <see cref="RepeatingHitPatterns"/> chunks between the current and repeated patterns.
|
||||||
|
/// </summary>
|
||||||
|
public void FindRepetitionInterval()
|
||||||
|
{
|
||||||
|
if (Previous == null)
|
||||||
|
{
|
||||||
|
RepetitionInterval = max_repetition_interval + 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RepeatingHitPatterns? other = Previous;
|
||||||
|
int interval = 1;
|
||||||
|
|
||||||
|
while (interval < max_repetition_interval)
|
||||||
|
{
|
||||||
|
if (isRepetitionOf(other))
|
||||||
|
{
|
||||||
|
RepetitionInterval = Math.Min(interval, max_repetition_interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
other = other.Previous;
|
||||||
|
if (other == null) break;
|
||||||
|
|
||||||
|
++interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
RepetitionInterval = max_repetition_interval + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,167 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||||
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
|
||||||
|
using osu.Game.Rulesets.Taiko.Objects;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class to perform various encodings.
|
||||||
|
/// </summary>
|
||||||
|
public static class TaikoColourDifficultyPreprocessor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Processes and encodes a list of <see cref="TaikoDifficultyHitObject"/>s into a list of <see cref="TaikoDifficultyHitObjectColour"/>s,
|
||||||
|
/// assigning the appropriate <see cref="TaikoDifficultyHitObjectColour"/>s to each <see cref="TaikoDifficultyHitObject"/>,
|
||||||
|
/// and pre-evaluating colour difficulty of each <see cref="TaikoDifficultyHitObject"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static void ProcessAndAssign(List<DifficultyHitObject> hitObjects)
|
||||||
|
{
|
||||||
|
List<RepeatingHitPatterns> hitPatterns = encode(hitObjects);
|
||||||
|
|
||||||
|
// Assign indexing and encoding data to all relevant objects. Only the first note of each encoding type is
|
||||||
|
// assigned with the relevant encodings.
|
||||||
|
foreach (var repeatingHitPattern in hitPatterns)
|
||||||
|
{
|
||||||
|
repeatingHitPattern.FirstHitObject.Colour.RepeatingHitPattern = repeatingHitPattern;
|
||||||
|
|
||||||
|
// The outermost loop is kept a ForEach loop since it doesn't need index information, and we want to
|
||||||
|
// keep i and j for AlternatingMonoPattern's and MonoStreak's index respectively, to keep it in line with
|
||||||
|
// documentation.
|
||||||
|
for (int i = 0; i < repeatingHitPattern.AlternatingMonoPatterns.Count; ++i)
|
||||||
|
{
|
||||||
|
AlternatingMonoPattern monoPattern = repeatingHitPattern.AlternatingMonoPatterns[i];
|
||||||
|
monoPattern.Parent = repeatingHitPattern;
|
||||||
|
monoPattern.Index = i;
|
||||||
|
monoPattern.FirstHitObject.Colour.AlternatingMonoPattern = monoPattern;
|
||||||
|
|
||||||
|
for (int j = 0; j < monoPattern.MonoStreaks.Count; ++j)
|
||||||
|
{
|
||||||
|
MonoStreak monoStreak = monoPattern.MonoStreaks[j];
|
||||||
|
monoStreak.Parent = monoPattern;
|
||||||
|
monoStreak.Index = j;
|
||||||
|
monoStreak.FirstHitObject.Colour.MonoStreak = monoStreak;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes a list of <see cref="TaikoDifficultyHitObject"/>s into a list of <see cref="RepeatingHitPatterns"/>s.
|
||||||
|
/// </summary>
|
||||||
|
private static List<RepeatingHitPatterns> encode(List<DifficultyHitObject> data)
|
||||||
|
{
|
||||||
|
List<MonoStreak> monoStreaks = encodeMonoStreak(data);
|
||||||
|
List<AlternatingMonoPattern> alternatingMonoPatterns = encodeAlternatingMonoPattern(monoStreaks);
|
||||||
|
List<RepeatingHitPatterns> repeatingHitPatterns = encodeRepeatingHitPattern(alternatingMonoPatterns);
|
||||||
|
|
||||||
|
return repeatingHitPatterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes a list of <see cref="TaikoDifficultyHitObject"/>s into a list of <see cref="MonoStreak"/>s.
|
||||||
|
/// </summary>
|
||||||
|
private static List<MonoStreak> encodeMonoStreak(List<DifficultyHitObject> data)
|
||||||
|
{
|
||||||
|
List<MonoStreak> monoStreaks = new List<MonoStreak>();
|
||||||
|
MonoStreak? currentMonoStreak = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < data.Count; i++)
|
||||||
|
{
|
||||||
|
TaikoDifficultyHitObject taikoObject = (TaikoDifficultyHitObject)data[i];
|
||||||
|
|
||||||
|
// This ignores all non-note objects, which may or may not be the desired behaviour
|
||||||
|
TaikoDifficultyHitObject? previousObject = taikoObject.PreviousNote(0);
|
||||||
|
|
||||||
|
// If this is the first object in the list or the colour changed, create a new mono streak
|
||||||
|
if (currentMonoStreak == null || previousObject == null || (taikoObject.BaseObject as Hit)?.Type != (previousObject.BaseObject as Hit)?.Type)
|
||||||
|
{
|
||||||
|
currentMonoStreak = new MonoStreak();
|
||||||
|
monoStreaks.Add(currentMonoStreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the current object to the encoded payload.
|
||||||
|
currentMonoStreak.HitObjects.Add(taikoObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return monoStreaks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes a list of <see cref="MonoStreak"/>s into a list of <see cref="AlternatingMonoPattern"/>s.
|
||||||
|
/// </summary>
|
||||||
|
private static List<AlternatingMonoPattern> encodeAlternatingMonoPattern(List<MonoStreak> data)
|
||||||
|
{
|
||||||
|
List<AlternatingMonoPattern> monoPatterns = new List<AlternatingMonoPattern>();
|
||||||
|
AlternatingMonoPattern? currentMonoPattern = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < data.Count; i++)
|
||||||
|
{
|
||||||
|
// Start a new AlternatingMonoPattern if the previous MonoStreak has a different mono length, or if this is the first MonoStreak in the list.
|
||||||
|
if (currentMonoPattern == null || data[i].RunLength != data[i - 1].RunLength)
|
||||||
|
{
|
||||||
|
currentMonoPattern = new AlternatingMonoPattern();
|
||||||
|
monoPatterns.Add(currentMonoPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the current MonoStreak to the encoded payload.
|
||||||
|
currentMonoPattern.MonoStreaks.Add(data[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return monoPatterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes a list of <see cref="AlternatingMonoPattern"/>s into a list of <see cref="RepeatingHitPatterns"/>s.
|
||||||
|
/// </summary>
|
||||||
|
private static List<RepeatingHitPatterns> encodeRepeatingHitPattern(List<AlternatingMonoPattern> data)
|
||||||
|
{
|
||||||
|
List<RepeatingHitPatterns> hitPatterns = new List<RepeatingHitPatterns>();
|
||||||
|
RepeatingHitPatterns? currentHitPattern = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < data.Count; i++)
|
||||||
|
{
|
||||||
|
// Start a new RepeatingHitPattern. AlternatingMonoPatterns that should be grouped together will be handled later within this loop.
|
||||||
|
currentHitPattern = new RepeatingHitPatterns(currentHitPattern);
|
||||||
|
|
||||||
|
// Determine if future AlternatingMonoPatterns should be grouped.
|
||||||
|
bool isCoupled = i < data.Count - 2 && data[i].IsRepetitionOf(data[i + 2]);
|
||||||
|
|
||||||
|
if (!isCoupled)
|
||||||
|
{
|
||||||
|
// If not, add the current AlternatingMonoPattern to the encoded payload and continue.
|
||||||
|
currentHitPattern.AlternatingMonoPatterns.Add(data[i]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If so, add the current AlternatingMonoPattern to the encoded payload and start repeatedly checking if the
|
||||||
|
// subsequent AlternatingMonoPatterns should be grouped by increasing i and doing the appropriate isCoupled check.
|
||||||
|
while (isCoupled)
|
||||||
|
{
|
||||||
|
currentHitPattern.AlternatingMonoPatterns.Add(data[i]);
|
||||||
|
i++;
|
||||||
|
isCoupled = i < data.Count - 2 && data[i].IsRepetitionOf(data[i + 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip over viewed data and add the rest to the payload
|
||||||
|
currentHitPattern.AlternatingMonoPatterns.Add(data[i]);
|
||||||
|
currentHitPattern.AlternatingMonoPatterns.Add(data[i + 1]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
hitPatterns.Add(currentHitPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final pass to find repetition intervals
|
||||||
|
for (int i = 0; i < hitPatterns.Count; i++)
|
||||||
|
{
|
||||||
|
hitPatterns[i].FindRepetitionInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
return hitPatterns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
// 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 osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Stores colour compression information for a <see cref="TaikoDifficultyHitObject"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class TaikoDifficultyHitObjectColour
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="MonoStreak"/> that encodes this note, only present if this is the first note within a <see cref="MonoStreak"/>
|
||||||
|
/// </summary>
|
||||||
|
public MonoStreak? MonoStreak;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="AlternatingMonoPattern"/> that encodes this note, only present if this is the first note within a <see cref="AlternatingMonoPattern"/>
|
||||||
|
/// </summary>
|
||||||
|
public AlternatingMonoPattern? AlternatingMonoPattern;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="RepeatingHitPattern"/> that encodes this note, only present if this is the first note within a <see cref="RepeatingHitPattern"/>
|
||||||
|
/// </summary>
|
||||||
|
public RepeatingHitPatterns? RepeatingHitPattern;
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +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.
|
||||||
|
|
||||||
#nullable disable
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
|
||||||
|
|
||||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a rhythm change in a taiko map.
|
/// Represents a rhythm change in a taiko map.
|
@ -1,14 +1,14 @@
|
|||||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
// See the LICENCE file in the repository root for full licence text.
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||||
using osu.Game.Rulesets.Objects;
|
using osu.Game.Rulesets.Objects;
|
||||||
using osu.Game.Rulesets.Taiko.Objects;
|
using osu.Game.Rulesets.Taiko.Objects;
|
||||||
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
|
||||||
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm;
|
||||||
|
|
||||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
||||||
{
|
{
|
||||||
@ -17,21 +17,36 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class TaikoDifficultyHitObject : DifficultyHitObject
|
public class TaikoDifficultyHitObject : DifficultyHitObject
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The list of all <see cref="TaikoDifficultyHitObject"/> of the same colour as this <see cref="TaikoDifficultyHitObject"/> in the beatmap.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IReadOnlyList<TaikoDifficultyHitObject>? monoDifficultyHitObjects;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The index of this <see cref="TaikoDifficultyHitObject"/> in <see cref="monoDifficultyHitObjects"/>.
|
||||||
|
/// </summary>
|
||||||
|
public readonly int MonoIndex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of all <see cref="TaikoDifficultyHitObject"/> that is either a regular note or finisher in the beatmap
|
||||||
|
/// </summary>
|
||||||
|
private readonly IReadOnlyList<TaikoDifficultyHitObject> noteDifficultyHitObjects;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The index of this <see cref="TaikoDifficultyHitObject"/> in <see cref="noteDifficultyHitObjects"/>.
|
||||||
|
/// </summary>
|
||||||
|
public readonly int NoteIndex;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The rhythm required to hit this hit object.
|
/// The rhythm required to hit this hit object.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly TaikoDifficultyHitObjectRhythm Rhythm;
|
public readonly TaikoDifficultyHitObjectRhythm Rhythm;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The hit type of this hit object.
|
/// Colour data for this hit object. This is used by colour evaluator to calculate colour difficulty, but can be used
|
||||||
|
/// by other skills in the future.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly HitType? HitType;
|
public readonly TaikoDifficultyHitObjectColour Colour;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the object should carry a penalty due to being hittable using special techniques
|
|
||||||
/// making it easier to do so.
|
|
||||||
/// </summary>
|
|
||||||
public bool StaminaCheese;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new difficulty hit object.
|
/// Creates a new difficulty hit object.
|
||||||
@ -40,15 +55,44 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
|||||||
/// <param name="lastObject">The gameplay <see cref="HitObject"/> preceding <paramref name="hitObject"/>.</param>
|
/// <param name="lastObject">The gameplay <see cref="HitObject"/> preceding <paramref name="hitObject"/>.</param>
|
||||||
/// <param name="lastLastObject">The gameplay <see cref="HitObject"/> preceding <paramref name="lastObject"/>.</param>
|
/// <param name="lastLastObject">The gameplay <see cref="HitObject"/> preceding <paramref name="lastObject"/>.</param>
|
||||||
/// <param name="clockRate">The rate of the gameplay clock. Modified by speed-changing mods.</param>
|
/// <param name="clockRate">The rate of the gameplay clock. Modified by speed-changing mods.</param>
|
||||||
/// <param name="objects">The list of <see cref="DifficultyHitObject"/>s in the current beatmap.</param>
|
/// <param name="objects">The list of all <see cref="DifficultyHitObject"/>s in the current beatmap.</param>
|
||||||
/// /// <param name="index">The position of this <see cref="DifficultyHitObject"/> in the <paramref name="objects"/> list.</param>
|
/// <param name="centreHitObjects">The list of centre (don) <see cref="DifficultyHitObject"/>s in the current beatmap.</param>
|
||||||
public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, List<DifficultyHitObject> objects, int index)
|
/// <param name="rimHitObjects">The list of rim (kat) <see cref="DifficultyHitObject"/>s in the current beatmap.</param>
|
||||||
|
/// <param name="noteObjects">The list of <see cref="DifficultyHitObject"/>s that is a hit (i.e. not a drumroll or swell) in the current beatmap.</param>
|
||||||
|
/// <param name="index">The position of this <see cref="DifficultyHitObject"/> in the <paramref name="objects"/> list.</param>
|
||||||
|
public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate,
|
||||||
|
List<DifficultyHitObject> objects,
|
||||||
|
List<TaikoDifficultyHitObject> centreHitObjects,
|
||||||
|
List<TaikoDifficultyHitObject> rimHitObjects,
|
||||||
|
List<TaikoDifficultyHitObject> noteObjects, int index)
|
||||||
: base(hitObject, lastObject, clockRate, objects, index)
|
: base(hitObject, lastObject, clockRate, objects, index)
|
||||||
{
|
{
|
||||||
var currentHit = hitObject as Hit;
|
noteDifficultyHitObjects = noteObjects;
|
||||||
|
|
||||||
|
// Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor
|
||||||
|
Colour = new TaikoDifficultyHitObjectColour();
|
||||||
Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate);
|
Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate);
|
||||||
HitType = currentHit?.Type;
|
|
||||||
|
switch ((hitObject as Hit)?.Type)
|
||||||
|
{
|
||||||
|
case HitType.Centre:
|
||||||
|
MonoIndex = centreHitObjects.Count;
|
||||||
|
centreHitObjects.Add(this);
|
||||||
|
monoDifficultyHitObjects = centreHitObjects;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case HitType.Rim:
|
||||||
|
MonoIndex = rimHitObjects.Count;
|
||||||
|
rimHitObjects.Add(this);
|
||||||
|
monoDifficultyHitObjects = rimHitObjects;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hitObject is Hit)
|
||||||
|
{
|
||||||
|
NoteIndex = noteObjects.Count;
|
||||||
|
noteObjects.Add(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -87,5 +131,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
|
|||||||
|
|
||||||
return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
|
return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TaikoDifficultyHitObject? PreviousMono(int backwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex - (backwardsIndex + 1));
|
||||||
|
|
||||||
|
public TaikoDifficultyHitObject? NextMono(int forwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex + (forwardsIndex + 1));
|
||||||
|
|
||||||
|
public TaikoDifficultyHitObject? PreviousNote(int backwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex - (backwardsIndex + 1));
|
||||||
|
|
||||||
|
public TaikoDifficultyHitObject? NextNote(int forwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex + (forwardsIndex + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||||
using osu.Game.Rulesets.Difficulty.Skills;
|
using osu.Game.Rulesets.Difficulty.Skills;
|
||||||
using osu.Game.Rulesets.Difficulty.Utils;
|
|
||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
|
||||||
using osu.Game.Rulesets.Taiko.Objects;
|
|
||||||
|
|
||||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||||
{
|
{
|
||||||
@ -18,29 +13,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class Colour : StrainDecaySkill
|
public class Colour : StrainDecaySkill
|
||||||
{
|
{
|
||||||
protected override double SkillMultiplier => 1;
|
protected override double SkillMultiplier => 0.12;
|
||||||
protected override double StrainDecayBase => 0.4;
|
|
||||||
|
|
||||||
/// <summary>
|
// This is set to decay slower than other skills, due to the fact that only the first note of each encoding class
|
||||||
/// Maximum number of entries to keep in <see cref="monoHistory"/>.
|
// having any difficulty values, and we want to allow colour difficulty to be able to build up even on
|
||||||
/// </summary>
|
// slower maps.
|
||||||
private const int mono_history_max_length = 5;
|
protected override double StrainDecayBase => 0.8;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Queue with the lengths of the last <see cref="mono_history_max_length"/> most recent mono (single-colour) patterns,
|
|
||||||
/// with the most recent value at the end of the queue.
|
|
||||||
/// </summary>
|
|
||||||
private readonly LimitedCapacityQueue<int> monoHistory = new LimitedCapacityQueue<int>(mono_history_max_length);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The <see cref="HitType"/> of the last object hit before the one being considered.
|
|
||||||
/// </summary>
|
|
||||||
private HitType? previousHitType;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Length of the current mono pattern.
|
|
||||||
/// </summary>
|
|
||||||
private int currentMonoLength;
|
|
||||||
|
|
||||||
public Colour(Mod[] mods)
|
public Colour(Mod[] mods)
|
||||||
: base(mods)
|
: base(mods)
|
||||||
@ -49,95 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
|||||||
|
|
||||||
protected override double StrainValueOf(DifficultyHitObject current)
|
protected override double StrainValueOf(DifficultyHitObject current)
|
||||||
{
|
{
|
||||||
// changing from/to a drum roll or a swell does not constitute a colour change.
|
return ColourEvaluator.EvaluateDifficultyOf(current);
|
||||||
// hits spaced more than a second apart are also exempt from colour strain.
|
|
||||||
if (!(current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000))
|
|
||||||
{
|
|
||||||
monoHistory.Clear();
|
|
||||||
|
|
||||||
var currentHit = current.BaseObject as Hit;
|
|
||||||
currentMonoLength = currentHit != null ? 1 : 0;
|
|
||||||
previousHitType = currentHit?.Type;
|
|
||||||
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
var taikoCurrent = (TaikoDifficultyHitObject)current;
|
|
||||||
|
|
||||||
double objectStrain = 0.0;
|
|
||||||
|
|
||||||
if (previousHitType != null && taikoCurrent.HitType != previousHitType)
|
|
||||||
{
|
|
||||||
// The colour has changed.
|
|
||||||
objectStrain = 1.0;
|
|
||||||
|
|
||||||
if (monoHistory.Count < 2)
|
|
||||||
{
|
|
||||||
// There needs to be at least two streaks to determine a strain.
|
|
||||||
objectStrain = 0.0;
|
|
||||||
}
|
|
||||||
else if ((monoHistory[^1] + currentMonoLength) % 2 == 0)
|
|
||||||
{
|
|
||||||
// The last streak in the history is guaranteed to be a different type to the current streak.
|
|
||||||
// If the total number of notes in the two streaks is even, nullify this object's strain.
|
|
||||||
objectStrain = 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
objectStrain *= repetitionPenalties();
|
|
||||||
currentMonoLength = 1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
currentMonoLength += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
previousHitType = taikoCurrent.HitType;
|
|
||||||
return objectStrain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The penalty to apply due to the length of repetition in colour streaks.
|
|
||||||
/// </summary>
|
|
||||||
private double repetitionPenalties()
|
|
||||||
{
|
|
||||||
const int most_recent_patterns_to_compare = 2;
|
|
||||||
double penalty = 1.0;
|
|
||||||
|
|
||||||
monoHistory.Enqueue(currentMonoLength);
|
|
||||||
|
|
||||||
for (int start = monoHistory.Count - most_recent_patterns_to_compare - 1; start >= 0; start--)
|
|
||||||
{
|
|
||||||
if (!isSamePattern(start, most_recent_patterns_to_compare))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
int notesSince = 0;
|
|
||||||
for (int i = start; i < monoHistory.Count; i++) notesSince += monoHistory[i];
|
|
||||||
penalty *= repetitionPenalty(notesSince);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return penalty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether the last <paramref name="mostRecentPatternsToCompare"/> patterns have repeated in the history
|
|
||||||
/// of single-colour note sequences, starting from <paramref name="start"/>.
|
|
||||||
/// </summary>
|
|
||||||
private bool isSamePattern(int start, int mostRecentPatternsToCompare)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < mostRecentPatternsToCompare; i++)
|
|
||||||
{
|
|
||||||
if (monoHistory[start + i] != monoHistory[monoHistory.Count - mostRecentPatternsToCompare + i])
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the strain penalty for a colour pattern repetition.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="notesSince">The number of notes since the last repetition of the pattern.</param>
|
|
||||||
private double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
93
osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs
Normal file
93
osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||||
|
using osu.Game.Rulesets.Difficulty.Skills;
|
||||||
|
using osu.Game.Rulesets.Mods;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||||
|
{
|
||||||
|
public class Peaks : Skill
|
||||||
|
{
|
||||||
|
private const double rhythm_skill_multiplier = 0.2 * final_multiplier;
|
||||||
|
private const double colour_skill_multiplier = 0.375 * final_multiplier;
|
||||||
|
private const double stamina_skill_multiplier = 0.375 * final_multiplier;
|
||||||
|
|
||||||
|
private const double final_multiplier = 0.0625;
|
||||||
|
|
||||||
|
private readonly Rhythm rhythm;
|
||||||
|
private readonly Colour colour;
|
||||||
|
private readonly Stamina stamina;
|
||||||
|
|
||||||
|
public double ColourDifficultyValue => colour.DifficultyValue() * colour_skill_multiplier;
|
||||||
|
public double RhythmDifficultyValue => rhythm.DifficultyValue() * rhythm_skill_multiplier;
|
||||||
|
public double StaminaDifficultyValue => stamina.DifficultyValue() * stamina_skill_multiplier;
|
||||||
|
|
||||||
|
public Peaks(Mod[] mods)
|
||||||
|
: base(mods)
|
||||||
|
{
|
||||||
|
rhythm = new Rhythm(mods);
|
||||||
|
colour = new Colour(mods);
|
||||||
|
stamina = new Stamina(mods);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <i>p</i>-norm of an <i>n</i>-dimensional vector.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="p">The value of <i>p</i> to calculate the norm for.</param>
|
||||||
|
/// <param name="values">The coefficients of the vector.</param>
|
||||||
|
private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p);
|
||||||
|
|
||||||
|
public override void Process(DifficultyHitObject current)
|
||||||
|
{
|
||||||
|
rhythm.Process(current);
|
||||||
|
colour.Process(current);
|
||||||
|
stamina.Process(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For each section, the peak strains of all separate skills are combined into a single peak strain for the section.
|
||||||
|
/// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more).
|
||||||
|
/// </remarks>
|
||||||
|
public override double DifficultyValue()
|
||||||
|
{
|
||||||
|
List<double> peaks = new List<double>();
|
||||||
|
|
||||||
|
var colourPeaks = colour.GetCurrentStrainPeaks().ToList();
|
||||||
|
var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList();
|
||||||
|
var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList();
|
||||||
|
|
||||||
|
for (int i = 0; i < colourPeaks.Count; i++)
|
||||||
|
{
|
||||||
|
double colourPeak = colourPeaks[i] * colour_skill_multiplier;
|
||||||
|
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier;
|
||||||
|
double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier;
|
||||||
|
|
||||||
|
double peak = norm(1.5, colourPeak, staminaPeak);
|
||||||
|
peak = norm(2, peak, rhythmPeak);
|
||||||
|
|
||||||
|
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||||
|
// These sections will not contribute to the difficulty.
|
||||||
|
if (peak > 0)
|
||||||
|
peaks.Add(peak);
|
||||||
|
}
|
||||||
|
|
||||||
|
double difficulty = 0;
|
||||||
|
double weight = 1;
|
||||||
|
|
||||||
|
foreach (double strain in peaks.OrderByDescending(d => d))
|
||||||
|
{
|
||||||
|
difficulty += strain * weight;
|
||||||
|
weight *= 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
return difficulty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,44 +0,0 @@
|
|||||||
// 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.
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
|
||||||
using osu.Game.Rulesets.Difficulty.Skills;
|
|
||||||
|
|
||||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Stamina of a single key, calculated based on repetition speed.
|
|
||||||
/// </summary>
|
|
||||||
public class SingleKeyStamina
|
|
||||||
{
|
|
||||||
private double? previousHitTime;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Similar to <see cref="StrainDecaySkill.StrainValueOf"/>
|
|
||||||
/// </summary>
|
|
||||||
public double StrainValueOf(DifficultyHitObject current)
|
|
||||||
{
|
|
||||||
if (previousHitTime == null)
|
|
||||||
{
|
|
||||||
previousHitTime = current.StartTime;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
double objectStrain = 0.5;
|
|
||||||
objectStrain += speedBonus(current.StartTime - previousHitTime.Value);
|
|
||||||
previousHitTime = current.StartTime;
|
|
||||||
return objectStrain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Applies a speed bonus dependent on the time since the last hit performed using this key.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="notePairDuration">The duration between the current and previous note hit using the same key.</param>
|
|
||||||
private double speedBonus(double notePairDuration)
|
|
||||||
{
|
|
||||||
return 175 / (notePairDuration + 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,8 +6,7 @@
|
|||||||
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||||
using osu.Game.Rulesets.Difficulty.Skills;
|
using osu.Game.Rulesets.Difficulty.Skills;
|
||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
|
||||||
using osu.Game.Rulesets.Taiko.Objects;
|
|
||||||
|
|
||||||
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
||||||
{
|
{
|
||||||
@ -19,31 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class Stamina : StrainDecaySkill
|
public class Stamina : StrainDecaySkill
|
||||||
{
|
{
|
||||||
protected override double SkillMultiplier => 1;
|
protected override double SkillMultiplier => 1.1;
|
||||||
protected override double StrainDecayBase => 0.4;
|
protected override double StrainDecayBase => 0.4;
|
||||||
|
|
||||||
private readonly SingleKeyStamina[] centreKeyStamina =
|
|
||||||
{
|
|
||||||
new SingleKeyStamina(),
|
|
||||||
new SingleKeyStamina()
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly SingleKeyStamina[] rimKeyStamina =
|
|
||||||
{
|
|
||||||
new SingleKeyStamina(),
|
|
||||||
new SingleKeyStamina()
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Current index into <see cref="centreKeyStamina" /> for a centre hit.
|
|
||||||
/// </summary>
|
|
||||||
private int centreKeyIndex;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Current index into <see cref="rimKeyStamina" /> for a rim hit.
|
|
||||||
/// </summary>
|
|
||||||
private int rimKeyIndex;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a <see cref="Stamina"/> skill.
|
/// Creates a <see cref="Stamina"/> skill.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -53,32 +30,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the next <see cref="SingleKeyStamina"/> to use for the given <see cref="TaikoDifficultyHitObject"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="current">The current <see cref="TaikoDifficultyHitObject"/>.</param>
|
|
||||||
private SingleKeyStamina getNextSingleKeyStamina(TaikoDifficultyHitObject current)
|
|
||||||
{
|
|
||||||
// Alternate key for the same color.
|
|
||||||
if (current.HitType == HitType.Centre)
|
|
||||||
{
|
|
||||||
centreKeyIndex = (centreKeyIndex + 1) % 2;
|
|
||||||
return centreKeyStamina[centreKeyIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
rimKeyIndex = (rimKeyIndex + 1) % 2;
|
|
||||||
return rimKeyStamina[rimKeyIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override double StrainValueOf(DifficultyHitObject current)
|
protected override double StrainValueOf(DifficultyHitObject current)
|
||||||
{
|
{
|
||||||
if (!(current.BaseObject is Hit))
|
return StaminaEvaluator.EvaluateDifficultyOf(current);
|
||||||
{
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current;
|
|
||||||
return getNextSingleKeyStamina(hitObject).StrainValueOf(hitObject);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,13 +29,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
public double ColourDifficulty { get; set; }
|
public double ColourDifficulty { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
|
/// The difficulty corresponding to the hardest parts of the map.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
[JsonProperty("peak_difficulty")]
|
||||||
/// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing.
|
public double PeakDifficulty { get; set; }
|
||||||
/// </remarks>
|
|
||||||
[JsonProperty("approach_rate")]
|
|
||||||
public double ApproachRate { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
|
/// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
|
||||||
|
@ -13,6 +13,7 @@ using osu.Game.Rulesets.Difficulty.Skills;
|
|||||||
using osu.Game.Rulesets.Mods;
|
using osu.Game.Rulesets.Mods;
|
||||||
using osu.Game.Rulesets.Scoring;
|
using osu.Game.Rulesets.Scoring;
|
||||||
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
|
||||||
|
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
|
||||||
using osu.Game.Rulesets.Taiko.Difficulty.Skills;
|
using osu.Game.Rulesets.Taiko.Difficulty.Skills;
|
||||||
using osu.Game.Rulesets.Taiko.Mods;
|
using osu.Game.Rulesets.Taiko.Mods;
|
||||||
using osu.Game.Rulesets.Taiko.Objects;
|
using osu.Game.Rulesets.Taiko.Objects;
|
||||||
@ -22,9 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
{
|
{
|
||||||
public class TaikoDifficultyCalculator : DifficultyCalculator
|
public class TaikoDifficultyCalculator : DifficultyCalculator
|
||||||
{
|
{
|
||||||
private const double rhythm_skill_multiplier = 0.014;
|
private const double difficulty_multiplier = 1.35;
|
||||||
private const double colour_skill_multiplier = 0.01;
|
|
||||||
private const double stamina_skill_multiplier = 0.021;
|
|
||||||
|
|
||||||
public override int Version => 20220701;
|
public override int Version => 20220701;
|
||||||
|
|
||||||
@ -33,12 +32,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[]
|
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
|
||||||
{
|
{
|
||||||
new Colour(mods),
|
return new Skill[]
|
||||||
new Rhythm(mods),
|
{
|
||||||
new Stamina(mods)
|
new Peaks(mods)
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected override Mod[] DifficultyAdjustmentMods => new Mod[]
|
protected override Mod[] DifficultyAdjustmentMods => new Mod[]
|
||||||
{
|
{
|
||||||
@ -50,18 +50,23 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
|
|
||||||
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
|
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
|
||||||
{
|
{
|
||||||
List<DifficultyHitObject> taikoDifficultyHitObjects = new List<DifficultyHitObject>();
|
List<DifficultyHitObject> difficultyHitObjects = new List<DifficultyHitObject>();
|
||||||
|
List<TaikoDifficultyHitObject> centreObjects = new List<TaikoDifficultyHitObject>();
|
||||||
|
List<TaikoDifficultyHitObject> rimObjects = new List<TaikoDifficultyHitObject>();
|
||||||
|
List<TaikoDifficultyHitObject> noteObjects = new List<TaikoDifficultyHitObject>();
|
||||||
|
|
||||||
for (int i = 2; i < beatmap.HitObjects.Count; i++)
|
for (int i = 2; i < beatmap.HitObjects.Count; i++)
|
||||||
{
|
{
|
||||||
taikoDifficultyHitObjects.Add(
|
difficultyHitObjects.Add(
|
||||||
new TaikoDifficultyHitObject(
|
new TaikoDifficultyHitObject(
|
||||||
beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, taikoDifficultyHitObjects, taikoDifficultyHitObjects.Count
|
beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, difficultyHitObjects,
|
||||||
)
|
centreObjects, rimObjects, noteObjects, difficultyHitObjects.Count)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return taikoDifficultyHitObjects;
|
TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects);
|
||||||
|
|
||||||
|
return difficultyHitObjects;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
|
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
|
||||||
@ -69,28 +74,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
if (beatmap.HitObjects.Count == 0)
|
if (beatmap.HitObjects.Count == 0)
|
||||||
return new TaikoDifficultyAttributes { Mods = mods };
|
return new TaikoDifficultyAttributes { Mods = mods };
|
||||||
|
|
||||||
var colour = (Colour)skills[0];
|
var combined = (Peaks)skills[0];
|
||||||
var rhythm = (Rhythm)skills[1];
|
|
||||||
var stamina = (Stamina)skills[2];
|
|
||||||
|
|
||||||
double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
|
double colourRating = combined.ColourDifficultyValue * difficulty_multiplier;
|
||||||
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
|
double rhythmRating = combined.RhythmDifficultyValue * difficulty_multiplier;
|
||||||
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier;
|
double staminaRating = combined.StaminaDifficultyValue * difficulty_multiplier;
|
||||||
|
|
||||||
double staminaPenalty = simpleColourPenalty(staminaRating, colourRating);
|
double combinedRating = combined.DifficultyValue() * difficulty_multiplier;
|
||||||
staminaRating *= staminaPenalty;
|
double starRating = rescale(combinedRating * 1.4);
|
||||||
|
|
||||||
//TODO : This is a temporary fix for the stamina rating of converts, due to their low colour variance.
|
// TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system.
|
||||||
if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0 && colourRating < 0.05)
|
if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0)
|
||||||
{
|
{
|
||||||
staminaPenalty *= 0.25;
|
starRating *= 0.925;
|
||||||
|
// For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused.
|
||||||
|
if (colourRating < 2 && staminaRating > 8)
|
||||||
|
starRating *= 0.80;
|
||||||
}
|
}
|
||||||
|
|
||||||
double combinedRating = locallyCombinedDifficulty(colour, rhythm, stamina, staminaPenalty);
|
|
||||||
double separatedRating = norm(1.5, colourRating, rhythmRating, staminaRating);
|
|
||||||
double starRating = 1.4 * separatedRating + 0.5 * combinedRating;
|
|
||||||
starRating = rescale(starRating);
|
|
||||||
|
|
||||||
HitWindows hitWindows = new TaikoHitWindows();
|
HitWindows hitWindows = new TaikoHitWindows();
|
||||||
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
|
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
|
||||||
|
|
||||||
@ -101,75 +102,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
StaminaDifficulty = staminaRating,
|
StaminaDifficulty = staminaRating,
|
||||||
RhythmDifficulty = rhythmRating,
|
RhythmDifficulty = rhythmRating,
|
||||||
ColourDifficulty = colourRating,
|
ColourDifficulty = colourRating,
|
||||||
|
PeakDifficulty = combinedRating,
|
||||||
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
|
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
|
||||||
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
|
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates the penalty for the stamina skill for maps with low colour difficulty.
|
/// Applies a final re-scaling of the star rating.
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Some maps (especially converts) can be easy to read despite a high note density.
|
|
||||||
/// This penalty aims to reduce the star rating of such maps by factoring in colour difficulty to the stamina skill.
|
|
||||||
/// </remarks>
|
|
||||||
private double simpleColourPenalty(double staminaDifficulty, double colorDifficulty)
|
|
||||||
{
|
|
||||||
if (colorDifficulty <= 0) return 0.79 - 0.25;
|
|
||||||
|
|
||||||
return 0.79 - Math.Atan(staminaDifficulty / colorDifficulty - 12) / Math.PI / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the <i>p</i>-norm of an <i>n</i>-dimensional vector.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="p">The value of <i>p</i> to calculate the norm for.</param>
|
|
||||||
/// <param name="values">The coefficients of the vector.</param>
|
|
||||||
private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the partial star rating of the beatmap, calculated using peak strains from all sections of the map.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// For each section, the peak strains of all separate skills are combined into a single peak strain for the section.
|
|
||||||
/// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more).
|
|
||||||
/// </remarks>
|
|
||||||
private double locallyCombinedDifficulty(Colour colour, Rhythm rhythm, Stamina stamina, double staminaPenalty)
|
|
||||||
{
|
|
||||||
List<double> peaks = new List<double>();
|
|
||||||
|
|
||||||
var colourPeaks = colour.GetCurrentStrainPeaks().ToList();
|
|
||||||
var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList();
|
|
||||||
var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList();
|
|
||||||
|
|
||||||
for (int i = 0; i < colourPeaks.Count; i++)
|
|
||||||
{
|
|
||||||
double colourPeak = colourPeaks[i] * colour_skill_multiplier;
|
|
||||||
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier;
|
|
||||||
double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * staminaPenalty;
|
|
||||||
|
|
||||||
double peak = norm(2, colourPeak, rhythmPeak, staminaPeak);
|
|
||||||
|
|
||||||
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
|
||||||
// These sections will not contribute to the difficulty.
|
|
||||||
if (peak > 0)
|
|
||||||
peaks.Add(peak);
|
|
||||||
}
|
|
||||||
|
|
||||||
double difficulty = 0;
|
|
||||||
double weight = 1;
|
|
||||||
|
|
||||||
foreach (double strain in peaks.OrderByDescending(d => d))
|
|
||||||
{
|
|
||||||
difficulty += strain * weight;
|
|
||||||
weight *= 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
return difficulty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Applies a final re-scaling of the star rating to bring maps with recorded full combos below 9.5 stars.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sr">The raw star rating value before re-scaling.</param>
|
/// <param name="sr">The raw star rating value before re-scaling.</param>
|
||||||
private double rescale(double sr)
|
private double rescale(double sr)
|
||||||
|
@ -17,6 +17,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
[JsonProperty("accuracy")]
|
[JsonProperty("accuracy")]
|
||||||
public double Accuracy { get; set; }
|
public double Accuracy { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("effective_miss_count")]
|
||||||
|
public double EffectiveMissCount { get; set; }
|
||||||
|
|
||||||
public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
|
public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
|
||||||
{
|
{
|
||||||
foreach (var attribute in base.GetAttributesForDisplay())
|
foreach (var attribute in base.GetAttributesForDisplay())
|
||||||
|
@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
private int countMeh;
|
private int countMeh;
|
||||||
private int countMiss;
|
private int countMiss;
|
||||||
|
|
||||||
|
private double effectiveMissCount;
|
||||||
|
|
||||||
public TaikoPerformanceCalculator()
|
public TaikoPerformanceCalculator()
|
||||||
: base(new TaikoRuleset())
|
: base(new TaikoRuleset())
|
||||||
{
|
{
|
||||||
@ -35,7 +37,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
|
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
|
||||||
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
|
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
|
||||||
|
|
||||||
double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things
|
// The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000.
|
||||||
|
if (totalSuccessfulHits > 0)
|
||||||
|
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
|
||||||
|
|
||||||
|
double multiplier = 1.13;
|
||||||
|
|
||||||
if (score.Mods.Any(m => m is ModHidden))
|
if (score.Mods.Any(m => m is ModHidden))
|
||||||
multiplier *= 1.075;
|
multiplier *= 1.075;
|
||||||
@ -55,6 +61,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
{
|
{
|
||||||
Difficulty = difficultyValue,
|
Difficulty = difficultyValue,
|
||||||
Accuracy = accuracyValue,
|
Accuracy = accuracyValue,
|
||||||
|
EffectiveMissCount = effectiveMissCount,
|
||||||
Total = totalValue
|
Total = totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -66,18 +73,21 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
|
double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
|
||||||
difficultyValue *= lengthBonus;
|
difficultyValue *= lengthBonus;
|
||||||
|
|
||||||
difficultyValue *= Math.Pow(0.986, countMiss);
|
difficultyValue *= Math.Pow(0.986, effectiveMissCount);
|
||||||
|
|
||||||
if (score.Mods.Any(m => m is ModEasy))
|
if (score.Mods.Any(m => m is ModEasy))
|
||||||
difficultyValue *= 0.980;
|
difficultyValue *= 0.985;
|
||||||
|
|
||||||
if (score.Mods.Any(m => m is ModHidden))
|
if (score.Mods.Any(m => m is ModHidden))
|
||||||
difficultyValue *= 1.025;
|
difficultyValue *= 1.025;
|
||||||
|
|
||||||
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
|
if (score.Mods.Any(m => m is ModHardRock))
|
||||||
difficultyValue *= 1.05 * lengthBonus;
|
difficultyValue *= 1.050;
|
||||||
|
|
||||||
return difficultyValue * Math.Pow(score.Accuracy, 1.5);
|
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
|
||||||
|
difficultyValue *= 1.050 * lengthBonus;
|
||||||
|
|
||||||
|
return difficultyValue * Math.Pow(score.Accuracy, 2.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes)
|
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes)
|
||||||
@ -85,18 +95,20 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
|||||||
if (attributes.GreatHitWindow <= 0)
|
if (attributes.GreatHitWindow <= 0)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
double accuracyValue = Math.Pow(140.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 12.0) * 27;
|
double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0;
|
||||||
|
|
||||||
double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
|
double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
|
||||||
accuracyValue *= lengthBonus;
|
accuracyValue *= lengthBonus;
|
||||||
|
|
||||||
// Slight HDFL Bonus for accuracy.
|
// Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values
|
||||||
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden))
|
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden))
|
||||||
accuracyValue *= 1.10 * lengthBonus;
|
accuracyValue *= Math.Max(1.050, 1.075 * lengthBonus);
|
||||||
|
|
||||||
return accuracyValue;
|
return accuracyValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int totalHits => countGreat + countOk + countMeh + countMiss;
|
private int totalHits => countGreat + countOk + countMeh + countMiss;
|
||||||
|
|
||||||
|
private int totalSuccessfulHits => countGreat + countOk + countMeh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,12 +84,15 @@ namespace osu.Game.Tests.Gameplay
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
AddStep("reset clock", () => gameplayContainer.Start());
|
AddStep("reset clock", () => gameplayContainer.Reset(startClock: true));
|
||||||
|
|
||||||
AddUntilStep("sample played", () => sample.RequestedPlaying);
|
AddUntilStep("sample played", () => sample.RequestedPlaying);
|
||||||
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
|
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sample at 0ms, start time at 1000ms (so the sample should not be played).
|
||||||
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
public void TestSampleHasLifetimeEndWithInitialClockTime()
|
public void TestSampleHasLifetimeEndWithInitialClockTime()
|
||||||
{
|
{
|
||||||
@ -104,12 +107,13 @@ namespace osu.Game.Tests.Gameplay
|
|||||||
|
|
||||||
Add(gameplayContainer = new MasterGameplayClockContainer(working, start_time)
|
Add(gameplayContainer = new MasterGameplayClockContainer(working, start_time)
|
||||||
{
|
{
|
||||||
StartTime = start_time,
|
|
||||||
Child = new FrameStabilityContainer
|
Child = new FrameStabilityContainer
|
||||||
{
|
{
|
||||||
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1))
|
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
gameplayContainer.Reset(start_time);
|
||||||
});
|
});
|
||||||
|
|
||||||
AddStep("start time", () => gameplayContainer.Start());
|
AddStep("start time", () => gameplayContainer.Start());
|
||||||
@ -143,7 +147,7 @@ namespace osu.Game.Tests.Gameplay
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
AddStep("start", () => gameplayContainer.Start());
|
AddStep("reset clock", () => gameplayContainer.Reset(startClock: true));
|
||||||
|
|
||||||
AddUntilStep("sample played", () => sample.IsPlayed);
|
AddUntilStep("sample played", () => sample.IsPlayed);
|
||||||
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
|
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
|
||||||
|
@ -19,20 +19,20 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
public class TestSceneCatchUpSyncManager : OsuTestScene
|
public class TestSceneCatchUpSyncManager : OsuTestScene
|
||||||
{
|
{
|
||||||
private GameplayClockContainer master;
|
private GameplayClockContainer master;
|
||||||
private CatchUpSyncManager syncManager;
|
private SpectatorSyncManager syncManager;
|
||||||
|
|
||||||
private Dictionary<ISpectatorPlayerClock, int> clocksById;
|
private Dictionary<SpectatorPlayerClock, int> clocksById;
|
||||||
private ISpectatorPlayerClock player1;
|
private SpectatorPlayerClock player1;
|
||||||
private ISpectatorPlayerClock player2;
|
private SpectatorPlayerClock player2;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void Setup()
|
public void Setup()
|
||||||
{
|
{
|
||||||
syncManager = new CatchUpSyncManager(master = new GameplayClockContainer(new TestManualClock()));
|
syncManager = new SpectatorSyncManager(master = new GameplayClockContainer(new TestManualClock()));
|
||||||
player1 = syncManager.CreateManagedClock();
|
player1 = syncManager.CreateManagedClock();
|
||||||
player2 = syncManager.CreateManagedClock();
|
player2 = syncManager.CreateManagedClock();
|
||||||
|
|
||||||
clocksById = new Dictionary<ISpectatorPlayerClock, int>
|
clocksById = new Dictionary<SpectatorPlayerClock, int>
|
||||||
{
|
{
|
||||||
{ player1, 1 },
|
{ player1, 1 },
|
||||||
{ player2, 2 }
|
{ player2, 2 }
|
||||||
@ -64,7 +64,7 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
public void TestReadyPlayersStartWhenReadyForMaximumDelayTime()
|
public void TestReadyPlayersStartWhenReadyForMaximumDelayTime()
|
||||||
{
|
{
|
||||||
setWaiting(() => player1, false);
|
setWaiting(() => player1, false);
|
||||||
AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction));
|
AddWaitStep($"wait {SpectatorSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction));
|
||||||
assertPlayerClockState(() => player1, true);
|
assertPlayerClockState(() => player1, true);
|
||||||
assertPlayerClockState(() => player2, false);
|
assertPlayerClockState(() => player2, false);
|
||||||
}
|
}
|
||||||
@ -74,7 +74,7 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
{
|
{
|
||||||
setAllWaiting(false);
|
setAllWaiting(false);
|
||||||
|
|
||||||
setMasterTime(CatchUpSyncManager.SYNC_TARGET + 1);
|
setMasterTime(SpectatorSyncManager.SYNC_TARGET + 1);
|
||||||
assertCatchingUp(() => player1, false);
|
assertCatchingUp(() => player1, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
{
|
{
|
||||||
setAllWaiting(false);
|
setAllWaiting(false);
|
||||||
|
|
||||||
setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1);
|
setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 1);
|
||||||
assertCatchingUp(() => player1, true);
|
assertCatchingUp(() => player1, true);
|
||||||
assertCatchingUp(() => player2, true);
|
assertCatchingUp(() => player2, true);
|
||||||
}
|
}
|
||||||
@ -93,8 +93,8 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
{
|
{
|
||||||
setAllWaiting(false);
|
setAllWaiting(false);
|
||||||
|
|
||||||
setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1);
|
setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 1);
|
||||||
setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET + 1);
|
setPlayerClockTime(() => player1, SpectatorSyncManager.SYNC_TARGET + 1);
|
||||||
assertCatchingUp(() => player1, true);
|
assertCatchingUp(() => player1, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,8 +103,8 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
{
|
{
|
||||||
setAllWaiting(false);
|
setAllWaiting(false);
|
||||||
|
|
||||||
setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 2);
|
setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 2);
|
||||||
setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET);
|
setPlayerClockTime(() => player1, SpectatorSyncManager.SYNC_TARGET);
|
||||||
assertCatchingUp(() => player1, false);
|
assertCatchingUp(() => player1, false);
|
||||||
assertCatchingUp(() => player2, true);
|
assertCatchingUp(() => player2, true);
|
||||||
}
|
}
|
||||||
@ -114,7 +114,7 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
{
|
{
|
||||||
setAllWaiting(false);
|
setAllWaiting(false);
|
||||||
|
|
||||||
setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET);
|
setPlayerClockTime(() => player1, -SpectatorSyncManager.SYNC_TARGET);
|
||||||
assertCatchingUp(() => player1, false);
|
assertCatchingUp(() => player1, false);
|
||||||
assertPlayerClockState(() => player1, true);
|
assertPlayerClockState(() => player1, true);
|
||||||
}
|
}
|
||||||
@ -124,7 +124,7 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
{
|
{
|
||||||
setAllWaiting(false);
|
setAllWaiting(false);
|
||||||
|
|
||||||
setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET - 1);
|
setPlayerClockTime(() => player1, -SpectatorSyncManager.SYNC_TARGET - 1);
|
||||||
|
|
||||||
// This is a silent catchup, where IsCatchingUp = false but IsRunning = false also.
|
// This is a silent catchup, where IsCatchingUp = false but IsRunning = false also.
|
||||||
assertCatchingUp(() => player1, false);
|
assertCatchingUp(() => player1, false);
|
||||||
@ -145,13 +145,13 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
assertPlayerClockState(() => player1, false);
|
assertPlayerClockState(() => player1, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setWaiting(Func<ISpectatorPlayerClock> playerClock, bool waiting)
|
private void setWaiting(Func<SpectatorPlayerClock> playerClock, bool waiting)
|
||||||
=> AddStep($"set player clock {clocksById[playerClock()]} waiting = {waiting}", () => playerClock().WaitingOnFrames.Value = waiting);
|
=> AddStep($"set player clock {clocksById[playerClock()]} waiting = {waiting}", () => playerClock().WaitingOnFrames = waiting);
|
||||||
|
|
||||||
private void setAllWaiting(bool waiting) => AddStep($"set all player clocks waiting = {waiting}", () =>
|
private void setAllWaiting(bool waiting) => AddStep($"set all player clocks waiting = {waiting}", () =>
|
||||||
{
|
{
|
||||||
player1.WaitingOnFrames.Value = waiting;
|
player1.WaitingOnFrames = waiting;
|
||||||
player2.WaitingOnFrames.Value = waiting;
|
player2.WaitingOnFrames = waiting;
|
||||||
});
|
});
|
||||||
|
|
||||||
private void setMasterTime(double time)
|
private void setMasterTime(double time)
|
||||||
@ -160,13 +160,13 @@ namespace osu.Game.Tests.OnlinePlay
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// clock.Time = master.Time - offsetFromMaster
|
/// clock.Time = master.Time - offsetFromMaster
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void setPlayerClockTime(Func<ISpectatorPlayerClock> playerClock, double offsetFromMaster)
|
private void setPlayerClockTime(Func<SpectatorPlayerClock> playerClock, double offsetFromMaster)
|
||||||
=> AddStep($"set player clock {clocksById[playerClock()]} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster));
|
=> AddStep($"set player clock {clocksById[playerClock()]} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster));
|
||||||
|
|
||||||
private void assertCatchingUp(Func<ISpectatorPlayerClock> playerClock, bool catchingUp) =>
|
private void assertCatchingUp(Func<SpectatorPlayerClock> playerClock, bool catchingUp) =>
|
||||||
AddAssert($"player clock {clocksById[playerClock()]} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp);
|
AddAssert($"player clock {clocksById[playerClock()]} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp);
|
||||||
|
|
||||||
private void assertPlayerClockState(Func<ISpectatorPlayerClock> playerClock, bool running)
|
private void assertPlayerClockState(Func<SpectatorPlayerClock> playerClock, bool running)
|
||||||
=> AddAssert($"player clock {clocksById[playerClock()]} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running);
|
=> AddAssert($"player clock {clocksById[playerClock()]} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running);
|
||||||
|
|
||||||
private class TestManualClock : ManualClock, IAdjustableClock
|
private class TestManualClock : ManualClock, IAdjustableClock
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
@ -314,15 +315,55 @@ namespace osu.Game.Tests.Rulesets.Scoring
|
|||||||
}), Is.EqualTo(expectedScore).Within(0.5d));
|
}), Is.EqualTo(expectedScore).Within(0.5d));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
[Test]
|
||||||
|
public void TestLegacyComboIncrease()
|
||||||
|
{
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.IncreasesCombo(), Is.True);
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.BreaksCombo(), Is.False);
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.AffectsCombo(), Is.True);
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.AffectsAccuracy(), Is.False);
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.IsBasic(), Is.False);
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.IsTick(), Is.False);
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.IsBonus(), Is.False);
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.IsHit(), Is.True);
|
||||||
|
Assert.That(HitResult.LegacyComboIncrease.IsScorable(), Is.True);
|
||||||
|
Assert.That(HitResultExtensions.ALL_TYPES, Does.Not.Contain(HitResult.LegacyComboIncrease));
|
||||||
|
|
||||||
|
// Cannot be used to apply results.
|
||||||
|
Assert.Throws<ArgumentException>(() => scoreProcessor.ApplyBeatmap(new Beatmap
|
||||||
|
{
|
||||||
|
HitObjects = { new TestHitObject(HitResult.LegacyComboIncrease) }
|
||||||
|
}));
|
||||||
|
|
||||||
|
ScoreInfo testScore = new ScoreInfo
|
||||||
|
{
|
||||||
|
MaxCombo = 1,
|
||||||
|
Statistics = new Dictionary<HitResult, int>
|
||||||
|
{
|
||||||
|
{ HitResult.Great, 1 }
|
||||||
|
},
|
||||||
|
MaximumStatistics = new Dictionary<HitResult, int>
|
||||||
|
{
|
||||||
|
{ HitResult.Great, 1 },
|
||||||
|
{ HitResult.LegacyComboIncrease, 1 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
double totalScore = new TestScoreProcessor().ComputeFinalScore(ScoringMode.Standardised, testScore);
|
||||||
|
Assert.That(totalScore, Is.EqualTo(750_000)); // 500K from accuracy (100%), and 250K from combo (50%).
|
||||||
|
}
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
private class TestRuleset : Ruleset
|
private class TestRuleset : Ruleset
|
||||||
{
|
{
|
||||||
public override IEnumerable<Mod> GetModsFor(ModType type) => throw new System.NotImplementedException();
|
public override IEnumerable<Mod> GetModsFor(ModType type) => throw new NotImplementedException();
|
||||||
|
|
||||||
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => throw new System.NotImplementedException();
|
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => throw new NotImplementedException();
|
||||||
|
|
||||||
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException();
|
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException();
|
||||||
|
|
||||||
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new System.NotImplementedException();
|
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException();
|
||||||
|
|
||||||
public override string Description => string.Empty;
|
public override string Description => string.Empty;
|
||||||
public override string ShortName => string.Empty;
|
public override string ShortName => string.Empty;
|
||||||
@ -352,5 +393,33 @@ namespace osu.Game.Tests.Rulesets.Scoring
|
|||||||
this.maxResult = maxResult;
|
this.maxResult = maxResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class TestScoreProcessor : ScoreProcessor
|
||||||
|
{
|
||||||
|
protected override double DefaultAccuracyPortion => 0.5;
|
||||||
|
protected override double DefaultComboPortion => 0.5;
|
||||||
|
|
||||||
|
public TestScoreProcessor()
|
||||||
|
: base(new TestRuleset())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once MemberHidesStaticFromOuterClass
|
||||||
|
private class TestRuleset : Ruleset
|
||||||
|
{
|
||||||
|
protected override IEnumerable<HitResult> GetValidHitResults() => new[] { HitResult.Great };
|
||||||
|
|
||||||
|
public override IEnumerable<Mod> GetModsFor(ModType type) => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public override string Description => string.Empty;
|
||||||
|
public override string ShortName => string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
|||||||
{
|
{
|
||||||
private LeadInPlayer player = null!;
|
private LeadInPlayer player = null!;
|
||||||
|
|
||||||
private const double lenience_ms = 10;
|
private const double lenience_ms = 100;
|
||||||
|
|
||||||
private const double first_hit_object = 2170;
|
private const double first_hit_object = 2170;
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
|||||||
|
|
||||||
sendFrames(startTime: gameplay_start);
|
sendFrames(startTime: gameplay_start);
|
||||||
|
|
||||||
AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start);
|
AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -119,7 +119,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
|||||||
waitForPlayer();
|
waitForPlayer();
|
||||||
|
|
||||||
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
|
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
|
||||||
AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start);
|
AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@ -147,7 +147,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
|||||||
AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame);
|
AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame);
|
||||||
checkPaused(true);
|
checkPaused(true);
|
||||||
|
|
||||||
AddAssert("time advanced", () => currentFrameStableTime > pausedTime);
|
AddAssert("time advanced", () => currentFrameStableTime, () => Is.GreaterThan(pausedTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@ -176,7 +176,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
|||||||
|
|
||||||
sendFrames(300);
|
sendFrames(300);
|
||||||
|
|
||||||
AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime > 30000);
|
AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
@ -165,11 +165,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
sendFrames(PLAYER_1_ID, 40);
|
sendFrames(PLAYER_1_ID, 40);
|
||||||
sendFrames(PLAYER_2_ID, 20);
|
sendFrames(PLAYER_2_ID, 20);
|
||||||
|
|
||||||
checkPaused(PLAYER_2_ID, true);
|
waitUntilPaused(PLAYER_2_ID);
|
||||||
checkPausedInstant(PLAYER_1_ID, false);
|
checkRunningInstant(PLAYER_1_ID);
|
||||||
AddAssert("master clock still running", () => this.ChildrenOfType<MasterGameplayClockContainer>().Single().IsRunning);
|
AddAssert("master clock still running", () => this.ChildrenOfType<MasterGameplayClockContainer>().Single().IsRunning);
|
||||||
|
|
||||||
checkPaused(PLAYER_1_ID, true);
|
waitUntilPaused(PLAYER_1_ID);
|
||||||
AddUntilStep("master clock paused", () => !this.ChildrenOfType<MasterGameplayClockContainer>().Single().IsRunning);
|
AddUntilStep("master clock paused", () => !this.ChildrenOfType<MasterGameplayClockContainer>().Single().IsRunning);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,13 +181,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
|
|
||||||
// Send frames for one player only, both should remain paused.
|
// Send frames for one player only, both should remain paused.
|
||||||
sendFrames(PLAYER_1_ID, 20);
|
sendFrames(PLAYER_1_ID, 20);
|
||||||
checkPausedInstant(PLAYER_1_ID, true);
|
checkPausedInstant(PLAYER_1_ID);
|
||||||
checkPausedInstant(PLAYER_2_ID, true);
|
checkPausedInstant(PLAYER_2_ID);
|
||||||
|
|
||||||
// Send frames for the other player, both should now start playing.
|
// Send frames for the other player, both should now start playing.
|
||||||
sendFrames(PLAYER_2_ID, 20);
|
sendFrames(PLAYER_2_ID, 20);
|
||||||
checkPausedInstant(PLAYER_1_ID, false);
|
checkRunningInstant(PLAYER_1_ID);
|
||||||
checkPausedInstant(PLAYER_2_ID, false);
|
checkRunningInstant(PLAYER_2_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@ -198,15 +198,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
|
|
||||||
// Send frames for one player only, both should remain paused.
|
// Send frames for one player only, both should remain paused.
|
||||||
sendFrames(PLAYER_1_ID, 1000);
|
sendFrames(PLAYER_1_ID, 1000);
|
||||||
checkPausedInstant(PLAYER_1_ID, true);
|
checkPausedInstant(PLAYER_1_ID);
|
||||||
checkPausedInstant(PLAYER_2_ID, true);
|
checkPausedInstant(PLAYER_2_ID);
|
||||||
|
|
||||||
// Wait for the start delay seconds...
|
// Wait for the start delay seconds...
|
||||||
AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction));
|
AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction));
|
||||||
|
|
||||||
// Player 1 should start playing by itself, player 2 should remain paused.
|
// Player 1 should start playing by itself, player 2 should remain paused.
|
||||||
checkPausedInstant(PLAYER_1_ID, false);
|
checkRunningInstant(PLAYER_1_ID);
|
||||||
checkPausedInstant(PLAYER_2_ID, true);
|
checkPausedInstant(PLAYER_2_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@ -218,26 +218,26 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
// Send initial frames for both players. A few more for player 1.
|
// Send initial frames for both players. A few more for player 1.
|
||||||
sendFrames(PLAYER_1_ID, 20);
|
sendFrames(PLAYER_1_ID, 20);
|
||||||
sendFrames(PLAYER_2_ID);
|
sendFrames(PLAYER_2_ID);
|
||||||
checkPausedInstant(PLAYER_1_ID, false);
|
checkRunningInstant(PLAYER_1_ID);
|
||||||
checkPausedInstant(PLAYER_2_ID, false);
|
checkRunningInstant(PLAYER_2_ID);
|
||||||
|
|
||||||
// Eventually player 2 will pause, player 1 must remain running.
|
// Eventually player 2 will pause, player 1 must remain running.
|
||||||
checkPaused(PLAYER_2_ID, true);
|
waitUntilPaused(PLAYER_2_ID);
|
||||||
checkPausedInstant(PLAYER_1_ID, false);
|
checkRunningInstant(PLAYER_1_ID);
|
||||||
|
|
||||||
// Eventually both players will run out of frames and should pause.
|
// Eventually both players will run out of frames and should pause.
|
||||||
checkPaused(PLAYER_1_ID, true);
|
waitUntilPaused(PLAYER_1_ID);
|
||||||
checkPausedInstant(PLAYER_2_ID, true);
|
checkPausedInstant(PLAYER_2_ID);
|
||||||
|
|
||||||
// Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused.
|
// Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused.
|
||||||
sendFrames(PLAYER_1_ID, 20);
|
sendFrames(PLAYER_1_ID, 20);
|
||||||
checkPausedInstant(PLAYER_2_ID, true);
|
checkPausedInstant(PLAYER_2_ID);
|
||||||
checkPausedInstant(PLAYER_1_ID, false);
|
checkRunningInstant(PLAYER_1_ID);
|
||||||
|
|
||||||
// Send more frames for the second player. Both should be playing
|
// Send more frames for the second player. Both should be playing
|
||||||
sendFrames(PLAYER_2_ID, 20);
|
sendFrames(PLAYER_2_ID, 20);
|
||||||
checkPausedInstant(PLAYER_2_ID, false);
|
checkRunningInstant(PLAYER_2_ID);
|
||||||
checkPausedInstant(PLAYER_1_ID, false);
|
checkRunningInstant(PLAYER_1_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@ -249,16 +249,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
// Send initial frames for both players. A few more for player 1.
|
// Send initial frames for both players. A few more for player 1.
|
||||||
sendFrames(PLAYER_1_ID, 1000);
|
sendFrames(PLAYER_1_ID, 1000);
|
||||||
sendFrames(PLAYER_2_ID, 30);
|
sendFrames(PLAYER_2_ID, 30);
|
||||||
checkPausedInstant(PLAYER_1_ID, false);
|
checkRunningInstant(PLAYER_1_ID);
|
||||||
checkPausedInstant(PLAYER_2_ID, false);
|
checkRunningInstant(PLAYER_2_ID);
|
||||||
|
|
||||||
// Eventually player 2 will run out of frames and should pause.
|
// Eventually player 2 will run out of frames and should pause.
|
||||||
checkPaused(PLAYER_2_ID, true);
|
waitUntilPaused(PLAYER_2_ID);
|
||||||
AddWaitStep("wait a few more frames", 10);
|
AddWaitStep("wait a few more frames", 10);
|
||||||
|
|
||||||
// Send more frames for player 2. It should unpause.
|
// Send more frames for player 2. It should unpause.
|
||||||
sendFrames(PLAYER_2_ID, 1000);
|
sendFrames(PLAYER_2_ID, 1000);
|
||||||
checkPausedInstant(PLAYER_2_ID, false);
|
checkRunningInstant(PLAYER_2_ID);
|
||||||
|
|
||||||
// Player 2 should catch up to player 1 after unpausing.
|
// Player 2 should catch up to player 1 after unpausing.
|
||||||
waitForCatchup(PLAYER_2_ID);
|
waitForCatchup(PLAYER_2_ID);
|
||||||
@ -271,21 +271,28 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
|
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
|
||||||
loadSpectateScreen();
|
loadSpectateScreen();
|
||||||
|
|
||||||
|
// With no frames, the synchronisation state will be TooFarAhead.
|
||||||
|
// In this state, all players should be muted.
|
||||||
assertMuted(PLAYER_1_ID, true);
|
assertMuted(PLAYER_1_ID, true);
|
||||||
assertMuted(PLAYER_2_ID, true);
|
assertMuted(PLAYER_2_ID, true);
|
||||||
|
|
||||||
sendFrames(PLAYER_1_ID);
|
// Send frames for both players, with more frames for player 2.
|
||||||
|
sendFrames(PLAYER_1_ID, 5);
|
||||||
sendFrames(PLAYER_2_ID, 20);
|
sendFrames(PLAYER_2_ID, 20);
|
||||||
checkPaused(PLAYER_1_ID, false);
|
|
||||||
assertOneNotMuted();
|
|
||||||
|
|
||||||
checkPaused(PLAYER_1_ID, true);
|
// While both players are running, one of them should be un-muted.
|
||||||
|
waitUntilRunning(PLAYER_1_ID);
|
||||||
|
assertOnePlayerNotMuted();
|
||||||
|
|
||||||
|
// After player 1 runs out of frames, the un-muted player should always be player 2.
|
||||||
|
waitUntilPaused(PLAYER_1_ID);
|
||||||
|
waitUntilRunning(PLAYER_2_ID);
|
||||||
assertMuted(PLAYER_1_ID, true);
|
assertMuted(PLAYER_1_ID, true);
|
||||||
assertMuted(PLAYER_2_ID, false);
|
assertMuted(PLAYER_2_ID, false);
|
||||||
|
|
||||||
sendFrames(PLAYER_1_ID, 100);
|
sendFrames(PLAYER_1_ID, 100);
|
||||||
waitForCatchup(PLAYER_1_ID);
|
waitForCatchup(PLAYER_1_ID);
|
||||||
checkPaused(PLAYER_2_ID, true);
|
waitUntilPaused(PLAYER_2_ID);
|
||||||
assertMuted(PLAYER_1_ID, false);
|
assertMuted(PLAYER_1_ID, false);
|
||||||
assertMuted(PLAYER_2_ID, true);
|
assertMuted(PLAYER_2_ID, true);
|
||||||
|
|
||||||
@ -318,8 +325,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
loadSpectateScreen();
|
loadSpectateScreen();
|
||||||
sendFrames(PLAYER_1_ID, 300);
|
sendFrames(PLAYER_1_ID, 300);
|
||||||
|
|
||||||
AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction));
|
AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction));
|
||||||
checkPaused(PLAYER_1_ID, false);
|
waitUntilRunning(PLAYER_1_ID);
|
||||||
|
|
||||||
sendFrames(PLAYER_2_ID, 300);
|
sendFrames(PLAYER_2_ID, 300);
|
||||||
AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType<DrawableRuleset>().Single().FrameStableClock.CurrentTime > 30000);
|
AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType<DrawableRuleset>().Single().FrameStableClock.CurrentTime > 30000);
|
||||||
@ -357,12 +364,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests spectating with a beatmap that has a high <see cref="BeatmapInfo.AudioLeadIn"/> value.
|
/// Tests spectating with a beatmap that has a high <see cref="BeatmapInfo.AudioLeadIn"/> value.
|
||||||
|
///
|
||||||
|
/// This test is not intended not to check the correct initial time value, but only to guard against
|
||||||
|
/// gameplay potentially getting stuck in a stopped state due to lead in time being present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000);
|
public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests spectating with a beatmap that has a storyboard element with a negative start time (i.e. intro storyboard element).
|
/// Tests spectating with a beatmap that has a storyboard element with a negative start time (i.e. intro storyboard element).
|
||||||
|
///
|
||||||
|
/// This test is not intended not to check the correct initial time value, but only to guard against
|
||||||
|
/// gameplay potentially getting stuck in a stopped state due to lead in time being present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
public void TestIntroStoryboardElement() => testLeadIn(b =>
|
public void TestIntroStoryboardElement() => testLeadIn(b =>
|
||||||
@ -384,10 +397,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
|
|
||||||
AddUntilStep("wait for player load", () => spectatorScreen.AllPlayersLoaded);
|
AddUntilStep("wait for player load", () => spectatorScreen.AllPlayersLoaded);
|
||||||
|
|
||||||
AddWaitStep("wait for progression", 3);
|
AddUntilStep("wait for clock running", () => getInstance(PLAYER_1_ID).SpectatorPlayerClock.IsRunning);
|
||||||
|
|
||||||
assertNotCatchingUp(PLAYER_1_ID);
|
assertNotCatchingUp(PLAYER_1_ID);
|
||||||
assertRunning(PLAYER_1_ID);
|
waitUntilRunning(PLAYER_1_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadSpectateScreen(bool waitForPlayerLoad = true, Action<WorkingBeatmap>? applyToBeatmap = null)
|
private void loadSpectateScreen(bool waitForPlayerLoad = true, Action<WorkingBeatmap>? applyToBeatmap = null)
|
||||||
@ -439,6 +452,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send new frames on behalf of a user.
|
||||||
|
/// Frames will last for count * 100 milliseconds.
|
||||||
|
/// </summary>
|
||||||
private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count);
|
private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count);
|
||||||
|
|
||||||
private void sendFrames(int[] userIds, int count = 10)
|
private void sendFrames(int[] userIds, int count = 10)
|
||||||
@ -450,30 +467,41 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkPaused(int userId, bool state)
|
private void checkRunningInstant(int userId)
|
||||||
=> AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().IsRunning != state);
|
|
||||||
|
|
||||||
private void checkPausedInstant(int userId, bool state)
|
|
||||||
{
|
{
|
||||||
checkPaused(userId, state);
|
waitUntilRunning(userId);
|
||||||
|
|
||||||
// Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time.
|
// Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time.
|
||||||
// AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
|
// AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertOneNotMuted() => AddAssert("one player not muted", () => spectatorScreen.ChildrenOfType<PlayerArea>().Count(p => !p.Mute) == 1);
|
private void checkPausedInstant(int userId)
|
||||||
|
{
|
||||||
|
waitUntilPaused(userId);
|
||||||
|
|
||||||
|
// Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time.
|
||||||
|
// AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertOnePlayerNotMuted() => AddAssert(nameof(assertOnePlayerNotMuted), () => spectatorScreen.ChildrenOfType<PlayerArea>().Count(p => !p.Mute) == 1);
|
||||||
|
|
||||||
private void assertMuted(int userId, bool muted)
|
private void assertMuted(int userId, bool muted)
|
||||||
=> AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted);
|
=> AddAssert($"{nameof(assertMuted)}({userId}, {muted})", () => getInstance(userId).Mute == muted);
|
||||||
|
|
||||||
private void assertRunning(int userId)
|
private void assertRunning(int userId)
|
||||||
=> AddAssert($"{userId} clock running", () => getInstance(userId).GameplayClock.IsRunning);
|
=> AddAssert($"{nameof(assertRunning)}({userId})", () => getInstance(userId).SpectatorPlayerClock.IsRunning);
|
||||||
|
|
||||||
|
private void waitUntilPaused(int userId)
|
||||||
|
=> AddUntilStep($"{nameof(waitUntilPaused)}({userId})", () => !getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().IsRunning);
|
||||||
|
|
||||||
|
private void waitUntilRunning(int userId)
|
||||||
|
=> AddUntilStep($"{nameof(waitUntilRunning)}({userId})", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().IsRunning);
|
||||||
|
|
||||||
private void assertNotCatchingUp(int userId)
|
private void assertNotCatchingUp(int userId)
|
||||||
=> AddAssert($"{userId} in sync", () => !getInstance(userId).GameplayClock.IsCatchingUp);
|
=> AddAssert($"{nameof(assertNotCatchingUp)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp);
|
||||||
|
|
||||||
private void waitForCatchup(int userId)
|
private void waitForCatchup(int userId)
|
||||||
=> AddUntilStep($"{userId} not catching up", () => !getInstance(userId).GameplayClock.IsCatchingUp);
|
=> AddUntilStep($"{nameof(waitForCatchup)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp);
|
||||||
|
|
||||||
private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType<Player>().Single();
|
private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType<Player>().Single();
|
||||||
|
|
||||||
|
@ -244,8 +244,12 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
|
|
||||||
const int total_set_count = 200;
|
const int total_set_count = 200;
|
||||||
|
|
||||||
for (int i = 0; i < total_set_count; i++)
|
AddStep("Populuate beatmap sets", () =>
|
||||||
sets.Add(TestResources.CreateTestBeatmapSetInfo());
|
{
|
||||||
|
sets.Clear();
|
||||||
|
for (int i = 0; i < total_set_count; i++)
|
||||||
|
sets.Add(TestResources.CreateTestBeatmapSetInfo());
|
||||||
|
});
|
||||||
|
|
||||||
loadBeatmaps(sets);
|
loadBeatmaps(sets);
|
||||||
|
|
||||||
@ -275,8 +279,12 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
|
|
||||||
const int total_set_count = 20;
|
const int total_set_count = 20;
|
||||||
|
|
||||||
for (int i = 0; i < total_set_count; i++)
|
AddStep("Populuate beatmap sets", () =>
|
||||||
sets.Add(TestResources.CreateTestBeatmapSetInfo(3));
|
{
|
||||||
|
sets.Clear();
|
||||||
|
for (int i = 0; i < total_set_count; i++)
|
||||||
|
sets.Add(TestResources.CreateTestBeatmapSetInfo(3));
|
||||||
|
});
|
||||||
|
|
||||||
loadBeatmaps(sets);
|
loadBeatmaps(sets);
|
||||||
|
|
||||||
@ -493,18 +501,23 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
|
|
||||||
const string zzz_string = "zzzzz";
|
const string zzz_string = "zzzzz";
|
||||||
|
|
||||||
for (int i = 0; i < 20; i++)
|
AddStep("Populuate beatmap sets", () =>
|
||||||
{
|
{
|
||||||
var set = TestResources.CreateTestBeatmapSetInfo();
|
sets.Clear();
|
||||||
|
|
||||||
if (i == 4)
|
for (int i = 0; i < 20; i++)
|
||||||
set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string);
|
{
|
||||||
|
var set = TestResources.CreateTestBeatmapSetInfo();
|
||||||
|
|
||||||
if (i == 16)
|
if (i == 4)
|
||||||
set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_string);
|
set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string);
|
||||||
|
|
||||||
sets.Add(set);
|
if (i == 16)
|
||||||
}
|
set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_string);
|
||||||
|
|
||||||
|
sets.Add(set);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
loadBeatmaps(sets);
|
loadBeatmaps(sets);
|
||||||
|
|
||||||
@ -521,21 +534,27 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
public void TestSortingStability()
|
public void TestSortingStability()
|
||||||
{
|
{
|
||||||
var sets = new List<BeatmapSetInfo>();
|
var sets = new List<BeatmapSetInfo>();
|
||||||
|
int idOffset = 0;
|
||||||
|
|
||||||
for (int i = 0; i < 10; i++)
|
AddStep("Populuate beatmap sets", () =>
|
||||||
{
|
{
|
||||||
var set = TestResources.CreateTestBeatmapSetInfo();
|
sets.Clear();
|
||||||
|
|
||||||
// only need to set the first as they are a shared reference.
|
for (int i = 0; i < 10; i++)
|
||||||
var beatmap = set.Beatmaps.First();
|
{
|
||||||
|
var set = TestResources.CreateTestBeatmapSetInfo();
|
||||||
|
|
||||||
beatmap.Metadata.Artist = $"artist {i / 2}";
|
// only need to set the first as they are a shared reference.
|
||||||
beatmap.Metadata.Title = $"title {9 - i}";
|
var beatmap = set.Beatmaps.First();
|
||||||
|
|
||||||
sets.Add(set);
|
beatmap.Metadata.Artist = $"artist {i / 2}";
|
||||||
}
|
beatmap.Metadata.Title = $"title {9 - i}";
|
||||||
|
|
||||||
int idOffset = sets.First().OnlineID;
|
sets.Add(set);
|
||||||
|
}
|
||||||
|
|
||||||
|
idOffset = sets.First().OnlineID;
|
||||||
|
});
|
||||||
|
|
||||||
loadBeatmaps(sets);
|
loadBeatmaps(sets);
|
||||||
|
|
||||||
@ -556,26 +575,32 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
public void TestSortingStabilityWithNewItems()
|
public void TestSortingStabilityWithNewItems()
|
||||||
{
|
{
|
||||||
List<BeatmapSetInfo> sets = new List<BeatmapSetInfo>();
|
List<BeatmapSetInfo> sets = new List<BeatmapSetInfo>();
|
||||||
|
int idOffset = 0;
|
||||||
|
|
||||||
for (int i = 0; i < 3; i++)
|
AddStep("Populuate beatmap sets", () =>
|
||||||
{
|
{
|
||||||
var set = TestResources.CreateTestBeatmapSetInfo(3);
|
sets.Clear();
|
||||||
|
|
||||||
// only need to set the first as they are a shared reference.
|
for (int i = 0; i < 3; i++)
|
||||||
var beatmap = set.Beatmaps.First();
|
{
|
||||||
|
var set = TestResources.CreateTestBeatmapSetInfo(3);
|
||||||
|
|
||||||
beatmap.Metadata.Artist = "same artist";
|
// only need to set the first as they are a shared reference.
|
||||||
beatmap.Metadata.Title = "same title";
|
var beatmap = set.Beatmaps.First();
|
||||||
|
|
||||||
sets.Add(set);
|
beatmap.Metadata.Artist = "same artist";
|
||||||
}
|
beatmap.Metadata.Title = "same title";
|
||||||
|
|
||||||
int idOffset = sets.First().OnlineID;
|
sets.Add(set);
|
||||||
|
}
|
||||||
|
|
||||||
|
idOffset = sets.First().OnlineID;
|
||||||
|
});
|
||||||
|
|
||||||
loadBeatmaps(sets);
|
loadBeatmaps(sets);
|
||||||
|
|
||||||
AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false));
|
AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false));
|
||||||
AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b));
|
assertOriginalOrderMaintained();
|
||||||
|
|
||||||
AddStep("Add new item", () =>
|
AddStep("Add new item", () =>
|
||||||
{
|
{
|
||||||
@ -590,10 +615,16 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
carousel.UpdateBeatmapSet(set);
|
carousel.UpdateBeatmapSet(set);
|
||||||
});
|
});
|
||||||
|
|
||||||
AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b));
|
assertOriginalOrderMaintained();
|
||||||
|
|
||||||
AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false));
|
AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false));
|
||||||
AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b));
|
assertOriginalOrderMaintained();
|
||||||
|
|
||||||
|
void assertOriginalOrderMaintained()
|
||||||
|
{
|
||||||
|
AddAssert("Items remain in original order",
|
||||||
|
() => carousel.BeatmapSets.Select(s => s.OnlineID), () => Is.EqualTo(carousel.BeatmapSets.Select((set, index) => idOffset + index)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@ -601,13 +632,18 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
{
|
{
|
||||||
List<BeatmapSetInfo> sets = new List<BeatmapSetInfo>();
|
List<BeatmapSetInfo> sets = new List<BeatmapSetInfo>();
|
||||||
|
|
||||||
for (int i = 0; i < 3; i++)
|
AddStep("Populuate beatmap sets", () =>
|
||||||
{
|
{
|
||||||
var set = TestResources.CreateTestBeatmapSetInfo(3);
|
sets.Clear();
|
||||||
set.Beatmaps[0].StarRating = 3 - i;
|
|
||||||
set.Beatmaps[2].StarRating = 6 + i;
|
for (int i = 0; i < 3; i++)
|
||||||
sets.Add(set);
|
{
|
||||||
}
|
var set = TestResources.CreateTestBeatmapSetInfo(3);
|
||||||
|
set.Beatmaps[0].StarRating = 3 - i;
|
||||||
|
set.Beatmaps[2].StarRating = 6 + i;
|
||||||
|
sets.Add(set);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
loadBeatmaps(sets);
|
loadBeatmaps(sets);
|
||||||
|
|
||||||
@ -759,8 +795,13 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
{
|
{
|
||||||
List<BeatmapSetInfo> manySets = new List<BeatmapSetInfo>();
|
List<BeatmapSetInfo> manySets = new List<BeatmapSetInfo>();
|
||||||
|
|
||||||
for (int i = 1; i <= 50; i++)
|
AddStep("Populuate beatmap sets", () =>
|
||||||
manySets.Add(TestResources.CreateTestBeatmapSetInfo(3));
|
{
|
||||||
|
manySets.Clear();
|
||||||
|
|
||||||
|
for (int i = 1; i <= 50; i++)
|
||||||
|
manySets.Add(TestResources.CreateTestBeatmapSetInfo(3));
|
||||||
|
});
|
||||||
|
|
||||||
loadBeatmaps(manySets);
|
loadBeatmaps(manySets);
|
||||||
|
|
||||||
@ -791,6 +832,8 @@ namespace osu.Game.Tests.Visual.SongSelect
|
|||||||
|
|
||||||
AddStep("populate maps", () =>
|
AddStep("populate maps", () =>
|
||||||
{
|
{
|
||||||
|
manySets.Clear();
|
||||||
|
|
||||||
for (int i = 0; i < 10; i++)
|
for (int i = 0; i < 10; i++)
|
||||||
{
|
{
|
||||||
manySets.Add(TestResources.CreateTestBeatmapSetInfo(3, new[]
|
manySets.Add(TestResources.CreateTestBeatmapSetInfo(3, new[]
|
||||||
|
213
osu.Game/Beatmaps/FramedBeatmapClock.cs
Normal file
213
osu.Game/Beatmaps/FramedBeatmapClock.cs
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using osu.Framework;
|
||||||
|
using osu.Framework.Allocation;
|
||||||
|
using osu.Framework.Audio.Track;
|
||||||
|
using osu.Framework.Bindables;
|
||||||
|
using osu.Framework.Graphics;
|
||||||
|
using osu.Framework.Timing;
|
||||||
|
using osu.Game.Configuration;
|
||||||
|
using osu.Game.Database;
|
||||||
|
using osu.Game.Screens.Play;
|
||||||
|
|
||||||
|
namespace osu.Game.Beatmaps
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A clock intended to be the single source-of-truth for beatmap timing.
|
||||||
|
///
|
||||||
|
/// It provides some functionality:
|
||||||
|
/// - Optionally applies (and tracks changes of) beatmap, user, and platform offsets (see ctor argument applyOffsets).
|
||||||
|
/// - Adjusts <see cref="Seek"/> operations to account for any applied offsets, seeking in raw "beatmap" time values.
|
||||||
|
/// - Exposes track length.
|
||||||
|
/// - Allows changing the source to a new track (for cases like editor track updating).
|
||||||
|
/// </summary>
|
||||||
|
public class FramedBeatmapClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock
|
||||||
|
{
|
||||||
|
private readonly bool applyOffsets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The length of the underlying beatmap track. Will default to 60 seconds if unavailable.
|
||||||
|
/// </summary>
|
||||||
|
public double TrackLength => Track.Length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The underlying beatmap track, if available.
|
||||||
|
/// </summary>
|
||||||
|
public Track Track { get; private set; } = new TrackVirtual(60000);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The total frequency adjustment from pause transforms. Should eventually be handled in a better way.
|
||||||
|
/// </summary>
|
||||||
|
public readonly BindableDouble ExternalPauseFrequencyAdjust = new BindableDouble(1);
|
||||||
|
|
||||||
|
private readonly OffsetCorrectionClock? userGlobalOffsetClock;
|
||||||
|
private readonly OffsetCorrectionClock? platformOffsetClock;
|
||||||
|
private readonly OffsetCorrectionClock? userBeatmapOffsetClock;
|
||||||
|
|
||||||
|
private readonly IFrameBasedClock finalClockSource;
|
||||||
|
|
||||||
|
private Bindable<double>? userAudioOffset;
|
||||||
|
|
||||||
|
private IDisposable? beatmapOffsetSubscription;
|
||||||
|
|
||||||
|
private readonly DecoupleableInterpolatingFramedClock decoupledClock;
|
||||||
|
|
||||||
|
[Resolved]
|
||||||
|
private OsuConfigManager config { get; set; } = null!;
|
||||||
|
|
||||||
|
[Resolved]
|
||||||
|
private RealmAccess realm { get; set; } = null!;
|
||||||
|
|
||||||
|
[Resolved]
|
||||||
|
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
|
||||||
|
|
||||||
|
public bool IsCoupled
|
||||||
|
{
|
||||||
|
get => decoupledClock.IsCoupled;
|
||||||
|
set => decoupledClock.IsCoupled = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FramedBeatmapClock(bool applyOffsets = false)
|
||||||
|
{
|
||||||
|
this.applyOffsets = applyOffsets;
|
||||||
|
|
||||||
|
// A decoupled clock is used to ensure precise time values even when the host audio subsystem is not reporting
|
||||||
|
// high precision times (on windows there's generally only 5-10ms reporting intervals, as an example).
|
||||||
|
decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true };
|
||||||
|
|
||||||
|
if (applyOffsets)
|
||||||
|
{
|
||||||
|
// Audio timings in general with newer BASS versions don't match stable.
|
||||||
|
// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
|
||||||
|
platformOffsetClock = new OffsetCorrectionClock(decoupledClock, ExternalPauseFrequencyAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
|
||||||
|
|
||||||
|
// User global offset (set in settings) should also be applied.
|
||||||
|
userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, ExternalPauseFrequencyAdjust);
|
||||||
|
|
||||||
|
// User per-beatmap offset will be applied to this final clock.
|
||||||
|
finalClockSource = userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, ExternalPauseFrequencyAdjust);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
finalClockSource = decoupledClock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void LoadComplete()
|
||||||
|
{
|
||||||
|
base.LoadComplete();
|
||||||
|
|
||||||
|
if (applyOffsets)
|
||||||
|
{
|
||||||
|
Debug.Assert(userBeatmapOffsetClock != null);
|
||||||
|
Debug.Assert(userGlobalOffsetClock != null);
|
||||||
|
|
||||||
|
userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset);
|
||||||
|
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
|
||||||
|
|
||||||
|
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
|
||||||
|
r => r.Find<BeatmapInfo>(beatmap.Value.BeatmapInfo.ID)?.UserSettings,
|
||||||
|
settings => settings.Offset,
|
||||||
|
val =>
|
||||||
|
{
|
||||||
|
userBeatmapOffsetClock.Offset = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Update()
|
||||||
|
{
|
||||||
|
base.Update();
|
||||||
|
finalClockSource.ProcessFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double totalAppliedOffset
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!applyOffsets)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
Debug.Assert(userGlobalOffsetClock != null);
|
||||||
|
Debug.Assert(userBeatmapOffsetClock != null);
|
||||||
|
Debug.Assert(platformOffsetClock != null);
|
||||||
|
|
||||||
|
return userGlobalOffsetClock.RateAdjustedOffset + userBeatmapOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Delegation of IAdjustableClock / ISourceChangeableClock to decoupled clock.
|
||||||
|
|
||||||
|
public void ChangeSource(IClock? source)
|
||||||
|
{
|
||||||
|
Track = source as Track ?? new TrackVirtual(60000);
|
||||||
|
decoupledClock.ChangeSource(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IClock? Source => decoupledClock.Source;
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
decoupledClock.Reset();
|
||||||
|
finalClockSource.ProcessFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
decoupledClock.Start();
|
||||||
|
finalClockSource.ProcessFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
decoupledClock.Stop();
|
||||||
|
finalClockSource.ProcessFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Seek(double position)
|
||||||
|
{
|
||||||
|
bool success = decoupledClock.Seek(position - totalAppliedOffset);
|
||||||
|
finalClockSource.ProcessFrame();
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetSpeedAdjustments() => decoupledClock.ResetSpeedAdjustments();
|
||||||
|
|
||||||
|
public double Rate
|
||||||
|
{
|
||||||
|
get => decoupledClock.Rate;
|
||||||
|
set => decoupledClock.Rate = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Delegation of IFrameBasedClock to clock with all offsets applied
|
||||||
|
|
||||||
|
public double CurrentTime => finalClockSource.CurrentTime;
|
||||||
|
|
||||||
|
public bool IsRunning => finalClockSource.IsRunning;
|
||||||
|
|
||||||
|
public void ProcessFrame()
|
||||||
|
{
|
||||||
|
// Noop to ensure an external consumer doesn't process the internal clock an extra time.
|
||||||
|
}
|
||||||
|
|
||||||
|
public double ElapsedFrameTime => finalClockSource.ElapsedFrameTime;
|
||||||
|
|
||||||
|
public double FramesPerSecond => finalClockSource.FramesPerSecond;
|
||||||
|
|
||||||
|
public FrameTimeInfo TimeInfo => finalClockSource.TimeInfo;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
protected override void Dispose(bool isDisposing)
|
||||||
|
{
|
||||||
|
base.Dispose(isDisposing);
|
||||||
|
beatmapOffsetSubscription?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -69,8 +69,9 @@ namespace osu.Game.Database
|
|||||||
/// 21 2022-07-27 Migrate collections to realm (BeatmapCollection).
|
/// 21 2022-07-27 Migrate collections to realm (BeatmapCollection).
|
||||||
/// 22 2022-07-31 Added ModPreset.
|
/// 22 2022-07-31 Added ModPreset.
|
||||||
/// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo.
|
/// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo.
|
||||||
|
/// 24 2022-08-22 Added MaximumStatistics to ScoreInfo.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const int schema_version = 23;
|
private const int schema_version = 24;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
|
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
|
||||||
|
@ -74,6 +74,9 @@ namespace osu.Game.Online.API.Requests.Responses
|
|||||||
[JsonProperty("statistics")]
|
[JsonProperty("statistics")]
|
||||||
public Dictionary<HitResult, int> Statistics { get; set; } = new Dictionary<HitResult, int>();
|
public Dictionary<HitResult, int> Statistics { get; set; } = new Dictionary<HitResult, int>();
|
||||||
|
|
||||||
|
[JsonProperty("maximum_statistics")]
|
||||||
|
public Dictionary<HitResult, int> MaximumStatistics { get; set; } = new Dictionary<HitResult, int>();
|
||||||
|
|
||||||
#region osu-web API additions (not stored to database).
|
#region osu-web API additions (not stored to database).
|
||||||
|
|
||||||
[JsonProperty("id")]
|
[JsonProperty("id")]
|
||||||
@ -153,6 +156,7 @@ namespace osu.Game.Online.API.Requests.Responses
|
|||||||
MaxCombo = MaxCombo,
|
MaxCombo = MaxCombo,
|
||||||
Rank = Rank,
|
Rank = Rank,
|
||||||
Statistics = Statistics,
|
Statistics = Statistics,
|
||||||
|
MaximumStatistics = MaximumStatistics,
|
||||||
Date = EndedAt,
|
Date = EndedAt,
|
||||||
Hash = HasReplay ? "online" : string.Empty, // TODO: temporary?
|
Hash = HasReplay ? "online" : string.Empty, // TODO: temporary?
|
||||||
Mods = mods,
|
Mods = mods,
|
||||||
@ -174,6 +178,7 @@ namespace osu.Game.Online.API.Requests.Responses
|
|||||||
Passed = score.Passed,
|
Passed = score.Passed,
|
||||||
Mods = score.APIMods,
|
Mods = score.APIMods,
|
||||||
Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||||
|
MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||||
};
|
};
|
||||||
|
|
||||||
public long OnlineID => ID ?? -1;
|
public long OnlineID => ID ?? -1;
|
||||||
|
@ -1,27 +1,27 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
#nullable disable
|
using System;
|
||||||
|
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Bindables;
|
using osu.Framework.Bindables;
|
||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
|
using osu.Framework.Graphics.Sprites;
|
||||||
using osu.Framework.Platform;
|
using osu.Framework.Platform;
|
||||||
using osu.Game.Configuration;
|
using osu.Game.Configuration;
|
||||||
using osu.Game.Overlays;
|
using osu.Game.Overlays;
|
||||||
using osu.Game.Overlays.Chat;
|
using osu.Game.Overlays.Dialog;
|
||||||
|
|
||||||
namespace osu.Game.Online.Chat
|
namespace osu.Game.Online.Chat
|
||||||
{
|
{
|
||||||
public class ExternalLinkOpener : Component
|
public class ExternalLinkOpener : Component
|
||||||
{
|
{
|
||||||
[Resolved]
|
[Resolved]
|
||||||
private GameHost host { get; set; }
|
private GameHost host { get; set; } = null!;
|
||||||
|
|
||||||
[Resolved(CanBeNull = true)]
|
[Resolved(CanBeNull = true)]
|
||||||
private IDialogOverlay dialogOverlay { get; set; }
|
private IDialogOverlay? dialogOverlay { get; set; }
|
||||||
|
|
||||||
private Bindable<bool> externalLinkWarning;
|
private Bindable<bool> externalLinkWarning = null!;
|
||||||
|
|
||||||
[BackgroundDependencyLoader(true)]
|
[BackgroundDependencyLoader(true)]
|
||||||
private void load(OsuConfigManager config)
|
private void load(OsuConfigManager config)
|
||||||
@ -31,10 +31,39 @@ namespace osu.Game.Online.Chat
|
|||||||
|
|
||||||
public void OpenUrlExternally(string url, bool bypassWarning = false)
|
public void OpenUrlExternally(string url, bool bypassWarning = false)
|
||||||
{
|
{
|
||||||
if (!bypassWarning && externalLinkWarning.Value)
|
if (!bypassWarning && externalLinkWarning.Value && dialogOverlay != null)
|
||||||
dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url)));
|
dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => host.GetClipboard()?.SetText(url)));
|
||||||
else
|
else
|
||||||
host.OpenUrlExternally(url);
|
host.OpenUrlExternally(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ExternalLinkDialog : PopupDialog
|
||||||
|
{
|
||||||
|
public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copyExternalLinkAction)
|
||||||
|
{
|
||||||
|
HeaderText = "Just checking...";
|
||||||
|
BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}";
|
||||||
|
|
||||||
|
Icon = FontAwesome.Solid.ExclamationTriangle;
|
||||||
|
|
||||||
|
Buttons = new PopupDialogButton[]
|
||||||
|
{
|
||||||
|
new PopupDialogOkButton
|
||||||
|
{
|
||||||
|
Text = @"Yes. Go for it.",
|
||||||
|
Action = openExternalLinkAction
|
||||||
|
},
|
||||||
|
new PopupDialogCancelButton
|
||||||
|
{
|
||||||
|
Text = @"Copy URL to the clipboard instead.",
|
||||||
|
Action = copyExternalLinkAction
|
||||||
|
},
|
||||||
|
new PopupDialogCancelButton
|
||||||
|
{
|
||||||
|
Text = @"No! Abort mission!"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
// 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.
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using osu.Framework.Graphics.Sprites;
|
|
||||||
using osu.Game.Overlays.Dialog;
|
|
||||||
|
|
||||||
namespace osu.Game.Overlays.Chat
|
|
||||||
{
|
|
||||||
public class ExternalLinkDialog : PopupDialog
|
|
||||||
{
|
|
||||||
public ExternalLinkDialog(string url, Action openExternalLinkAction)
|
|
||||||
{
|
|
||||||
HeaderText = "Just checking...";
|
|
||||||
BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}";
|
|
||||||
|
|
||||||
Icon = FontAwesome.Solid.ExclamationTriangle;
|
|
||||||
|
|
||||||
Buttons = new PopupDialogButton[]
|
|
||||||
{
|
|
||||||
new PopupDialogOkButton
|
|
||||||
{
|
|
||||||
Text = @"Yes. Go for it.",
|
|
||||||
Action = openExternalLinkAction
|
|
||||||
},
|
|
||||||
new PopupDialogCancelButton
|
|
||||||
{
|
|
||||||
Text = @"No! Abort mission!"
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
|
|||||||
using osu.Framework.Localisation;
|
using osu.Framework.Localisation;
|
||||||
using osu.Game.Graphics;
|
using osu.Game.Graphics;
|
||||||
using osu.Game.Graphics.Sprites;
|
using osu.Game.Graphics.Sprites;
|
||||||
|
using osu.Game.Graphics.Cursor;
|
||||||
using osu.Game.Graphics.UserInterface;
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osu.Game.Online.API;
|
using osu.Game.Online.API;
|
||||||
using osu.Game.Online.API.Requests.Responses;
|
using osu.Game.Online.API.Requests.Responses;
|
||||||
@ -70,85 +71,90 @@ namespace osu.Game.Overlays.Profile.Header
|
|||||||
Masking = true,
|
Masking = true,
|
||||||
CornerRadius = avatar_size * 0.25f,
|
CornerRadius = avatar_size * 0.25f,
|
||||||
},
|
},
|
||||||
new Container
|
new OsuContextMenuContainer
|
||||||
{
|
{
|
||||||
RelativeSizeAxes = Axes.Y,
|
RelativeSizeAxes = Axes.Y,
|
||||||
AutoSizeAxes = Axes.X,
|
AutoSizeAxes = Axes.X,
|
||||||
Padding = new MarginPadding { Left = 10 },
|
Child = new Container
|
||||||
Children = new Drawable[]
|
|
||||||
{
|
{
|
||||||
new FillFlowContainer
|
RelativeSizeAxes = Axes.Y,
|
||||||
|
AutoSizeAxes = Axes.X,
|
||||||
|
Padding = new MarginPadding { Left = 10 },
|
||||||
|
Children = new Drawable[]
|
||||||
{
|
{
|
||||||
AutoSizeAxes = Axes.Both,
|
new FillFlowContainer
|
||||||
Direction = FillDirection.Vertical,
|
|
||||||
Children = new Drawable[]
|
|
||||||
{
|
{
|
||||||
new FillFlowContainer
|
AutoSizeAxes = Axes.Both,
|
||||||
|
Direction = FillDirection.Vertical,
|
||||||
|
Children = new Drawable[]
|
||||||
{
|
{
|
||||||
AutoSizeAxes = Axes.Both,
|
new FillFlowContainer
|
||||||
Direction = FillDirection.Horizontal,
|
|
||||||
Children = new Drawable[]
|
|
||||||
{
|
{
|
||||||
usernameText = new OsuSpriteText
|
AutoSizeAxes = Axes.Both,
|
||||||
|
Direction = FillDirection.Horizontal,
|
||||||
|
Children = new Drawable[]
|
||||||
{
|
{
|
||||||
Font = OsuFont.GetFont(size: 24, weight: FontWeight.Regular)
|
usernameText = new OsuSpriteText
|
||||||
},
|
{
|
||||||
openUserExternally = new ExternalLinkButton
|
Font = OsuFont.GetFont(size: 24, weight: FontWeight.Regular)
|
||||||
{
|
},
|
||||||
Margin = new MarginPadding { Left = 5 },
|
openUserExternally = new ExternalLinkButton
|
||||||
Anchor = Anchor.CentreLeft,
|
{
|
||||||
Origin = Anchor.CentreLeft,
|
Margin = new MarginPadding { Left = 5 },
|
||||||
},
|
Anchor = Anchor.CentreLeft,
|
||||||
}
|
Origin = Anchor.CentreLeft,
|
||||||
},
|
},
|
||||||
titleText = new OsuSpriteText
|
|
||||||
{
|
|
||||||
Font = OsuFont.GetFont(size: 18, weight: FontWeight.Regular)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
new FillFlowContainer
|
|
||||||
{
|
|
||||||
Origin = Anchor.BottomLeft,
|
|
||||||
Anchor = Anchor.BottomLeft,
|
|
||||||
Direction = FillDirection.Vertical,
|
|
||||||
AutoSizeAxes = Axes.Both,
|
|
||||||
Children = new Drawable[]
|
|
||||||
{
|
|
||||||
supporterTag = new SupporterIcon
|
|
||||||
{
|
|
||||||
Height = 20,
|
|
||||||
Margin = new MarginPadding { Top = 5 }
|
|
||||||
},
|
|
||||||
new Box
|
|
||||||
{
|
|
||||||
RelativeSizeAxes = Axes.X,
|
|
||||||
Height = 1.5f,
|
|
||||||
Margin = new MarginPadding { Top = 10 },
|
|
||||||
Colour = colourProvider.Light1,
|
|
||||||
},
|
|
||||||
new FillFlowContainer
|
|
||||||
{
|
|
||||||
AutoSizeAxes = Axes.Both,
|
|
||||||
Margin = new MarginPadding { Top = 5 },
|
|
||||||
Direction = FillDirection.Horizontal,
|
|
||||||
Children = new Drawable[]
|
|
||||||
{
|
|
||||||
userFlag = new UpdateableFlag
|
|
||||||
{
|
|
||||||
Size = new Vector2(28, 20),
|
|
||||||
ShowPlaceholderOnUnknown = false,
|
|
||||||
},
|
|
||||||
userCountryText = new OsuSpriteText
|
|
||||||
{
|
|
||||||
Font = OsuFont.GetFont(size: 17.5f, weight: FontWeight.Regular),
|
|
||||||
Margin = new MarginPadding { Left = 10 },
|
|
||||||
Origin = Anchor.CentreLeft,
|
|
||||||
Anchor = Anchor.CentreLeft,
|
|
||||||
Colour = colourProvider.Light1,
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
titleText = new OsuSpriteText
|
||||||
|
{
|
||||||
|
Font = OsuFont.GetFont(size: 18, weight: FontWeight.Regular)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new FillFlowContainer
|
||||||
|
{
|
||||||
|
Origin = Anchor.BottomLeft,
|
||||||
|
Anchor = Anchor.BottomLeft,
|
||||||
|
Direction = FillDirection.Vertical,
|
||||||
|
AutoSizeAxes = Axes.Both,
|
||||||
|
Children = new Drawable[]
|
||||||
|
{
|
||||||
|
supporterTag = new SupporterIcon
|
||||||
|
{
|
||||||
|
Height = 20,
|
||||||
|
Margin = new MarginPadding { Top = 5 }
|
||||||
|
},
|
||||||
|
new Box
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.X,
|
||||||
|
Height = 1.5f,
|
||||||
|
Margin = new MarginPadding { Top = 10 },
|
||||||
|
Colour = colourProvider.Light1,
|
||||||
|
},
|
||||||
|
new FillFlowContainer
|
||||||
|
{
|
||||||
|
AutoSizeAxes = Axes.Both,
|
||||||
|
Margin = new MarginPadding { Top = 5 },
|
||||||
|
Direction = FillDirection.Horizontal,
|
||||||
|
Children = new Drawable[]
|
||||||
|
{
|
||||||
|
userFlag = new UpdateableFlag
|
||||||
|
{
|
||||||
|
Size = new Vector2(28, 20),
|
||||||
|
ShowPlaceholderOnUnknown = false,
|
||||||
|
},
|
||||||
|
userCountryText = new OsuSpriteText
|
||||||
|
{
|
||||||
|
Font = OsuFont.GetFont(size: 17.5f, weight: FontWeight.Regular),
|
||||||
|
Margin = new MarginPadding { Left = 10 },
|
||||||
|
Origin = Anchor.CentreLeft,
|
||||||
|
Anchor = Anchor.CentreLeft,
|
||||||
|
Colour = colourProvider.Light1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -119,8 +119,20 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
[EnumMember(Value = "ignore_hit")]
|
[EnumMember(Value = "ignore_hit")]
|
||||||
[Order(12)]
|
[Order(12)]
|
||||||
IgnoreHit,
|
IgnoreHit,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A special result used as a padding value for legacy rulesets. It is a hit type and affects combo, but does not affect the base score (does not affect accuracy).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// DO NOT USE.
|
||||||
|
/// </remarks>
|
||||||
|
[EnumMember(Value = "legacy_combo_increase")]
|
||||||
|
[Order(99)]
|
||||||
|
[Obsolete("Do not use.")]
|
||||||
|
LegacyComboIncrease = 99
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma warning disable CS0618
|
||||||
public static class HitResultExtensions
|
public static class HitResultExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -150,6 +162,7 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
case HitResult.Perfect:
|
case HitResult.Perfect:
|
||||||
case HitResult.LargeTickHit:
|
case HitResult.LargeTickHit:
|
||||||
case HitResult.LargeTickMiss:
|
case HitResult.LargeTickMiss:
|
||||||
|
case HitResult.LegacyComboIncrease:
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -161,13 +174,25 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
/// Whether a <see cref="HitResult"/> affects the accuracy portion of the score.
|
/// Whether a <see cref="HitResult"/> affects the accuracy portion of the score.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool AffectsAccuracy(this HitResult result)
|
public static bool AffectsAccuracy(this HitResult result)
|
||||||
=> IsScorable(result) && !IsBonus(result);
|
{
|
||||||
|
// LegacyComboIncrease is a special type which is neither a basic, tick, bonus, or accuracy-affecting result.
|
||||||
|
if (result == HitResult.LegacyComboIncrease)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return IsScorable(result) && !IsBonus(result);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a <see cref="HitResult"/> is a non-tick and non-bonus result.
|
/// Whether a <see cref="HitResult"/> is a non-tick and non-bonus result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsBasic(this HitResult result)
|
public static bool IsBasic(this HitResult result)
|
||||||
=> IsScorable(result) && !IsTick(result) && !IsBonus(result);
|
{
|
||||||
|
// LegacyComboIncrease is a special type which is neither a basic, tick, bonus, or accuracy-affecting result.
|
||||||
|
if (result == HitResult.LegacyComboIncrease)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return IsScorable(result) && !IsTick(result) && !IsBonus(result);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a <see cref="HitResult"/> should be counted as a tick.
|
/// Whether a <see cref="HitResult"/> should be counted as a tick.
|
||||||
@ -225,12 +250,19 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a <see cref="HitResult"/> is scorable.
|
/// Whether a <see cref="HitResult"/> is scorable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsScorable(this HitResult result) => result >= HitResult.Miss && result < HitResult.IgnoreMiss;
|
public static bool IsScorable(this HitResult result)
|
||||||
|
{
|
||||||
|
// LegacyComboIncrease is not actually scorable (in terms of usable by rulesets for that purpose), but needs to be defined as such to be correctly included in statistics output.
|
||||||
|
if (result == HitResult.LegacyComboIncrease)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return result >= HitResult.Miss && result < HitResult.IgnoreMiss;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An array of all scorable <see cref="HitResult"/>s.
|
/// An array of all scorable <see cref="HitResult"/>s.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly HitResult[] ALL_TYPES = ((HitResult[])Enum.GetValues(typeof(HitResult))).ToArray();
|
public static readonly HitResult[] ALL_TYPES = ((HitResult[])Enum.GetValues(typeof(HitResult))).Except(new[] { HitResult.LegacyComboIncrease }).ToArray();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a <see cref="HitResult"/> is valid within a given <see cref="HitResult"/> range.
|
/// Whether a <see cref="HitResult"/> is valid within a given <see cref="HitResult"/> range.
|
||||||
@ -251,4 +283,5 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
return result > minResult && result < maxResult;
|
return result > minResult && result < maxResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#pragma warning restore CS0618
|
||||||
}
|
}
|
||||||
|
@ -61,6 +61,11 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
/// <param name="result">The <see cref="JudgementResult"/> to apply.</param>
|
/// <param name="result">The <see cref="JudgementResult"/> to apply.</param>
|
||||||
public void ApplyResult(JudgementResult result)
|
public void ApplyResult(JudgementResult result)
|
||||||
{
|
{
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
if (result.Type == HitResult.LegacyComboIncrease)
|
||||||
|
throw new ArgumentException(@$"A {nameof(HitResult.LegacyComboIncrease)} hit result cannot be applied.");
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
JudgedHits++;
|
JudgedHits++;
|
||||||
lastAppliedResult = result;
|
lastAppliedResult = result;
|
||||||
|
|
||||||
|
@ -128,8 +128,7 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
private bool beatmapApplied;
|
private bool beatmapApplied;
|
||||||
|
|
||||||
private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>();
|
private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>();
|
||||||
|
private readonly Dictionary<HitResult, int> maximumResultCounts = new Dictionary<HitResult, int>();
|
||||||
private Dictionary<HitResult, int>? maximumResultCounts;
|
|
||||||
|
|
||||||
private readonly List<HitEvent> hitEvents = new List<HitEvent>();
|
private readonly List<HitEvent> hitEvents = new List<HitEvent>();
|
||||||
private HitObject? lastHitObject;
|
private HitObject? lastHitObject;
|
||||||
@ -405,8 +404,6 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
return ScoreRank.D;
|
return ScoreRank.D;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resets this ScoreProcessor to a default state.
|
/// Resets this ScoreProcessor to a default state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -421,7 +418,9 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
if (storeResults)
|
if (storeResults)
|
||||||
{
|
{
|
||||||
maximumScoringValues = currentScoringValues;
|
maximumScoringValues = currentScoringValues;
|
||||||
maximumResultCounts = new Dictionary<HitResult, int>(scoreResultCounts);
|
|
||||||
|
maximumResultCounts.Clear();
|
||||||
|
maximumResultCounts.AddRange(scoreResultCounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
scoreResultCounts.Clear();
|
scoreResultCounts.Clear();
|
||||||
@ -449,7 +448,10 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
score.HitEvents = hitEvents;
|
score.HitEvents = hitEvents;
|
||||||
|
|
||||||
foreach (var result in HitResultExtensions.ALL_TYPES)
|
foreach (var result in HitResultExtensions.ALL_TYPES)
|
||||||
score.Statistics[result] = GetStatistic(result);
|
score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result);
|
||||||
|
|
||||||
|
foreach (var result in HitResultExtensions.ALL_TYPES)
|
||||||
|
score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result);
|
||||||
|
|
||||||
// Populate total score after everything else.
|
// Populate total score after everything else.
|
||||||
score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score));
|
score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score));
|
||||||
@ -534,6 +536,9 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
{
|
{
|
||||||
extractScoringValues(scoreInfo.Statistics, out current, out maximum);
|
extractScoringValues(scoreInfo.Statistics, out current, out maximum);
|
||||||
current.MaxCombo = scoreInfo.MaxCombo;
|
current.MaxCombo = scoreInfo.MaxCombo;
|
||||||
|
|
||||||
|
if (scoreInfo.MaximumStatistics.Count > 0)
|
||||||
|
extractScoringValues(scoreInfo.MaximumStatistics, out _, out maximum);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -589,7 +594,8 @@ namespace osu.Game.Rulesets.Scoring
|
|||||||
|
|
||||||
if (result.IsBonus())
|
if (result.IsBonus())
|
||||||
current.BonusScore += count * Judgement.ToNumericResult(result);
|
current.BonusScore += count * Judgement.ToNumericResult(result);
|
||||||
else
|
|
||||||
|
if (result.AffectsAccuracy())
|
||||||
{
|
{
|
||||||
// The maximum result of this judgement if it wasn't a miss.
|
// The maximum result of this judgement if it wasn't a miss.
|
||||||
// E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT).
|
// E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT).
|
||||||
|
@ -281,7 +281,7 @@ namespace osu.Game.Rulesets.UI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public double? StartTime => parentGameplayClock?.StartTime;
|
public double StartTime => parentGameplayClock?.StartTime ?? 0;
|
||||||
|
|
||||||
public IEnumerable<double> NonGameplayAdjustments => parentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty<double>();
|
public IEnumerable<double> NonGameplayAdjustments => parentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty<double>();
|
||||||
|
|
||||||
|
@ -73,6 +73,9 @@ namespace osu.Game.Scoring
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(model.StatisticsJson))
|
if (string.IsNullOrEmpty(model.StatisticsJson))
|
||||||
model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics);
|
model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(model.MaximumStatisticsJson))
|
||||||
|
model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void PostImport(ScoreInfo model, Realm realm, bool batchImport)
|
protected override void PostImport(ScoreInfo model, Realm realm, bool batchImport)
|
||||||
|
@ -63,6 +63,9 @@ namespace osu.Game.Scoring
|
|||||||
[MapTo("Statistics")]
|
[MapTo("Statistics")]
|
||||||
public string StatisticsJson { get; set; } = string.Empty;
|
public string StatisticsJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MapTo("MaximumStatistics")]
|
||||||
|
public string MaximumStatisticsJson { get; set; } = string.Empty;
|
||||||
|
|
||||||
public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null)
|
public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null)
|
||||||
{
|
{
|
||||||
Ruleset = ruleset ?? new RulesetInfo();
|
Ruleset = ruleset ?? new RulesetInfo();
|
||||||
@ -133,6 +136,7 @@ namespace osu.Game.Scoring
|
|||||||
var clone = (ScoreInfo)this.Detach().MemberwiseClone();
|
var clone = (ScoreInfo)this.Detach().MemberwiseClone();
|
||||||
|
|
||||||
clone.Statistics = new Dictionary<HitResult, int>(clone.Statistics);
|
clone.Statistics = new Dictionary<HitResult, int>(clone.Statistics);
|
||||||
|
clone.MaximumStatistics = new Dictionary<HitResult, int>(clone.MaximumStatistics);
|
||||||
clone.RealmUser = new RealmUser
|
clone.RealmUser = new RealmUser
|
||||||
{
|
{
|
||||||
OnlineID = RealmUser.OnlineID,
|
OnlineID = RealmUser.OnlineID,
|
||||||
@ -181,6 +185,24 @@ namespace osu.Game.Scoring
|
|||||||
set => statistics = value;
|
set => statistics = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Dictionary<HitResult, int>? maximumStatistics;
|
||||||
|
|
||||||
|
[Ignored]
|
||||||
|
public Dictionary<HitResult, int> MaximumStatistics
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (maximumStatistics != null)
|
||||||
|
return maximumStatistics;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(MaximumStatisticsJson))
|
||||||
|
maximumStatistics = JsonConvert.DeserializeObject<Dictionary<HitResult, int>>(MaximumStatisticsJson);
|
||||||
|
|
||||||
|
return maximumStatistics ??= new Dictionary<HitResult, int>();
|
||||||
|
}
|
||||||
|
set => maximumStatistics = value;
|
||||||
|
}
|
||||||
|
|
||||||
private Mod[]? mods;
|
private Mod[]? mods;
|
||||||
|
|
||||||
[Ignored]
|
[Ignored]
|
||||||
|
@ -27,7 +27,11 @@ namespace osu.Game.Screens.Edit.GameplayTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
|
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
|
||||||
=> new MasterGameplayClockContainer(beatmap, gameplayStart) { StartTime = editorState.Time };
|
{
|
||||||
|
var masterGameplayClockContainer = new MasterGameplayClockContainer(beatmap, gameplayStart);
|
||||||
|
masterGameplayClockContainer.Reset(editorState.Time);
|
||||||
|
return masterGameplayClockContainer;
|
||||||
|
}
|
||||||
|
|
||||||
protected override void LoadComplete()
|
protected override void LoadComplete()
|
||||||
{
|
{
|
||||||
|
@ -1,95 +0,0 @@
|
|||||||
// 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 osu.Framework.Bindables;
|
|
||||||
using osu.Framework.Timing;
|
|
||||||
|
|
||||||
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A <see cref="ISpectatorPlayerClock"/> which catches up using rate adjustment.
|
|
||||||
/// </summary>
|
|
||||||
public class CatchUpSpectatorPlayerClock : ISpectatorPlayerClock
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The catch up rate.
|
|
||||||
/// </summary>
|
|
||||||
public const double CATCHUP_RATE = 2;
|
|
||||||
|
|
||||||
public readonly IFrameBasedClock Source;
|
|
||||||
|
|
||||||
public double CurrentTime { get; private set; }
|
|
||||||
|
|
||||||
public bool IsRunning { get; private set; }
|
|
||||||
|
|
||||||
public CatchUpSpectatorPlayerClock(IFrameBasedClock source)
|
|
||||||
{
|
|
||||||
Source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Reset() => CurrentTime = 0;
|
|
||||||
|
|
||||||
public void Start() => IsRunning = true;
|
|
||||||
|
|
||||||
public void Stop() => IsRunning = false;
|
|
||||||
|
|
||||||
void IAdjustableClock.Start()
|
|
||||||
{
|
|
||||||
// Our running state should only be managed by an ISyncManager, ignore calls from external sources.
|
|
||||||
}
|
|
||||||
|
|
||||||
void IAdjustableClock.Stop()
|
|
||||||
{
|
|
||||||
// Our running state should only be managed by an ISyncManager, ignore calls from external sources.
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Seek(double position)
|
|
||||||
{
|
|
||||||
CurrentTime = position;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ResetSpeedAdjustments()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public double Rate => IsCatchingUp ? CATCHUP_RATE : 1;
|
|
||||||
|
|
||||||
double IAdjustableClock.Rate
|
|
||||||
{
|
|
||||||
get => Rate;
|
|
||||||
set => throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
double IClock.Rate => Rate;
|
|
||||||
|
|
||||||
public void ProcessFrame()
|
|
||||||
{
|
|
||||||
ElapsedFrameTime = 0;
|
|
||||||
FramesPerSecond = 0;
|
|
||||||
|
|
||||||
Source.ProcessFrame();
|
|
||||||
|
|
||||||
if (IsRunning)
|
|
||||||
{
|
|
||||||
double elapsedSource = Source.ElapsedFrameTime;
|
|
||||||
double elapsed = elapsedSource * Rate;
|
|
||||||
|
|
||||||
CurrentTime += elapsed;
|
|
||||||
ElapsedFrameTime = elapsed;
|
|
||||||
FramesPerSecond = Source.FramesPerSecond;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public double ElapsedFrameTime { get; private set; }
|
|
||||||
|
|
||||||
public double FramesPerSecond { get; private set; }
|
|
||||||
|
|
||||||
public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime };
|
|
||||||
|
|
||||||
public Bindable<bool> WaitingOnFrames { get; } = new Bindable<bool>(true);
|
|
||||||
|
|
||||||
public bool IsCatchingUp { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
// 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 osu.Framework.Bindables;
|
|
||||||
using osu.Framework.Timing;
|
|
||||||
|
|
||||||
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A clock which is used by <see cref="MultiSpectatorPlayer"/>s and managed by an <see cref="ISyncManager"/>.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISpectatorPlayerClock : IFrameBasedClock, IAdjustableClock
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Starts this <see cref="ISpectatorPlayerClock"/>.
|
|
||||||
/// </summary>
|
|
||||||
new void Start();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops this <see cref="ISpectatorPlayerClock"/>.
|
|
||||||
/// </summary>
|
|
||||||
new void Stop();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this clock is waiting on frames to continue playback.
|
|
||||||
/// </summary>
|
|
||||||
Bindable<bool> WaitingOnFrames { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this clock is behind the master clock and running at a higher rate to catch up to it.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Of note, this will be false if this clock is *ahead* of the master clock.
|
|
||||||
/// </remarks>
|
|
||||||
bool IsCatchingUp { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
// 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 osu.Framework.Bindables;
|
|
||||||
using osu.Game.Screens.Play;
|
|
||||||
|
|
||||||
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Manages the synchronisation between one or more <see cref="ISpectatorPlayerClock"/>s in relation to a master clock.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISyncManager
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// An event which is invoked when gameplay is ready to start.
|
|
||||||
/// </summary>
|
|
||||||
event Action? ReadyToStart;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The master clock which player clocks should synchronise to.
|
|
||||||
/// </summary>
|
|
||||||
GameplayClockContainer MasterClock { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An event which is invoked when the state of <see cref="MasterClock"/> is changed.
|
|
||||||
/// </summary>
|
|
||||||
IBindable<MasterClockState> MasterState { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new managed <see cref="ISpectatorPlayerClock"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The newly created <see cref="ISpectatorPlayerClock"/>.</returns>
|
|
||||||
ISpectatorPlayerClock CreateManagedClock();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes an <see cref="ISpectatorPlayerClock"/>, stopping it from being managed by this <see cref="ISyncManager"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="clock">The <see cref="ISpectatorPlayerClock"/> to remove.</param>
|
|
||||||
void RemoveManagedClock(ISpectatorPlayerClock clock);
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,7 +2,6 @@
|
|||||||
// 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.Bindables;
|
|
||||||
using osu.Game.Beatmaps;
|
using osu.Game.Beatmaps;
|
||||||
using osu.Game.Scoring;
|
using osu.Game.Scoring;
|
||||||
using osu.Game.Screens.Play;
|
using osu.Game.Screens.Play;
|
||||||
@ -14,15 +13,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class MultiSpectatorPlayer : SpectatorPlayer
|
public class MultiSpectatorPlayer : SpectatorPlayer
|
||||||
{
|
{
|
||||||
private readonly Bindable<bool> waitingOnFrames = new Bindable<bool>(true);
|
private readonly SpectatorPlayerClock spectatorPlayerClock;
|
||||||
private readonly ISpectatorPlayerClock spectatorPlayerClock;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new <see cref="MultiSpectatorPlayer"/>.
|
/// Creates a new <see cref="MultiSpectatorPlayer"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="score">The score containing the player's replay.</param>
|
/// <param name="score">The score containing the player's replay.</param>
|
||||||
/// <param name="spectatorPlayerClock">The clock controlling the gameplay running state.</param>
|
/// <param name="spectatorPlayerClock">The clock controlling the gameplay running state.</param>
|
||||||
public MultiSpectatorPlayer(Score score, ISpectatorPlayerClock spectatorPlayerClock)
|
public MultiSpectatorPlayer(Score score, SpectatorPlayerClock spectatorPlayerClock)
|
||||||
: base(score, new PlayerConfiguration { AllowUserInteraction = false })
|
: base(score, new PlayerConfiguration { AllowUserInteraction = false })
|
||||||
{
|
{
|
||||||
this.spectatorPlayerClock = spectatorPlayerClock;
|
this.spectatorPlayerClock = spectatorPlayerClock;
|
||||||
@ -31,8 +29,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
[BackgroundDependencyLoader]
|
[BackgroundDependencyLoader]
|
||||||
private void load()
|
private void load()
|
||||||
{
|
{
|
||||||
spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames);
|
|
||||||
|
|
||||||
HUDOverlay.PlayerSettingsOverlay.Expire();
|
HUDOverlay.PlayerSettingsOverlay.Expire();
|
||||||
HUDOverlay.HoldToQuit.Expire();
|
HUDOverlay.HoldToQuit.Expire();
|
||||||
}
|
}
|
||||||
@ -40,9 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
protected override void Update()
|
protected override void Update()
|
||||||
{
|
{
|
||||||
// The player clock's running state is controlled externally, but the local pausing state needs to be updated to start/stop gameplay.
|
// The player clock's running state is controlled externally, but the local pausing state needs to be updated to start/stop gameplay.
|
||||||
CatchUpSpectatorPlayerClock catchUpClock = (CatchUpSpectatorPlayerClock)GameplayClockContainer.SourceClock;
|
if (GameplayClockContainer.SourceClock.IsRunning)
|
||||||
|
|
||||||
if (catchUpClock.IsRunning)
|
|
||||||
GameplayClockContainer.Start();
|
GameplayClockContainer.Start();
|
||||||
else
|
else
|
||||||
GameplayClockContainer.Stop();
|
GameplayClockContainer.Stop();
|
||||||
@ -55,7 +49,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
base.UpdateAfterChildren();
|
base.UpdateAfterChildren();
|
||||||
|
|
||||||
// This is required because the frame stable clock is set to WaitingOnFrames = false for one frame.
|
// This is required because the frame stable clock is set to WaitingOnFrames = false for one frame.
|
||||||
waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0;
|
spectatorPlayerClock.WaitingOnFrames = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
|
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
|
||||||
|
@ -4,11 +4,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Bindables;
|
|
||||||
using osu.Framework.Extensions.ObjectExtensions;
|
using osu.Framework.Extensions.ObjectExtensions;
|
||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
using osu.Framework.Graphics.Containers;
|
using osu.Framework.Graphics.Containers;
|
||||||
using osu.Game.Beatmaps;
|
|
||||||
using osu.Game.Graphics;
|
using osu.Game.Graphics;
|
||||||
using osu.Game.Online.Multiplayer;
|
using osu.Game.Online.Multiplayer;
|
||||||
using osu.Game.Online.Rooms;
|
using osu.Game.Online.Rooms;
|
||||||
@ -47,11 +45,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
|
|
||||||
private readonly PlayerArea[] instances;
|
private readonly PlayerArea[] instances;
|
||||||
private MasterGameplayClockContainer masterClockContainer = null!;
|
private MasterGameplayClockContainer masterClockContainer = null!;
|
||||||
private ISyncManager syncManager = null!;
|
private SpectatorSyncManager syncManager = null!;
|
||||||
private PlayerGrid grid = null!;
|
private PlayerGrid grid = null!;
|
||||||
private MultiSpectatorLeaderboard leaderboard = null!;
|
private MultiSpectatorLeaderboard leaderboard = null!;
|
||||||
private PlayerArea? currentAudioSource;
|
private PlayerArea? currentAudioSource;
|
||||||
private bool canStartMasterClock;
|
|
||||||
|
|
||||||
private readonly Room room;
|
private readonly Room room;
|
||||||
private readonly MultiplayerRoomUser[] users;
|
private readonly MultiplayerRoomUser[] users;
|
||||||
@ -76,50 +73,54 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
FillFlowContainer leaderboardFlow;
|
FillFlowContainer leaderboardFlow;
|
||||||
Container scoreDisplayContainer;
|
Container scoreDisplayContainer;
|
||||||
|
|
||||||
masterClockContainer = CreateMasterGameplayClockContainer(Beatmap.Value);
|
InternalChildren = new Drawable[]
|
||||||
|
|
||||||
InternalChildren = new[]
|
|
||||||
{
|
{
|
||||||
(Drawable)(syncManager = new CatchUpSyncManager(masterClockContainer)),
|
masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
|
||||||
masterClockContainer.WithChild(new GridContainer
|
|
||||||
{
|
{
|
||||||
RelativeSizeAxes = Axes.Both,
|
Child = new GridContainer
|
||||||
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
|
|
||||||
Content = new[]
|
|
||||||
{
|
{
|
||||||
new Drawable[]
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
|
||||||
|
Content = new[]
|
||||||
{
|
{
|
||||||
scoreDisplayContainer = new Container
|
new Drawable[]
|
||||||
{
|
{
|
||||||
RelativeSizeAxes = Axes.X,
|
scoreDisplayContainer = new Container
|
||||||
AutoSizeAxes = Axes.Y
|
|
||||||
},
|
|
||||||
},
|
|
||||||
new Drawable[]
|
|
||||||
{
|
|
||||||
new GridContainer
|
|
||||||
{
|
|
||||||
RelativeSizeAxes = Axes.Both,
|
|
||||||
ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
|
|
||||||
Content = new[]
|
|
||||||
{
|
{
|
||||||
new Drawable[]
|
RelativeSizeAxes = Axes.X,
|
||||||
|
AutoSizeAxes = Axes.Y
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new Drawable[]
|
||||||
|
{
|
||||||
|
new GridContainer
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
|
||||||
|
Content = new[]
|
||||||
{
|
{
|
||||||
leaderboardFlow = new FillFlowContainer
|
new Drawable[]
|
||||||
{
|
{
|
||||||
Anchor = Anchor.CentreLeft,
|
leaderboardFlow = new FillFlowContainer
|
||||||
Origin = Anchor.CentreLeft,
|
{
|
||||||
AutoSizeAxes = Axes.Both,
|
Anchor = Anchor.CentreLeft,
|
||||||
Direction = FillDirection.Vertical,
|
Origin = Anchor.CentreLeft,
|
||||||
Spacing = new Vector2(5)
|
AutoSizeAxes = Axes.Both,
|
||||||
},
|
Direction = FillDirection.Vertical,
|
||||||
grid = new PlayerGrid { RelativeSizeAxes = Axes.Both }
|
Spacing = new Vector2(5)
|
||||||
|
},
|
||||||
|
grid = new PlayerGrid { RelativeSizeAxes = Axes.Both }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
syncManager = new SpectatorSyncManager(masterClockContainer)
|
||||||
|
{
|
||||||
|
ReadyToStart = performInitialSeek,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (int i = 0; i < Users.Count; i++)
|
for (int i = 0; i < Users.Count; i++)
|
||||||
@ -131,7 +132,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
}, _ =>
|
}, _ =>
|
||||||
{
|
{
|
||||||
foreach (var instance in instances)
|
foreach (var instance in instances)
|
||||||
leaderboard.AddClock(instance.UserId, instance.GameplayClock);
|
leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock);
|
||||||
|
|
||||||
leaderboardFlow.Insert(0, leaderboard);
|
leaderboardFlow.Insert(0, leaderboard);
|
||||||
|
|
||||||
@ -156,19 +157,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
base.LoadComplete();
|
base.LoadComplete();
|
||||||
|
|
||||||
masterClockContainer.Reset();
|
masterClockContainer.Reset();
|
||||||
|
|
||||||
syncManager.ReadyToStart += onReadyToStart;
|
|
||||||
syncManager.MasterState.BindValueChanged(onMasterStateChanged, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Update()
|
protected override void Update()
|
||||||
{
|
{
|
||||||
base.Update();
|
base.Update();
|
||||||
|
|
||||||
if (!isCandidateAudioSource(currentAudioSource?.GameplayClock))
|
if (!isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock))
|
||||||
{
|
{
|
||||||
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock))
|
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock))
|
||||||
.OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.MasterClock.CurrentTime))
|
.OrderBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
foreach (var instance in instances)
|
foreach (var instance in instances)
|
||||||
@ -176,10 +174,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool isCandidateAudioSource(ISpectatorPlayerClock? clock)
|
private bool isCandidateAudioSource(SpectatorPlayerClock? clock)
|
||||||
=> clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value;
|
=> clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames;
|
||||||
|
|
||||||
private void onReadyToStart()
|
private void performInitialSeek()
|
||||||
{
|
{
|
||||||
// Seek the master clock to the gameplay time.
|
// Seek the master clock to the gameplay time.
|
||||||
// This is chosen as the first available frame in the players' replays, which matches the seek by each individual SpectatorPlayer.
|
// This is chosen as the first available frame in the players' replays, which matches the seek by each individual SpectatorPlayer.
|
||||||
@ -189,27 +187,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
.DefaultIfEmpty(0)
|
.DefaultIfEmpty(0)
|
||||||
.Min();
|
.Min();
|
||||||
|
|
||||||
masterClockContainer.StartTime = startTime;
|
masterClockContainer.Reset(startTime, true);
|
||||||
masterClockContainer.Reset(true);
|
|
||||||
|
|
||||||
// Although the clock has been started, this flag is set to allow for later synchronisation state changes to also be able to start it.
|
|
||||||
canStartMasterClock = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onMasterStateChanged(ValueChangedEvent<MasterClockState> state)
|
|
||||||
{
|
|
||||||
switch (state.NewValue)
|
|
||||||
{
|
|
||||||
case MasterClockState.Synchronised:
|
|
||||||
if (canStartMasterClock)
|
|
||||||
masterClockContainer.Start();
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MasterClockState.TooFarAhead:
|
|
||||||
masterClockContainer.Stop();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
|
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
|
||||||
@ -237,7 +215,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
var instance = instances.Single(i => i.UserId == userId);
|
var instance = instances.Single(i => i.UserId == userId);
|
||||||
|
|
||||||
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
|
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
|
||||||
syncManager.RemoveManagedClock(instance.GameplayClock);
|
syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool OnBackButton()
|
public override bool OnBackButton()
|
||||||
@ -251,7 +229,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
|
|
||||||
return base.OnBackButton();
|
return base.OnBackButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected virtual MasterGameplayClockContainer CreateMasterGameplayClockContainer(WorkingBeatmap beatmap) => new MasterGameplayClockContainer(beatmap, 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,9 +38,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
public readonly int UserId;
|
public readonly int UserId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The <see cref="ISpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
|
/// The <see cref="Spectate.SpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly ISpectatorPlayerClock GameplayClock;
|
public readonly SpectatorPlayerClock SpectatorPlayerClock;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The currently-loaded score.
|
/// The currently-loaded score.
|
||||||
@ -55,10 +55,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
private readonly LoadingLayer loadingLayer;
|
private readonly LoadingLayer loadingLayer;
|
||||||
private OsuScreenStack? stack;
|
private OsuScreenStack? stack;
|
||||||
|
|
||||||
public PlayerArea(int userId, ISpectatorPlayerClock clock)
|
public PlayerArea(int userId, SpectatorPlayerClock clock)
|
||||||
{
|
{
|
||||||
UserId = userId;
|
UserId = userId;
|
||||||
GameplayClock = clock;
|
SpectatorPlayerClock = clock;
|
||||||
|
|
||||||
RelativeSizeAxes = Axes.Both;
|
RelativeSizeAxes = Axes.Both;
|
||||||
Masking = true;
|
Masking = true;
|
||||||
@ -95,7 +95,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
|
|
||||||
stack.Push(new MultiSpectatorPlayerLoader(Score, () =>
|
stack.Push(new MultiSpectatorPlayerLoader(Score, () =>
|
||||||
{
|
{
|
||||||
var player = new MultiSpectatorPlayer(Score, GameplayClock);
|
var player = new MultiSpectatorPlayer(Score, SpectatorPlayerClock);
|
||||||
player.OnGameplayStarted += () => OnGameplayStarted?.Invoke();
|
player.OnGameplayStarted += () => OnGameplayStarted?.Invoke();
|
||||||
return player;
|
return player;
|
||||||
}));
|
}));
|
||||||
|
@ -0,0 +1,104 @@
|
|||||||
|
// 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 osu.Framework.Timing;
|
||||||
|
using osu.Game.Screens.Play;
|
||||||
|
|
||||||
|
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A clock which catches up using rate adjustment.
|
||||||
|
/// </summary>
|
||||||
|
public class SpectatorPlayerClock : IFrameBasedClock, IAdjustableClock
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The catch up rate.
|
||||||
|
/// </summary>
|
||||||
|
private const double catchup_rate = 2;
|
||||||
|
|
||||||
|
private readonly GameplayClockContainer masterClock;
|
||||||
|
|
||||||
|
public double CurrentTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this clock is waiting on frames to continue playback.
|
||||||
|
/// </summary>
|
||||||
|
public bool WaitingOnFrames { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this clock is behind the master clock and running at a higher rate to catch up to it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Of note, this will be false if this clock is *ahead* of the master clock.
|
||||||
|
/// </remarks>
|
||||||
|
public bool IsCatchingUp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this spectator clock should be running.
|
||||||
|
/// Use instead of <see cref="Start"/> / <see cref="Stop"/> to control time.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRunning { get; set; }
|
||||||
|
|
||||||
|
public SpectatorPlayerClock(GameplayClockContainer masterClock)
|
||||||
|
{
|
||||||
|
this.masterClock = masterClock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset() => CurrentTime = 0;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
// Our running state should only be managed by SpectatorSyncManager via IsRunning.
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
// Our running state should only be managed by an SpectatorSyncManager via IsRunning.
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Seek(double position)
|
||||||
|
{
|
||||||
|
CurrentTime = position;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetSpeedAdjustments()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Rate
|
||||||
|
{
|
||||||
|
get => IsCatchingUp ? catchup_rate : 1;
|
||||||
|
set => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ProcessFrame()
|
||||||
|
{
|
||||||
|
if (IsRunning)
|
||||||
|
{
|
||||||
|
// When in catch-up mode, the source is usually not running.
|
||||||
|
// In such a case, its elapsed time may be zero, which would cause catch-up to get stuck.
|
||||||
|
// To avoid this, use a constant 16ms elapsed time for now. Probably not too correct, but this whole logic isn't too correct anyway.
|
||||||
|
// Clamping is required to ensure that player clocks don't get too far ahead if ProcessFrame is run multiple times.
|
||||||
|
double elapsedSource = masterClock.ElapsedFrameTime != 0 ? masterClock.ElapsedFrameTime : Math.Clamp(masterClock.CurrentTime - CurrentTime, 0, 16);
|
||||||
|
double elapsed = elapsedSource * Rate;
|
||||||
|
|
||||||
|
CurrentTime += elapsed;
|
||||||
|
ElapsedFrameTime = elapsed;
|
||||||
|
FramesPerSecond = masterClock.FramesPerSecond;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ElapsedFrameTime = 0;
|
||||||
|
FramesPerSecond = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double ElapsedFrameTime { get; private set; }
|
||||||
|
|
||||||
|
public double FramesPerSecond { get; private set; }
|
||||||
|
|
||||||
|
public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime };
|
||||||
|
}
|
||||||
|
}
|
@ -4,16 +4,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using osu.Framework.Bindables;
|
|
||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
|
using osu.Framework.Logging;
|
||||||
using osu.Game.Screens.Play;
|
using osu.Game.Screens.Play;
|
||||||
|
|
||||||
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A <see cref="ISyncManager"/> which synchronises de-synced player clocks through catchup.
|
/// Manages the synchronisation between one or more <see cref="SpectatorPlayerClock"/>s in relation to a master clock.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CatchUpSyncManager : Component, ISyncManager
|
public class SpectatorSyncManager : Component
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The offset from the master clock to which player clocks should remain within to be considered in-sync.
|
/// The offset from the master clock to which player clocks should remain within to be considered in-sync.
|
||||||
@ -30,41 +30,53 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const double MAXIMUM_START_DELAY = 15000;
|
public const double MAXIMUM_START_DELAY = 15000;
|
||||||
|
|
||||||
public event Action? ReadyToStart;
|
/// <summary>
|
||||||
|
/// An event which is invoked when gameplay is ready to start.
|
||||||
|
/// </summary>
|
||||||
|
public Action? ReadyToStart;
|
||||||
|
|
||||||
|
public double CurrentMasterTime => masterClock.CurrentTime;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The master clock which is used to control the timing of all player clocks clocks.
|
/// The master clock which is used to control the timing of all player clocks clocks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public GameplayClockContainer MasterClock { get; }
|
private readonly GameplayClockContainer masterClock;
|
||||||
|
|
||||||
public IBindable<MasterClockState> MasterState => masterState;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The player clocks.
|
/// The player clocks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly List<ISpectatorPlayerClock> playerClocks = new List<ISpectatorPlayerClock>();
|
private readonly List<SpectatorPlayerClock> playerClocks = new List<SpectatorPlayerClock>();
|
||||||
|
|
||||||
private readonly Bindable<MasterClockState> masterState = new Bindable<MasterClockState>();
|
private MasterClockState masterState = MasterClockState.Synchronised;
|
||||||
|
|
||||||
private bool hasStarted;
|
private bool hasStarted;
|
||||||
|
|
||||||
private double? firstStartAttemptTime;
|
private double? firstStartAttemptTime;
|
||||||
|
|
||||||
public CatchUpSyncManager(GameplayClockContainer master)
|
public SpectatorSyncManager(GameplayClockContainer master)
|
||||||
{
|
{
|
||||||
MasterClock = master;
|
masterClock = master;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ISpectatorPlayerClock CreateManagedClock()
|
/// <summary>
|
||||||
|
/// Create a new managed <see cref="SpectatorPlayerClock"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The newly created <see cref="SpectatorPlayerClock"/>.</returns>
|
||||||
|
public SpectatorPlayerClock CreateManagedClock()
|
||||||
{
|
{
|
||||||
var clock = new CatchUpSpectatorPlayerClock(MasterClock);
|
var clock = new SpectatorPlayerClock(masterClock);
|
||||||
playerClocks.Add(clock);
|
playerClocks.Add(clock);
|
||||||
return clock;
|
return clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemoveManagedClock(ISpectatorPlayerClock clock)
|
/// <summary>
|
||||||
|
/// Removes an <see cref="SpectatorPlayerClock"/>, stopping it from being managed by this <see cref="SpectatorSyncManager"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clock">The <see cref="SpectatorPlayerClock"/> to remove.</param>
|
||||||
|
public void RemoveManagedClock(SpectatorPlayerClock clock)
|
||||||
{
|
{
|
||||||
playerClocks.Remove(clock);
|
playerClocks.Remove(clock);
|
||||||
clock.Stop();
|
clock.IsRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Update()
|
protected override void Update()
|
||||||
@ -75,7 +87,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
{
|
{
|
||||||
// Ensure all player clocks are stopped until the start succeeds.
|
// Ensure all player clocks are stopped until the start succeeds.
|
||||||
foreach (var clock in playerClocks)
|
foreach (var clock in playerClocks)
|
||||||
clock.Stop();
|
clock.IsRunning = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +107,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
if (playerClocks.Count == 0)
|
if (playerClocks.Count == 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value);
|
int readyCount = playerClocks.Count(s => !s.WaitingOnFrames);
|
||||||
|
|
||||||
if (readyCount == playerClocks.Count)
|
if (readyCount == playerClocks.Count)
|
||||||
return performStart();
|
return performStart();
|
||||||
@ -128,7 +140,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
|
|
||||||
// How far this player's clock is out of sync, compared to the master clock.
|
// How far this player's clock is out of sync, compared to the master clock.
|
||||||
// A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up).
|
// A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up).
|
||||||
double timeDelta = MasterClock.CurrentTime - clock.CurrentTime;
|
double timeDelta = masterClock.CurrentTime - clock.CurrentTime;
|
||||||
|
|
||||||
// Check that the player clock isn't too far ahead.
|
// Check that the player clock isn't too far ahead.
|
||||||
// This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock.
|
// This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock.
|
||||||
@ -137,15 +149,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
// Importantly, set the clock to a non-catchup state. if this isn't done, updateMasterState may incorrectly pause the master clock
|
// Importantly, set the clock to a non-catchup state. if this isn't done, updateMasterState may incorrectly pause the master clock
|
||||||
// when it is required to be running (ie. if all players are ahead of the master).
|
// when it is required to be running (ie. if all players are ahead of the master).
|
||||||
clock.IsCatchingUp = false;
|
clock.IsCatchingUp = false;
|
||||||
clock.Stop();
|
clock.IsRunning = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure the player clock is running if it can.
|
// Make sure the player clock is running if it can.
|
||||||
if (!clock.WaitingOnFrames.Value)
|
clock.IsRunning = !clock.WaitingOnFrames;
|
||||||
clock.Start();
|
|
||||||
else
|
|
||||||
clock.Stop();
|
|
||||||
|
|
||||||
if (clock.IsCatchingUp)
|
if (clock.IsCatchingUp)
|
||||||
{
|
{
|
||||||
@ -167,8 +176,26 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void updateMasterState()
|
private void updateMasterState()
|
||||||
{
|
{
|
||||||
bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp);
|
MasterClockState newState = playerClocks.Any(s => !s.IsCatchingUp) ? MasterClockState.Synchronised : MasterClockState.TooFarAhead;
|
||||||
masterState.Value = anyInSync ? MasterClockState.Synchronised : MasterClockState.TooFarAhead;
|
|
||||||
|
if (masterState == newState)
|
||||||
|
return;
|
||||||
|
|
||||||
|
masterState = newState;
|
||||||
|
Logger.Log($"{nameof(SpectatorSyncManager)}'s master clock become {masterState}");
|
||||||
|
|
||||||
|
switch (masterState)
|
||||||
|
{
|
||||||
|
case MasterClockState.Synchronised:
|
||||||
|
if (hasStarted)
|
||||||
|
masterClock.Start();
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MasterClockState.TooFarAhead:
|
||||||
|
masterClock.Stop();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -11,12 +11,14 @@ using osu.Framework.Graphics.Containers;
|
|||||||
using osu.Framework.Logging;
|
using osu.Framework.Logging;
|
||||||
using osu.Framework.Timing;
|
using osu.Framework.Timing;
|
||||||
using osu.Framework.Utils;
|
using osu.Framework.Utils;
|
||||||
|
using osu.Game.Beatmaps;
|
||||||
|
|
||||||
namespace osu.Game.Screens.Play
|
namespace osu.Game.Screens.Play
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Encapsulates gameplay timing logic and provides a <see cref="IGameplayClock"/> via DI for gameplay components to use.
|
/// Encapsulates gameplay timing logic and provides a <see cref="IGameplayClock"/> via DI for gameplay components to use.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Cached(typeof(IGameplayClock))]
|
||||||
public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock
|
public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -36,119 +38,137 @@ namespace osu.Game.Screens.Play
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The time from which the clock should start. Will be seeked to on calling <see cref="Reset"/>.
|
/// The time from which the clock should start. Will be seeked to on calling <see cref="Reset"/>.
|
||||||
|
/// Can be adjusted by calling <see cref="Reset"/> with a time value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// If not set, a value of zero will be used.
|
/// By default, a value of zero will be used.
|
||||||
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
|
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public double? StartTime { get; set; }
|
public double StartTime { get; protected set; }
|
||||||
|
|
||||||
public virtual IEnumerable<double> NonGameplayAdjustments => Enumerable.Empty<double>();
|
public virtual IEnumerable<double> NonGameplayAdjustments => Enumerable.Empty<double>();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The final clock which is exposed to gameplay components.
|
|
||||||
/// </summary>
|
|
||||||
protected IFrameBasedClock FramedClock { get; private set; }
|
|
||||||
|
|
||||||
private readonly BindableBool isPaused = new BindableBool(true);
|
private readonly BindableBool isPaused = new BindableBool(true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The adjustable source clock used for gameplay. Should be used for seeks and clock control.
|
/// The adjustable source clock used for gameplay. Should be used for seeks and clock control.
|
||||||
|
/// This is the final source exposed to gameplay components <see cref="IGameplayClock"/> via delegation in this class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly DecoupleableInterpolatingFramedClock decoupledClock;
|
protected readonly FramedBeatmapClock GameplayClock;
|
||||||
|
|
||||||
|
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new <see cref="GameplayClockContainer"/>.
|
/// Creates a new <see cref="GameplayClockContainer"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sourceClock">The source <see cref="IClock"/> used for timing.</param>
|
/// <param name="sourceClock">The source <see cref="IClock"/> used for timing.</param>
|
||||||
public GameplayClockContainer(IClock sourceClock)
|
/// <param name="applyOffsets">Whether to apply platform, user and beatmap offsets to the mix.</param>
|
||||||
|
public GameplayClockContainer(IClock sourceClock, bool applyOffsets = false)
|
||||||
{
|
{
|
||||||
SourceClock = sourceClock;
|
SourceClock = sourceClock;
|
||||||
|
|
||||||
RelativeSizeAxes = Axes.Both;
|
RelativeSizeAxes = Axes.Both;
|
||||||
|
|
||||||
decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false };
|
InternalChildren = new Drawable[]
|
||||||
IsPaused.BindValueChanged(OnIsPausedChanged);
|
{
|
||||||
|
GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false },
|
||||||
// this will be replaced during load, but non-null for tests which don't add this component to the hierarchy.
|
Content
|
||||||
FramedClock = new FramedClock();
|
};
|
||||||
}
|
|
||||||
|
|
||||||
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
|
|
||||||
{
|
|
||||||
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
|
|
||||||
|
|
||||||
FramedClock = CreateGameplayClock(decoupledClock);
|
|
||||||
|
|
||||||
dependencies.CacheAs<IGameplayClock>(this);
|
|
||||||
|
|
||||||
return dependencies;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts gameplay.
|
/// Starts gameplay and marks un-paused state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
ensureSourceClockSet();
|
if (!isPaused.Value)
|
||||||
|
return;
|
||||||
if (!decoupledClock.IsRunning)
|
|
||||||
{
|
|
||||||
// Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time
|
|
||||||
// This accounts for the clock source potentially taking time to enter a completely stopped state
|
|
||||||
Seek(FramedClock.CurrentTime);
|
|
||||||
|
|
||||||
decoupledClock.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
isPaused.Value = false;
|
isPaused.Value = false;
|
||||||
|
|
||||||
|
ensureSourceClockSet();
|
||||||
|
|
||||||
|
// Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time
|
||||||
|
// This accounts for the clock source potentially taking time to enter a completely stopped state
|
||||||
|
Seek(GameplayClock.CurrentTime);
|
||||||
|
|
||||||
|
// The case which caused this to be added is FrameStabilityContainer, which manages its own current and elapsed time.
|
||||||
|
// Because we generally update our own current time quicker than children can query it (via Start/Seek/Update),
|
||||||
|
// this means that the first frame ever exposed to children may have a non-zero current time.
|
||||||
|
//
|
||||||
|
// If the child component is not aware of the parent ElapsedFrameTime (which is the case for FrameStabilityContainer)
|
||||||
|
// they will take on the new CurrentTime with a zero elapsed time. This can in turn cause components to behave incorrectly
|
||||||
|
// if they are intending to trigger events at the precise StartTime (ie. DrawableStoryboardSample).
|
||||||
|
//
|
||||||
|
// By scheduling the start call, children are guaranteed to receive one frame at the original start time, allowing
|
||||||
|
// then to progress with a correct locally calculated elapsed time.
|
||||||
|
SchedulerAfterChildren.Add(() =>
|
||||||
|
{
|
||||||
|
if (isPaused.Value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
StartGameplayClock();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seek to a specific time in gameplay.
|
/// Seek to a specific time in gameplay.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="time">The destination time to seek to.</param>
|
/// <param name="time">The destination time to seek to.</param>
|
||||||
public virtual void Seek(double time)
|
public void Seek(double time)
|
||||||
{
|
{
|
||||||
Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}");
|
Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}");
|
||||||
|
|
||||||
decoupledClock.Seek(time);
|
GameplayClock.Seek(time);
|
||||||
|
|
||||||
// Manually process to make sure the gameplay clock is correctly updated after a seek.
|
|
||||||
FramedClock.ProcessFrame();
|
|
||||||
|
|
||||||
OnSeek?.Invoke();
|
OnSeek?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stops gameplay.
|
/// Stops gameplay and marks paused state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Stop() => isPaused.Value = true;
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (isPaused.Value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
isPaused.Value = true;
|
||||||
|
StopGameplayClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void StartGameplayClock() => GameplayClock.Start();
|
||||||
|
protected virtual void StopGameplayClock() => GameplayClock.Stop();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resets this <see cref="GameplayClockContainer"/> and the source to an initial state ready for gameplay.
|
/// Resets this <see cref="GameplayClockContainer"/> and the source to an initial state ready for gameplay.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="time">The time to seek to on resetting. If <c>null</c>, the existing <see cref="StartTime"/> will be used.</param>
|
||||||
/// <param name="startClock">Whether to start the clock immediately, if not already started.</param>
|
/// <param name="startClock">Whether to start the clock immediately, if not already started.</param>
|
||||||
public void Reset(bool startClock = false)
|
public void Reset(double? time = null, bool startClock = false)
|
||||||
{
|
{
|
||||||
// Manually stop the source in order to not affect the IsPaused state.
|
bool wasPaused = isPaused.Value;
|
||||||
decoupledClock.Stop();
|
|
||||||
|
|
||||||
if (!IsPaused.Value || startClock)
|
Stop();
|
||||||
Start();
|
|
||||||
|
|
||||||
ensureSourceClockSet();
|
ensureSourceClockSet();
|
||||||
Seek(StartTime ?? 0);
|
|
||||||
|
if (time != null)
|
||||||
|
StartTime = time.Value;
|
||||||
|
|
||||||
|
Seek(StartTime);
|
||||||
|
|
||||||
|
if (!wasPaused || startClock)
|
||||||
|
Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Changes the source clock.
|
/// Changes the source clock.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sourceClock">The new source.</param>
|
/// <param name="sourceClock">The new source.</param>
|
||||||
protected void ChangeSource(IClock sourceClock) => decoupledClock.ChangeSource(SourceClock = sourceClock);
|
protected void ChangeSource(IClock sourceClock) => GameplayClock.ChangeSource(SourceClock = sourceClock);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensures that the <see cref="decoupledClock"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
|
/// Ensures that the <see cref="GameplayClock"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
|
||||||
/// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode,
|
/// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode,
|
||||||
/// but not the actual source clock.
|
/// but not the actual source clock.
|
||||||
/// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor,
|
/// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor,
|
||||||
@ -156,40 +176,10 @@ namespace osu.Game.Screens.Play
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void ensureSourceClockSet()
|
private void ensureSourceClockSet()
|
||||||
{
|
{
|
||||||
if (decoupledClock.Source == null)
|
if (GameplayClock.Source == null)
|
||||||
ChangeSource(SourceClock);
|
ChangeSource(SourceClock);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Update()
|
|
||||||
{
|
|
||||||
if (!IsPaused.Value)
|
|
||||||
FramedClock.ProcessFrame();
|
|
||||||
|
|
||||||
base.Update();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Invoked when the value of <see cref="IsPaused"/> is changed to start or stop the <see cref="decoupledClock"/> clock.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="isPaused">Whether the clock should now be paused.</param>
|
|
||||||
protected virtual void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
|
|
||||||
{
|
|
||||||
if (isPaused.NewValue)
|
|
||||||
decoupledClock.Stop();
|
|
||||||
else
|
|
||||||
decoupledClock.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the final <see cref="FramedClock"/> which is exposed via DI to be used by gameplay components.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Any intermediate clocks such as platform offsets should be applied here.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="source">The <see cref="IFrameBasedClock"/> providing the source time.</param>
|
|
||||||
/// <returns>The final <see cref="FramedClock"/>.</returns>
|
|
||||||
protected virtual IFrameBasedClock CreateGameplayClock(IFrameBasedClock source) => source;
|
|
||||||
|
|
||||||
#region IAdjustableClock
|
#region IAdjustableClock
|
||||||
|
|
||||||
bool IAdjustableClock.Seek(double position)
|
bool IAdjustableClock.Seek(double position)
|
||||||
@ -204,15 +194,15 @@ namespace osu.Game.Screens.Play
|
|||||||
|
|
||||||
double IAdjustableClock.Rate
|
double IAdjustableClock.Rate
|
||||||
{
|
{
|
||||||
get => FramedClock.Rate;
|
get => GameplayClock.Rate;
|
||||||
set => throw new NotSupportedException();
|
set => throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Rate => FramedClock.Rate;
|
public double Rate => GameplayClock.Rate;
|
||||||
|
|
||||||
public double CurrentTime => FramedClock.CurrentTime;
|
public double CurrentTime => GameplayClock.CurrentTime;
|
||||||
|
|
||||||
public bool IsRunning => FramedClock.IsRunning;
|
public bool IsRunning => GameplayClock.IsRunning;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@ -221,11 +211,11 @@ namespace osu.Game.Screens.Play
|
|||||||
// Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times.
|
// Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times.
|
||||||
}
|
}
|
||||||
|
|
||||||
public double ElapsedFrameTime => FramedClock.ElapsedFrameTime;
|
public double ElapsedFrameTime => GameplayClock.ElapsedFrameTime;
|
||||||
|
|
||||||
public double FramesPerSecond => FramedClock.FramesPerSecond;
|
public double FramesPerSecond => GameplayClock.FramesPerSecond;
|
||||||
|
|
||||||
public FrameTimeInfo TimeInfo => FramedClock.TimeInfo;
|
public FrameTimeInfo TimeInfo => GameplayClock.TimeInfo;
|
||||||
|
|
||||||
public double TrueGameplayRate
|
public double TrueGameplayRate
|
||||||
{
|
{
|
||||||
|
@ -82,7 +82,7 @@ namespace osu.Game.Screens.Play.HUD
|
|||||||
|
|
||||||
if (isInIntro)
|
if (isInIntro)
|
||||||
{
|
{
|
||||||
double introStartTime = GameplayClock.StartTime ?? 0;
|
double introStartTime = GameplayClock.StartTime;
|
||||||
|
|
||||||
double introOffsetCurrent = currentTime - introStartTime;
|
double introOffsetCurrent = currentTime - introStartTime;
|
||||||
double introDuration = FirstHitTime - introStartTime;
|
double introDuration = FirstHitTime - introStartTime;
|
||||||
|
@ -19,10 +19,10 @@ namespace osu.Game.Screens.Play
|
|||||||
/// The time from which the clock should start. Will be seeked to on calling <see cref="GameplayClockContainer.Reset"/>.
|
/// The time from which the clock should start. Will be seeked to on calling <see cref="GameplayClockContainer.Reset"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// If not set, a value of zero will be used.
|
/// By default, a value of zero will be used.
|
||||||
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
|
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
double? StartTime { get; }
|
double StartTime { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All adjustments applied to this clock which don't come from gameplay or mods.
|
/// All adjustments applied to this clock which don't come from gameplay or mods.
|
||||||
|
@ -4,8 +4,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using osu.Framework;
|
|
||||||
using osu.Framework.Allocation;
|
|
||||||
using osu.Framework.Audio;
|
using osu.Framework.Audio;
|
||||||
using osu.Framework.Audio.Track;
|
using osu.Framework.Audio.Track;
|
||||||
using osu.Framework.Bindables;
|
using osu.Framework.Bindables;
|
||||||
@ -13,8 +11,6 @@ using osu.Framework.Graphics;
|
|||||||
using osu.Framework.Timing;
|
using osu.Framework.Timing;
|
||||||
using osu.Game.Beatmaps;
|
using osu.Game.Beatmaps;
|
||||||
using osu.Game.Beatmaps.ControlPoints;
|
using osu.Game.Beatmaps.ControlPoints;
|
||||||
using osu.Game.Configuration;
|
|
||||||
using osu.Game.Database;
|
|
||||||
|
|
||||||
namespace osu.Game.Screens.Play
|
namespace osu.Game.Screens.Play
|
||||||
{
|
{
|
||||||
@ -43,28 +39,10 @@ namespace osu.Game.Screens.Play
|
|||||||
Precision = 0.1,
|
Precision = 0.1,
|
||||||
};
|
};
|
||||||
|
|
||||||
private double totalAppliedOffset => userBeatmapOffsetClock.RateAdjustedOffset + userGlobalOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset;
|
|
||||||
|
|
||||||
private readonly BindableDouble pauseFreqAdjust = new BindableDouble(); // Important that this starts at zero, matching the paused state of the clock.
|
|
||||||
|
|
||||||
private readonly WorkingBeatmap beatmap;
|
private readonly WorkingBeatmap beatmap;
|
||||||
|
|
||||||
private OffsetCorrectionClock userGlobalOffsetClock = null!;
|
|
||||||
private OffsetCorrectionClock userBeatmapOffsetClock = null!;
|
|
||||||
private OffsetCorrectionClock platformOffsetClock = null!;
|
|
||||||
|
|
||||||
private Bindable<double> userAudioOffset = null!;
|
|
||||||
|
|
||||||
private IDisposable? beatmapOffsetSubscription;
|
|
||||||
|
|
||||||
private readonly double skipTargetTime;
|
private readonly double skipTargetTime;
|
||||||
|
|
||||||
[Resolved]
|
|
||||||
private RealmAccess realm { get; set; } = null!;
|
|
||||||
|
|
||||||
[Resolved]
|
|
||||||
private OsuConfigManager config { get; set; } = null!;
|
|
||||||
|
|
||||||
private readonly List<Bindable<double>> nonGameplayAdjustments = new List<Bindable<double>>();
|
private readonly List<Bindable<double>> nonGameplayAdjustments = new List<Bindable<double>>();
|
||||||
|
|
||||||
public override IEnumerable<double> NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value);
|
public override IEnumerable<double> NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value);
|
||||||
@ -75,32 +53,12 @@ namespace osu.Game.Screens.Play
|
|||||||
/// <param name="beatmap">The beatmap to be used for time and metadata references.</param>
|
/// <param name="beatmap">The beatmap to be used for time and metadata references.</param>
|
||||||
/// <param name="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
|
/// <param name="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
|
||||||
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime)
|
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime)
|
||||||
: base(beatmap.Track)
|
: base(beatmap.Track, true)
|
||||||
{
|
{
|
||||||
this.beatmap = beatmap;
|
this.beatmap = beatmap;
|
||||||
this.skipTargetTime = skipTargetTime;
|
this.skipTargetTime = skipTargetTime;
|
||||||
}
|
|
||||||
|
|
||||||
protected override void LoadComplete()
|
StartTime = findEarliestStartTime();
|
||||||
{
|
|
||||||
base.LoadComplete();
|
|
||||||
|
|
||||||
userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset);
|
|
||||||
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
|
|
||||||
|
|
||||||
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
|
|
||||||
r => r.Find<BeatmapInfo>(beatmap.BeatmapInfo.ID)?.UserSettings,
|
|
||||||
settings => settings.Offset,
|
|
||||||
val => userBeatmapOffsetClock.Offset = val);
|
|
||||||
|
|
||||||
// Reset may have been called externally before LoadComplete.
|
|
||||||
// If it was, and the clock is in a playing state, we want to ensure that it isn't stopped here.
|
|
||||||
bool isStarted = !IsPaused.Value;
|
|
||||||
|
|
||||||
// If a custom start time was not specified, calculate the best value to use.
|
|
||||||
StartTime ??= findEarliestStartTime();
|
|
||||||
|
|
||||||
Reset(startClock: isStarted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private double findEarliestStartTime()
|
private double findEarliestStartTime()
|
||||||
@ -126,54 +84,49 @@ namespace osu.Game.Screens.Play
|
|||||||
return time;
|
return time;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
|
protected override void StopGameplayClock()
|
||||||
{
|
{
|
||||||
if (IsLoaded)
|
if (IsLoaded)
|
||||||
{
|
{
|
||||||
// During normal operation, the source is stopped after performing a frequency ramp.
|
// During normal operation, the source is stopped after performing a frequency ramp.
|
||||||
if (isPaused.NewValue)
|
this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 0, 200, Easing.Out).OnComplete(_ =>
|
||||||
{
|
{
|
||||||
this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ =>
|
if (IsPaused.Value)
|
||||||
{
|
base.StopGameplayClock();
|
||||||
if (IsPaused.Value == isPaused.NewValue)
|
});
|
||||||
base.OnIsPausedChanged(isPaused);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (isPaused.NewValue)
|
base.StopGameplayClock();
|
||||||
base.OnIsPausedChanged(isPaused);
|
|
||||||
|
|
||||||
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
|
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
|
||||||
pauseFreqAdjust.Value = isPaused.NewValue ? 0 : 1;
|
GameplayClock.ExternalPauseFrequencyAdjust.Value = 0;
|
||||||
|
|
||||||
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
|
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
|
||||||
// Without doing this, an initial seek may be performed with the wrong offset.
|
// Without doing this, an initial seek may be performed with the wrong offset.
|
||||||
FramedClock.ProcessFrame();
|
GameplayClock.ProcessFrame();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Start()
|
protected override void StartGameplayClock()
|
||||||
{
|
{
|
||||||
addSourceClockAdjustments();
|
addSourceClockAdjustments();
|
||||||
base.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
base.StartGameplayClock();
|
||||||
/// Seek to a specific time in gameplay.
|
|
||||||
/// </summary>
|
if (IsLoaded)
|
||||||
/// <remarks>
|
{
|
||||||
/// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track).
|
this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 1, 200, Easing.In);
|
||||||
/// </remarks>
|
}
|
||||||
/// <param name="time">The destination time to seek to.</param>
|
else
|
||||||
public override void Seek(double time)
|
{
|
||||||
{
|
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
|
||||||
// remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track.
|
GameplayClock.ExternalPauseFrequencyAdjust.Value = 1;
|
||||||
// we may want to consider reversing the application of offsets in the future as it may feel more correct.
|
|
||||||
base.Seek(time - totalAppliedOffset);
|
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
|
||||||
|
// Without doing this, an initial seek may be performed with the wrong offset.
|
||||||
|
GameplayClock.ProcessFrame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -181,29 +134,18 @@ namespace osu.Game.Screens.Play
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Skip()
|
public void Skip()
|
||||||
{
|
{
|
||||||
if (FramedClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
|
if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME;
|
double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME;
|
||||||
|
|
||||||
if (FramedClock.CurrentTime < 0 && skipTarget > 6000)
|
if (GameplayClock.CurrentTime < 0 && skipTarget > 6000)
|
||||||
// double skip exception for storyboards with very long intros
|
// double skip exception for storyboards with very long intros
|
||||||
skipTarget = 0;
|
skipTarget = 0;
|
||||||
|
|
||||||
Seek(skipTarget);
|
Seek(skipTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override IFrameBasedClock CreateGameplayClock(IFrameBasedClock source)
|
|
||||||
{
|
|
||||||
// Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited.
|
|
||||||
// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
|
|
||||||
platformOffsetClock = new OffsetCorrectionClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
|
|
||||||
|
|
||||||
// the final usable gameplay clock with user-set offsets applied.
|
|
||||||
userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, pauseFreqAdjust);
|
|
||||||
return userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, pauseFreqAdjust);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Changes the backing clock to avoid using the originally provided track.
|
/// Changes the backing clock to avoid using the originally provided track.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -224,10 +166,10 @@ namespace osu.Game.Screens.Play
|
|||||||
if (SourceClock is not Track track)
|
if (SourceClock is not Track track)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
|
track.AddAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
|
||||||
track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
|
track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
|
||||||
|
|
||||||
nonGameplayAdjustments.Add(pauseFreqAdjust);
|
nonGameplayAdjustments.Add(GameplayClock.ExternalPauseFrequencyAdjust);
|
||||||
nonGameplayAdjustments.Add(UserPlaybackRate);
|
nonGameplayAdjustments.Add(UserPlaybackRate);
|
||||||
|
|
||||||
speedAdjustmentsApplied = true;
|
speedAdjustmentsApplied = true;
|
||||||
@ -241,10 +183,10 @@ namespace osu.Game.Screens.Play
|
|||||||
if (SourceClock is not Track track)
|
if (SourceClock is not Track track)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
|
track.RemoveAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
|
||||||
track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
|
track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
|
||||||
|
|
||||||
nonGameplayAdjustments.Remove(pauseFreqAdjust);
|
nonGameplayAdjustments.Remove(GameplayClock.ExternalPauseFrequencyAdjust);
|
||||||
nonGameplayAdjustments.Remove(UserPlaybackRate);
|
nonGameplayAdjustments.Remove(UserPlaybackRate);
|
||||||
|
|
||||||
speedAdjustmentsApplied = false;
|
speedAdjustmentsApplied = false;
|
||||||
@ -253,7 +195,6 @@ namespace osu.Game.Screens.Play
|
|||||||
protected override void Dispose(bool isDisposing)
|
protected override void Dispose(bool isDisposing)
|
||||||
{
|
{
|
||||||
base.Dispose(isDisposing);
|
base.Dispose(isDisposing);
|
||||||
beatmapOffsetSubscription?.Dispose();
|
|
||||||
removeSourceClockAdjustments();
|
removeSourceClockAdjustments();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -640,8 +640,7 @@ namespace osu.Game.Screens.Play
|
|||||||
bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
|
bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
|
||||||
DrawableRuleset.FrameStablePlayback = false;
|
DrawableRuleset.FrameStablePlayback = false;
|
||||||
|
|
||||||
GameplayClockContainer.StartTime = time;
|
GameplayClockContainer.Reset(time);
|
||||||
GameplayClockContainer.Reset();
|
|
||||||
|
|
||||||
// Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
|
// Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
|
||||||
frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
|
frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
|
||||||
@ -1012,7 +1011,7 @@ namespace osu.Game.Screens.Play
|
|||||||
if (GameplayClockContainer.IsRunning)
|
if (GameplayClockContainer.IsRunning)
|
||||||
throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running");
|
throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running");
|
||||||
|
|
||||||
GameplayClockContainer.Reset(true);
|
GameplayClockContainer.Reset(startClock: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnSuspending(ScreenTransitionEvent e)
|
public override void OnSuspending(ScreenTransitionEvent e)
|
||||||
|
@ -55,8 +55,6 @@ namespace osu.Game.Screens.Select.Carousel
|
|||||||
match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) ||
|
match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) ||
|
||||||
criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode);
|
criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode);
|
||||||
|
|
||||||
match &= criteria.Sort != SortMode.DateRanked || BeatmapInfo.BeatmapSet?.DateRanked != null;
|
|
||||||
|
|
||||||
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
|
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
|
||||||
|
|
||||||
if (match && criteria.SearchTerms.Length > 0)
|
if (match && criteria.SearchTerms.Length > 0)
|
||||||
|
@ -99,6 +99,13 @@ namespace osu.Game.Screens.Select.Carousel
|
|||||||
|
|
||||||
case SortMode.Difficulty:
|
case SortMode.Difficulty:
|
||||||
return compareUsingAggregateMax(otherSet, b => b.StarRating);
|
return compareUsingAggregateMax(otherSet, b => b.StarRating);
|
||||||
|
|
||||||
|
case SortMode.DateSubmitted:
|
||||||
|
// Beatmaps which have no submitted date should already be filtered away in this mode.
|
||||||
|
if (BeatmapSet.DateSubmitted == null || otherSet.BeatmapSet.DateSubmitted == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return otherSet.BeatmapSet.DateSubmitted.Value.CompareTo(BeatmapSet.DateSubmitted.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +129,12 @@ namespace osu.Game.Screens.Select.Carousel
|
|||||||
public override void Filter(FilterCriteria criteria)
|
public override void Filter(FilterCriteria criteria)
|
||||||
{
|
{
|
||||||
base.Filter(criteria);
|
base.Filter(criteria);
|
||||||
Filtered.Value = Items.All(i => i.Filtered.Value);
|
bool match = Items.All(i => i.Filtered.Value);
|
||||||
|
|
||||||
|
match &= criteria.Sort != SortMode.DateRanked || BeatmapSet?.DateRanked != null;
|
||||||
|
match &= criteria.Sort != SortMode.DateSubmitted || BeatmapSet?.DateSubmitted != null;
|
||||||
|
|
||||||
|
Filtered.Value = match;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => BeatmapSet.ToString();
|
public override string ToString() => BeatmapSet.ToString();
|
||||||
|
@ -20,6 +20,9 @@ namespace osu.Game.Screens.Select.Filter
|
|||||||
[LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))]
|
[LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))]
|
||||||
BPM,
|
BPM,
|
||||||
|
|
||||||
|
[Description("Date Submitted")]
|
||||||
|
DateSubmitted,
|
||||||
|
|
||||||
[Description("Date Added")]
|
[Description("Date Added")]
|
||||||
DateAdded,
|
DateAdded,
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Realm" Version="10.15.1" />
|
<PackageReference Include="Realm" Version="10.15.1" />
|
||||||
<PackageReference Include="ppy.osu.Framework" Version="2022.819.0" />
|
<PackageReference Include="ppy.osu.Framework" Version="2022.825.0" />
|
||||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
||||||
<PackageReference Include="Sentry" Version="3.20.1" />
|
<PackageReference Include="Sentry" Version="3.20.1" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.32.2" />
|
<PackageReference Include="SharpCompress" Version="0.32.2" />
|
||||||
|
@ -61,7 +61,7 @@
|
|||||||
<Reference Include="System.Net.Http" />
|
<Reference Include="System.Net.Http" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Label="Package References">
|
<ItemGroup Label="Package References">
|
||||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.819.0" />
|
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.825.0" />
|
||||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
|
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
|
||||||
@ -84,7 +84,7 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||||
<PackageReference Include="ppy.osu.Framework" Version="2022.819.0" />
|
<PackageReference Include="ppy.osu.Framework" Version="2022.825.0" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.32.1" />
|
<PackageReference Include="SharpCompress" Version="0.32.1" />
|
||||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||||
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
||||||
|
Loading…
Reference in New Issue
Block a user