1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-13 02:13:21 +08:00

Merge branch 'master' into text-selection-sfx

This commit is contained in:
Dean Herbert 2022-08-26 22:05:32 +09:00 committed by GitHub
commit e4100ee3f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1013 additions and 331 deletions

View File

@ -77,6 +77,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
return sliderCreatedFor(args);
});
AddAssert("samples exist", sliderSampleExist);
AddStep("undo", () => Editor.Undo());
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);
});
AddAssert("samples exist", sliderSampleExist);
AddAssert("merged slider matches first slider", () =>
{
var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
@ -165,6 +169,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
(pos: circle1.Position, pathType: PathType.Linear),
(pos: circle2.Position, pathType: null)));
AddAssert("samples exist", sliderSampleExist);
AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner));
}
@ -209,5 +215,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
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;
}
}
}

View File

@ -182,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
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();
});

View 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();
});
}
}
}

View File

@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
private InputManager inputManager;
public Action<List<PathControlPoint>> RemoveControlPointsRequested;
public Action<List<PathControlPoint>> SplitControlPointsRequested;
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider snapProvider { get; set; }
@ -104,6 +105,29 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
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)
{
switch (e.Action)
@ -324,25 +348,42 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (count == 0)
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]))
items.Add(createMenuItemForPathType(null));
curveTypeItems.Add(createMenuItemForPathType(null));
// todo: hide/disable items which aren't valid for selected points
items.Add(createMenuItemForPathType(PathType.Linear));
items.Add(createMenuItemForPathType(PathType.PerfectCurve));
items.Add(createMenuItemForPathType(PathType.Bezier));
items.Add(createMenuItemForPathType(PathType.Catmull));
curveTypeItems.Add(createMenuItemForPathType(PathType.Linear));
curveTypeItems.Add(createMenuItemForPathType(PathType.PerfectCurve));
curveTypeItems.Add(createMenuItemForPathType(PathType.Bezier));
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")
{
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();
}
}

View File

@ -13,6 +13,7 @@ using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
@ -111,7 +112,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true)
{
RemoveControlPointsRequested = removeControlPoints
RemoveControlPointsRequested = removeControlPoints,
SplitControlPointsRequested = splitControlPoints
});
base.OnSelected();
@ -249,6 +251,74 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
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()
{
if (editorBeatmap == null || beatDivisor == null)

View File

@ -371,6 +371,7 @@ namespace osu.Game.Rulesets.Osu.Edit
Position = firstHitObject.Position,
NewCombo = firstHitObject.NewCombo,
SampleControlPoint = firstHitObject.SampleControlPoint,
Samples = firstHitObject.Samples,
};
if (mergedHitObject.Path.ControlPoints.Count == 0)

View File

@ -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 has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
}
/// <summary>
/// Sample at 0ms, start time at 1000ms (so the sample should not be played).
/// </summary>
[Test]
public void TestSampleHasLifetimeEndWithInitialClockTime()
{
@ -104,12 +107,13 @@ namespace osu.Game.Tests.Gameplay
Add(gameplayContainer = new MasterGameplayClockContainer(working, start_time)
{
StartTime = start_time,
Child = new FrameStabilityContainer
{
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1))
}
});
gameplayContainer.Reset(start_time);
});
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 has lifetime end", () => sample.LifetimeEnd < double.MaxValue);

View File

@ -3,6 +3,7 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
@ -314,15 +315,55 @@ namespace osu.Game.Tests.Rulesets.Scoring
}), 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
{
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 ShortName => string.Empty;
@ -352,5 +393,33 @@ namespace osu.Game.Tests.Rulesets.Scoring
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;
}
}
}
}

View File

@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
private LeadInPlayer player = null!;
private const double lenience_ms = 10;
private const double lenience_ms = 100;
private const double first_hit_object = 2170;

View File

