Merge branch 'master' into kps

This commit is contained in:
Dean Herbert
2022-08-24 18:18:45 +09:00
129 changed files with 886 additions and 656 deletions

View File

@ -9,6 +9,7 @@ using System.Linq;
using System.Reflection;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
@ -43,12 +44,47 @@ namespace osu.Game.Configuration
/// </remarks>
public Type? SettingControlType { get; set; }
public SettingSourceAttribute(Type declaringType, string label, string? description = null)
{
Label = getLocalisableStringFromMember(label) ?? string.Empty;
Description = getLocalisableStringFromMember(description) ?? string.Empty;
LocalisableString? getLocalisableStringFromMember(string? member)
{
if (member == null)
return null;
var property = declaringType.GetMember(member, BindingFlags.Static | BindingFlags.Public).FirstOrDefault();
if (property == null)
return null;
switch (property)
{
case FieldInfo f:
return (LocalisableString)f.GetValue(null).AsNonNull();
case PropertyInfo p:
return (LocalisableString)p.GetValue(null).AsNonNull();
default:
throw new InvalidOperationException($"Member \"{member}\" was not found in type {declaringType} (must be a static field or property)");
}
}
}
public SettingSourceAttribute(string? label, string? description = null)
{
Label = label ?? string.Empty;
Description = description ?? string.Empty;
}
public SettingSourceAttribute(Type declaringType, string label, string description, int orderPosition)
: this(declaringType, label, description)
{
OrderPosition = orderPosition;
}
public SettingSourceAttribute(string label, string description, int orderPosition)
: this(label, description)
{

View File

@ -128,5 +128,13 @@ namespace osu.Game.Online.API
IBindable<UserActivity> IAPIProvider.Activity => Activity;
public void FailNextLogin() => shouldFailNextLogin = true;
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
// Ensure (as much as we can) that any pending tasks are run.
Scheduler.Update();
}
}
}

View File

@ -10,6 +10,8 @@ using osuTK;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Localisation;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
@ -126,7 +128,7 @@ namespace osu.Game.Online.Leaderboards
private class HitResultCell : CompositeDrawable
{
private readonly string displayName;
private readonly LocalisableString displayName;
private readonly HitResult result;
private readonly int count;
@ -134,7 +136,7 @@ namespace osu.Game.Online.Leaderboards
{
AutoSizeAxes = Axes.Both;
displayName = stat.DisplayName;
displayName = stat.DisplayName.ToUpper();
result = stat.Result;
count = stat.Count;
}
@ -153,7 +155,7 @@ namespace osu.Game.Online.Leaderboards
new OsuSpriteText
{
Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold),
Text = displayName.ToUpperInvariant(),
Text = displayName.ToUpper(),
Colour = colours.ForHitResult(result),
},
new OsuSpriteText

View File

@ -59,7 +59,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
/// <summary>
/// The statistics that appear in the table, in order of appearance.
/// </summary>
private readonly List<(HitResult result, string displayName)> statisticResultTypes = new List<(HitResult, string)>();
private readonly List<(HitResult result, LocalisableString displayName)> statisticResultTypes = new List<(HitResult, LocalisableString)>();
private bool showPerformancePoints;
@ -114,7 +114,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
if (result.IsBonus())
continue;
string displayName = ruleset.GetDisplayNameForHitResult(result);
var displayName = ruleset.GetDisplayNameForHitResult(result);
columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 35, maxSize: 60)));
statisticResultTypes.Add((result, displayName));

View File

@ -31,10 +31,7 @@ namespace osu.Game.Overlays.Mods
set => current.Current = value;
}
private readonly BindableNumberWithCurrent<double> current = new BindableNumberWithCurrent<double>(1)
{
Precision = 0.01
};
private readonly BindableNumberWithCurrent<double> current = new BindableNumberWithCurrent<double>(1);
private readonly Box underlayBackground;
private readonly Box contentBackground;

View File

@ -66,7 +66,10 @@ namespace osu.Game.Overlays.Mods
private IModHotkeyHandler hotkeyHandler = null!;
private Task? latestLoadTask;
internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true;
private ICollection<ModPanel>? latestLoadedPanels;
internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true && latestLoadedPanels?.All(panel => panel.Parent != null) == true;
public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks;
public ModColumn(ModType modType, bool allowIncompatibleSelection)
{
@ -130,7 +133,8 @@ namespace osu.Game.Overlays.Mods
{
cancellationTokenSource?.Cancel();
var panels = availableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = Vector2.Zero));
var panels = availableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = Vector2.Zero)).ToArray();
latestLoadedPanels = panels;
latestLoadTask = LoadComponentsAsync(panels, loaded =>
{

View File

@ -57,6 +57,18 @@ namespace osu.Game.Overlays.Mods
Filtered.BindValueChanged(_ => updateFilterState(), true);
}
protected override void Select()
{
modState.PendingConfiguration = Mod.RequiresConfiguration;
Active.Value = true;
}
protected override void Deselect()
{
modState.PendingConfiguration = false;
Active.Value = false;
}
#region Filtering support
private void updateFilterState()

View File

@ -37,8 +37,6 @@ namespace osu.Game.Overlays.Mods
Title = preset.Value.Name;
Description = preset.Value.Description;
Action = toggleRequestedByUser;
}
[BackgroundDependencyLoader]
@ -54,15 +52,19 @@ namespace osu.Game.Overlays.Mods
selectedMods.BindValueChanged(_ => selectedModsChanged(), true);
}
private void toggleRequestedByUser()
protected override void Select()
{
// if the preset is not active at the point of the user click, then set the mods using the preset directly, discarding any previous selections,
// which will also have the side effect of activating the preset (see `updateActiveState()`).
selectedMods.Value = Preset.Value.Mods.ToArray();
}
protected override void Deselect()
{
// if the preset is not active at the point of the user click, then set the mods using the preset directly, discarding any previous selections.
// if the preset is active when the user has clicked it, then it means that the set of active mods is exactly equal to the set of mods in the preset
// (there are no other active mods than what the preset specifies, and the mod settings match exactly).
// therefore it's safe to just clear selected mods, since it will have the effect of toggling the preset off.
selectedMods.Value = !Active.Value
? Preset.Value.Mods.ToArray()
: Array.Empty<Mod>();
selectedMods.Value = Array.Empty<Mod>();
}
private void selectedModsChanged()

View File

