1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 10:07:52 +08:00

Merge branch 'master' into fix-ubo-not-bound

This commit is contained in:
Dean Herbert 2023-03-22 16:46:39 +09:00
commit ad5bdf6511
33 changed files with 951 additions and 245 deletions

View File

@ -5,7 +5,6 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Versioning;
using System.Threading.Tasks;
@ -139,7 +138,17 @@ namespace osu.Desktop
desktopWindow.CursorState |= CursorState.Hidden;
desktopWindow.Title = Name;
desktopWindow.DragDrop += f => fileDrop(new[] { f });
desktopWindow.DragDrop += f =>
{
// on macOS, URL associations are handled via SDL_DROPFILE events.
if (f.StartsWith(OSU_PROTOCOL, StringComparison.Ordinal))
{
HandleLink(f);
return;
}
fileDrop(new[] { f });
};
}
protected override BatteryInfo CreateBatteryInfo() => new SDL2BatteryInfo();
@ -151,10 +160,6 @@ namespace osu.Desktop
{
lock (importableFiles)
{
string firstExtension = Path.GetExtension(filePaths.First());
if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return;
importableFiles.AddRange(filePaths);
Logger.Log($"Adding {filePaths.Length} files for import");

View File

@ -236,6 +236,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
};
// Position and resize the body to lie half-way under the head and the tail notes.
// The rationale for this is account for heads/tails with corner radius.
bodyPiece.Y = (Direction.Value == ScrollingDirection.Up ? 1 : -1) * Head.Height / 2;
bodyPiece.Height = DrawHeight - Head.Height / 2 + Tail.Height / 2;

View File

@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail;
protected DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject;
protected internal DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject;
public DrawableHoldNoteTail()
: this(null)

View File

@ -43,9 +43,10 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
largeFaint = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
Height = ArgonNotePiece.NOTE_ACCENT_RATIO,
Masking = true,
CornerRadius = ArgonNotePiece.CORNER_RADIUS,
Blending = BlendingParameters.Additive,

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
private void load(IScrollingInfo scrollingInfo)
{
RelativeSizeAxes = Axes.X;
Height = ArgonNotePiece.NOTE_HEIGHT;
Height = ArgonNotePiece.NOTE_HEIGHT * ArgonNotePiece.NOTE_ACCENT_RATIO;
Masking = true;
CornerRadius = ArgonNotePiece.CORNER_RADIUS;

View File

@ -20,10 +20,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
public partial class ArgonHoldBodyPiece : CompositeDrawable, IHoldNoteBody
{
protected readonly Bindable<Color4> AccentColour = new Bindable<Color4>();
protected readonly IBindable<bool> IsHitting = new Bindable<bool>();
private Drawable background = null!;
private Box foreground = null!;
private ArgonHoldNoteHittingLayer hittingLayer = null!;
public ArgonHoldBodyPiece()
{
@ -32,7 +31,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
// Without this, the width of the body will be slightly larger than the head/tail.
Masking = true;
CornerRadius = ArgonNotePiece.CORNER_RADIUS;
Blending = BlendingParameters.Additive;
}
[BackgroundDependencyLoader(true)]
@ -41,12 +39,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
InternalChildren = new[]
{
background = new Box { RelativeSizeAxes = Axes.Both },
foreground = new Box
{
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive,
Alpha = 0,
},
hittingLayer = new ArgonHoldNoteHittingLayer()
};
if (drawableObject != null)
@ -54,44 +47,19 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
var holdNote = (DrawableHoldNote)drawableObject;
AccentColour.BindTo(holdNote.AccentColour);
IsHitting.BindTo(holdNote.IsHitting);
hittingLayer.AccentColour.BindTo(holdNote.AccentColour);
((IBindable<bool>)hittingLayer.IsHitting).BindTo(holdNote.IsHitting);
}
AccentColour.BindValueChanged(colour =>
{
background.Colour = colour.NewValue.Darken(1.2f);
foreground.Colour = colour.NewValue.Opacity(0.2f);
background.Colour = colour.NewValue.Darken(0.6f);
}, true);
IsHitting.BindValueChanged(hitting =>
{
const float animation_length = 50;
foreground.ClearTransforms();
if (hitting.NewValue)
{
// wait for the next sync point
double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2);
using (foreground.BeginDelayedSequence(synchronisedOffset))
{
foreground.FadeTo(1, animation_length).Then()
.FadeTo(0.5f, animation_length)
.Loop();
}
}
else
{
foreground.FadeOut(animation_length);
}
});
}
public void Recycle()
{
foreground.ClearTransforms();
foreground.Alpha = 0;
hittingLayer.Recycle();
}
}
}

View File

@ -0,0 +1,20 @@
// 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.Graphics;
using osu.Framework.Graphics.Shapes;
using osuTK;
namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
internal partial class ArgonHoldNoteHeadPiece : ArgonNotePiece
{
protected override Drawable CreateIcon() => new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Y = 2,
Size = new Vector2(20, 5),
};
}
}

View File

@ -0,0 +1,64 @@
// 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.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osuTK.Graphics;
using Box = osu.Framework.Graphics.Shapes.Box;
namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
public partial class ArgonHoldNoteHittingLayer : Box
{
public readonly Bindable<Color4> AccentColour = new Bindable<Color4>();
public readonly Bindable<bool> IsHitting = new Bindable<bool>();
public ArgonHoldNoteHittingLayer()
{
RelativeSizeAxes = Axes.Both;
Blending = BlendingParameters.Additive;
Alpha = 0;
}
protected override void LoadComplete()
{
base.LoadComplete();
AccentColour.BindValueChanged(colour =>
{
Colour = colour.NewValue.Lighten(0.2f).Opacity(0.3f);
}, true);
IsHitting.BindValueChanged(hitting =>
{
const float animation_length = 80;
ClearTransforms();
if (hitting.NewValue)
{
// wait for the next sync point
double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2);
using (BeginDelayedSequence(synchronisedOffset))
{
this.FadeTo(1, animation_length, Easing.OutSine).Then()
.FadeTo(0.5f, animation_length, Easing.InSine)
.Loop();
}
}
else
{
this.FadeOut(animation_length);
}
}, true);
}
public void Recycle()
{
ClearTransforms();
Alpha = 0;
}
}
}

View File

