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

Merge pull request #23976 from peppy/gameplay-sample-trigger-source-correctness

Adjust `GameplaySampleTriggerSource` to only switch samples when close enough to the next hit object
This commit is contained in:
Bartłomiej Dach 2023-06-25 08:23:55 +02:00 committed by GitHub
commit 25842105ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 183 additions and 82 deletions

View File

@ -6,7 +6,6 @@ using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Timing; using osu.Framework.Timing;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
@ -17,14 +16,13 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.Taiko.UI; using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual; using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Taiko.Tests namespace osu.Game.Rulesets.Taiko.Tests
{ {
public partial class TestSceneDrumSampleTriggerSource : OsuTestScene public partial class TestSceneDrumSampleTriggerSource : OsuTestScene
{ {
private readonly ManualClock manualClock = new ManualClock();
[Cached(typeof(IScrollingInfo))] [Cached(typeof(IScrollingInfo))]
private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo
{ {
@ -34,23 +32,25 @@ namespace osu.Game.Rulesets.Taiko.Tests
private ScrollingHitObjectContainer hitObjectContainer = null!; private ScrollingHitObjectContainer hitObjectContainer = null!;
private TestDrumSampleTriggerSource triggerSource = null!; private TestDrumSampleTriggerSource triggerSource = null!;
private readonly ManualClock manualClock = new TestManualClock();
private GameplayClockContainer gameplayClock = null!;
[SetUp] [SetUp]
public void SetUp() => Schedule(() => public void SetUp() => Schedule(() =>
{ {
hitObjectContainer = new ScrollingHitObjectContainer(); gameplayClock = new GameplayClockContainer(manualClock)
manualClock.CurrentTime = 0;
Child = new Container
{ {
Clock = new FramedClock(manualClock),
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = new Drawable[] Children = new Drawable[]
{ {
hitObjectContainer, hitObjectContainer = new ScrollingHitObjectContainer(),
triggerSource = new TestDrumSampleTriggerSource(hitObjectContainer) triggerSource = new TestDrumSampleTriggerSource(hitObjectContainer)
} }
}; };
gameplayClock.Reset(0);
hitObjectContainer.Clock = gameplayClock;
Child = gameplayClock;
}); });
[Test] [Test]
@ -75,7 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
AddStep("seek past hit", () => manualClock.CurrentTime = 200); seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
@ -103,12 +103,67 @@ namespace osu.Game.Rulesets.Taiko.Tests
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
AddStep("seek past hit", () => manualClock.CurrentTime = 200); seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
} }
[Test]
public void TestBetweenHits()
{
Hit first = null!, second = null!;
AddStep("add hit with normal samples", () =>
{
first = new Hit
{
StartTime = 100,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL)
}
};
first.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
var drawableHit = new DrawableHit(first);
hitObjectContainer.Add(drawableHit);
});
AddStep("add hit with soft samples", () =>
{
second = new Hit
{
StartTime = 500,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT),
new HitSampleInfo(HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT)
}
};
second.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
var drawableHit = new DrawableHit(second);
hitObjectContainer.Add(drawableHit);
});
AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
seekTo(120);
AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
seekTo(480);
AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(700);
AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
}
[Test] [Test]
public void TestDrumStrongHit() public void TestDrumStrongHit()
{ {
@ -128,11 +183,11 @@ namespace osu.Game.Rulesets.Taiko.Tests
hitObjectContainer.Add(drawableHit); hitObjectContainer.Add(drawableHit);
}); });
AddAssert("most valid object is strong nested hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit.StrongNestedHit>); AddAssert("most valid object is nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit.StrongNestedHit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
AddStep("seek past hit", () => manualClock.CurrentTime = 200); seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
@ -161,12 +216,12 @@ namespace osu.Game.Rulesets.Taiko.Tests
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
@ -195,12 +250,12 @@ namespace osu.Game.Rulesets.Taiko.Tests
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
@ -226,16 +281,16 @@ namespace osu.Game.Rulesets.Taiko.Tests
hitObjectContainer.Add(drawableDrumRoll); hitObjectContainer.Add(drawableDrumRoll);
}); });
AddAssert("most valid object is drum roll tick's nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick.StrongNestedHit>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); seekTo(600);
AddAssert("most valid object is drum roll tick's nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick.StrongNestedHit>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
@ -260,16 +315,19 @@ namespace osu.Game.Rulesets.Taiko.Tests
hitObjectContainer.Add(drawableSwell); hitObjectContainer.Add(drawableSwell);
}); });
AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<SwellTick>); // You might think that this should be a SwellTick since we're before the swell, but SwellTicks get no StartTime (ie. they are zero).
// This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits.
// But for sample playback purposes they can be ignored as noise.
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
AddStep("seek to middle of swell", () => manualClock.CurrentTime = 600); seekTo(600);
AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<SwellTick>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
AddStep("seek past swell", () => manualClock.CurrentTime = 1200); seekTo(1200);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
@ -294,16 +352,19 @@ namespace osu.Game.Rulesets.Taiko.Tests
hitObjectContainer.Add(drawableSwell); hitObjectContainer.Add(drawableSwell);
}); });
AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<SwellTick>); // You might think that this should be a SwellTick since we're before the swell, but SwellTicks get no StartTime (ie. they are zero).
// This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits.
// But for sample playback purposes they can be ignored as noise.
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
AddStep("seek to middle of swell", () => manualClock.CurrentTime = 600); seekTo(600);
AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<SwellTick>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
AddStep("seek past swell", () => manualClock.CurrentTime = 1200); seekTo(1200);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
@ -316,6 +377,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Single().Bank, () => Is.EqualTo(expectedBank)); AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Single().Bank, () => Is.EqualTo(expectedBank));
} }
private void seekTo(double time) => AddStep($"seek to {time}", () => gameplayClock.Seek(time));
private partial class TestDrumSampleTriggerSource : DrumSampleTriggerSource private partial class TestDrumSampleTriggerSource : DrumSampleTriggerSource
{ {
public ISampleInfo[]? LastPlayedSamples { get; private set; } public ISampleInfo[]? LastPlayedSamples { get; private set; }
@ -331,7 +394,33 @@ namespace osu.Game.Rulesets.Taiko.Tests
LastPlayedSamples = samples; LastPlayedSamples = samples;
} }
public new HitObject GetMostValidObject() => base.GetMostValidObject(); public new HitObject? GetMostValidObject() => base.GetMostValidObject();
}
private class TestManualClock : ManualClock, IAdjustableClock
{
public TestManualClock()
{
IsRunning = true;
}
public void Start() => IsRunning = true;
public void Stop() => IsRunning = false;
public bool Seek(double position)
{
CurrentTime = position;
return true;
}
public void Reset()
{
}
public void ResetSpeedAdjustments()
{
}
} }
} }
} }