@ -159,12 +159,15 @@ namespace osu.Game.Overlays.Mods
int wordIndex = 0;
headerText.AddText(text, t =>
ITextPart part = headerText.AddText(text, t =>
{
if (wordIndex == 0)
t.Font = t.Font.With(weight: FontWeight.SemiBold);
wordIndex += 1;
});
// Reset the index so that if the parts are refreshed (e.g. through changes in localisation) the correct word is re-emboldened.
part.DrawablePartsRecreated += _ => wordIndex = 0;
}
[BackgroundDependencyLoader]

View File

@ -87,7 +87,7 @@ namespace osu.Game.Overlays.Mods
{
if (AllowCustomisation)
{
yield return customisationButton = new ShearedToggleButton(BUTTON_WIDTH)
yield return CustomisationButton = new ShearedToggleButton(BUTTON_WIDTH)
{
Text = ModSelectOverlayStrings.ModCustomisation,
Active = { BindTarget = customisationVisible }
@ -107,11 +107,11 @@ namespace osu.Game.Overlays.Mods
private ColumnScrollContainer columnScroll = null!;
private ColumnFlowContainer columnFlow = null!;
private FillFlowContainer<ShearedButton> footerButtonFlow = null!;
private ShearedButton backButton = null!;
private DifficultyMultiplierDisplay? multiplierDisplay;
private ShearedToggleButton? customisationButton;
protected ShearedButton BackButton { get; private set; } = null!;
protected ShearedToggleButton? CustomisationButton { get; private set; }
private Sample? columnAppearSample;
@ -214,7 +214,7 @@ namespace osu.Game.Overlays.Mods
Horizontal = 70
},
Spacing = new Vector2(10),
ChildrenEnumerable = CreateFooterButtons().Prepend(backButton = new ShearedButton(BUTTON_WIDTH)
ChildrenEnumerable = CreateFooterButtons().Prepend(BackButton = new ShearedButton(BUTTON_WIDTH)
{
Text = CommonStrings.Back,
Action = Hide,
@ -247,8 +247,8 @@ namespace osu.Game.Overlays.Mods
modSettingChangeTracker?.Dispose();
updateMultiplier();
updateCustomisation(val);
updateFromExternalSelection();
updateCustomisation();
if (AllowCustomisation)
{
@ -356,25 +356,26 @@ namespace osu.Game.Overlays.Mods
multiplierDisplay.Current.Value = multiplier;
}
private void updateCustomisation(ValueChangedEvent<IReadOnlyList<Mod>> valueChangedEvent)
private void updateCustomisation()
{
if (customisationButton == null)
if (CustomisationButton == null)
return;
bool anyCustomisableMod = false;
bool anyModWithRequiredCustomisationAdded = false;
bool anyCustomisableModActive = false;
bool anyModPendingConfiguration = false;
foreach (var mod in SelectedMods.Value)
foreach (var modState in allAvailableMods)
{
anyCustomisableMod |= mod.GetSettingsSourceProperties().Any();
anyModWithRequiredCustomisationAdded |= valueChangedEvent.OldValue.All(m => m.GetType() != mod.GetType()) && mod.RequiresConfiguration;
anyCustomisableModActive |= modState.Active.Value && modState.Mod.GetSettingsSourceProperties().Any();
anyModPendingConfiguration |= modState.PendingConfiguration;
modState.PendingConfiguration = false;
}
if (anyCustomisableMod)
if (anyCustomisableModActive)
{
customisationVisible.Disabled = false;
if (anyModWithRequiredCustomisationAdded && !customisationVisible.Value)
if (anyModPendingConfiguration && !customisationVisible.Value)
customisationVisible.Value = true;
}
else
@ -394,7 +395,7 @@ namespace osu.Game.Overlays.Mods
foreach (var button in footerButtonFlow)
{
if (button != customisationButton)
if (button != CustomisationButton)
button.Enabled.Value = !customisationVisible.Value;
}
@ -587,14 +588,14 @@ namespace osu.Game.Overlays.Mods
{
if (customisationVisible.Value)
{
Debug.Assert(customisationButton != null);
customisationButton.TriggerClick();
Debug.Assert(CustomisationButton != null);
CustomisationButton.TriggerClick();
if (!immediate)
return;
}
backButton.TriggerClick();
BackButton.TriggerClick();
}
}
@ -708,7 +709,18 @@ namespace osu.Game.Overlays.Mods
FinishTransforms();
}
protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate || (Column as ModColumn)?.SelectionAnimationRunning == true;
protected override bool RequiresChildrenUpdate
{
get
{
bool result = base.RequiresChildrenUpdate;
if (Column is ModColumn modColumn)
result |= !modColumn.ItemsLoaded || modColumn.SelectionAnimationRunning;
return result;
}
}
private void updateState()
{

View File

@ -143,9 +143,25 @@ namespace osu.Game.Overlays.Mods
}
};
Action = () => Active.Toggle();
Action = () =>
{
if (!Active.Value)
Select();
else
Deselect();
};
}
/// <summary>
/// Performs all actions necessary to select this <see cref="ModSelectPanel"/>.
/// </summary>
protected abstract void Select();
/// <summary>
/// Performs all actions necessary to deselect this <see cref="ModSelectPanel"/>.
/// </summary>
protected abstract void Deselect();
[BackgroundDependencyLoader]
private void load(AudioManager audio, ISamplePlaybackDisabler? samplePlaybackDisabler)
{

View File

@ -24,6 +24,13 @@ namespace osu.Game.Overlays.Mods
/// </summary>
public BindableBool Active { get; } = new BindableBool();
/// <summary>
/// Whether the mod requires further customisation.
/// This flag is read by the <see cref="ModSelectOverlay"/> to determine if the customisation panel should be opened after a mod change
/// and cleared after reading.
/// </summary>
public bool PendingConfiguration { get; set; }
/// <summary>
/// Whether the mod is currently filtered out due to not matching imposed criteria.
/// </summary>

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
@ -70,85 +71,90 @@ namespace osu.Game.Overlays.Profile.Header
Masking = true,
CornerRadius = avatar_size * 0.25f,
},
new Container
new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Padding = new MarginPadding { Left = 10 },
Children = new Drawable[]
Child = new Container
{
new FillFlowContainer
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Padding = new MarginPadding { Left = 10 },
Children = new Drawable[]
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Children = new Drawable[]
new FillFlowContainer
{
new FillFlowContainer
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new Drawable[]
new FillFlowContainer
{
usernameText = new OsuSpriteText
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
Font = OsuFont.GetFont(size: 24, weight: FontWeight.Regular)
},
openUserExternally = new ExternalLinkButton
{
Margin = new MarginPadding { Left = 5 },
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
}
},
titleText = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 18, weight: FontWeight.Regular)
},
}
},
new FillFlowContainer
{
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
Direction = FillDirection.Vertical,
AutoSizeAxes = Axes.Both,
Children = new Drawable[]
{
supporterTag = new SupporterIcon
{
Height = 20,
Margin = new MarginPadding { Top = 5 }
},
new Box
{
RelativeSizeAxes = Axes.X,
Height = 1.5f,
Margin = new MarginPadding { Top = 10 },
Colour = colourProvider.Light1,
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Top = 5 },
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
userFlag = new UpdateableFlag
{
Size = new Vector2(28, 20),
ShowPlaceholderOnUnknown = false,
},
userCountryText = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 17.5f, weight: FontWeight.Regular),
Margin = new MarginPadding { Left = 10 },
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Colour = colourProvider.Light1,
usernameText = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 24, weight: FontWeight.Regular)
},
openUserExternally = new ExternalLinkButton
{
Margin = new MarginPadding { Left = 5 },
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
}
}
},
},
titleText = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 18, weight: FontWeight.Regular)
},
}
},
new FillFlowContainer
{
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
Direction = FillDirection.Vertical,
AutoSizeAxes = Axes.Both,
Children = new Drawable[]
{
supporterTag = new SupporterIcon
{
Height = 20,
Margin = new MarginPadding { Top = 5 }
},
new Box
{
RelativeSizeAxes = Axes.X,
Height = 1.5f,
Margin = new MarginPadding { Top = 10 },
Colour = colourProvider.Light1,
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Top = 5 },
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
userFlag = new UpdateableFlag
{
Size = new Vector2(28, 20),
ShowPlaceholderOnUnknown = false,
},
userCountryText = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 17.5f, weight: FontWeight.Regular),
Margin = new MarginPadding { Left = 10 },
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Colour = colourProvider.Light1,
}
}
},
}
}
}
}

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 osu.Framework.Localisation;
using osu.Game.Rulesets;

