Merge branch 'master' of https://github.com/ppy/osu into carousel-perform-selection

 Conflicts:
	osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
This commit is contained in:
Endrik Tombak
2022-10-24 21:40:48 +03:00
815 changed files with 17450 additions and 20087 deletions

View File

@ -129,11 +129,19 @@ namespace osu.Game.Screens.Backgrounds
}
case BackgroundSource.Skin:
// default skins should use the default background rotation, which won't be the case if a SkinBackground is created for them.
if (skin.Value is DefaultSkin || skin.Value is DefaultLegacySkin)
break;
switch (skin.Value)
{
case TrianglesSkin:
case ArgonSkin:
case DefaultLegacySkin:
// default skins should use the default background rotation, which won't be the case if a SkinBackground is created for them.
break;
default:
newBackground = new SkinBackground(skin.Value, getBackgroundTextureName());
break;
}
newBackground = new SkinBackground(skin.Value, getBackgroundTextureName());
break;
}
}

View File

@ -8,13 +8,12 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osuTK;
using osuTK.Graphics;
@ -30,9 +29,9 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
public readonly RadioButton Button;
private Color4 defaultBackgroundColour;
private Color4 defaultBubbleColour;
private Color4 defaultIconColour;
private Color4 selectedBackgroundColour;
private Color4 selectedBubbleColour;
private Color4 selectedIconColour;
private Drawable icon;
@ -50,20 +49,13 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
private void load(OverlayColourProvider colourProvider)
{
defaultBackgroundColour = colours.Gray3;
defaultBubbleColour = defaultBackgroundColour.Darken(0.5f);
selectedBackgroundColour = colours.BlueDark;
selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f);
defaultBackgroundColour = colourProvider.Background3;
selectedBackgroundColour = colourProvider.Background1;
Content.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Radius = 2,
Offset = new Vector2(0, 1),
Colour = Color4.Black.Opacity(0.5f)
};
defaultIconColour = defaultBackgroundColour.Darken(0.5f);
selectedIconColour = selectedBackgroundColour.Lighten(0.5f);
Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b =>
{
@ -98,7 +90,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
return;
BackgroundColour = Button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour;
icon.Colour = Button.Selected.Value ? selectedBubbleColour : defaultBubbleColour;
icon.Colour = Button.Selected.Value ? selectedIconColour : defaultIconColour;
}
protected override SpriteText CreateText() => new OsuSpriteText

View File

@ -6,12 +6,11 @@
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osuTK;
using osuTK.Graphics;
@ -20,9 +19,9 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
internal class DrawableTernaryButton : OsuButton
{
private Color4 defaultBackgroundColour;
private Color4 defaultBubbleColour;
private Color4 defaultIconColour;
private Color4 selectedBackgroundColour;
private Color4 selectedBubbleColour;
private Color4 selectedIconColour;
private Drawable icon;
@ -38,20 +37,13 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
private void load(OverlayColourProvider colourProvider)
{
defaultBackgroundColour = colours.Gray3;
defaultBubbleColour = defaultBackgroundColour.Darken(0.5f);
selectedBackgroundColour = colours.BlueDark;
selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f);
defaultBackgroundColour = colourProvider.Background3;
selectedBackgroundColour = colourProvider.Background1;
Content.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Radius = 2,
Offset = new Vector2(0, 1),
Colour = Color4.Black.Opacity(0.5f)
};
defaultIconColour = defaultBackgroundColour.Darken(0.5f);
selectedIconColour = selectedBackgroundColour.Lighten(0.5f);
Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b =>
{
@ -85,17 +77,17 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
switch (Button.Bindable.Value)
{
case TernaryState.Indeterminate:
icon.Colour = selectedBubbleColour.Darken(0.5f);
icon.Colour = selectedIconColour.Darken(0.5f);
BackgroundColour = selectedBackgroundColour.Darken(0.5f);
break;
case TernaryState.False:
icon.Colour = defaultBubbleColour;
icon.Colour = defaultIconColour;
BackgroundColour = defaultBackgroundColour;
break;
case TernaryState.True:
icon.Colour = selectedBubbleColour;
icon.Colour = selectedIconColour;
BackgroundColour = selectedBackgroundColour;
break;
}

View File

@ -123,16 +123,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
},
new Drawable[]
{
new TextFlowContainer(s => s.Font = s.Font.With(size: 14))
{
Padding = new MarginPadding { Horizontal = 15 },
Text = "beat snap",
RelativeSizeAxes = Axes.X,
TextAnchor = Anchor.TopCentre
},
},
new Drawable[]
{
new Container
{
@ -173,6 +163,16 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
}
},
new Drawable[]
{
new TextFlowContainer(s => s.Font = s.Font.With(size: 14))
{
Padding = new MarginPadding { Horizontal = 15, Vertical = 8 },
Text = "beat snap",
RelativeSizeAxes = Axes.X,
TextAnchor = Anchor.TopCentre,
},
},
},
RowDimensions = new[]
{

View File

@ -3,7 +3,6 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
@ -13,7 +12,6 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
@ -61,25 +59,31 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
case NotifyCollectionChangedAction.Add:
foreach (object o in args.NewItems)
SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select();
{
if (blueprintMap.TryGetValue((T)o, out var blueprint))
blueprint.Select();
}
break;
case NotifyCollectionChangedAction.Remove:
foreach (object o in args.OldItems)
SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect();
{
if (blueprintMap.TryGetValue((T)o, out var blueprint))
blueprint.Deselect();
}
break;
}
};
SelectionHandler = CreateSelectionHandler();
SelectionHandler.DeselectAll = deselectAll;
SelectionHandler.DeselectAll = DeselectAll;
SelectionHandler.SelectedItems.BindTo(SelectedItems);
AddRangeInternal(new[]
{
DragBox = CreateDragBox(selectBlueprintsFromDragRectangle),
DragBox = CreateDragBox(),
SelectionHandler,
SelectionBlueprints = CreateSelectionBlueprintContainer(),
SelectionHandler.CreateProxy(),
@ -101,12 +105,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
[CanBeNull]
protected virtual SelectionBlueprint<T> CreateBlueprintFor(T item) => null;
protected virtual DragBox CreateDragBox(Action<RectangleF> performSelect) => new DragBox(performSelect);
/// <summary>
/// Whether this component is in a state where items outside a drag selection should be deselected. If false, selection will only be added to.
/// </summary>
protected virtual bool AllowDeselectionDuringDrag => true;
protected virtual DragBox CreateDragBox() => new DragBox();
protected override bool OnMouseDown(MouseDownEvent e)
{
@ -142,7 +141,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (endClickSelection(e) || ClickedBlueprint != null)
return true;
deselectAll();
DeselectAll();
return true;
}
@ -171,11 +170,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
finishSelectionMovement();
}
private MouseButtonEvent lastDragEvent;
protected override bool OnDragStart(DragStartEvent e)
{
if (e.Button == MouseButton.Right)
return false;
lastDragEvent = e;
if (movementBlueprints != null)
{
isDraggingBlueprint = true;
@ -183,30 +186,21 @@ namespace osu.Game.Screens.Edit.Compose.Components
return true;
}
if (DragBox.HandleDrag(e))
{
DragBox.Show();
return true;
}
return false;
DragBox.HandleDrag(e);
DragBox.Show();
return true;
}
protected override void OnDrag(DragEvent e)
{
if (e.Button == MouseButton.Right)
return;
if (DragBox.State == Visibility.Visible)
DragBox.HandleDrag(e);
lastDragEvent = e;
moveCurrentSelection(e);
}
protected override void OnDragEnd(DragEndEvent e)
{
if (e.Button == MouseButton.Right)
return;
lastDragEvent = null;
if (isDraggingBlueprint)
{
@ -214,8 +208,19 @@ namespace osu.Game.Screens.Edit.Compose.Components
changeHandler?.EndChange();
}
if (DragBox.State == Visibility.Visible)
DragBox.Hide();
DragBox.Hide();
}
protected override void Update()
{
base.Update();
if (lastDragEvent != null && DragBox.State == Visibility.Visible)
{
lastDragEvent.Target = this;
DragBox.HandleDrag(lastDragEvent);
UpdateSelectionFromDragBox();
}
}
/// <summary>
@ -233,7 +238,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (!SelectionHandler.SelectedBlueprints.Any())
return false;
deselectAll();
DeselectAll();
return true;
}
@ -292,7 +297,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
blueprint.Selected -= OnBlueprintSelected;
blueprint.Deselected -= OnBlueprintDeselected;
SelectionBlueprints.Remove(blueprint);
SelectionBlueprints.Remove(blueprint, true);
if (movementBlueprints?.Contains(blueprint) == true)
finishSelectionMovement();
@ -380,44 +385,39 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
/// <summary>
/// Select all masks in a given rectangle selection area.
/// Select all blueprints in a selection area specified by <see cref="DragBox"/>.
/// </summary>
/// <param name="rect">The rectangle to perform a selection on in screen-space coordinates.</param>
private void selectBlueprintsFromDragRectangle(RectangleF rect)
protected virtual void UpdateSelectionFromDragBox()
{
var quad = DragBox.Box.ScreenSpaceDrawQuad;
foreach (var blueprint in SelectionBlueprints)
{
// only run when utmost necessary to avoid unnecessary rect computations.
bool isValidForSelection() => blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.ScreenSpaceSelectionPoint);
switch (blueprint.State)
{
case SelectionState.NotSelected:
if (isValidForSelection())
blueprint.Select();
case SelectionState.Selected:
// Selection is preserved even after blueprint becomes dead.
if (!quad.Contains(blueprint.ScreenSpaceSelectionPoint))
blueprint.Deselect();
break;
case SelectionState.Selected:
if (AllowDeselectionDuringDrag && !isValidForSelection())
blueprint.Deselect();
case SelectionState.NotSelected:
if (blueprint.IsAlive && blueprint.IsPresent && quad.Contains(blueprint.ScreenSpaceSelectionPoint))
blueprint.Select();
break;
}
}
}
/// <summary>
/// Selects all <see cref="SelectionBlueprint{T}"/>s.
/// Select all currently-present items.
/// </summary>
protected virtual void SelectAll()
{
// Scheduled to allow the change in lifetime to take place.
Schedule(() => SelectionBlueprints.ToList().ForEach(m => m.Select()));
}
protected abstract void SelectAll();
/// <summary>
/// Deselects all selected <see cref="SelectionBlueprint{T}"/>s.
/// Deselect all selected items.
/// </summary>
private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect());
protected void DeselectAll() => SelectedItems.Clear();
protected virtual void OnBlueprintSelected(SelectionBlueprint<T> blueprint)
{

View File

@ -12,7 +12,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Game.Audio;
using osu.Game.Graphics.UserInterface;
@ -37,7 +36,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected new EditorSelectionHandler SelectionHandler => (EditorSelectionHandler)base.SelectionHandler;
private PlacementBlueprint currentPlacement;
private InputManager inputManager;
/// <remarks>
/// Positional input must be received outside the container's bounds,
@ -66,8 +64,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
base.LoadComplete();
inputManager = GetContainingInputManager();
Beatmap.HitObjectAdded += hitObjectAdded;
// updates to selected are handled for us by SelectionHandler.
@ -220,7 +216,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
private void updatePlacementPosition()
{
var snapResult = Composer.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position);
var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position);
// if no time was found from positional snapping, we should still quantize to the beat.
snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null);

View File

@ -8,7 +8,6 @@ using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Layout;
@ -21,18 +20,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// </summary>
public class DragBox : CompositeDrawable, IStateful<Visibility>
{
protected readonly Action<RectangleF> PerformSelection;
protected Drawable Box;
public Drawable Box { get; private set; }
/// <summary>
/// Creates a new <see cref="DragBox"/>.
/// </summary>
/// <param name="performSelection">A delegate that performs drag selection.</param>
public DragBox(Action<RectangleF> performSelection)
public DragBox()
{
PerformSelection = performSelection;
RelativeSizeAxes = Axes.Both;
AlwaysPresent = true;
Alpha = 0;
@ -46,30 +40,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected virtual Drawable CreateBox() => new BoxWithBorders();
private RectangleF? dragRectangle;
/// <summary>
/// Handle a forwarded mouse event.
/// </summary>
/// <param name="e">The mouse event.</param>
/// <returns>Whether the event should be handled and blocking.</returns>
public virtual bool HandleDrag(MouseButtonEvent e)
public virtual void HandleDrag(MouseButtonEvent e)
{
var dragPosition = e.ScreenSpaceMousePosition;
var dragStartPosition = e.ScreenSpaceMouseDownPosition;
var dragQuad = new Quad(dragStartPosition.X, dragStartPosition.Y, dragPosition.X - dragStartPosition.X, dragPosition.Y - dragStartPosition.Y);
// We use AABBFloat instead of RectangleF since it handles negative sizes for us
var rec = dragQuad.AABBFloat;
dragRectangle = rec;
var topLeft = ToLocalSpace(rec.TopLeft);
var bottomRight = ToLocalSpace(rec.BottomRight);
Box.Position = topLeft;
Box.Size = bottomRight - topLeft;
return true;
Box.Position = Vector2.ComponentMin(e.MouseDownPosition, e.MousePosition);
Box.Size = Vector2.ComponentMax(e.MouseDownPosition, e.MousePosition) - Box.Position;
}
private Visibility state;
@ -87,19 +65,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
}
protected override void Update()
{
base.Update();
if (dragRectangle != null)
PerformSelection?.Invoke(dragRectangle.Value);
}
public override void Hide()
{
State = Visibility.Hidden;
dragRectangle = null;
}
public override void Hide() => State = Visibility.Hidden;
public override void Show() => State = Visibility.Visible;

View File

@ -8,6 +8,7 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
@ -27,6 +28,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
private HitObjectUsageEventBuffer usageEventBuffer;
protected InputManager InputManager { get; private set; }
protected EditorBlueprintContainer(HitObjectComposer composer)
{
Composer = composer;
@ -42,6 +45,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
base.LoadComplete();
InputManager = GetContainingInputManager();
Beatmap.HitObjectAdded += AddBlueprintFor;
Beatmap.HitObjectRemoved += RemoveBlueprintFor;
@ -66,8 +71,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected override IEnumerable<SelectionBlueprint<HitObject>> SortForMovement(IReadOnlyList<SelectionBlueprint<HitObject>> blueprints)
=> blueprints.OrderBy(b => b.Item.StartTime);
protected override bool AllowDeselectionDuringDrag => !EditorClock.IsRunning;
protected override bool ApplySnapResult(SelectionBlueprint<HitObject>[] blueprints, SnapResult result)
{
if (!base.ApplySnapResult(blueprints, result))
@ -133,8 +136,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected override void SelectAll()
{
Composer.Playfield.KeepAllAlive();
base.SelectAll();
SelectedItems.AddRange(Beatmap.HitObjects.Except(SelectedItems).ToArray());
}
protected override void OnBlueprintSelected(SelectionBlueprint<HitObject> blueprint)

View File

@ -24,21 +24,19 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
base.LoadComplete();
editorBeatmap.HitObjectUpdated += hitObjectUpdated;
editorBeatmap.BeatmapReprocessed += SortInternal;
}
private void hitObjectUpdated(HitObject _) => SortInternal();
public override void Add(SelectionBlueprint<HitObject> drawable)
{
SortInternal();
base.Add(drawable);
}
public override bool Remove(SelectionBlueprint<HitObject> drawable)
public override bool Remove(SelectionBlueprint<HitObject> drawable, bool disposeImmediately)
{
SortInternal();
return base.Remove(drawable);
return base.Remove(drawable, disposeImmediately);
}
protected override int Compare(Drawable x, Drawable y)
@ -64,7 +62,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (result != 0) return result;
}
return CompareReverseChildID(y, x);
return CompareReverseChildID(x, y);
}
protected override void Dispose(bool isDisposing)
@ -72,7 +70,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
base.Dispose(isDisposing);
if (editorBeatmap != null)
editorBeatmap.HitObjectUpdated -= hitObjectUpdated;
editorBeatmap.BeatmapReprocessed -= SortInternal;
}
}
}

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 System;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
namespace osu.Game.Screens.Edit.Compose.Components
{
/// <summary>
/// A <see cref="DragBox"/> that scrolls along with the scrolling playfield.
/// </summary>
public class ScrollingDragBox : DragBox
{
public double MinTime { get; private set; }
public double MaxTime { get; private set; }
private double? startTime;
private readonly ScrollingPlayfield playfield;
public ScrollingDragBox(Playfield playfield)
{
this.playfield = playfield as ScrollingPlayfield ?? throw new ArgumentException("Playfield must be of type {nameof(ScrollingPlayfield)} to use this class.", nameof(playfield));
}
public override void HandleDrag(MouseButtonEvent e)
{
base.HandleDrag(e);
startTime ??= playfield.TimeAtScreenSpacePosition(e.ScreenSpaceMouseDownPosition);
double endTime = playfield.TimeAtScreenSpacePosition(e.ScreenSpaceMousePosition);
MinTime = Math.Min(startTime.Value, endTime);
MaxTime = Math.Max(startTime.Value, endTime);
var startPos = ToLocalSpace(playfield.ScreenSpacePositionAtTime(startTime.Value));
var endPos = ToLocalSpace(playfield.ScreenSpacePositionAtTime(endTime));
switch (playfield.ScrollingInfo.Direction.Value)
{
case ScrollingDirection.Up:
case ScrollingDirection.Down:
Box.Y = Math.Min(startPos.Y, endPos.Y);
Box.Height = Math.Max(startPos.Y, endPos.Y) - Box.Y;
break;
case ScrollingDirection.Left:
case ScrollingDirection.Right:
Box.X = Math.Min(startPos.X, endPos.X);
Box.Width = Math.Max(startPos.X, endPos.X) - Box.X;
break;
}
}
public override void Hide()
{
base.Hide();
startTime = null;
}
}
}

View File

@ -305,7 +305,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected void DeleteSelected()
{
DeleteItems(selectedBlueprints.Select(b => b.Item));
DeleteItems(SelectedItems.ToArray());
}
#endregion

View File

@ -5,7 +5,6 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
@ -64,8 +63,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
/// </summary>
private bool trackWasPlaying;
private Track track;
/// <summary>
/// The timeline zoom level at a 1x zoom scale.
/// </summary>
@ -93,6 +90,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private Bindable<float> waveformOpacity;
private double trackLengthForZoom;
[BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours, OsuConfigManager config)
{
@ -144,9 +143,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
Beatmap.BindValueChanged(b =>
{
waveform.Waveform = b.NewValue.Waveform;
track = b.NewValue.Track;
setupTimelineZoom();
}, true);
Zoom = (float)(defaultTimelineZoom * editorBeatmap.BeatmapInfo.TimelineZoom);
@ -185,8 +181,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private void updateWaveformOpacity() =>
waveform.FadeTo(WaveformVisible.Value ? waveformOpacity.Value : 0, 200, Easing.OutQuint);
private float getZoomLevelForVisibleMilliseconds(double milliseconds) => Math.Max(1, (float)(track.Length / milliseconds));
protected override void Update()
{
base.Update();
@ -197,20 +191,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
// This needs to happen after transforms are updated, but before the scroll position is updated in base.UpdateAfterChildren
if (editorClock.IsRunning)
scrollToTrackTime();
}
private void setupTimelineZoom()
{
if (!track.IsLoaded)
if (editorClock.TrackLength != trackLengthForZoom)
{
Scheduler.AddOnce(setupTimelineZoom);
return;
defaultTimelineZoom = getZoomLevelForVisibleMilliseconds(6000);
float initialZoom = (float)(defaultTimelineZoom * (editorBeatmap.BeatmapInfo.TimelineZoom == 0 ? 1 : editorBeatmap.BeatmapInfo.TimelineZoom));
float minimumZoom = getZoomLevelForVisibleMilliseconds(10000);
float maximumZoom = getZoomLevelForVisibleMilliseconds(500);
SetupZoom(initialZoom, minimumZoom, maximumZoom);
float getZoomLevelForVisibleMilliseconds(double milliseconds) => Math.Max(1, (float)(editorClock.TrackLength / milliseconds));
trackLengthForZoom = editorClock.TrackLength;
}
defaultTimelineZoom = getZoomLevelForVisibleMilliseconds(6000);
float initialZoom = (float)(defaultTimelineZoom * editorBeatmap.BeatmapInfo.TimelineZoom);
SetupZoom(initialZoom, getZoomLevelForVisibleMilliseconds(10000), getZoomLevelForVisibleMilliseconds(500));
}
protected override bool OnScroll(ScrollEvent e)
@ -255,16 +250,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private void seekTrackToCurrent()
{
if (!track.IsLoaded)
return;
double target = Current / Content.DrawWidth * track.Length;
editorClock.Seek(Math.Min(track.Length, target));
double target = Current / Content.DrawWidth * editorClock.TrackLength;
editorClock.Seek(Math.Min(editorClock.TrackLength, target));
}
private void scrollToTrackTime()
{
if (!track.IsLoaded || track.Length == 0)
if (editorClock.TrackLength == 0)
return;
// covers the case where the user starts playback after a drag is in progress.
@ -272,7 +264,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
if (handlingDragInput)
editorClock.Stop();
ScrollTo((float)(editorClock.CurrentTime / track.Length) * Content.DrawWidth, false);
ScrollTo((float)(editorClock.CurrentTime / editorClock.TrackLength) * Content.DrawWidth, false);
}
protected override bool OnMouseDown(MouseDownEvent e)
@ -310,12 +302,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
/// <summary>
/// The total amount of time visible on the timeline.
/// </summary>
public double VisibleRange => track.Length / Zoom;
public double VisibleRange => editorClock.TrackLength / Zoom;
public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) =>
new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition))));
public double TimeAtPosition(float x)
{
return x / Content.DrawWidth * editorClock.TrackLength;
}
private double getTimeFromPosition(Vector2 localPosition) =>
(localPosition.X / Content.DrawWidth) * track.Length;
public float PositionAtTime(double time)
{
return (float)(time / editorClock.TrackLength * Content.DrawWidth);
}
public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All)
{
double time = TimeAtPosition(Content.ToLocalSpace(screenSpacePosition).X);
return new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(time));
}
}
}

View File

@ -78,16 +78,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
LabelText = "Waveform",
Current = { Value = true },
},
controlPointsCheckbox = new OsuCheckbox
{
LabelText = "Control Points",
Current = { Value = true },
},
ticksCheckbox = new OsuCheckbox
{
LabelText = "Ticks",
Current = { Value = true },
}
},
controlPointsCheckbox = new OsuCheckbox
{
LabelText = "BPM",
Current = { Value = true },
},
}
}
}