@ -85,7 +85,7 @@ namespace osu.Game.Tests.Visual.Gameplay
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>
@ -119,7 +119,7 @@ namespace osu.Game.Tests.Visual.Gameplay
waitForPlayer();
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]
@ -147,7 +147,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame);
checkPaused(true);
AddAssert("time advanced", () => currentFrameStableTime > pausedTime);
AddAssert("time advanced", () => currentFrameStableTime, () => Is.GreaterThan(pausedTime));
}
[Test]
@ -176,7 +176,7 @@ namespace osu.Game.Tests.Visual.Gameplay
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]

View File

@ -165,11 +165,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
sendFrames(PLAYER_1_ID, 40);
sendFrames(PLAYER_2_ID, 20);
checkPaused(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID, false);
waitUntilPaused(PLAYER_2_ID);
checkRunningInstant(PLAYER_1_ID);
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);
}
@ -181,13 +181,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send frames for one player only, both should remain paused.
sendFrames(PLAYER_1_ID, 20);
checkPausedInstant(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID);
checkPausedInstant(PLAYER_2_ID);
// Send frames for the other player, both should now start playing.
sendFrames(PLAYER_2_ID, 20);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
checkRunningInstant(PLAYER_1_ID);
checkRunningInstant(PLAYER_2_ID);
}
[Test]
@ -198,15 +198,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send frames for one player only, both should remain paused.
sendFrames(PLAYER_1_ID, 1000);
checkPausedInstant(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID);
checkPausedInstant(PLAYER_2_ID);
// Wait for the start delay seconds...
AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction));
// Player 1 should start playing by itself, player 2 should remain paused.
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, true);
checkRunningInstant(PLAYER_1_ID);
checkPausedInstant(PLAYER_2_ID);
}
[Test]
@ -218,26 +218,26 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send initial frames for both players. A few more for player 1.
sendFrames(PLAYER_1_ID, 20);
sendFrames(PLAYER_2_ID);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
checkRunningInstant(PLAYER_1_ID);
checkRunningInstant(PLAYER_2_ID);
// Eventually player 2 will pause, player 1 must remain running.
checkPaused(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID, false);
waitUntilPaused(PLAYER_2_ID);
checkRunningInstant(PLAYER_1_ID);
// Eventually both players will run out of frames and should pause.
checkPaused(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
waitUntilPaused(PLAYER_1_ID);
checkPausedInstant(PLAYER_2_ID);
// Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused.
sendFrames(PLAYER_1_ID, 20);
checkPausedInstant(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID);
checkRunningInstant(PLAYER_1_ID);
// Send more frames for the second player. Both should be playing
sendFrames(PLAYER_2_ID, 20);
checkPausedInstant(PLAYER_2_ID, false);
checkPausedInstant(PLAYER_1_ID, false);
checkRunningInstant(PLAYER_2_ID);
checkRunningInstant(PLAYER_1_ID);
}
[Test]
@ -249,16 +249,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send initial frames for both players. A few more for player 1.
sendFrames(PLAYER_1_ID, 1000);
sendFrames(PLAYER_2_ID, 30);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
checkRunningInstant(PLAYER_1_ID);
checkRunningInstant(PLAYER_2_ID);
// 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);
// Send more frames for player 2. It should unpause.
sendFrames(PLAYER_2_ID, 1000);
checkPausedInstant(PLAYER_2_ID, false);
checkRunningInstant(PLAYER_2_ID);
// Player 2 should catch up to player 1 after unpausing.
waitForCatchup(PLAYER_2_ID);
@ -271,21 +271,28 @@ namespace osu.Game.Tests.Visual.Multiplayer
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
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_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);
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_2_ID, false);
sendFrames(PLAYER_1_ID, 100);
waitForCatchup(PLAYER_1_ID);
checkPaused(PLAYER_2_ID, true);
waitUntilPaused(PLAYER_2_ID);
assertMuted(PLAYER_1_ID, false);
assertMuted(PLAYER_2_ID, true);
@ -319,7 +326,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
sendFrames(PLAYER_1_ID, 300);
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);
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>
/// 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>
[Test]
public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000);
/// <summary>
/// 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>
[Test]
public void TestIntroStoryboardElement() => testLeadIn(b =>
@ -384,10 +397,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
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);
assertRunning(PLAYER_1_ID);
waitUntilRunning(PLAYER_1_ID);
}
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[] userIds, int count = 10)
@ -450,30 +467,41 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
}
private void checkPaused(int userId, bool state)
=> AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().IsRunning != state);
private void checkPausedInstant(int userId, bool state)
private void checkRunningInstant(int userId)
{
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.
// 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)
=> 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)
=> 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)
=> AddAssert($"{userId} in sync", () => !getInstance(userId).GameplayClock.IsCatchingUp);
=> AddAssert($"{nameof(assertNotCatchingUp)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp);
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();

View 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();
}
}
}