View File

@ -3,6 +3,7 @@
using System;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Rulesets.Mods
{
@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Mods
/// <summary>
/// The user readable description of this mod.
/// </summary>
string Description { get; }
LocalisableString Description { get; }
/// <summary>
/// The type of this mod.

View File

@ -9,6 +9,7 @@ using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Rulesets.UI;
@ -34,7 +35,7 @@ namespace osu.Game.Rulesets.Mods
public virtual ModType Type => ModType.Fun;
[JsonIgnore]
public abstract string Description { get; }
public abstract LocalisableString Description { get; }
/// <summary>
/// The tooltip to display for this mod when used in a <see cref="ModIcon"/>.

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "AS";
public override string Description => "Let track speed adapt to you.";
public override LocalisableString Description => "Let track speed adapt to you.";
public override ModType Type => ModType.Fun;

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Replays;
@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "AT";
public override IconUsage? Icon => OsuIcon.ModAuto;
public override ModType Type => ModType.Automation;
public override string Description => "Watch a perfect automated play through the song.";
public override LocalisableString Description => "Watch a perfect automated play through the song.";
public override double ScoreMultiplier => 1;
public bool PerformFail() => false;

View File

@ -5,6 +5,7 @@ using System;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
@ -34,7 +35,7 @@ namespace osu.Game.Rulesets.Mods
public override string Name => "Barrel Roll";
public override string Acronym => "BR";
public override string Description => "The whole playfield is on a wheel!";
public override LocalisableString Description => "The whole playfield is on a wheel!";
public override double ScoreMultiplier => 1;
public override string SettingDescription => $"{SpinSpeed.Value:N2} rpm {Direction.Value.GetDescription().ToLowerInvariant()}";

View File

@ -4,6 +4,7 @@
using System;
using System.Linq;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
@ -27,7 +28,7 @@ namespace osu.Game.Rulesets.Mods
public override string Name => "Cinema";
public override string Acronym => "CN";
public override IconUsage? Icon => OsuIcon.ModCinema;
public override string Description => "Watch the video without visual distractions.";
public override LocalisableString Description => "Watch the video without visual distractions.";
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAutoplay)).ToArray();

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Rulesets.Mods
{
@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => FontAwesome.Solid.History;
public override string Description => "Feeling nostalgic?";
public override LocalisableString Description => "Feeling nostalgic?";
public override ModType Type => ModType.Conversion;
}

View File

@ -4,6 +4,7 @@
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Rulesets.Mods
{
@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mods
public override string Name => "Daycore";
public override string Acronym => "DC";
public override IconUsage? Icon => null;
public override string Description => "Whoaaaaa...";
public override LocalisableString Description => "Whoaaaaa...";
private readonly BindableNumber<double> tempoAdjust = new BindableDouble(1);
private readonly BindableNumber<double> freqAdjust = new BindableDouble(1);

View File

@ -5,6 +5,7 @@ using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods
{
public override string Name => @"Difficulty Adjust";
public override string Description => @"Override a beatmap's difficulty settings.";
public override LocalisableString Description => @"Override a beatmap's difficulty settings.";
public override string Acronym => "DA";

View File

@ -3,6 +3,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics;
@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "DT";
public override IconUsage? Icon => OsuIcon.ModDoubleTime;
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Zoooooooooom...";
public override LocalisableString Description => "Zoooooooooom...";
[SettingSource("Speed increase", "The actual increase to apply")]
public override BindableNumber<double> SpeedChange { get; } = new BindableDouble

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Rendering.Vertices;
using osu.Framework.Graphics.Shaders;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps.Timing;
using osu.Game.Configuration;
using osu.Game.Graphics;
@ -30,7 +31,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "FL";
public override IconUsage? Icon => OsuIcon.ModFlashlight;
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Restricted view area.";
public override LocalisableString Description => "Restricted view area.";
[SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")]
public abstract BindableFloat SizeMultiplier { get; }

View File

@ -3,6 +3,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics;
@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "HT";
public override IconUsage? Icon => OsuIcon.ModHalftime;
public override ModType Type => ModType.DifficultyReduction;
public override string Description => "Less zoom...";
public override LocalisableString Description => "Less zoom...";
[SettingSource("Speed decrease", "The actual decrease to apply")]
public override BindableNumber<double> SpeedChange { get; } = new BindableDouble

View File

@ -3,6 +3,7 @@
using System;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "HR";
public override IconUsage? Icon => OsuIcon.ModHardRock;
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Everything just got a bit harder...";
public override LocalisableString Description => "Everything just got a bit harder...";
public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) };
public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty)

View File