View File

@ -3,7 +3,6 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
@ -13,7 +12,6 @@ using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
@ -31,10 +29,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
[Resolved(CanBeNull = true)]
private Timeline timeline { get; set; }
private DragEvent lastDragEvent;
private Bindable<HitObject> placement;
private SelectionBlueprint<HitObject> placementBlueprint;
private bool hitObjectDragged;
/// <remarks>
/// Positional input must be received outside the container's bounds,
/// in order to handle timeline blueprints which are stacked offscreen.
@ -65,7 +64,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
protected override void LoadComplete()
{
base.LoadComplete();
DragBox.Alpha = 0;
placement = Beatmap.PlacementObject.GetBoundCopy();
placement.ValueChanged += placementChanged;
@ -77,7 +75,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
if (placementBlueprint != null)
{
SelectionBlueprints.Remove(placementBlueprint);
SelectionBlueprints.Remove(placementBlueprint, true);
placementBlueprint = null;
}
}
@ -93,24 +91,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
protected override Container<SelectionBlueprint<HitObject>> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both };
protected override void OnDrag(DragEvent e)
protected override bool OnDragStart(DragStartEvent e)
{
handleScrollViaDrag(e);
if (!base.ReceivePositionalInputAt(e.ScreenSpaceMouseDownPosition))
return false;
base.OnDrag(e);
}
protected override void OnDragEnd(DragEndEvent e)
{
base.OnDragEnd(e);
lastDragEvent = null;
return base.OnDragStart(e);
}
protected override void Update()
{
// trigger every frame so drags continue to update selection while playback is scrolling the timeline.
if (lastDragEvent != null)
OnDrag(lastDragEvent);
if (IsDragged || hitObjectDragged)
handleScrollViaDrag();
if (Composer != null && timeline != null)
{
@ -165,30 +157,45 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
return new TimelineHitObjectBlueprint(item)
{
OnDragHandled = handleScrollViaDrag,
OnDragHandled = e => hitObjectDragged = e != null,
};
}
protected override DragBox CreateDragBox(Action<RectangleF> performSelect) => new TimelineDragBox(performSelect);
protected sealed override DragBox CreateDragBox() => new TimelineDragBox();
private void handleScrollViaDrag(DragEvent e)
protected override void UpdateSelectionFromDragBox()
{
lastDragEvent = e;
var dragBox = (TimelineDragBox)DragBox;
double minTime = dragBox.MinTime;
double maxTime = dragBox.MaxTime;
if (lastDragEvent == null)
return;
SelectedItems.RemoveAll(hitObject => !shouldBeSelected(hitObject));
if (timeline != null)
foreach (var hitObject in Beatmap.HitObjects.Except(SelectedItems).Where(shouldBeSelected))
{
var timelineQuad = timeline.ScreenSpaceDrawQuad;
float mouseX = e.ScreenSpaceMousePosition.X;
// scroll if in a drag and dragging outside visible extents
if (mouseX > timelineQuad.TopRight.X)
timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime));
else if (mouseX < timelineQuad.TopLeft.X)
timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime));
Composer.Playfield.SetKeepAlive(hitObject, true);
SelectedItems.Add(hitObject);
}
bool shouldBeSelected(HitObject hitObject)
{
double midTime = (hitObject.StartTime + hitObject.GetEndTime()) / 2;
return minTime <= midTime && midTime <= maxTime;
}
}
private void handleScrollViaDrag()
{
if (timeline == null) return;
var timelineQuad = timeline.ScreenSpaceDrawQuad;
float mouseX = InputManager.CurrentState.Mouse.Position.X;
// scroll if in a drag and dragging outside visible extents
if (mouseX > timelineQuad.TopRight.X)
timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime));
else if (mouseX < timelineQuad.TopLeft.X)
timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime));
}
private class SelectableAreaBackground : CompositeDrawable

View File

@ -6,76 +6,44 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
public class TimelineDragBox : DragBox
{
// the following values hold the start and end X positions of the drag box in the timeline's local space,
// but with zoom unapplied in order to be able to compensate for positional changes
// while the timeline is being zoomed in/out.
private float? selectionStart;
private float selectionEnd;
public double MinTime { get; private set; }
public double MaxTime { get; private set; }
private double? startTime;
[Resolved]
private Timeline timeline { get; set; }
public TimelineDragBox(Action<RectangleF> performSelect)
: base(performSelect)
{
}
protected override Drawable CreateBox() => new Box
{
RelativeSizeAxes = Axes.Y,
Alpha = 0.3f
};
public override bool HandleDrag(MouseButtonEvent e)
public override void HandleDrag(MouseButtonEvent e)
{
// The dragbox should only be active if the mouseDownPosition.Y is within this drawable's bounds.
float localY = ToLocalSpace(e.ScreenSpaceMouseDownPosition).Y;
if (DrawRectangle.Top > localY || DrawRectangle.Bottom < localY)
return false;
startTime ??= timeline.TimeAtPosition(e.MouseDownPosition.X);
double endTime = timeline.TimeAtPosition(e.MousePosition.X);
selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom;
MinTime = Math.Min(startTime.Value, endTime);
MaxTime = Math.Max(startTime.Value, endTime);
// only calculate end when a transition is not in progress to avoid bouncing.
if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom))
selectionEnd = e.MousePosition.X / timeline.CurrentZoom;
updateDragBoxPosition();
return true;
}
private void updateDragBoxPosition()
{
if (selectionStart == null)
return;
float rescaledStart = selectionStart.Value * timeline.CurrentZoom;
float rescaledEnd = selectionEnd * timeline.CurrentZoom;
Box.X = Math.Min(rescaledStart, rescaledEnd);
Box.Width = Math.Abs(rescaledStart - rescaledEnd);
var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat;
// we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment.
boxScreenRect.Y -= boxScreenRect.Height;
boxScreenRect.Height *= 2;
PerformSelection?.Invoke(boxScreenRect);
Box.X = timeline.PositionAtTime(MinTime);
Box.Width = timeline.PositionAtTime(MaxTime) - Box.X;
}
public override void Hide()
{
base.Hide();
selectionStart = null;
startTime = null;
}
}
}

View File

@ -1,8 +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.
#nullable disable
using System;
using JetBrains.Annotations;
using osu.Framework.Allocation;
@ -33,19 +31,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
private const float circle_size = 38;
private Container repeatsContainer;
private Container? repeatsContainer;
public Action<DragEvent> OnDragHandled;
public Action<DragEvent?>? OnDragHandled = null!;
[UsedImplicitly]
private readonly Bindable<double> startTime;
private Bindable<int> indexInCurrentComboBindable;
private Bindable<int>? indexInCurrentComboBindable;
private Bindable<int> comboIndexBindable;
private Bindable<int> comboIndexWithOffsetsBindable;
private Bindable<int>? comboIndexBindable;
private Bindable<int>? comboIndexWithOffsetsBindable;
private Bindable<Color4> displayColourBindable;
private Bindable<Color4> displayColourBindable = null!;
private readonly ExtendableCircle circle;
private readonly Border border;
@ -54,7 +52,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private readonly OsuSpriteText comboIndexText;
[Resolved]
private ISkinSource skin { get; set; }
private ISkinSource skin { get; set; } = null!;
public TimelineHitObjectBlueprint(HitObject item)
: base(item)
@ -124,7 +122,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
case IHasComboInformation comboInfo:
indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy();
indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true);
indexInCurrentComboBindable.BindValueChanged(_ =>
{
comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString();
}, true);
comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy();
comboIndexWithOffsetsBindable = comboInfo.ComboIndexWithOffsetsBindable.GetBoundCopy();
@ -149,8 +150,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
updateColour();
}
private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString();
private void updateColour()
{
Color4 colour;
@ -183,11 +182,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
colouredComponents.Colour = OsuColour.ForegroundTextColourFor(averageColour);
}
private SamplePointPiece sampleOverrideDisplay;
private DifficultyPointPiece difficultyOverrideDisplay;
private SamplePointPiece? sampleOverrideDisplay;
private DifficultyPointPiece? difficultyOverrideDisplay;
private DifficultyControlPoint difficultyControlPoint;
private SampleControlPoint sampleControlPoint;
private DifficultyControlPoint difficultyControlPoint = null!;
private SampleControlPoint sampleControlPoint = null!;
protected override void Update()
{
@ -276,16 +275,27 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
public class DragArea : Circle
{
private readonly HitObject hitObject;
private readonly HitObject? hitObject;
[Resolved]
private Timeline timeline { get; set; }
private EditorBeatmap beatmap { get; set; } = null!;
public Action<DragEvent> OnDragHandled;
[Resolved]
private IBeatSnapProvider beatSnapProvider { get; set; } = null!;
[Resolved]
private Timeline timeline { get; set; } = null!;
[Resolved(CanBeNull = true)]
private IEditorChangeHandler? changeHandler { get; set; }
private ScheduledDelegate? dragOperation;
public Action<DragEvent?>? OnDragHandled;
public override bool HandlePositionalInput => hitObject != null;
public DragArea(HitObject hitObject)
public DragArea(HitObject? hitObject)
{
this.hitObject = hitObject;
@ -356,23 +366,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
this.FadeTo(IsHovered || hasMouseDown ? 1f : 0.9f, 200, Easing.OutQuint);
}
[Resolved]
private EditorBeatmap beatmap { get; set; }
[Resolved]
private IBeatSnapProvider beatSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
private IEditorChangeHandler changeHandler { get; set; }
protected override bool OnDragStart(DragStartEvent e)
{
changeHandler?.BeginChange();
return true;
}
private ScheduledDelegate dragOperation;
protected override void OnDrag(DragEvent e)
{
base.OnDrag(e);

View File

@ -4,11 +4,12 @@
#nullable disable
using System;
using System.Linq;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts;
@ -34,8 +35,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
[Resolved]
private OsuColour colours { get; set; }
private static readonly int highest_divisor = BindableBeatDivisor.PREDEFINED_DIVISORS.Last();
public TimelineTickDisplay()
{
RelativeSizeAxes = Axes.Both;
@ -80,20 +79,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
base.Update();
if (timeline != null)
if (timeline == null || DrawWidth <= 0) return;
(float, float) newRange = (
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X,
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X);
if (visibleRange != newRange)
{
var newRange = (
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X,
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X);
visibleRange = newRange;
if (visibleRange != newRange)
{
visibleRange = newRange;
// actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries.
if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick))
tickCache.Invalidate();
}
// actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries.
if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick))
tickCache.Invalidate();
}
if (!tickCache.IsValid)
@ -151,6 +149,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
}
}
if (Children.Count > 512)
{
// There should always be a sanely small number of ticks rendered.
// If this assertion triggers, either the zoom logic is broken or a beatmap is
// probably doing weird things...
//
// Let's hope the latter never happens.
// If it does, we can choose to either fix it or ignore it as an outlier.
string message = $"Timeline is rendering many ticks ({Children.Count})";
Logger.Log(message);
Debug.Fail(message);
}
int usedDrawables = drawableIndex;
// save a few drawables beyond the currently used for edge cases.

View File