View File

@ -18,6 +18,7 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Storyboards; using osu.Game.Storyboards;
using osuTK; using osuTK;
@ -62,25 +63,30 @@ namespace osu.Game.Tests.Visual.Gameplay
{ {
new HitCircle new HitCircle
{ {
HitWindows = new HitWindows(),
StartTime = t += spacing, StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
}, },
new HitCircle new HitCircle
{ {
HitWindows = new HitWindows(),
StartTime = t += spacing, StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) } Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) }
}, },
new HitCircle new HitCircle
{ {
HitWindows = new HitWindows(),
StartTime = t += spacing, StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) }, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) },
}, },
new HitCircle new HitCircle
{ {
HitWindows = new HitWindows(),
StartTime = t += spacing, StartTime = t += spacing,
}, },
new Slider new Slider
{ {
HitWindows = new HitWindows(),
StartTime = t += spacing, StartTime = t += spacing,
Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, Vector2.UnitY * 200 }), Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, Vector2.UnitY * 200 }),
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT) }, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT) },
@ -101,7 +107,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{ {
base.SetUpSteps(); base.SetUpSteps();
AddStep("Add trigger source", () => Player.HUDOverlay.Add(sampleTriggerSource = new TestGameplaySampleTriggerSource(Player.DrawableRuleset.Playfield.HitObjectContainer))); AddStep("Add trigger source", () => Player.GameplayClockContainer.Add(sampleTriggerSource = new TestGameplaySampleTriggerSource(Player.DrawableRuleset.Playfield.HitObjectContainer)));
} }
[Test] [Test]
@ -131,7 +137,12 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("first object hit", () => getNextAliveObject()?.Entry?.Result?.HasResult == true); AddAssert("first object hit", () => getNextAliveObject()?.Entry?.Result?.HasResult == true);
checkValidObjectIndex(1); // next object is too far away, so we still use the already hit object.
checkValidObjectIndex(0);
// still too far away.
seekBeforeIndex(1, 400);
checkValidObjectIndex(0);
// Still object 1 as it's not hit yet. // Still object 1 as it's not hit yet.
seekBeforeIndex(1); seekBeforeIndex(1);
@ -168,9 +179,9 @@ namespace osu.Game.Tests.Visual.Gameplay
checkValidObjectIndex(4); checkValidObjectIndex(4);
} }
private void seekBeforeIndex(int index) private void seekBeforeIndex(int index, double amount = 100)
{ {
AddStep($"seek to just before object {index}", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects[index].StartTime - 100)); AddStep($"seek to {amount} ms before object {index}", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects[index].StartTime - amount));
waitForCatchUp(); waitForCatchUp();
} }
@ -204,7 +215,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{ {
} }
public new HitObject GetMostValidObject() => base.GetMostValidObject(); public new HitObject? GetMostValidObject() => base.GetMostValidObject();
} }
} }
} }

View File