@ -5,8 +5,10 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
@ -16,47 +18,68 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
internal partial class ArgonHoldNoteTailPiece : CompositeDrawable
{
[Resolved]
private DrawableHitObject? drawableObject { get; set; }
private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
private readonly IBindable<Color4> accentColour = new Bindable<Color4>();
private readonly Box shadeBackground;
private readonly Box shadeForeground;
private readonly Box foreground;
private readonly ArgonHoldNoteHittingLayer hittingLayer;
private readonly Box foregroundAdditive;
public ArgonHoldNoteTailPiece()
{
RelativeSizeAxes = Axes.X;
Height = ArgonNotePiece.NOTE_HEIGHT;
CornerRadius = ArgonNotePiece.CORNER_RADIUS;
Masking = true;
InternalChildren = new Drawable[]
{
shadeBackground = new Box
{
RelativeSizeAxes = Axes.Both,
},
new Container
{
RelativeSizeAxes = Axes.Both,
Height = ArgonNotePiece.NOTE_ACCENT_RATIO,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.X,
Height = ArgonNotePiece.NOTE_HEIGHT,
CornerRadius = ArgonNotePiece.CORNER_RADIUS,
Masking = true,
Children = new Drawable[]
{
shadeForeground = new Box
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Colour4.Black),
// Avoid ugly single pixel overlap.
Height = 0.9f,
},
},
new Container
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Height = ArgonNotePiece.NOTE_ACCENT_RATIO,
CornerRadius = ArgonNotePiece.CORNER_RADIUS,
Masking = true,
Children = new Drawable[]
{
foreground = new Box
{
RelativeSizeAxes = Axes.Both,
},
hittingLayer = new ArgonHoldNoteHittingLayer(),
foregroundAdditive = new Box
{
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive,
Height = 0.5f,
},
},
},
}
},
};
}
[BackgroundDependencyLoader(true)]
private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject)
private void load(IScrollingInfo scrollingInfo)
{
direction.BindTo(scrollingInfo.Direction);
direction.BindValueChanged(onDirectionChanged, true);
@ -65,9 +88,24 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
accentColour.BindTo(drawableObject.AccentColour);
accentColour.BindValueChanged(onAccentChanged, true);
drawableObject.HitObjectApplied += hitObjectApplied;
}
}
private void hitObjectApplied(DrawableHitObject drawableHitObject)
{
var holdNoteTail = (DrawableHoldNoteTail)drawableHitObject;
hittingLayer.Recycle();
hittingLayer.AccentColour.UnbindBindings();
hittingLayer.AccentColour.BindTo(holdNoteTail.HoldNote.AccentColour);
hittingLayer.IsHitting.UnbindBindings();
((IBindable<bool>)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHitting);
}
private void onDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
{
Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Up ? -1 : 1);
@ -75,8 +113,20 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
private void onAccentChanged(ValueChangedEvent<Color4> accent)
{
shadeBackground.Colour = accent.NewValue.Darken(1.7f);
shadeForeground.Colour = accent.NewValue.Darken(1.1f);
foreground.Colour = accent.NewValue.Darken(0.6f); // matches body
foregroundAdditive.Colour = ColourInfo.GradientVertical(
accent.NewValue.Opacity(0.4f),
accent.NewValue.Opacity(0)
);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableObject != null)
drawableObject.HitObjectApplied -= hitObjectApplied;
}
}
}

View File

@ -26,7 +26,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
private readonly IBindable<Color4> accentColour = new Bindable<Color4>();
private readonly Box colouredBox;
private readonly Box shadow;
public ArgonNotePiece()
{
@ -36,11 +35,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
CornerRadius = CORNER_RADIUS;
Masking = true;
InternalChildren = new Drawable[]
InternalChildren = new[]
{
shadow = new Box
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Colour4.Black)
},
new Container
{
@ -65,18 +65,22 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
RelativeSizeAxes = Axes.X,
Height = CORNER_RADIUS * 2,
},
new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Y = 4,
Icon = FontAwesome.Solid.AngleDown,
Size = new Vector2(20),
Scale = new Vector2(1, 0.7f)
}
CreateIcon(),
};
}
protected virtual Drawable CreateIcon() => new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Y = 4,
// TODO: replace with a non-squashed version.
// The 0.7f height scale should be removed.
Icon = FontAwesome.Solid.AngleDown,
Size = new Vector2(20),
Scale = new Vector2(1, 0.7f)
};
[BackgroundDependencyLoader(true)]
private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject)
{
@ -105,8 +109,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
accent.NewValue.Lighten(0.1f),
accent.NewValue
);
shadow.Colour = accent.NewValue.Darken(0.5f);
}
}
}

View File

