Skip to content

Commit

Permalink
Merge pull request #19089 from tsunyoku/singletap-mod
Browse files Browse the repository at this point in the history
Add "Single Tap" mod for osu! ruleset, abstract Alternate & Single Tap into InputBlockingMod
  • Loading branch information
peppy authored Jul 13, 2022
2 parents 7ed0527 + 732d86b commit 02ffdd2
Show file tree
Hide file tree
Showing 8 changed files with 314 additions and 106 deletions.
175 changes: 175 additions & 0 deletions osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Collections.Generic;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Replays;
using osuTK;

namespace osu.Game.Rulesets.Osu.Tests.Mods
{
public class TestSceneOsuModSingleTap : OsuModTestScene
{
[Test]
public void TestInputSingular() => CreateModTest(new ModTestData
{
Mod = new OsuModSingleTap(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 2,
Autoplay = false,
Beatmap = new Beatmap
{
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 500,
Position = new Vector2(100),
},
new HitCircle
{
StartTime = 1000,
Position = new Vector2(200, 100),
},
new HitCircle
{
StartTime = 1500,
Position = new Vector2(300, 100),
},
new HitCircle
{
StartTime = 2000,
Position = new Vector2(400, 100),
},
},
},
ReplayFrames = new List<ReplayFrame>
{
new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
new OsuReplayFrame(501, new Vector2(100)),
new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.LeftButton),
}
});

[Test]
public void TestInputAlternating() => CreateModTest(new ModTestData
{
Mod = new OsuModSingleTap(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1,
Autoplay = false,
Beatmap = new Beatmap
{
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 500,
Position = new Vector2(100),
},
new HitCircle
{
StartTime = 1000,
Position = new Vector2(200, 100),
},
},
},
ReplayFrames = new List<ReplayFrame>
{
new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
new OsuReplayFrame(501, new Vector2(100)),
new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.RightButton),
new OsuReplayFrame(1001, new Vector2(200, 100)),
new OsuReplayFrame(1500, new Vector2(300, 100), OsuAction.LeftButton),
new OsuReplayFrame(1501, new Vector2(300, 100)),
new OsuReplayFrame(2000, new Vector2(400, 100), OsuAction.RightButton),
new OsuReplayFrame(2001, new Vector2(400, 100)),
}
});

/// <summary>
/// Ensures singletapping is reset before the first hitobject after intro.
/// </summary>
[Test]
public void TestInputAlternatingAtIntro() => CreateModTest(new ModTestData
{
Mod = new OsuModSingleTap(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 1,
Autoplay = false,
Beatmap = new Beatmap
{
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 1000,
Position = new Vector2(100),
},
},
},
ReplayFrames = new List<ReplayFrame>
{
// first press during intro.
new OsuReplayFrame(500, new Vector2(200), OsuAction.LeftButton),
new OsuReplayFrame(501, new Vector2(200)),
// press different key at hitobject and ensure it has been hit.
new OsuReplayFrame(1000, new Vector2(100), OsuAction.RightButton),
}
});

/// <summary>
/// Ensures singletapping is reset before the first hitobject after a break.
/// </summary>
[Test]
public void TestInputAlternatingWithBreak() => CreateModTest(new ModTestData
{
Mod = new OsuModSingleTap(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2,
Autoplay = false,
Beatmap = new Beatmap
{
Breaks = new List<BreakPeriod>
{
new BreakPeriod(500, 2000),
},
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 500,
Position = new Vector2(100),
},
new HitCircle
{
StartTime = 2500,
Position = new Vector2(500, 100),
},
new HitCircle
{
StartTime = 3000,
Position = new Vector2(500, 100),
},
}
},
ReplayFrames = new List<ReplayFrame>
{
// first press to start singletap lock.
new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
new OsuReplayFrame(501, new Vector2(100)),
// press different key after break but before hit object.
new OsuReplayFrame(2250, new Vector2(300, 100), OsuAction.RightButton),
new OsuReplayFrame(2251, new Vector2(300, 100)),
// press same key at second hitobject and ensure it has been hit.
new OsuReplayFrame(2500, new Vector2(500, 100), OsuAction.LeftButton),
new OsuReplayFrame(2501, new Vector2(500, 100)),
// press different key at third hitobject and ensure it has been missed.
new OsuReplayFrame(3000, new Vector2(500, 100), OsuAction.RightButton),
new OsuReplayFrame(3001, new Vector2(500, 100)),
}
});
}
}
114 changes: 114 additions & 0 deletions osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Linq;
using osu.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.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osu.Game.Utils;

namespace osu.Game.Rulesets.Osu.Mods
{
public abstract class InputBlockingMod : Mod, IApplicableToDrawableRuleset<OsuHitObject>
{
public override double ScoreMultiplier => 1.0;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(OsuModCinema) };
public override ModType Type => ModType.Conversion;

private const double flash_duration = 1000;

private DrawableRuleset<OsuHitObject> ruleset = null!;

protected OsuAction? LastAcceptedAction { get; private set; }

/// <summary>
/// A tracker for periods where alternate should not be forced (i.e. non-gameplay periods).
/// </summary>
/// <remarks>
/// This is different from <see cref="Player.IsBreakTime"/> in that the periods here end strictly at the first object after the break, rather than the break's end time.
/// </remarks>
private PeriodTracker nonGameplayPeriods = null!;

private IFrameStableClock gameplayClock = null!;

public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{
ruleset = drawableRuleset;
drawableRuleset.KeyBindingInputManager.Add(new InputInterceptor(this));

var periods = new List<Period>();

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;
}

protected abstract bool CheckValidNewAction(OsuAction action);

private bool checkCorrectAction(OsuAction action)
{
if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime))
{
LastAcceptedAction = null;
return true;
}

switch (action)
{
case OsuAction.LeftButton:
case OsuAction.RightButton:
break;

// Any action which is not left or right button should be ignored.
default:
return true;
}

if (CheckValidNewAction(action))
{
LastAcceptedAction = action;
return true;
}

ruleset.Cursor.FlashColour(Colour4.Red, flash_duration, Easing.OutQuint);
return false;
}

private class InputInterceptor : Component, IKeyBindingHandler<OsuAction>
{
private readonly InputBlockingMod mod;

public InputInterceptor(InputBlockingMod mod)
{
this.mod = mod;
}

public bool OnPressed(KeyBindingPressEvent<OsuAction> e)
// if the pressed action is incorrect, block it from reaching gameplay.
=> !mod.checkCorrectAction(e.Action);

public void OnReleased(KeyBindingReleaseEvent<OsuAction> e)
{
}
}
}
}
Loading

0 comments on commit 02ffdd2

Please sign in to comment.