// 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.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Configuration; using osuTK; namespace osu.Game.Graphics.Cursor { public partial class MenuCursorContainer : CursorContainer { private readonly IBindable screenshotCursorVisibility = new Bindable(true); public override bool IsPresent => screenshotCursorVisibility.Value && base.IsPresent; private bool hideCursorOnNonMouseInput; public bool HideCursorOnNonMouseInput { get => hideCursorOnNonMouseInput; set { if (hideCursorOnNonMouseInput == value) return; hideCursorOnNonMouseInput = value; updateState(); } } protected override Drawable CreateCursor() => activeCursor = new Cursor(); private Cursor activeCursor = null!; private DragRotationState dragRotationState; private Vector2 positionMouseDown; private Vector2 lastMovePosition; private Bindable cursorRotate = null!; private Sample tapSample = null!; private MouseInputDetector mouseInputDetector = null!; private bool visible; [BackgroundDependencyLoader] private void load(OsuConfigManager config, ScreenshotManager? screenshotManager, AudioManager audio) { cursorRotate = config.GetBindable(OsuSetting.CursorRotation); if (screenshotManager != null) screenshotCursorVisibility.BindTo(screenshotManager.CursorVisibility); tapSample = audio.Samples.Get(@"UI/cursor-tap"); Add(mouseInputDetector = new MouseInputDetector()); } [Resolved] private OsuGame? game { get; set; } private readonly IBindable lastInputWasMouse = new BindableBool(); private readonly IBindable gameActive = new BindableBool(true); private readonly IBindable gameIdle = new BindableBool(); protected override void LoadComplete() { base.LoadComplete(); lastInputWasMouse.BindTo(mouseInputDetector.LastInputWasMouseSource); lastInputWasMouse.BindValueChanged(_ => updateState(), true); if (game != null) { gameIdle.BindTo(game.IsIdle); gameIdle.BindValueChanged(_ => updateState()); gameActive.BindTo(game.IsActive); gameActive.BindValueChanged(_ => updateState()); } } protected override void UpdateState(ValueChangedEvent state) => updateState(); private void updateState() { bool combinedVisibility = getCursorVisibility(); if (visible == combinedVisibility) return; visible = combinedVisibility; if (visible) PopIn(); else PopOut(); } private bool getCursorVisibility() { // do not display when explicitly set to hidden state. if (State.Value == Visibility.Hidden) return false; // only hide cursor when game is focused, otherwise it should always be displayed. if (gameActive.Value) { // do not display when last input is not mouse. if (hideCursorOnNonMouseInput && !lastInputWasMouse.Value) return false; // do not display when game is idle. if (gameIdle.Value) return false; } return true; } protected override void Update() { base.Update(); if (dragRotationState != DragRotationState.NotDragging && Vector2.Distance(positionMouseDown, lastMovePosition) > 60) { // make the rotation centre point floating. positionMouseDown = Interpolation.ValueAt(0.04f, positionMouseDown, lastMovePosition, 0, Clock.ElapsedFrameTime); } } protected override bool OnMouseMove(MouseMoveEvent e) { if (dragRotationState != DragRotationState.NotDragging) { lastMovePosition = e.MousePosition; float distance = Vector2Extensions.Distance(lastMovePosition, positionMouseDown); // don't start rotating until we're moved a minimum distance away from the mouse down location, // else it can have an annoying effect. if (dragRotationState == DragRotationState.DragStarted && distance > 80) dragRotationState = DragRotationState.Rotating; // don't rotate when distance is zero to avoid NaN if (dragRotationState == DragRotationState.Rotating && distance > 0) { Vector2 offset = e.MousePosition - positionMouseDown; float degrees = float.RadiansToDegrees(MathF.Atan2(-offset.X, offset.Y)) + 24.3f; // Always rotate in the direction of least distance float diff = (degrees - activeCursor.Rotation) % 360; if (diff < -180) diff += 360; if (diff > 180) diff -= 360; degrees = activeCursor.Rotation + diff; activeCursor.RotateTo(degrees, 120, Easing.OutQuint); } } return base.OnMouseMove(e); } protected override bool OnMouseDown(MouseDownEvent e) { if (State.Value == Visibility.Visible) { // only trigger animation for main mouse buttons activeCursor.Scale = new Vector2(1); activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); activeCursor.AdditiveLayer.Alpha = 0; activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) { // if cursor is already rotating don't reset its rotate origin dragRotationState = DragRotationState.DragStarted; positionMouseDown = e.MousePosition; } playTapSample(); } return base.OnMouseDown(e); } protected override void OnMouseUp(MouseUpEvent e) { if (!e.HasAnyButtonPressed) { activeCursor.AdditiveLayer.FadeOutFromOne(500, Easing.OutQuint); activeCursor.ScaleTo(1, 500, Easing.OutElastic); if (dragRotationState != DragRotationState.NotDragging) { activeCursor.RotateTo(0, 400 * (0.5f + Math.Abs(activeCursor.Rotation / 960)), Easing.OutElasticQuarter); dragRotationState = DragRotationState.NotDragging; } if (State.Value == Visibility.Visible) playTapSample(0.8); } base.OnMouseUp(e); } protected override void PopIn() { activeCursor.FadeTo(1, 250, Easing.OutQuint); activeCursor.ScaleTo(1, 400, Easing.OutQuint); activeCursor.RotateTo(0, 400, Easing.OutQuint); dragRotationState = DragRotationState.NotDragging; } protected override void PopOut() { activeCursor.FadeTo(0, 250, Easing.OutQuint); activeCursor.ScaleTo(0.6f, 250, Easing.In); activeCursor.RotateTo(0, 400, Easing.OutQuint); dragRotationState = DragRotationState.NotDragging; } private void playTapSample(double baseFrequency = 1f) { const float random_range = 0.02f; SampleChannel channel = tapSample.GetChannel(); // Scale to [-0.75, 0.75] so that the sample isn't fully panned left or right (sounds weird) channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * OsuGameBase.SFX_STEREO_STRENGTH; channel.Frequency.Value = baseFrequency - (random_range / 2f) + RNG.NextDouble(random_range); channel.Volume.Value = baseFrequency; channel.Play(); } public partial class Cursor : Container { private Container cursorContainer = null!; private Bindable cursorScale = null!; private const float base_scale = 0.15f; public Sprite AdditiveLayer = null!; public Cursor() { AutoSizeAxes = Axes.Both; } [BackgroundDependencyLoader] private void load(OsuConfigManager config, TextureStore textures, OsuColour colour) { Children = new Drawable[] { cursorContainer = new Container { AutoSizeAxes = Axes.Both, Children = new Drawable[] { new Sprite { Texture = textures.Get(@"Cursor/menu-cursor"), }, AdditiveLayer = new Sprite { Blending = BlendingParameters.Additive, Colour = colour.Pink, Alpha = 0, Texture = textures.Get(@"Cursor/menu-cursor-additive"), }, } } }; cursorScale = config.GetBindable(OsuSetting.MenuCursorSize); cursorScale.BindValueChanged(scale => cursorContainer.Scale = new Vector2(scale.NewValue * base_scale), true); } } private partial class MouseInputDetector : Component { /// /// Whether the last input applied to the game is sourced from mouse. /// public IBindable LastInputWasMouseSource => lastInputWasMouseSource; private readonly Bindable lastInputWasMouseSource = new Bindable(); public MouseInputDetector() { RelativeSizeAxes = Axes.Both; } protected override bool Handle(UIEvent e) { switch (e) { case MouseDownEvent: case MouseMoveEvent: lastInputWasMouseSource.Value = true; return false; case KeyDownEvent keyDown when !keyDown.Repeat: case JoystickPressEvent: case MidiDownEvent: lastInputWasMouseSource.Value = false; return false; } return false; } } private enum DragRotationState { NotDragging, DragStarted, Rotating, } } }