@ -50,6 +50,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
return new ArgonHoldNoteTailPiece();
case ManiaSkinComponents.HoldNoteHead:
return new ArgonHoldNoteHeadPiece();
case ManiaSkinComponents.Note:
return new ArgonNotePiece();
@ -69,12 +71,23 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
return base.GetDrawableComponent(lookup);
}
private static readonly Color4 colour_special_column = new Color4(169, 106, 255, 255);
private const int total_colours = 6;
private static readonly Color4 colour_yellow = new Color4(255, 197, 40, 255);
private static readonly Color4 colour_orange = new Color4(252, 109, 1, 255);
private static readonly Color4 colour_pink = new Color4(213, 35, 90, 255);
private static readonly Color4 colour_purple = new Color4(203, 60, 236, 255);
private static readonly Color4 colour_cyan = new Color4(72, 198, 255, 255);
private static readonly Color4 colour_green = new Color4(100, 192, 92, 255);
public override IBindable<TValue>? GetConfig<TLookup, TValue>(TLookup lookup)
{
if (lookup is ManiaSkinConfigurationLookup maniaLookup)
{
int column = maniaLookup.ColumnIndex ?? 0;
var stage = beatmap.GetStageForColumnIndex(column);
int columnIndex = maniaLookup.ColumnIndex ?? 0;
var stage = beatmap.GetStageForColumnIndex(columnIndex);
switch (maniaLookup.Lookup)
{
@ -87,53 +100,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
case LegacyManiaSkinConfigurationLookups.ColumnWidth:
return SkinUtils.As<TValue>(new Bindable<float>(
stage.IsSpecialColumn(column) ? 120 : 60
stage.IsSpecialColumn(columnIndex) ? 120 : 60
));
case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour:
Color4 colour;
const int total_colours = 7;
if (stage.IsSpecialColumn(column))
colour = new Color4(159, 101, 255, 255);
else
{
switch (column % total_colours)
{
case 0:
colour = new Color4(240, 216, 0, 255);
break;
case 1:
colour = new Color4(240, 101, 0, 255);
break;
case 2:
colour = new Color4(240, 0, 130, 255);
break;
case 3:
colour = new Color4(192, 0, 240, 255);
break;
case 4:
colour = new Color4(0, 96, 240, 255);
break;
case 5:
colour = new Color4(0, 226, 240, 255);
break;
case 6:
colour = new Color4(0, 240, 96, 255);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
var colour = getColourForLayout(columnIndex, stage);
return SkinUtils.As<TValue>(new Bindable<Color4>(colour));
}
@ -141,5 +113,203 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
return base.GetConfig<TLookup, TValue>(lookup);
}
private Color4 getColourForLayout(int columnIndex, StageDefinition stage)
{
// Account for cases like dual-stage (assume that all stages have the same column count for now).
columnIndex %= stage.Columns;
// For now, these are defined per column count as per https://user-images.githubusercontent.com/50823728/218038463-b450f46c-ef21-4551-b133-f866be59970c.png
// See https://github.com/ppy/osu/discussions/21996 for discussion.
switch (stage.Columns)
{
case 1:
return colour_yellow;
case 2:
switch (columnIndex)
{
case 0: return colour_green;
case 1: return colour_cyan;
default: throw new ArgumentOutOfRangeException();
}
case 3:
switch (columnIndex)
{
case 0: return colour_pink;
case 1: return colour_orange;
case 2: return colour_yellow;
default: throw new ArgumentOutOfRangeException();
}
case 4:
switch (columnIndex)
{
case 0: return colour_yellow;
case 1: return colour_orange;
case 2: return colour_pink;
case 3: return colour_purple;
default: throw new ArgumentOutOfRangeException();
}
case 5:
switch (columnIndex)
{
case 0: return colour_pink;
case 1: return colour_orange;
case 2: return colour_yellow;
case 3: return colour_green;
case 4: return colour_cyan;
default: throw new ArgumentOutOfRangeException();
}
case 6:
switch (columnIndex)
{
case 0: return colour_pink;
case 1: return colour_orange;
case 2: return colour_yellow;
case 3: return colour_cyan;
case 4: return colour_purple;
case 5: return colour_pink;
default: throw new ArgumentOutOfRangeException();
}
case 7:
switch (columnIndex)
{
case 0: return colour_pink;
case 1: return colour_cyan;
case 2: return colour_pink;
case 3: return colour_special_column;
case 4: return colour_green;
case 5: return colour_cyan;
case 6: return colour_green;
default: throw new ArgumentOutOfRangeException();
}
case 8:
switch (columnIndex)
{
case 0: return colour_purple;
case 1: return colour_pink;
case 2: return colour_orange;
case 3: return colour_yellow;
case 4: return colour_yellow;
case 5: return colour_orange;
case 6: return colour_pink;
case 7: return colour_purple;
default: throw new ArgumentOutOfRangeException();
}
case 9:
switch (columnIndex)
{
case 0: return colour_purple;
case 1: return colour_pink;
case 2: return colour_orange;
case 3: return colour_yellow;
case 4: return colour_special_column;
case 5: return colour_yellow;
case 6: return colour_orange;
case 7: return colour_pink;
case 8: return colour_purple;
default: throw new ArgumentOutOfRangeException();
}
case 10:
switch (columnIndex)
{
case 0: return colour_purple;
case 1: return colour_pink;
case 2: return colour_orange;
case 3: return colour_yellow;
case 4: return colour_cyan;
case 5: return colour_green;
case 6: return colour_yellow;
case 7: return colour_orange;
case 8: return colour_pink;
case 9: return colour_purple;
default: throw new ArgumentOutOfRangeException();
}
}
// fallback for unhandled scenarios
if (stage.IsSpecialColumn(columnIndex))
return colour_special_column;
switch (columnIndex % total_colours)
{
case 0: return colour_yellow;
case 1: return colour_orange;
case 2: return colour_pink;
case 3: return colour_purple;
case 4: return colour_cyan;
case 5: return colour_green;
default: throw new ArgumentOutOfRangeException();
}
}
}
}

View File

@ -150,6 +150,42 @@ namespace osu.Game.Rulesets.Osu.Tests
assertKeyCounter(1, 1);
}
[Test]
public void TestPositionalTrackingAfterLongDistanceTravelled()
{
// When a single touch has already travelled enough distance on screen, it should remain as the positional
// tracking touch until released (unless a direct touch occurs).
beginTouch(TouchSource.Touch1);
assertKeyCounter(1, 0);
checkPressed(OsuAction.LeftButton);
checkPosition(TouchSource.Touch1);
// cover some distance
beginTouch(TouchSource.Touch1, new Vector2(0));
beginTouch(TouchSource.Touch1, new Vector2(9999));
beginTouch(TouchSource.Touch1, new Vector2(0));
beginTouch(TouchSource.Touch1, new Vector2(9999));
beginTouch(TouchSource.Touch1);
beginTouch(TouchSource.Touch2);
assertKeyCounter(1, 1);
checkNotPressed(OsuAction.LeftButton);
checkPressed(OsuAction.RightButton);
// in this case, touch 2 should not become the positional tracking touch.
checkPosition(TouchSource.Touch1);
// even if the second touch moves on the screen, the original tracking touch is retained.
beginTouch(TouchSource.Touch2, new Vector2(0));
beginTouch(TouchSource.Touch2, new Vector2(9999));
beginTouch(TouchSource.Touch2, new Vector2(0));
beginTouch(TouchSource.Touch2, new Vector2(9999));
checkPosition(TouchSource.Touch1);
}
[Test]
public void TestPositionalInputUpdatesOnlyFromMostRecentTouch()
{

View File

@ -22,6 +22,13 @@ namespace osu.Game.Rulesets.Osu.UI
/// </summary>
private readonly List<TrackedTouch> trackedTouches = new List<TrackedTouch>();
/// <summary>
/// The distance (in local pixels) that a touch must move before being considered a permanent tracking touch.
/// After this distance is covered, any extra touches on the screen will be considered as button inputs, unless
/// a new touch directly interacts with a hit circle.
/// </summary>
private const float distance_before_position_tracking_lock_in = 100;
private TrackedTouch? positionTrackingTouch;
private readonly OsuInputManager osuInputManager;
@ -97,26 +104,32 @@ namespace osu.Game.Rulesets.Osu.UI
return;
}
// ..or if the current position tracking touch was not a direct touch (this one is debatable and may be change in the future, but it's the simplest way to handle)
if (!positionTrackingTouch.DirectTouch)
// ..or if the current position tracking touch was not a direct touch (and didn't travel across the screen too far).
if (!positionTrackingTouch.DirectTouch && positionTrackingTouch.DistanceTravelled < distance_before_position_tracking_lock_in)
{
positionTrackingTouch = newTouch;
return;
}
// In the case the new touch was not used for position tracking, we should also check the previous position tracking touch.
// If it was a direct touch and still has its action pressed, that action should be released.
// If it still has its action pressed, that action should be released.
//
// This is done to allow tracking with the initial touch while still having both Left/Right actions available for alternating with two more touches.
if (positionTrackingTouch.DirectTouch && positionTrackingTouch.Action is OsuAction directTouchAction)
if (positionTrackingTouch.Action is OsuAction touchAction)
{
osuInputManager.KeyBindingContainer.TriggerReleased(directTouchAction);
osuInputManager.KeyBindingContainer.TriggerReleased(touchAction);
positionTrackingTouch.Action = null;
}
}
private void handleTouchMovement(TouchEvent touchEvent)
{
if (touchEvent is TouchMoveEvent moveEvent)
{
var trackedTouch = trackedTouches.Single(t => t.Source == touchEvent.Touch.Source);
trackedTouch.DistanceTravelled += moveEvent.Delta.Length;
}
// Movement should only be tracked for the most recent touch.
if (touchEvent.Touch.Source != positionTrackingTouch?.Source)
return;
@ -148,8 +161,16 @@ namespace osu.Game.Rulesets.Osu.UI
public OsuAction? Action;
/// <summary>
/// Whether the touch was on a hit circle receptor.
/// </summary>
public readonly bool DirectTouch;
/// <summary>
/// The total distance on screen travelled by this touch (in local pixels).
/// </summary>
public float DistanceTravelled;
public TrackedTouch(TouchSource source, OsuAction? action, bool directTouch)
{
Source = source;

View File

@ -176,6 +176,7 @@ namespace osu.Game.Tests.Resources
CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
},
BeatmapInfo = beatmap,
BeatmapHash = beatmap.Hash,
Ruleset = beatmap.Ruleset,
Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() },
TotalScore = 2845370,

