Merge branch 'master' into fix-multiplayer-unobserved

This commit is contained in:
Dean Herbert
2022-04-06 11:33:10 +09:00
committed by GitHub
68 changed files with 1816 additions and 602 deletions

View File

@ -5,6 +5,7 @@ using System.Collections.Generic;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Beatmaps
{
@ -70,4 +71,27 @@ namespace osu.Game.Beatmaps
/// </summary>
new IReadOnlyList<T> HitObjects { get; }
}
public static class BeatmapExtensions
{
/// <summary>
/// Finds the maximum achievable combo by hitting all <see cref="HitObject"/>s in a beatmap.
/// </summary>
public static int GetMaxCombo(this IBeatmap beatmap)
{
int combo = 0;
foreach (var h in beatmap.HitObjects)
addCombo(h, ref combo);
return combo;
static void addCombo(HitObject hitObject, ref int combo)
{
if (hitObject.CreateJudgement().MaxResult.AffectsCombo())
combo++;
foreach (var nested in hitObject.NestedHitObjects)
addCombo(nested, ref combo);
}
}
}
}

View File

@ -10,6 +10,7 @@ using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
@ -44,6 +45,8 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f);
SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal);
SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full);
// Online settings
@ -297,6 +300,7 @@ namespace osu.Game.Configuration
RandomSelectAlgorithm,
ShowFpsDisplay,
ChatDisplayHeight,
BeatmapListingCardSize,
ToolbarClockDisplayMode,
Version,
ShowConvertedBeatmaps,

View File

@ -88,6 +88,7 @@ namespace osu.Game.Configuration
throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})");
var control = (Drawable)Activator.CreateInstance(controlType);
controlType.GetProperty(nameof(SettingsItem<object>.SettingSourceObject))?.SetValue(control, obj);
controlType.GetProperty(nameof(SettingsItem<object>.LabelText))?.SetValue(control, attr.Label);
controlType.GetProperty(nameof(SettingsItem<object>.TooltipText))?.SetValue(control, attr.Description);
controlType.GetProperty(nameof(SettingsItem<object>.Current))?.SetValue(control, value);

View File

@ -0,0 +1,177 @@
// 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 enable
using System;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Graphics.UserInterface
{
public class ShearedToggleButton : OsuClickableContainer
{
public BindableBool Active { get; } = new BindableBool();
public LocalisableString Text
{
get => text.Text;
set => text.Text = value;
}
private readonly Box background;
private readonly OsuSpriteText text;
private Sample? sampleOff;
private Sample? sampleOn;
private const float shear = 0.2f;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
/// <summary>
/// Creates a new <see cref="ShearedToggleButton"/>
/// </summary>
/// <param name="width">
/// The width of the button.
/// <list type="bullet">
/// <item>If a non-<see langword="null"/> value is provided, this button will have a fixed width equal to the provided value.</item>
/// <item>If a <see langword="null"/> value is provided (or the argument is omitted entirely), the button will autosize in width to fit the text.</item>
/// </list>
/// </param>
public ShearedToggleButton(float? width = null)
{
Height = 50;
Padding = new MarginPadding { Horizontal = shear * 50 };
Content.CornerRadius = 7;
Content.Shear = new Vector2(shear, 0);
Content.Masking = true;
Content.BorderThickness = 2;
Content.Anchor = Content.Origin = Anchor.Centre;
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both
},
text = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.TorusAlternate.With(size: 17),
Shear = new Vector2(-shear, 0)
}
};
if (width != null)
{
Width = width.Value;
}
else
{
AutoSizeAxes = Axes.X;
text.Margin = new MarginPadding { Horizontal = 15 };
}
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sampleOn = audio.Samples.Get(@"UI/check-on");
sampleOff = audio.Samples.Get(@"UI/check-off");
}
protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet);
protected override void LoadComplete()
{
base.LoadComplete();
Active.BindValueChanged(_ =>
{
updateState();
playSample();
});
Active.BindDisabledChanged(disabled =>
{
updateState();
Action = disabled ? (Action?)null : Active.Toggle;
}, true);
FinishTransforms(true);
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
base.OnHoverLost(e);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
Content.ScaleTo(0.8f, 2000, Easing.OutQuint);
return base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseUpEvent e)
{
Content.ScaleTo(1, 1000, Easing.OutElastic);
base.OnMouseUp(e);
}
private void updateState()
{
var darkerColour = Active.Value ? colourProvider.Highlight1 : colourProvider.Background3;
var lighterColour = Active.Value ? colourProvider.Colour0 : colourProvider.Background1;
if (Active.Disabled)
{
darkerColour = darkerColour.Darken(0.3f);
lighterColour = lighterColour.Darken(0.3f);
}
else if (IsHovered)
{
darkerColour = darkerColour.Lighten(0.3f);
lighterColour = lighterColour.Lighten(0.3f);
}
background.FadeColour(darkerColour, 150, Easing.OutQuint);
Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(darkerColour, lighterColour), 150, Easing.OutQuint);
var textColour = Active.Value ? colourProvider.Background6 : colourProvider.Content1;
if (Active.Disabled)
textColour = textColour.Opacity(0.6f);
text.FadeColour(textColour, 150, Easing.OutQuint);
}
private void playSample()
{
if (Active.Value)
sampleOn?.Play();
else
sampleOff?.Play();
}
}
}

View File