View File

@ -65,11 +65,12 @@ namespace osu.Game.Online.Spectator
public virtual event Action<int, SpectatorState>? OnUserFinishedPlaying;
/// <summary>
/// All users currently being watched.
/// A dictionary containing all users currently being watched, with the number of watching components for each user.
/// </summary>
private readonly List<int> watchedUsers = new List<int>();
private readonly Dictionary<int, int> watchedUsersRefCounts = new Dictionary<int, int>();
private readonly BindableDictionary<int, SpectatorState> watchedUserStates = new BindableDictionary<int, SpectatorState>();
private readonly BindableList<int> playingUsers = new BindableList<int>();
private readonly SpectatorState currentState = new SpectatorState();
@ -94,12 +95,15 @@ namespace osu.Game.Online.Spectator
if (connected.NewValue)
{
// get all the users that were previously being watched
int[] users = watchedUsers.ToArray();
watchedUsers.Clear();
var users = new Dictionary<int, int>(watchedUsersRefCounts);
watchedUsersRefCounts.Clear();
// resubscribe to watched users.
foreach (int userId in users)
WatchUser(userId);
foreach ((int user, int watchers) in users)
{
for (int i = 0; i < watchers; i++)
WatchUser(user);
}
// re-send state in case it wasn't received
if (IsPlaying)
@ -121,7 +125,7 @@ namespace osu.Game.Online.Spectator
if (!playingUsers.Contains(userId))
playingUsers.Add(userId);
if (watchedUsers.Contains(userId))
if (watchedUsersRefCounts.ContainsKey(userId))
watchedUserStates[userId] = state;
OnUserBeganPlaying?.Invoke(userId, state);
@ -136,7 +140,7 @@ namespace osu.Game.Online.Spectator
{
playingUsers.Remove(userId);
if (watchedUsers.Contains(userId))
if (watchedUsersRefCounts.ContainsKey(userId))
watchedUserStates[userId] = state;
OnUserFinishedPlaying?.Invoke(userId, state);
@ -232,11 +236,13 @@ namespace osu.Game.Online.Spectator
{
Debug.Assert(ThreadSafety.IsUpdateThread);
if (watchedUsers.Contains(userId))
if (watchedUsersRefCounts.ContainsKey(userId))
{
watchedUsersRefCounts[userId]++;
return;
}
watchedUsers.Add(userId);
watchedUsersRefCounts.Add(userId, 1);
WatchUserInternal(userId);
}
@ -246,7 +252,13 @@ namespace osu.Game.Online.Spectator
// Todo: This should not be a thing, but requires framework changes.
Schedule(() =>
{
watchedUsers.Remove(userId);
if (watchedUsersRefCounts.TryGetValue(userId, out int watchers) && watchers > 1)
{
watchedUsersRefCounts[userId]--;
return;
}
watchedUsersRefCounts.Remove(userId);
watchedUserStates.Remove(userId);
StopWatchingUserInternal(userId);
});

View File

@ -119,8 +119,20 @@ namespace osu.Game.Rulesets.Scoring
[EnumMember(Value = "ignore_hit")]
[Order(12)]
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
{
/// <summary>
@ -150,6 +162,7 @@ namespace osu.Game.Rulesets.Scoring
case HitResult.Perfect:
case HitResult.LargeTickHit:
case HitResult.LargeTickMiss:
case HitResult.LegacyComboIncrease:
return true;
default:
@ -161,13 +174,25 @@ namespace osu.Game.Rulesets.Scoring
/// Whether a <see cref="HitResult"/> affects the accuracy portion of the score.
/// </summary>
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>
/// Whether a <see cref="HitResult"/> is a non-tick and non-bonus result.
/// </summary>
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>
/// Whether a <see cref="HitResult"/> should be counted as a tick.
@ -225,12 +250,19 @@ namespace osu.Game.Rulesets.Scoring
/// <summary>
/// Whether a <see cref="HitResult"/> is scorable.
/// </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>
/// An array of all scorable <see cref="HitResult"/>s.
/// </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>
/// 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;
}
}
#pragma warning restore CS0618
}