View File

@ -17,6 +17,8 @@ using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osu.Game.Skinning;
using osu.Game.Skinning.Components;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay
@ -52,6 +54,134 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("wait for loaded", () => skinEditor.IsLoaded);
}
[Test]
public void TestDragSelection()
{
BigBlackBox box1 = null!;
BigBlackBox box2 = null!;
BigBlackBox box3 = null!;
AddStep("Add big black boxes", () =>
{
var target = Player.ChildrenOfType<SkinComponentsContainer>().First();
target.Add(box1 = new BigBlackBox
{
Position = new Vector2(-90),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
target.Add(box2 = new BigBlackBox
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
target.Add(box3 = new BigBlackBox
{
Position = new Vector2(90),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
});
// This step is specifically added to reproduce an edge case which was found during cyclic selection development.
// If everything is working as expected it should not affect the subsequent drag selections.
AddRepeatStep("Select top left", () =>
{
InputManager.MoveMouseTo(box1.ScreenSpaceDrawQuad.TopLeft + new Vector2(box1.ScreenSpaceDrawQuad.Width / 8));
InputManager.Click(MouseButton.Left);
}, 2);
AddStep("Begin drag top left", () =>
{
InputManager.MoveMouseTo(box1.ScreenSpaceDrawQuad.TopLeft - new Vector2(box1.ScreenSpaceDrawQuad.Width / 4));
InputManager.PressButton(MouseButton.Left);
});
AddStep("Drag to bottom right", () =>
{
InputManager.MoveMouseTo(box3.ScreenSpaceDrawQuad.TopRight + new Vector2(-box3.ScreenSpaceDrawQuad.Width / 8, box3.ScreenSpaceDrawQuad.Height / 4));
});
AddStep("Release button", () =>
{
InputManager.ReleaseButton(MouseButton.Left);
});
AddAssert("First two boxes selected", () => skinEditor.SelectedComponents, () => Is.EqualTo(new[] { box1, box2 }));
AddStep("Begin drag bottom right", () =>
{
InputManager.MoveMouseTo(box3.ScreenSpaceDrawQuad.BottomRight + new Vector2(box3.ScreenSpaceDrawQuad.Width / 4));
InputManager.PressButton(MouseButton.Left);
});
AddStep("Drag to top left", () =>
{
InputManager.MoveMouseTo(box2.ScreenSpaceDrawQuad.Centre - new Vector2(box2.ScreenSpaceDrawQuad.Width / 4));
});
AddStep("Release button", () =>
{
InputManager.ReleaseButton(MouseButton.Left);
});
AddAssert("Last two boxes selected", () => skinEditor.SelectedComponents, () => Is.EqualTo(new[] { box2, box3 }));
// Test cyclic selection doesn't trigger in this state.
AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left));
AddAssert("Last two boxes still selected", () => skinEditor.SelectedComponents, () => Is.EqualTo(new[] { box2, box3 }));
}
[Test]
public void TestCyclicSelection()
{
SkinBlueprint[] blueprints = null!;
AddStep("Add big black boxes", () =>
{
InputManager.MoveMouseTo(skinEditor.ChildrenOfType<BigBlackBox>().First());
InputManager.Click(MouseButton.Left);
InputManager.Click(MouseButton.Left);
InputManager.Click(MouseButton.Left);
});
AddAssert("Three black boxes added", () => targetContainer.Components.OfType<BigBlackBox>().Count(), () => Is.EqualTo(3));
AddStep("Store black box blueprints", () =>
{
blueprints = skinEditor.ChildrenOfType<SkinBlueprint>().Where(b => b.Item is BigBlackBox).ToArray();
});
AddAssert("Selection is black box 1", () => skinEditor.SelectedComponents.Single(), () => Is.EqualTo(blueprints[0].Item));
AddStep("move cursor to black box", () =>
{
// Slightly offset from centre to avoid random failures (see https://github.com/ppy/osu-framework/issues/5669).
InputManager.MoveMouseTo(((Drawable)blueprints[0].Item).ScreenSpaceDrawQuad.Centre + new Vector2(1));
});
AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left));
AddAssert("Selection is black box 2", () => skinEditor.SelectedComponents.Single(), () => Is.EqualTo(blueprints[1].Item));
AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left));
AddAssert("Selection is black box 3", () => skinEditor.SelectedComponents.Single(), () => Is.EqualTo(blueprints[2].Item));
AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left));
AddAssert("Selection is black box 1", () => skinEditor.SelectedComponents.Single(), () => Is.EqualTo(blueprints[0].Item));
AddStep("select all boxes", () =>
{
skinEditor.SelectedComponents.Clear();
skinEditor.SelectedComponents.AddRange(targetContainer.Components.OfType<BigBlackBox>().Skip(1));
});
AddAssert("all boxes selected", () => skinEditor.SelectedComponents, () => Has.Count.EqualTo(2));
AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left));
AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left));
AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left));
AddAssert("all boxes still selected", () => skinEditor.SelectedComponents, () => Has.Count.EqualTo(2));
}
[TestCase(false)]
[TestCase(true)]
public void TestBringToFront(bool alterSelectionOrder)

