diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs new file mode 100644 index 0000000000..0cd3b85f8e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs @@ -0,0 +1,212 @@ +// 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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public partial class TestSceneTaikoModSingleTap : TaikoModTestScene + { + [Test] + public void TestInputAlternate() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + new Hit + { + StartTime = 300, + Type = HitType.Rim + }, + new Hit + { + StartTime = 500, + Type = HitType.Rim + }, + new Hit + { + StartTime = 700, + Type = HitType.Rim + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(100, TaikoAction.RightRim), + new TaikoReplayFrame(120), + new TaikoReplayFrame(300, TaikoAction.LeftRim), + new TaikoReplayFrame(320), + new TaikoReplayFrame(500, TaikoAction.RightRim), + new TaikoReplayFrame(520), + new TaikoReplayFrame(700, TaikoAction.LeftRim), + new TaikoReplayFrame(720), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1 + }); + + [Test] + public void TestInputSameKey() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + new Hit + { + StartTime = 300, + Type = HitType.Rim + }, + new Hit + { + StartTime = 500, + Type = HitType.Rim + }, + new Hit + { + StartTime = 700, + Type = HitType.Rim + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(100, TaikoAction.RightRim), + new TaikoReplayFrame(120), + new TaikoReplayFrame(300, TaikoAction.RightRim), + new TaikoReplayFrame(320), + new TaikoReplayFrame(500, TaikoAction.RightRim), + new TaikoReplayFrame(520), + new TaikoReplayFrame(700, TaikoAction.RightRim), + new TaikoReplayFrame(720), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 4 + }); + + [Test] + public void TestInputIntro() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(0, TaikoAction.RightRim), + new TaikoReplayFrame(20), + new TaikoReplayFrame(100, TaikoAction.LeftRim), + new TaikoReplayFrame(120), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 1 + }); + + [Test] + public void TestInputStrong() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + new Hit + { + StartTime = 300, + Type = HitType.Rim, + IsStrong = true + }, + new Hit + { + StartTime = 500, + Type = HitType.Rim, + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(100, TaikoAction.RightRim), + new TaikoReplayFrame(120), + new TaikoReplayFrame(300, TaikoAction.LeftRim), + new TaikoReplayFrame(320), + new TaikoReplayFrame(500, TaikoAction.LeftRim), + new TaikoReplayFrame(520), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2 + }); + + [Test] + public void TestInputBreaks() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + Breaks = new List + { + new BreakPeriod(100, 1600), + }, + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + new Hit + { + StartTime = 2000, + Type = HitType.Rim, + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(100, TaikoAction.RightRim), + new TaikoReplayFrame(120), + // Press different key after break but before hit object. + new TaikoReplayFrame(1900, TaikoAction.LeftRim), + new TaikoReplayFrame(1820), + // Press original key at second hitobject and ensure it has been hit. + new TaikoReplayFrame(2000, TaikoAction.RightRim), + new TaikoReplayFrame(2020), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 2 + }); + } +} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs index 4b74b4991e..1e09061859 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs @@ -1,7 +1,9 @@ // 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 System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Replays; @@ -12,5 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new TaikoAutoGenerator(beatmap).Generate(), new ModCreatedUser { Username = "mekkadosu!" }); + + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(TaikoModSingleTap) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs index fee0cb2744..c268087f0a 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs @@ -1,7 +1,9 @@ // 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 System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -13,5 +15,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new TaikoAutoGenerator(beatmap).Generate(), new ModCreatedUser { Username = "mekkadosu!" }); + + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(TaikoModSingleTap) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs index 69a98f8372..e90ab589fc 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs @@ -1,6 +1,8 @@ // 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 System.Linq; using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; @@ -9,5 +11,7 @@ namespace osu.Game.Rulesets.Taiko.Mods public class TaikoModRelax : ModRelax { public override LocalisableString Description => @"No ninja-like spinners, demanding drumrolls or unexpected katus."; + + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(TaikoModSingleTap) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs new file mode 100644 index 0000000000..a5cffca06f --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs @@ -0,0 +1,127 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Utils; +using osu.Game.Rulesets.Taiko.UI; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public partial class TaikoModSingleTap : Mod, IApplicableToDrawableRuleset, IUpdatableByPlayfield + { + public override string Name => @"Single Tap"; + public override string Acronym => @"SG"; + public override LocalisableString Description => @"One key for dons, one key for kats."; + + public override double ScoreMultiplier => 1.0; + public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(TaikoModCinema) }; + public override ModType Type => ModType.Conversion; + + private DrawableTaikoRuleset ruleset = null!; + + private TaikoPlayfield playfield { get; set; } = null!; + + private TaikoAction? lastAcceptedCentreAction { get; set; } + private TaikoAction? lastAcceptedRimAction { get; set; } + + /// + /// A tracker for periods where single tap should not be enforced (i.e. non-gameplay periods). + /// + /// + /// This is different from in that the periods here end strictly at the first object after the break, rather than the break's end time. + /// + private PeriodTracker nonGameplayPeriods = null!; + + private IFrameStableClock gameplayClock = null!; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + ruleset = (DrawableTaikoRuleset)drawableRuleset; + ruleset.KeyBindingInputManager.Add(new InputInterceptor(this)); + playfield = (TaikoPlayfield)ruleset.Playfield; + + var periods = new List(); + + if (drawableRuleset.Objects.Any()) + { + periods.Add(new Period(int.MinValue, getValidJudgementTime(ruleset.Objects.First()) - 1)); + + foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks) + periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1)); + + static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh); + } + + nonGameplayPeriods = new PeriodTracker(periods); + + gameplayClock = drawableRuleset.FrameStableClock; + } + + public void Update(Playfield playfield) + { + if (!nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) return; + + lastAcceptedCentreAction = null; + lastAcceptedRimAction = null; + } + + private bool checkCorrectAction(TaikoAction action) + { + if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) + return true; + + // If next hit object is strong, allow usage of all actions. Strong drumrolls are ignored in this check. + if (playfield.HitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true)?.HitObject is TaikoStrongableHitObject hitObject + && hitObject.IsStrong + && hitObject is not DrumRoll) + return true; + + if ((action == TaikoAction.LeftCentre || action == TaikoAction.RightCentre) + && (lastAcceptedCentreAction == null || lastAcceptedCentreAction == action)) + { + lastAcceptedCentreAction = action; + return true; + } + + if ((action == TaikoAction.LeftRim || action == TaikoAction.RightRim) + && (lastAcceptedRimAction == null || lastAcceptedRimAction == action)) + { + lastAcceptedRimAction = action; + return true; + } + + return false; + } + + private partial class InputInterceptor : Component, IKeyBindingHandler + { + private readonly TaikoModSingleTap mod; + + public InputInterceptor(TaikoModSingleTap mod) + { + this.mod = mod; + } + + public bool OnPressed(KeyBindingPressEvent e) + // if the pressed action is incorrect, block it from reaching gameplay. + => !mod.checkCorrectAction(e.Action); + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 4f435e73b3..a35fdb890d 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -158,6 +158,7 @@ namespace osu.Game.Rulesets.Taiko new TaikoModDifficultyAdjust(), new TaikoModClassic(), new TaikoModSwap(), + new TaikoModSingleTap(), }; case ModType.Automation: diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 40203440c5..a08877e2dd 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -33,6 +33,8 @@ namespace osu.Game.Rulesets.Taiko.UI public readonly BindableBool LockPlayfieldMaxAspect = new BindableBool(true); + public new TaikoInputManager KeyBindingInputManager => (TaikoInputManager)base.KeyBindingInputManager; + protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping; protected override bool UserScrollSpeedAdjustment => false;