@ -56,7 +56,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
protected ZoomableScrollContainer()
: base(Direction.Horizontal)
{
base.Content.Add(zoomedContent = new Container { RelativeSizeAxes = Axes.Y });
base.Content.Add(zoomedContent = new Container
{
RelativeSizeAxes = Axes.Y,
// We must hide content until SetupZoom is called.
// If not, a child component that relies on its DrawWidth (via RelativeSizeAxes) may see a very incorrect value
// momentarily, as noticed in the TimelineTickDisplay, which would render thousands of ticks incorrectly.
Alpha = 0,
});
AddLayout(zoomedContentWidthCache);
}
@ -87,10 +94,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
if (minimum > maximum)
throw new ArgumentException($"{nameof(minimum)} ({minimum}) must be less than {nameof(maximum)} ({maximum})");
if (initial < minimum || initial > maximum)
throw new ArgumentException($"{nameof(initial)} ({initial}) must be between {nameof(minimum)} ({minimum}) and {nameof(maximum)} ({maximum})");
minZoom = minimum;
maxZoom = maximum;
CurrentZoom = zoomTarget = initial;
isZoomSetUp = true;
zoomedContent.Show();
}
/// <summary>
@ -115,9 +127,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
CurrentZoom = zoomTarget = newZoom;
}
protected override void Update()
protected override void UpdateAfterChildren()
{
base.Update();
base.UpdateAfterChildren();
if (!zoomedContentWidthCache.IsValid)
updateZoomedContentWidth();

View File

@ -0,0 +1,18 @@
// 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.Game.Beatmaps;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Screens.Edit
{
public class DeleteDifficultyConfirmationDialog : DeleteConfirmationDialog
{
public DeleteDifficultyConfirmationDialog(BeatmapInfo beatmapInfo, Action deleteAction)
{
BodyText = $"\"{beatmapInfo.DifficultyName}\" difficulty";
DeleteAction = deleteAction;
}
}
}

View File

@ -5,7 +5,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework;
@ -220,7 +219,7 @@ namespace osu.Game.Screens.Edit
}
// Todo: should probably be done at a DrawableRuleset level to share logic with Player.
clock = new EditorClock(playableBeatmap, beatDivisor) { IsCoupled = false };
clock = new EditorClock(playableBeatmap, beatDivisor);
clock.ChangeSource(loadableBeatmap.Track);
dependencies.CacheAs(clock);
@ -879,35 +878,61 @@ namespace osu.Game.Screens.Edit
clock.SeekForward(!trackPlaying, amount);
}
private void updateLastSavedHash()
{
lastSavedHash = changeHandler?.CurrentStateHash;
}
private List<MenuItem> createFileMenuItems() => new List<MenuItem>
{
new EditorMenuItem("Save", MenuItemType.Standard, () => Save()),
new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap) { Action = { Disabled = !RuntimeInfo.IsDesktop } },
new EditorMenuItemSpacer(),
createDifficultyCreationMenu(),
createDifficultySwitchMenu(),
new EditorMenuItemSpacer(),
new EditorMenuItem("Delete difficulty", MenuItemType.Standard, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } },
new EditorMenuItemSpacer(),
new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)
};
private void exportBeatmap()
{
Save();
new LegacyBeatmapExporter(storage).Export(Beatmap.Value.BeatmapSetInfo);
}
private void updateLastSavedHash()
{
lastSavedHash = changeHandler?.CurrentStateHash;
}
/// <summary>
/// Beatmaps of the currently edited set, grouped by ruleset and ordered by difficulty.
/// </summary>
private IOrderedEnumerable<IGrouping<RulesetInfo, BeatmapInfo>> groupedOrderedBeatmaps => Beatmap.Value.BeatmapSetInfo.Beatmaps
.OrderBy(b => b.StarRating)
.GroupBy(b => b.Ruleset)
.OrderBy(group => group.Key);
private List<MenuItem> createFileMenuItems()
private void deleteDifficulty()
{
var fileMenuItems = new List<MenuItem>
if (dialogOverlay == null)
delete();
else
dialogOverlay.Push(new DeleteDifficultyConfirmationDialog(Beatmap.Value.BeatmapInfo, delete));
void delete()
{
new EditorMenuItem("Save", MenuItemType.Standard, () => Save())
};
BeatmapInfo difficultyToDelete = playableBeatmap.BeatmapInfo;
if (RuntimeInfo.IsDesktop)
fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap));
var difficultiesBeforeDeletion = groupedOrderedBeatmaps.SelectMany(g => g).ToList();
fileMenuItems.Add(new EditorMenuItemSpacer());
beatmapManager.DeleteDifficultyImmediately(difficultyToDelete);
fileMenuItems.Add(createDifficultyCreationMenu());
fileMenuItems.Add(createDifficultySwitchMenu());
int deletedIndex = difficultiesBeforeDeletion.IndexOf(difficultyToDelete);
// of note, we're still working with the cloned version, so indices are all prior to deletion.
BeatmapInfo nextToShow = difficultiesBeforeDeletion[deletedIndex == 0 ? 1 : deletedIndex - 1];
fileMenuItems.Add(new EditorMenuItemSpacer());
fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit));
return fileMenuItems;
Beatmap.Value = beatmapManager.GetWorkingBeatmap(nextToShow);
SwitchToDifficulty(nextToShow);
}
}
private EditorMenuItem createDifficultyCreationMenu()
@ -939,18 +964,14 @@ namespace osu.Game.Screens.Edit
private EditorMenuItem createDifficultySwitchMenu()
{
var beatmapSet = playableBeatmap.BeatmapInfo.BeatmapSet;
Debug.Assert(beatmapSet != null);
var difficultyItems = new List<MenuItem>();
foreach (var rulesetBeatmaps in beatmapSet.Beatmaps.GroupBy(b => b.Ruleset).OrderBy(group => group.Key))
foreach (var rulesetBeatmaps in groupedOrderedBeatmaps)
{
if (difficultyItems.Count > 0)
difficultyItems.Add(new EditorMenuItemSpacer());
foreach (var beatmap in rulesetBeatmaps.OrderBy(b => b.StarRating))
foreach (var beatmap in rulesetBeatmaps)
{
bool isCurrentDifficulty = playableBeatmap.BeatmapInfo.Equals(beatmap);
difficultyItems.Add(new DifficultyMenuItem(beatmap, isCurrentDifficulty, SwitchToDifficulty));

View File

@ -48,6 +48,15 @@ namespace osu.Game.Screens.Edit
/// </summary>
public event Action<HitObject> HitObjectUpdated;
/// <summary>
/// Invoked after any state changes occurred which triggered a beatmap reprocess via an <see cref="IBeatmapProcessor"/>.
/// </summary>
/// <remarks>
/// Beatmap processing may change the order of hitobjects. This event gives external components a chance to handle any changes
/// not covered by the <see cref="HitObjectAdded"/> / <see cref="HitObjectUpdated"/> / <see cref="HitObjectRemoved"/> events.
/// </remarks>
public event Action BeatmapReprocessed;
/// <summary>
/// All currently selected <see cref="HitObject"/>s.
/// </summary>
@ -331,6 +340,8 @@ namespace osu.Game.Screens.Edit
beatmapProcessor?.PostProcess();
BeatmapReprocessed?.Invoke();
// callbacks may modify the lists so let's be safe about it
var deletes = batchPendingDeletes.ToArray();
batchPendingDeletes.Clear();
@ -341,6 +352,8 @@ namespace osu.Game.Screens.Edit
var updates = batchPendingUpdates.ToArray();
batchPendingUpdates.Clear();
foreach (var h in deletes) SelectedHitObjects.Remove(h);
foreach (var h in deletes) HitObjectRemoved?.Invoke(h);
foreach (var h in inserts) HitObjectAdded?.Invoke(h);
foreach (var h in updates) HitObjectUpdated?.Invoke(h);

View File

@ -4,10 +4,12 @@
#nullable disable
using System;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Transforms;
using osu.Framework.Timing;
using osu.Framework.Utils;
@ -19,7 +21,7 @@ namespace osu.Game.Screens.Edit
/// <summary>
/// A decoupled clock which adds editor-specific functionality, such as snapping to a user-defined beat divisor.
/// </summary>
public class EditorClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock
public class EditorClock : CompositeComponent, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock
{
public IBindable<Track> Track => track;
@ -33,7 +35,7 @@ namespace osu.Game.Screens.Edit
private readonly BindableBeatDivisor beatDivisor;
private readonly DecoupleableInterpolatingFramedClock underlyingClock;
private readonly FramedBeatmapClock underlyingClock;
private bool playbackFinished;
@ -52,7 +54,8 @@ namespace osu.Game.Screens.Edit
this.beatDivisor = beatDivisor ?? new BindableBeatDivisor();
underlyingClock = new DecoupleableInterpolatingFramedClock();
underlyingClock = new FramedBeatmapClock(applyOffsets: true) { IsCoupled = false };
AddInternal(underlyingClock);
}
/// <summary>
@ -155,6 +158,8 @@ namespace osu.Game.Screens.Edit
public double CurrentTime => underlyingClock.CurrentTime;
public double TotalAppliedOffset => underlyingClock.TotalAppliedOffset;
public void Reset()
{
ClearTransforms();
@ -219,18 +224,7 @@ namespace osu.Game.Screens.Edit
public void ProcessFrame()
{
underlyingClock.ProcessFrame();
playbackFinished = CurrentTime >= TrackLength;
if (playbackFinished)
{
if (IsRunning)
underlyingClock.Stop();
if (CurrentTime > TrackLength)
underlyingClock.Seek(TrackLength);
}
// Noop to ensure an external consumer doesn't process the internal clock an extra time.
}
public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime;
@ -247,18 +241,26 @@ namespace osu.Game.Screens.Edit
public IClock Source => underlyingClock.Source;
public bool IsCoupled
{
get => underlyingClock.IsCoupled;
set => underlyingClock.IsCoupled = value;
}
private const double transform_time = 300;
protected override void Update()
{
base.Update();
// EditorClock wasn't being added in many places. This gives us more certainty that it is.
Debug.Assert(underlyingClock.LoadState > LoadState.NotLoaded);
playbackFinished = CurrentTime >= TrackLength;
if (playbackFinished)
{
if (IsRunning)
underlyingClock.Stop();
if (CurrentTime > TrackLength)
underlyingClock.Seek(TrackLength);
}
updateSeekingState();
}

View File

@ -27,7 +27,11 @@ namespace osu.Game.Screens.Edit.GameplayTest
}
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
=> new MasterGameplayClockContainer(beatmap, gameplayStart) { StartTime = editorState.Time };
{
var masterGameplayClockContainer = new MasterGameplayClockContainer(beatmap, gameplayStart);
masterGameplayClockContainer.Reset(editorState.Time);
return masterGameplayClockContainer;
}
protected override void LoadComplete()
{

View File

@ -41,6 +41,11 @@ namespace osu.Game.Screens
/// </summary>
bool HideOverlaysOnEnter { get; }
/// <summary>
/// Whether the menu cursor should be hidden when non-mouse input is received.
/// </summary>
bool HideMenuCursorOnNonMouseInput { get; }
/// <summary>
/// Whether overlays should be able to be opened when this screen is current.
/// </summary>

View File

@ -14,7 +14,6 @@ using osu.Game.Screens.Menu;
using osu.Framework.Screens;
using osu.Framework.Threading;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using IntroSequence = osu.Game.Configuration.IntroSequence;
@ -66,32 +65,13 @@ namespace osu.Game.Screens
protected virtual ShaderPrecompiler CreateShaderPrecompiler() => new ShaderPrecompiler();
[Resolved(canBeNull: true)]
private DatabaseContextFactory efContextFactory { get; set; }
private EFToRealmMigrator realmMigrator;
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
LoadComponentAsync(precompiler = CreateShaderPrecompiler(), AddInternal);
// A non-null context factory means there's still content to migrate.
if (efContextFactory != null)
{
LoadComponentAsync(realmMigrator = new EFToRealmMigrator(), AddInternal);
realmMigrator.MigrationCompleted.ContinueWith(_ => Schedule(() =>
{
// Delay initial screen loading to ensure that the migration is in a complete and sane state
// before the intro screen may import the game intro beatmap.
LoadComponentAsync(loadableScreen = CreateLoadableScreen());
}));
}
else
{
LoadComponentAsync(loadableScreen = CreateLoadableScreen());
}
LoadComponentAsync(loadableScreen = CreateLoadableScreen());
LoadComponentAsync(spinner = new LoadingSpinner(true, true)
{

View File

@ -278,11 +278,11 @@ namespace osu.Game.Screens.Menu
if (!UsingThemedIntro)
{
initialBeatmap?.PrepareTrackForPreview(false);
initialBeatmap?.PrepareTrackForPreview(false, -2600);
drawableTrack.VolumeTo(0);
drawableTrack.Restart();
drawableTrack.VolumeTo(1, 2200, Easing.InCubic);
drawableTrack.VolumeTo(1, 2600, Easing.InCubic);
}
else
{

View File

@ -255,8 +255,7 @@ namespace osu.Game.Screens.Menu
{
lazerLogo.FadeOut().OnComplete(_ =>
{
logoContainerSecondary.Remove(lazerLogo);
lazerLogo.Dispose(); // explicit disposal as we are pushing a new screen and the expire may not get run.
logoContainerSecondary.Remove(lazerLogo, true);
logo.FadeIn();

View File

@ -78,13 +78,17 @@ namespace osu.Game.Screens.Menu
if (reverbChannel != null)
intro.LogoVisualisation.AddAmplitudeSource(reverbChannel);
Scheduler.AddDelayed(() =>
{
if (!UsingThemedIntro)
StartTrack();
// this classic intro loops forever.
Scheduler.AddDelayed(() =>
{
if (UsingThemedIntro)
{
StartTrack();
// this classic intro loops forever.
Track.Looping = true;
}
const float fade_in_time = 200;

View File

@ -561,6 +561,10 @@ namespace osu.Game.Screens.OnlinePlay
{
switch (state.NewValue)
{
case DownloadState.Unknown:
// Ignore initial state to ensure the button doesn't briefly appear.
break;
case DownloadState.LocallyAvailable:
// Perform a local query of the beatmap by beatmap checksum, and reset the state if not matching.
if (beatmapManager.QueryBeatmap(b => b.MD5Hash == beatmap.MD5Hash) == null)

View File

@ -232,7 +232,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
private void removeUser(APIUser user)
{
avatarFlow.RemoveAll(a => a.User == user);
avatarFlow.RemoveAll(a => a.User == user, true);
}
private void clearUsers()
@ -250,7 +250,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
hiddenUsers.Count = hiddenCount;
if (displayedCircles > NumberOfCircles)
avatarFlow.Remove(avatarFlow.Last());
avatarFlow.Remove(avatarFlow.Last(), true);
else if (displayedCircles < NumberOfCircles)
{
var nextUser = RecentParticipants.FirstOrDefault(u => avatarFlow.All(a => a.User != u));

View File

@ -138,7 +138,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
foreach (var r in rooms)
{
roomFlow.RemoveAll(d => d.Room == r);
roomFlow.RemoveAll(d => d.Room == r, true);
// selection may have a lease due to being in a sub screen.
if (!SelectedRoom.Disabled)

View File

@ -1,8 +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.
#nullable disable
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -16,7 +14,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components
public class MatchLeaderboard : Leaderboard<MatchLeaderboardScope, APIUserScoreAggregate>
{
[Resolved(typeof(Room), nameof(Room.RoomID))]
private Bindable<long?> roomId { get; set; }
private Bindable<long?> roomId { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
@ -33,7 +31,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components
protected override bool IsOnlineScope => true;
protected override APIRequest FetchScores(CancellationToken cancellationToken)
protected override APIRequest? FetchScores(CancellationToken cancellationToken)
{
if (roomId.Value == null)
return null;

View File

@ -433,6 +433,9 @@ namespace osu.Game.Screens.OnlinePlay.Match
private void updateWorkingBeatmap()
{
if (SelectedItem.Value == null || !this.IsCurrentScreen())
return;
var beatmap = SelectedItem.Value?.Beatmap;
// Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info

View File

@ -109,7 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Debug.Assert(clickOperation == null);
clickOperation = ongoingOperationTracker.BeginOperation();
if (isReady() && Client.IsHost && Room.Countdown == null)
if (isReady() && Client.IsHost && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown))
startMatch();
else
toggleReady();
@ -140,10 +140,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void cancelCountdown()
{
if (Client.Room == null)
return;
Debug.Assert(clickOperation == null);
clickOperation = ongoingOperationTracker.BeginOperation();
Client.SendMatchRequest(new StopCountdownRequest()).ContinueWith(_ => endOperation());
MultiplayerCountdown countdown = Client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown);
Client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation());
}
private void endOperation()
@ -192,7 +196,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
// When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready.
if (localUser?.State == MultiplayerUserState.Spectating)
readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && Room.Countdown == null;
readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown);
if (newCountReady == countReady)
return;

View File

@ -4,6 +4,7 @@
#nullable disable
using System;
using System.Linq;
using Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
@ -79,7 +80,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void onRoomUpdated() => Scheduler.AddOnce(() =>
{
bool countdownActive = multiplayerClient.Room?.Countdown is MatchStartCountdown;
bool countdownActive = multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true;
if (countdownActive)
{
@ -121,7 +122,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
});
}
if (multiplayerClient.Room?.Countdown != null && multiplayerClient.IsHost)
if (multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true && multiplayerClient.IsHost)
{
flow.Add(new OsuButton
{

View File

@ -1,12 +1,9 @@
// 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;
using System.ComponentModel;
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
@ -30,12 +27,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
public class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay
{
private MatchSettings settings;
private MatchSettings settings = null!;
protected override OsuButton SubmitButton => settings.ApplyButton;
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!;
protected override bool IsLoading => ongoingOperationTracker.InProgress.Value;
@ -57,19 +54,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
private const float disabled_alpha = 0.2f;
public Action SettingsApplied;
public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks;
public OsuTextBox NameField, MaxParticipantsField;
public MatchTypePicker TypePicker;
public OsuEnumDropdown<QueueMode> QueueModeDropdown;
public OsuTextBox PasswordTextBox;
public TriangleButton ApplyButton;
public Action? SettingsApplied;
public OsuSpriteText ErrorText;
public OsuTextBox NameField = null!;
public OsuTextBox MaxParticipantsField = null!;
public MatchTypePicker TypePicker = null!;
public OsuEnumDropdown<QueueMode> QueueModeDropdown = null!;
public OsuTextBox PasswordTextBox = null!;
public OsuCheckbox AutoSkipCheckbox = null!;
public TriangleButton ApplyButton = null!;
private OsuEnumDropdown<StartMode> startModeDropdown;
private OsuSpriteText typeLabel;
private LoadingLayer loadingLayer;
public OsuSpriteText ErrorText = null!;
private OsuEnumDropdown<StartMode> startModeDropdown = null!;
private OsuSpriteText typeLabel = null!;
private LoadingLayer loadingLayer = null!;
public void SelectBeatmap()
{
@ -78,26 +79,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
[Resolved]
private MultiplayerMatchSubScreen matchSubScreen { get; set; }
private MultiplayerMatchSubScreen matchSubScreen { get; set; } = null!;
[Resolved]
private IRoomManager manager { get; set; }
private IRoomManager manager { get; set; } = null!;
[Resolved]
private MultiplayerClient client { get; set; }
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!;
private readonly IBindable<bool> operationInProgress = new BindableBool();
[CanBeNull]
private IDisposable applyingSettingsOperation;
private readonly Room room;
private Drawable playlistContainer;
private DrawableRoomPlaylist drawablePlaylist;
private IDisposable? applyingSettingsOperation;
private Drawable playlistContainer = null!;
private DrawableRoomPlaylist drawablePlaylist = null!;
public MatchSettings(Room room)
{
@ -249,6 +247,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
LengthLimit = 255,
},
},
new Section("Other")
{
Child = AutoSkipCheckbox = new OsuCheckbox
{
LabelText = "Automatically skip the beatmap intro"
}
}
}
}
},
@ -343,6 +348,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Password.BindValueChanged(password => PasswordTextBox.Text = password.NewValue ?? string.Empty, true);
QueueMode.BindValueChanged(mode => QueueModeDropdown.Current.Value = mode.NewValue, true);
AutoStartDuration.BindValueChanged(duration => startModeDropdown.Current.Value = (StartMode)(int)duration.NewValue.TotalSeconds, true);
AutoSkip.BindValueChanged(autoSkip => AutoSkipCheckbox.Current.Value = autoSkip.NewValue, true);
operationInProgress.BindTo(ongoingOperationTracker.InProgress);
operationInProgress.BindValueChanged(v =>
@ -390,7 +396,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
password: PasswordTextBox.Text,
matchType: TypePicker.Current.Value,
queueMode: QueueModeDropdown.Current.Value,
autoStartDuration: autoStartDuration)
autoStartDuration: autoStartDuration,
autoSkip: AutoSkipCheckbox.Current.Value)
.ContinueWith(t => Schedule(() =>
{
if (t.IsCompletedSuccessfully)
@ -406,19 +413,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
room.Password.Value = PasswordTextBox.Current.Value;
room.QueueMode.Value = QueueModeDropdown.Current.Value;
room.AutoStartDuration.Value = autoStartDuration;
room.AutoSkip.Value = AutoSkipCheckbox.Current.Value;
if (int.TryParse(MaxParticipantsField.Text, out int max))
room.MaxParticipants.Value = max;
else
room.MaxParticipants.Value = null;
manager?.CreateRoom(room, onSuccess, onError);
manager.CreateRoom(room, onSuccess, onError);
}
}
private void hideError() => ErrorText.FadeOut(50);
private void onSuccess(Room room)
private void onSuccess(Room room) => Schedule(() =>
{
Debug.Assert(applyingSettingsOperation != null);
@ -426,9 +434,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
applyingSettingsOperation.Dispose();
applyingSettingsOperation = null;
}
});
private void onError(string text)
private void onError(string text) => Schedule(() =>
{
Debug.Assert(applyingSettingsOperation != null);
@ -449,13 +457,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
applyingSettingsOperation.Dispose();
applyingSettingsOperation = null;
}
});
}
public class CreateOrUpdateButton : TriangleButton
{
[Resolved(typeof(Room), nameof(Room.RoomID))]
private Bindable<long?> roomId { get; set; }
private Bindable<long?> roomId { get; set; } = null!;
protected override void LoadComplete()
{

View File

@ -57,23 +57,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void onRoomUpdated() => Scheduler.AddOnce(() =>
{
MultiplayerCountdown newCountdown;
switch (room?.Countdown)
{
case MatchStartCountdown:
newCountdown = room.Countdown;
break;
// Clear the countdown with any other (including non-null) countdown values.
default:
newCountdown = null;
break;
}
MultiplayerCountdown newCountdown = room?.ActiveCountdowns.SingleOrDefault(c => c is MatchStartCountdown);
if (newCountdown != countdown)
{
countdown = room?.Countdown;
countdown = newCountdown;
countdownChangeTime = Time.Current;
}
@ -213,7 +201,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
case MultiplayerUserState.Spectating:
case MultiplayerUserState.Ready:
if (room?.Host?.Equals(localUser) == true && room.Countdown == null)
if (room?.Host?.Equals(localUser) == true && !room.ActiveCountdowns.Any(c => c is MatchStartCountdown))
setGreen();
else
setYellow();
@ -248,8 +236,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
get
{
if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready && !room.Settings.AutoStartEnabled)
if (room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true
&& multiplayerClient.IsHost
&& multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready
&& !room.Settings.AutoStartEnabled)
{
return "Cancel countdown";
}
return base.TooltipText;
}

View File

@ -78,9 +78,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
return;
bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost;
bool isValidItem = isItemOwner && !Item.Expired;
AllowDeletion = isItemOwner && !Item.Expired && Item.ID != multiplayerClient.Room.Settings.PlaylistItemId;
AllowEditing = isItemOwner && !Item.Expired;
AllowDeletion = isValidItem
&& (Item.ID != multiplayerClient.Room.Settings.PlaylistItemId // This is an optimisation for the following check.
|| multiplayerClient.Room.Playlist.Count(i => !i.Expired) > 1);
AllowEditing = isValidItem;
}
protected override void Dispose(bool isDisposing)

View File

@ -56,7 +56,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
roomAccessTypeDropdown.Current.BindValueChanged(_ => UpdateFilter());
return base.CreateFilterControls().Prepend(roomAccessTypeDropdown);
return base.CreateFilterControls().Append(roomAccessTypeDropdown);
}
protected override FilterCriteria CreateFilterCriteria()

View File

@ -8,11 +8,9 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select;
@ -27,7 +25,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private OngoingOperationTracker operationTracker { get; set; } = null!;
private readonly IBindable<bool> operationInProgress = new Bindable<bool>();
private readonly long? itemToEdit;
private readonly PlaylistItem? itemToEdit;
private LoadingLayer loadingLayer = null!;
private IDisposable? selectionOperation;
@ -37,21 +35,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
/// </summary>
/// <param name="room">The room.</param>
/// <param name="itemToEdit">The item to be edited. May be null, in which case a new item will be added to the playlist.</param>
/// <param name="beatmap">An optional initial beatmap selection to perform.</param>
/// <param name="ruleset">An optional initial ruleset selection to perform.</param>
public MultiplayerMatchSongSelect(Room room, long? itemToEdit = null, WorkingBeatmap? beatmap = null, RulesetInfo? ruleset = null)
: base(room)
public MultiplayerMatchSongSelect(Room room, PlaylistItem? itemToEdit = null)
: base(room, itemToEdit)
{
this.itemToEdit = itemToEdit;
if (beatmap != null || ruleset != null)
{
Schedule(() =>
{
if (beatmap != null) Beatmap.Value = beatmap;
if (ruleset != null) Ruleset.Value = ruleset;
});
}
}
[BackgroundDependencyLoader]
@ -80,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
if (operationInProgress.Value)
{
Logger.Log($"{nameof(SelectedItem)} aborted due to {nameof(operationInProgress)}");
Logger.Log($"{nameof(SelectItem)} aborted due to {nameof(operationInProgress)}");
return false;
}
@ -92,7 +79,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
var multiplayerItem = new MultiplayerPlaylistItem
{
ID = itemToEdit ?? 0,
ID = itemToEdit?.ID ?? 0,
BeatmapID = item.Beatmap.OnlineID,
BeatmapChecksum = item.Beatmap.MD5Hash,
RulesetID = item.RulesetID,

View File

@ -49,11 +49,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
[Resolved]
private MultiplayerClient client { get; set; }
[Resolved]
private BeatmapManager beatmapManager { get; set; }
private readonly IBindable<bool> isConnected = new Bindable<bool>();
private AddItemButton addItemButton;
public MultiplayerMatchSubScreen(Room room)
@ -227,12 +222,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (!this.IsCurrentScreen())
return;
int id = itemToEdit?.Beatmap.OnlineID ?? Room.Playlist.Last().Beatmap.OnlineID;
var localBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == id);
var workingBeatmap = localBeatmap == null ? null : beatmapManager.GetWorkingBeatmap(localBeatmap);
this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit?.ID, workingBeatmap));
this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit));
}
protected override Drawable CreateFooter() => new MultiplayerMatchFooter();
@ -424,7 +414,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
return;
}
this.Push(new MultiplayerMatchSongSelect(Room, client.Room.Settings.PlaylistItemId, beatmap, ruleset));
this.Push(new MultiplayerMatchSongSelect(Room, Room.Playlist.Single(item => item.ID == client.Room.Settings.PlaylistItemId)));
}
protected override void Dispose(bool isDisposing)

View File

@ -9,8 +9,6 @@ using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Graphics.UserInterface;
@ -21,7 +19,6 @@ using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
using osu.Game.Users;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
@ -41,14 +38,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private readonly TaskCompletionSource<bool> resultsReady = new TaskCompletionSource<bool>();
private MultiplayerGameplayLeaderboard leaderboard;
private readonly MultiplayerRoomUser[] users;
private readonly Bindable<bool> leaderboardExpanded = new BindableBool();
private LoadingLayer loadingDisplay;
private FillFlowContainer leaderboardFlow;
private MultiplayerGameplayLeaderboard multiplayerLeaderboard;
/// <summary>
/// Construct a multiplayer player.
@ -61,7 +55,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
AllowPause = false,
AllowRestart = false,
AllowSkipping = false,
AllowSkipping = room.AutoSkip.Value,
AutomaticallySkipIntro = room.AutoSkip.Value,
AlwaysShowLeaderboard = true,
})
{
this.users = users;
@ -73,45 +69,33 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (!LoadedBeatmapSuccessfully)
return;
HUDOverlay.Add(leaderboardFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5)
});
HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState());
LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true);
// todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area.
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(users), l =>
{
if (!LoadedBeatmapSuccessfully)
return;
leaderboard.Expanded.BindTo(leaderboardExpanded);
leaderboardFlow.Insert(0, l);
if (leaderboard.TeamScores.Count >= 2)
{
LoadComponentAsync(new GameplayMatchScoreDisplay
{
Team1Score = { BindTarget = leaderboard.TeamScores.First().Value },
Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value },
Expanded = { BindTarget = HUDOverlay.ShowHud },
}, scoreDisplay => leaderboardFlow.Insert(1, scoreDisplay));
}
});
LoadComponentAsync(new GameplayChatDisplay(Room)
{
Expanded = { BindTarget = leaderboardExpanded },
}, chat => leaderboardFlow.Insert(2, chat));
Expanded = { BindTarget = LeaderboardExpandedState },
}, chat => HUDOverlay.LeaderboardFlow.Insert(2, chat));
HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue });
}
protected override GameplayLeaderboard CreateGameplayLeaderboard() => multiplayerLeaderboard = new MultiplayerGameplayLeaderboard(users);
protected override void AddLeaderboardToHUD(GameplayLeaderboard leaderboard)
{
Debug.Assert(leaderboard == multiplayerLeaderboard);
HUDOverlay.LeaderboardFlow.Insert(0, leaderboard);
if (multiplayerLeaderboard.TeamScores.Count >= 2)
{
LoadComponentAsync(new GameplayMatchScoreDisplay
{
Team1Score = { BindTarget = multiplayerLeaderboard.TeamScores.First().Value },
Team2Score = { BindTarget = multiplayerLeaderboard.TeamScores.Last().Value },
Expanded = { BindTarget = HUDOverlay.ShowHud },
}, scoreDisplay => HUDOverlay.LeaderboardFlow.Insert(1, scoreDisplay));
}
}
protected override void LoadAsyncComplete()
{
base.LoadAsyncComplete();
@ -166,9 +150,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
}
}
private void updateLeaderboardExpandedState() =>
leaderboardExpanded.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value;
private void failAndBail(string message = null)
{
if (!string.IsNullOrEmpty(message))
@ -177,23 +158,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
Schedule(() => PerformExit(false));
}
protected override void Update()
{
base.Update();
if (!LoadedBeatmapSuccessfully)
return;
adjustLeaderboardPosition();
}
private void adjustLeaderboardPosition()
{
const float padding = 44; // enough margin to avoid the hit error display.
leaderboardFlow.Position = new Vector2(padding, padding + HUDOverlay.TopScoringElementsHeight);
}
private void onGameplayStarted() => Scheduler.Add(() =>
{
if (!this.IsCurrentScreen())
@ -231,8 +195,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
Debug.Assert(Room.RoomID.Value != null);
return leaderboard.TeamScores.Count == 2
? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, leaderboard.TeamScores)
return multiplayerLeaderboard.TeamScores.Count == 2
? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, multiplayerLeaderboard.TeamScores)
: new MultiplayerResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem);
}

View File

@ -110,6 +110,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (Client != null)
{
Client.RoomUpdated -= invokeOnRoomUpdated;
Client.LoadRequested -= invokeOnRoomLoadRequested;
Client.UserLeft -= invokeUserLeft;
Client.UserKicked -= invokeUserKicked;
Client.UserJoined -= invokeUserJoined;

View File

@ -1,10 +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.
#nullable disable
using System;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
@ -25,9 +22,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{
private const double fade_time = 50;
private SpriteIcon icon;
private OsuSpriteText text;
private ProgressBar progressBar;
private SpriteIcon icon = null!;
private OsuSpriteText text = null!;
private ProgressBar progressBar = null!;
public StateDisplay()
{
@ -86,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
};
}
private OsuColour colours;
private OsuColour colours = null!;
public void UpdateStatus(MultiplayerUserState state, BeatmapAvailability availability)
{
@ -164,10 +161,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
break;
case DownloadState.Downloading:
Debug.Assert(availability.DownloadProgress != null);
progressBar.FadeIn(fade_time);
progressBar.CurrentTime = availability.DownloadProgress.Value;
progressBar.CurrentTime = availability.DownloadProgress ?? 0;
text.Text = "downloading map";
icon.Icon = FontAwesome.Solid.ArrowAltCircleDown;

View File

@ -1,8 +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.
#nullable disable
using System;
using osu.Framework.Timing;
using osu.Game.Online.Multiplayer;

View File

@ -1,7 +1,9 @@
// 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.Threading;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
@ -13,6 +15,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// </summary>
public class MultiSpectatorPlayer : SpectatorPlayer
{
/// <summary>
/// All adjustments applied to the clock of this <see cref="MultiSpectatorPlayer"/> which come from mods.
/// </summary>
public IAggregateAudioAdjustment ClockAdjustmentsFromMods => clockAdjustmentsFromMods;
private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments();
private readonly SpectatorPlayerClock spectatorPlayerClock;
/// <summary>
@ -27,8 +35,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}
[BackgroundDependencyLoader]
private void load()
private void load(CancellationToken cancellationToken)
{
// HUD overlay may not be loaded if load has been cancelled early.
if (cancellationToken.IsCancellationRequested)
return;
HUDOverlay.PlayerSettingsOverlay.Expire();
HUDOverlay.HoldToQuit.Expire();
}
@ -53,6 +65,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
=> new GameplayClockContainer(spectatorPlayerClock);
{
var gameplayClockContainer = new GameplayClockContainer(spectatorPlayerClock);
clockAdjustmentsFromMods.BindAdjustments(gameplayClockContainer.AdjustmentsFromMods);
return gameplayClockContainer;
}
}
}