@ -11,6 +11,7 @@ using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Utils;
namespace osu.Game.Online.Rooms
{
@ -84,6 +85,19 @@ namespace osu.Game.Online.Rooms
Beatmap = beatmap;
}
public PlaylistItem(MultiplayerPlaylistItem item)
: this(new APIBeatmap { OnlineID = item.BeatmapID })
{
ID = item.ID;
OwnerID = item.OwnerID;
RulesetID = item.RulesetID;
Expired = item.Expired;
PlaylistOrder = item.PlaylistOrder;
PlayedAt = item.PlayedAt;
RequiredMods = item.RequiredMods.ToArray();
AllowedMods = item.AllowedMods.ToArray();
}
public void MarkInvalid() => valid.Value = false;
#region Newtonsoft.Json implicit ShouldSerialize() methods
@ -101,13 +115,13 @@ namespace osu.Game.Online.Rooms
#endregion
public PlaylistItem With(IBeatmapInfo beatmap) => new PlaylistItem(beatmap)
public PlaylistItem With(Optional<IBeatmapInfo> beatmap = default, Optional<ushort?> playlistOrder = default) => new PlaylistItem(beatmap.GetOr(Beatmap))
{
ID = ID,
OwnerID = OwnerID,
RulesetID = RulesetID,
Expired = Expired,
PlaylistOrder = PlaylistOrder,
PlaylistOrder = playlistOrder.GetOr(PlaylistOrder),
PlayedAt = PlayedAt,
AllowedMods = AllowedMods,
RequiredMods = RequiredMods,
@ -119,6 +133,7 @@ namespace osu.Game.Online.Rooms
&& Beatmap.OnlineID == other.Beatmap.OnlineID
&& RulesetID == other.RulesetID
&& Expired == other.Expired
&& PlaylistOrder == other.PlaylistOrder
&& AllowedMods.SequenceEqual(other.AllowedMods)
&& RequiredMods.SequenceEqual(other.RequiredMods);
}

View File

@ -1061,6 +1061,12 @@ namespace osu.Game
return true;
case GlobalAction.RandomSkin:
// Don't allow random skin selection while in the skin editor.
// This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path.
// If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow.
if (skinEditor.State.Value == Visibility.Visible)
return false;
SkinManager.SelectRandomSkin();
return true;
}

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Configuration;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
@ -53,7 +54,9 @@ namespace osu.Game.Overlays.BeatmapListing
/// <summary>
/// The currently selected <see cref="BeatmapCardSize"/>.
/// </summary>
public IBindable<BeatmapCardSize> CardSize { get; } = new Bindable<BeatmapCardSize>();
public IBindable<BeatmapCardSize> CardSize => cardSize;
private readonly Bindable<BeatmapCardSize> cardSize = new Bindable<BeatmapCardSize>();
private readonly BeatmapListingSearchControl searchControl;
private readonly BeatmapListingSortTabControl sortControl;
@ -128,6 +131,9 @@ namespace osu.Game.Overlays.BeatmapListing
};
}
[Resolved]
private OsuConfigManager config { get; set; }
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, IAPIProvider api)
{
@ -141,6 +147,8 @@ namespace osu.Game.Overlays.BeatmapListing
{
base.LoadComplete();
config.BindWith(OsuSetting.BeatmapListingCardSize, cardSize);
var sortCriteria = sortControl.Current;
var sortDirection = sortControl.SortDirection;

View File

@ -5,6 +5,8 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@ -19,7 +21,7 @@ namespace osu.Game.Overlays.BeatmapSet
protected readonly FailRetryGraph Graph;
private readonly FillFlowContainer header;
private readonly OsuSpriteText successPercent;
private readonly SuccessRatePercentage successPercent;
private readonly Bar successRate;
private readonly Container percentContainer;
@ -45,6 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet
float rate = playCount != 0 ? (float)passCount / playCount : 0;
successPercent.Text = rate.ToLocalisableString(@"0.#%");
successPercent.TooltipText = $"{passCount} / {playCount}";
successRate.Length = rate;
percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic);
@ -80,7 +83,7 @@ namespace osu.Game.Overlays.BeatmapSet
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Width = 0f,
Child = successPercent = new OsuSpriteText
Child = successPercent = new SuccessRatePercentage
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopCentre,
@ -121,5 +124,10 @@ namespace osu.Game.Overlays.BeatmapSet
Graph.Padding = new MarginPadding { Top = header.DrawHeight };
}
private class SuccessRatePercentage : OsuSpriteText, IHasTooltip
{
public LocalisableString TooltipText { get; set; }
}
}
}

View File

@ -0,0 +1,163 @@
// 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 enable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.Containers;
using osu.Game.Online.Chat;
using osuTK;
namespace osu.Game.Overlays.Chat
{
public class ChatTextBar : Container
{
public readonly BindableBool ShowSearch = new BindableBool();
public event Action<string>? OnChatMessageCommitted;
public event Action<string>? OnSearchTermsChanged;
[Resolved]
private Bindable<Channel> currentChannel { get; set; } = null!;
private OsuTextFlowContainer chattingTextContainer = null!;
private Container searchIconContainer = null!;
private ChatTextBox chatTextBox = null!;
private const float chatting_text_width = 180;
private const float search_icon_width = 40;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
RelativeSizeAxes = Axes.X;
Height = 60;
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5,
},
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(),
},
Content = new[]
{
new Drawable[]
{
chattingTextContainer = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 20))
{
Masking = true,
Width = chatting_text_width,
Padding = new MarginPadding { Left = 10 },
RelativeSizeAxes = Axes.Y,
TextAnchor = Anchor.CentreRight,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Colour = colourProvider.Background1,
},
searchIconContainer = new Container
{
RelativeSizeAxes = Axes.Y,
Width = search_icon_width,
Child = new SpriteIcon
{
Icon = FontAwesome.Solid.Search,
Origin = Anchor.CentreRight,
Anchor = Anchor.CentreRight,
Size = new Vector2(20),
Margin = new MarginPadding { Right = 2 },
},
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = 5 },
Child = chatTextBox = new ChatTextBox
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.X,
ShowSearch = { BindTarget = ShowSearch },
HoldFocus = true,
ReleaseFocusOnCommit = false,
},
},
},
},
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
chatTextBox.Current.ValueChanged += chatTextBoxChange;
chatTextBox.OnCommit += chatTextBoxCommit;
ShowSearch.BindValueChanged(change =>
{
bool showSearch = change.NewValue;
chattingTextContainer.FadeTo(showSearch ? 0 : 1);
searchIconContainer.FadeTo(showSearch ? 1 : 0);
// Clear search terms if any exist when switching back to chat mode
if (!showSearch)
OnSearchTermsChanged?.Invoke(string.Empty);
}, true);
currentChannel.BindValueChanged(change =>
{
Channel newChannel = change.NewValue;
switch (newChannel?.Type)
{
case ChannelType.Public:
chattingTextContainer.Text = $"chatting in {newChannel.Name}";
break;
case ChannelType.PM:
chattingTextContainer.Text = $"chatting with {newChannel.Name}";
break;
default:
chattingTextContainer.Text = string.Empty;
break;
}
}, true);
}
private void chatTextBoxChange(ValueChangedEvent<string> change)
{
if (ShowSearch.Value)
OnSearchTermsChanged?.Invoke(change.NewValue);
}
private void chatTextBoxCommit(TextBox sender, bool newText)
{
if (ShowSearch.Value)
return;
OnChatMessageCommitted?.Invoke(sender.Text);
sender.Text = string.Empty;
}
}
}