View File

@ -84,16 +84,80 @@ namespace osu.Game.Tests.Visual.SongSelect
});
clearScores();
checkCount(0);
checkDisplayedCount(0);
loadMoreScores(() => beatmapInfo);
checkCount(10);
importMoreScores(() => beatmapInfo);
checkDisplayedCount(10);
loadMoreScores(() => beatmapInfo);
checkCount(20);
importMoreScores(() => beatmapInfo);
checkDisplayedCount(20);
clearScores();
checkCount(0);
checkDisplayedCount(0);
}
[Test]
public void TestLocalScoresDisplayOnBeatmapEdit()
{
BeatmapInfo beatmapInfo = null!;
string originalHash = string.Empty;
AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Local);
AddStep(@"Import beatmap", () =>
{
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First();
leaderboard.BeatmapInfo = beatmapInfo;
});
clearScores();
checkDisplayedCount(0);
AddStep(@"Perform initial save to guarantee stable hash", () =>
{
IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap;
beatmapManager.Save(beatmapInfo, beatmap);
originalHash = beatmapInfo.Hash;
});
importMoreScores(() => beatmapInfo);
checkDisplayedCount(10);
checkStoredCount(10);
AddStep(@"Save with changes", () =>
{
IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap;
beatmap.Difficulty.ApproachRate = 12;
beatmapManager.Save(beatmapInfo, beatmap);
});
AddAssert("Hash changed", () => beatmapInfo.Hash, () => Is.Not.EqualTo(originalHash));
checkDisplayedCount(0);
checkStoredCount(10);
importMoreScores(() => beatmapInfo);
importMoreScores(() => beatmapInfo);
checkDisplayedCount(20);
checkStoredCount(30);
AddStep(@"Revert changes", () =>
{
IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap;
beatmap.Difficulty.ApproachRate = 8;
beatmapManager.Save(beatmapInfo, beatmap);
});
AddAssert("Hash restored", () => beatmapInfo.Hash, () => Is.EqualTo(originalHash));
checkDisplayedCount(10);
checkStoredCount(30);
clearScores();
checkDisplayedCount(0);
checkStoredCount(0);
}
[Test]
@ -162,9 +226,9 @@ namespace osu.Game.Tests.Visual.SongSelect
});
}
private void loadMoreScores(Func<BeatmapInfo> beatmapInfo)
private void importMoreScores(Func<BeatmapInfo> beatmapInfo)
{
AddStep(@"Load new scores via manager", () =>
AddStep(@"Import new scores", () =>
{
foreach (var score in generateSampleScores(beatmapInfo()))
scoreManager.Import(score);
@ -176,8 +240,11 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("Clear all scores", () => scoreManager.Delete());
}
private void checkCount(int expected) =>
AddUntilStep("Correct count displayed", () => leaderboard.ChildrenOfType<LeaderboardScore>().Count() == expected);
private void checkDisplayedCount(int expected) =>
AddUntilStep($"{expected} scores displayed", () => leaderboard.ChildrenOfType<LeaderboardScore>().Count(), () => Is.EqualTo(expected));
private void checkStoredCount(int expected) =>
AddUntilStep($"Total scores stored is {expected}", () => Realm.Run(r => r.All<ScoreInfo>().Count(s => !s.DeletePending)), () => Is.EqualTo(expected));
private static ScoreInfo[] generateSampleScores(BeatmapInfo beatmapInfo)
{
@ -210,6 +277,7 @@ namespace osu.Game.Tests.Visual.SongSelect
},
Ruleset = new OsuRuleset().RulesetInfo,
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
User = new APIUser
{
Id = 6602580,
@ -226,6 +294,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddSeconds(-30),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
{
@ -243,6 +312,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddSeconds(-70),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
@ -261,6 +331,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddMinutes(-40),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
@ -279,6 +350,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddHours(-2),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
@ -297,6 +369,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddHours(-25),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
@ -315,6 +388,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddHours(-50),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
@ -333,6 +407,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddHours(-72),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
@ -351,6 +426,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddMonths(-3),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
@ -369,6 +445,7 @@ namespace osu.Game.Tests.Visual.SongSelect
Date = DateTime.Now.AddYears(-2),
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), },
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser

View File

@ -94,6 +94,7 @@ namespace osu.Game.Tests.Visual.UserInterface
{
OnlineID = i,
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
Accuracy = RNG.NextDouble(),
TotalScore = RNG.Next(1, 1000000),
MaxCombo = RNG.Next(1, 1000),

View File

@ -70,8 +70,9 @@ namespace osu.Game.Database
/// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo.
/// 24 2022-08-22 Added MaximumStatistics to ScoreInfo.
/// 25 2022-09-18 Remove skins to add with new naming.
/// 26 2023-02-05 Added BeatmapHash to ScoreInfo.
/// </summary>
private const int schema_version = 25;
private const int schema_version = 26;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -866,6 +867,15 @@ namespace osu.Game.Database
// Remove the default skins so they can be added back by SkinManager with updated naming.
migration.NewRealm.RemoveRange(migration.NewRealm.All<SkinInfo>().Where(s => s.Protected));
break;
case 26:
// Add ScoreInfo.BeatmapHash property to ensure scores correspond to the correct version of beatmap.
var scores = migration.NewRealm.All<ScoreInfo>();
foreach (var score in scores)
score.BeatmapHash = score.BeatmapInfo.Hash;
break;
}
}

View File

@ -3,13 +3,18 @@
#nullable disable
using System;
using System.Runtime.InteropServices;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Rendering.Vertices;
using osu.Framework.Graphics.Shaders;
using osu.Framework.Graphics.Shaders.Types;
using osu.Framework.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
using osuTK.Graphics.ES30;
namespace osu.Game.Graphics.Sprites
{
@ -18,7 +23,7 @@ namespace osu.Game.Graphics.Sprites
[BackgroundDependencyLoader]
private void load(ShaderManager shaders)
{
TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation");
TextureShader = shaders.Load(@"LogoAnimation", @"LogoAnimation");
}
private float animationProgress;
@ -43,11 +48,22 @@ namespace osu.Game.Graphics.Sprites
{
private LogoAnimation source => (LogoAnimation)Source;
private readonly Action<TexturedVertex2D> addVertexAction;
private float progress;
public LogoAnimationDrawNode(LogoAnimation source)
: base(source)
{
addVertexAction = v =>
{
animationVertexBatch!.Add(new LogoAnimationVertex
{
Position = v.Position,
Colour = v.Colour,
TexturePosition = v.TexturePosition,
});
};
}
public override void ApplyState()
@ -58,17 +74,36 @@ namespace osu.Game.Graphics.Sprites
}
private IUniformBuffer<AnimationData> animationDataBuffer;
private IVertexBatch<LogoAnimationVertex> animationVertexBatch;
protected override void BindUniformResources(IShader shader, IRenderer renderer)
{
base.BindUniformResources(shader, renderer);
animationDataBuffer ??= renderer.CreateUniformBuffer<AnimationData>();
animationVertexBatch ??= renderer.CreateQuadBatch<LogoAnimationVertex>(1, 2);
animationDataBuffer.Data = animationDataBuffer.Data with { Progress = progress };
shader.BindUniformBlock(@"m_AnimationData", animationDataBuffer);
}
protected override void Blit(IRenderer renderer)
{
if (DrawRectangle.Width == 0 || DrawRectangle.Height == 0)
return;
base.Blit(renderer);
renderer.DrawQuad(
Texture,
ScreenSpaceDrawQuad,
DrawColourInfo.Colour,
inflationPercentage: new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height),
textureCoords: TextureCoords,
vertexAction: addVertexAction);
}
protected override bool CanDrawOpaqueInterior => false;
protected override void Dispose(bool isDisposing)
@ -83,6 +118,24 @@ namespace osu.Game.Graphics.Sprites
public UniformFloat Progress;
private readonly UniformPadding12 pad1;
}
[StructLayout(LayoutKind.Sequential)]
private struct LogoAnimationVertex : IEquatable<LogoAnimationVertex>, IVertex
{
[VertexMember(2, VertexAttribPointerType.Float)]
public Vector2 Position;
[VertexMember(4, VertexAttribPointerType.Float)]
public Color4 Colour;
[VertexMember(2, VertexAttribPointerType.Float)]
public Vector2 TexturePosition;
public readonly bool Equals(LogoAnimationVertex other) =>
Position.Equals(other.Position)
&& TexturePosition.Equals(other.TexturePosition)
&& Colour.Equals(other.Colour);
}
}
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// 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;

