1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 18:07:23 +08:00

Merge branch 'master' into skin-size-editing

This commit is contained in:
Bartłomiej Dach 2023-11-10 18:19:27 +09:00
commit c522a703eb
No known key found for this signature in database
37 changed files with 697 additions and 78 deletions

View File

@ -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

View File

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

View File

@ -0,0 +1,204 @@
// 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.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<OsuModTouchDevice>());
}
[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<OsuModTouchDevice>());
}
[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<OsuModTouchDevice>());
}
[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<OsuModTouchDevice>());
}
[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<OsuModTouchDevice>());
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<OsuModTouchDevice>());
}
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,
})
{
}
}
}
}

View File

@ -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());

View File

@ -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();
}
/// <summary>
@ -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();
}
/// <summary>
@ -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();
}
/// <summary>
@ -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();
}
/// <summary>
@ -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();
}
/// <summary>
@ -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();
}
/// <summary>
@ -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();
}
/// <summary>
@ -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<ReplayFrame> frames, Slider? slider = null, double? bpm = null, int? tickRate = null)
{

View File

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

View File

@ -1,18 +1,14 @@
// 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 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();
}
}

View File

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

View File

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

View File

@ -1,10 +1,6 @@
// 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 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;
}
}
}

View File

@ -9,16 +9,28 @@ namespace osu.Game.Rulesets.Osu.Objects
{
public class SliderTailCircle : SliderEndCircle
{
/// <summary>
/// Whether to treat this <see cref="SliderHeadCircle"/> as a normal <see cref="HitCircle"/> for judgement purposes.
/// If <c>false</c>, this <see cref="SliderHeadCircle"/> will be judged as a <see cref="SliderTick"/> instead.
/// </summary>
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;
}
}
}

View File

@ -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[]

View File

@ -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)

View File

@ -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();

View File

@ -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<IDialogOverlay>().CurrentDialog is ConfirmExitDialog);
}
[Test]
public void TestTouchScreenDetectionAtSongSelect()
{
AddStep("touch logo", () =>
{
var button = Game.ChildrenOfType<OsuLogo>().Single();
var touch = new Touch(TouchSource.Touch1, button.ScreenSpaceDrawQuad.Centre);
InputManager.BeginTouch(touch);
InputManager.EndTouch(touch);
});
AddAssert("touch screen detected active", () => Game.Dependencies.Get<SessionStatics>().Get<bool>(Static.TouchInputActive), () => Is.True);
AddStep("click settings button", () =>
{
var button = Game.ChildrenOfType<MainMenuButton>().Last();
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
AddAssert("touch screen detected inactive", () => Game.Dependencies.Get<SessionStatics>().Get<bool>(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<BeatmapInfoWedge>().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<ModTouchDevice>());
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<ModTouchDevice>());
AddStep("touch beatmap wedge", () =>
{
var wedge = Game.ChildrenOfType<BeatmapInfoWedge>().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<ModTouchDevice>());
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<ModTouchDevice>());
AddStep("click beatmap wedge", () =>
{
InputManager.MoveMouseTo(Game.ChildrenOfType<BeatmapInfoWedge>().Single());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("touch device mod not activated", () => Game.SelectedMods.Value, () => Has.None.InstanceOf<ModTouchDevice>());
}
[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<ModTouchDevice>());
AddStep("exit player", () => player.Exit());
AddUntilStep("touch device mod still active", () => Game.SelectedMods.Value, () => Has.One.InstanceOf<ModTouchDevice>());
}
private Func<Player> playToResults()
{
var player = playToCompletion();

View File

@ -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<AddPresetButton>().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<AddPresetButton>().Single().Enabled.Value);
AddStep("click add preset button", () =>
@ -209,6 +215,9 @@ namespace osu.Game.Tests.Visual.UserInterface
});
AddUntilStep("popover closed", () => !this.ChildrenOfType<OsuPopover>().Any());
AddUntilStep("preset creation occurred", () => this.ChildrenOfType<ModPresetPanel>().Count() == 4);
AddAssert("preset has correct mods",
() => this.ChildrenOfType<ModPresetPanel>().Single(panel => panel.Preset.Value.Name == "new preset").Preset.Value.Mods,
() => Has.Count.EqualTo(2));
AddStep("click add preset button", () =>
{

View File

@ -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<Mod> mods)

View File

@ -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<APISeasonalBackgrounds>(Static.SeasonalBackgrounds, null);
SetDefault(Static.TouchInputActive, RuntimeInfo.IsMobile);
}
/// <summary>
@ -63,6 +66,12 @@ namespace osu.Game.Configuration
/// The last playback time in milliseconds of an on/off sample (from <see cref="ModSelectPanel"/>).
/// Used to debounce <see cref="ModSelectPanel"/> on/off sounds game-wide to avoid volume saturation, especially in activating mod presets with many mods.
/// </summary>
LastModSelectPanelSamplePlaybackTime
LastModSelectPanelSamplePlaybackTime,
/// <summary>
/// Whether the last positional input received was a touch input.
/// Used in touchscreen detection scenarios (<see cref="TouchInputInterceptor"/>).
/// </summary>
TouchInputActive,
}
}