View File

@ -4,6 +4,7 @@
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -43,6 +44,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
[Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!;
private IAggregateAudioAdjustment? boundAdjustments;
private readonly PlayerArea[] instances;
private MasterGameplayClockContainer masterClockContainer = null!;
private SpectatorSyncManager syncManager = null!;
@ -132,7 +135,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}, _ =>
{
foreach (var instance in instances)
leaderboard.AddClock(instance.UserId, instance.GameplayClock);
leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock);
leaderboardFlow.Insert(0, leaderboard);
@ -157,23 +160,39 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
base.LoadComplete();
masterClockContainer.Reset();
// Start with adjustments from the first player to keep a sane state.
bindAudioAdjustments(instances.First());
}
protected override void Update()
{
base.Update();
if (!isCandidateAudioSource(currentAudioSource?.GameplayClock))
if (!isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock))
{
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock))
.OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.CurrentMasterTime))
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock))
.OrderBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime))
.FirstOrDefault();
// Only bind adjustments if there's actually a valid source, else just use the previous ones to ensure no sudden changes to audio.
if (currentAudioSource != null)
bindAudioAdjustments(currentAudioSource);
foreach (var instance in instances)
instance.Mute = instance != currentAudioSource;
}
}
private void bindAudioAdjustments(PlayerArea first)
{
if (boundAdjustments != null)
masterClockContainer.AdjustmentsFromMods.UnbindAdjustments(boundAdjustments);
boundAdjustments = first.ClockAdjustmentsFromMods;
masterClockContainer.AdjustmentsFromMods.BindAdjustments(boundAdjustments);
}
private bool isCandidateAudioSource(SpectatorPlayerClock? clock)
=> clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames;
@ -187,8 +206,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
.DefaultIfEmpty(0)
.Min();
masterClockContainer.StartTime = startTime;
masterClockContainer.Reset(true);
masterClockContainer.Reset(startTime, true);
}
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
@ -198,25 +216,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState)
=> instances.Single(i => i.UserId == userId).LoadScore(spectatorGameplayState.Score);
protected override void EndGameplay(int userId, SpectatorState state)
protected override void QuitGameplay(int userId)
{
// Allowed passed/failed users to complete their remaining replay frames.
// The failed state isn't really possible in multiplayer (yet?) but is added here just for safety in case it starts being used.
if (state.State == SpectatedUserState.Passed || state.State == SpectatedUserState.Failed)
return;
// we could also potentially receive EndGameplay with "Playing" state, at which point we can only early-return and hope it's a passing player.
// todo: this shouldn't exist, but it's here as a hotfix for an issue with multi-spectator screen not proceeding to results screen.
// see: https://github.com/ppy/osu/issues/19593
if (state.State == SpectatedUserState.Playing)
return;
RemoveUser(userId);
var instance = instances.Single(i => i.UserId == userId);
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
syncManager.RemoveManagedClock(instance.GameplayClock);
syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
}
public override bool OnBackButton()

View File

@ -38,9 +38,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public readonly int UserId;
/// <summary>
/// The <see cref="SpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
/// The <see cref="Spectate.SpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
/// </summary>
public readonly SpectatorPlayerClock GameplayClock;
public readonly SpectatorPlayerClock SpectatorPlayerClock;
/// <summary>
/// The clock adjustments applied by the <see cref="Player"/> loaded in this area.
/// </summary>
public IAggregateAudioAdjustment ClockAdjustmentsFromMods => clockAdjustmentsFromMods;
/// <summary>
/// The currently-loaded score.
@ -50,6 +55,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments();
private readonly BindableDouble volumeAdjustment = new BindableDouble();
private readonly Container gameplayContent;
private readonly LoadingLayer loadingLayer;
@ -58,7 +64,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public PlayerArea(int userId, SpectatorPlayerClock clock)
{
UserId = userId;
GameplayClock = clock;
SpectatorPlayerClock = clock;
RelativeSizeAxes = Axes.Both;
Masking = true;
@ -95,8 +101,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
stack.Push(new MultiSpectatorPlayerLoader(Score, () =>
{
var player = new MultiSpectatorPlayer(Score, GameplayClock);
var player = new MultiSpectatorPlayer(Score, SpectatorPlayerClock);
player.OnGameplayStarted += () => OnGameplayStarted?.Invoke();
clockAdjustmentsFromMods.BindAdjustments(player.ClockAdjustmentsFromMods);
return player;
}));

View File

@ -77,7 +77,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
if (IsRunning)
{
double elapsedSource = masterClock.ElapsedFrameTime;
// When in catch-up mode, the source is usually not running.
// In such a case, its elapsed time may be zero, which would cause catch-up to get stuck.
// To avoid this, use a constant 16ms elapsed time for now. Probably not too correct, but this whole logic isn't too correct anyway.
// Clamping is required to ensure that player clocks don't get too far ahead if ProcessFrame is run multiple times.
double elapsedSource = masterClock.ElapsedFrameTime != 0 ? masterClock.ElapsedFrameTime : Math.Clamp(masterClock.CurrentTime - CurrentTime, 0, 16);
double elapsed = elapsedSource * Rate;
CurrentTime += elapsed;

View File

@ -86,6 +86,9 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved(typeof(Room))]
protected Bindable<TimeSpan> AutoStartDuration { get; private set; }
[Resolved(typeof(Room))]
protected Bindable<bool> AutoSkip { get; private set; }
[Resolved(CanBeNull = true)]
private IBindable<PlaylistItem> subScreenSelectedItem { get; set; }

View File

@ -105,7 +105,8 @@ namespace osu.Game.Screens.OnlinePlay
while (this.IsCurrentScreen())
this.Exit();
}
else
// Also handle the case where a child screen is current (ie. gameplay).
else if (this.GetChildScreen() != null)
{
this.MakeCurrent();
Schedule(forcefullyExit);

View File

@ -1,13 +1,11 @@
// 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;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Humanizer;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -35,32 +33,34 @@ namespace osu.Game.Screens.OnlinePlay
public override bool AllowEditing => false;
[Resolved(typeof(Room), nameof(Room.Playlist))]
protected BindableList<PlaylistItem> Playlist { get; private set; }
[CanBeNull]
[Resolved(CanBeNull = true)]
protected IBindable<PlaylistItem> SelectedItem { get; private set; }
protected BindableList<PlaylistItem> Playlist { get; private set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; }
private RulesetStore rulesets { get; set; } = null!;
[Resolved]
private BeatmapManager beatmapManager { get; set; } = null!;
protected override UserActivity InitialActivity => new UserActivity.InLobby(room);
protected readonly Bindable<IReadOnlyList<Mod>> FreeMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
private readonly Room room;
private WorkingBeatmap initialBeatmap;
private RulesetInfo initialRuleset;
private IReadOnlyList<Mod> initialMods;
private bool itemSelected;
private readonly PlaylistItem? initialItem;
private readonly FreeModSelectOverlay freeModSelectOverlay;
private IDisposable freeModSelectOverlayRegistration;
protected OnlinePlaySongSelect(Room room)
private IDisposable? freeModSelectOverlayRegistration;
/// <summary>
/// Creates a new <see cref="OnlinePlaySongSelect"/>.
/// </summary>
/// <param name="room">The room.</param>
/// <param name="initialItem">An optional initial <see cref="PlaylistItem"/> to use for the initial beatmap/ruleset/mods.
/// If <c>null</c>, the last <see cref="PlaylistItem"/> in the room will be used.</param>
protected OnlinePlaySongSelect(Room room, PlaylistItem? initialItem = null)
{
this.room = room;
this.initialItem = initialItem ?? room.Playlist.LastOrDefault();
Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING };
@ -75,11 +75,6 @@ namespace osu.Game.Screens.OnlinePlay
private void load()
{
LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT };
initialBeatmap = Beatmap.Value;
initialRuleset = Ruleset.Value;
initialMods = Mods.Value.ToList();
LoadComponent(freeModSelectOverlay);
}
@ -87,14 +82,35 @@ namespace osu.Game.Screens.OnlinePlay
{
base.LoadComplete();
var rulesetInstance = SelectedItem?.Value?.RulesetID == null ? null : rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance();
if (rulesetInstance != null)
if (initialItem != null)
{
// At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods.
// Similarly, freeMods is currently empty but should only contain the allowed mods.
Mods.Value = SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
FreeMods.Value = SelectedItem.Value.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
// Prefer using a local databased beatmap lookup since OnlineId may be -1 for an invalid beatmap selection.
BeatmapInfo? beatmapInfo = initialItem.Beatmap as BeatmapInfo;
// And in the case that this isn't a local databased beatmap, query by online ID.
if (beatmapInfo == null)
{
int onlineId = initialItem.Beatmap.OnlineID;
beatmapInfo = beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId);
}
if (beatmapInfo != null)
Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo);
RulesetInfo? ruleset = rulesets.GetRuleset(initialItem.RulesetID);
if (ruleset != null)
{
Ruleset.Value = ruleset;
var rulesetInstance = ruleset.CreateInstance();
Debug.Assert(rulesetInstance != null);
// At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods.
// Similarly, freeMods is currently empty but should only contain the allowed mods.
Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
}
}
Mods.BindValueChanged(onModsChanged);
@ -125,13 +141,7 @@ namespace osu.Game.Screens.OnlinePlay
AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray()
};
if (SelectItem(item))
{
itemSelected = true;
return true;
}
return false;
return SelectItem(item);
}
/// <summary>
@ -154,15 +164,7 @@ namespace osu.Game.Screens.OnlinePlay
public override bool OnExiting(ScreenExitEvent e)
{
if (!itemSelected)
{
Beatmap.Value = initialBeatmap;
Ruleset.Value = initialRuleset;
Mods.Value = initialMods;
}
freeModSelectOverlay.Hide();
return base.OnExiting(e);
}
@ -199,7 +201,6 @@ namespace osu.Game.Screens.OnlinePlay
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
freeModSelectOverlayRegistration?.Dispose();
}
}

View File

@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
{
await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false);
Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.ComputeFinalScore(ScoringMode.Standardised, Score.ScoreInfo));
Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.ComputeScore(ScoringMode.Standardised, Score.ScoreInfo));
}
protected override void Dispose(bool isDisposing)

View File

@ -180,31 +180,26 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
/// <param name="callback">The callback to invoke with the final <see cref="ScoreInfo"/>s.</param>
/// <param name="scores">The <see cref="MultiplayerScore"/>s that were retrieved from <see cref="APIRequest"/>s.</param>
/// <param name="pivot">An optional pivot around which the scores were retrieved.</param>
private void performSuccessCallback([NotNull] Action<IEnumerable<ScoreInfo>> callback, [NotNull] List<MultiplayerScore> scores, [CanBeNull] MultiplayerScores pivot = null)
private void performSuccessCallback([NotNull] Action<IEnumerable<ScoreInfo>> callback, [NotNull] List<MultiplayerScore> scores, [CanBeNull] MultiplayerScores pivot = null) => Schedule(() =>
{
var scoreInfos = scores.Select(s => s.CreateScoreInfo(rulesets, playlistItem, Beatmap.Value.BeatmapInfo)).ToArray();
var scoreInfos = scoreManager.OrderByTotalScore(scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, Beatmap.Value.BeatmapInfo))).ToArray();
// Score panels calculate total score before displaying, which can take some time. In order to count that calculation as part of the loading spinner display duration,
// calculate the total scores locally before invoking the success callback.
scoreManager.OrderByTotalScoreAsync(scoreInfos).ContinueWith(_ => Schedule(() =>
// Select a score if we don't already have one selected.
// Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll).
if (SelectedScore.Value == null)
{
// Select a score if we don't already have one selected.
// Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll).
if (SelectedScore.Value == null)
Schedule(() =>
{
Schedule(() =>
{
// Prefer selecting the local user's score, or otherwise default to the first visible score.
SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault();
});
}
// Prefer selecting the local user's score, or otherwise default to the first visible score.
SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault();
});
}
// Invoke callback to add the scores. Exclude the user's current score which was added previously.
callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID));
// Invoke callback to add the scores. Exclude the user's current score which was added previously.
callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID));
hideLoadingSpinners(pivot);
}));
}
hideLoadingSpinners(pivot);
});
private void hideLoadingSpinners([CanBeNull] MultiplayerScores pivot = null)
{

View File

@ -1,8 +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.
#nullable disable
using System.Linq;
using osu.Framework.Screens;
using osu.Game.Online.API;

View File

@ -40,11 +40,10 @@ namespace osu.Game.Screens
public virtual bool AllowExternalScreenChange => false;
/// <summary>
/// Whether all overlays should be hidden when this screen is entered or resumed.
/// </summary>
public virtual bool HideOverlaysOnEnter => false;
public virtual bool HideMenuCursorOnNonMouseInput => false;
/// <summary>
/// The initial overlay activation mode to use when this screen is entered for the first time.
/// </summary>

View File

@ -1,14 +1,11 @@
// 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 osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Game.Rulesets.UI;
using System;
using System.Collections.Generic;
using JetBrains.Annotations;
using ManagedBass.Fx;
using osu.Framework.Allocation;
using osu.Framework.Audio.Sample;
@ -34,27 +31,27 @@ namespace osu.Game.Screens.Play
/// </summary>
public class FailAnimation : Container
{
public Action OnComplete;
public Action? OnComplete;
private readonly DrawableRuleset drawableRuleset;
private readonly BindableDouble trackFreq = new BindableDouble(1);
private readonly BindableDouble volumeAdjustment = new BindableDouble(0.5);
private Container filters;
private Container filters = null!;
private Box redFlashLayer;
private Box redFlashLayer = null!;
private Track track;
private Track track = null!;
private AudioFilter failLowPassFilter;
private AudioFilter failHighPassFilter;
private AudioFilter failLowPassFilter = null!;
private AudioFilter failHighPassFilter = null!;
private const float duration = 2500;
private Sample failSample;
private Sample? failSample;
[Resolved]
private OsuConfigManager config { get; set; }
private OsuConfigManager config { get; set; } = null!;
protected override Container<Drawable> Content { get; } = new Container
{
@ -66,8 +63,7 @@ namespace osu.Game.Screens.Play
/// <summary>
/// The player screen background, used to adjust appearance on failing.
/// </summary>
[CanBeNull]
public BackgroundScreen Background { private get; set; }
public BackgroundScreen? Background { private get; set; }
public FailAnimation(DrawableRuleset drawableRuleset)
{
@ -105,6 +101,7 @@ namespace osu.Game.Screens.Play
}
private bool started;
private bool filtersRemoved;
/// <summary>
/// Start the fail animation playing.
@ -113,6 +110,7 @@ namespace osu.Game.Screens.Play
public void Start()
{
if (started) throw new InvalidOperationException("Animation cannot be started more than once.");
if (filtersRemoved) throw new InvalidOperationException("Animation cannot be started after filters have been removed.");
started = true;
@ -125,7 +123,7 @@ namespace osu.Game.Screens.Play
failHighPassFilter.CutoffTo(300);
failLowPassFilter.CutoffTo(300, duration, Easing.OutCubic);
failSample.Play();
failSample?.Play();
track.AddAdjustment(AdjustableProperty.Frequency, trackFreq);
track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment);
@ -155,16 +153,20 @@ namespace osu.Game.Screens.Play
public void RemoveFilters(bool resetTrackFrequency = true)
{
if (resetTrackFrequency)
track?.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq);
filtersRemoved = true;
track?.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment);
if (!started)
return;
if (resetTrackFrequency)
track.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq);
track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment);
if (filters.Parent == null)
return;
RemoveInternal(filters);
filters.Dispose();
RemoveInternal(filters, true);
}
protected override void Update()

View File

@ -2,21 +2,21 @@
// 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;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
namespace osu.Game.Screens.Play
{
/// <summary>
/// Encapsulates gameplay timing logic and provides a <see cref="IGameplayClock"/> via DI for gameplay components to use.
/// </summary>
[Cached(typeof(IGameplayClock))]
public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock
{
/// <summary>
@ -36,72 +36,83 @@ namespace osu.Game.Screens.Play
/// <summary>
/// The time from which the clock should start. Will be seeked to on calling <see cref="Reset"/>.
/// Can be adjusted by calling <see cref="Reset"/> with a time value.
/// </summary>
/// <remarks>
/// If not set, a value of zero will be used.
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
/// By default, a value of zero will be used.
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
/// </remarks>
public double? StartTime { get; set; }
public double StartTime { get; protected set; }
public virtual IEnumerable<double> NonGameplayAdjustments => Enumerable.Empty<double>();
/// <summary>
/// The final clock which is exposed to gameplay components.
/// </summary>
protected IFrameBasedClock FramedClock { get; private set; }
public IAdjustableAudioComponent AdjustmentsFromMods { get; } = new AudioAdjustments();
private readonly BindableBool isPaused = new BindableBool(true);
/// <summary>
/// The adjustable source clock used for gameplay. Should be used for seeks and clock control.
/// This is the final source exposed to gameplay components <see cref="IGameplayClock"/> via delegation in this class.
/// </summary>
private readonly DecoupleableInterpolatingFramedClock decoupledClock;
protected readonly FramedBeatmapClock GameplayClock;
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
/// <summary>
/// Creates a new <see cref="GameplayClockContainer"/>.
/// </summary>
/// <param name="sourceClock">The source <see cref="IClock"/> used for timing.</param>
public GameplayClockContainer(IClock sourceClock)
/// <param name="applyOffsets">Whether to apply platform, user and beatmap offsets to the mix.</param>
public GameplayClockContainer(IClock sourceClock, bool applyOffsets = false)
{
SourceClock = sourceClock;
RelativeSizeAxes = Axes.Both;
decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false };
IsPaused.BindValueChanged(OnIsPausedChanged);
// this will be replaced during load, but non-null for tests which don't add this component to the hierarchy.
FramedClock = new FramedClock();
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
FramedClock = CreateGameplayClock(decoupledClock);
dependencies.CacheAs<IGameplayClock>(this);
return dependencies;
InternalChildren = new Drawable[]
{
GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false },
Content
};
}
/// <summary>
/// Starts gameplay.
/// Starts gameplay and marks un-paused state.
/// </summary>
public virtual void Start()
public void Start()
{
ensureSourceClockSet();
if (!decoupledClock.IsRunning)
{
// Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time
// This accounts for the clock source potentially taking time to enter a completely stopped state
Seek(FramedClock.CurrentTime);
decoupledClock.Start();
}
if (!isPaused.Value)
return;
isPaused.Value = false;
ensureSourceClockSet();
PrepareStart();
// The case which caused this to be added is FrameStabilityContainer, which manages its own current and elapsed time.
// Because we generally update our own current time quicker than children can query it (via Start/Seek/Update),
// this means that the first frame ever exposed to children may have a non-zero current time.
//
// If the child component is not aware of the parent ElapsedFrameTime (which is the case for FrameStabilityContainer)
// they will take on the new CurrentTime with a zero elapsed time. This can in turn cause components to behave incorrectly
// if they are intending to trigger events at the precise StartTime (ie. DrawableStoryboardSample).
//
// By scheduling the start call, children are guaranteed to receive one frame at the original start time, allowing
// then to progress with a correct locally calculated elapsed time.
SchedulerAfterChildren.Add(() =>
{
if (isPaused.Value)
return;
StartGameplayClock();
});
}
/// <summary>
/// When <see cref="Start"/> is called, this will be run to give an opportunity to prepare the clock at the correct
/// start location.
/// </summary>
protected virtual void PrepareStart()
{
}
/// <summary>
@ -112,43 +123,56 @@ namespace osu.Game.Screens.Play
{
Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}");
decoupledClock.Seek(time);
// Manually process to make sure the gameplay clock is correctly updated after a seek.
FramedClock.ProcessFrame();
GameplayClock.Seek(time);
OnSeek?.Invoke();
}
/// <summary>
/// Stops gameplay.
/// Stops gameplay and marks paused state.
/// </summary>
public void Stop() => isPaused.Value = true;
public void Stop()
{
if (isPaused.Value)
return;
isPaused.Value = true;
StopGameplayClock();
}
protected virtual void StartGameplayClock() => GameplayClock.Start();
protected virtual void StopGameplayClock() => GameplayClock.Stop();
/// <summary>
/// Resets this <see cref="GameplayClockContainer"/> and the source to an initial state ready for gameplay.
/// </summary>
/// <param name="time">The time to seek to on resetting. If <c>null</c>, the existing <see cref="StartTime"/> will be used.</param>
/// <param name="startClock">Whether to start the clock immediately, if not already started.</param>
public void Reset(bool startClock = false)
public void Reset(double? time = null, bool startClock = false)
{
// Manually stop the source in order to not affect the IsPaused state.
decoupledClock.Stop();
bool wasPaused = isPaused.Value;
if (!IsPaused.Value || startClock)
Start();
Stop();
ensureSourceClockSet();
Seek(StartTime ?? 0);
if (time != null)
StartTime = time.Value;
Seek(StartTime);
if (!wasPaused || startClock)
Start();
}
/// <summary>
/// Changes the source clock.
/// </summary>
/// <param name="sourceClock">The new source.</param>
protected void ChangeSource(IClock sourceClock) => decoupledClock.ChangeSource(SourceClock = sourceClock);
protected void ChangeSource(IClock sourceClock) => GameplayClock.ChangeSource(SourceClock = sourceClock);
/// <summary>
/// Ensures that the <see cref="decoupledClock"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
/// Ensures that the <see cref="GameplayClock"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
/// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode,
/// but not the actual source clock.
/// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor,
@ -156,40 +180,10 @@ namespace osu.Game.Screens.Play
/// </summary>
private void ensureSourceClockSet()
{
if (decoupledClock.Source == null)
if (GameplayClock.Source == null)
ChangeSource(SourceClock);
}
protected override void Update()
{
if (!IsPaused.Value)
FramedClock.ProcessFrame();
base.Update();
}
/// <summary>
/// Invoked when the value of <see cref="IsPaused"/> is changed to start or stop the <see cref="decoupledClock"/> clock.
/// </summary>
/// <param name="isPaused">Whether the clock should now be paused.</param>
protected virtual void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
{
if (isPaused.NewValue)
decoupledClock.Stop();
else
decoupledClock.Start();
}
/// <summary>
/// Creates the final <see cref="FramedClock"/> which is exposed via DI to be used by gameplay components.
/// </summary>
/// <remarks>
/// Any intermediate clocks such as platform offsets should be applied here.
/// </remarks>
/// <param name="source">The <see cref="IFrameBasedClock"/> providing the source time.</param>
/// <returns>The final <see cref="FramedClock"/>.</returns>
protected virtual IFrameBasedClock CreateGameplayClock(IFrameBasedClock source) => source;
#region IAdjustableClock
bool IAdjustableClock.Seek(double position)
@ -200,19 +194,21 @@ namespace osu.Game.Screens.Play
void IAdjustableClock.Reset() => Reset();
public void ResetSpeedAdjustments() => throw new NotImplementedException();
public virtual void ResetSpeedAdjustments()
{
}
double IAdjustableClock.Rate
{
get => FramedClock.Rate;
get => GameplayClock.Rate;
set => throw new NotSupportedException();
}
public double Rate => FramedClock.Rate;
public double Rate => GameplayClock.Rate;
public double CurrentTime => FramedClock.CurrentTime;
public double CurrentTime => GameplayClock.CurrentTime;
public bool IsRunning => FramedClock.IsRunning;
public bool IsRunning => GameplayClock.IsRunning;
#endregion
@ -221,28 +217,10 @@ namespace osu.Game.Screens.Play
// Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times.
}
public double ElapsedFrameTime => FramedClock.ElapsedFrameTime;
public double ElapsedFrameTime => GameplayClock.ElapsedFrameTime;
public double FramesPerSecond => FramedClock.FramesPerSecond;
public double FramesPerSecond => GameplayClock.FramesPerSecond;
public FrameTimeInfo TimeInfo => FramedClock.TimeInfo;
public double TrueGameplayRate
{
get
{
double baseRate = Rate;
foreach (double adjustment in NonGameplayAdjustments)
{
if (Precision.AlmostEquals(adjustment, 0))
return 0;
baseRate /= adjustment;
}
return baseRate;
}
}
public FrameTimeInfo TimeInfo => GameplayClock.TimeInfo;
}
}