View File

@ -2,15 +2,12 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osuTK;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Game.Resources.Localisation.Web;
using osu.Framework.Localisation;
@ -52,7 +49,6 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu
{
private readonly OsuSpriteText valueText;
protected readonly LinkFlowContainer DescriptionText;
private readonly Box lineBackground;
public new int Count
{
@ -63,25 +59,14 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Padding = new MarginPadding { Top = 10, Bottom = 20 };
Padding = new MarginPadding { Bottom = 20 };
Child = new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 5),
Children = new Drawable[]
{
new CircularContainer
{
Masking = true,
RelativeSizeAxes = Axes.X,
Height = 2,
Child = lineBackground = new Box
{
RelativeSizeAxes = Axes.Both,
}
},
new OsuSpriteText
{
Text = header,
@ -91,7 +76,6 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu
{
Text = "0",
Font = OsuFont.GetFont(size: 40, weight: FontWeight.Light),
UseFullGlyphHeight = false,
},
DescriptionText = new LinkFlowContainer(t => t.Font = t.Font.With(size: 14))
{
@ -101,12 +85,6 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu
}
};
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
lineBackground.Colour = colourProvider.Highlight1;
}
}
}
}

View File

@ -25,6 +25,8 @@ namespace osu.Game.Overlays.SkinEditor
[Resolved]
private SkinEditor editor { get; set; } = null!;
protected override bool AllowCyclicSelection => true;
public SkinBlueprintContainer(ISerialisableDrawableContainer targetContainer)
{
this.targetContainer = targetContainer;

View File

@ -24,6 +24,6 @@ namespace osu.Game.Scoring.Legacy
}
protected override Ruleset GetRuleset(int rulesetId) => rulesets.GetRuleset(rulesetId)?.CreateInstance();
protected override WorkingBeatmap GetBeatmap(string md5Hash) => beatmaps.GetWorkingBeatmap(beatmaps.QueryBeatmap(b => b.MD5Hash == md5Hash && !b.BeatmapSet.DeletePending));
protected override WorkingBeatmap GetBeatmap(string md5Hash) => beatmaps.GetWorkingBeatmap(beatmaps.QueryBeatmap(b => b.MD5Hash == md5Hash));
}
}

View File

@ -123,6 +123,7 @@ namespace osu.Game.Scoring.Legacy
// before returning for database import, we must restore the database-sourced BeatmapInfo.
// if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception.
score.ScoreInfo.BeatmapInfo = workingBeatmap.BeatmapInfo;
score.ScoreInfo.BeatmapHash = workingBeatmap.BeatmapInfo.Hash;
return score;
}

View File