View File

@ -0,0 +1,38 @@
// 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 enable
using osu.Framework.Bindables;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Chat
{
public class ChatTextBox : FocusedTextBox
{
public readonly BindableBool ShowSearch = new BindableBool();
public override bool HandleLeftRightArrows => !ShowSearch.Value;
protected override void LoadComplete()
{
base.LoadComplete();
ShowSearch.BindValueChanged(change =>
{
bool showSearch = change.NewValue;
PlaceholderText = showSearch ? "type here to search" : "type here";
Text = string.Empty;
}, true);
}
protected override void Commit()
{
if (ShowSearch.Value)
return;
base.Commit();
}
}
}

View File

@ -17,7 +17,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
HeaderText = @"Confirm deletion of";
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
new PopupDialogDangerousButton
{
Text = @"Yes. Go for it.",
Action = deleteAction

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.Containers;
@ -24,6 +25,11 @@ namespace osu.Game.Overlays.Settings
protected Drawable Control { get; }
/// <summary>
/// The source component if this <see cref="SettingsItem{T}"/> was created via <see cref="SettingSourceAttribute"/>.
/// </summary>
public object SettingSourceObject { get; internal set; }
private IHasCurrentValue<T> controlWithCurrent => Control as IHasCurrentValue<T>;
protected override Container<Drawable> Content => FlowContent;

View File

@ -6,7 +6,6 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Overlays.Toolbar
{
@ -42,7 +41,7 @@ namespace osu.Game.Overlays.Toolbar
{
Y = 14,
Colour = colours.PinkLight,
Scale = new Vector2(0.6f)
Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold),
}
};

View File