View File

@ -0,0 +1,24 @@
// 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;
namespace osu.Game.Screens.Play
{
public static class GameplayClockExtensions
{
/// <summary>
/// The rate of gameplay when playback is at 100%.
/// This excludes any seeking / user adjustments.
/// </summary>
public static double GetTrueGameplayRate(this IGameplayClock clock)
{
// To handle rewind, we still want to maintain the same direction as the underlying clock.
double rate = clock.Rate == 0 ? 1 : Math.Sign(clock.Rate);
return rate
* clock.AdjustmentsFromMods.AggregateFrequency.Value
* clock.AdjustmentsFromMods.AggregateTempo.Value;
}
}
}

View File

@ -0,0 +1,59 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Rulesets.UI;
namespace osu.Game.Screens.Play.HUD.ClicksPerSecond
{
public class ClicksPerSecondCalculator : Component
{
private readonly List<double> timestamps = new List<double>();
[Resolved]
private IGameplayClock gameplayClock { get; set; } = null!;
[Resolved(canBeNull: true)]
private DrawableRuleset? drawableRuleset { get; set; }
public int Value { get; private set; }
// Even though `FrameStabilityContainer` caches as a `GameplayClock`, we need to check it directly via `drawableRuleset`
// as this calculator is not contained within the `FrameStabilityContainer` and won't see the dependency.
private IGameplayClock clock => drawableRuleset?.FrameStableClock ?? gameplayClock;
public ClicksPerSecondCalculator()
{
RelativeSizeAxes = Axes.Both;
}
public void AddInputTimestamp() => timestamps.Add(clock.CurrentTime);
protected override void Update()
{
base.Update();
double latestValidTime = clock.CurrentTime;
double earliestTimeValid = latestValidTime - 1000 * gameplayClock.GetTrueGameplayRate();
int count = 0;
for (int i = timestamps.Count - 1; i >= 0; i--)
{
// handle rewinding by removing future timestamps as we go
if (timestamps[i] > latestValidTime)
{
timestamps.RemoveAt(i);
continue;
}
if (timestamps[i] >= earliestTimeValid)
count++;
}
Value = count;
}
}
}

View File

@ -0,0 +1,102 @@
// 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.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Screens.Play.HUD.ClicksPerSecond
{
public class ClicksPerSecondCounter : RollingCounter<int>, ISkinnableDrawable
{
[Resolved]
private ClicksPerSecondCalculator calculator { get; set; } = null!;
protected override double RollingDuration => 350;
public bool UsesFixedAnchor { get; set; }
public ClicksPerSecondCounter()
{
Current.Value = 0;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Colour = colours.BlueLighter;
}
protected override void Update()
{
base.Update();
Current.Value = calculator.Value;
}
protected override IHasText CreateText() => new TextComponent();
private class TextComponent : CompositeDrawable, IHasText
{
public LocalisableString Text
{
get => text.Text;
set => text.Text = value;
}
private readonly OsuSpriteText text;
public TextComponent()
{
AutoSizeAxes = Axes.Both;
InternalChild = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(2),
Children = new Drawable[]
{
text = new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = OsuFont.Numeric.With(size: 16, fixedWidth: true)
},
new FillFlowContainer
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Direction = FillDirection.Vertical,
AutoSizeAxes = Axes.Both,
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.TopLeft,
Origin = Anchor.TopLeft,
Font = OsuFont.Numeric.With(size: 6, fixedWidth: false),
Text = @"clicks",
},
new OsuSpriteText
{
Anchor = Anchor.TopLeft,
Origin = Anchor.TopLeft,
Font = OsuFont.Numeric.With(size: 6, fixedWidth: false),
Text = @"/sec",
Padding = new MarginPadding { Bottom = 3f }, // align baseline better
}
}
}
}
};
}
}
}
}

View File

@ -0,0 +1,24 @@
// 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.Game.Graphics.UserInterface;
using osu.Game.Skinning;
namespace osu.Game.Screens.Play.HUD
{
public abstract class ComboCounter : RollingCounter<int>, ISkinnableDrawable
{
public bool UsesFixedAnchor { get; set; }
protected ComboCounter()
{
Current.Value = DisplayedCount = 0;
}
protected override double GetProportionalDuration(int currentValue, int newValue)
{
return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f;
}
}
}

View File

@ -1,29 +1,17 @@
// 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;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Scoring;
using osu.Game.Skinning;
namespace osu.Game.Screens.Play.HUD
{
public class DefaultComboCounter : RollingCounter<int>, ISkinnableDrawable
public class DefaultComboCounter : ComboCounter
{
public bool UsesFixedAnchor { get; set; }
public DefaultComboCounter()
{
Current.Value = DisplayedCount = 0;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours, ScoreProcessor scoreProcessor)
{
@ -31,17 +19,12 @@ namespace osu.Game.Screens.Play.HUD
Current.BindTo(scoreProcessor.Combo);
}
protected override OsuSpriteText CreateSpriteText()
=> base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f));
protected override LocalisableString FormatCount(int count)
{
return $@"{count}x";
}
protected override double GetProportionalDuration(int currentValue, int newValue)
{
return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f;
}
protected override OsuSpriteText CreateSpriteText()
=> base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f));
}
}

View File

@ -16,7 +16,6 @@ namespace osu.Game.Screens.Play.HUD
{
public class DefaultSongProgress : SongProgress
{
private const float info_height = 20;
private const float bottom_bar_height = 5;
private const float graph_height = SquareGraph.Column.WIDTH * 6;
private const float handle_height = 18;
@ -65,7 +64,6 @@ namespace osu.Game.Screens.Play.HUD
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
RelativeSizeAxes = Axes.X,
Height = info_height,
},
graph = new SongProgressGraph
{
@ -178,7 +176,7 @@ namespace osu.Game.Screens.Play.HUD
protected override void Update()
{
base.Update();
Height = bottom_bar_height + graph_height + handle_size.Y + info_height - graph.Y;
Height = bottom_bar_height + graph_height + handle_size.Y + info.Height - graph.Y;
}
private void updateBarVisibility()

View File

@ -1,11 +1,8 @@
// 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;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions;
@ -13,15 +10,14 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Users;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD
{
public class GameplayLeaderboard : CompositeDrawable
public abstract class GameplayLeaderboard : CompositeDrawable
{
private readonly int maxPanels;
private readonly Cached sorting = new Cached();
public Bindable<bool> Expanded = new Bindable<bool>();
@ -31,22 +27,22 @@ namespace osu.Game.Screens.Play.HUD
private bool requiresScroll;
private readonly OsuScrollContainer scroll;
private GameplayLeaderboardScore trackedScore;
public GameplayLeaderboardScore? TrackedScore { get; private set; }
private const int max_panels = 8;
/// <summary>
/// Create a new leaderboard.
/// </summary>
/// <param name="maxPanels">The maximum panels to show at once. Defines the maximum height of this component.</param>
public GameplayLeaderboard(int maxPanels = 8)
protected GameplayLeaderboard()
{
this.maxPanels = maxPanels;
Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH;
InternalChildren = new Drawable[]
{
scroll = new InputDisabledScrollContainer
{
ClampExtension = 0,
RelativeSizeAxes = Axes.Both,
Child = Flow = new FillFlowContainer<GameplayLeaderboardScore>
{
@ -77,24 +73,25 @@ namespace osu.Game.Screens.Play.HUD
/// Whether the player should be tracked on the leaderboard.
/// Set to <c>true</c> for the local player or a player whose replay is currently being played.
/// </param>
public ILeaderboardScore Add([CanBeNull] APIUser user, bool isTracked)
public ILeaderboardScore Add(IUser? user, bool isTracked)
{
var drawable = CreateLeaderboardScoreDrawable(user, isTracked);
if (isTracked)
{
if (trackedScore != null)
if (TrackedScore != null)
throw new InvalidOperationException("Cannot track more than one score.");
trackedScore = drawable;
TrackedScore = drawable;
}
drawable.Expanded.BindTo(Expanded);
Flow.Add(drawable);
drawable.TotalScore.BindValueChanged(_ => sorting.Invalidate(), true);
drawable.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true);
int displayCount = Math.Min(Flow.Count, maxPanels);
int displayCount = Math.Min(Flow.Count, max_panels);
Height = displayCount * (GameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y);
requiresScroll = displayCount != Flow.Count;
@ -104,21 +101,22 @@ namespace osu.Game.Screens.Play.HUD
public void Clear()
{
Flow.Clear();
trackedScore = null;
TrackedScore = null;
scroll.ScrollToStart(false);
}
protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) =>
protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) =>
new GameplayLeaderboardScore(user, isTracked);
protected override void Update()
{
base.Update();
if (requiresScroll && trackedScore != null)
if (requiresScroll && TrackedScore != null)
{
float scrollTarget = scroll.GetChildPosInContent(trackedScore) + trackedScore.DrawHeight / 2 - scroll.DrawHeight / 2;
scroll.ScrollTo(scrollTarget, false);
float scrollTarget = scroll.GetChildPosInContent(TrackedScore) + TrackedScore.DrawHeight / 2 - scroll.DrawHeight / 2;
scroll.ScrollTo(scrollTarget);
}
const float panel_height = GameplayLeaderboardScore.PANEL_HEIGHT;
@ -126,7 +124,7 @@ namespace osu.Game.Screens.Play.HUD
float fadeBottom = scroll.Current + scroll.DrawHeight;
float fadeTop = scroll.Current + panel_height;
if (scroll.Current <= 0) fadeTop -= panel_height;
if (scroll.IsScrolledToStart()) fadeTop -= panel_height;
if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height;
// logic is mostly shared with Leaderboard, copied here for simplicity.
@ -165,7 +163,10 @@ namespace osu.Game.Screens.Play.HUD
if (sorting.IsValid)
return;
var orderedByScore = Flow.OrderByDescending(i => i.TotalScore.Value).ToList();
var orderedByScore = Flow
.OrderByDescending(i => i.TotalScore.Value)
.ThenBy(i => i.DisplayOrder.Value)
.ToList();
for (int i = 0; i < Flow.Count; i++)
{

View File

@ -3,6 +3,7 @@
#nullable disable
using System;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -12,7 +13,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Users;
using osu.Game.Users.Drawables;
using osu.Game.Utils;
using osuTK;
@ -39,8 +40,6 @@ namespace osu.Game.Screens.Play.HUD
private const float rank_text_width = 35f;
private const float score_components_width = 85f;
private const float avatar_size = 25f;
private const double panel_transition_duration = 500;
@ -55,6 +54,7 @@ namespace osu.Game.Screens.Play.HUD
public BindableDouble Accuracy { get; } = new BindableDouble(1);
public BindableInt Combo { get; } = new BindableInt();
public BindableBool HasQuit { get; } = new BindableBool();
public Bindable<long> DisplayOrder { get; } = new Bindable<long>();
public Color4? BackgroundColour { get; set; }
@ -81,7 +81,7 @@ namespace osu.Game.Screens.Play.HUD
}
[CanBeNull]
public APIUser User { get; }
public IUser User { get; }
/// <summary>
/// Whether this score is the local user or a replay player (and should be focused / always visible).
@ -103,7 +103,7 @@ namespace osu.Game.Screens.Play.HUD
/// </summary>
/// <param name="user">The score's player.</param>
/// <param name="tracked">Whether the player is the local user or a replay player.</param>
public GameplayLeaderboardScore([CanBeNull] APIUser user, bool tracked)
public GameplayLeaderboardScore([CanBeNull] IUser user, bool tracked)
{
User = user;
Tracked = tracked;
@ -160,7 +160,7 @@ namespace osu.Game.Screens.Play.HUD
{
new Dimension(GridSizeMode.Absolute, rank_text_width),
new Dimension(),
new Dimension(GridSizeMode.AutoSize, maxSize: score_components_width),
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
@ -285,8 +285,19 @@ namespace osu.Game.Screens.Play.HUD
LoadComponentAsync(new DrawableAvatar(User), avatarContainer.Add);
TotalScore.BindValueChanged(v => scoreText.Text = v.NewValue.ToString("N0"), true);
Accuracy.BindValueChanged(v => accuracyText.Text = v.NewValue.FormatAccuracy(), true);
Combo.BindValueChanged(v => comboText.Text = $"{v.NewValue}x", true);
Accuracy.BindValueChanged(v =>
{
accuracyText.Text = v.NewValue.FormatAccuracy();
updateDetailsWidth();
}, true);
Combo.BindValueChanged(v =>
{
comboText.Text = $"{v.NewValue}x";
updateDetailsWidth();
}, true);
HasQuit.BindValueChanged(_ => updateState());
}
@ -302,13 +313,10 @@ namespace osu.Game.Screens.Play.HUD
private void changeExpandedState(ValueChangedEvent<bool> expanded)
{
scoreComponents.ClearTransforms();
if (expanded.NewValue)
{
gridContainer.ResizeWidthTo(regular_width, panel_transition_duration, Easing.OutQuint);
scoreComponents.ResizeWidthTo(score_components_width, panel_transition_duration, Easing.OutQuint);
scoreComponents.FadeIn(panel_transition_duration, Easing.OutQuint);
usernameText.FadeIn(panel_transition_duration, Easing.OutQuint);
@ -317,11 +325,29 @@ namespace osu.Game.Screens.Play.HUD
{
gridContainer.ResizeWidthTo(compact_width, panel_transition_duration, Easing.OutQuint);
scoreComponents.ResizeWidthTo(0, panel_transition_duration, Easing.OutQuint);
scoreComponents.FadeOut(text_transition_duration, Easing.OutQuint);
usernameText.FadeOut(text_transition_duration, Easing.OutQuint);
}
updateDetailsWidth();
}
private float? scoreComponentsTargetWidth;
private void updateDetailsWidth()
{
const float score_components_min_width = 88f;
float newWidth = Expanded.Value
? Math.Max(score_components_min_width, comboText.DrawWidth + accuracyText.DrawWidth + 25)
: 0;
if (scoreComponentsTargetWidth == newWidth)
return;
scoreComponentsTargetWidth = newWidth;
scoreComponents.ResizeWidthTo(newWidth, panel_transition_duration, Easing.OutQuint);
}
private void updateState()

View File

@ -15,6 +15,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
@ -44,8 +45,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
public Bindable<LabelStyles> LabelStyle { get; } = new Bindable<LabelStyles>(LabelStyles.Icons);
private SpriteIcon arrow;
private Drawable labelEarly;
private Drawable labelLate;
private UprightAspectMaintainingContainer labelEarly;
private UprightAspectMaintainingContainer labelLate;
private Container colourBarsEarly;
private Container colourBarsLate;
@ -122,6 +123,20 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
RelativeSizeAxes = Axes.Y,
Width = judgement_line_width,
},
labelEarly = new UprightAspectMaintainingContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Y = -10,
},
labelLate = new UprightAspectMaintainingContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.Centre,
Y = 10,
},
}
},
arrowContainer = new Container
@ -261,57 +276,39 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
{
const float icon_size = 14;
labelEarly?.Expire();
labelEarly = null;
labelLate?.Expire();
labelLate = null;
switch (style)
{
case LabelStyles.None:
break;
case LabelStyles.Icons:
labelEarly = new SpriteIcon
labelEarly.Child = new SpriteIcon
{
Y = -10,
Size = new Vector2(icon_size),
Icon = FontAwesome.Solid.ShippingFast,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
};
labelLate = new SpriteIcon
labelLate.Child = new SpriteIcon
{
Y = 10,
Size = new Vector2(icon_size),
Icon = FontAwesome.Solid.Bicycle,
Anchor = Anchor.BottomCentre,
Origin = Anchor.Centre,
};
break;
case LabelStyles.Text:
labelEarly = new OsuSpriteText
labelEarly.Child = new OsuSpriteText
{
Y = -10,
Text = "Early",
Font = OsuFont.Default.With(size: 10),
Height = 12,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
};
labelLate = new OsuSpriteText
labelLate.Child = new OsuSpriteText
{
Y = 10,
Text = "Late",
Font = OsuFont.Default.With(size: 10),
Height = 12,
Anchor = Anchor.BottomCentre,
Origin = Anchor.Centre,
};
break;
@ -320,26 +317,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
throw new ArgumentOutOfRangeException(nameof(style), style, null);
}
if (labelEarly != null)
{
colourBars.Add(labelEarly);
labelEarly.FadeInFromZero(500);
}
if (labelLate != null)
{
colourBars.Add(labelLate);
labelLate.FadeInFromZero(500);
}
}
protected override void Update()
{
base.Update();
// undo any layout rotation to display icons in the correct orientation
if (labelEarly != null) labelEarly.Rotation = -Rotation;
if (labelLate != null) labelLate.Rotation = -Rotation;
labelEarly.FadeInFromZero(500);
labelLate.FadeInFromZero(500);
}
private void createColourBars((HitResult result, double length)[] windows)

View File

@ -1,13 +1,13 @@
// 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.
#nullable disable
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Configuration;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osuTK;
@ -17,18 +17,37 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
{
public class ColourHitErrorMeter : HitErrorMeter
{
internal const int MAX_DISPLAYED_JUDGEMENTS = 20;
private const int animation_duration = 200;
private const int drawable_judgement_size = 8;
private const int spacing = 2;
[SettingSource("Judgement count", "The number of displayed judgements")]
public BindableNumber<int> JudgementCount { get; } = new BindableNumber<int>(20)
{
MinValue = 1,
MaxValue = 50,
};
[SettingSource("Judgement spacing", "The space between each displayed judgement")]
public BindableNumber<float> JudgementSpacing { get; } = new BindableNumber<float>(2)
{
MinValue = 0,
MaxValue = 10,
};
[SettingSource("Judgement shape", "The shape of each displayed judgement")]
public Bindable<ShapeStyle> JudgementShape { get; } = new Bindable<ShapeStyle>();
private readonly JudgementFlow judgementsFlow;
public ColourHitErrorMeter()
{
AutoSizeAxes = Axes.Both;
InternalChild = judgementsFlow = new JudgementFlow();
InternalChild = judgementsFlow = new JudgementFlow
{
JudgementShape = { BindTarget = JudgementShape },
JudgementSpacing = { BindTarget = JudgementSpacing },
JudgementCount = { BindTarget = JudgementCount }
};
}
protected override void OnNewJudgement(JudgementResult judgement)
@ -41,53 +60,105 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
public override void Clear() => judgementsFlow.Clear();
private class JudgementFlow : FillFlowContainer<HitErrorCircle>
private class JudgementFlow : FillFlowContainer<HitErrorShape>
{
public override IEnumerable<Drawable> FlowingChildren => base.FlowingChildren.Reverse();
public readonly Bindable<ShapeStyle> JudgementShape = new Bindable<ShapeStyle>();
public readonly Bindable<float> JudgementSpacing = new Bindable<float>();
public readonly Bindable<int> JudgementCount = new Bindable<int>();
public JudgementFlow()
{
AutoSizeAxes = Axes.X;
Height = MAX_DISPLAYED_JUDGEMENTS * (drawable_judgement_size + spacing) - spacing;
Spacing = new Vector2(0, spacing);
Width = drawable_judgement_size;
Direction = FillDirection.Vertical;
LayoutDuration = animation_duration;
LayoutEasing = Easing.OutQuint;
}
public void Push(Color4 colour)
{
Add(new HitErrorCircle(colour, drawable_judgement_size));
if (Children.Count > MAX_DISPLAYED_JUDGEMENTS)
Children.FirstOrDefault(c => !c.IsRemoved)?.Remove();
}
}
internal class HitErrorCircle : Container
{
public bool IsRemoved { get; private set; }
private readonly Circle circle;
public HitErrorCircle(Color4 colour, int size)
{
Size = new Vector2(size);
Child = circle = new Circle
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Colour = colour
};
}
protected override void LoadComplete()
{
base.LoadComplete();
circle.FadeInFromZero(animation_duration, Easing.OutQuint);
circle.MoveToY(-DrawSize.Y);
circle.MoveToY(0, animation_duration, Easing.OutQuint);
JudgementCount.BindValueChanged(count =>
{
removeExtraJudgements();
updateMetrics();
});
JudgementSpacing.BindValueChanged(_ => updateMetrics(), true);
}
public void Push(Color4 colour)
{
Add(new HitErrorShape(colour, drawable_judgement_size)
{
Shape = { BindTarget = JudgementShape },
});
removeExtraJudgements();
}
private void removeExtraJudgements()
{
var remainingChildren = Children.Where(c => !c.IsRemoved);
while (remainingChildren.Count() > JudgementCount.Value)
remainingChildren.First().Remove();
}
private void updateMetrics()
{
Height = JudgementCount.Value * (drawable_judgement_size + JudgementSpacing.Value) - JudgementSpacing.Value;
Spacing = new Vector2(0, JudgementSpacing.Value);
}
}
public class HitErrorShape : Container
{
public bool IsRemoved { get; private set; }
public readonly Bindable<ShapeStyle> Shape = new Bindable<ShapeStyle>();
private readonly Color4 colour;
private Container content = null!;
public HitErrorShape(Color4 colour, int size)
{
this.colour = colour;
Size = new Vector2(size);
}
protected override void LoadComplete()
{
base.LoadComplete();
Child = content = new Container
{
RelativeSizeAxes = Axes.Both,
Colour = colour
};
Shape.BindValueChanged(shape =>
{
switch (shape.NewValue)
{
case ShapeStyle.Circle:
content.Child = new Circle { RelativeSizeAxes = Axes.Both };
break;
case ShapeStyle.Square:
content.Child = new Box { RelativeSizeAxes = Axes.Both };
break;
}
}, true);
content.FadeInFromZero(animation_duration, Easing.OutQuint);
content.MoveToY(-DrawSize.Y);
content.MoveToY(0, animation_duration, Easing.OutQuint);
}
public void Remove()
@ -97,5 +168,11 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
this.FadeOut(animation_duration, Easing.OutQuint).Expire();
}
}
public enum ShapeStyle
{
Circle,
Square
}
}
}

View File

@ -59,30 +59,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
protected Color4 GetColourForHitResult(HitResult result)
{
switch (result)
{
case HitResult.SmallTickMiss:
case HitResult.LargeTickMiss:
case HitResult.Miss:
return colours.Red;
case HitResult.Meh:
return colours.Yellow;
case HitResult.Ok:
return colours.Green;
case HitResult.Good:
return colours.GreenLight;
case HitResult.SmallTickHit:
case HitResult.LargeTickHit:
case HitResult.Great:
return colours.Blue;
default:
return colours.BlueLight;
}
return colours.ForHitResult(result);
}
/// <summary>

View File

@ -14,5 +14,11 @@ namespace osu.Game.Screens.Play.HUD
BindableInt Combo { get; }
BindableBool HasQuit { get; }
/// <summary>
/// An optional value to guarantee stable ordering.
/// Lower numbers will appear higher in cases of <see cref="TotalScore"/> ties.
/// </summary>
Bindable<long> DisplayOrder { get; }
}
}