@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mods
public override string Name => "Muted";
public override string Acronym => "MU";
public override IconUsage? Icon => FontAwesome.Solid.VolumeMute;
public override string Description => "Can you still feel the rhythm without music?";
public override LocalisableString Description => "Can you still feel the rhythm without music?";
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Audio;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Mods
public override string Name => "Nightcore";
public override string Acronym => "NC";
public override IconUsage? Icon => OsuIcon.ModNightcore;
public override string Description => "Uguuuuuuuu...";
public override LocalisableString Description => "Uguuuuuuuu...";
}
public abstract class ModNightcore<TObject> : ModNightcore, IApplicableToDrawableRuleset<TObject>

View File

@ -3,6 +3,7 @@
using System;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
namespace osu.Game.Rulesets.Mods
@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "NF";
public override IconUsage? Icon => OsuIcon.ModNoFail;
public override ModType Type => ModType.DifficultyReduction;
public override string Description => "You can't fail, no matter what.";
public override LocalisableString Description => "You can't fail, no matter what.";
public override double ScoreMultiplier => 0.5;
public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModAutoplay) };
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Rulesets.Mods
{
@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mods
{
public override string Name => "No Mod";
public override string Acronym => "NM";
public override string Description => "No mods applied.";
public override LocalisableString Description => "No mods applied.";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => FontAwesome.Solid.Ban;
public override ModType Type => ModType.System;

View File

@ -4,6 +4,7 @@
using System;
using System.Linq;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => OsuIcon.ModPerfect;
public override ModType Type => ModType.DifficultyIncrease;
public override double ScoreMultiplier => 1;
public override string Description => "SS or quit.";
public override LocalisableString Description => "SS or quit.";
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModSuddenDeath)).ToArray();

View File

@ -4,6 +4,7 @@
using System;
using System.Linq;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "SD";
public override IconUsage? Icon => OsuIcon.ModSuddenDeath;
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Miss and fail.";
public override LocalisableString Description => "Miss and fail.";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray();

View File

@ -5,6 +5,7 @@ using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Configuration;
namespace osu.Game.Rulesets.Mods
@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mods
{
public override string Name => "Wind Down";
public override string Acronym => "WD";
public override string Description => "Sloooow doooown...";
public override LocalisableString Description => "Sloooow doooown...";
public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleDown;
public override double ScoreMultiplier => 1.0;

View File

@ -5,6 +5,7 @@ using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Configuration;
namespace osu.Game.Rulesets.Mods
@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mods
{
public override string Name => "Wind Up";
public override string Acronym => "WU";
public override string Description => "Can you keep up?";
public override LocalisableString Description => "Can you keep up?";
public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleUp;
public override double ScoreMultiplier => 1.0;

View File

@ -3,6 +3,7 @@
using System;
using System.Linq;
using osu.Framework.Localisation;
namespace osu.Game.Rulesets.Mods
{
@ -10,7 +11,7 @@ namespace osu.Game.Rulesets.Mods
{
public override string Name => string.Empty;
public override string Acronym => string.Empty;
public override string Description => string.Empty;
public override LocalisableString Description => string.Empty;
public override double ScoreMultiplier => 0;
public Mod[] Mods { get; }

View File

@ -1,6 +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.
using osu.Framework.Localisation;
namespace osu.Game.Rulesets.Mods
{
public class UnknownMod : Mod
@ -12,7 +14,7 @@ namespace osu.Game.Rulesets.Mods
public override string Name => $"Unknown mod ({OriginalAcronym})";
public override string Acronym => $"{OriginalAcronym}??";
public override string Description => "This mod could not be resolved by the game.";
public override LocalisableString Description => "This mod could not be resolved by the game.";
public override double ScoreMultiplier => 0;
public override bool UserPlayable => false;

View File

@ -5,8 +5,8 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Database;
namespace osu.Game.Rulesets
@ -68,8 +68,14 @@ namespace osu.Game.Rulesets
{
try
{
var resolvedType = Type.GetType(r.InstantiationInfo)
?? throw new RulesetLoadException(@"Type could not be resolved");
var resolvedType = Type.GetType(r.InstantiationInfo);
if (resolvedType == null)
{
// ruleset DLL was probably deleted.
r.Available = false;
continue;
}
var instanceInfo = (Activator.CreateInstance(resolvedType) as Ruleset)?.RulesetInfo
?? throw new RulesetLoadException(@"Instantiation failure");
@ -83,17 +89,35 @@ namespace osu.Game.Rulesets
r.InstantiationInfo = instanceInfo.InstantiationInfo;
r.Available = true;
testRulesetCompatibility(r);
detachedRulesets.Add(r.Clone());
}
catch (Exception ex)
{
r.Available = false;
Logger.Log($"Could not load ruleset {r}: {ex.Message}");
LogFailedLoad(r.Name, ex);
}
}
availableRulesets.AddRange(detachedRulesets.OrderBy(r => r));
});
}
private void testRulesetCompatibility(RulesetInfo rulesetInfo)
{
// do various operations to ensure that we are in a good state.
// if we can avoid loading the ruleset at this point (rather than erroring later in runtime) then that is preferred.
var instance = rulesetInfo.CreateInstance();
instance.CreateAllMods();
instance.CreateIcon();
instance.CreateResourceStore();
var beatmap = new Beatmap();
var converter = instance.CreateBeatmapConverter(beatmap);
instance.CreateBeatmapProcessor(converter.Convert());
}
}
}

View File