@ -190,7 +190,7 @@ namespace osu.Game.Overlays.Toolbar
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (e.Action == Hotkey)
if (e.Action == Hotkey && !e.Repeat)
{
TriggerClick();
return true;

View File

@ -3,27 +3,35 @@
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Overlays.Toolbar
{
public class ToolbarClock : CompositeDrawable
public class ToolbarClock : OsuClickableContainer
{
private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
private Box hoverBackground;
private Box flashBackground;
private DigitalClockDisplay digital;
private AnalogClockDisplay analog;
public ToolbarClock()
: base(HoverSampleSet.Toolbar)
{
RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X;
Padding = new MarginPadding(10);
}
[BackgroundDependencyLoader]
@ -31,23 +39,41 @@ namespace osu.Game.Overlays.Toolbar
{
clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
InternalChild = new FillFlowContainer
Children = new Drawable[]
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Children = new Drawable[]
hoverBackground = new Box
{
analog = new AnalogClockDisplay
RelativeSizeAxes = Axes.Both,
Colour = OsuColour.Gray(80).Opacity(180),
Blending = BlendingParameters.Additive,
Alpha = 0,
},
flashBackground = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Colour = Color4.White.Opacity(100),
Blending = BlendingParameters.Additive,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Padding = new MarginPadding(10),
Children = new Drawable[]
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
digital = new DigitalClockDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
analog = new AnalogClockDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
digital = new DigitalClockDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
}
}
};
@ -72,8 +98,25 @@ namespace osu.Game.Overlays.Toolbar
protected override bool OnClick(ClickEvent e)
{
flashBackground.FadeOutFromOne(800, Easing.OutQuint);
cycleDisplayMode();
return true;
return base.OnClick(e);
}
protected override bool OnHover(HoverEvent e)
{
hoverBackground.FadeIn(200);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
hoverBackground.FadeOut(200);
base.OnHoverLost(e);
}
private void cycleDisplayMode()

View File

@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Difficulty
{
@ -119,15 +120,23 @@ namespace osu.Game.Rulesets.Difficulty
/// <summary>
/// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
/// </summary>
/// <remarks>
/// This can only be used to compute difficulties for legacy mod combinations.
/// </remarks>
/// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
public IEnumerable<DifficultyAttributes> CalculateAllLegacyCombinations(CancellationToken cancellationToken = default)
{
var rulesetInstance = ruleset.CreateInstance();
foreach (var combination in CreateDifficultyAdjustmentModCombinations())
{
if (combination is MultiMod multi)
yield return Calculate(multi.Mods, cancellationToken);
else
yield return Calculate(combination.Yield(), cancellationToken);
Mod classicMod = rulesetInstance.CreateAllMods().SingleOrDefault(m => m is ModClassic);
var finalCombination = ModUtils.FlattenMod(combination);
if (classicMod != null)
finalCombination = finalCombination.Append(classicMod);
yield return Calculate(finalCombination.ToArray(), cancellationToken);
}
}

View File

@ -1,19 +1,16 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays.Settings;
namespace osu.Game.Screens.Edit.Timing
{
internal class TimingSection : Section<TimingControlPoint>
{
private SettingsSlider<double> bpmSlider;
private LabelledTimeSignature timeSignature;
private BPMTextBox bpmTextEntry;
@ -23,7 +20,6 @@ namespace osu.Game.Screens.Edit.Timing
Flow.AddRange(new Drawable[]
{
bpmTextEntry = new BPMTextBox(),
bpmSlider = new BPMSlider(),
timeSignature = new LabelledTimeSignature
{
Label = "Time Signature"
@ -35,11 +31,8 @@ namespace osu.Game.Screens.Edit.Timing
{
if (point.NewValue != null)
{
bpmSlider.Current = point.NewValue.BeatLengthBindable;
bpmSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable;
// no need to hook change handler here as it's the same bindable as above
bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
timeSignature.Current = point.NewValue.TimeSignatureBindable;
timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
@ -102,51 +95,6 @@ namespace osu.Game.Screens.Edit.Timing
}
}
private class BPMSlider : SettingsSlider<double>
{
private const double sane_minimum = 60;
private const double sane_maximum = 240;
private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable;
private readonly BindableDouble bpmBindable = new BindableDouble(60000 / TimingControlPoint.DEFAULT_BEAT_LENGTH)
{
MinValue = sane_minimum,
MaxValue = sane_maximum,
};
public BPMSlider()
{
beatLengthBindable.BindValueChanged(beatLength => updateCurrent(beatLengthToBpm(beatLength.NewValue)), true);
bpmBindable.BindValueChanged(bpm => beatLengthBindable.Value = beatLengthToBpm(bpm.NewValue));
base.Current = bpmBindable;
TransferValueOnCommit = true;
}
public override Bindable<double> Current
{
get => base.Current;
set
{
// incoming will be beat length, not bpm
beatLengthBindable.UnbindBindings();
beatLengthBindable.BindTo(value);
}
}
private void updateCurrent(double newValue)
{
// we use a more sane range for the slider display unless overridden by the user.
// if a value comes in outside our range, we should expand temporarily.
bpmBindable.MinValue = Math.Min(newValue, sane_minimum);
bpmBindable.MaxValue = Math.Max(newValue, sane_maximum);
bpmBindable.Value = newValue;
}
}
private static double beatLengthToBpm(double beatLength) => 60000 / beatLength;
}
}

View File

@ -5,6 +5,8 @@ using System;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Graphics;
@ -27,6 +29,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
[CanBeNull]
private MultiplayerRoom room => multiplayerClient.Room;
private Sample countdownTickSample;
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
countdownTickSample = audio.Samples.Get(@"Multiplayer/countdown-tick");
// disabled for now pending further work on sound effect
// countdownTickFinalSample = audio.Samples.Get(@"Multiplayer/countdown-tick-final");
}
protected override void LoadComplete()
{
base.LoadComplete();
@ -36,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
private MultiplayerCountdown countdown;
private DateTimeOffset countdownChangeTime;
private double countdownChangeTime;
private ScheduledDelegate countdownUpdateDelegate;
private void onRoomUpdated() => Scheduler.AddOnce(() =>
@ -44,20 +56,55 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
if (countdown != room?.Countdown)
{
countdown = room?.Countdown;
countdownChangeTime = DateTimeOffset.Now;
countdownChangeTime = Time.Current;
}
scheduleNextCountdownUpdate();
updateButtonText();
updateButtonColour();
});
private void scheduleNextCountdownUpdate()
{
countdownUpdateDelegate?.Cancel();
if (countdown != null)
countdownUpdateDelegate ??= Scheduler.AddDelayed(updateButtonText, 100, true);
{
// The remaining time on a countdown may be at a fractional portion between two seconds.
// We want to align certain audio/visual cues to the point at which integer seconds change.
// To do so, we schedule to the next whole second. Note that scheduler invocation isn't
// guaranteed to be accurate, so this may still occur slightly late, but even in such a case
// the next invocation will be roughly correct.
double timeToNextSecond = countdownTimeRemaining.TotalMilliseconds % 1000;
countdownUpdateDelegate = Scheduler.AddDelayed(onCountdownTick, timeToNextSecond);
}
else
{
countdownUpdateDelegate?.Cancel();
countdownUpdateDelegate = null;
}
updateButtonText();
updateButtonColour();
});
void onCountdownTick()
{
updateButtonText();
int secondsRemaining = countdownTimeRemaining.Seconds;
playTickSound(secondsRemaining);
if (secondsRemaining > 0)
scheduleNextCountdownUpdate();
}
}
private void playTickSound(int secondsRemaining)
{
if (secondsRemaining < 10) countdownTickSample?.Play();
// disabled for now pending further work on sound effect
// if (secondsRemaining <= 3) countdownTickFinalSample?.Play();
}
private void updateButtonText()
{
@ -75,15 +122,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
if (countdown != null)
{
TimeSpan timeElapsed = DateTimeOffset.Now - countdownChangeTime;
TimeSpan countdownRemaining;
if (timeElapsed > countdown.TimeRemaining)
countdownRemaining = TimeSpan.Zero;
else
countdownRemaining = countdown.TimeRemaining - timeElapsed;
string countdownText = $"Starting in {countdownRemaining:mm\\:ss}";
string countdownText = $"Starting in {countdownTimeRemaining:mm\\:ss}";
switch (localUser?.State)
{
@ -116,6 +155,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
}
private TimeSpan countdownTimeRemaining
{
get
{
double timeElapsed = Time.Current - countdownChangeTime;
TimeSpan remaining;
if (timeElapsed > countdown.TimeRemaining.TotalMilliseconds)
remaining = TimeSpan.Zero;
else
remaining = countdown.TimeRemaining - TimeSpan.FromMilliseconds(timeElapsed);
return remaining;
}
}
private void updateButtonColour()
{
if (room == null)

View File

@ -117,8 +117,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
{
base.PlaylistItemChanged(item);
removeItemFromLists(item.ID);
addItemToLists(item);
var newApiItem = Playlist.SingleOrDefault(i => i.ID == item.ID);
var existingApiItemInQueue = queueList.Items.SingleOrDefault(i => i.ID == item.ID);
// Test if the only change between the two playlist items is the order.
if (newApiItem != null && existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem))
{
// Set the new playlist order directly without refreshing the DrawablePlaylistItem.
existingApiItemInQueue.PlaylistOrder = newApiItem.PlaylistOrder;
// The following isn't really required, but is here for safety and explicitness.
// MultiplayerQueueList internally binds to changes in Playlist to invalidate its own layout, which is mutated on every playlist operation.
queueList.Invalidate();
}
else
{
removeItemFromLists(item.ID);
addItemToLists(item);
}
}
private void addItemToLists(MultiplayerPlaylistItem item)

View File

@ -9,6 +9,7 @@ using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Skinning;
@ -84,9 +85,17 @@ namespace osu.Game.Screens.Play.HUD
/// <returns>The new instance.</returns>
public Drawable CreateInstance()
{
Drawable d = (Drawable)Activator.CreateInstance(Type);
d.ApplySkinnableInfo(this);
return d;
try
{
Drawable d = (Drawable)Activator.CreateInstance(Type);
d.ApplySkinnableInfo(this);
return d;
}
catch (Exception e)
{
Logger.Error(e, $"Unable to create skin component {Type.Name}");
return Drawable.Empty();
}
}
}
}

View File

@ -26,7 +26,7 @@ namespace osu.Game.Screens.Select
HeaderText = @"Confirm deletion of";
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
new PopupDialogDangerousButton
{
Text = @"Yes. Totally. Delete it.",
Action = () => manager?.Delete(beatmap),

View File

@ -174,7 +174,7 @@ namespace osu.Game.Screens.Select
public virtual bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (e.Action == Hotkey)
if (e.Action == Hotkey && !e.Repeat)
{
TriggerClick();
return true;

View File

@ -38,7 +38,7 @@ namespace osu.Game.Screens.Select
HeaderText = "Confirm deletion of local score";
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
new PopupDialogDangerousButton
{
Text = "Yes. Please.",
Action = () => scoreManager?.Delete(score)

View File

@ -8,6 +8,7 @@ using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Audio;
using osu.Game.Beatmaps.Formats;
@ -46,13 +47,13 @@ namespace osu.Game.Skinning
this.resources = resources;
}
public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null;
public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT);
public override ISample GetSample(ISampleInfo sampleInfo)
{
foreach (string lookup in sampleInfo.LookupNames)
{
var sample = resources.AudioManager.Samples.Get(lookup);
var sample = Samples?.Get(lookup) ?? resources.AudioManager.Samples.Get(lookup);
if (sample != null)
return sample;
}
@ -157,6 +158,16 @@ namespace osu.Game.Skinning
break;
}
switch (component.LookupName)
{
// Temporary until default skin has a valid hit lighting.
case @"lighting":
return Drawable.Empty();
}
if (GetTexture(component.LookupName) is Texture t)
return new Sprite { Texture = t };
return null;
}

View File

@ -3,7 +3,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -11,6 +13,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
@ -22,7 +25,7 @@ using osu.Game.Screens.Edit.Components.Menus;
namespace osu.Game.Skinning.Editor
{
[Cached(typeof(SkinEditor))]
public class SkinEditor : VisibilityContainer
public class SkinEditor : VisibilityContainer, ICanAcceptFiles
{
public const double TRANSITION_DURATION = 500;
@ -36,12 +39,18 @@ namespace osu.Game.Skinning.Editor
private Bindable<Skin> currentSkin;
[Resolved(canBeNull: true)]
private OsuGame game { get; set; }
[Resolved]
private SkinManager skins { get; set; }
[Resolved]
private OsuColour colours { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
[Resolved(canBeNull: true)]
private SkinEditorOverlay skinEditorOverlay { get; set; }
@ -171,6 +180,8 @@ namespace osu.Game.Skinning.Editor
Show();
game?.RegisterImportHandler(this);
// as long as the skin editor is loaded, let's make sure we can modify the current skin.
currentSkin = skins.CurrentSkin.GetBoundCopy();
@ -229,21 +240,29 @@ namespace osu.Game.Skinning.Editor
}
private void placeComponent(Type type)
{
if (!(Activator.CreateInstance(type) is ISkinnableDrawable component))
throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}.");
placeComponent(component);
}
private void placeComponent(ISkinnableDrawable component, bool applyDefaults = true)
{
var targetContainer = getFirstTarget();
if (targetContainer == null)
return;
if (!(Activator.CreateInstance(type) is ISkinnableDrawable component))
throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}.");
var drawableComponent = (Drawable)component;
// give newly added components a sane starting location.
drawableComponent.Origin = Anchor.TopCentre;
drawableComponent.Anchor = Anchor.TopCentre;
drawableComponent.Y = targetContainer.DrawSize.Y / 2;
if (applyDefaults)
{
// give newly added components a sane starting location.
drawableComponent.Origin = Anchor.TopCentre;
drawableComponent.Anchor = Anchor.TopCentre;
drawableComponent.Y = targetContainer.DrawSize.Y / 2;
}
targetContainer.Add(component);
@ -313,5 +332,54 @@ namespace osu.Game.Skinning.Editor
foreach (var item in items)
availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item);
}
#region Drag & drop import handling
public Task Import(params string[] paths)
{
Schedule(() =>
{
var file = new FileInfo(paths.First());
// import to skin
currentSkin.Value.SkinInfo.PerformWrite(skinInfo =>
{
using (var contents = file.OpenRead())
skins.AddFile(skinInfo, contents, file.Name);
});
// Even though we are 100% on an update thread, we need to wait for realm callbacks to fire (to correctly invalidate caches in RealmBackedResourceStore).
// See https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-2483573 for further discussion.
// This is the best we can do for now.
realm.Run(r => r.Refresh());
// place component
var sprite = new SkinnableSprite
{
SpriteName = { Value = file.Name },
Origin = Anchor.Centre,
Position = getFirstTarget().ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position),
};
placeComponent(sprite, false);
SkinSelectionHandler.ApplyClosestAnchor(sprite);
});
return Task.CompletedTask;
}
public Task Import(params ImportTask[] tasks) => throw new NotImplementedException();
public IEnumerable<string> HandledExtensions => new[] { ".jpg", ".jpeg", ".png" };
#endregion
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
game?.UnregisterImportHandler(this);
}
}
}

