1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-22 09:27:34 +08:00

Merge pull request #22233 from peppy/osu-ruleset-multi-touch-basic

Add basic multi-touch support for osu! ruleset
This commit is contained in:
Bartłomiej Dach 2023-01-19 00:59:45 +01:00 committed by GitHub
commit 83c38a8960
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 519 additions and 17 deletions

View File

@ -0,0 +1,405 @@
// 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 System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Input.States;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Tests
{
[TestFixture]
public partial class TestSceneTouchInput : OsuManualInputManagerTestScene
{
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private TestActionKeyCounter leftKeyCounter = null!;
private TestActionKeyCounter rightKeyCounter = null!;
private OsuInputManager osuInputManager = null!;
[SetUpSteps]
public void SetUpSteps()
{
releaseAllTouches();
AddStep("Create tests", () =>
{
Children = new Drawable[]
{
osuInputManager = new OsuInputManager(new OsuRuleset().RulesetInfo)
{
Child = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new Drawable[]
{
leftKeyCounter = new TestActionKeyCounter(OsuAction.LeftButton)
{
Anchor = Anchor.Centre,
Origin = Anchor.CentreRight,
X = -100,
},
rightKeyCounter = new TestActionKeyCounter(OsuAction.RightButton)
{
Anchor = Anchor.Centre,
Origin = Anchor.CentreLeft,
X = 100,
}
},
}
},
new TouchVisualiser(),
};
});
}
[Test]
public void TestSimpleInput()
{
beginTouch(TouchSource.Touch1);
assertKeyCounter(1, 0);
checkPressed(OsuAction.LeftButton);
checkPosition(TouchSource.Touch1);
beginTouch(TouchSource.Touch2);
assertKeyCounter(1, 1);
checkPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
checkPosition(TouchSource.Touch2);
// Subsequent touches should be ignored (except position).
beginTouch(TouchSource.Touch3);
checkPosition(TouchSource.Touch3);
beginTouch(TouchSource.Touch4);
checkPosition(TouchSource.Touch4);
assertKeyCounter(1, 1);
checkPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
assertKeyCounter(1, 1);
}
[Test]
public void TestPositionalInputUpdatesOnlyFromMostRecentTouch()
{
beginTouch(TouchSource.Touch1);
checkPosition(TouchSource.Touch1);
beginTouch(TouchSource.Touch2);
checkPosition(TouchSource.Touch2);
beginTouch(TouchSource.Touch1, Vector2.One);
checkPosition(TouchSource.Touch2);
endTouch(TouchSource.Touch2);
checkPosition(TouchSource.Touch2);
// note that touch1 was never ended, but becomes active for tracking again.
beginTouch(TouchSource.Touch1);
checkPosition(TouchSource.Touch1);
}
[Test]
public void TestMovementWhileDisallowed()
{
// aka "autopilot" mod
AddStep("Disallow gameplay cursor movement", () => osuInputManager.AllowUserCursorMovement = false);
Vector2? positionBefore = null;
AddStep("Store cursor position", () => positionBefore = osuInputManager.CurrentState.Mouse.Position);
beginTouch(TouchSource.Touch1);
assertKeyCounter(1, 0);
checkPressed(OsuAction.LeftButton);
AddAssert("Cursor position unchanged", () => osuInputManager.CurrentState.Mouse.Position, () => Is.EqualTo(positionBefore));
}
[Test]
public void TestActionWhileDisallowed()
{
// aka "relax" mod
AddStep("Disallow gameplay actions", () => osuInputManager.AllowGameplayInputs = false);
beginTouch(TouchSource.Touch1);
assertKeyCounter(0, 0);
checkNotPressed(OsuAction.LeftButton);
checkPosition(TouchSource.Touch1);
}
[Test]
public void TestInputWhileMouseButtonsDisabled()
{
AddStep("Disable mouse buttons", () => config.SetValue(OsuSetting.MouseDisableButtons, true));
beginTouch(TouchSource.Touch1);
assertKeyCounter(0, 0);
checkNotPressed(OsuAction.LeftButton);
checkPosition(TouchSource.Touch1);
beginTouch(TouchSource.Touch2);
assertKeyCounter(0, 0);
checkNotPressed(OsuAction.LeftButton);
checkNotPressed(OsuAction.RightButton);
checkPosition(TouchSource.Touch2);
}
[Test]
public void TestAlternatingInput()
{
beginTouch(TouchSource.Touch1);
assertKeyCounter(1, 0);
checkPressed(OsuAction.LeftButton);
beginTouch(TouchSource.Touch2);
assertKeyCounter(1, 1);
checkPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
for (int i = 0; i < 2; i++)
{
endTouch(TouchSource.Touch1);
checkPressed(OsuAction.RightButton);
checkNotPressed(OsuAction.LeftButton);
beginTouch(TouchSource.Touch1);
checkPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
endTouch(TouchSource.Touch2);
checkPressed(OsuAction.LeftButton);
checkNotPressed(OsuAction.RightButton);
beginTouch(TouchSource.Touch2);
checkPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
}
}
[Test]
public void TestPressReleaseOrder()
{
beginTouch(TouchSource.Touch1);
beginTouch(TouchSource.Touch2);
beginTouch(TouchSource.Touch3);
assertKeyCounter(1, 1);
checkPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
// Touch 3 was ignored, but let's ensure that if 1 or 2 are released, 3 will be handled a second attempt.
endTouch(TouchSource.Touch1);
assertKeyCounter(1, 1);
checkPressed(OsuAction.RightButton);
endTouch(TouchSource.Touch3);
assertKeyCounter(1, 1);
checkPressed(OsuAction.RightButton);
beginTouch(TouchSource.Touch3);
assertKeyCounter(2, 1);
checkPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
}
[Test]
public void TestWithDisallowedUserCursor()
{
beginTouch(TouchSource.Touch1);
assertKeyCounter(1, 0);
checkPressed(OsuAction.LeftButton);
beginTouch(TouchSource.Touch2);
assertKeyCounter(1, 1);
checkPressed(OsuAction.RightButton);
// Subsequent touches should be ignored.
beginTouch(TouchSource.Touch3);
beginTouch(TouchSource.Touch4);
assertKeyCounter(1, 1);
checkPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
assertKeyCounter(1, 1);
}
private void beginTouch(TouchSource source, Vector2? screenSpacePosition = null) =>
AddStep($"Begin touch for {source}", () => InputManager.BeginTouch(new Touch(source, screenSpacePosition ??= getSanePositionForSource(source))));
private void endTouch(TouchSource source, Vector2? screenSpacePosition = null) =>
AddStep($"Release touch for {source}", () => InputManager.EndTouch(new Touch(source, screenSpacePosition ??= getSanePositionForSource(source))));
private Vector2 getSanePositionForSource(TouchSource source)
{
return new Vector2(
osuInputManager.ScreenSpaceDrawQuad.Centre.X + osuInputManager.ScreenSpaceDrawQuad.Width * (-1 + (int)source) / 8,
osuInputManager.ScreenSpaceDrawQuad.Centre.Y - 100
);
}
private void checkPosition(TouchSource touchSource) =>
AddAssert("Cursor position is correct", () => osuInputManager.CurrentState.Mouse.Position, () => Is.EqualTo(getSanePositionForSource(touchSource)));
private void assertKeyCounter(int left, int right)
{
AddAssert($"The left key was pressed {left} times", () => leftKeyCounter.CountPresses, () => Is.EqualTo(left));
AddAssert($"The right key was pressed {right} times", () => rightKeyCounter.CountPresses, () => Is.EqualTo(right));
}
private void releaseAllTouches()
{
AddStep("Release all touches", () =>
{
config.SetValue(OsuSetting.MouseDisableButtons, false);
foreach (TouchSource source in InputManager.CurrentState.Touch.ActiveSources)
InputManager.EndTouch(new Touch(source, osuInputManager.ScreenSpaceDrawQuad.Centre));
});
}
private void checkNotPressed(OsuAction action) => AddAssert($"Not pressing {action}", () => !osuInputManager.PressedActions.Contains(action));
private void checkPressed(OsuAction action) => AddAssert($"Is pressing {action}", () => osuInputManager.PressedActions.Contains(action));
public partial class TestActionKeyCounter : KeyCounter, IKeyBindingHandler<OsuAction>
{
public OsuAction Action { get; }
public TestActionKeyCounter(OsuAction action)
: base(action.ToString())
{
Action = action;
}
public bool OnPressed(KeyBindingPressEvent<OsuAction> e)
{
if (e.Action == Action)
{
IsLit = true;
Increment();
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<OsuAction> e)
{
if (e.Action == Action) IsLit = false;
}
}
public partial class TouchVisualiser : CompositeDrawable
{
private readonly Drawable?[] drawableTouches = new Drawable?[TouchState.MAX_TOUCH_COUNT];
public TouchVisualiser()
{
RelativeSizeAxes = Axes.Both;
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
protected override bool OnTouchDown(TouchDownEvent e)
{
if (IsDisposed)
return false;
var circle = new Circle
{
Alpha = 0.5f,
Origin = Anchor.Centre,
Size = new Vector2(20),
Position = e.Touch.Position,
Colour = colourFor(e.Touch.Source),
};
AddInternal(circle);
drawableTouches[(int)e.Touch.Source] = circle;
return false;
}
protected override void OnTouchMove(TouchMoveEvent e)
{
if (IsDisposed)
return;
var circle = drawableTouches[(int)e.Touch.Source];
Debug.Assert(circle != null);
AddInternal(new FadingCircle(circle));
circle.Position = e.Touch.Position;
}
protected override void OnTouchUp(TouchUpEvent e)
{
var circle = drawableTouches[(int)e.Touch.Source];
Debug.Assert(circle != null);
circle.FadeOut(200, Easing.OutQuint).Expire();
drawableTouches[(int)e.Touch.Source] = null;
}
private Color4 colourFor(TouchSource source)
{
return Color4.FromHsv(new Vector4((float)source / TouchState.MAX_TOUCH_COUNT, 1f, 1f, 1f));
}
private partial class FadingCircle : Circle
{
public FadingCircle(Drawable source)
{
Origin = Anchor.Centre;
Size = source.Size;
Position = source.Position;
Colour = source.Colour;
}
protected override void LoadComplete()
{
base.LoadComplete();
this.FadeOut(200).Expire();
}
}
}
}
}

View File

@ -1,16 +1,15 @@
// 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.
#nullable disable
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using osu.Framework.Input;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Input.StateChanges.Events;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Osu
@ -28,6 +27,7 @@ namespace osu.Game.Rulesets.Osu
/// </remarks>
public bool AllowGameplayInputs
{
get => ((OsuKeyBindingContainer)KeyBindingContainer).AllowGameplayInputs;
set => ((OsuKeyBindingContainer)KeyBindingContainer).AllowGameplayInputs = value;
}
@ -45,6 +45,12 @@ namespace osu.Game.Rulesets.Osu
{
}
[BackgroundDependencyLoader]
private void load()
{
Add(new OsuTouchInputMapper(this) { RelativeSizeAxes = Axes.Both });
}
protected override bool Handle(UIEvent e)
{
if ((e is MouseMoveEvent || e is TouchMoveEvent) && !AllowUserCursorMovement) return false;
@ -52,19 +58,6 @@ namespace osu.Game.Rulesets.Osu
return base.Handle(e);
}
protected override bool HandleMouseTouchStateChange(TouchStateChangeEvent e)
{
if (!AllowUserCursorMovement)
{
// Still allow for forwarding of the "touch" part, but replace the positional data with that of the mouse.
// Primarily relied upon by the "autopilot" osu! mod.
var touch = new Touch(e.Touch.Source, CurrentState.Mouse.Position);
e = new TouchStateChangeEvent(e.State, e.Input, touch, e.IsActive, null);
}
return base.HandleMouseTouchStateChange(e);
}
private partial class OsuKeyBindingContainer : RulesetKeyBindingContainer
{
private bool allowGameplayInputs = true;

View File

@ -0,0 +1,104 @@
// 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.Framework.Input;
using osu.Framework.Input.Events;
using osu.Framework.Input.StateChanges;
using osu.Game.Configuration;
namespace osu.Game.Rulesets.Osu.UI
{
public partial class OsuTouchInputMapper : Drawable
{
/// <summary>
/// All the active <see cref="TouchSource"/>s and the <see cref="OsuAction"/> that it triggered (if any).
/// Ordered from oldest to newest touch chronologically.
/// </summary>
private readonly List<TrackedTouch> trackedTouches = new List<TrackedTouch>();
private readonly OsuInputManager osuInputManager;
private Bindable<bool> mouseDisabled = null!;
public OsuTouchInputMapper(OsuInputManager inputManager)
{
osuInputManager = inputManager;
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
// The mouse button disable setting affects touch. It's a bit weird.
// This is mostly just doing the same as what is done in RulesetInputManager to match behaviour.
mouseDisabled = config.GetBindable<bool>(OsuSetting.MouseDisableButtons);
}
protected override void OnTouchMove(TouchMoveEvent e)
{
base.OnTouchMove(e);
handleTouchMovement(e);
}
protected override bool OnTouchDown(TouchDownEvent e)
{
OsuAction action = trackedTouches.Any(t => t.Action == OsuAction.LeftButton)
? OsuAction.RightButton
: OsuAction.LeftButton;
// Ignore any taps which trigger an action which is already handled. But track them for potential positional input in the future.
bool shouldResultInAction = osuInputManager.AllowGameplayInputs && !mouseDisabled.Value && trackedTouches.All(t => t.Action != action);
trackedTouches.Add(new TrackedTouch(e.Touch.Source, shouldResultInAction ? action : null));
// Important to update position before triggering the pressed action.
handleTouchMovement(e);
if (shouldResultInAction)
osuInputManager.KeyBindingContainer.TriggerPressed(action);
return true;
}
private void handleTouchMovement(TouchEvent touchEvent)
{
// Movement should only be tracked for the most recent touch.
if (touchEvent.Touch.Source != trackedTouches.Last().Source)
return;
if (!osuInputManager.AllowUserCursorMovement)
return;
new MousePositionAbsoluteInput { Position = touchEvent.ScreenSpaceTouch.Position }.Apply(osuInputManager.CurrentState, osuInputManager);
}
protected override void OnTouchUp(TouchUpEvent e)
{
var tracked = trackedTouches.Single(t => t.Source == e.Touch.Source);
if (tracked.Action is OsuAction action)
osuInputManager.KeyBindingContainer.TriggerReleased(action);
trackedTouches.Remove(tracked);
base.OnTouchUp(e);
}
private class TrackedTouch
{
public readonly TouchSource Source;
public readonly OsuAction? Action;
public TrackedTouch(TouchSource source, OsuAction? action)
{
Source = source;
Action = action;
}
}
}
}