@ -28,6 +28,7 @@ using osu.Game.Users;
using JetBrains.Annotations;
using osu.Framework.Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Localisation;
using osu.Framework.Testing;
using osu.Game.Extensions;
using osu.Game.Rulesets.Filter;
@ -288,7 +289,7 @@ namespace osu.Game.Rulesets
/// </summary>
/// <param name="variant">The variant.</param>
/// <returns>A descriptive name of the variant.</returns>
public virtual string GetVariantName(int variant) => string.Empty;
public virtual LocalisableString GetVariantName(int variant) => string.Empty;
/// <summary>
/// For rulesets which support legacy (osu-stable) replay conversion, this method will create an empty replay frame
@ -313,7 +314,7 @@ namespace osu.Game.Rulesets
/// <returns>
/// All valid <see cref="HitResult"/>s along with a display-friendly name.
/// </returns>
public IEnumerable<(HitResult result, string displayName)> GetHitResults()
public IEnumerable<(HitResult result, LocalisableString displayName)> GetHitResults()
{
var validResults = GetValidHitResults();
@ -351,7 +352,7 @@ namespace osu.Game.Rulesets
/// </summary>
/// <param name="result">The result type to get the name for.</param>
/// <returns>The display name.</returns>
public virtual string GetDisplayNameForHitResult(HitResult result) => result.GetDescription();
public virtual LocalisableString GetDisplayNameForHitResult(HitResult result) => result.GetLocalisableDescription();
/// <summary>
/// Creates ruleset-specific beatmap filter criteria to be used on the song select screen.

View File

@ -138,7 +138,7 @@ namespace osu.Game.Rulesets
}
catch (Exception e)
{
Logger.Error(e, $"Failed to load ruleset {filename}");
LogFailedLoad(filename, e);
}
}
@ -158,7 +158,7 @@ namespace osu.Game.Rulesets
}
catch (Exception e)
{
Logger.Error(e, $"Failed to add ruleset {assembly}");
LogFailedLoad(assembly.FullName, e);
}
}
@ -173,6 +173,12 @@ namespace osu.Game.Rulesets
AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly;
}
protected void LogFailedLoad(string name, Exception exception)
{
Logger.Log($"Could not load ruleset {name}. Please check for an update from the developer.", level: LogLevel.Error);
Logger.Log($"Ruleset load failed: {exception}");
}
#region Implementation of IRulesetStore
IRulesetInfo? IRulesetStore.GetRuleset(int id) => GetRuleset(id);

View File

@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.UI
this.gameplayStartTime = gameplayStartTime;
}
[BackgroundDependencyLoader]
[BackgroundDependencyLoader(true)]
private void load(IGameplayClock? gameplayClock)
{
if (gameplayClock != null)

View File

@ -3,6 +3,7 @@
#nullable disable
using osu.Framework.Localisation;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Scoring
@ -30,9 +31,9 @@ namespace osu.Game.Scoring
/// <summary>
/// A custom display name for the result type. May be provided by rulesets to give better clarity.
/// </summary>
public string DisplayName { get; }
public LocalisableString DisplayName { get; }
public HitResultDisplayStatistic(HitResult result, int count, int? maxCount, string displayName)
public HitResultDisplayStatistic(HitResult result, int count, int? maxCount, LocalisableString displayName)
{
Result = result;
Count = count;

View File

@ -1,96 +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.
using System;
using osu.Framework.Bindables;
using osu.Framework.Timing;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A <see cref="ISpectatorPlayerClock"/> which catches up using rate adjustment.
/// </summary>
public class CatchUpSpectatorPlayerClock : ISpectatorPlayerClock
{
/// <summary>
/// The catch up rate.
/// </summary>
public const double CATCHUP_RATE = 2;
/// <summary>
/// The source clock.
/// </summary>
public IFrameBasedClock? Source { get; set; }
public double CurrentTime { get; private set; }
public bool IsRunning { get; private set; }
public void Reset() => CurrentTime = 0;
public void Start() => IsRunning = true;
public void Stop() => IsRunning = false;
void IAdjustableClock.Start()
{
// Our running state should only be managed by an ISyncManager, ignore calls from external sources.
}
void IAdjustableClock.Stop()
{
// Our running state should only be managed by an ISyncManager, ignore calls from external sources.
}
public bool Seek(double position)
{
CurrentTime = position;
return true;
}
public void ResetSpeedAdjustments()
{
}
public double Rate => IsCatchingUp ? CATCHUP_RATE : 1;
double IAdjustableClock.Rate
{
get => Rate;
set => throw new NotSupportedException();
}
double IClock.Rate => Rate;
public void ProcessFrame()
{
ElapsedFrameTime = 0;
FramesPerSecond = 0;
if (Source == null)
return;
Source.ProcessFrame();
if (IsRunning)
{
double elapsedSource = Source.ElapsedFrameTime;
double elapsed = elapsedSource * Rate;
CurrentTime += elapsed;
ElapsedFrameTime = elapsed;
FramesPerSecond = Source.FramesPerSecond;
}
}
public double ElapsedFrameTime { get; private set; }
public double FramesPerSecond { get; private set; }
public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime };
public Bindable<bool> WaitingOnFrames { get; } = new Bindable<bool>(true);
public bool IsCatchingUp { get; set; }
}
}

View File

@ -1,44 +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
using osu.Framework.Bindables;
using osu.Framework.Timing;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A clock which is used by <see cref="MultiSpectatorPlayer"/>s and managed by an <see cref="ISyncManager"/>.
/// </summary>
public interface ISpectatorPlayerClock : IFrameBasedClock, IAdjustableClock
{
/// <summary>
/// Starts this <see cref="ISpectatorPlayerClock"/>.
/// </summary>
new void Start();
/// <summary>
/// Stops this <see cref="ISpectatorPlayerClock"/>.
/// </summary>
new void Stop();
/// <summary>
/// Whether this clock is waiting on frames to continue playback.
/// </summary>
Bindable<bool> WaitingOnFrames { get; }
/// <summary>
/// Whether this clock is behind the master clock and running at a higher rate to catch up to it.
/// </summary>
/// <remarks>
/// Of note, this will be false if this clock is *ahead* of the master clock.
/// </remarks>
bool IsCatchingUp { get; set; }
/// <summary>
/// The source clock
/// </summary>
IFrameBasedClock Source { set; }
}
}

View File