View File

@ -61,6 +61,11 @@ namespace osu.Game.Rulesets.Scoring
/// <param name="result">The <see cref="JudgementResult"/> to apply.</param>
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++;
lastAppliedResult = result;

View File

@ -536,6 +536,9 @@ namespace osu.Game.Rulesets.Scoring
{
extractScoringValues(scoreInfo.Statistics, out current, out maximum);
current.MaxCombo = scoreInfo.MaxCombo;
if (scoreInfo.MaximumStatistics.Count > 0)
extractScoringValues(scoreInfo.MaximumStatistics, out _, out maximum);
}
/// <summary>
@ -591,7 +594,8 @@ namespace osu.Game.Rulesets.Scoring
if (result.IsBonus())
current.BonusScore += count * Judgement.ToNumericResult(result);
else
if (result.AffectsAccuracy())
{
// 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).

View File

@ -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>();

View File

@ -27,7 +27,11 @@ namespace osu.Game.Screens.Edit.GameplayTest
}
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()
{

View File

@ -132,7 +132,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}, _ =>
{
foreach (var instance in instances)
leaderboard.AddClock(instance.UserId, instance.GameplayClock);
leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock);
leaderboardFlow.Insert(0, leaderboard);
@ -163,10 +163,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
base.Update();
if (!isCandidateAudioSource(currentAudioSource?.GameplayClock))
if (!isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock))
{
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock))
.OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.CurrentMasterTime))
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock))
.OrderBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime))
.FirstOrDefault();
foreach (var instance in instances)
@ -187,8 +187,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
.DefaultIfEmpty(0)
.Min();
masterClockContainer.StartTime = startTime;
masterClockContainer.Reset(true);
masterClockContainer.Reset(startTime, true);
}
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
@ -198,25 +197,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState)
=> instances.Single(i => i.UserId == userId).LoadScore(spectatorGameplayState.Score);
protected override void EndGameplay(int userId, SpectatorState state)
protected override void QuitGameplay(int userId)
{
// Allowed passed/failed users to complete their remaining replay frames.
// The failed state isn't really possible in multiplayer (yet?) but is added here just for safety in case it starts being used.
if (state.State == SpectatedUserState.Passed || state.State == SpectatedUserState.Failed)
return;
// we could also potentially receive EndGameplay with "Playing" state, at which point we can only early-return and hope it's a passing player.
// todo: this shouldn't exist, but it's here as a hotfix for an issue with multi-spectator screen not proceeding to results screen.
// see: https://github.com/ppy/osu/issues/19593
if (state.State == SpectatedUserState.Playing)
return;
RemoveUser(userId);
var instance = instances.Single(i => i.UserId == userId);
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
syncManager.RemoveManagedClock(instance.GameplayClock);
syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
}
public override bool OnBackButton()

View File