View File

@ -0,0 +1,72 @@
// 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.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
{
/// <summary>
/// Intercepts all positional input events and sets the appropriate <see cref="Static.TouchInputActive"/> value
/// for consumption by particular game screens.
/// </summary>
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();
}
}
}

View File

@ -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);

View File

@ -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<RulesetInfo>(ruleset.Value.ShortName)!
}));

View File

@ -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()

View File

@ -1,7 +1,6 @@
// 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.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<Mod>();
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<Mod>(Preset.Value.Mods).SetEquals(selectedMods.Value);
Active.Value = new HashSet<Mod>(Preset.Value.Mods).SetEquals(selectedMods.Value.Where(mod => mod.Type != ModType.System));
}
#region Filtering support

View File

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

View File

@ -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
{

View File

@ -59,6 +59,13 @@ namespace osu.Game.Rulesets.Mods
/// </summary>
bool ValidForMultiplayerAsFreeMod { get; }
/// <summary>
/// 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. <see cref="ModTouchDevice"/>).
/// </summary>
bool AlwaysValidForSubmission { get; }
/// <summary>
/// Create a fresh <see cref="Mod"/> instance based on this mod.
/// </summary>

View File

@ -156,6 +156,10 @@ namespace osu.Game.Rulesets.Mods
[JsonIgnore]
public virtual bool ValidForMultiplayerAsFreeMod => true;
/// <inheritdoc/>
[JsonIgnore]
public virtual bool AlwaysValidForSubmission => false;
/// <summary>
/// Whether this mod requires configuration to apply changes to the game.
/// </summary>

View File

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

View File

@ -0,0 +1,22 @@
// 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 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) };
}
}

View File

@ -204,6 +204,8 @@ namespace osu.Game.Rulesets
public ModAutoplay? GetAutoplayMod() => CreateMod<ModAutoplay>();
public ModTouchDevice? GetTouchDeviceMod() => CreateMod<ModTouchDevice>();
/// <summary>
/// Create a transformer which adds lookups specific to a ruleset to skin sources.
/// </summary>

View File

@ -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.");

View File

@ -1,6 +1,7 @@
// 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 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<bool>(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<bool>(RuntimeInfo.IsDesktop ? OsuSetting.MouseDisableButtons : OsuSetting.TouchDisableGameplayTaps)
}
};
}

View File

@ -0,0 +1,60 @@
// 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.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<bool> touchActive = new BindableBool();
[BackgroundDependencyLoader]
private void load(SessionStatics statics)
{
touchActive = statics.GetBindable<bool>(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<ModTouchDevice>().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;
}
}
}

View File

@ -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();

View File

@ -279,6 +279,7 @@ namespace osu.Game.Screens.Select
{
RelativeSizeAxes = Axes.Both,
},
new SongSelectTouchInputDetector()
});
if (ShowFooter)

View File

@ -0,0 +1,69 @@
// 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 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<RulesetInfo> ruleset { get; set; } = null!;
[Resolved]
private Bindable<IReadOnlyList<Mod>> mods { get; set; } = null!;
private IBindable<bool> touchActive = null!;
[BackgroundDependencyLoader]
private void load(SessionStatics statics)
{
touchActive = statics.GetBindable<bool>(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();
}
}
}

View File

@ -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);
}
/// <summary>