@ -1,44 +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
using System;
using osu.Framework.Bindables;
using osu.Framework.Timing;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// Manages the synchronisation between one or more <see cref="ISpectatorPlayerClock"/>s in relation to a master clock.
/// </summary>
public interface ISyncManager
{
/// <summary>
/// An event which is invoked when gameplay is ready to start.
/// </summary>
event Action ReadyToStart;
/// <summary>
/// The master clock which player clocks should synchronise to.
/// </summary>
IAdjustableClock MasterClock { get; }
/// <summary>
/// An event which is invoked when the state of <see cref="MasterClock"/> is changed.
/// </summary>
IBindable<MasterClockState> MasterState { get; }
/// <summary>
/// Adds an <see cref="ISpectatorPlayerClock"/> to manage.
/// </summary>
/// <param name="clock">The <see cref="ISpectatorPlayerClock"/> to add.</param>
void AddPlayerClock(ISpectatorPlayerClock clock);
/// <summary>
/// Removes an <see cref="ISpectatorPlayerClock"/>, stopping it from being managed by this <see cref="ISyncManager"/>.
/// </summary>
/// <param name="clock">The <see cref="ISpectatorPlayerClock"/> to remove.</param>
void RemovePlayerClock(ISpectatorPlayerClock clock);
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
@ -14,15 +13,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// </summary>
public class MultiSpectatorPlayer : SpectatorPlayer
{
private readonly Bindable<bool> waitingOnFrames = new Bindable<bool>(true);
private readonly ISpectatorPlayerClock spectatorPlayerClock;
private readonly SpectatorPlayerClock spectatorPlayerClock;
/// <summary>
/// Creates a new <see cref="MultiSpectatorPlayer"/>.
/// </summary>
/// <param name="score">The score containing the player's replay.</param>
/// <param name="spectatorPlayerClock">The clock controlling the gameplay running state.</param>
public MultiSpectatorPlayer(Score score, ISpectatorPlayerClock spectatorPlayerClock)
public MultiSpectatorPlayer(Score score, SpectatorPlayerClock spectatorPlayerClock)
: base(score, new PlayerConfiguration { AllowUserInteraction = false })
{
this.spectatorPlayerClock = spectatorPlayerClock;
@ -31,8 +29,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
[BackgroundDependencyLoader]
private void load()
{
spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames);
HUDOverlay.PlayerSettingsOverlay.Expire();
HUDOverlay.HoldToQuit.Expire();
}
@ -40,9 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
protected override void Update()
{
// The player clock's running state is controlled externally, but the local pausing state needs to be updated to start/stop gameplay.
CatchUpSpectatorPlayerClock catchUpClock = (CatchUpSpectatorPlayerClock)GameplayClockContainer.SourceClock;
if (catchUpClock.IsRunning)
if (GameplayClockContainer.SourceClock.IsRunning)
GameplayClockContainer.Start();
else
GameplayClockContainer.Stop();
@ -55,7 +49,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
base.UpdateAfterChildren();
// This is required because the frame stable clock is set to WaitingOnFrames = false for one frame.
waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0;
spectatorPlayerClock.WaitingOnFrames = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0;
}
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)

View File

@ -1,16 +1,12 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
@ -42,18 +38,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
protected override UserActivity InitialActivity => new UserActivity.SpectatingMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value);
[Resolved]
private OsuColour colours { get; set; }
private OsuColour colours { get; set; } = null!;
[Resolved]
private MultiplayerClient multiplayerClient { get; set; }
private MultiplayerClient multiplayerClient { get; set; } = null!;
private readonly PlayerArea[] instances;
private MasterGameplayClockContainer masterClockContainer;
private ISyncManager syncManager;
private PlayerGrid grid;
private MultiSpectatorLeaderboard leaderboard;
private PlayerArea currentAudioSource;
private bool canStartMasterClock;
private MasterGameplayClockContainer masterClockContainer = null!;
private SpectatorSyncManager syncManager = null!;
private PlayerGrid grid = null!;
private MultiSpectatorLeaderboard leaderboard = null!;
private PlayerArea? currentAudioSource;
private readonly Room room;
private readonly MultiplayerRoomUser[] users;
@ -78,57 +73,58 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
FillFlowContainer leaderboardFlow;
Container scoreDisplayContainer;
masterClockContainer = CreateMasterGameplayClockContainer(Beatmap.Value);
InternalChildren = new[]
InternalChildren = new Drawable[]
{
(Drawable)(syncManager = new CatchUpSyncManager(masterClockContainer)),
masterClockContainer.WithChild(new GridContainer
masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
{
RelativeSizeAxes = Axes.Both,
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
Content = new[]
Child = new GridContainer
{
new Drawable[]
RelativeSizeAxes = Axes.Both,
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
Content = new[]
{
scoreDisplayContainer = new Container
new Drawable[]
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
},
},
new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
Content = new[]
scoreDisplayContainer = new Container
{
new Drawable[]
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
},
},
new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
Content = new[]
{
leaderboardFlow = new FillFlowContainer
new Drawable[]
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5)
},
grid = new PlayerGrid { RelativeSizeAxes = Axes.Both }
leaderboardFlow = new FillFlowContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5)
},
grid = new PlayerGrid { RelativeSizeAxes = Axes.Both }
}
}
}
}
}
}
})
},
syncManager = new SpectatorSyncManager(masterClockContainer)
{
ReadyToStart = performInitialSeek,
}
};
for (int i = 0; i < Users.Count; i++)
{
grid.Add(instances[i] = new PlayerArea(Users[i], masterClockContainer));
syncManager.AddPlayerClock(instances[i].GameplayClock);
}
grid.Add(instances[i] = new PlayerArea(Users[i], syncManager.CreateManagedClock()));
LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(users)
{
@ -161,9 +157,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
base.LoadComplete();
masterClockContainer.Reset();
syncManager.ReadyToStart += onReadyToStart;
syncManager.MasterState.BindValueChanged(onMasterStateChanged, true);
}
protected override void Update()
@ -173,7 +166,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
if (!isCandidateAudioSource(currentAudioSource?.GameplayClock))
{
currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock))
.OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.MasterClock.CurrentTime))
.OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.CurrentMasterTime))
.FirstOrDefault();
foreach (var instance in instances)
@ -181,40 +174,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}
}
private bool isCandidateAudioSource([CanBeNull] ISpectatorPlayerClock clock)
=> clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value;
private bool isCandidateAudioSource(SpectatorPlayerClock? clock)
=> clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames;
private void onReadyToStart()
private void performInitialSeek()
{
// Seek the master clock to the gameplay time.
// This is chosen as the first available frame in the players' replays, which matches the seek by each individual SpectatorPlayer.
double startTime = instances.Where(i => i.Score != null)
.SelectMany(i => i.Score.Replay.Frames)
.SelectMany(i => i.Score.AsNonNull().Replay.Frames)
.Select(f => f.Time)
.DefaultIfEmpty(0)
.Min();
masterClockContainer.StartTime = startTime;
masterClockContainer.Reset(true);
// Although the clock has been started, this flag is set to allow for later synchronisation state changes to also be able to start it.
canStartMasterClock = true;
}
private void onMasterStateChanged(ValueChangedEvent<MasterClockState> state)
{
switch (state.NewValue)
{
case MasterClockState.Synchronised:
if (canStartMasterClock)
masterClockContainer.Start();
break;
case MasterClockState.TooFarAhead:
masterClockContainer.Stop();
break;
}
}
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
@ -242,7 +216,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
var instance = instances.Single(i => i.UserId == userId);
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
syncManager.RemovePlayerClock(instance.GameplayClock);
syncManager.RemoveManagedClock(instance.GameplayClock);
}
public override bool OnBackButton()
@ -256,7 +230,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
return base.OnBackButton();
}
protected virtual MasterGameplayClockContainer CreateMasterGameplayClockContainer(WorkingBeatmap beatmap) => new MasterGameplayClockContainer(beatmap, 0);
}
}