@ -38,9 +38,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public readonly int UserId;
/// <summary>
/// The <see cref="SpectatorPlayerClock"/> 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>
public readonly SpectatorPlayerClock GameplayClock;
public readonly SpectatorPlayerClock SpectatorPlayerClock;
/// <summary>
/// The currently-loaded score.
@ -58,7 +58,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public PlayerArea(int userId, SpectatorPlayerClock clock)
{
UserId = userId;
GameplayClock = clock;
SpectatorPlayerClock = clock;
RelativeSizeAxes = Axes.Both;
Masking = true;
@ -95,7 +95,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
stack.Push(new MultiSpectatorPlayerLoader(Score, () =>
{
var player = new MultiSpectatorPlayer(Score, GameplayClock);
var player = new MultiSpectatorPlayer(Score, SpectatorPlayerClock);
player.OnGameplayStarted += () => OnGameplayStarted?.Invoke();
return player;
}));

View File

@ -77,7 +77,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
if (IsRunning)
{
double elapsedSource = masterClock.ElapsedFrameTime;
// 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;

View File

@ -11,12 +11,14 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
namespace osu.Game.Screens.Play
{
/// <summary>
/// Encapsulates gameplay timing logic and provides a <see cref="IGameplayClock"/> via DI for gameplay components to use.
/// </summary>
[Cached(typeof(IGameplayClock))]
public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock
{
/// <summary>
@ -36,119 +38,137 @@ namespace osu.Game.Screens.Play
/// <summary>
/// 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>
/// <remarks>
/// If not set, a value of zero will be used.
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
/// By default, a value of zero will be used.
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
/// </remarks>
public double? StartTime { get; set; }
public double StartTime { get; protected set; }
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);
/// <summary>
/// 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>
private readonly DecoupleableInterpolatingFramedClock decoupledClock;
protected readonly FramedBeatmapClock GameplayClock;
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
/// <summary>
/// Creates a new <see cref="GameplayClockContainer"/>.
/// </summary>
/// <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;
RelativeSizeAxes = Axes.Both;
decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false };
IsPaused.BindValueChanged(OnIsPausedChanged);
// this will be replaced during load, but non-null for tests which don't add this component to the hierarchy.
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;
InternalChildren = new Drawable[]
{
GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false },
Content
};
}
/// <summary>
/// Starts gameplay.
/// Starts gameplay and marks un-paused state.
/// </summary>
public virtual void Start()
public void Start()
{
ensureSourceClockSet();
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();
}
if (!isPaused.Value)
return;
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>
/// Seek to a specific time in gameplay.
/// </summary>
/// <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}");
decoupledClock.Seek(time);
// Manually process to make sure the gameplay clock is correctly updated after a seek.
FramedClock.ProcessFrame();
GameplayClock.Seek(time);
OnSeek?.Invoke();
}
/// <summary>
/// Stops gameplay.
/// Stops gameplay and marks paused state.
/// </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>
/// Resets this <see cref="GameplayClockContainer"/> and the source to an initial state ready for gameplay.
/// </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>
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.
decoupledClock.Stop();
bool wasPaused = isPaused.Value;
if (!IsPaused.Value || startClock)
Start();
Stop();
ensureSourceClockSet();
Seek(StartTime ?? 0);
if (time != null)
StartTime = time.Value;
Seek(StartTime);
if (!wasPaused || startClock)
Start();
}
/// <summary>
/// Changes the source clock.
/// </summary>
/// <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>
/// 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,
/// 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,
@ -156,40 +176,10 @@ namespace osu.Game.Screens.Play
/// </summary>
private void ensureSourceClockSet()
{
if (decoupledClock.Source == null)
if (GameplayClock.Source == null)
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
bool IAdjustableClock.Seek(double position)
@ -204,15 +194,15 @@ namespace osu.Game.Screens.Play
double IAdjustableClock.Rate
{
get => FramedClock.Rate;
get => GameplayClock.Rate;
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
@ -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.
}
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
{

View File

@ -82,7 +82,7 @@ namespace osu.Game.Screens.Play.HUD
if (isInIntro)
{
double introStartTime = GameplayClock.StartTime ?? 0;
double introStartTime = GameplayClock.StartTime;
double introOffsetCurrent = currentTime - introStartTime;
double introDuration = FirstHitTime - introStartTime;

View File

@ -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"/>.
/// </summary>
/// <remarks>
/// If not set, a value of zero will be used.
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
/// By default, a value of zero will be used.
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
/// </remarks>
double? StartTime { get; }
double StartTime { get; }
/// <summary>
/// All adjustments applied to this clock which don't come from gameplay or mods.

View File

@ -4,8 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
@ -13,8 +11,6 @@ using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Database;
namespace osu.Game.Screens.Play
{
@ -43,28 +39,10 @@ namespace osu.Game.Screens.Play
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 OffsetCorrectionClock userGlobalOffsetClock = null!;
private OffsetCorrectionClock userBeatmapOffsetClock = null!;
private OffsetCorrectionClock platformOffsetClock = null!;
private Bindable<double> userAudioOffset = null!;
private IDisposable? beatmapOffsetSubscription;
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>>();
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="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime)
: base(beatmap.Track)
: base(beatmap.Track, true)
{
this.beatmap = beatmap;
this.skipTargetTime = skipTargetTime;
}
protected override void LoadComplete()
{
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);
StartTime = findEarliestStartTime();
}
private double findEarliestStartTime()
@ -126,54 +84,49 @@ namespace osu.Game.Screens.Play
return time;
}
protected override void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
protected override void StopGameplayClock()
{
if (IsLoaded)
{
// 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 == isPaused.NewValue)
base.OnIsPausedChanged(isPaused);
});
}
else
this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In);
if (IsPaused.Value)
base.StopGameplayClock();
});
}
else
{
if (isPaused.NewValue)
base.OnIsPausedChanged(isPaused);
base.StopGameplayClock();
// 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.
// 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();
base.Start();
}
/// <summary>
/// Seek to a specific time in gameplay.
/// </summary>
/// <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).
/// </remarks>
/// <param name="time">The destination time to seek to.</param>
public override void Seek(double time)
{
// remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track.
// we may want to consider reversing the application of offsets in the future as it may feel more correct.
base.Seek(time - totalAppliedOffset);
base.StartGameplayClock();
if (IsLoaded)
{
this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 1, 200, Easing.In);
}
else
{
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
GameplayClock.ExternalPauseFrequencyAdjust.Value = 1;
// 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>
@ -181,29 +134,18 @@ namespace osu.Game.Screens.Play
/// </summary>
public void Skip()
{
if (FramedClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
return;
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
skipTarget = 0;
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>
/// Changes the backing clock to avoid using the originally provided track.
/// </summary>
@ -224,10 +166,10 @@ namespace osu.Game.Screens.Play
if (SourceClock is not Track track)
return;
track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
track.AddAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
nonGameplayAdjustments.Add(pauseFreqAdjust);
nonGameplayAdjustments.Add(GameplayClock.ExternalPauseFrequencyAdjust);
nonGameplayAdjustments.Add(UserPlaybackRate);
speedAdjustmentsApplied = true;
@ -241,10 +183,10 @@ namespace osu.Game.Screens.Play
if (SourceClock is not Track track)
return;
track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
track.RemoveAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
nonGameplayAdjustments.Remove(pauseFreqAdjust);
nonGameplayAdjustments.Remove(GameplayClock.ExternalPauseFrequencyAdjust);
nonGameplayAdjustments.Remove(UserPlaybackRate);
speedAdjustmentsApplied = false;
@ -253,7 +195,6 @@ namespace osu.Game.Screens.Play
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
beatmapOffsetSubscription?.Dispose();
removeSourceClockAdjustments();
}

View File

@ -640,8 +640,7 @@ namespace osu.Game.Screens.Play
bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
DrawableRuleset.FrameStablePlayback = false;
GameplayClockContainer.StartTime = time;
GameplayClockContainer.Reset();
GameplayClockContainer.Reset(time);
// Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
@ -1012,7 +1011,7 @@ namespace osu.Game.Screens.Play
if (GameplayClockContainer.IsRunning)
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)

View File

@ -182,7 +182,7 @@ namespace osu.Game.Screens.Play
scheduleStart(spectatorGameplayState);
}
protected override void EndGameplay(int userId, SpectatorState state)
protected override void QuitGameplay(int userId)
{
scheduledStart?.Cancel();
immediateSpectatorGameplayState = null;

View File

@ -115,14 +115,9 @@ namespace osu.Game.Screens.Spectate
{
case NotifyDictionaryChangedAction.Add:
case NotifyDictionaryChangedAction.Replace:
foreach ((int userId, var state) in e.NewItems.AsNonNull())
foreach ((int userId, SpectatorState state) in e.NewItems.AsNonNull())
onUserStateChanged(userId, state);
break;
case NotifyDictionaryChangedAction.Remove:
foreach ((int userId, SpectatorState state) in e.OldItems.AsNonNull())
onUserStateRemoved(userId, state);
break;
}
}
@ -136,33 +131,21 @@ namespace osu.Game.Screens.Spectate
switch (newState.State)
{
case SpectatedUserState.Passed:
// Make sure that gameplay completes to the end.
if (gameplayStates.TryGetValue(userId, out var gameplayState))
gameplayState.Score.Replay.HasReceivedAllFrames = true;
break;
case SpectatedUserState.Playing:
Schedule(() => OnNewPlayingUserState(userId, newState));
startGameplay(userId);
break;
case SpectatedUserState.Passed:
markReceivedAllFrames(userId);
break;
case SpectatedUserState.Quit:
quitGameplay(userId);
break;
}
}
private void onUserStateRemoved(int userId, SpectatorState state)
{
if (!userMap.ContainsKey(userId))
return;
if (!gameplayStates.TryGetValue(userId, out var gameplayState))
return;
gameplayState.Score.Replay.HasReceivedAllFrames = true;
gameplayStates.Remove(userId);
Schedule(() => EndGameplay(userId, state));
}
private void startGameplay(int userId)
{
Debug.Assert(userMap.ContainsKey(userId));
@ -196,6 +179,29 @@ namespace osu.Game.Screens.Spectate
Schedule(() => StartGameplay(userId, gameplayState));
}
/// <summary>
/// Marks an existing gameplay session as received all frames.
/// </summary>
private void markReceivedAllFrames(int userId)
{
if (gameplayStates.TryGetValue(userId, out var gameplayState))
gameplayState.Score.Replay.HasReceivedAllFrames = true;
}
private void quitGameplay(int userId)
{
if (!userMap.ContainsKey(userId))
return;
if (!gameplayStates.ContainsKey(userId))
return;
markReceivedAllFrames(userId);
gameplayStates.Remove(userId);
Schedule(() => QuitGameplay(userId));
}
/// <summary>
/// Invoked when a spectated user's state has changed to a new state indicating the player is currently playing.
/// </summary>
@ -211,11 +217,10 @@ namespace osu.Game.Screens.Spectate
protected abstract void StartGameplay(int userId, [NotNull] SpectatorGameplayState spectatorGameplayState);
/// <summary>
/// Ends gameplay for a user.
/// Quits gameplay for a user.
/// </summary>
/// <param name="userId">The user to end gameplay for.</param>
/// <param name="state">The final user state.</param>
protected abstract void EndGameplay(int userId, SpectatorState state);
/// <param name="userId">The user to quit gameplay for.</param>
protected abstract void QuitGameplay(int userId);
/// <summary>
/// Stops spectating a user.
@ -223,10 +228,10 @@ namespace osu.Game.Screens.Spectate
/// <param name="userId">The user to stop spectating.</param>
protected void RemoveUser(int userId)
{
if (!userStates.TryGetValue(userId, out var state))
if (!userStates.ContainsKey(userId))
return;
onUserStateRemoved(userId, state);
quitGameplay(userId);
users.Remove(userId);
userMap.Remove(userId);