@ -22,6 +22,9 @@ using Realms;
namespace osu.Game.Scoring
{
/// <summary>
/// A realm model containing metadata for a single score.
/// </summary>
[ExcludeFromDynamicCompile]
[MapTo("Score")]
public class ScoreInfo : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable<ScoreInfo>, IScoreInfo
@ -29,8 +32,19 @@ namespace osu.Game.Scoring
[PrimaryKey]
public Guid ID { get; set; }
/// <summary>
/// The <see cref="BeatmapInfo"/> this score was made against.
/// </summary>
/// <remarks>
/// When setting this, make sure to also set <see cref="BeatmapHash"/> to allow relational consistency when a beatmap is potentially changed.
/// </remarks>
public BeatmapInfo BeatmapInfo { get; set; } = null!;
/// <summary>
/// The <see cref="osu.Game.Beatmaps.BeatmapInfo.Hash"/> at the point in time when the score was set.
/// </summary>
public string BeatmapHash { get; set; } = string.Empty;
public RulesetInfo Ruleset { get; set; } = null!;
public IList<RealmNamedFileUsage> Files { get; } = null!;

View File

@ -45,6 +45,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected readonly BindableList<T> SelectedItems = new BindableList<T>();
/// <summary>
/// Whether to allow cyclic selection on clicking multiple times.
/// </summary>
/// <remarks>
/// Disabled by default as it does not work well with editors that support double-clicking or other advanced interactions.
/// Can probably be made to work with more thought.
/// </remarks>
protected virtual bool AllowCyclicSelection => false;
protected BlueprintContainer()
{
RelativeSizeAxes = Axes.Both;
@ -167,8 +176,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
Schedule(() =>
{
endClickSelection(e);
clickSelectionBegan = false;
clickSelectionHandled = false;
isDraggingBlueprint = false;
wasDragStarted = false;
});
finishSelectionMovement();
@ -182,6 +192,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
return false;
lastDragEvent = e;
wasDragStarted = true;
if (movementBlueprints != null)
{
@ -339,7 +350,12 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <summary>
/// Whether a blueprint was selected by a previous click event.
/// </summary>
private bool clickSelectionBegan;
private bool clickSelectionHandled;
/// <summary>
/// Whether the selected blueprint(s) were already selected on mouse down. Generally used to perform selection cycling on mouse up in such a case.
/// </summary>
private bool selectedBlueprintAlreadySelectedOnMouseDown;
/// <summary>
/// Attempts to select any hovered blueprints.
@ -354,7 +370,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
if (!blueprint.IsHovered) continue;
return clickSelectionBegan = SelectionHandler.MouseDownSelectionRequested(blueprint, e);
selectedBlueprintAlreadySelectedOnMouseDown = blueprint.State == SelectionState.Selected;
return clickSelectionHandled = SelectionHandler.MouseDownSelectionRequested(blueprint, e);
}
return false;
@ -367,25 +384,48 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <returns>Whether a click selection was active.</returns>
private bool endClickSelection(MouseButtonEvent e)
{
if (!clickSelectionBegan && !isDraggingBlueprint)
// If already handled a selection or drag, we don't want to perform a mouse up / click action.
if (clickSelectionHandled || isDraggingBlueprint) return true;
if (e.Button != MouseButton.Left) return false;
if (e.ControlPressed)
{
// if a selection didn't occur, we may want to trigger a deselection.
if (e.ControlPressed && e.Button == MouseButton.Left)
{
// Iterate from the top of the input stack (blueprints closest to the front of the screen first).
// Priority is given to already-selected blueprints.
foreach (SelectionBlueprint<T> blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected))
{
if (!blueprint.IsHovered) continue;
return clickSelectionBegan = SelectionHandler.MouseUpSelectionRequested(blueprint, e);
}
}
// Iterate from the top of the input stack (blueprints closest to the front of the screen first).
// Priority is given to already-selected blueprints.
foreach (SelectionBlueprint<T> blueprint in SelectionBlueprints.AliveChildren.Where(b => b.IsHovered).OrderByDescending(b => b.IsSelected))
return clickSelectionHandled = SelectionHandler.MouseUpSelectionRequested(blueprint, e);
return false;
}
return true;
if (!wasDragStarted && selectedBlueprintAlreadySelectedOnMouseDown && SelectedItems.Count == 1 && AllowCyclicSelection)
{
// If a click occurred and was handled by the currently selected blueprint but didn't result in a drag,
// cycle between other blueprints which are also under the cursor.
// The depth of blueprints is constantly changing (see above where selected blueprints are brought to the front).
// For this logic, we want a stable sort order so we can correctly cycle, thus using the blueprintMap instead.
IEnumerable<SelectionBlueprint<T>> cyclingSelectionBlueprints = blueprintMap.Values;
// If there's already a selection, let's start from the blueprint after the selection.
cyclingSelectionBlueprints = cyclingSelectionBlueprints.SkipWhile(b => !b.IsSelected).Skip(1);
// Add the blueprints from before the selection to the end of the enumerable to allow for cyclic selection.
cyclingSelectionBlueprints = cyclingSelectionBlueprints.Concat(blueprintMap.Values.TakeWhile(b => !b.IsSelected));
foreach (SelectionBlueprint<T> blueprint in cyclingSelectionBlueprints)
{
if (!blueprint.IsHovered) continue;
// We are performing a mouse up, but selection handlers perform selection on mouse down, so we need to call that instead.
return clickSelectionHandled = SelectionHandler.MouseDownSelectionRequested(blueprint, e);
}
}
return false;
}
/// <summary>
@ -441,8 +481,17 @@ namespace osu.Game.Screens.Edit.Compose.Components
private Vector2[][] movementBlueprintsOriginalPositions;
private SelectionBlueprint<T>[] movementBlueprints;
/// <summary>
/// Whether a blueprint is currently being dragged.
/// </summary>
private bool isDraggingBlueprint;
/// <summary>
/// Whether a drag operation was started at all.
/// </summary>
private bool wasDragStarted;
/// <summary>
/// Attempts to begin the movement of any selected blueprints.
/// </summary>
@ -454,7 +503,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
// Any selected blueprint that is hovered can begin the movement of the group, however only the first item (according to SortForMovement) is used for movement.
// A special case is added for when a click selection occurred before the drag
if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered))
if (!clickSelectionHandled && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered))
return false;
// Movement is tracked from the blueprint of the earliest item, since it only makes sense to distance snap from that item

View File

@ -248,6 +248,7 @@ namespace osu.Game.Screens.Play
// ensure the score is in a consistent state with the current player.
Score.ScoreInfo.BeatmapInfo = Beatmap.Value.BeatmapInfo;
Score.ScoreInfo.BeatmapHash = Beatmap.Value.BeatmapInfo.Hash;
Score.ScoreInfo.Ruleset = ruleset.RulesetInfo;
Score.ScoreInfo.Mods = gameplayMods;

View File

@ -49,6 +49,11 @@ namespace osu.Game.Screens.Select
/// </summary>
public Action? BeatmapSetsChanged;
/// <summary>
/// Triggered after filter conditions have finished being applied to the model hierarchy.
/// </summary>
public Action? FilterApplied;
/// <summary>
/// The currently selected beatmap.
/// </summary>
@ -56,6 +61,11 @@ namespace osu.Game.Screens.Select
private CarouselBeatmap? selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected);
/// <summary>
/// The total count of non-filtered beatmaps displayed.
/// </summary>
public int CountDisplayed => beatmapSets.Where(s => !s.Filtered.Value).Sum(s => s.Beatmaps.Count(b => !b.Filtered.Value));
/// <summary>
/// The currently selected beatmap set.
/// </summary>
@ -639,6 +649,8 @@ namespace osu.Game.Screens.Select
if (alwaysResetScrollPosition || !Scroll.UserScrolling)
ScrollToSelected(true);
FilterApplied?.Invoke();
}
}

