Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPreciseTapping.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// 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.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osuTK;

namespace osu.Game.Rulesets.Osu.Tests.Mods
{
public partial class TestSceneOsuModPreciseTapping : OsuModTestScene
{
[Test]
public void TestPressWithoutHitMissesNextObject() => CreateModTest(new ModTestData
{
Mod = new OsuModPreciseTapping(),
PassCondition = () => Player.ScoreProcessor.Statistics.GetValueOrDefault(HitResult.Miss) == 1,
Autoplay = false,
CreateBeatmap = () => 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(250, new Vector2(300, 100), OsuAction.LeftButton),
new OsuReplayFrame(251, new Vector2(300, 100)),
new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
}
});

[Test]
public void TestExtraPressDoesNotChainMissNextObject() => CreateModTest(new ModTestData
{
Mod = new OsuModPreciseTapping(),
PassCondition = () => Player.ScoreProcessor.Statistics.GetValueOrDefault(HitResult.Miss) == 1
&& Player.ScoreProcessor.Statistics.GetValueOrDefault(HitResult.Great) == 1,
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 500,
Position = new Vector2(100),
},
new HitCircle
{
StartTime = 700,
Position = new Vector2(200, 100),
},
},
},
ReplayFrames = new List<ReplayFrame>
{
new OsuReplayFrame(400, new Vector2(300, 300), OsuAction.LeftButton),
new OsuReplayFrame(401, new Vector2(300, 300)),
new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
new OsuReplayFrame(501, new Vector2(100)),
new OsuReplayFrame(700, new Vector2(200, 100), OsuAction.LeftButton),
new OsuReplayFrame(701, new Vector2(200, 100)),
}
});

[Test]
public void TestExtraPressDuringSliderMissesNextObject() => CreateModTest(new ModTestData
{
Mod = new OsuModPreciseTapping(),
PassCondition = () => Player.ScoreProcessor.Statistics.GetValueOrDefault(HitResult.Miss) == 1,
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
new Slider
{
StartTime = 500,
Position = new Vector2(100),
Path = new SliderPath(PathType.LINEAR, new[]
{
Vector2.Zero,
new Vector2(100, 0),
}),
},
new HitCircle
{
StartTime = 1500,
Position = new Vector2(300, 100),
},
},
},
ReplayFrames = new List<ReplayFrame>
{
new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton),
new OsuReplayFrame(700, new Vector2(150, 100), OsuAction.LeftButton, OsuAction.RightButton),
new OsuReplayFrame(701, new Vector2(150, 100), OsuAction.LeftButton),
new OsuReplayFrame(900, new Vector2(200, 100)),
new OsuReplayFrame(1500, new Vector2(300, 100), OsuAction.LeftButton),
new OsuReplayFrame(1501, new Vector2(300, 100)),
}
});

[Test]
public void TestPressBlockedByAlternateIsNotCountedAsExtra() => CreateModTest(new ModTestData
{
Mods = new Mod[] { new OsuModAlternate(), new OsuModPreciseTapping() },
PassCondition = () => Player.ScoreProcessor.Statistics.GetValueOrDefault(HitResult.Great) == 2
&& Player.ScoreProcessor.Statistics.GetValueOrDefault(HitResult.Miss) == 0,
Autoplay = false,
CreateBeatmap = () => 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(700, new Vector2(150, 100), OsuAction.LeftButton),
new OsuReplayFrame(701, new Vector2(150, 100)),
new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.RightButton),
new OsuReplayFrame(1001, new Vector2(200, 100)),
}
});
}
}
140 changes: 140 additions & 0 deletions osu.Game.Rulesets.Osu/Mods/OsuModPreciseTapping.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// 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.Framework.Localisation;
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.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Utils;

namespace osu.Game.Rulesets.Osu.Mods
{
public partial class OsuModPreciseTapping : Mod, IApplicableToDrawableRuleset<OsuHitObject>, IUpdatableByPlayfield
{
public override string Name => @"Precise Tapping";
public override string Acronym => @"PT";
public override LocalisableString Description => @"Extra key presses count as misses.";
public override double ScoreMultiplier => 1.0;
public override ModType Type => ModType.Conversion;
public override bool Ranked => true;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(OsuModCinema) };

private DrawableOsuRuleset ruleset = null!;

private PeriodTracker nonGameplayPeriods = null!;

private IFrameStableClock gameplayClock = null!;

private bool penaltyActive = true;

public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{
ruleset = (DrawableOsuRuleset)drawableRuleset;
ruleset.Playfield.AttachInputInterceptor(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;
}

public void Update(Playfield playfield)
{
if (gameplayClock.IsRewinding || nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime))
penaltyActive = true;
}

private DrawableHitCircle? getNextTappable()
{
foreach (var alive in ruleset.Playfield.HitObjectContainer.AliveObjects)
{
DrawableHitCircle? circle = alive switch
{
DrawableSlider slider => slider.HeadCircle,
DrawableHitCircle hitCircle => hitCircle,
_ => null
};

if (circle != null && circle.Result?.HasResult != true)
return circle;
}

return null;
}

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

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

public bool OnPressed(KeyBindingPressEvent<OsuAction> e)
{
if (e.Action != OsuAction.LeftButton && e.Action != OsuAction.RightButton)
return false;

if (mod.nonGameplayPeriods.IsInAny(mod.gameplayClock.CurrentTime))
return false;

if (mod.gameplayClock.IsRewinding)
return false;

var tappable = mod.getNextTappable();

if (tappable != null)
{
bool wasActive = mod.penaltyActive;

Scheduler.Add(() =>
{
if (tappable.Result?.IsHit == true)
{
mod.penaltyActive = true;
return;
}

if (tappable.Result?.HasResult == true)
return;

if (!wasActive)
return;

tappable.MissForcefully();
mod.penaltyActive = false;
});
}

return false;
}

public void OnReleased(KeyBindingReleaseEvent<OsuAction> e)
{
}
}
}
}
3 changes: 2 additions & 1 deletion osu.Game.Rulesets.Osu/OsuRuleset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ public override IEnumerable<Mod> GetModsFor(ModType type)
new OsuModClassic(),
new OsuModRandom(),
new OsuModMirror(),
new MultiMod(new OsuModAlternate(), new OsuModSingleTap())
new MultiMod(new OsuModAlternate(), new OsuModSingleTap()),
new OsuModPreciseTapping()
};

case ModType.Automation:
Expand Down
2 changes: 2 additions & 0 deletions osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ public void AttachResumeOverlayInputBlocker(OsuResumeOverlay.OsuResumeOverlayInp
AddInternal(resumeInputBlocker);
}

public void AttachInputInterceptor(Drawable interceptor) => AddInternal(interceptor);

private partial class ProxyContainer : LifetimeManagementContainer
{
public void Add(Drawable proxy) => AddInternal(proxy);
Expand Down
Loading