View File

@ -157,13 +157,13 @@ namespace osu.Game.Skinning.Editor
if (item.UsesFixedAnchor) continue;
applyClosestAnchor(drawable);
ApplyClosestAnchor(drawable);
}
return true;
}
private static void applyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable));
public static void ApplyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable));
protected override void OnSelectionChanged()
{
@ -252,7 +252,7 @@ namespace osu.Game.Skinning.Editor
if (item.UsesFixedAnchor) continue;
applyClosestAnchor(drawable);
ApplyClosestAnchor(drawable);
}
}
@ -279,7 +279,7 @@ namespace osu.Game.Skinning.Editor
foreach (var item in SelectedItems)
{
item.UsesFixedAnchor = false;
applyClosestAnchor((Drawable)item);
ApplyClosestAnchor((Drawable)item);
}
}

View File

@ -11,6 +11,7 @@ using osu.Framework.IO.Stores;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
@ -37,11 +38,11 @@ namespace osu.Game.Skinning
private static IResourceStore<byte[]> createRealmBackedStore(BeatmapInfo beatmapInfo, IStorageResourceProvider? resources)
{
if (resources == null)
if (resources == null || beatmapInfo.BeatmapSet == null)
// should only ever be used in tests.
return new ResourceStore<byte[]>();
return new RealmBackedResourceStore(beatmapInfo.BeatmapSet, resources.Files, new[] { @"ogg" });
return new RealmBackedResourceStore<BeatmapSetInfo>(beatmapInfo.BeatmapSet.ToLive(resources.RealmAccess), resources.Files, resources.RealmAccess);
}
public override Drawable? GetDrawableComponent(ISkinComponent component)