View File

@ -65,6 +65,7 @@ namespace osu.Game.Screens.Select.Carousel
r.All<ScoreInfo>()
.Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0"
+ $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1"
+ $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}"
+ $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2"
+ $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName),
localScoresChanged);

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Graphics;
@ -27,20 +28,27 @@ namespace osu.Game.Screens.Select
{
public partial class FilterControl : Container
{
public const float HEIGHT = 2 * side_margin + 85;
private const float side_margin = 20;
public const float HEIGHT = 2 * side_margin + 120;
private const float side_margin = 10;
public Action<FilterCriteria> FilterChanged;
public Bindable<string> CurrentTextSearch => searchTextBox.Current;
public LocalisableString InformationalText
{
get => searchTextBox.FilterText.Text;
set => searchTextBox.FilterText.Text = value;
}
private OsuTabControl<SortMode> sortTabs;
private Bindable<SortMode> sortMode;
private Bindable<GroupMode> groupMode;
private SeekLimitedSearchTextBox searchTextBox;
private FilterControlTextBox searchTextBox;
private CollectionDropdown collectionDropdown;
@ -99,72 +107,63 @@ namespace osu.Game.Screens.Select
{
RelativeSizeAxes = Axes.Both,
Spacing = new Vector2(0, 5),
Children = new[]
Children = new Drawable[]
{
new Container
searchTextBox = new FilterControlTextBox
{
RelativeSizeAxes = Axes.X,
Height = 60,
Children = new Drawable[]
},
new Box
{
RelativeSizeAxes = Axes.X,
Height = 1,
Colour = OsuColour.Gray(80),
},
new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
searchTextBox = new SeekLimitedSearchTextBox { RelativeSizeAxes = Axes.X },
new Box
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.Absolute, OsuTabControl<SortMode>.HORIZONTAL_SPACING),
new Dimension(),
new Dimension(GridSizeMode.Absolute, OsuTabControl<SortMode>.HORIZONTAL_SPACING),
new Dimension(GridSizeMode.AutoSize),
},
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
Content = new[]
{
new[]
{
RelativeSizeAxes = Axes.X,
Height = 1,
Colour = OsuColour.Gray(80),
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
new GridContainer
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
new OsuSpriteText
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.Absolute, OsuTabControl<SortMode>.HORIZONTAL_SPACING),
new Dimension(),
new Dimension(GridSizeMode.Absolute, OsuTabControl<SortMode>.HORIZONTAL_SPACING),
new Dimension(GridSizeMode.AutoSize),
Text = SortStrings.Default,
Font = OsuFont.GetFont(size: 14),
Margin = new MarginPadding(5),
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
},
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
Content = new[]
Empty(),
sortTabs = new OsuTabControl<SortMode>
{
new[]
{
new OsuSpriteText
{
Text = SortStrings.Default,
Font = OsuFont.GetFont(size: 14),
Margin = new MarginPadding(5),
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
},
Empty(),
sortTabs = new OsuTabControl<SortMode>
{
RelativeSizeAxes = Axes.X,
Height = 24,
AutoSort = true,
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
AccentColour = colours.GreenLight,
Current = { BindTarget = sortMode }
},
Empty(),
new OsuTabControlCheckbox
{
Text = "Show converted",
Current = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps),
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
},
}
}
},
RelativeSizeAxes = Axes.X,
Height = 24,
AutoSort = true,
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
AccentColour = colours.GreenLight,
Current = { BindTarget = sortMode }
},
Empty(),
new OsuTabControlCheckbox
{
Text = "Show converted",
Current = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps),
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
},
}
}
},
new Container
@ -248,5 +247,32 @@ namespace osu.Game.Screens.Select
protected override bool OnClick(ClickEvent e) => true;
protected override bool OnHover(HoverEvent e) => true;
private partial class FilterControlTextBox : SeekLimitedSearchTextBox
{
private const float filter_text_size = 12;
public OsuSpriteText FilterText { get; private set; }
public FilterControlTextBox()
{
Height += filter_text_size;
TextContainer.Margin = new MarginPadding { Bottom = filter_text_size };
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
TextContainer.Add(FilterText = new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.TopLeft,
Depth = float.MinValue,
Font = OsuFont.Default.With(size: filter_text_size, weight: FontWeight.SemiBold),
Margin = new MarginPadding { Top = 2, Left = 2 },
Colour = colours.Yellow
});
}
}
}
}

View File

@ -191,6 +191,7 @@ namespace osu.Game.Screens.Select.Leaderboards
scoreSubscription = realm.RegisterForNotifications(r =>
r.All<ScoreInfo>().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0"
+ $" AND {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}"
+ $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1"
+ $" AND {nameof(ScoreInfo.DeletePending)} == false"
, beatmapInfo.ID, ruleset.Value.ShortName), localScoresChanged);

View File

@ -162,6 +162,7 @@ namespace osu.Game.Screens.Select
BleedBottom = Footer.HEIGHT,
SelectionChanged = updateSelectedBeatmap,
BeatmapSetsChanged = carouselBeatmapsLoaded,
FilterApplied = updateVisibleBeatmapCount,
GetRecommendedBeatmap = s => recommender?.GetRecommendedBeatmap(s),
}, c => carouselContainer.Child = c);
@ -828,6 +829,7 @@ namespace osu.Game.Screens.Select
private void carouselBeatmapsLoaded()
{
bindBindables();
updateVisibleBeatmapCount();
Carousel.AllowSelection = true;
@ -857,6 +859,15 @@ namespace osu.Game.Screens.Select
}
}
private void updateVisibleBeatmapCount()
{
FilterControl.InformationalText = Carousel.CountDisplayed == 1
// Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918
// but also in this case we want support for formatting a number within a string).
? $"{Carousel.CountDisplayed:#,0} matching beatmap"
: $"{Carousel.CountDisplayed:#,0} matching beatmaps";
}
private bool boundLocalBindables;
private void bindBindables()

View File

@ -37,7 +37,7 @@
</PackageReference>
<PackageReference Include="Realm" Version="10.20.0" />
<PackageReference Include="ppy.osu.Framework" Version="2023.322.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.314.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.320.0" />
<PackageReference Include="Sentry" Version="3.28.1" />
<PackageReference Include="SharpCompress" Version="0.32.2" />
<PackageReference Include="NUnit" Version="3.13.3" />