View File

@ -1,17 +1,13 @@
// 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 JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
@ -29,7 +25,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// <summary>
/// Raised after <see cref="Player.StartGameplay"/> is called on <see cref="Player"/>.
/// </summary>
public event Action OnGameplayStarted;
public event Action? OnGameplayStarted;
/// <summary>
/// Whether a <see cref="Player"/> is loaded in the area.
@ -42,25 +38,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public readonly int UserId;
/// <summary>
/// The <see cref="ISpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
/// The <see cref="SpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
/// </summary>
[NotNull]
public readonly ISpectatorPlayerClock GameplayClock = new CatchUpSpectatorPlayerClock();
public readonly SpectatorPlayerClock GameplayClock;
/// <summary>
/// The currently-loaded score.
/// </summary>
[CanBeNull]
public Score Score { get; private set; }
public Score? Score { get; private set; }
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
private readonly BindableDouble volumeAdjustment = new BindableDouble();
private readonly Container gameplayContent;
private readonly LoadingLayer loadingLayer;
private OsuScreenStack stack;
private OsuScreenStack? stack;
public PlayerArea(int userId, IFrameBasedClock masterClock)
public PlayerArea(int userId, SpectatorPlayerClock clock)
{
UserId = userId;
GameplayClock = clock;
RelativeSizeAxes = Axes.Both;
Masking = true;
@ -77,14 +75,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
};
audioContainer.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment);
GameplayClock.Source = masterClock;
}
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; }
public void LoadScore([NotNull] Score score)
public void LoadScore(Score score)
{
if (Score != null)
throw new InvalidOperationException($"Cannot load a new score on a {nameof(PlayerArea)} that has an existing score.");

View File

@ -0,0 +1,100 @@
// 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.Timing;
using osu.Game.Screens.Play;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A clock which catches up using rate adjustment.
/// </summary>
public class SpectatorPlayerClock : IFrameBasedClock, IAdjustableClock
{
/// <summary>
/// The catch up rate.
/// </summary>
private const double catchup_rate = 2;
private readonly GameplayClockContainer masterClock;
public double CurrentTime { get; private set; }
/// <summary>
/// Whether this clock is waiting on frames to continue playback.
/// </summary>
public bool WaitingOnFrames { get; set; } = true;
/// <summary>
/// Whether this clock is behind the master clock and running at a higher rate to catch up to it.
/// </summary>
/// <remarks>
/// Of note, this will be false if this clock is *ahead* of the master clock.
/// </remarks>
public bool IsCatchingUp { get; set; }
/// <summary>
/// Whether this spectator clock should be running.
/// Use instead of <see cref="Start"/> / <see cref="Stop"/> to control time.
/// </summary>
public bool IsRunning { get; set; }
public SpectatorPlayerClock(GameplayClockContainer masterClock)
{
this.masterClock = masterClock;
}
public void Reset() => CurrentTime = 0;
public void Start()
{
// Our running state should only be managed by SpectatorSyncManager via IsRunning.
}
public void Stop()
{
// Our running state should only be managed by an SpectatorSyncManager via IsRunning.
}
public bool Seek(double position)
{
CurrentTime = position;
return true;
}
public void ResetSpeedAdjustments()
{
}
public double Rate
{
get => IsCatchingUp ? catchup_rate : 1;
set => throw new NotImplementedException();
}
public void ProcessFrame()
{
if (IsRunning)
{
double elapsedSource = masterClock.ElapsedFrameTime;
double elapsed = elapsedSource * Rate;
CurrentTime += elapsed;
ElapsedFrameTime = elapsed;
FramesPerSecond = masterClock.FramesPerSecond;
}
else
{
ElapsedFrameTime = 0;
FramesPerSecond = 0;
}
}
public double ElapsedFrameTime { get; private set; }
public double FramesPerSecond { get; private set; }
public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime };
}
}

View File

@ -1,22 +1,19 @@
// 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 osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Framework.Logging;
using osu.Game.Screens.Play;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A <see cref="ISyncManager"/> which synchronises de-synced player clocks through catchup.
/// Manages the synchronisation between one or more <see cref="SpectatorPlayerClock"/>s in relation to a master clock.
/// </summary>
public class CatchUpSyncManager : Component, ISyncManager
public class SpectatorSyncManager : Component
{
/// <summary>
/// The offset from the master clock to which player clocks should remain within to be considered in-sync.
@ -33,40 +30,53 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// </summary>
public const double MAXIMUM_START_DELAY = 15000;
public event Action ReadyToStart;
/// <summary>
/// An event which is invoked when gameplay is ready to start.
/// </summary>
public Action? ReadyToStart;
public double CurrentMasterTime => masterClock.CurrentTime;
/// <summary>
/// The master clock which is used to control the timing of all player clocks clocks.
/// </summary>
public IAdjustableClock MasterClock { get; }
public IBindable<MasterClockState> MasterState => masterState;
private readonly GameplayClockContainer masterClock;
/// <summary>
/// The player clocks.
/// </summary>
private readonly List<ISpectatorPlayerClock> playerClocks = new List<ISpectatorPlayerClock>();
private readonly List<SpectatorPlayerClock> playerClocks = new List<SpectatorPlayerClock>();
private readonly Bindable<MasterClockState> masterState = new Bindable<MasterClockState>();
private MasterClockState masterState = MasterClockState.Synchronised;
private bool hasStarted;
private double? firstStartAttemptTime;
public CatchUpSyncManager(IAdjustableClock master)
public SpectatorSyncManager(GameplayClockContainer master)
{
MasterClock = master;
masterClock = master;
}
public void AddPlayerClock(ISpectatorPlayerClock clock)
/// <summary>
/// Create a new managed <see cref="SpectatorPlayerClock"/>.
/// </summary>
/// <returns>The newly created <see cref="SpectatorPlayerClock"/>.</returns>
public SpectatorPlayerClock CreateManagedClock()
{
Debug.Assert(!playerClocks.Contains(clock));
var clock = new SpectatorPlayerClock(masterClock);
playerClocks.Add(clock);
return clock;
}
public void RemovePlayerClock(ISpectatorPlayerClock clock)
/// <summary>
/// Removes an <see cref="SpectatorPlayerClock"/>, stopping it from being managed by this <see cref="SpectatorSyncManager"/>.
/// </summary>
/// <param name="clock">The <see cref="SpectatorPlayerClock"/> to remove.</param>
public void RemoveManagedClock(SpectatorPlayerClock clock)
{
playerClocks.Remove(clock);
clock.Stop();
clock.IsRunning = false;
}
protected override void Update()
@ -77,7 +87,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
// Ensure all player clocks are stopped until the start succeeds.
foreach (var clock in playerClocks)
clock.Stop();
clock.IsRunning = false;
return;
}
@ -97,7 +107,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
if (playerClocks.Count == 0)
return false;
int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value);
int readyCount = playerClocks.Count(s => !s.WaitingOnFrames);
if (readyCount == playerClocks.Count)
return performStart();
@ -130,7 +140,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
// How far this player's clock is out of sync, compared to the master clock.
// A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up).
double timeDelta = MasterClock.CurrentTime - clock.CurrentTime;
double timeDelta = masterClock.CurrentTime - clock.CurrentTime;
// Check that the player clock isn't too far ahead.
// This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock.
@ -139,15 +149,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
// Importantly, set the clock to a non-catchup state. if this isn't done, updateMasterState may incorrectly pause the master clock
// when it is required to be running (ie. if all players are ahead of the master).
clock.IsCatchingUp = false;
clock.Stop();
clock.IsRunning = false;
continue;
}
// Make sure the player clock is running if it can.
if (!clock.WaitingOnFrames.Value)
clock.Start();
else
clock.Stop();
clock.IsRunning = !clock.WaitingOnFrames;
if (clock.IsCatchingUp)
{
@ -169,8 +176,26 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// </summary>
private void updateMasterState()
{
bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp);
masterState.Value = anyInSync ? MasterClockState.Synchronised : MasterClockState.TooFarAhead;
MasterClockState newState = playerClocks.Any(s => !s.IsCatchingUp) ? MasterClockState.Synchronised : MasterClockState.TooFarAhead;
if (masterState == newState)
return;
masterState = newState;
Logger.Log($"{nameof(SpectatorSyncManager)}'s master clock become {masterState}");
switch (masterState)
{
case MasterClockState.Synchronised:
if (hasStarted)
masterClock.Start();
break;
case MasterClockState.TooFarAhead:
masterClock.Stop();
break;
}
}
}
}