View File

@ -0,0 +1,83 @@
// 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.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Scoring;
using osuTK;
namespace osu.Game.Screens.Play.HUD
{
public class LongestComboCounter : ComboCounter
{
[BackgroundDependencyLoader]
private void load(OsuColour colours, ScoreProcessor scoreProcessor)
{
Colour = colours.YellowLighter;
Current.BindTo(scoreProcessor.HighestCombo);
}
protected override IHasText CreateText() => new TextComponent();
private class TextComponent : CompositeDrawable, IHasText
{
public LocalisableString Text
{
get => text.Text;
set => text.Text = $"{value}x";
}
private readonly OsuSpriteText text;
public TextComponent()
{
AutoSizeAxes = Axes.Both;
InternalChild = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(2),
Children = new Drawable[]
{
text = new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = OsuFont.Numeric.With(size: 20)
},
new FillFlowContainer
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Direction = FillDirection.Vertical,
AutoSizeAxes = Axes.Both,
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.TopLeft,
Origin = Anchor.TopLeft,
Font = OsuFont.Numeric.With(size: 8),
Text = @"longest",
},
new OsuSpriteText
{
Anchor = Anchor.TopLeft,
Origin = Anchor.TopLeft,
Font = OsuFont.Numeric.With(size: 8),
Text = @"combo",
Padding = new MarginPadding { Bottom = 3f }
}
}
}
}
};
}
}
}
}

View File

@ -1,8 +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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
@ -12,6 +10,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics;
@ -21,6 +20,7 @@ using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Users;
using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD
@ -33,19 +33,20 @@ namespace osu.Game.Screens.Play.HUD
public readonly SortedDictionary<int, BindableLong> TeamScores = new SortedDictionary<int, BindableLong>();
[Resolved]
private OsuColour colours { get; set; }
private OsuColour colours { get; set; } = null!;
[Resolved]
private SpectatorClient spectatorClient { get; set; }
private SpectatorClient spectatorClient { get; set; } = null!;
[Resolved]
private MultiplayerClient multiplayerClient { get; set; }
private MultiplayerClient multiplayerClient { get; set; } = null!;
[Resolved]
private UserLookupCache userLookupCache { get; set; }
private UserLookupCache userLookupCache { get; set; } = null!;
private Bindable<ScoringMode> scoringMode = null!;
private readonly MultiplayerRoomUser[] playingUsers;
private Bindable<ScoringMode> scoringMode;
private readonly IBindableList<int> playingUserIds = new BindableList<int>();
@ -125,14 +126,17 @@ namespace osu.Game.Screens.Play.HUD
playingUserIds.BindCollectionChanged(playingUsersChanged);
}
protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked)
protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked)
{
var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked);
if (UserScores[user.Id].Team is int team)
if (user != null)
{
leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f);
leaderboardScore.TextColour = Color4.White;
if (UserScores[user.OnlineID].Team is int team)
{
leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f);
leaderboardScore.TextColour = Color4.White;
}
}
return leaderboardScore;
@ -188,7 +192,7 @@ namespace osu.Game.Screens.Play.HUD
{
base.Dispose(isDisposing);
if (spectatorClient != null)
if (spectatorClient.IsNotNull())
{
foreach (var user in playingUsers)
spectatorClient.StopWatchingUser(user.UserID);

View File

@ -0,0 +1,99 @@
// 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.Scoring;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Screens.Play.HUD
{
public class SoloGameplayLeaderboard : GameplayLeaderboard
{
private const int duration = 100;
private readonly Bindable<bool> configVisibility = new Bindable<bool>();
private readonly IUser trackingUser;
public readonly IBindableList<ScoreInfo> Scores = new BindableList<ScoreInfo>();
// hold references to ensure bindables are updated.
private readonly List<Bindable<long>> scoreBindables = new List<Bindable<long>>();
[Resolved]
private ScoreProcessor scoreProcessor { get; set; } = null!;
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
/// <summary>
/// Whether the leaderboard should be visible regardless of the configuration value.
/// This is true by default, but can be changed.
/// </summary>
public readonly Bindable<bool> AlwaysVisible = new Bindable<bool>(true);
public SoloGameplayLeaderboard(IUser trackingUser)
{
this.trackingUser = trackingUser;
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility);
}
protected override void LoadComplete()
{
base.LoadComplete();
Scores.BindCollectionChanged((_, _) => Scheduler.AddOnce(showScores), true);
// Alpha will be updated via `updateVisibility` below.
Alpha = 0;
AlwaysVisible.BindValueChanged(_ => updateVisibility());
configVisibility.BindValueChanged(_ => updateVisibility(), true);
}
private void showScores()
{
Clear();
scoreBindables.Clear();
if (!Scores.Any())
return;
foreach (var s in Scores)
{
var score = Add(s.User, false);
var bindableTotal = scoreManager.GetBindableTotalScore(s);
// Direct binding not possible due to differing types (see https://github.com/ppy/osu/issues/20298).
bindableTotal.BindValueChanged(total => score.TotalScore.Value = total.NewValue, true);
scoreBindables.Add(bindableTotal);
score.Accuracy.Value = s.Accuracy;
score.Combo.Value = s.MaxCombo;
score.DisplayOrder.Value = s.OnlineID > 0 ? s.OnlineID : s.Date.ToUnixTimeSeconds();
}
ILeaderboardScore local = Add(trackingUser, true);
local.TotalScore.BindTarget = scoreProcessor.TotalScore;
local.Accuracy.BindTarget = scoreProcessor.Accuracy;
local.Combo.BindTarget = scoreProcessor.HighestCombo;
// Local score should always show lower than any existing scores in cases of ties.
local.DisplayOrder.Value = long.MaxValue;
}
private void updateVisibility() =>
this.FadeTo(AlwaysVisible.Value || configVisibility.Value ? 1 : 0, duration);
}
}

View File

@ -82,7 +82,7 @@ namespace osu.Game.Screens.Play.HUD
if (isInIntro)
{
double introStartTime = GameplayClock.StartTime ?? 0;
double introStartTime = GameplayClock.StartTime;
double introOffsetCurrent = currentTime - introStartTime;
double introDuration = FirstHitTime - introStartTime;

View File

@ -7,6 +7,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using System;
@ -14,9 +15,9 @@ namespace osu.Game.Screens.Play.HUD
{
public class SongProgressInfo : Container
{
private OsuSpriteText timeCurrent;
private OsuSpriteText timeLeft;
private OsuSpriteText progress;
private SizePreservingSpriteText timeCurrent;
private SizePreservingSpriteText timeLeft;
private SizePreservingSpriteText progress;
private double startTime;
private double endTime;
@ -46,36 +47,71 @@ namespace osu.Game.Screens.Play.HUD
if (clock != null)
gameplayClock = clock;
AutoSizeAxes = Axes.Y;
Children = new Drawable[]
{
timeCurrent = new OsuSpriteText
new Container
{
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
Colour = colours.BlueLighter,
Font = OsuFont.Numeric,
Margin = new MarginPadding
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
Child = new UprightAspectMaintainingContainer
{
Left = margin,
},
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Scaling = ScaleMode.Vertical,
ScalingFactor = 0.5f,
Child = timeCurrent = new SizePreservingSpriteText
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Colour = colours.BlueLighter,
Font = OsuFont.Numeric,
}
}
},
progress = new OsuSpriteText
new Container
{
Origin = Anchor.BottomCentre,
Anchor = Anchor.BottomCentre,
Colour = colours.BlueLighter,
Font = OsuFont.Numeric,
},
timeLeft = new OsuSpriteText
{
Origin = Anchor.BottomRight,
Anchor = Anchor.BottomRight,
Colour = colours.BlueLighter,
Font = OsuFont.Numeric,
Margin = new MarginPadding
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Child = new UprightAspectMaintainingContainer
{
Right = margin,
},
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Scaling = ScaleMode.Vertical,
ScalingFactor = 0.5f,
Child = progress = new SizePreservingSpriteText
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Colour = colours.BlueLighter,
Font = OsuFont.Numeric,
}
}
},
new Container
{
Origin = Anchor.CentreRight,
Anchor = Anchor.CentreRight,
AutoSizeAxes = Axes.Both,
Child = new UprightAspectMaintainingContainer
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Scaling = ScaleMode.Vertical,
ScalingFactor = 0.5f,
Child = timeLeft = new SizePreservingSpriteText
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Colour = colours.BlueLighter,
Font = OsuFont.Numeric,
}
}
}
};
}

View File

@ -9,7 +9,6 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings;
@ -22,6 +21,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Play.HUD.ClicksPerSecond;
using osu.Game.Skinning;
using osuTK;
@ -34,21 +34,23 @@ namespace osu.Game.Screens.Play
public const Easing FADE_EASING = Easing.OutQuint;
/// <summary>
/// The total height of all the top of screen scoring elements.
/// </summary>
public float TopScoringElementsHeight { get; private set; }
/// <summary>
/// The total height of all the bottom of screen scoring elements.
/// </summary>
public float BottomScoringElementsHeight { get; private set; }
// HUD uses AlwaysVisible on child components so they can be in an updated state for next display.
// Without blocking input, this would also allow them to be interacted with in such a state.
public override bool PropagatePositionalInputSubTree => ShowHud.Value;
public readonly KeyCounterDisplay KeyCounter;
public readonly ModDisplay ModDisplay;
public readonly HoldForMenuButton HoldToQuit;
public readonly PlayerSettingsOverlay PlayerSettingsOverlay;
[Cached]
private readonly ClicksPerSecondCalculator clicksPerSecondCalculator;
public Bindable<bool> ShowHealthBar = new Bindable<bool>(true);
private readonly DrawableRuleset drawableRuleset;
@ -76,9 +78,15 @@ namespace osu.Game.Screens.Play
private readonly SkinnableTargetContainer mainComponents;
private IEnumerable<Drawable> hideTargets => new Drawable[] { mainComponents, KeyCounter, topRightElements };
/// <summary>
/// A flow which sits at the left side of the screen to house leaderboard (and related) components.
/// Will automatically be positioned to avoid colliding with top scoring elements.
/// </summary>
public readonly FillFlowContainer LeaderboardFlow;
public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList<Mod> mods)
private readonly List<Drawable> hideTargets;
public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList<Mod> mods, bool alwaysShowLeaderboard = true)
{
this.drawableRuleset = drawableRuleset;
this.mods = mods;
@ -122,8 +130,21 @@ namespace osu.Game.Screens.Play
KeyCounter = CreateKeyCounter(),
HoldToQuit = CreateHoldForMenuButton(),
}
}
},
LeaderboardFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Padding = new MarginPadding(44), // enough margin to avoid the hit error display
Spacing = new Vector2(5)
},
clicksPerSecondCalculator = new ClicksPerSecondCalculator(),
};
hideTargets = new List<Drawable> { mainComponents, KeyCounter, topRightElements };
if (!alwaysShowLeaderboard)
hideTargets.Add(LeaderboardFlow);
}
[BackgroundDependencyLoader(true)]
@ -172,22 +193,36 @@ namespace osu.Game.Screens.Play
{
base.Update();
Vector2? lowestTopScreenSpace = null;
float? lowestTopScreenSpaceLeft = null;
float? lowestTopScreenSpaceRight = null;
Vector2? highestBottomScreenSpace = null;
// LINQ cast can be removed when IDrawable interface includes Anchor / RelativeSizeAxes.
foreach (var element in mainComponents.Components.Cast<Drawable>())
{
// for now align top-right components with the bottom-edge of the lowest top-anchored hud element.
if (element.Anchor.HasFlagFast(Anchor.TopRight) || (element.Anchor.HasFlagFast(Anchor.y0) && element.RelativeSizeAxes == Axes.X))
// for now align some top components with the bottom-edge of the lowest top-anchored hud element.
if (element.Anchor.HasFlagFast(Anchor.y0))
{
// health bars are excluded for the sake of hacky legacy skins which extend the health bar to take up the full screen area.
if (element is LegacyHealthDisplay)
continue;
var bottomRight = element.ScreenSpaceDrawQuad.BottomRight;
if (lowestTopScreenSpace == null || bottomRight.Y > lowestTopScreenSpace.Value.Y)
lowestTopScreenSpace = bottomRight;
float bottom = element.ScreenSpaceDrawQuad.BottomRight.Y;
bool isRelativeX = element.RelativeSizeAxes == Axes.X;
if (element.Anchor.HasFlagFast(Anchor.TopRight) || isRelativeX)
{
if (lowestTopScreenSpaceRight == null || bottom > lowestTopScreenSpaceRight.Value)
lowestTopScreenSpaceRight = bottom;
}
if (element.Anchor.HasFlagFast(Anchor.TopLeft) || isRelativeX)
{
if (lowestTopScreenSpaceLeft == null || bottom > lowestTopScreenSpaceLeft.Value)
lowestTopScreenSpaceLeft = bottom;
}
}
// and align bottom-right components with the top-edge of the highest bottom-anchored hud element.
else if (element.Anchor.HasFlagFast(Anchor.BottomRight) || (element.Anchor.HasFlagFast(Anchor.y2) && element.RelativeSizeAxes == Axes.X))
@ -198,11 +233,16 @@ namespace osu.Game.Screens.Play
}
}
if (lowestTopScreenSpace.HasValue)
topRightElements.Y = TopScoringElementsHeight = MathHelper.Clamp(ToLocalSpace(lowestTopScreenSpace.Value).Y, 0, DrawHeight - topRightElements.DrawHeight);
if (lowestTopScreenSpaceRight.HasValue)
topRightElements.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - topRightElements.DrawHeight);
else
topRightElements.Y = 0;
if (lowestTopScreenSpaceLeft.HasValue)
LeaderboardFlow.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceLeft.Value)).Y, 0, DrawHeight - LeaderboardFlow.DrawHeight);
else
LeaderboardFlow.Y = 0;
if (highestBottomScreenSpace.HasValue)
bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight);
else
@ -259,7 +299,11 @@ namespace osu.Game.Screens.Play
protected virtual void BindDrawableRuleset(DrawableRuleset drawableRuleset)
{
(drawableRuleset as ICanAttachKeyCounter)?.Attach(KeyCounter);
if (drawableRuleset is ICanAttachHUDPieces attachTarget)
{
attachTarget.Attach(KeyCounter);
attachTarget.Attach(clicksPerSecondCalculator);
}
replayLoaded.BindTo(drawableRuleset.HasReplayLoaded);
}

View File

@ -1,7 +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 System.Collections.Generic;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Timing;
@ -9,25 +9,19 @@ namespace osu.Game.Screens.Play
{
public interface IGameplayClock : IFrameBasedClock
{
/// <summary>
/// The rate of gameplay when playback is at 100%.
/// This excludes any seeking / user adjustments.
/// </summary>
double TrueGameplayRate { get; }
/// <summary>
/// The time from which the clock should start. Will be seeked to on calling <see cref="GameplayClockContainer.Reset"/>.
/// </summary>
/// <remarks>
/// If not set, a value of zero will be used.
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
/// By default, a value of zero will be used.
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
/// </remarks>
double? StartTime { get; }
double StartTime { get; }
/// <summary>
/// All adjustments applied to this clock which don't come from gameplay or mods.
/// All adjustments applied to this clock which come from mods.
/// </summary>
IEnumerable<double> NonGameplayAdjustments { get; }
IAdjustableAudioComponent AdjustmentsFromMods { get; }
IBindable<bool> IsPaused { get; }
}

View File

@ -39,6 +39,7 @@ namespace osu.Game.Screens.Play
{
Direction = FillDirection.Horizontal,
AutoSizeAxes = Axes.Both,
Alpha = 0,
};
}

View File

@ -1,19 +0,0 @@
// 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
namespace osu.Game.Screens.Play
{
public class KeyCounterState
{
public KeyCounterState(double time, int count)
{
Time = time;
Count = count;
}
public readonly double Time;
public readonly int Count;
}
}

View File

@ -2,9 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
@ -13,8 +11,7 @@ using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Overlays;
namespace osu.Game.Screens.Play
{
@ -37,37 +34,30 @@ namespace osu.Game.Screens.Play
public readonly BindableNumber<double> UserPlaybackRate = new BindableDouble(1)
{
Default = 1,
MinValue = 0.5,
MaxValue = 2,
Precision = 0.1,
};
private double totalAppliedOffset => userBeatmapOffsetClock.RateAdjustedOffset + userGlobalOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset;
private readonly BindableDouble pauseFreqAdjust = new BindableDouble(); // Important that this starts at zero, matching the paused state of the clock.
private readonly WorkingBeatmap beatmap;
private OffsetCorrectionClock userGlobalOffsetClock = null!;
private OffsetCorrectionClock userBeatmapOffsetClock = null!;
private OffsetCorrectionClock platformOffsetClock = null!;
private Bindable<double> userAudioOffset = null!;
private IDisposable? beatmapOffsetSubscription;
private readonly Track track;
private readonly double skipTargetTime;
[Resolved]
private RealmAccess realm { get; set; } = null!;
/// <summary>
/// Stores the time at which the last <see cref="StopGameplayClock"/> call was triggered.
/// This is used to ensure we resume from that precise point in time, ignoring the proceeding frequency ramp.
///
/// Optimally, we'd have gameplay ramp down with the frequency, but I believe this was intentionally disabled
/// to avoid fails occurring after the pause screen has been shown.
///
/// In the future I want to change this.
/// </summary>
private double? actualStopTime;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private readonly List<Bindable<double>> nonGameplayAdjustments = new List<Bindable<double>>();
public override IEnumerable<double> NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value);
private MusicController musicController { get; set; } = null!;
/// <summary>
/// Create a new master gameplay clock container.
@ -75,32 +65,14 @@ namespace osu.Game.Screens.Play
/// <param name="beatmap">The beatmap to be used for time and metadata references.</param>
/// <param name="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime)
: base(beatmap.Track)
: base(beatmap.Track, true)
{
this.beatmap = beatmap;
this.skipTargetTime = skipTargetTime;
}
protected override void LoadComplete()
{
base.LoadComplete();
track = beatmap.Track;
userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset);
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
r => r.Find<BeatmapInfo>(beatmap.BeatmapInfo.ID)?.UserSettings,
settings => settings.Offset,
val => userBeatmapOffsetClock.Offset = val);
// Reset may have been called externally before LoadComplete.
// If it was, and the clock is in a playing state, we want to ensure that it isn't stopped here.
bool isStarted = !IsPaused.Value;
// If a custom start time was not specified, calculate the best value to use.
StartTime ??= findEarliestStartTime();
Reset(startClock: isStarted);
StartTime = findEarliestStartTime();
}
private double findEarliestStartTime()
@ -126,54 +98,70 @@ namespace osu.Game.Screens.Play
return time;
}
protected override void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
protected override void StopGameplayClock()
{
actualStopTime = GameplayClock.CurrentTime;
if (IsLoaded)
{
// During normal operation, the source is stopped after performing a frequency ramp.
if (isPaused.NewValue)
this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 0, 200, Easing.Out).OnComplete(_ =>
{
this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ =>
{
if (IsPaused.Value == isPaused.NewValue)
base.OnIsPausedChanged(isPaused);
});
}
else
this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In);
if (IsPaused.Value)
base.StopGameplayClock();
});
}
else
{
if (isPaused.NewValue)
base.OnIsPausedChanged(isPaused);
base.StopGameplayClock();
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
pauseFreqAdjust.Value = isPaused.NewValue ? 0 : 1;
GameplayClock.ExternalPauseFrequencyAdjust.Value = 0;
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
// Without doing this, an initial seek may be performed with the wrong offset.
FramedClock.ProcessFrame();
GameplayClock.ProcessFrame();
}
}
public override void Start()
{
addSourceClockAdjustments();
base.Start();
}
/// <summary>
/// Seek to a specific time in gameplay.
/// </summary>
/// <remarks>
/// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track).
/// </remarks>
/// <param name="time">The destination time to seek to.</param>
public override void Seek(double time)
{
// remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track.
// we may want to consider reversing the application of offsets in the future as it may feel more correct.
base.Seek(time - totalAppliedOffset);
// Safety in case the clock is seeked while stopped.
actualStopTime = null;
base.Seek(time);
}
protected override void PrepareStart()
{
if (actualStopTime != null)
{
Seek(actualStopTime.Value);
actualStopTime = null;
}
else
base.PrepareStart();
}
protected override void StartGameplayClock()
{
addSourceClockAdjustments();
base.StartGameplayClock();
if (IsLoaded)
{
this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 1, 200, Easing.In);
}
else
{
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
GameplayClock.ExternalPauseFrequencyAdjust.Value = 1;
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
// Without doing this, an initial seek may be performed with the wrong offset.
GameplayClock.ProcessFrame();
}
}
/// <summary>
@ -181,29 +169,18 @@ namespace osu.Game.Screens.Play
/// </summary>
public void Skip()
{
if (FramedClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
return;
double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME;
if (FramedClock.CurrentTime < 0 && skipTarget > 6000)
if (GameplayClock.CurrentTime < 0 && skipTarget > 6000)
// double skip exception for storyboards with very long intros
skipTarget = 0;
Seek(skipTarget);
}
protected override IFrameBasedClock CreateGameplayClock(IFrameBasedClock source)
{
// Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited.
// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
platformOffsetClock = new OffsetCorrectionClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
// the final usable gameplay clock with user-set offsets applied.
userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, pauseFreqAdjust);
return userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, pauseFreqAdjust);
}
/// <summary>
/// Changes the backing clock to avoid using the originally provided track.
/// </summary>
@ -221,15 +198,12 @@ namespace osu.Game.Screens.Play
if (speedAdjustmentsApplied)
return;
if (SourceClock is not Track track)
return;
musicController.ResetTrackAdjustments();
track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
track.BindAdjustments(AdjustmentsFromMods);
track.AddAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
nonGameplayAdjustments.Add(pauseFreqAdjust);
nonGameplayAdjustments.Add(UserPlaybackRate);
speedAdjustmentsApplied = true;
}
@ -238,22 +212,16 @@ namespace osu.Game.Screens.Play
if (!speedAdjustmentsApplied)
return;
if (SourceClock is not Track track)
return;
track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
track.UnbindAdjustments(AdjustmentsFromMods);
track.RemoveAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
nonGameplayAdjustments.Remove(pauseFreqAdjust);
nonGameplayAdjustments.Remove(UserPlaybackRate);
speedAdjustmentsApplied = false;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
beatmapOffsetSubscription?.Dispose();
removeSourceClockAdjustments();
}

