diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index e7c628e365..d4150208d3 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -185,9 +185,11 @@ jobs: - name: Add comment environment if: ${{ github.event_name == 'issue_comment' }} + env: + COMMENT_BODY: ${{ github.event.comment.body }} run: | # Add comment environment - echo '${{ github.event.comment.body }}' | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do + echo $COMMENT_BODY | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do opt=$(echo ${line} | cut -d '=' -f1) sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}" done diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index dea70e6b27..52cfb67f42 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -11,6 +11,7 @@ using osu.Framework.Input.Handlers; using osu.Framework.Platform; using osu.Game; using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections.Input; using osu.Game.Updater; using osu.Game.Utils; @@ -97,6 +98,9 @@ namespace osu.Android case AndroidJoystickHandler jh: return new AndroidJoystickSettings(jh); + case AndroidTouchHandler th: + return new TouchSettings(th); + default: return base.CreateSettingsSubsectionFor(handler); } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModTouchDevice.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModTouchDevice.cs new file mode 100644 index 0000000000..cd51ccd751 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModTouchDevice.cs @@ -0,0 +1,204 @@ +// 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.Framework.Allocation; +using osu.Framework.Input; +using osu.Framework.Screens; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Configuration; +using osu.Game.Input; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModTouchDevice : RateAdjustedBeatmapTestScene + { + [Resolved] + private SessionStatics statics { get; set; } = null!; + + private ScoreAccessibleSoloPlayer currentPlayer = null!; + private readonly ManualClock manualClock = new ManualClock { Rate = 0 }; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) + => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(manualClock), Audio); + + [BackgroundDependencyLoader] + private void load() + { + Add(new TouchInputInterceptor()); + } + + public override void SetUpSteps() + { + AddStep("reset static", () => statics.SetValue(Static.TouchInputActive, false)); + base.SetUpSteps(); + } + + [Test] + public void TestUserAlreadyHasTouchDeviceActive() + { + loadPlayer(); + // it is presumed that a previous screen (i.e. song select) will set this up + AddStep("set up touchscreen user", () => + { + currentPlayer.Score.ScoreInfo.Mods = currentPlayer.Score.ScoreInfo.Mods.Append(new OsuModTouchDevice()).ToArray(); + statics.SetValue(Static.TouchInputActive, true); + }); + + AddStep("seek to 0", () => currentPlayer.GameplayClockContainer.Seek(0)); + AddUntilStep("wait until 0", () => currentPlayer.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(0)); + AddStep("touch circle", () => + { + var touch = new Touch(TouchSource.Touch1, currentPlayer.DrawableRuleset.Playfield.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddAssert("touch device mod activated", () => currentPlayer.Score.ScoreInfo.Mods, () => Has.One.InstanceOf()); + } + + [Test] + public void TestTouchDuringBreak() + { + loadPlayer(); + AddStep("seek to 2000", () => currentPlayer.GameplayClockContainer.Seek(2000)); + AddUntilStep("wait until 2000", () => currentPlayer.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(2000)); + AddUntilStep("wait until break entered", () => currentPlayer.IsBreakTime.Value); + AddStep("touch playfield", () => + { + var touch = new Touch(TouchSource.Touch1, currentPlayer.DrawableRuleset.Playfield.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddAssert("touch device mod not activated", () => currentPlayer.Score.ScoreInfo.Mods, () => Has.None.InstanceOf()); + } + + [Test] + public void TestTouchMiss() + { + loadPlayer(); + // ensure mouse is active (and that it's not suppressed due to touches in previous tests) + AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); + + AddStep("seek to 200", () => currentPlayer.GameplayClockContainer.Seek(200)); + AddUntilStep("wait until 200", () => currentPlayer.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(200)); + AddStep("touch playfield", () => + { + var touch = new Touch(TouchSource.Touch1, currentPlayer.DrawableRuleset.Playfield.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddAssert("touch device mod activated", () => currentPlayer.Score.ScoreInfo.Mods, () => Has.One.InstanceOf()); + } + + [Test] + public void TestIncompatibleModActive() + { + loadPlayer(); + // this is only a veneer of enabling autopilot as having it actually active from the start is annoying to make happen + // given the tests' structure. + AddStep("enable autopilot", () => currentPlayer.Score.ScoreInfo.Mods = new Mod[] { new OsuModAutopilot() }); + + AddStep("seek to 0", () => currentPlayer.GameplayClockContainer.Seek(0)); + AddUntilStep("wait until 0", () => currentPlayer.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(0)); + AddStep("touch playfield", () => + { + var touch = new Touch(TouchSource.Touch1, currentPlayer.DrawableRuleset.Playfield.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddAssert("touch device mod not activated", () => currentPlayer.Score.ScoreInfo.Mods, () => Has.None.InstanceOf()); + } + + [Test] + public void TestSecondObjectTouched() + { + loadPlayer(); + // ensure mouse is active (and that it's not suppressed due to touches in previous tests) + AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); + + AddStep("seek to 0", () => currentPlayer.GameplayClockContainer.Seek(0)); + AddUntilStep("wait until 0", () => currentPlayer.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(0)); + AddStep("click circle", () => + { + InputManager.MoveMouseTo(currentPlayer.DrawableRuleset.Playfield.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Left); + }); + AddAssert("touch device mod not activated", () => currentPlayer.Score.ScoreInfo.Mods, () => Has.None.InstanceOf()); + + AddStep("seek to 5000", () => currentPlayer.GameplayClockContainer.Seek(5000)); + AddUntilStep("wait until 5000", () => currentPlayer.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(5000)); + AddStep("touch playfield", () => + { + var touch = new Touch(TouchSource.Touch1, currentPlayer.DrawableRuleset.Playfield.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddAssert("touch device mod activated", () => currentPlayer.Score.ScoreInfo.Mods, () => Has.One.InstanceOf()); + } + + private void loadPlayer() + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new OsuBeatmap + { + HitObjects = + { + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 0, + }, + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 5000, + }, + }, + Breaks = + { + new BreakPeriod(2000, 3000) + } + }); + + var p = new ScoreAccessibleSoloPlayer(); + + LoadScreen(currentPlayer = p); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + } + + private partial class ScoreAccessibleSoloPlayer : SoloPlayer + { + public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; + + public new DrawableRuleset DrawableRuleset => base.DrawableRuleset; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleSoloPlayer() + : base(new PlayerConfiguration + { + AllowPause = false, + ShowResults = false, + }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 7b7deb9c67..fa7454b435 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.710442985146793d, 206, "diffcalc-test")] - [TestCase(1.4386882251130073d, 45, "zero-length-sliders")] - [TestCase(0.42506480230838789d, 2, "very-fast-slider")] - [TestCase(0.14102693012101306d, 1, "nan-slider")] + [TestCase(6.710442985146793d, 239, "diffcalc-test")] + [TestCase(1.4386882251130073d, 54, "zero-length-sliders")] + [TestCase(0.42506480230838789d, 4, "very-fast-slider")] + [TestCase(0.14102693012101306d, 2, "nan-slider")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(8.9742952703071666d, 206, "diffcalc-test")] - [TestCase(0.55071082800473514d, 2, "very-fast-slider")] - [TestCase(1.743180218215227d, 45, "zero-length-sliders")] + [TestCase(8.9742952703071666d, 239, "diffcalc-test")] + [TestCase(1.743180218215227d, 54, "zero-length-sliders")] + [TestCase(0.55071082800473514d, 4, "very-fast-slider")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); [TestCase(6.710442985146793d, 239, "diffcalc-test")] - [TestCase(0.42506480230838789d, 4, "very-fast-slider")] [TestCase(1.4386882251130073d, 54, "zero-length-sliders")] + [TestCase(0.42506480230838789d, 4, "very-fast-slider")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index f718a5069f..c9d721d1c4 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -157,7 +157,7 @@ namespace osu.Game.Rulesets.Osu.Tests if (hit) assertAllMaxJudgements(); else - AddAssert("Tracking dropped", assertMidSliderJudgementFail); + assertMidSliderJudgementFail(); AddAssert("Head judgement is first", () => judgementResults.First().HitObject is SliderHeadCircle); @@ -197,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_during_slide_1 }, }); - AddAssert("Tracking lost", assertMidSliderJudgementFail); + assertMidSliderJudgementFail(); } /// @@ -278,7 +278,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_before_slider }, }); - AddAssert("Tracking retained, sliderhead miss", assertHeadMissTailTracked); + assertHeadMissTailTracked(); } /// @@ -302,7 +302,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_during_slide_4 }, }); - AddAssert("Tracking re-acquired", assertMidSliderJudgements); + assertMidSliderJudgements(); } /// @@ -328,7 +328,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_during_slide_4 }, }); - AddAssert("Tracking lost", assertMidSliderJudgementFail); + assertMidSliderJudgementFail(); } /// @@ -350,7 +350,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_during_slide_4 }, }); - AddAssert("Tracking acquired", assertMidSliderJudgements); + assertMidSliderJudgements(); } /// @@ -373,7 +373,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_during_slide_2 }, }); - AddAssert("Tracking acquired", assertMidSliderJudgements); + assertMidSliderJudgements(); } [Test] @@ -387,7 +387,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_2 }, }); - AddAssert("Tracking acquired", assertMidSliderJudgements); + assertMidSliderJudgements(); } /// @@ -412,7 +412,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_during_slide_4 }, }); - AddAssert("Tracking acquired", assertMidSliderJudgements); + assertMidSliderJudgements(); } /// @@ -454,7 +454,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(slider_path_length, OsuHitObject.OBJECT_RADIUS * 1.201f), Actions = { OsuAction.LeftButton }, Time = time_slider_end }, }); - AddAssert("Tracking dropped", assertMidSliderJudgementFail); + assertMidSliderJudgementFail(); } private void assertAllMaxJudgements() @@ -465,11 +465,21 @@ namespace osu.Game.Rulesets.Osu.Tests }, () => Is.EqualTo(judgementResults.Select(j => (j.HitObject, j.Judgement.MaxResult)))); } - private bool assertHeadMissTailTracked() => judgementResults[^2].Type == HitResult.SmallTickHit && !judgementResults.First().IsHit; + private void assertHeadMissTailTracked() + { + AddAssert("Tracking retained", () => judgementResults[^2].Type, () => Is.EqualTo(HitResult.LargeTickHit)); + AddAssert("Slider head missed", () => judgementResults.First().IsHit, () => Is.False); + } - private bool assertMidSliderJudgements() => judgementResults[^2].Type == HitResult.SmallTickHit; + private void assertMidSliderJudgements() + { + AddAssert("Tracking acquired", () => judgementResults[^2].Type, () => Is.EqualTo(HitResult.LargeTickHit)); + } - private bool assertMidSliderJudgementFail() => judgementResults[^2].Type == HitResult.SmallTickMiss; + private void assertMidSliderJudgementFail() + { + AddAssert("Tracking lost", () => judgementResults[^2].Type, () => Is.EqualTo(HitResult.IgnoreMiss)); + } private void performTest(List frames, Slider? slider = null, double? bpm = null, int? tickRate = null) { diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 3841c9c716..56bf0e08e9 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -33,7 +33,8 @@ namespace osu.Game.Rulesets.Osu.Mods typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised), - typeof(OsuModRepel) + typeof(OsuModRepel), + typeof(ModTouchDevice) }; public bool PerformFail() => false; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs index fd5c46a226..f1468d414e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs @@ -1,18 +1,14 @@ // 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.Linq; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModTouchDevice : Mod + public class OsuModTouchDevice : ModTouchDevice { - public override string Name => "Touch Device"; - public override string Acronym => "TD"; - public override LocalisableString Description => "Automatically applied to plays on devices with a touchscreen."; - public override double ScoreMultiplier => 1; - - public override ModType Type => ModType.System; + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 7f2d9592af..506145568e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -135,6 +135,8 @@ namespace osu.Game.Rulesets.Osu.Objects classicSliderBehaviour = value; if (HeadCircle != null) HeadCircle.ClassicSliderBehaviour = value; + if (TailCircle != null) + TailCircle.ClassicSliderBehaviour = value; } } @@ -218,6 +220,7 @@ namespace osu.Game.Rulesets.Osu.Objects StartTime = e.Time, Position = EndPosition, StackHeight = StackHeight, + ClassicSliderBehaviour = ClassicSliderBehaviour, }); break; @@ -273,9 +276,9 @@ namespace osu.Game.Rulesets.Osu.Objects } public override Judgement CreateJudgement() => ClassicSliderBehaviour - // See logic in `DrawableSlider.CheckForResult()` + // Final combo is provided by the slider itself - see logic in `DrawableSlider.CheckForResult()` ? new OsuJudgement() - // Of note, this creates a combo discrepancy for non-classic-mod sliders (there is no combo increase for tail or slider judgement). + // Final combo is provided by the tail circle - see `SliderTailCircle` : new OsuIgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; diff --git a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs index ddbbb300ca..88a34fcb8f 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs @@ -3,6 +3,8 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects @@ -43,5 +45,12 @@ namespace osu.Game.Rulesets.Osu.Objects } protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public override Judgement CreateJudgement() => new SliderEndJudgement(); + + public class SliderEndJudgement : OsuJudgement + { + public override HitResult MaxResult => HitResult.LargeTickHit; + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs index cca86361c2..e95cfd369d 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs @@ -1,10 +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 osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Osu.Judgements; -using osu.Game.Rulesets.Scoring; - namespace osu.Game.Rulesets.Osu.Objects { public class SliderRepeat : SliderEndCircle @@ -13,12 +9,5 @@ namespace osu.Game.Rulesets.Osu.Objects : base(slider) { } - - public override Judgement CreateJudgement() => new SliderRepeatJudgement(); - - public class SliderRepeatJudgement : OsuJudgement - { - public override HitResult MaxResult => HitResult.LargeTickHit; - } } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs index 54d2afb444..357476ed30 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs @@ -9,16 +9,28 @@ namespace osu.Game.Rulesets.Osu.Objects { public class SliderTailCircle : SliderEndCircle { + /// + /// Whether to treat this as a normal for judgement purposes. + /// If false, this will be judged as a instead. + /// + public bool ClassicSliderBehaviour; + public SliderTailCircle(Slider slider) : base(slider) { } - public override Judgement CreateJudgement() => new SliderTailJudgement(); + public override Judgement CreateJudgement() => ClassicSliderBehaviour ? new LegacyTailJudgement() : new TailJudgement(); - public class SliderTailJudgement : OsuJudgement + public class LegacyTailJudgement : OsuJudgement { public override HitResult MaxResult => HitResult.SmallTickHit; } + + public class TailJudgement : SliderEndJudgement + { + public override HitResult MaxResult => HitResult.LargeTickHit; + public override HitResult MinResult => HitResult.IgnoreMiss; + } } } diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index aa41fd830b..9107ddd1ae 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -147,11 +147,11 @@ namespace osu.Game.Tests.Mods new Mod[] { new OsuModDeflate(), new OsuModApproachDifferent() }, new[] { typeof(OsuModDeflate), typeof(OsuModApproachDifferent) } }, - // system mod. + // system mod not applicable in lazer. new object[] { - new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, - new[] { typeof(OsuModTouchDevice) } + new Mod[] { new OsuModHidden(), new ModScoreV2() }, + new[] { typeof(ModScoreV2) } }, // multi mod. new object[] diff --git a/osu.Game.Tests/Rulesets/Scoring/HitResultTest.cs b/osu.Game.Tests/Rulesets/Scoring/HitResultTest.cs index 68d7335055..e003c9c534 100644 --- a/osu.Game.Tests/Rulesets/Scoring/HitResultTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/HitResultTest.cs @@ -11,9 +11,9 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestFixture] public class HitResultTest { - [TestCase(new[] { HitResult.Perfect, HitResult.Great, HitResult.Good, HitResult.Ok, HitResult.Meh }, new[] { HitResult.Miss })] - [TestCase(new[] { HitResult.LargeTickHit }, new[] { HitResult.LargeTickMiss })] - [TestCase(new[] { HitResult.SmallTickHit }, new[] { HitResult.SmallTickMiss })] + [TestCase(new[] { HitResult.Perfect, HitResult.Great, HitResult.Good, HitResult.Ok, HitResult.Meh }, new[] { HitResult.Miss, HitResult.IgnoreMiss })] + [TestCase(new[] { HitResult.LargeTickHit }, new[] { HitResult.LargeTickMiss, HitResult.IgnoreMiss })] + [TestCase(new[] { HitResult.SmallTickHit }, new[] { HitResult.SmallTickMiss, HitResult.IgnoreMiss })] [TestCase(new[] { HitResult.LargeBonus, HitResult.SmallBonus }, new[] { HitResult.IgnoreMiss })] [TestCase(new[] { HitResult.IgnoreHit }, new[] { HitResult.IgnoreMiss, HitResult.ComboBreak })] public void TestValidResultPairs(HitResult[] maxResults, HitResult[] minResults) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index 66ba908879..f1674401cd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -133,6 +133,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private bool assertAllAvailableModsSelected() { var allAvailableMods = availableMods.Value + .Where(pair => pair.Key != ModType.System) .SelectMany(pair => pair.Value) .Where(mod => mod.UserPlayable && mod.HasImplementation) .ToList(); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 9e743ef336..c6a668a714 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -12,6 +12,7 @@ using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -835,6 +836,110 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("exit dialog is shown", () => Game.Dependencies.Get().CurrentDialog is ConfirmExitDialog); } + [Test] + public void TestTouchScreenDetectionAtSongSelect() + { + AddStep("touch logo", () => + { + var button = Game.ChildrenOfType().Single(); + var touch = new Touch(TouchSource.Touch1, button.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddAssert("touch screen detected active", () => Game.Dependencies.Get().Get(Static.TouchInputActive), () => Is.True); + + AddStep("click settings button", () => + { + var button = Game.ChildrenOfType().Last(); + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + AddAssert("touch screen detected inactive", () => Game.Dependencies.Get().Get(Static.TouchInputActive), () => Is.False); + + AddStep("close settings sidebar", () => InputManager.Key(Key.Escape)); + + Screens.Select.SongSelect songSelect = null; + AddRepeatStep("go to solo", () => InputManager.Key(Key.P), 3); + AddUntilStep("wait for song select", () => (songSelect = Game.ScreenStack.CurrentScreen as Screens.Select.SongSelect) != null); + AddUntilStep("wait for beatmap sets loaded", () => songSelect.BeatmapSetsLoaded); + + AddStep("switch to osu! ruleset", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.Number1); + InputManager.ReleaseKey(Key.LControl); + }); + AddStep("touch beatmap wedge", () => + { + var wedge = Game.ChildrenOfType().Single(); + var touch = new Touch(TouchSource.Touch2, wedge.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddUntilStep("touch device mod activated", () => Game.SelectedMods.Value, () => Has.One.InstanceOf()); + + AddStep("switch to mania ruleset", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.Number4); + InputManager.ReleaseKey(Key.LControl); + }); + AddUntilStep("touch device mod not activated", () => Game.SelectedMods.Value, () => Has.None.InstanceOf()); + AddStep("touch beatmap wedge", () => + { + var wedge = Game.ChildrenOfType().Single(); + var touch = new Touch(TouchSource.Touch2, wedge.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddUntilStep("touch device mod not activated", () => Game.SelectedMods.Value, () => Has.None.InstanceOf()); + + AddStep("switch to osu! ruleset", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.Number1); + InputManager.ReleaseKey(Key.LControl); + }); + AddUntilStep("touch device mod activated", () => Game.SelectedMods.Value, () => Has.One.InstanceOf()); + + AddStep("click beatmap wedge", () => + { + InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("touch device mod not activated", () => Game.SelectedMods.Value, () => Has.None.InstanceOf()); + } + + [Test] + public void TestTouchScreenDetectionInGame() + { + PushAndConfirm(() => new TestPlaySongSelect()); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + AddStep("select", () => InputManager.Key(Key.Enter)); + + Player player = null; + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return (player = Game.ScreenStack.CurrentScreen as Player) != null; + }); + + AddUntilStep("wait for track playing", () => Game.Beatmap.Value.Track.IsRunning); + + AddStep("touch", () => + { + var touch = new Touch(TouchSource.Touch2, Game.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + InputManager.EndTouch(touch); + }); + AddUntilStep("touch device mod added to score", () => player.Score.ScoreInfo.Mods, () => Has.One.InstanceOf()); + + AddStep("exit player", () => player.Exit()); + AddUntilStep("touch device mod still active", () => Game.SelectedMods.Value, () => Has.One.InstanceOf()); + } + private Func playToResults() { var player = playToCompletion(); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs index 1779b240cc..b7c1428397 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -167,7 +167,7 @@ namespace osu.Game.Tests.Visual.UserInterface } [Test] - public void TestAddingFlow() + public void TestAddingFlow([Values] bool withSystemModActive) { ModPresetColumn modPresetColumn = null!; @@ -181,7 +181,13 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("items loaded", () => modPresetColumn.IsLoaded && modPresetColumn.ItemsLoaded); AddAssert("add preset button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); - AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModDaycore(), new OsuModClassic() }); + AddStep("set mods", () => + { + var newMods = new Mod[] { new OsuModDaycore(), new OsuModClassic() }; + if (withSystemModActive) + newMods = newMods.Append(new OsuModTouchDevice()).ToArray(); + SelectedMods.Value = newMods; + }); AddAssert("add preset button enabled", () => this.ChildrenOfType().Single().Enabled.Value); AddStep("click add preset button", () => @@ -209,6 +215,9 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddUntilStep("popover closed", () => !this.ChildrenOfType().Any()); AddUntilStep("preset creation occurred", () => this.ChildrenOfType().Count() == 4); + AddAssert("preset has correct mods", + () => this.ChildrenOfType().Single(panel => panel.Preset.Value.Name == "new preset").Preset.Value.Mods, + () => Has.Count.EqualTo(2)); AddStep("click add preset button", () => { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs index 35e352534b..c79cbd3691 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs @@ -86,6 +86,10 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("set mods to HD+HR+DT", () => SelectedMods.Value = new Mod[] { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime() }); AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + // system mods are not included in presets. + AddStep("set mods to HR+DT+TD", () => SelectedMods.Value = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime(), new OsuModTouchDevice() }); + AddAssert("panel is active", () => panel.AsNonNull().Active.Value); } [Test] @@ -113,6 +117,10 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("set customised mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); assertSelectedModsEquivalentTo(new Mod[] { new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } }); + + AddStep("set system mod", () => SelectedMods.Value = new[] { new OsuModTouchDevice() }); + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(new Mod[] { new OsuModTouchDevice(), new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } }); } private void assertSelectedModsEquivalentTo(IEnumerable mods) diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 5e2f0c2128..8f0a60b23d 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -3,7 +3,9 @@ #nullable disable +using osu.Framework; using osu.Game.Graphics.UserInterface; +using osu.Game.Input; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Mods; @@ -24,6 +26,7 @@ namespace osu.Game.Configuration SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null); SetDefault(Static.LastModSelectPanelSamplePlaybackTime, (double?)null); SetDefault(Static.SeasonalBackgrounds, null); + SetDefault(Static.TouchInputActive, RuntimeInfo.IsMobile); } /// @@ -63,6 +66,12 @@ namespace osu.Game.Configuration /// The last playback time in milliseconds of an on/off sample (from ). /// Used to debounce on/off sounds game-wide to avoid volume saturation, especially in activating mod presets with many mods. /// - LastModSelectPanelSamplePlaybackTime + LastModSelectPanelSamplePlaybackTime, + + /// + /// Whether the last positional input received was a touch input. + /// Used in touchscreen detection scenarios (). + /// + TouchInputActive, } } diff --git a/osu.Game/Input/TouchInputInterceptor.cs b/osu.Game/Input/TouchInputInterceptor.cs new file mode 100644 index 0000000000..368d8469ae --- /dev/null +++ b/osu.Game/Input/TouchInputInterceptor.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; +using osu.Framework.Logging; +using osu.Game.Configuration; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Input +{ + /// + /// Intercepts all positional input events and sets the appropriate value + /// for consumption by particular game screens. + /// + public partial class TouchInputInterceptor : Component + { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + private readonly BindableBool touchInputActive = new BindableBool(); + + [BackgroundDependencyLoader] + private void load(SessionStatics statics) + { + statics.BindWith(Static.TouchInputActive, touchInputActive); + } + + protected override bool Handle(UIEvent e) + { + bool touchInputWasActive = touchInputActive.Value; + + switch (e) + { + case MouseEvent: + if (e.CurrentState.Mouse.LastSource is not ISourcedFromTouch) + { + if (touchInputWasActive) + Logger.Log($@"Touch input deactivated due to received {e.GetType().ReadableName()}", LoggingTarget.Input); + touchInputActive.Value = false; + } + + break; + + case TouchEvent: + if (!touchInputWasActive) + Logger.Log($@"Touch input activated due to received {e.GetType().ReadableName()}", LoggingTarget.Input); + touchInputActive.Value = true; + break; + + case KeyDownEvent keyDown: + if (keyDown.Key == Key.T && keyDown.ControlPressed && keyDown.ShiftPressed) + debugToggleTouchInputActive(); + break; + } + + return false; + } + + [Conditional("TOUCH_INPUT_DEBUG")] + private void debugToggleTouchInputActive() + { + Logger.Log($@"Debug-toggling touch input to {(touchInputActive.Value ? @"inactive" : @"active")}", LoggingTarget.Information, LogLevel.Important); + touchInputActive.Toggle(); + } + } +} diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 1f46eb0c0d..228edc8952 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -407,6 +407,8 @@ namespace osu.Game }) }); + base.Content.Add(new TouchInputInterceptor()); + KeyBindingStore = new RealmKeyBindingStore(realm, keyCombinationProvider); KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); @@ -575,14 +577,14 @@ namespace osu.Game case JoystickHandler jh: return new JoystickSettings(jh); - - case TouchHandler th: - return new TouchSettings(th); } } switch (handler) { + case TouchHandler th: + return new TouchSettings(th); + case MidiHandler: return new InputSection.HandlerSection(handler); diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs index 638592a9b5..b782b5d6ba 100644 --- a/osu.Game/Overlays/Mods/AddPresetPopover.cs +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -115,7 +115,7 @@ namespace osu.Game.Overlays.Mods { Name = nameTextBox.Current.Value, Description = descriptionTextBox.Current.Value, - Mods = selectedMods.Value.ToArray(), + Mods = selectedMods.Value.Where(mod => mod.Type != ModType.System).ToArray(), Ruleset = r.Find(ruleset.Value.ShortName)! })); diff --git a/osu.Game/Overlays/Mods/EditPresetPopover.cs b/osu.Game/Overlays/Mods/EditPresetPopover.cs index 571021b0f8..8bce57c96a 100644 --- a/osu.Game/Overlays/Mods/EditPresetPopover.cs +++ b/osu.Game/Overlays/Mods/EditPresetPopover.cs @@ -153,7 +153,7 @@ namespace osu.Game.Overlays.Mods private void useCurrentMods() { - saveableMods = selectedMods.Value.ToHashSet(); + saveableMods = selectedMods.Value.Where(mod => mod.Type != ModType.System).ToHashSet(); updateState(); } @@ -168,7 +168,7 @@ namespace osu.Game.Overlays.Mods if (!selectedMods.Value.Any()) return false; - return !saveableMods.SetEquals(selectedMods.Value); + return !saveableMods.SetEquals(selectedMods.Value.Where(mod => mod.Type != ModType.System)); } private void save() diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs index 00f6e36972..3982abeba7 100644 --- a/osu.Game/Overlays/Mods/ModPresetPanel.cs +++ b/osu.Game/Overlays/Mods/ModPresetPanel.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; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -56,17 +55,14 @@ namespace osu.Game.Overlays.Mods protected override void Select() { - // if the preset is not active at the point of the user click, then set the mods using the preset directly, discarding any previous selections, - // which will also have the side effect of activating the preset (see `updateActiveState()`). - selectedMods.Value = Preset.Value.Mods.ToArray(); + var selectedSystemMods = selectedMods.Value.Where(mod => mod.Type == ModType.System); + // will also have the side effect of activating the preset (see `updateActiveState()`). + selectedMods.Value = Preset.Value.Mods.Concat(selectedSystemMods).ToArray(); } protected override void Deselect() { - // if the preset is active when the user has clicked it, then it means that the set of active mods is exactly equal to the set of mods in the preset - // (there are no other active mods than what the preset specifies, and the mod settings match exactly). - // therefore it's safe to just clear selected mods, since it will have the effect of toggling the preset off. - selectedMods.Value = Array.Empty(); + selectedMods.Value = selectedMods.Value.Except(Preset.Value.Mods).ToArray(); } private void selectedModsChanged() @@ -79,7 +75,7 @@ namespace osu.Game.Overlays.Mods private void updateActiveState() { - Active.Value = new HashSet(Preset.Value.Mods).SetEquals(selectedMods.Value); + Active.Value = new HashSet(Preset.Value.Mods).SetEquals(selectedMods.Value.Where(mod => mod.Type != ModType.System)); } #region Filtering support diff --git a/osu.Game/Overlays/Mods/SelectAllModsButton.cs b/osu.Game/Overlays/Mods/SelectAllModsButton.cs index bb61cdc35d..b6b3051a0d 100644 --- a/osu.Game/Overlays/Mods/SelectAllModsButton.cs +++ b/osu.Game/Overlays/Mods/SelectAllModsButton.cs @@ -41,6 +41,7 @@ namespace osu.Game.Overlays.Mods private void updateEnabledState() { Enabled.Value = availableMods.Value + .Where(pair => pair.Key != ModType.System) .SelectMany(pair => pair.Value) .Any(modState => !modState.Active.Value && modState.Visible); } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs index 175fcc4709..0056de6674 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Input.Handlers; using osu.Framework.Localisation; @@ -28,11 +29,14 @@ namespace osu.Game.Overlays.Settings.Sections.Input [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig) { - Add(new SettingsCheckbox + if (!RuntimeInfo.IsMobile) // don't allow disabling the only input method (touch) on mobile. { - LabelText = CommonStrings.Enabled, - Current = handler.Enabled - }); + Add(new SettingsCheckbox + { + LabelText = CommonStrings.Enabled, + Current = handler.Enabled + }); + } Add(new SettingsCheckbox { diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index ce2d123884..744d02a4fa 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -59,6 +59,13 @@ namespace osu.Game.Rulesets.Mods /// bool ValidForMultiplayerAsFreeMod { get; } + /// + /// Indicates that this mod is always permitted in scenarios wherein a user is submitting a score regardless of other circumstances. + /// Intended for mods that are informational in nature and do not really affect gameplay by themselves, + /// but are more of a gauge of increased/decreased difficulty due to the user's configuration (e.g. ). + /// + bool AlwaysValidForSubmission { get; } + /// /// Create a fresh instance based on this mod. /// diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 49c2fdd394..775f6a0ed4 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -156,6 +156,10 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool ValidForMultiplayerAsFreeMod => true; + /// + [JsonIgnore] + public virtual bool AlwaysValidForSubmission => false; + /// /// Whether this mod requires configuration to apply changes to the game. /// diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index 973fcffba8..302cdf69c0 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Mods public sealed override bool ValidForMultiplayer => false; public sealed override bool ValidForMultiplayerAsFreeMod => false; - public override Type[] IncompatibleMods => new[] { typeof(ModCinema), typeof(ModRelax), typeof(ModAdaptiveSpeed) }; + public override Type[] IncompatibleMods => new[] { typeof(ModCinema), typeof(ModRelax), typeof(ModAdaptiveSpeed), typeof(ModTouchDevice) }; public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0; diff --git a/osu.Game/Rulesets/Mods/ModTouchDevice.cs b/osu.Game/Rulesets/Mods/ModTouchDevice.cs new file mode 100644 index 0000000000..b80b042f11 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModTouchDevice.cs @@ -0,0 +1,22 @@ +// 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.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; + +namespace osu.Game.Rulesets.Mods +{ + public class ModTouchDevice : Mod, IApplicableMod + { + public sealed override string Name => "Touch Device"; + public sealed override string Acronym => "TD"; + public sealed override IconUsage? Icon => OsuIcon.PlayStyleTouch; + public sealed override LocalisableString Description => "Automatically applied to plays on devices with a touchscreen."; + public sealed override double ScoreMultiplier => 1; + public sealed override ModType Type => ModType.System; + public sealed override bool AlwaysValidForSubmission => true; + public override Type[] IncompatibleMods => new[] { typeof(ICreateReplayData) }; + } +} diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index be0d757e06..f8aa6c9f57 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -204,6 +204,8 @@ namespace osu.Game.Rulesets public ModAutoplay? GetAutoplayMod() => CreateMod(); + public ModTouchDevice? GetTouchDeviceMod() => CreateMod(); + /// /// Create a transformer which adds lookups specific to a ruleset to skin sources. /// diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index fed338b012..6380b73558 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -350,6 +350,9 @@ namespace osu.Game.Rulesets.Scoring if (maxResult.IsBonus() && minResult != HitResult.IgnoreMiss) throw new ArgumentOutOfRangeException(nameof(minResult), $"{HitResult.IgnoreMiss} is the only valid minimum result for a {maxResult} judgement."); + if (minResult == HitResult.IgnoreMiss) + return; + if (maxResult == HitResult.LargeTickHit && minResult != HitResult.LargeTickMiss) throw new ArgumentOutOfRangeException(nameof(minResult), $"{HitResult.LargeTickMiss} is the only valid minimum result for a {maxResult} judgement."); diff --git a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs index 852fbd8dcc..1387e01305 100644 --- a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs @@ -1,6 +1,7 @@ // 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; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; @@ -22,8 +23,9 @@ namespace osu.Game.Screens.Play.PlayerSettings { new PlayerCheckbox { - LabelText = MouseSettingsStrings.DisableClicksDuringGameplay, - Current = config.GetBindable(OsuSetting.MouseDisableButtons) + // TODO: change to touchscreen detection once https://github.com/ppy/osu/pull/25348 makes it in + LabelText = RuntimeInfo.IsDesktop ? MouseSettingsStrings.DisableClicksDuringGameplay : TouchSettingsStrings.DisableTapsDuringGameplay, + Current = config.GetBindable(RuntimeInfo.IsDesktop ? OsuSetting.MouseDisableButtons : OsuSetting.TouchDisableGameplayTaps) } }; } diff --git a/osu.Game/Screens/Play/PlayerTouchInputDetector.cs b/osu.Game/Screens/Play/PlayerTouchInputDetector.cs new file mode 100644 index 0000000000..69c3cd0ded --- /dev/null +++ b/osu.Game/Screens/Play/PlayerTouchInputDetector.cs @@ -0,0 +1,60 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; + +namespace osu.Game.Screens.Play +{ + public partial class PlayerTouchInputDetector : Component + { + [Resolved] + private Player player { get; set; } = null!; + + [Resolved] + private GameplayState gameplayState { get; set; } = null!; + + private IBindable touchActive = new BindableBool(); + + [BackgroundDependencyLoader] + private void load(SessionStatics statics) + { + touchActive = statics.GetBindable(Static.TouchInputActive); + touchActive.BindValueChanged(_ => updateState()); + } + + private void updateState() + { + if (!touchActive.Value) + return; + + if (gameplayState.HasPassed || gameplayState.HasFailed || gameplayState.HasQuit) + return; + + if (gameplayState.Score.ScoreInfo.Mods.OfType().Any()) + return; + + if (player.IsBreakTime.Value) + return; + + var touchDeviceMod = gameplayState.Ruleset.GetTouchDeviceMod(); + if (touchDeviceMod == null) + return; + + var candidateMods = player.Score.ScoreInfo.Mods.Append(touchDeviceMod).ToArray(); + + if (!ModUtils.CheckCompatibleSet(candidateMods, out _)) + return; + + // `Player` (probably rightly so) assumes immutability of mods, + // so this will not be shown immediately on the mod display in the top right. + // if this is to change, the mod immutability should be revisited. + player.Score.ScoreInfo.Mods = candidateMods; + } + } +} diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index a75546f835..30fecbe149 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -44,6 +44,18 @@ namespace osu.Game.Screens.Play { } + [BackgroundDependencyLoader] + private void load() + { + if (DrawableRuleset == null) + { + // base load must have failed (e.g. due to an unknown mod); bail. + return; + } + + AddInternal(new PlayerTouchInputDetector()); + } + protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index dfea4e3794..03083672d5 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -279,6 +279,7 @@ namespace osu.Game.Screens.Select { RelativeSizeAxes = Axes.Both, }, + new SongSelectTouchInputDetector() }); if (ShowFooter) diff --git a/osu.Game/Screens/Select/SongSelectTouchInputDetector.cs b/osu.Game/Screens/Select/SongSelectTouchInputDetector.cs new file mode 100644 index 0000000000..b726acb45f --- /dev/null +++ b/osu.Game/Screens/Select/SongSelectTouchInputDetector.cs @@ -0,0 +1,69 @@ +// 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.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; + +namespace osu.Game.Screens.Select +{ + public partial class SongSelectTouchInputDetector : Component + { + [Resolved] + private Bindable ruleset { get; set; } = null!; + + [Resolved] + private Bindable> mods { get; set; } = null!; + + private IBindable touchActive = null!; + + [BackgroundDependencyLoader] + private void load(SessionStatics statics) + { + touchActive = statics.GetBindable(Static.TouchInputActive); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + mods.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + mods.BindDisabledChanged(_ => Scheduler.AddOnce(updateState)); + touchActive.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + updateState(); + } + + private void updateState() + { + if (mods.Disabled) + return; + + var touchDeviceMod = ruleset.Value.CreateInstance().GetTouchDeviceMod(); + + if (touchDeviceMod == null) + return; + + bool touchDeviceModEnabled = mods.Value.Any(mod => mod is ModTouchDevice); + + if (touchActive.Value && !touchDeviceModEnabled) + { + var candidateMods = mods.Value.Append(touchDeviceMod).ToArray(); + + if (!ModUtils.CheckCompatibleSet(candidateMods, out _)) + return; + + mods.Value = candidateMods; + } + + if (!touchActive.Value && touchDeviceModEnabled) + mods.Value = mods.Value.Where(mod => mod is not ModTouchDevice).ToArray(); + } + } +} diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index edf9cc80da..1bd60fcdde 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -121,7 +121,7 @@ namespace osu.Game.Utils if (!CheckCompatibleSet(mods, out invalidMods)) return false; - return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation, out invalidMods); + return checkValid(mods, m => m.HasImplementation, out invalidMods); } ///