View File

@ -23,7 +23,7 @@ namespace osu.Game.Skinning
/// The <see cref="ISkin"/> which is being transformed.
/// </summary>
[NotNull]
protected ISkin Skin { get; }
protected internal ISkin Skin { get; }
protected LegacySkinTransformer([NotNull] ISkin skin)
{

View File

@ -1,51 +1,77 @@
// 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 enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Extensions;
using osu.Framework.IO.Stores;
using osu.Game.Database;
using osu.Game.Extensions;
using Realms;
namespace osu.Game.Skinning
{
public class RealmBackedResourceStore : ResourceStore<byte[]>
public class RealmBackedResourceStore<T> : ResourceStore<byte[]>
where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey
{
private readonly Dictionary<string, string> fileToStoragePathMapping = new Dictionary<string, string>();
private Lazy<Dictionary<string, string>> fileToStoragePathMapping;
public RealmBackedResourceStore(IHasRealmFiles source, IResourceStore<byte[]> underlyingStore, string[] extensions = null)
private readonly Live<T> liveSource;
private readonly IDisposable? realmSubscription;
public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess? realm)
: base(underlyingStore)
{
// Must be initialised before the file cache.
if (extensions != null)
{
foreach (string extension in extensions)
AddExtension(extension);
}
liveSource = source;
initialiseFileCache(source);
invalidateCache();
Debug.Assert(fileToStoragePathMapping != null);
realmSubscription = realm?.RegisterForNotifications(r => r.All<T>().Where(s => s.ID == source.ID), skinChanged);
}
private void initialiseFileCache(IHasRealmFiles source)
protected override void Dispose(bool disposing)
{
fileToStoragePathMapping.Clear();
foreach (var f in source.Files)
fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
base.Dispose(disposing);
realmSubscription?.Dispose();
}
private void skinChanged(IRealmCollection<T> sender, ChangeSet changes, Exception error) => invalidateCache();
protected override IEnumerable<string> GetFilenames(string name)
{
foreach (string filename in base.GetFilenames(name))
{
string path = getPathForFile(filename.ToStandardisedPath());
string? path = getPathForFile(filename.ToStandardisedPath());
if (path != null)
yield return path;
}
}
private string getPathForFile(string filename) =>
fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null;
private string? getPathForFile(string filename)
{
if (fileToStoragePathMapping.Value.TryGetValue(filename.ToLowerInvariant(), out string path))
return path;
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Keys;
return null;
}
private void invalidateCache() => fileToStoragePathMapping = new Lazy<Dictionary<string, string>>(initialiseFileCache);
private Dictionary<string, string> initialiseFileCache() => liveSource.PerformRead(source =>
{
var dictionary = new Dictionary<string, string>();
dictionary.Clear();
foreach (var f in source.Files)
dictionary[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
return dictionary;
});
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Value.Keys;
}
}

View File

@ -54,6 +54,8 @@ namespace osu.Game.Skinning
where TLookup : notnull
where TValue : notnull;
private readonly RealmBackedResourceStore<SkinInfo>? realmBackedStorage;
/// <summary>
/// Construct a new skin.
/// </summary>
@ -67,7 +69,9 @@ namespace osu.Game.Skinning
{
SkinInfo = skin.ToLive(resources.RealmAccess);
storage ??= new RealmBackedResourceStore(skin, resources.Files, new[] { @"ogg" });
storage ??= realmBackedStorage = new RealmBackedResourceStore<SkinInfo>(SkinInfo, resources.Files, resources.RealmAccess);
(storage as ResourceStore<byte[]>)?.AddExtension("ogg");
var samples = resources.AudioManager?.GetSampleStore(storage);
if (samples != null)
@ -155,16 +159,7 @@ namespace osu.Game.Skinning
var components = new List<Drawable>();
foreach (var i in skinnableInfo)
{
try
{
components.Add(i.CreateInstance());
}
catch (Exception e)
{
Logger.Error(e, $"Unable to create skin component {i.Type.Name}");
}
}
components.Add(i.CreateInstance());
return new SkinnableTargetComponentsContainer
{
@ -200,6 +195,8 @@ namespace osu.Game.Skinning
Textures?.Dispose();
Samples?.Dispose();
realmBackedStorage?.Dispose();
}
#endregion

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
@ -23,7 +24,9 @@ using osu.Game.Audio;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Models;
using osu.Game.Overlays.Notifications;
using osu.Game.Utils;
namespace osu.Game.Skinning
{
@ -35,7 +38,7 @@ namespace osu.Game.Skinning
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
/// </remarks>
[ExcludeFromDynamicCompile]
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>, IModelManager<SkinInfo>, IModelFileManager<SkinInfo, RealmNamedFileUsage>
{
private readonly AudioManager audio;
@ -95,7 +98,10 @@ namespace osu.Game.Skinning
}
});
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
CurrentSkinInfo.ValueChanged += skin =>
{
CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
};
CurrentSkin.Value = DefaultSkin;
CurrentSkin.ValueChanged += skin =>
@ -144,20 +150,26 @@ namespace osu.Game.Skinning
if (!s.Protected)
return;
string[] existingSkinNames = realm.Run(r => r.All<SkinInfo>()
.Where(skin => !skin.DeletePending)
.AsEnumerable()
.Select(skin => skin.Name).ToArray());
// if the user is attempting to save one of the default skin implementations, create a copy first.
var result = skinModelManager.Import(new SkinInfo
var skinInfo = new SkinInfo
{
Name = s.Name + @" (modified)",
Creator = s.Creator,
InstantiationInfo = s.InstantiationInfo,
});
Name = NamingUtils.GetNextBestName(existingSkinNames, $"{s.Name} (modified)")
};
var result = skinModelManager.Import(skinInfo);
if (result != null)
{
// save once to ensure the required json content is populated.
// currently this only happens on save.
result.PerformRead(skin => Save(skin.CreateInstance(this)));
CurrentSkinInfo.Value = result;
}
});
@ -306,5 +318,45 @@ namespace osu.Game.Skinning
}
#endregion
public bool Delete(SkinInfo item)
{
return skinModelManager.Delete(item);
}
public void Delete(List<SkinInfo> items, bool silent = false)
{
skinModelManager.Delete(items, silent);
}
public void Undelete(List<SkinInfo> items, bool silent = false)
{
skinModelManager.Undelete(items, silent);
}
public void Undelete(SkinInfo item)
{
skinModelManager.Undelete(item);
}
public bool IsAvailableLocally(SkinInfo model)
{
return skinModelManager.IsAvailableLocally(model);
}
public void ReplaceFile(SkinInfo model, RealmNamedFileUsage file, Stream contents)
{
skinModelManager.ReplaceFile(model, file, contents);
}
public void DeleteFile(SkinInfo model, RealmNamedFileUsage file)
{
skinModelManager.DeleteFile(model, file);
}
public void AddFile(SkinInfo model, Stream contents, string filename)
{
skinModelManager.AddFile(model, contents, filename);
}
}
}