View File

@ -8,6 +8,7 @@ using osu.Framework.Allocation;
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 osuTK;
@ -31,7 +32,7 @@ namespace osu.Game.Screens.Play
protected const float TRANSITION_TIME = 500;
protected abstract string Message { get; }
protected abstract LocalisableString Message { get; }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;

View File

@ -8,7 +8,6 @@ using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
@ -84,7 +83,10 @@ namespace osu.Game.Screens.Play
api.Queue(req);
tcs.Task.WaitSafely();
// 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)"));
return true;
void handleTokenFailure(Exception exception)

View File

@ -8,6 +8,7 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
@ -59,7 +60,7 @@ namespace osu.Game.Screens.Ranking.Statistics
private static Drawable createHeader(StatisticItem item)
{
if (string.IsNullOrEmpty(item.Name))
if (LocalisableString.IsNullOrEmpty(item.Name))
return Empty();
return new FillFlowContainer

View File

@ -7,6 +7,7 @@ using System;
using JetBrains.Annotations;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Localisation;
namespace osu.Game.Screens.Ranking.Statistics
{
@ -18,7 +19,7 @@ namespace osu.Game.Screens.Ranking.Statistics
/// <summary>
/// The name of this item.
/// </summary>
public readonly string Name;
public readonly LocalisableString Name;
/// <summary>
/// A function returning the <see cref="Drawable"/> content to be displayed.
@ -44,11 +45,11 @@ namespace osu.Game.Screens.Ranking.Statistics
/// <summary>
/// Creates a new <see cref="StatisticItem"/>, to be displayed inside a <see cref="StatisticRow"/> in the results screen.
/// </summary>
/// <param name="name">The name of the item. Can be <see cref="string.Empty"/> to hide the item header.</param>
/// <param name="name">The name of the item. Can be <see langword="null"/> to hide the item header.</param>
/// <param name="createContent">A function returning the <see cref="Drawable"/> content to be displayed.</param>
/// <param name="requiresHitEvents">Whether this item requires hit events. If true, <see cref="CreateContent"/> will not be called if no hit events are available.</param>
/// <param name="dimension">The <see cref="Dimension"/> of this item. This can be thought of as the column dimension of an encompassing <see cref="GridContainer"/>.</param>
public StatisticItem([NotNull] string name, [NotNull] Func<Drawable> createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null)
public StatisticItem(LocalisableString name, [NotNull] Func<Drawable> createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null)
{
Name = name;
RequiresHitEvents = requiresHitEvents;

View File

@ -55,8 +55,6 @@ namespace osu.Game.Screens.Select.Carousel
match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) ||
criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode);
match &= criteria.Sort != SortMode.DateRanked || BeatmapInfo.BeatmapSet?.DateRanked != null;
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
if (match && criteria.SearchTerms.Length > 0)

View File

@ -99,6 +99,13 @@ namespace osu.Game.Screens.Select.Carousel
case SortMode.Difficulty:
return compareUsingAggregateMax(otherSet, b => b.StarRating);
case SortMode.DateSubmitted:
// Beatmaps which have no submitted date should already be filtered away in this mode.
if (BeatmapSet.DateSubmitted == null || otherSet.BeatmapSet.DateSubmitted == null)
return 0;
return otherSet.BeatmapSet.DateSubmitted.Value.CompareTo(BeatmapSet.DateSubmitted.Value);
}
}
@ -122,7 +129,12 @@ namespace osu.Game.Screens.Select.Carousel
public override void Filter(FilterCriteria criteria)
{
base.Filter(criteria);
Filtered.Value = Items.All(i => i.Filtered.Value);
bool match = Items.All(i => i.Filtered.Value);
match &= criteria.Sort != SortMode.DateRanked || BeatmapSet?.DateRanked != null;
match &= criteria.Sort != SortMode.DateSubmitted || BeatmapSet?.DateSubmitted != null;
Filtered.Value = match;
}
public override string ToString() => BeatmapSet.ToString();

View File

@ -20,6 +20,9 @@ namespace osu.Game.Screens.Select.Filter
[LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))]
BPM,
[Description("Date Submitted")]
DateSubmitted,
[Description("Date Added")]
DateAdded,

View File

@ -928,7 +928,7 @@ namespace osu.Game.Screens.Select
}
}
private class SoloModSelectOverlay : UserModSelectOverlay
internal class SoloModSelectOverlay : UserModSelectOverlay
{
protected override bool ShowPresets => true;
}