diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 5aff4e200b..9ac223a0d7 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -6,7 +6,6 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.Mania.UI; @@ -29,11 +28,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables [Resolved(canBeNull: true)] private ManiaPlayfield playfield { get; set; } - /// - /// Gets the samples that are played by this object during gameplay. - /// - public ISampleInfo[] GetGameplaySamples() => Samples.Samples; - protected override float SamplePlaybackPosition { get diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 9b5893b268..f5e30efd91 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,6 +18,7 @@ using osuTK; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania.UI { @@ -28,12 +28,6 @@ namespace osu.Game.Rulesets.Mania.UI public const float COLUMN_WIDTH = 80; public const float SPECIAL_COLUMN_WIDTH = 70; - /// - /// For hitsounds played by this (i.e. not as a result of hitting a hitobject), - /// a certain number of samples are allowed to be played concurrently so that it feels better when spam-pressing the key. - /// - private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY; - /// /// The index of this column as part of the whole playfield. /// @@ -45,10 +39,10 @@ namespace osu.Game.Rulesets.Mania.UI internal readonly Container TopLevelContainer; private readonly DrawablePool hitExplosionPool; private readonly OrderedHitPolicy hitPolicy; - private readonly Container hitSounds; - public Container UnderlayElements => HitObjectArea.UnderlayElements; + private readonly GameplaySampleTriggerSource sampleTriggerSource; + public Column(int index) { Index = index; @@ -64,6 +58,7 @@ namespace osu.Game.Rulesets.Mania.UI InternalChildren = new[] { hitExplosionPool = new DrawablePool(5), + sampleTriggerSource = new GameplaySampleTriggerSource(HitObjectContainer), // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both }, @@ -72,12 +67,6 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Both }, background, - hitSounds = new Container - { - Name = "Column samples pool", - RelativeSizeAxes = Axes.Both, - Children = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound()).ToArray() - }, TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both } }; @@ -133,29 +122,12 @@ namespace osu.Game.Rulesets.Mania.UI HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result))); } - private int nextHitSoundIndex; - public bool OnPressed(ManiaAction action) { if (action != Action.Value) return false; - var nextObject = - HitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ?? - // fallback to non-alive objects to find next off-screen object - HitObjectContainer.Objects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ?? - HitObjectContainer.Objects.LastOrDefault(); - - if (nextObject is DrawableManiaHitObject maniaObject) - { - var hitSound = hitSounds[nextHitSoundIndex]; - - hitSound.Samples = maniaObject.GetGameplaySamples(); - hitSound.Play(); - - nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds; - } - + sampleTriggerSource.Play(); return true; } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs index f9b8e9a985..269a855219 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Origin = Anchor.Centre, Children = new Drawable[] { - new TaikoPlayfield(new ControlPointInfo()), + new TaikoPlayfield(), hoc = new ScrollingHitObjectContainer() } }; @@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Origin = Anchor.Centre, Children = new Drawable[] { - new TaikoPlayfield(new ControlPointInfo()), + new TaikoPlayfield(), hoc = new ScrollingHitObjectContainer() } }; diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs index 055a292fe8..24db046748 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Taiko.UI; using osuTK; @@ -17,6 +16,13 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [BackgroundDependencyLoader] private void load() { + var playfield = new TaikoPlayfield(); + + var beatmap = CreateWorkingBeatmap(new TaikoRuleset().RulesetInfo).GetPlayableBeatmap(new TaikoRuleset().RulesetInfo); + + foreach (var h in beatmap.HitObjects) + playfield.Add(h); + SetContents(_ => new TaikoInputManager(new TaikoRuleset().RulesetInfo) { RelativeSizeAxes = Axes.Both, @@ -25,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200), - Child = new InputDrum(new ControlPointInfo()) + Child = new InputDrum(playfield.HitObjectContainer) } }); } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs index f96297a06d..6f2fcd08f1 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Beatmap.Value.Track.Start(); }); - AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield(new ControlPointInfo()) + AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, diff --git a/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs b/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs deleted file mode 100644 index e4dc261363..0000000000 --- a/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Skinning; - -namespace osu.Game.Rulesets.Taiko.Audio -{ - /// - /// Stores samples for the input drum. - /// The lifetime of the samples is adjusted so that they are only alive during the appropriate sample control point. - /// - public class DrumSampleContainer : LifetimeManagementContainer - { - private readonly ControlPointInfo controlPoints; - private readonly Dictionary mappings = new Dictionary(); - - private readonly IBindableList samplePoints = new BindableList(); - - public DrumSampleContainer(ControlPointInfo controlPoints) - { - this.controlPoints = controlPoints; - } - - [BackgroundDependencyLoader] - private void load() - { - samplePoints.BindTo(controlPoints.SamplePoints); - samplePoints.BindCollectionChanged((_, __) => recreateMappings(), true); - } - - private void recreateMappings() - { - mappings.Clear(); - ClearInternal(); - - SampleControlPoint[] points = samplePoints.Count == 0 - ? new[] { controlPoints.SamplePointAt(double.MinValue) } - : samplePoints.ToArray(); - - for (int i = 0; i < points.Length; i++) - { - var samplePoint = points[i]; - - var lifetimeStart = i > 0 ? samplePoint.Time : double.MinValue; - var lifetimeEnd = i + 1 < points.Length ? points[i + 1].Time : double.MaxValue; - - AddInternal(mappings[samplePoint.Time] = new DrumSample(samplePoint) - { - LifetimeStart = lifetimeStart, - LifetimeEnd = lifetimeEnd - }); - } - } - - public DrumSample SampleAt(double time) => mappings[controlPoints.SamplePointAt(time).Time]; - - public class DrumSample : CompositeDrawable - { - public override bool RemoveWhenNotAlive => false; - - public PausableSkinnableSound Centre { get; private set; } - public PausableSkinnableSound Rim { get; private set; } - - private readonly SampleControlPoint samplePoint; - - private Bindable sampleBank; - private BindableNumber sampleVolume; - - public DrumSample(SampleControlPoint samplePoint) - { - this.samplePoint = samplePoint; - } - - [BackgroundDependencyLoader] - private void load() - { - sampleBank = samplePoint.SampleBankBindable.GetBoundCopy(); - sampleBank.BindValueChanged(_ => recreate()); - - sampleVolume = samplePoint.SampleVolumeBindable.GetBoundCopy(); - sampleVolume.BindValueChanged(_ => recreate()); - - recreate(); - } - - private void recreate() - { - InternalChildren = new Drawable[] - { - Centre = new PausableSkinnableSound(samplePoint.GetSampleInfo()), - Rim = new PausableSkinnableSound(samplePoint.GetSampleInfo(HitSampleInfo.HIT_CLAP)) - }; - } - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs index 795885d4b9..9d35093591 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs @@ -7,7 +7,8 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Taiko.Audio; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; using osu.Game.Skinning; using osuTK; @@ -111,7 +112,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public readonly Sprite Centre; [Resolved] - private DrumSampleContainer sampleContainer { get; set; } + private DrumSampleTriggerSource sampleTriggerSource { get; set; } public LegacyHalfDrum(bool flipped) { @@ -143,17 +144,16 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public bool OnPressed(TaikoAction action) { Drawable target = null; - var drumSample = sampleContainer.SampleAt(Time.Current); if (action == CentreAction) { target = Centre; - drumSample.Centre?.Play(); + sampleTriggerSource.Play(HitType.Centre); } else if (action == RimAction) { target = Rim; - drumSample.Rim?.Play(); + sampleTriggerSource.Play(HitType.Rim); } if (target != null) diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 650ce1f5a3..6ddbf3c16b 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Taiko.UI protected override PassThroughInputManager CreateInputManager() => new TaikoInputManager(Ruleset.RulesetInfo); - protected override Playfield CreatePlayfield() => new TaikoPlayfield(Beatmap.ControlPointInfo); + protected override Playfield CreatePlayfield() => new TaikoPlayfield(); public override DrawableHitObject CreateDrawableRepresentation(TaikoHitObject h) => null; diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs new file mode 100644 index 0000000000..3279d128d3 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Audio; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public class DrumSampleTriggerSource : GameplaySampleTriggerSource + { + public DrumSampleTriggerSource(HitObjectContainer hitObjectContainer) + : base(hitObjectContainer) + { + } + + public void Play(HitType hitType) + { + var hitObject = GetMostValidObject(); + + if (hitObject == null) + return; + + PlaySamples(new ISampleInfo[] { hitObject.SampleControlPoint.GetSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL) }); + } + + public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead"); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index 1ca1be1bdf..ddfaf64549 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -2,18 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; -using osu.Game.Rulesets.Taiko.Audio; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.UI { @@ -25,11 +25,11 @@ namespace osu.Game.Rulesets.Taiko.UI private const float middle_split = 0.025f; [Cached] - private DrumSampleContainer sampleContainer; + private DrumSampleTriggerSource sampleTriggerSource; - public InputDrum(ControlPointInfo controlPoints) + public InputDrum(HitObjectContainer hitObjectContainer) { - sampleContainer = new DrumSampleContainer(controlPoints); + sampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer); RelativeSizeAxes = Axes.Both; } @@ -70,7 +70,7 @@ namespace osu.Game.Rulesets.Taiko.UI } } }), - sampleContainer + sampleTriggerSource }; } @@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Taiko.UI private readonly Sprite centreHit; [Resolved] - private DrumSampleContainer sampleContainer { get; set; } + private DrumSampleTriggerSource sampleTriggerSource { get; set; } public TaikoHalfDrum(bool flipped) { @@ -156,21 +156,19 @@ namespace osu.Game.Rulesets.Taiko.UI Drawable target = null; Drawable back = null; - var drumSample = sampleContainer.SampleAt(Time.Current); - if (action == CentreAction) { target = centreHit; back = centre; - drumSample.Centre?.Play(); + sampleTriggerSource.Play(HitType.Centre); } else if (action == RimAction) { target = rimHit; back = rim; - drumSample.Rim?.Play(); + sampleTriggerSource.Play(HitType.Rim); } if (target != null) diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 0d9e08b8b7..d650cab729 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -8,7 +8,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Judgements; @@ -27,8 +26,6 @@ namespace osu.Game.Rulesets.Taiko.UI { public class TaikoPlayfield : ScrollingPlayfield { - private readonly ControlPointInfo controlPoints; - /// /// Default height of a when inside a . /// @@ -56,11 +53,6 @@ namespace osu.Game.Rulesets.Taiko.UI private Container hitTargetOffsetContent; - public TaikoPlayfield(ControlPointInfo controlPoints) - { - this.controlPoints = controlPoints; - } - [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -131,7 +123,7 @@ namespace osu.Game.Rulesets.Taiko.UI Children = new Drawable[] { new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()), - new InputDrum(controlPoints) + new InputDrum(HitObjectContainer) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs new file mode 100644 index 0000000000..fccc1a377c --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -0,0 +1,135 @@ +// Copyright (c) ppy Pty Ltd . 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.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.UI; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneGameplaySampleTriggerSource : PlayerTestScene + { + private TestGameplaySampleTriggerSource sampleTriggerSource; + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + private Beatmap beatmap; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty { CircleSize = 6, SliderMultiplier = 3 }, + Ruleset = ruleset + } + }; + + const double start_offset = 8000; + const double spacing = 2000; + + double t = start_offset; + beatmap.HitObjects.AddRange(new[] + { + new HitCircle + { + // intentionally start objects a bit late so we can test the case of no alive objects. + StartTime = t += spacing, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }, + new HitCircle + { + StartTime = t += spacing, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) } + }, + new HitCircle + { + StartTime = t += spacing, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, + SampleControlPoint = new SampleControlPoint { SampleBank = "soft" }, + }, + new HitCircle + { + StartTime = t + spacing, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) }, + SampleControlPoint = new SampleControlPoint { SampleBank = "soft" }, + }, + }); + + return beatmap; + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("Add trigger source", () => Player.HUDOverlay.Add(sampleTriggerSource = new TestGameplaySampleTriggerSource(Player.DrawableRuleset.Playfield.HitObjectContainer))); + } + + [Test] + public void TestCorrectHitObject() + { + HitObjectLifetimeEntry nextObjectEntry = null; + + AddAssert("no alive objects", () => getNextAliveObject() == null); + + AddAssert("check initially correct object", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[0]); + + AddUntilStep("get next object", () => + { + var nextDrawableObject = getNextAliveObject(); + + if (nextDrawableObject != null) + { + nextObjectEntry = nextDrawableObject.Entry; + InputManager.MoveMouseTo(nextDrawableObject.ScreenSpaceDrawQuad.Centre); + return true; + } + + return false; + }); + + AddUntilStep("hit first hitobject", () => + { + InputManager.Click(MouseButton.Left); + return nextObjectEntry.Result.HasResult; + }); + + AddAssert("check correct object after hit", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[1]); + + AddUntilStep("check correct object after miss", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[2]); + AddUntilStep("check correct object after miss", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[3]); + + AddUntilStep("no alive objects", () => getNextAliveObject() == null); + AddAssert("check correct object after none alive", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[3]); + } + + private DrawableHitObject getNextAliveObject() => + Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(); + + [Test] + public void TestSampleTriggering() + { + AddRepeatStep("trigger sample", () => sampleTriggerSource.Play(), 10); + } + + public class TestGameplaySampleTriggerSource : GameplaySampleTriggerSource + { + public TestGameplaySampleTriggerSource(HitObjectContainer hitObjectContainer) + : base(hitObjectContainer) + { + } + + public new HitObject GetMostValidObject() => base.GetMostValidObject(); + } + } +} diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs new file mode 100644 index 0000000000..c18698f77e --- /dev/null +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics.Containers; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.UI +{ + /// + /// A component which can trigger the most appropriate hit sound for a given point in time, based on the state of a + /// + public class GameplaySampleTriggerSource : CompositeDrawable + { + /// + /// The number of concurrent samples allowed to be played concurrently so that it feels better when spam-pressing a key. + /// + private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY; + + private readonly HitObjectContainer hitObjectContainer; + + private int nextHitSoundIndex; + + private readonly Container hitSounds; + + public GameplaySampleTriggerSource(HitObjectContainer hitObjectContainer) + { + this.hitObjectContainer = hitObjectContainer; + + InternalChild = hitSounds = new Container + { + Name = "concurrent sample pool", + ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new PausableSkinnableSound()) + }; + } + + private HitObjectLifetimeEntry fallbackObject; + + /// + /// Play the most appropriate hit sound for the current point in time. + /// + public virtual void Play() + { + var nextObject = GetMostValidObject(); + + if (nextObject == null) + return; + + var samples = nextObject.Samples + .Select(s => nextObject.SampleControlPoint.ApplyTo(s)) + .Cast() + .ToArray(); + + PlaySamples(samples); + } + + protected void PlaySamples(ISampleInfo[] samples) + { + var hitSound = getNextSample(); + hitSound.Samples = samples; + hitSound.Play(); + } + + 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. + var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true)?.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. + if (hitObject == null) + { + // 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). + // If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager. + fallbackObject = hitObjectContainer.Entries + .Where(e => e.Result?.HasResult != true) + .OrderBy(e => e.HitObject.StartTime) + .FirstOrDefault(); + + // In the case there are no unjudged objects, the last hit object should be used instead. + fallbackObject ??= hitObjectContainer.Entries.LastOrDefault(); + } + + hitObject = fallbackObject?.HitObject; + } + + return hitObject; + } + + private SkinnableSound getNextSample() + { + SkinnableSound hitSound = hitSounds[nextHitSoundIndex]; + + // round robin over available samples to allow for concurrent playback. + nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds; + + return hitSound; + } + } +}