View File

@ -31,7 +31,7 @@ namespace osu.Game.Skinning
set => base.AutoSizeAxes = value;
}
private readonly ISkinComponent component;
protected readonly ISkinComponent Component;
private readonly ConfineMode confineMode;
@ -49,7 +49,7 @@ namespace osu.Game.Skinning
protected SkinnableDrawable(ISkinComponent component, ConfineMode confineMode = ConfineMode.NoScaling)
{
this.component = component;
Component = component;
this.confineMode = confineMode;
RelativeSizeAxes = Axes.Both;
@ -75,13 +75,13 @@ namespace osu.Game.Skinning
protected override void SkinChanged(ISkinSource skin)
{
Drawable = skin.GetDrawableComponent(component);
Drawable = skin.GetDrawableComponent(Component);
isDefault = false;
if (Drawable == null)
{
Drawable = CreateDefault(component);
Drawable = CreateDefault(Component);
isDefault = true;
}

View File

@ -1,26 +1,56 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Configuration;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays.Settings;
using osuTK;
namespace osu.Game.Skinning
{
/// <summary>
/// A skinnable element which uses a stable sprite and can therefore share implementation logic.
/// A skinnable element which uses a single texture backing.
/// </summary>
public class SkinnableSprite : SkinnableDrawable
public class SkinnableSprite : SkinnableDrawable, ISkinnableDrawable
{
protected override bool ApplySizeRestrictionsToDefault => true;
[Resolved]
private TextureStore textures { get; set; }
[SettingSource("Sprite name", "The filename of the sprite", SettingControlType = typeof(SpriteSelectorControl))]
public Bindable<string> SpriteName { get; } = new Bindable<string>(string.Empty);
[Resolved]
private ISkinSource source { get; set; }
public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling)
: base(new SpriteComponent(textureName), confineMode)
{
SpriteName.Value = textureName;
}
public SkinnableSprite()
: base(new SpriteComponent(string.Empty), ConfineMode.NoScaling)
{
RelativeSizeAxes = Axes.None;
AutoSizeAxes = Axes.Both;
SpriteName.BindValueChanged(name =>
{
((SpriteComponent)Component).LookupName = name.NewValue ?? string.Empty;
if (IsLoaded)
SkinChanged(CurrentSkin);
});
}
protected override Drawable CreateDefault(ISkinComponent component)
@ -28,19 +58,85 @@ namespace osu.Game.Skinning
var texture = textures.Get(component.LookupName);
if (texture == null)
return null;
return new SpriteNotFound(component.LookupName);
return new Sprite { Texture = texture };
}
public bool UsesFixedAnchor { get; set; }
private class SpriteComponent : ISkinComponent
{
public string LookupName { get; set; }
public SpriteComponent(string textureName)
{
LookupName = textureName;
}
}
public string LookupName { get; }
public class SpriteSelectorControl : SettingsDropdown<string>
{
protected override void LoadComplete()
{
base.LoadComplete();
// Round-about way of getting the user's skin to find available resources.
// In the future we'll probably want to allow access to resources from the fallbacks, or potentially other skins
// but that requires further thought.
var highestPrioritySkin = getHighestPriorityUserSkin(((SkinnableSprite)SettingSourceObject).source.AllSources) as Skin;
string[] availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files
.Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal)
|| f.Filename.EndsWith(".jpg", StringComparison.Ordinal))
.Select(f => f.Filename).Distinct()).ToArray();
if (availableFiles?.Length > 0)
Items = availableFiles;
static ISkin getHighestPriorityUserSkin(IEnumerable<ISkin> skins)
{
foreach (var skin in skins)
{
if (skin is LegacySkinTransformer transformer && isUserSkin(transformer.Skin))
return transformer.Skin;
if (isUserSkin(skin))
return skin;
}
return null;
}
// Temporarily used to exclude undesirable ISkin implementations
static bool isUserSkin(ISkin skin)
=> skin.GetType() == typeof(DefaultSkin)
|| skin.GetType() == typeof(DefaultLegacySkin)
|| skin.GetType() == typeof(LegacySkin);
}
}
public class SpriteNotFound : CompositeDrawable
{
public SpriteNotFound(string lookup)
{
AutoSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
new SpriteIcon
{
Size = new Vector2(50),
Icon = FontAwesome.Solid.QuestionCircle
},
new OsuSpriteText
{
Position = new Vector2(25, 50),
Text = $"missing: {lookup}",
Origin = Anchor.TopCentre,
}
};
}
}
}
}