View File

@ -4,10 +4,12 @@
#nullable disable
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
@ -33,6 +35,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
using osu.Game.Skinning;
using osu.Game.Users;
@ -63,6 +66,8 @@ namespace osu.Game.Screens.Play
public override bool HideOverlaysOnEnter => true;
public override bool HideMenuCursorOnNonMouseInput => true;
protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered;
// We are managing our own adjustments (see OnEntering/OnExiting).
@ -91,6 +96,11 @@ namespace osu.Game.Screens.Play
public int RestartCount;
/// <summary>
/// Whether the <see cref="HUDOverlay"/> is currently visible.
/// </summary>
public IBindable<bool> ShowingOverlayComponents = new Bindable<bool>();
[Resolved]
private ScoreManager scoreManager { get; set; }
@ -374,6 +384,8 @@ namespace osu.Game.Screens.Play
if (Configuration.AutomaticallySkipIntro)
skipIntroOverlay.SkipWhenReady();
loadLeaderboard();
}
protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart);
@ -416,7 +428,7 @@ namespace osu.Game.Screens.Play
// display the cursor above some HUD elements.
DrawableRuleset.Cursor?.CreateProxy() ?? new Container(),
DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(),
HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods)
HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard)
{
HoldToQuit =
{
@ -561,9 +573,6 @@ namespace osu.Game.Screens.Play
/// </param>
protected void PerformExit(bool showDialogFirst)
{
// if an exit has been requested, cancel any pending completion (the user has shown intention to exit).
resultsDisplayDelegate?.Cancel();
// there is a chance that an exit request occurs after the transition to results has already started.
// even in such a case, the user has shown intent, so forcefully return to this screen (to proceed with the upwards exit process).
if (!this.IsCurrentScreen())
@ -598,6 +607,9 @@ namespace osu.Game.Screens.Play
}
}
// if an exit has been requested, cancel any pending completion (the user has shown intention to exit).
resultsDisplayDelegate?.Cancel();
// The actual exit is performed if
// - the pause / fail dialog was not requested
// - the pause / fail dialog was requested but is already displayed (user showing intention to exit).
@ -640,8 +652,7 @@ namespace osu.Game.Screens.Play
bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
DrawableRuleset.FrameStablePlayback = false;
GameplayClockContainer.StartTime = time;
GameplayClockContainer.Reset();
GameplayClockContainer.Reset(time);
// Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
@ -776,19 +787,11 @@ namespace osu.Game.Screens.Play
/// </summary>
/// <remarks>
/// A final display will only occur once all work is completed in <see cref="PrepareScoreForResultsAsync"/>. This means that even after calling this method, the results screen will never be shown until <see cref="JudgementProcessor.HasCompleted">ScoreProcessor.HasCompleted</see> becomes <see langword="true"/>.
///
/// Calling this method multiple times will have no effect.
/// </remarks>
/// <param name="withDelay">Whether a minimum delay (<see cref="RESULTS_DISPLAY_DELAY"/>) should be added before the screen is displayed.</param>
private void progressToResults(bool withDelay)
{
if (resultsDisplayDelegate != null)
// Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be
// accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued
// may take x00 more milliseconds than expected in the very rare edge case).
//
// If required we can handle this more correctly by rescheduling here.
return;
resultsDisplayDelegate?.Cancel();
double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0;
@ -820,6 +823,41 @@ namespace osu.Game.Screens.Play
return mouseWheelDisabled.Value && !e.AltPressed;
}
#region Gameplay leaderboard
protected readonly Bindable<bool> LeaderboardExpandedState = new BindableBool();
private void loadLeaderboard()
{
HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState());
LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true);
var gameplayLeaderboard = CreateGameplayLeaderboard();
if (gameplayLeaderboard != null)
{
LoadComponentAsync(gameplayLeaderboard, leaderboard =>
{
if (!LoadedBeatmapSuccessfully)
return;
leaderboard.Expanded.BindTo(LeaderboardExpandedState);
AddLeaderboardToHUD(leaderboard);
});
}
}
[CanBeNull]
protected virtual GameplayLeaderboard CreateGameplayLeaderboard() => null;
protected virtual void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) => HUDOverlay.LeaderboardFlow.Add(leaderboard);
private void updateLeaderboardExpandedState() =>
LeaderboardExpandedState.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value;
#endregion
#region Fail Logic
protected FailOverlay FailOverlay { get; private set; }
@ -828,9 +866,17 @@ namespace osu.Game.Screens.Play
private bool onFail()
{
// Failing after the quit sequence has started may cause weird side effects with the fail animation / effects.
if (GameplayState.HasQuit)
return false;
if (!CheckModsAllowFailure())
return false;
Debug.Assert(!GameplayState.HasFailed);
Debug.Assert(!GameplayState.HasPassed);
Debug.Assert(!GameplayState.HasQuit);
GameplayState.HasFailed = true;
updateGameplayState();
@ -976,6 +1022,8 @@ namespace osu.Game.Screens.Play
});
HUDOverlay.IsPlaying.BindTo(localUserPlaying);
ShowingOverlayComponents.BindTo(HUDOverlay.ShowHud);
DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime);
DimmableStoryboard.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground);
@ -988,12 +1036,8 @@ namespace osu.Game.Screens.Play
foreach (var mod in GameplayState.Mods.OfType<IApplicableToHUD>())
mod.ApplyToHUD(HUDOverlay);
// Our mods are local copies of the global mods so they need to be re-applied to the track.
// This is done through the music controller (for now), because resetting speed adjustments on the beatmap track also removes adjustments provided by DrawableTrack.
// Todo: In the future, player will receive in a track and will probably not have to worry about this...
musicController.ResetTrackAdjustments();
foreach (var mod in GameplayState.Mods.OfType<IApplicableToTrack>())
mod.ApplyToTrack(musicController.CurrentTrack);
mod.ApplyToTrack(GameplayClockContainer.AdjustmentsFromMods);
updateGameplayState();
@ -1012,7 +1056,7 @@ namespace osu.Game.Screens.Play
if (GameplayClockContainer.IsRunning)
throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running");
GameplayClockContainer.Reset(true);
GameplayClockContainer.Reset(startClock: true);
}
public override void OnSuspending(ScreenTransitionEvent e)
@ -1045,6 +1089,7 @@ namespace osu.Game.Screens.Play
musicController.ResetTrackAdjustments();
fadeOut();
return base.OnExiting(e);
}

View File

@ -36,5 +36,10 @@ namespace osu.Game.Screens.Play
/// Whether the intro should be skipped by default.
/// </summary>
public bool AutomaticallySkipIntro { get; set; }
/// <summary>
/// Whether the gameplay leaderboard should always be shown (usually in a contracted state).
/// </summary>
public bool AlwaysShowLeaderboard { get; set; }
}
}

View File

@ -64,6 +64,8 @@ namespace osu.Game.Screens.Play
protected Task? DisposalTask { get; private set; }
private OsuScrollContainer settingsScroll = null!;
private bool backgroundBrightnessReduction;
private readonly BindableDouble volumeAdjustment = new BindableDouble(1);
@ -71,6 +73,9 @@ namespace osu.Game.Screens.Play
private AudioFilter lowPassFilter = null!;
private AudioFilter highPassFilter = null!;
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
protected bool BackgroundBrightnessReduction
{
set
@ -165,30 +170,30 @@ namespace osu.Game.Screens.Play
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new OsuScrollContainer
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.Y,
Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2,
Padding = new MarginPadding { Vertical = padding },
Masking = false,
Child = PlayerSettings = new FillFlowContainer<PlayerSettingsGroup>
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 20),
Padding = new MarginPadding { Horizontal = padding },
Children = new PlayerSettingsGroup[]
{
VisualSettings = new VisualSettings(),
AudioSettings = new AudioSettings(),
new InputSettings()
}
},
},
idleTracker = new IdleTracker(750),
}),
settingsScroll = new OsuScrollContainer
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.Y,
Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2,
Padding = new MarginPadding { Vertical = padding },
Masking = false,
Child = PlayerSettings = new FillFlowContainer<PlayerSettingsGroup>
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 20),
Padding = new MarginPadding { Horizontal = padding },
Children = new PlayerSettingsGroup[]
{
VisualSettings = new VisualSettings(),
AudioSettings = new AudioSettings(),
new InputSettings()
}
},
},
idleTracker = new IdleTracker(750),
lowPassFilter = new AudioFilter(audio.TrackMixer),
highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass)
};
@ -224,6 +229,9 @@ namespace osu.Game.Screens.Play
Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment);
// Start off-screen.
settingsScroll.MoveToX(settingsScroll.DrawWidth);
content.ScaleTo(0.7f);
contentIn();
@ -313,6 +321,16 @@ namespace osu.Game.Screens.Play
content.StopTracking();
}
protected override void LogoSuspending(OsuLogo logo)
{
base.LogoSuspending(logo);
content.StopTracking();
logo
.FadeOut(CONTENT_OUT_DURATION / 2, Easing.OutQuint)
.ScaleTo(logo.Scale * 0.8f, CONTENT_OUT_DURATION * 2, Easing.OutQuint);
}
#endregion
protected override void Update()
@ -363,7 +381,7 @@ namespace osu.Game.Screens.Play
return;
CurrentPlayer = createPlayer();
CurrentPlayer.Configuration.AutomaticallySkipIntro = quickRestart;
CurrentPlayer.Configuration.AutomaticallySkipIntro |= quickRestart;
CurrentPlayer.RestartCount = restartCount++;
CurrentPlayer.RestartRequested = restartRequested;
@ -391,6 +409,10 @@ namespace osu.Game.Screens.Play
content.FadeInFromZero(400);
content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer);
settingsScroll.FadeInFromZero(500, Easing.Out)
.MoveToX(0, 500, Easing.OutQuint);
lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint);
highPassFilter.CutoffTo(300).Then().CutoffTo(0, 1250); // 1250 is to line up with the appearance of MetadataInfo (750 delay + 500 fade-in)
@ -404,6 +426,10 @@ namespace osu.Game.Screens.Play
content.ScaleTo(0.7f, CONTENT_OUT_DURATION * 2, Easing.OutQuint);
content.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint);
settingsScroll.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint)
.MoveToX(settingsScroll.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint);
lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, CONTENT_OUT_DURATION);
highPassFilter.CutoffTo(0, CONTENT_OUT_DURATION);
}
@ -432,7 +458,7 @@ namespace osu.Game.Screens.Play
ContentOut();
TransformSequence<PlayerLoader> pushSequence = this.Delay(CONTENT_OUT_DURATION);
TransformSequence<PlayerLoader> pushSequence = this.Delay(0);
// only show if the warning was created (i.e. the beatmap needs it)
// and this is not a restart of the map (the warning expires after first load).
@ -441,6 +467,7 @@ namespace osu.Game.Screens.Play
const double epilepsy_display_length = 3000;
pushSequence
.Delay(CONTENT_OUT_DURATION)
.Schedule(() => epilepsyWarning.State.Value = Visibility.Visible)
.TransformBindableTo(volumeAdjustment, 0.25, EpilepsyWarning.FADE_DURATION, Easing.OutQuint)
.Delay(epilepsy_display_length)
@ -502,7 +529,7 @@ namespace osu.Game.Screens.Play
private int restartCount;
private const double volume_requirement = 0.05;
private const double volume_requirement = 0.01;
private void showMuteWarningIfNeeded()
{
@ -530,7 +557,7 @@ namespace osu.Game.Screens.Play
private void load(OsuColour colours, AudioManager audioManager, INotificationOverlay notificationOverlay, VolumeOverlay volumeOverlay)
{
Icon = FontAwesome.Solid.VolumeMute;
IconBackground.Colour = colours.RedDark;
IconContent.Colour = colours.RedDark;
Activated = delegate
{
@ -539,10 +566,11 @@ namespace osu.Game.Screens.Play
volumeOverlay.IsMuted.Value = false;
// Check values before resetting, as the user may have only had mute enabled, in which case we might not need to adjust volumes.
// Note that we only restore halfway to ensure the user isn't suddenly overloaded by unexpectedly high volume.
if (audioManager.Volume.Value <= volume_requirement)
audioManager.Volume.SetDefault();
audioManager.Volume.Value = 0.5f;
if (audioManager.VolumeTrack.Value <= volume_requirement)
audioManager.VolumeTrack.SetDefault();
audioManager.VolumeTrack.Value = 0.5f;
return true;
};
@ -584,7 +612,7 @@ namespace osu.Game.Screens.Play
private void load(OsuColour colours, INotificationOverlay notificationOverlay)
{
Icon = FontAwesome.Solid.BatteryQuarter;
IconBackground.Colour = colours.RedDark;
IconContent.Colour = colours.RedDark;
Activated = delegate
{

View File

@ -31,8 +31,6 @@ namespace osu.Game.Screens.Play.PlayerSettings
public BindableDouble Current { get; } = new BindableDouble
{
Default = 0,
Value = 0,
MinValue = -50,
MaxValue = 50,
Precision = 0.1,

View File

@ -17,7 +17,6 @@ namespace osu.Game.Screens.Play.PlayerSettings
public readonly Bindable<double> UserPlaybackRate = new BindableDouble(1)
{
Default = 1,
MinValue = 0.5,
MaxValue = 2,
Precision = 0.1,

View File

@ -7,6 +7,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
@ -14,6 +15,7 @@ using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
namespace osu.Game.Screens.Play
@ -55,6 +57,15 @@ namespace osu.Game.Screens.Play
// Don't re-import replay scores as they're already present in the database.
protected override Task ImportScore(Score score) => Task.CompletedTask;
public readonly BindableList<ScoreInfo> LeaderboardScores = new BindableList<ScoreInfo>();
protected override GameplayLeaderboard CreateGameplayLeaderboard() =>
new SoloGameplayLeaderboard(Score.ScoreInfo.User)
{
AlwaysVisible = { Value = true },
Scores = { BindTarget = LeaderboardScores }
};
protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false);
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)

View File

@ -63,8 +63,7 @@ namespace osu.Game.Screens.Play
if (player != null)
{
importedScore = realm.Run(r => r.Find<ScoreInfo>(player.Score.ScoreInfo.ID)?.Detach());
if (importedScore != null)
state.Value = DownloadState.LocallyAvailable;
state.Value = importedScore != null ? DownloadState.LocallyAvailable : DownloadState.NotDownloaded;
}
state.BindValueChanged(state =>

View File

@ -114,16 +114,17 @@ namespace osu.Game.Screens.Play
{
base.LoadComplete();
displayTime = gameplayClock.CurrentTime;
// skip is not required if there is no extra "empty" time to skip.
// we may need to remove this if rewinding before the initial player load position becomes a thing.
if (fadeOutBeginTime < gameplayClock.CurrentTime)
if (fadeOutBeginTime <= displayTime)
{
Expire();
return;
}
button.Action = () => RequestSkip?.Invoke();
displayTime = gameplayClock.CurrentTime;
fadeContainer.TriggerShow();
@ -146,7 +147,12 @@ namespace osu.Game.Screens.Play
{
base.Update();
double progress = fadeOutBeginTime <= displayTime ? 1 : Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime));
// This case causes an immediate expire in `LoadComplete`, but `Update` may run once after that.
// Avoid div-by-zero below.
if (fadeOutBeginTime <= displayTime)
return;
double progress = Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime));
remainingTimeBox.Width = (float)Interpolation.Lerp(remainingTimeBox.Width, progress, Math.Clamp(Time.Elapsed / 40, 0, 1));

View File

@ -5,12 +5,15 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Screens.Play
{
@ -40,8 +43,27 @@ namespace osu.Game.Screens.Play
return new CreateSoloScoreRequest(Beatmap.Value.BeatmapInfo, rulesetId, Game.VersionHash);
}
public readonly BindableList<ScoreInfo> LeaderboardScores = new BindableList<ScoreInfo>();
protected override GameplayLeaderboard CreateGameplayLeaderboard() =>
new SoloGameplayLeaderboard(Score.ScoreInfo.User)
{
AlwaysVisible = { Value = false },
Scores = { BindTarget = LeaderboardScores }
};
protected override bool HandleTokenRetrievalFailure(Exception exception) => false;
protected override Task ImportScore(Score score)
{
// Before importing a score, stop binding the leaderboard with its score source.
// This avoids a case where the imported score may cause a leaderboard refresh
// (if the leaderboard's source is local).
LeaderboardScores.UnbindBindings();
return base.ImportScore(score);
}
protected override APIRequest<MultiplayerScore> CreateSubmissionRequest(Score score, long token)
{
IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo;

View File

@ -182,7 +182,7 @@ namespace osu.Game.Screens.Play
scheduleStart(spectatorGameplayState);
}
protected override void EndGameplay(int userId, SpectatorState state)
protected override void QuitGameplay(int userId)
{
scheduledStart?.Cancel();
immediateSpectatorGameplayState = null;

View File

@ -3,6 +3,7 @@
#nullable disable
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Screens;
@ -84,6 +85,7 @@ namespace osu.Game.Screens.Play
foreach (var frame in bundle.Frames)
{
IConvertibleReplayFrame convertibleFrame = GameplayState.Ruleset.CreateConvertibleReplayFrame();
Debug.Assert(convertibleFrame != null);
convertibleFrame.FromLegacy(frame, GameplayState.Beatmap);
var convertedFrame = (ReplayFrame)convertibleFrame;

View File

@ -15,7 +15,6 @@ using osuTK;
using osuTK.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Allocation;
using osu.Framework.Layout;
using osu.Framework.Threading;
namespace osu.Game.Screens.Play
@ -24,11 +23,6 @@ namespace osu.Game.Screens.Play
{
private BufferedContainer<Column> columns;
public SquareGraph()
{
AddLayout(layout);
}
public int ColumnCount => columns?.Children.Count ?? 0;
private int progress;
@ -57,7 +51,7 @@ namespace osu.Game.Screens.Play
if (value == values) return;
values = value;
layout.Invalidate();
graphNeedsUpdate = true;
}
}
@ -75,21 +69,25 @@ namespace osu.Game.Screens.Play
}
}
private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize);
private ScheduledDelegate scheduledCreate;
private bool graphNeedsUpdate;
private Vector2 previousDrawSize;
protected override void Update()
{
base.Update();
if (values != null && !layout.IsValid)
if (graphNeedsUpdate || (values != null && DrawSize != previousDrawSize))
{
columns?.FadeOut(500, Easing.OutQuint).Expire();
scheduledCreate?.Cancel();
scheduledCreate = Scheduler.AddDelayed(RecreateGraph, 500);
layout.Validate();
previousDrawSize = DrawSize;
graphNeedsUpdate = false;
}
}

View File

@ -76,6 +76,7 @@ namespace osu.Game.Screens.Play
req.Success += r =>
{
Logger.Log($"Score submission token retrieved ({r.ID})");
token = r.ID;
tcs.SetResult(true);
};
@ -85,12 +86,14 @@ namespace osu.Game.Screens.Play
// Generally a timeout would not happen here as APIAccess will timeout first.
if (!tcs.Task.Wait(60000))
handleTokenFailure(new InvalidOperationException("Token retrieval timed out (request never run)"));
req.TriggerFailure(new InvalidOperationException("Token retrieval timed out (request never run)"));
return true;
void handleTokenFailure(Exception exception)
{
tcs.SetResult(false);
if (HandleTokenRetrievalFailure(exception))
{
if (string.IsNullOrEmpty(exception.Message))
@ -104,8 +107,12 @@ namespace osu.Game.Screens.Play
this.Exit();
});
}
tcs.SetResult(false);
else
{
// Gameplay is allowed to continue, but we still should keep track of the error.
// In the future, this should be visible to the user in some way.
Logger.Log($"Score submission token retrieval failed ({exception.Message})");
}
}
}

View File