@ -1,13 +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.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Rulesets.UI namespace osu.Game.Rulesets.UI
@ -28,6 +29,11 @@ namespace osu.Game.Rulesets.UI
private readonly Container<SkinnableSound> hitSounds; private readonly Container<SkinnableSound> hitSounds;
private HitObjectLifetimeEntry? mostValidObject;
[Resolved]
private IGameplayClock? gameplayClock { get; set; }
public GameplaySampleTriggerSource(HitObjectContainer hitObjectContainer) public GameplaySampleTriggerSource(HitObjectContainer hitObjectContainer)
{ {
this.hitObjectContainer = hitObjectContainer; this.hitObjectContainer = hitObjectContainer;
@ -39,14 +45,12 @@ namespace osu.Game.Rulesets.UI
}; };
} }
private HitObjectLifetimeEntry fallbackObject;
/// <summary> /// <summary>
/// Play the most appropriate hit sound for the current point in time. /// Play the most appropriate hit sound for the current point in time.
/// </summary> /// </summary>
public virtual void Play() public virtual void Play()
{ {
var nextObject = GetMostValidObject(); HitObject? nextObject = GetMostValidObject();
if (nextObject == null) if (nextObject == null)
return; return;
@ -65,64 +69,61 @@ namespace osu.Game.Rulesets.UI
hitSound.Play(); hitSound.Play();
}); });
protected HitObject GetMostValidObject() protected HitObject? GetMostValidObject()
{ {
// The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time. if (mostValidObject == null || isAlreadyHit(mostValidObject))
var drawableHitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true);
if (drawableHitObject != null)
{
// A hit object may have a more valid nested object.
drawableHitObject = getMostValidNestedDrawable(drawableHitObject);
return drawableHitObject.HitObject;
}
// In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play.
// This lookup can be skipped if the last entry is still valid (in the future and not yet hit).
if (fallbackObject == null || fallbackObject.Result?.HasResult == true)
{ {
// We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty). // We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty).
// If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager. // If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager.
fallbackObject = hitObjectContainer.Entries var candidate =
.Where(e => e.Result?.HasResult != true).MinBy(e => e.HitObject.StartTime); // Use alive entries first as an optimisation.
hitObjectContainer.AliveEntries.Select(tuple => tuple.Entry).Where(e => !isAlreadyHit(e)).MinBy(e => e.HitObject.StartTime)
if (fallbackObject != null) ?? hitObjectContainer.Entries.Where(e => !isAlreadyHit(e)).MinBy(e => e.HitObject.StartTime);
return getEarliestNestedObject(fallbackObject.HitObject);
// In the case there are no non-judged objects, the last hit object should be used instead. // In the case there are no non-judged objects, the last hit object should be used instead.
fallbackObject ??= hitObjectContainer.Entries.LastOrDefault(); if (candidate == null)
{
mostValidObject = hitObjectContainer.Entries.LastOrDefault();
}
else
{
if (isCloseEnoughToCurrentTime(candidate.HitObject))
{
mostValidObject = candidate;
}
else
{
mostValidObject ??= hitObjectContainer.Entries.FirstOrDefault();
}
}
} }
if (fallbackObject == null) if (mostValidObject == null)
return null; return null;
bool fallbackHasResult = fallbackObject.Result?.HasResult == true;
// If the fallback has been judged then we want the sample from the object itself. // If the fallback has been judged then we want the sample from the object itself.
if (fallbackHasResult) if (isAlreadyHit(mostValidObject))
return fallbackObject.HitObject; return mostValidObject.HitObject;
// Else we want the earliest (including nested). // Else we want the earliest valid nested.
// In cases of nested objects, they will always have earlier sample data than their parent object. // In cases of nested objects, they will always have earlier sample data than their parent object.
return getEarliestNestedObject(fallbackObject.HitObject); return getAllNested(mostValidObject.HitObject).OrderBy(h => h.GetEndTime()).SkipWhile(h => h.GetEndTime() <= getReferenceTime()).FirstOrDefault() ?? mostValidObject.HitObject;
} }
private DrawableHitObject getMostValidNestedDrawable(DrawableHitObject o) private bool isAlreadyHit(HitObjectLifetimeEntry h) => h.Result?.HasResult == true;
private bool isCloseEnoughToCurrentTime(HitObject h) => getReferenceTime() >= h.StartTime - h.HitWindows.WindowFor(HitResult.Miss) * 2;
private double getReferenceTime() => gameplayClock?.CurrentTime ?? Clock.CurrentTime;
private IEnumerable<HitObject> getAllNested(HitObject hitObject)
{ {
var nestedWithoutResult = o.NestedHitObjects.FirstOrDefault(n => n.Result?.HasResult != true); foreach (var h in hitObject.NestedHitObjects)
{
yield return h;
if (nestedWithoutResult == null) foreach (var n in getAllNested(h))
return o; yield return n;
return getMostValidNestedDrawable(nestedWithoutResult);
} }
private HitObject getEarliestNestedObject(HitObject hitObject)
{
var nested = hitObject.NestedHitObjects.FirstOrDefault();
return nested != null ? getEarliestNestedObject(nested) : hitObject;
} }
private SkinnableSound getNextSample() private SkinnableSound getNextSample()