View File

@ -22,10 +22,13 @@ namespace osu.Game.Tests.Beatmaps
protected abstract string ResourceAssembly { get; }
protected void Test(double expected, string name, params Mod[] mods)
protected void Test(double expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods)
{
var attributes = CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods);
// Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences.
Assert.That(CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods).StarRating, Is.EqualTo(expected).Within(0.00001));
Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001));
Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo));
}
private IWorkingBeatmap getBeatmap(string name)

View File

@ -31,7 +31,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
public override IBindable<bool> IsConnected => isConnected;
private readonly Bindable<bool> isConnected = new Bindable<bool>(true);
/// <summary>
/// The local client's <see cref="Room"/>. This is not always equivalent to the server-side room.
/// </summary>
public new Room? APIRoom => base.APIRoom;
public Action<MultiplayerRoom>? RoomSetupAction;
public bool RoomJoined { get; private set; }
@ -46,6 +50,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
/// </summary>
private readonly List<MultiplayerPlaylistItem> serverSidePlaylist = new List<MultiplayerPlaylistItem>();
/// <summary>
/// Guaranteed up-to-date API room.
/// </summary>
private Room? serverSideAPIRoom;
private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex];
private int currentIndex;
private long lastPlaylistItemId;
@ -192,13 +201,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
protected override async Task<MultiplayerRoom> JoinRoom(long roomId, string? password = null)
{
var apiRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId);
serverSideAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId);
if (password != apiRoom.Password.Value)
if (password != serverSideAPIRoom.Password.Value)
throw new InvalidOperationException("Invalid password.");
serverSidePlaylist.Clear();
serverSidePlaylist.AddRange(apiRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)));
serverSidePlaylist.AddRange(serverSideAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)));
lastPlaylistItemId = serverSidePlaylist.Max(item => item.ID);
var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id)
@ -210,11 +219,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
Settings =
{
Name = apiRoom.Name.Value,
MatchType = apiRoom.Type.Value,
Name = serverSideAPIRoom.Name.Value,
MatchType = serverSideAPIRoom.Type.Value,
Password = password,
QueueMode = apiRoom.QueueMode.Value,
AutoStartDuration = apiRoom.AutoStartDuration.Value
QueueMode = serverSideAPIRoom.QueueMode.Value,
AutoStartDuration = serverSideAPIRoom.AutoStartDuration.Value
},
Playlist = serverSidePlaylist.ToList(),
Users = { localUser },
@ -449,8 +458,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item)
{
Debug.Assert(Room != null);
Debug.Assert(APIRoom != null);
Debug.Assert(currentItem != null);
Debug.Assert(serverSideAPIRoom != null);
item.OwnerID = userId;
@ -469,6 +478,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
item.PlaylistOrder = existingItem.PlaylistOrder;
serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item;
serverSideAPIRoom.Playlist[serverSideAPIRoom.Playlist.IndexOf(serverSideAPIRoom.Playlist.Single(i => i.ID == item.ID))] = new PlaylistItem(item);
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
}
@ -479,6 +489,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
Debug.Assert(Room != null);
Debug.Assert(APIRoom != null);
Debug.Assert(serverSideAPIRoom != null);
var item = serverSidePlaylist.Find(i => i.ID == playlistItemId);
@ -495,6 +506,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
throw new InvalidOperationException("Attempted to remove an item which has already been played.");
serverSidePlaylist.Remove(item);
serverSideAPIRoom.Playlist.RemoveAll(i => i.ID == item.ID);
await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false);
await updateCurrentItem(Room).ConfigureAwait(false);
@ -576,10 +588,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
private async Task addItem(MultiplayerPlaylistItem item)
{
Debug.Assert(Room != null);
Debug.Assert(serverSideAPIRoom != null);
item.ID = ++lastPlaylistItemId;
serverSidePlaylist.Add(item);
serverSideAPIRoom.Playlist.Add(new PlaylistItem(item));
await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false);
await updatePlaylistOrder(Room).ConfigureAwait(false);
@ -603,6 +617,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
private async Task updatePlaylistOrder(MultiplayerRoom room)
{
Debug.Assert(serverSideAPIRoom != null);
List<MultiplayerPlaylistItem> orderedActiveItems;
switch (room.Settings.QueueMode)
@ -648,6 +664,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
}
// Also ensure that the API room's playlist is correct.
foreach (var item in serverSideAPIRoom.Playlist)
item.PlaylistOrder = serverSidePlaylist.Single(i => i.ID == item.ID).PlaylistOrder;
}
}
}

View File

@ -36,8 +36,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.10.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.325.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
<PackageReference Include="Sentry" Version="3.14.1" />
<PackageReference Include="SharpCompress" Version="0.30.1" />
<PackageReference Include="NUnit" Version="3.13.2" />