@ -7,12 +7,14 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
@ -67,12 +69,10 @@ namespace osu.Game.Screens.Ranking.Expanded
var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata;
string creator = metadata.Author.Username;
int? beatmapMaxCombo = scoreManager.GetMaximumAchievableComboAsync(score).GetResultSafely();
var topStatistics = new List<StatisticDisplay>
{
new AccuracyStatistic(score.Accuracy),
new ComboStatistic(score.MaxCombo, beatmapMaxCombo),
new ComboStatistic(score.MaxCombo, scoreManager.GetMaximumAchievableCombo(score)),
new PerformanceStatistic(score),
};
@ -282,12 +282,34 @@ namespace osu.Game.Screens.Ranking.Expanded
public class PlayedOnText : OsuSpriteText
{
private readonly DateTimeOffset time;
private readonly Bindable<bool> prefer24HourTime = new Bindable<bool>();
public PlayedOnText(DateTimeOffset time)
{
this.time = time;
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold);
Text = $"Played on {time.ToLocalTime():d MMMM yyyy HH:mm}";
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager configManager)
{
configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime);
}
protected override void LoadComplete()
{
base.LoadComplete();
prefer24HourTime.BindValueChanged(_ => updateDisplay(), true);
}
private void updateDisplay()
{
Text = prefer24HourTime.Value ? $"Played on {time.ToLocalTime():d MMMM yyyy HH:mm}" : $"Played on {time.ToLocalTime():d MMMM yyyy h:mm tt}";
}
}
}

View File

@ -317,7 +317,7 @@ namespace osu.Game.Screens.Ranking
var screenSpacePos = detachedPanel.ScreenSpaceDrawQuad.TopLeft;
// Remove from the local container and re-attach.
detachedPanelContainer.Remove(detachedPanel);
detachedPanelContainer.Remove(detachedPanel, false);
ScorePanelList.Attach(detachedPanel);
// Move into its original location in the attached container first, then to the final location.

View File

@ -99,7 +99,7 @@ namespace osu.Game.Screens.Ranking
[Resolved]
private OsuGameBase game { get; set; }
private DrawableAudioMixer mixer;
private AudioContainer audioContent;
private bool displayWithFlair;
@ -130,7 +130,7 @@ namespace osu.Game.Screens.Ranking
// Adding a manual offset here allows the expanded version to take on an "acceptable" vertical centre when at 100% UI scale.
const float vertical_fudge = 20;
InternalChild = mixer = new DrawableAudioMixer
InternalChild = audioContent = new AudioContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -225,7 +225,7 @@ namespace osu.Game.Screens.Ranking
protected override void Update()
{
base.Update();
mixer.Balance.Value = (ScreenSpaceDrawQuad.Centre.X / game.ScreenSpaceDrawQuad.Width) * 2 - 1;
audioContent.Balance.Value = (ScreenSpaceDrawQuad.Centre.X / game.ScreenSpaceDrawQuad.Width) * 2 - 1;
}
private void playAppearSample()
@ -274,7 +274,7 @@ namespace osu.Game.Screens.Ranking
break;
}
mixer.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint);
audioContent.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint);
bool topLayerExpanded = topLayerContainer.Y < 0;

View File

@ -8,11 +8,9 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
@ -151,32 +149,27 @@ namespace osu.Game.Screens.Ranking
var score = trackingContainer.Panel.Score;
// Calculating score can take a while in extreme scenarios, so only display scores after the process completes.
scoreManager.GetTotalScoreAsync(score)
.ContinueWith(task => Schedule(() =>
{
flow.SetLayoutPosition(trackingContainer, task.GetResultSafely());
flow.SetLayoutPosition(trackingContainer, scoreManager.GetTotalScore(score));
trackingContainer.Show();
trackingContainer.Show();
if (SelectedScore.Value?.Equals(score) == true)
{
SelectedScore.TriggerChange();
}
else
{
// We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done.
// But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel.
if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score))
{
// A somewhat hacky property is used here because we need to:
// 1) Scroll after the scroll container's visible range is updated.
// 2) Scroll before the scroll container's scroll position is updated.
// Without this, we would have a 1-frame positioning error which looks very jarring.
scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing;
}
}
}), TaskContinuationOptions.OnlyOnRanToCompletion);
if (SelectedScore.Value?.Equals(score) == true)
{
SelectedScore.TriggerChange();
}
else
{
// We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done.
// But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel.
if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score))
{
// A somewhat hacky property is used here because we need to:
// 1) Scroll after the scroll container's visible range is updated.
// 2) Scroll before the scroll container's scroll position is updated.
// Without this, we would have a 1-frame positioning error which looks very jarring.
scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing;
}
}
}
/// <summary>

View File

@ -34,7 +34,7 @@ namespace osu.Game.Screens.Ranking
if (InternalChildren.Count == 0)
throw new InvalidOperationException("Score panel container is not attached.");
RemoveInternal(Panel);
RemoveInternal(Panel, false);
}
/// <summary>

View File

@ -1,13 +1,10 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -48,6 +45,12 @@ namespace osu.Game.Screens.Ranking.Statistics
/// </summary>
private readonly IReadOnlyList<HitEvent> hitEvents;
private readonly IDictionary<HitResult, int>[] bins;
private double binSize;
private double hitOffset;
private Bar[]? barDrawables;
/// <summary>
/// Creates a new <see cref="HitEventTimingDistributionGraph"/>.
/// </summary>
@ -55,22 +58,15 @@ namespace osu.Game.Screens.Ranking.Statistics
public HitEventTimingDistributionGraph(IReadOnlyList<HitEvent> hitEvents)
{
this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList();
bins = Enumerable.Range(0, total_timing_distribution_bins).Select(_ => new Dictionary<HitResult, int>()).ToArray<IDictionary<HitResult, int>>();
}
private int[] bins;
private double binSize;
private double hitOffset;
private Bar[] barDrawables;
[BackgroundDependencyLoader]
private void load()
{
if (hitEvents == null || hitEvents.Count == 0)
if (hitEvents.Count == 0)
return;
bins = new int[total_timing_distribution_bins];
binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins);
// Prevent div-by-0 by enforcing a minimum bin size
@ -89,7 +85,8 @@ namespace osu.Game.Screens.Ranking.Statistics
{
bool roundUp = true;
Array.Clear(bins, 0, bins.Length);
foreach (var bin in bins)
bin.Clear();
foreach (var e in hitEvents)
{
@ -110,23 +107,23 @@ namespace osu.Game.Screens.Ranking.Statistics
// may be out of range when applying an offset. for such cases we can just drop the results.
if (index >= 0 && index < bins.Length)
bins[index]++;
{
bins[index].TryGetValue(e.Result, out int value);
bins[index][e.Result] = ++value;
}
}
if (barDrawables != null)
{
for (int i = 0; i < barDrawables.Length; i++)
{
barDrawables[i].UpdateOffset(bins[i]);
barDrawables[i].UpdateOffset(bins[i].Sum(b => b.Value));
}
}
else
{
int maxCount = bins.Max();
barDrawables = new Bar[total_timing_distribution_bins];
for (int i = 0; i < barDrawables.Length; i++)
barDrawables[i] = new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index);
int maxCount = bins.Max(b => b.Values.Sum());
barDrawables = bins.Select((bin, i) => new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index)).ToArray();
Container axisFlow;
@ -209,50 +206,102 @@ namespace osu.Game.Screens.Ranking.Statistics
private class Bar : CompositeDrawable
{
private readonly float value;
private readonly IReadOnlyList<KeyValuePair<HitResult, int>> values;
private readonly float maxValue;
private readonly bool isCentre;
private readonly float totalValue;
private readonly Circle boxOriginal;
private Circle boxAdjustment;
private float basalHeight;
private float offsetAdjustment;
private const float minimum_height = 0.05f;
private Circle[] boxOriginals = null!;
public Bar(float value, float maxValue, bool isCentre)
private Circle? boxAdjustment;
[Resolved]
private OsuColour colours { get; set; } = null!;
private const double duration = 300;
public Bar(IDictionary<HitResult, int> values, float maxValue, bool isCentre)
{
this.value = value;
this.values = values.OrderBy(v => v.Key.GetIndexForOrderedDisplay()).ToList();
this.maxValue = maxValue;
this.isCentre = isCentre;
totalValue = values.Sum(v => v.Value);
offsetAdjustment = totalValue;
RelativeSizeAxes = Axes.Both;
Masking = true;
}
InternalChildren = new Drawable[]
[BackgroundDependencyLoader]
private void load()
{
if (values.Any())
{
boxOriginal = new Circle
boxOriginals = values.Select((v, i) => new Circle
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Colour = isCentre ? Color4.White : Color4Extensions.FromHex("#66FFCC"),
Height = minimum_height,
},
};
Colour = isCentre && i == 0 ? Color4.White : colours.ForHitResult(v.Key),
Height = 0,
}).ToArray();
// The bars of the stacked bar graph will be processed (stacked) from the bottom, which is the base position,
// to the top, and the bottom bar should be drawn more toward the front by design,
// while the drawing order is from the back to the front, so the order passed to `InternalChildren` is the opposite.
InternalChildren = boxOriginals.Reverse().ToArray();
}
else
{
// A bin with no value draws a grey dot instead.
Circle dot = new Circle
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Colour = isCentre ? Color4.White : Color4.Gray,
Height = 0,
};
InternalChildren = boxOriginals = new[] { dot };
}
}
private const double duration = 300;
protected override void LoadComplete()
{
base.LoadComplete();
float height = Math.Clamp(value / maxValue, minimum_height, 1);
if (!values.Any())
return;
if (height > minimum_height)
boxOriginal.ResizeHeightTo(height, duration, Easing.OutQuint);
updateBasalHeight();
foreach (var boxOriginal in boxOriginals)
{
boxOriginal.Y = 0;
boxOriginal.Height = basalHeight;
}
float offsetValue = 0;
for (int i = 0; i < values.Count; i++)
{
boxOriginals[i].MoveToY(offsetForValue(offsetValue) * BoundingBox.Height, duration, Easing.OutQuint);
boxOriginals[i].ResizeHeightTo(heightForValue(values[i].Value), duration, Easing.OutQuint);
offsetValue -= values[i].Value;
}
}
protected override void Update()
{
base.Update();
updateBasalHeight();
}
public void UpdateOffset(float adjustment)
{
bool hasAdjustment = adjustment != value && adjustment / maxValue >= minimum_height;
bool hasAdjustment = adjustment != totalValue;
if (boxAdjustment == null)
{
@ -271,7 +320,53 @@ namespace osu.Game.Screens.Ranking.Statistics
});
}
boxAdjustment.ResizeHeightTo(Math.Clamp(adjustment / maxValue, minimum_height, 1), duration, Easing.OutQuint);
offsetAdjustment = adjustment;
drawAdjustmentBar();
}
private void updateBasalHeight()
{
float newBasalHeight = DrawHeight > DrawWidth ? DrawWidth / DrawHeight : 1;
if (newBasalHeight == basalHeight)
return;
basalHeight = newBasalHeight;
foreach (var dot in boxOriginals)
dot.Height = basalHeight;
draw();
}
private float offsetForValue(float value) => (1 - basalHeight) * value / maxValue;
private float heightForValue(float value) => MathF.Max(basalHeight + offsetForValue(value), 0);
private void draw()
{
resizeBars();
if (boxAdjustment != null)
drawAdjustmentBar();
}
private void resizeBars()
{
float offsetValue = 0;
for (int i = 0; i < values.Count; i++)
{
boxOriginals[i].Y = offsetForValue(offsetValue) * DrawHeight;
boxOriginals[i].Height = heightForValue(values[i].Value);
offsetValue -= values[i].Value;
}
}
private void drawAdjustmentBar()
{
bool hasAdjustment = offsetAdjustment != totalValue;
boxAdjustment.ResizeHeightTo(heightForValue(offsetAdjustment), duration, Easing.OutQuint);
boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint);
}
}

View File

@ -1,8 +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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
@ -49,31 +47,31 @@ namespace osu.Game.Screens.Select
/// <summary>
/// Triggered when the <see cref="BeatmapSets"/> loaded change and are completely loaded.
/// </summary>
public Action BeatmapSetsChanged;
public Action? BeatmapSetsChanged;
/// <summary>
/// The currently selected beatmap.
/// </summary>
public BeatmapInfo SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo;
public BeatmapInfo? SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo;
private CarouselBeatmap selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected);
private CarouselBeatmap? selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected);
/// <summary>
/// The currently selected beatmap set.
/// </summary>
public BeatmapSetInfo SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet;
public BeatmapSetInfo? SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet;
/// <summary>
/// A function to optionally decide on a recommended difficulty from a beatmap set.
/// </summary>
public Func<IEnumerable<BeatmapInfo>, BeatmapInfo> GetRecommendedBeatmap;
public Func<IEnumerable<BeatmapInfo>, BeatmapInfo>? GetRecommendedBeatmap;
private CarouselBeatmapSet selectedBeatmapSet;
private CarouselBeatmapSet? selectedBeatmapSet;
/// <summary>
/// Raised when the <see cref="SelectedBeatmapInfo"/> is changed.
/// </summary>
public Action<BeatmapInfo> SelectionChanged;
public Action<BeatmapInfo?>? SelectionChanged;
public override bool HandleNonPositionalInput => AllowSelection;
public override bool HandlePositionalInput => AllowSelection;
@ -151,15 +149,15 @@ namespace osu.Game.Screens.Select
private CarouselRoot root;
private IDisposable subscriptionSets;
private IDisposable subscriptionDeletedSets;
private IDisposable subscriptionBeatmaps;
private IDisposable subscriptionHiddenBeatmaps;
private IDisposable? subscriptionSets;
private IDisposable? subscriptionDeletedSets;
private IDisposable? subscriptionBeatmaps;
private IDisposable? subscriptionHiddenBeatmaps;
private readonly DrawablePool<DrawableCarouselBeatmapSet> setPool = new DrawablePool<DrawableCarouselBeatmapSet>(100);
private Sample spinSample;
private Sample randomSelectSample;
private Sample? spinSample;
private Sample? randomSelectSample;
private int visibleSetsCount;
@ -200,7 +198,7 @@ namespace osu.Game.Screens.Select
}
[Resolved]
private RealmAccess realm { get; set; }
private RealmAccess realm { get; set; } = null!;
protected override void LoadComplete()
{
@ -215,7 +213,7 @@ namespace osu.Game.Screens.Select
subscriptionHiddenBeatmaps = realm.RegisterForNotifications(r => r.All<BeatmapInfo>().Where(b => b.Hidden), beatmapsChanged);
}
private void deletedBeatmapSetsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error)
private void deletedBeatmapSetsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception? error)
{
// If loading test beatmaps, avoid overwriting with realm subscription callbacks.
if (loadedTestBeatmaps)
@ -228,7 +226,7 @@ namespace osu.Game.Screens.Select
removeBeatmapSet(sender[i].ID);
}
private void beatmapSetsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error)
private void beatmapSetsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception? error)
{
// If loading test beatmaps, avoid overwriting with realm subscription callbacks.
if (loadedTestBeatmaps)
@ -265,9 +263,49 @@ namespace osu.Game.Screens.Select
foreach (int i in changes.InsertedIndices)
UpdateBeatmapSet(sender[i].Detach());
if (changes.DeletedIndices.Length > 0 && SelectedBeatmapInfo != null)
{
// If SelectedBeatmapInfo is non-null, the set should also be non-null.
Debug.Assert(SelectedBeatmapSet != null);
// To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions.
// When an update occurs, the previous beatmap set is either soft or hard deleted.
// Check if the current selection was potentially deleted by re-querying its validity.
bool selectedSetMarkedDeleted = realm.Run(r => r.Find<BeatmapSetInfo>(SelectedBeatmapSet.ID))?.DeletePending != false;
int[] modifiedAndInserted = changes.NewModifiedIndices.Concat(changes.InsertedIndices).ToArray();
if (selectedSetMarkedDeleted && modifiedAndInserted.Any())
{
// If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices.
// This relies on the full update operation being in a single transaction, so please don't change that.
foreach (int i in modifiedAndInserted)
{
var beatmapSetInfo = sender[i];
foreach (var beatmapInfo in beatmapSetInfo.Beatmaps)
{
if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata))
continue;
// Best effort matching. We can't use ID because in the update flow a new version will get its own GUID.
if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName)
{
SelectBeatmap(beatmapInfo);
return;
}
}
}
// If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed.
// Let's attempt to follow set-level selection anyway.
SelectBeatmap(sender[modifiedAndInserted.First()].Beatmaps.First());
}
}
}
private void beatmapsChanged(IRealmCollection<BeatmapInfo> sender, ChangeSet changes, Exception error)
private void beatmapsChanged(IRealmCollection<BeatmapInfo> sender, ChangeSet? changes, Exception? error)
{
// we only care about actual changes in hidden status.
if (changes == null)
@ -330,7 +368,7 @@ namespace osu.Game.Screens.Select
// check if we can/need to maintain our current selection.
if (previouslySelectedID != null)
select((CarouselItem)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet);
select((CarouselItem?)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet);
}
itemsCache.Invalidate();
@ -347,7 +385,7 @@ namespace osu.Game.Screens.Select
/// <param name="beatmapInfo">The beatmap to select.</param>
/// <param name="bypassFilters">Whether to select the beatmap even if it is filtered (i.e., not visible on carousel).</param>
/// <returns>True if a selection was made, False if it wasn't.</returns>
public bool SelectBeatmap(BeatmapInfo beatmapInfo, bool bypassFilters = true)
public bool SelectBeatmap(BeatmapInfo? beatmapInfo, bool bypassFilters = true)
{
// ensure that any pending events from BeatmapManager have been run before attempting a selection.
Scheduler.Update();
@ -405,6 +443,9 @@ namespace osu.Game.Screens.Select
private void selectNextSet(int direction, bool skipDifficulties)
{
if (selectedBeatmap == null || selectedBeatmapSet == null)
return;
var unfilteredSets = beatmapSets.Where(s => !s.Filtered.Value).ToList();
var nextSet = unfilteredSets[(unfilteredSets.IndexOf(selectedBeatmapSet) + direction + unfilteredSets.Count) % unfilteredSets.Count];
@ -417,7 +458,7 @@ namespace osu.Game.Screens.Select
private void selectNextDifficulty(int direction)
{
if (selectedBeatmap == null)
if (selectedBeatmap == null || selectedBeatmapSet == null)
return;
var unfilteredDifficulties = selectedBeatmapSet.Items.Where(s => !s.Filtered.Value).ToList();
@ -446,7 +487,7 @@ namespace osu.Game.Screens.Select
if (!visibleSets.Any())
return false;
if (selectedBeatmap != null)
if (selectedBeatmap != null && selectedBeatmapSet != null)
{
randomSelectedBeatmaps.Push(selectedBeatmap);
@ -489,11 +530,13 @@ namespace osu.Game.Screens.Select
if (!beatmap.Filtered.Value)
{
if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation)
previouslyVisitedRandomSets.Remove(selectedBeatmapSet);
if (selectedBeatmapSet != null)
{
if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation)
previouslyVisitedRandomSets.Remove(selectedBeatmapSet);
playSpinSample(distanceBetween(beatmap, selectedBeatmapSet));
}
select(beatmap);
break;
@ -505,14 +548,18 @@ namespace osu.Game.Screens.Select
private void playSpinSample(double distance)
{
var chan = spinSample.GetChannel();
chan.Frequency.Value = 1f + Math.Min(1f, distance / visibleSetsCount);
chan.Play();
var chan = spinSample?.GetChannel();
if (chan != null)
{
chan.Frequency.Value = 1f + Math.Min(1f, distance / visibleSetsCount);
chan.Play();
}
randomSelectSample?.Play();
}
private void select(CarouselItem item)
private void select(CarouselItem? item)
{
if (!AllowSelection)
return;
@ -524,7 +571,7 @@ namespace osu.Game.Screens.Select
private FilterCriteria activeCriteria = new FilterCriteria();
protected ScheduledDelegate PendingFilter;
protected ScheduledDelegate? PendingFilter;
public bool AllowSelection = true;
@ -556,7 +603,7 @@ namespace osu.Game.Screens.Select
}
}
public void Filter(FilterCriteria newCriteria, bool debounce = true)
public void Filter(FilterCriteria? newCriteria, bool debounce = true)
{
if (newCriteria != null)
activeCriteria = newCriteria;
@ -759,7 +806,7 @@ namespace osu.Game.Screens.Select
return (firstIndex, lastIndex);
}
private CarouselBeatmapSet createCarouselSet(BeatmapSetInfo beatmapSet)
private CarouselBeatmapSet? createCarouselSet(BeatmapSetInfo beatmapSet)
{
// This can be moved to the realm query if required using:
// .Filter("DeletePending == false && Protected == false && ANY Beatmaps.Hidden == false")
@ -925,7 +972,7 @@ namespace osu.Game.Screens.Select
/// </summary>
/// <param name="item">The item to be updated.</param>
/// <param name="parent">For nested items, the parent of the item to be updated.</param>
private void updateItem(DrawableCarouselItem item, DrawableCarouselItem parent = null)
private void updateItem(DrawableCarouselItem item, DrawableCarouselItem? parent = null)
{
Vector2 posInScroll = Scroll.ScrollContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre);
float itemDrawY = posInScroll.Y - visibleUpperBound;
@ -953,13 +1000,13 @@ namespace osu.Game.Screens.Select
/// </summary>
private class CarouselBoundsItem : CarouselItem
{
public override DrawableCarouselItem CreateDrawableRepresentation() =>
throw new NotImplementedException();
public override DrawableCarouselItem CreateDrawableRepresentation() => throw new NotImplementedException();
}
private class CarouselRoot : CarouselGroupEagerSelect
{
private readonly BeatmapCarousel carousel;
// May only be null during construction (State.Value set causes PerformSelection to be triggered).
private readonly BeatmapCarousel? carousel;
public readonly Dictionary<Guid, CarouselBeatmapSet> BeatmapSetsByID = new Dictionary<Guid, CarouselBeatmapSet>();
@ -980,7 +1027,7 @@ namespace osu.Game.Screens.Select
base.AddItem(i);
}
public CarouselBeatmapSet RemoveChild(Guid beatmapSetID)
public CarouselBeatmapSet? RemoveChild(Guid beatmapSetID)
{
if (BeatmapSetsByID.TryGetValue(beatmapSetID, out var carouselBeatmapSet))
{

Some files were not shown because too many files have changed in this diff Show More