Merge branch 'master' into fix_progress_bar_info

This commit is contained in:
Dean Herbert
2022-08-26 16:14:21 +09:00
412 changed files with 6091 additions and 2767 deletions

View File

@ -105,5 +105,11 @@ namespace osu.Game.Audio
/// Retrieves the audio track.
/// </summary>
protected abstract Track? GetTrack();
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
Track?.Dispose();
}
}
}

View File

@ -0,0 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Beatmaps
{
public static class BeatSyncProviderExtensions
{
/// <summary>
/// Check whether beat sync is currently available.
/// </summary>
public static bool CheckBeatSyncAvailable(this IBeatSyncProvider provider) => provider.Clock != null;
/// <summary>
/// Whether the beat sync provider is currently in a kiai section. Should make everything more epic.
/// </summary>
public static bool CheckIsKiaiTime(this IBeatSyncProvider provider) => provider.Clock != null && provider.ControlPoints?.EffectPointAt(provider.Clock.CurrentTime).KiaiMode == true;
}
}

View File

@ -102,6 +102,14 @@ namespace osu.Game.Beatmaps
public string OnlineMD5Hash { get; set; } = string.Empty;
/// <summary>
/// The last time of a local modification (via the editor).
/// </summary>
public DateTimeOffset? LastLocalUpdate { get; set; }
/// <summary>
/// The last time online metadata was applied to this beatmap.
/// </summary>
public DateTimeOffset? LastOnlineUpdate { get; set; }
/// <summary>
@ -121,7 +129,8 @@ namespace osu.Game.Beatmaps
OnlineID = -1;
LastOnlineUpdate = null;
OnlineMD5Hash = string.Empty;
Status = BeatmapOnlineStatus.None;
if (Status != BeatmapOnlineStatus.LocallyModified)
Status = BeatmapOnlineStatus.None;
}
#region Properties we may not want persisted (but also maybe no harm?)
@ -198,8 +207,8 @@ namespace osu.Game.Beatmaps
Debug.Assert(x.BeatmapSet != null);
Debug.Assert(y.BeatmapSet != null);
string? fileHashX = x.BeatmapSet.Files.FirstOrDefault(f => f.Filename == getFilename(x.Metadata))?.File.Hash;
string? fileHashY = y.BeatmapSet.Files.FirstOrDefault(f => f.Filename == getFilename(y.Metadata))?.File.Hash;
string? fileHashX = x.BeatmapSet.GetFile(getFilename(x.Metadata))?.File.Hash;
string? fileHashY = y.BeatmapSet.GetFile(getFilename(y.Metadata))?.File.Hash;
return fileHashX == fileHashY;
}

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Linq;
using osu.Framework.Localisation;

View File

@ -94,6 +94,7 @@ namespace osu.Game.Beatmaps
var beatmapSet = new BeatmapSetInfo
{
DateAdded = DateTimeOffset.UtcNow,
Beatmaps =
{
new BeatmapInfo(ruleset, new BeatmapDifficulty(), metadata)
@ -300,7 +301,7 @@ namespace osu.Game.Beatmaps
stream.Seek(0, SeekOrigin.Begin);
// AddFile generally handles updating/replacing files, but this is a case where the filename may have also changed so let's delete for simplicity.
var existingFileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase));
var existingFileInfo = beatmapInfo.Path != null ? setInfo.GetFile(beatmapInfo.Path) : null;
string targetFilename = createBeatmapFilenameFromMetadata(beatmapInfo);
// ensure that two difficulties from the set don't point at the same beatmap file.
@ -313,9 +314,13 @@ namespace osu.Game.Beatmaps
beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
beatmapInfo.Hash = stream.ComputeSHA2Hash();
beatmapInfo.LastLocalUpdate = DateTimeOffset.Now;
beatmapInfo.Status = BeatmapOnlineStatus.LocallyModified;
AddFile(setInfo, stream, createBeatmapFilenameFromMetadata(beatmapInfo));
setInfo.Hash = beatmapImporter.ComputeHash(setInfo);
setInfo.Status = BeatmapOnlineStatus.LocallyModified;
Realm.Write(r =>
{

View File

@ -3,13 +3,23 @@
#nullable disable
using System.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Beatmaps
{
public enum BeatmapOnlineStatus
{
/// <summary>
/// This is a special status given when local changes are made via the editor.
/// Once in this state, online status changes should be ignored unless the beatmap is reverted or submitted.
/// </summary>
[Description("Local")]
[LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LocallyModified))]
LocallyModified = -4,
None = -3,
[LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusGraveyard))]

View File

@ -84,13 +84,6 @@ namespace osu.Game.Beatmaps
{
}
/// <summary>
/// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null.
/// The path returned is relative to the user file storage.
/// </summary>
/// <param name="filename">The name of the file to get the storage path of.</param>
public string? GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath();
public bool Equals(BeatmapSetInfo? other)
{
if (ReferenceEquals(this, other)) return true;

View File

@ -0,0 +1,33 @@
// 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.Linq;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Models;
namespace osu.Game.Beatmaps
{
public static class BeatmapSetInfoExtensions
{
/// <summary>
/// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null.
/// The path returned is relative to the user file storage.
/// The lookup is case insensitive.
/// </summary>
/// <param name="model">The model to operate on.</param>
/// <param name="filename">The name of the file to get the storage path of.</param>
public static string? GetPathForFile(this IHasRealmFiles model, string filename) => model.GetFile(filename)?.File.GetStoragePath();
/// <summary>
/// Returns the file usage for the file in this beatmapset with the given filename, if any exists, otherwise null.
/// The path returned is relative to the user file storage.
/// The lookup is case insensitive.
/// </summary>
/// <param name="model">The model to operate on.</param>
/// <param name="filename">The name of the file to get the storage path of.</param>
public static RealmNamedFileUsage? GetFile(this IHasRealmFiles model, string filename) =>
model.Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase));
}
}

View File

@ -6,6 +6,7 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using osu.Framework.Development;
@ -51,6 +52,9 @@ namespace osu.Game.Beatmaps
/// <summary>
/// Queue an update for a beatmap set.
/// </summary>
/// <remarks>
/// This may happen during initial import, or at a later stage in response to a user action or server event.
/// </remarks>
/// <param name="beatmapSet">The beatmap set to update. Updates will be applied directly (so a transaction should be started if this instance is managed).</param>
/// <param name="preferOnlineFetch">Whether metadata from an online source should be preferred. If <c>true</c>, the local cache will be skipped to ensure the freshest data state possible.</param>
public void Update(BeatmapSetInfo beatmapSet, bool preferOnlineFetch)
@ -89,21 +93,26 @@ namespace osu.Game.Beatmaps
if (res != null)
{
beatmapInfo.Status = res.Status;
Debug.Assert(beatmapInfo.BeatmapSet != null);
beatmapInfo.BeatmapSet.Status = res.BeatmapSet?.Status ?? BeatmapOnlineStatus.None;
beatmapInfo.BeatmapSet.OnlineID = res.OnlineBeatmapSetID;
beatmapInfo.BeatmapSet.DateRanked = res.BeatmapSet?.Ranked;
beatmapInfo.BeatmapSet.DateSubmitted = res.BeatmapSet?.Submitted;
beatmapInfo.OnlineID = res.OnlineID;
beatmapInfo.OnlineMD5Hash = res.MD5Hash;
beatmapInfo.LastOnlineUpdate = res.LastUpdated;
beatmapInfo.OnlineID = res.OnlineID;
Debug.Assert(beatmapInfo.BeatmapSet != null);
beatmapInfo.BeatmapSet.OnlineID = res.OnlineBeatmapSetID;
beatmapInfo.Metadata.Author.OnlineID = res.AuthorID;
// Some metadata should only be applied if there's no local changes.
if (shouldSaveOnlineMetadata(beatmapInfo))
{
beatmapInfo.Status = res.Status;
beatmapInfo.Metadata.Author.OnlineID = res.AuthorID;
}
if (beatmapInfo.BeatmapSet.Beatmaps.All(shouldSaveOnlineMetadata))
{
beatmapInfo.BeatmapSet.Status = res.BeatmapSet?.Status ?? BeatmapOnlineStatus.None;
beatmapInfo.BeatmapSet.DateRanked = res.BeatmapSet?.Ranked;
beatmapInfo.BeatmapSet.DateSubmitted = res.BeatmapSet?.Submitted;
}
logForModel(set, $"Online retrieval mapped {beatmapInfo} to {res.OnlineBeatmapSetID} / {res.OnlineID}.");
}
@ -202,19 +211,26 @@ namespace osu.Game.Beatmaps
{
var status = (BeatmapOnlineStatus)reader.GetByte(2);
beatmapInfo.Status = status;
// Some metadata should only be applied if there's no local changes.
if (shouldSaveOnlineMetadata(beatmapInfo))
{
beatmapInfo.Status = status;
beatmapInfo.Metadata.Author.OnlineID = reader.GetInt32(3);
}
Debug.Assert(beatmapInfo.BeatmapSet != null);
beatmapInfo.BeatmapSet.Status = status;
beatmapInfo.BeatmapSet.OnlineID = reader.GetInt32(0);
// TODO: DateSubmitted and DateRanked are not provided by local cache.
beatmapInfo.OnlineID = reader.GetInt32(1);
beatmapInfo.Metadata.Author.OnlineID = reader.GetInt32(3);
beatmapInfo.OnlineMD5Hash = reader.GetString(4);
beatmapInfo.LastOnlineUpdate = reader.GetDateTimeOffset(5);
Debug.Assert(beatmapInfo.BeatmapSet != null);
beatmapInfo.BeatmapSet.OnlineID = reader.GetInt32(0);
if (beatmapInfo.BeatmapSet.Beatmaps.All(shouldSaveOnlineMetadata))
{
beatmapInfo.BeatmapSet.Status = status;
}
logForModel(set, $"Cached local retrieval for {beatmapInfo}.");
return true;
}
@ -233,6 +249,12 @@ namespace osu.Game.Beatmaps
private void logForModel(BeatmapSetInfo set, string message) =>
RealmArchiveModelImporter<BeatmapSetInfo>.LogForModel(set, $"[{nameof(BeatmapUpdaterMetadataLookup)}] {message}");
/// <summary>
/// Check whether the provided beatmap is in a state where online "ranked" status metadata should be saved against it.
/// Handles the case where a user may have locally modified a beatmap in the editor and expects the local status to stick.
/// </summary>
private static bool shouldSaveOnlineMetadata(BeatmapInfo beatmapInfo) => beatmapInfo.MatchesOnlineVersion || beatmapInfo.Status != BeatmapOnlineStatus.LocallyModified;
public void Dispose()
{
cacheDownloadRequest?.Dispose();

View File

@ -6,15 +6,18 @@ using osu.Framework.Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osuTK.Graphics;
namespace osu.Game.Beatmaps.Drawables
{
public class BeatmapSetOnlineStatusPill : CircularContainer
public class BeatmapSetOnlineStatusPill : CircularContainer, IHasTooltip
{
private BeatmapOnlineStatus status;
@ -96,5 +99,19 @@ namespace osu.Game.Beatmaps.Drawables
background.Colour = OsuColour.ForBeatmapSetOnlineStatus(Status) ?? colourProvider?.Light1 ?? colours.GreySeaFoamLighter;
}
public LocalisableString TooltipText
{
get
{
switch (Status)
{
case BeatmapOnlineStatus.LocallyModified:
return SongSelectStrings.LocallyModifiedTooltip;
}
return string.Empty;
}
}
}
}

View File

@ -118,7 +118,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons
// another async load might have completed before this one.
// if so, do not make any changes.
if (loadedPreview != previewTrack)
{
loadedPreview.Dispose();
return;
}
AddInternal(loadedPreview);
toggleLoading(false);

View File

@ -2,7 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Audio;
using osu.Framework.Timing;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.Containers;
@ -14,12 +14,16 @@ namespace osu.Game.Beatmaps
/// Primarily intended for use with <see cref="BeatSyncedContainer"/>.
/// </summary>
[Cached]
public interface IBeatSyncProvider
public interface IBeatSyncProvider : IHasAmplitudes
{
/// <summary>
/// Access any available control points from a beatmap providing beat sync. If <c>null</c>, no current provider is available.
/// </summary>
ControlPointInfo? ControlPoints { get; }
/// <summary>
/// Access a clock currently responsible for providing beat sync. If <c>null</c>, no current provider is available.
/// </summary>
IClock? Clock { get; }
ChannelAmplitudes? Amplitudes { get; }
}
}

View File

@ -134,6 +134,6 @@ namespace osu.Game.Beatmaps
/// <summary>
/// Reads the correct track restart point from beatmap metadata and sets looping to enabled.
/// </summary>
void PrepareTrackForPreviewLooping();
void PrepareTrackForPreview(bool looping);
}
}

View File

@ -110,9 +110,9 @@ namespace osu.Game.Beatmaps
public Track LoadTrack() => track = GetBeatmapTrack() ?? GetVirtualTrack(1000);
public void PrepareTrackForPreviewLooping()
public void PrepareTrackForPreview(bool looping)
{
Track.Looping = true;
Track.Looping = looping;
Track.RestartPoint = Metadata.PreviewTime;
if (Track.RestartPoint == -1)

View File

@ -9,6 +9,8 @@ using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Rendering.Dummy;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Lists;
@ -56,7 +58,7 @@ namespace osu.Game.Beatmaps
this.resources = resources;
this.host = host;
this.files = files;
largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(files));
largeTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), host?.CreateTextureLoaderStore(files));
this.trackStore = trackStore;
}
@ -110,6 +112,7 @@ namespace osu.Game.Beatmaps
TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore;
ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
IRenderer IStorageResourceProvider.Renderer => host?.Renderer ?? new DummyRenderer();
AudioManager IStorageResourceProvider.AudioManager => audioManager;
RealmAccess IStorageResourceProvider.RealmAccess => null;
IResourceStore<byte[]> IStorageResourceProvider.Files => files;

View File

@ -3,33 +3,17 @@
using System;
using Humanizer;
using osu.Framework.Graphics.Sprites;
using osu.Game.Database;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Collections
{
public class DeleteCollectionDialog : PopupDialog
public class DeleteCollectionDialog : DeleteConfirmationDialog
{
public DeleteCollectionDialog(Live<BeatmapCollection> collection, Action deleteAction)
{
HeaderText = "Confirm deletion of";
BodyText = collection.PerformRead(c => $"{c.Name} ({"beatmap".ToQuantity(c.BeatmapMD5Hashes.Count)})");
Icon = FontAwesome.Regular.TrashAlt;
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
{
Text = @"Yes. Go for it.",
Action = deleteAction
},
new PopupDialogCancelButton
{
Text = @"No! Abort mission!",
},
};
DeleteAction = deleteAction;
}
}
}

View File

@ -3,16 +3,20 @@
#nullable disable
using System.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Configuration
{
public enum BackgroundSource
{
[LocalisableDescription(typeof(SkinSettingsStrings), nameof(SkinSettingsStrings.SkinSectionHeader))]
Skin,
[LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.BeatmapHeader))]
Beatmap,
[Description("Beatmap (with storyboard / video)")]
[LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.BeatmapWithStoryboard))]
BeatmapWithStoryboard,
}
}

View File

@ -3,17 +3,20 @@
#nullable disable
using System.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Configuration
{
public enum DiscordRichPresenceMode
{
[LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.DiscordPresenceOff))]
Off,
[Description("Hide identifiable information")]
[LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.HideIdentifiableInformation))]
Limited,
[LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.DiscordPresenceFull))]
Full
}
}

View File

@ -3,17 +3,20 @@
#nullable disable
using System.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Configuration
{
public enum HUDVisibilityMode
{
[LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.NeverShowHUD))]
Never,
[Description("Hide during gameplay")]
[LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.HideDuringGameplay))]
HideDuringGameplay,
[LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.AlwaysShowHUD))]
Always
}
}

View File

@ -3,16 +3,17 @@
#nullable disable
using System.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Configuration
{
public enum RandomSelectAlgorithm
{
[Description("Never repeat")]
[LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.NeverRepeat))]
RandomPermutation,
[Description("True Random")]
[LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.TrueRandom))]
Random
}
}

View File

@ -3,17 +3,23 @@
#nullable disable
using System.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Configuration
{
public enum ScalingMode
{
[LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScalingOff))]
Off,
[LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleEverything))]
Everything,
[Description("Excluding overlays")]
[LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleEverythingExcludingOverlays))]
ExcludeOverlays,
[LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleGameplay))]
Gameplay,
}
}

View File

@ -3,16 +3,17 @@
#nullable disable
using System.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Configuration
{
public enum ScreenshotFormat
{
[Description("JPG (web-friendly)")]
[LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.Jpg))]
Jpg = 1,
[Description("PNG (lossless)")]
[LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.Png))]
Png = 2
}
}

View File

@ -3,6 +3,9 @@
#nullable disable
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Configuration
{
public enum SeasonalBackgroundMode
@ -10,16 +13,19 @@ namespace osu.Game.Configuration
/// <summary>
/// Seasonal backgrounds are shown regardless of season, if at all available.
/// </summary>
[LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.AlwaysSeasonalBackground))]
Always,
/// <summary>
/// Seasonal backgrounds are shown only during their corresponding season.
/// </summary>
[LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.SometimesSeasonalBackground))]
Sometimes,
/// <summary>
/// Seasonal backgrounds are never shown.
/// </summary>
[LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.NeverSeasonalBackground))]
Never
}
}

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

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Models;
namespace osu.Game.Database
@ -11,8 +12,16 @@ namespace osu.Game.Database
/// </summary>
public interface IHasRealmFiles
{
/// <summary>
/// Available files in this model, with locally filenames.
/// When performing lookups, consider using <see cref="BeatmapSetInfoExtensions.GetFile"/> or <see cref="BeatmapSetInfoExtensions.GetPathForFile"/> to do case-insensitive lookups.
/// </summary>
IList<RealmNamedFileUsage> Files { get; }
/// <summary>
/// A combined hash representing the model, based on the files it contains.
/// Implementation specific.
/// </summary>
string Hash { get; set; }
}
}

View File

@ -89,7 +89,7 @@ namespace osu.Game.Database
if (existing != null)
{
foreach (string newBeatmap in existing.BeatmapMD5Hashes)
foreach (string newBeatmap in collection.BeatmapMD5Hashes)
{
if (!existing.BeatmapMD5Hashes.Contains(newBeatmap))
existing.BeatmapMD5Hashes.Add(newBeatmap);

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.Threading;
@ -27,27 +25,30 @@ namespace osu.Game.Database
public class LegacyImportManager : Component
{
[Resolved]
private SkinManager skins { get; set; }
private SkinManager skins { get; set; } = null!;
[Resolved]
private BeatmapManager beatmaps { get; set; }
private BeatmapManager beatmaps { get; set; } = null!;
[Resolved]
private ScoreManager scores { get; set; }
[Resolved(canBeNull: true)]
private OsuGame game { get; set; }
private ScoreManager scores { get; set; } = null!;
[Resolved]
private IDialogOverlay dialogOverlay { get; set; }
private OsuGame? game { get; set; }
[Resolved]
private RealmAccess realmAccess { get; set; }
private IDialogOverlay dialogOverlay { get; set; } = null!;
[Resolved(canBeNull: true)]
private DesktopGameHost desktopGameHost { get; set; }
[Resolved]
private RealmAccess realmAccess { get; set; } = null!;
private StableStorage cachedStorage;
[Resolved(canBeNull: true)] // canBeNull required while we remain on mono for mobile platforms.
private DesktopGameHost? desktopGameHost { get; set; }
[Resolved]
private INotificationOverlay? notifications { get; set; }
private StableStorage? cachedStorage;
public bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
@ -98,6 +99,9 @@ namespace osu.Game.Database
stableStorage = GetCurrentStableStorage();
}
if (stableStorage == null)
return;
var importTasks = new List<Task>();
Task beatmapImportTask = Task.CompletedTask;
@ -108,7 +112,14 @@ namespace osu.Game.Database
importTasks.Add(new LegacySkinImporter(skins).ImportFromStableAsync(stableStorage));
if (content.HasFlagFast(StableContent.Collections))
importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyCollectionImporter(realmAccess).ImportFromStorage(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
{
importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyCollectionImporter(realmAccess)
{
// Other legacy importers import via model managers which handle the posting of notifications.
// Collections are an exception.
PostNotification = n => notifications?.Post(n)
}.ImportFromStorage(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
}
if (content.HasFlagFast(StableContent.Scores))
importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyScoreImporter(scores).ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
@ -116,7 +127,7 @@ namespace osu.Game.Database
await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false);
}
public StableStorage GetCurrentStableStorage()
public StableStorage? GetCurrentStableStorage()
{
if (cachedStorage != null)
return cachedStorage;

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Humanizer;
using osu.Framework.Logging;
@ -107,7 +108,15 @@ namespace osu.Game.Database
notification.State = ProgressNotificationState.Cancelled;
if (!(error is OperationCanceledException))
Logger.Error(error, $"{importer.HumanisedModelName.Titleize()} download failed!");
{
if (error is WebException webException && webException.Message == @"TooManyRequests")
{
notification.Close();
PostNotification?.Invoke(new TooManyDownloadsNotification());
}
else
Logger.Error(error, $"{importer.HumanisedModelName.Titleize()} download failed!");
}
}
}

View File

@ -7,6 +7,7 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Models;
using osu.Game.Overlays.Notifications;
@ -79,7 +80,7 @@ namespace osu.Game.Database
/// </summary>
public void AddFile(TModel item, Stream contents, string filename, Realm realm)
{
var existing = item.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase));
var existing = item.GetFile(filename);
if (existing != null)
{

View File

@ -25,6 +25,7 @@ using osu.Game.Configuration;
using osu.Game.Input.Bindings;
using osu.Game.Models;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Skinning;
using Realms;
@ -67,8 +68,10 @@ namespace osu.Game.Database
/// 20 2022-07-21 Added LastAppliedDifficultyVersion to RulesetInfo, changed default value of BeatmapInfo.StarRating to -1.
/// 21 2022-07-27 Migrate collections to realm (BeatmapCollection).
/// 22 2022-07-31 Added ModPreset.
/// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo.
/// 24 2022-08-22 Added MaximumStatistics to ScoreInfo.
/// </summary>
private const int schema_version = 22;
private const int schema_version = 24;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -172,6 +175,11 @@ namespace osu.Game.Database
if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal))
Filename += realm_extension;
#if DEBUG
if (!DebugUtils.IsNUnitRunning)
applyFilenameSchemaSuffix(ref Filename);
#endif
string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}";
// Attempt to recover a newer database version if available.
@ -211,6 +219,51 @@ namespace osu.Game.Database
}
}
/// <summary>
/// Some developers may be annoyed if a newer version migration (ie. caused by testing a pull request)
/// cause their test database to be unusable with previous versions.
/// To get around this, store development databases against their realm version.
/// Note that this means changes made on newer realm versions will disappear.
/// </summary>
private void applyFilenameSchemaSuffix(ref string filename)
{
string originalFilename = filename;
filename = getVersionedFilename(schema_version);
// First check if the current realm version already exists...
if (storage.Exists(filename))
return;
// Check for a previous version we can use as a base database to migrate from...
for (int i = schema_version - 1; i >= 0; i--)
{
string previousFilename = getVersionedFilename(i);
if (storage.Exists(previousFilename))
{
copyPreviousVersion(previousFilename, filename);
return;
}
}
// Finally, check for a non-versioned file exists (aka before this method was added)...
if (storage.Exists(originalFilename))
copyPreviousVersion(originalFilename, filename);
void copyPreviousVersion(string previousFilename, string newFilename)
{
using (var previous = storage.GetStream(previousFilename))
using (var current = storage.CreateFileSafely(newFilename))
{
Logger.Log(@$"Copying previous realm database {previousFilename} to {newFilename} for migration to schema version {schema_version}");
previous.CopyTo(current);
}
}
string getVersionedFilename(int version) => originalFilename.Replace(realm_extension, $"_{version}{realm_extension}");
}
private void attemptRecoverFromFile(string recoveryFilename)
{
Logger.Log($@"Performing recovery from {recoveryFilename}", LoggingTarget.Database);
@ -292,6 +345,11 @@ namespace osu.Game.Database
foreach (var s in pendingDeleteSkins)
realm.Remove(s);
var pendingDeletePresets = realm.All<ModPreset>().Where(s => s.DeletePending);
foreach (var s in pendingDeletePresets)
realm.Remove(s);
transaction.Commit();
}
@ -844,8 +902,15 @@ namespace osu.Game.Database
try
{
using (var source = storage.GetStream(Filename, mode: FileMode.Open))
using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew))
source.CopyTo(destination);
{
// source may not exist.
if (source == null)
return;
using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew))
source.CopyTo(destination);
}
return;
}
catch (IOException)

View File

@ -0,0 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Overlays.Notifications;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Database
{
public class TooManyDownloadsNotification : SimpleNotification
{
public TooManyDownloadsNotification()
{
Text = BeatmapsetsStrings.DownloadLimitExceeded;
Icon = FontAwesome.Solid.ExclamationCircle;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
IconBackground.Colour = colours.RedDark;
}
}
}

View File

@ -14,9 +14,8 @@ using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Allocation;
using System.Collections.Generic;
using osu.Framework.Graphics.Batches;
using osu.Framework.Graphics.OpenGL.Buffers;
using osu.Framework.Graphics.OpenGL.Vertices;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Rendering.Vertices;
using osu.Framework.Lists;
namespace osu.Game.Graphics.Backgrounds
@ -88,7 +87,7 @@ namespace osu.Game.Graphics.Backgrounds
private Random stableRandom;
private IShader shader;
private readonly Texture texture;
private Texture texture;
/// <summary>
/// Construct a new triangle visualisation.
@ -98,13 +97,12 @@ namespace osu.Game.Graphics.Backgrounds
{
if (seed != null)
stableRandom = new Random(seed.Value);
texture = Texture.WhitePixel;
}
[BackgroundDependencyLoader]
private void load(ShaderManager shaders)
private void load(IRenderer renderer, ShaderManager shaders)
{
texture = renderer.WhitePixel;
shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED);
}
@ -184,8 +182,8 @@ namespace osu.Game.Graphics.Backgrounds
private void addTriangles(bool randomY)
{
// limited by the maximum size of QuadVertexBuffer for safety.
const int max_triangles = QuadVertexBuffer<TexturedVertex2D>.MAX_QUADS;
// Limited by the maximum size of QuadVertexBuffer for safety.
const int max_triangles = ushort.MaxValue / (IRenderer.VERTICES_PER_QUAD + 2);
AimCount = (int)Math.Min(max_triangles, (DrawWidth * DrawHeight * 0.002f / (triangleScale * triangleScale) * SpawnRatio));
@ -251,7 +249,7 @@ namespace osu.Game.Graphics.Backgrounds
private readonly List<TriangleParticle> parts = new List<TriangleParticle>();
private Vector2 size;
private QuadBatch<TexturedVertex2D> vertexBatch;
private IVertexBatch<TexturedVertex2D> vertexBatch;
public TrianglesDrawNode(Triangles source)
: base(source)
@ -270,14 +268,14 @@ namespace osu.Game.Graphics.Backgrounds
parts.AddRange(Source.parts);
}
public override void Draw(Action<TexturedVertex2D> vertexAction)
public override void Draw(IRenderer renderer)
{
base.Draw(vertexAction);
base.Draw(renderer);
if (Source.AimCount > 0 && (vertexBatch == null || vertexBatch.Size != Source.AimCount))
{
vertexBatch?.Dispose();
vertexBatch = new QuadBatch<TexturedVertex2D>(Source.AimCount, 1);
vertexBatch = renderer.CreateQuadBatch<TexturedVertex2D>(Source.AimCount, 1);
}
shader.Bind();
@ -297,7 +295,7 @@ namespace osu.Game.Graphics.Backgrounds
ColourInfo colourInfo = DrawColourInfo.Colour;
colourInfo.ApplyChild(particle.Colour);
DrawTriangle(
renderer.DrawTriangle(
texture,
triangle,
colourInfo,

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Diagnostics;
using osu.Framework.Allocation;
@ -10,13 +8,12 @@ using osu.Framework.Audio.Track;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Screens.Play;
namespace osu.Game.Graphics.Containers
{
/// <summary>
/// A container which fires a callback when a new beat is reached.
/// Consumes a parent <see cref="GameplayClock"/> or <see cref="Beatmap"/> (whichever is first available).
/// Consumes a parent <see cref="IBeatSyncProvider"/>.
/// </summary>
/// <remarks>
/// This container does not set its own clock to the source used for beat matching.
@ -28,8 +25,10 @@ namespace osu.Game.Graphics.Containers
public class BeatSyncedContainer : Container
{
private int lastBeat;
protected TimingControlPoint LastTimingPoint { get; private set; }
protected EffectControlPoint LastEffectPoint { get; private set; }
private TimingControlPoint? lastTimingPoint { get; set; }
protected bool IsKiaiTime { get; private set; }
/// <summary>
/// The amount of time before a beat we should fire <see cref="OnNewBeat(int, TimingControlPoint, EffectControlPoint, ChannelAmplitudes)"/>.
@ -71,12 +70,12 @@ namespace osu.Game.Graphics.Containers
public double MinimumBeatLength { get; set; }
/// <summary>
/// Whether this container is currently tracking a beatmap's timing data.
/// Whether this container is currently tracking a beat sync provider.
/// </summary>
protected bool IsBeatSyncedWithTrack { get; private set; }
[Resolved]
protected IBeatSyncProvider BeatSyncSource { get; private set; }
protected IBeatSyncProvider BeatSyncSource { get; private set; } = null!;
protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{
@ -87,19 +86,18 @@ namespace osu.Game.Graphics.Containers
TimingControlPoint timingPoint;
EffectControlPoint effectPoint;
IsBeatSyncedWithTrack = BeatSyncSource.Clock?.IsRunning == true && BeatSyncSource.ControlPoints != null;
IsBeatSyncedWithTrack = BeatSyncSource.CheckBeatSyncAvailable() && BeatSyncSource.Clock?.IsRunning == true;
double currentTrackTime;
if (IsBeatSyncedWithTrack)
{
Debug.Assert(BeatSyncSource.ControlPoints != null);
Debug.Assert(BeatSyncSource.Clock != null);
currentTrackTime = BeatSyncSource.Clock.CurrentTime + EarlyActivationMilliseconds;
timingPoint = BeatSyncSource.ControlPoints.TimingPointAt(currentTrackTime);
effectPoint = BeatSyncSource.ControlPoints.EffectPointAt(currentTrackTime);
timingPoint = BeatSyncSource.ControlPoints?.TimingPointAt(currentTrackTime) ?? TimingControlPoint.DEFAULT;
effectPoint = BeatSyncSource.ControlPoints?.EffectPointAt(currentTrackTime) ?? EffectControlPoint.DEFAULT;
}
else
{
@ -128,7 +126,7 @@ namespace osu.Game.Graphics.Containers
TimeSinceLastBeat = beatLength - TimeUntilNextBeat;
if (ReferenceEquals(timingPoint, LastTimingPoint) && beatIndex == lastBeat)
if (ReferenceEquals(timingPoint, lastTimingPoint) && beatIndex == lastBeat)
return;
// as this event is sometimes used for sound triggers where `BeginDelayedSequence` has no effect, avoid firing it if too far away from the beat.
@ -136,12 +134,13 @@ namespace osu.Game.Graphics.Containers
if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE)
{
using (BeginDelayedSequence(-TimeSinceLastBeat))
OnNewBeat(beatIndex, timingPoint, effectPoint, BeatSyncSource.Amplitudes ?? ChannelAmplitudes.Empty);
OnNewBeat(beatIndex, timingPoint, effectPoint, BeatSyncSource.CurrentAmplitudes);
}
lastBeat = beatIndex;
LastTimingPoint = timingPoint;
LastEffectPoint = effectPoint;
lastTimingPoint = timingPoint;
IsKiaiTime = effectPoint.KiaiMode;
}
}
}

View File

@ -5,7 +5,7 @@
using System;
using System.Runtime.InteropServices;
using osu.Framework.Graphics.OpenGL.Vertices;
using osu.Framework.Graphics.Rendering.Vertices;
using osuTK;
using osuTK.Graphics;
using osuTK.Graphics.ES30;

View File

@ -137,6 +137,9 @@ namespace osu.Game.Graphics
{
switch (status)
{
case BeatmapOnlineStatus.LocallyModified:
return Color4.OrangeRed;
case BeatmapOnlineStatus.Ranked:
case BeatmapOnlineStatus.Approved:
return Color4Extensions.FromHex(@"b3ff66");

View File

@ -6,8 +6,8 @@
using System;
using System.Collections.Generic;
using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Vertices;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Utils;
@ -89,7 +89,7 @@ namespace osu.Game.Graphics
currentTime = source.Time.Current;
}
protected override void Blit(Action<TexturedVertex2D> vertexAction)
protected override void Blit(IRenderer renderer)
{
double time = currentTime - startTime;
@ -112,9 +112,9 @@ namespace osu.Game.Graphics
Vector2Extensions.Transform(rect.BottomRight, DrawInfo.Matrix)
);
DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction,
new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height),
null, TextureCoords);
renderer.DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha),
inflationPercentage: new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height),
textureCoords: TextureCoords);
}
}

View File

@ -7,8 +7,8 @@ using System;
using osu.Framework.Bindables;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Vertices;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Utils;
@ -107,7 +107,7 @@ namespace osu.Game.Graphics
sourceSize = Source.DrawSize;
}
protected override void Blit(Action<TexturedVertex2D> vertexAction)
protected override void Blit(IRenderer renderer)
{
foreach (var p in particles)
{
@ -136,9 +136,9 @@ namespace osu.Game.Graphics
transformPosition(rect.BottomRight, rect.Centre, angle)
);
DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction,
new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height),
null, TextureCoords);
renderer.DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha),
inflationPercentage: new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height),
textureCoords: TextureCoords);
}
}

View File

@ -3,10 +3,9 @@
#nullable disable
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Vertices;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Shaders;
using osu.Framework.Graphics.Sprites;
@ -57,11 +56,11 @@ namespace osu.Game.Graphics.Sprites
progress = source.animationProgress;
}
protected override void Blit(Action<TexturedVertex2D> vertexAction)
protected override void Blit(IRenderer renderer)
{
Shader.GetUniform<float>("progress").UpdateValue(ref progress);
GetAppropriateShader(renderer).GetUniform<float>("progress").UpdateValue(ref progress);
base.Blit(vertexAction);
base.Blit(renderer);
}
protected override bool CanDrawOpaqueInterior => false;

View File

@ -167,6 +167,11 @@ namespace osu.Game.Graphics.UserInterface
{
base.Update();
// If the game goes into a suspended state (ie. debugger attached or backgrounded on a mobile device)
// we want to ignore really long periods of no processing.
if (updateClock.ElapsedFrameTime > 10000)
return;
mainContent.Width = Math.Max(mainContent.Width, counters.DrawWidth);
// Handle the case where the window has become inactive or the user changed the
@ -177,15 +182,15 @@ namespace osu.Game.Graphics.UserInterface
// use elapsed frame time rather then FramesPerSecond to better catch stutter frames.
bool hasDrawSpike = displayedFpsCount > (1000 / spike_time_ms) && drawClock.ElapsedFrameTime > spike_time_ms;
// note that we use an elapsed time here of 1 intentionally.
// this weights all updates equally. if we passed in the elapsed time, longer frames would be weighted incorrectly lower.
displayedFrameTime = Interpolation.DampContinuously(displayedFrameTime, updateClock.ElapsedFrameTime, hasUpdateSpike ? 0 : 100, 1);
const float damp_time = 100;
displayedFrameTime = Interpolation.DampContinuously(displayedFrameTime, updateClock.ElapsedFrameTime, hasUpdateSpike ? 0 : damp_time, updateClock.ElapsedFrameTime);
if (hasDrawSpike)
// show spike time using raw elapsed value, to account for `FramesPerSecond` being so averaged spike frames don't show.
displayedFpsCount = 1000 / drawClock.ElapsedFrameTime;
else
displayedFpsCount = Interpolation.DampContinuously(displayedFpsCount, drawClock.FramesPerSecond, 100, Time.Elapsed);
displayedFpsCount = Interpolation.DampContinuously(displayedFpsCount, drawClock.FramesPerSecond, damp_time, Time.Elapsed);
if (Time.Current - lastUpdate > min_time_between_updates)
{
@ -203,7 +208,7 @@ namespace osu.Game.Graphics.UserInterface
if (hasSignificantChanges)
requestDisplay();
else if (isDisplayed && Time.Current - lastDisplayRequiredTime > 2000)
else if (isDisplayed && Time.Current - lastDisplayRequiredTime > 2000 && !IsHovered)
{
mainContent.FadeTo(0, 300, Easing.OutQuint);
isDisplayed = false;

View File

@ -20,6 +20,8 @@ namespace osu.Game.Graphics.UserInterface
/// </summary>
public class LoadingLayer : LoadingSpinner
{
private readonly bool blockInput;
[CanBeNull]
protected Box BackgroundDimLayer { get; }
@ -28,9 +30,11 @@ namespace osu.Game.Graphics.UserInterface
/// </summary>
/// <param name="dimBackground">Whether the full background area should be dimmed while loading.</param>
/// <param name="withBox">Whether the spinner should have a surrounding black box for visibility.</param>
public LoadingLayer(bool dimBackground = false, bool withBox = true)
/// <param name="blockInput">Whether to block input of components behind the loading layer.</param>
public LoadingLayer(bool dimBackground = false, bool withBox = true, bool blockInput = true)
: base(withBox)
{
this.blockInput = blockInput;
RelativeSizeAxes = Axes.Both;
Size = new Vector2(1);
@ -52,6 +56,9 @@ namespace osu.Game.Graphics.UserInterface
protected override bool Handle(UIEvent e)
{
if (!blockInput)
return false;
switch (e)
{
// blocking scroll can cause weird behaviour when this layer is used within a ScrollContainer.
@ -83,7 +90,7 @@ namespace osu.Game.Graphics.UserInterface
{
base.Update();
MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 30, 100));
MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 20, 100));
}
}
}

View File

@ -49,15 +49,15 @@ namespace osu.Game.Graphics.UserInterface
Active.BindDisabledChanged(disabled => Action = disabled ? null : Active.Toggle, true);
Active.BindValueChanged(_ =>
{
updateActiveState();
UpdateActiveState();
playSample();
});
updateActiveState();
UpdateActiveState();
base.LoadComplete();
}
private void updateActiveState()
protected virtual void UpdateActiveState()
{
DarkerColour = Active.Value ? ColourProvider.Highlight1 : ColourProvider.Background3;
LighterColour = Active.Value ? ColourProvider.Colour0 : ColourProvider.Background1;

View File

@ -13,6 +13,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osuTK;
@ -26,9 +27,9 @@ namespace osu.Game.Graphics.UserInterfaceV2
{
public BindableList<Colour4> Colours { get; } = new BindableList<Colour4>();
private string colourNamePrefix = "Colour";
private LocalisableString colourNamePrefix = "Colour";
public string ColourNamePrefix
public LocalisableString ColourNamePrefix
{
get => colourNamePrefix;
set

View File

@ -5,6 +5,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.UserInterfaceV2
{
@ -17,7 +18,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
public BindableList<Colour4> Colours => Component.Colours;
public string ColourNamePrefix
public LocalisableString ColourNamePrefix
{
get => Component.ColourNamePrefix;
set => Component.ColourNamePrefix = value;

View File

@ -1,9 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Framework.Audio;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Game.Database;
@ -12,10 +11,15 @@ namespace osu.Game.IO
{
public interface IStorageResourceProvider
{
/// <summary>
/// The game renderer.
/// </summary>
IRenderer Renderer { get; }
/// <summary>
/// Retrieve the game-wide audio manager.
/// </summary>
AudioManager AudioManager { get; }
AudioManager? AudioManager { get; }
/// <summary>
/// Access game-wide user files.

View File

@ -26,6 +26,11 @@ namespace osu.Game.IO
/// </summary>
public virtual string[] IgnoreFiles => Array.Empty<string>();
/// <summary>
/// A list of file/directory suffixes which should not be migrated.
/// </summary>
public virtual string[] IgnoreSuffixes => Array.Empty<string>();
protected MigratableStorage(Storage storage, string subPath = null)
: base(storage, subPath)
{
@ -73,6 +78,9 @@ namespace osu.Game.IO
if (topLevelExcludes && IgnoreFiles.Contains(fi.Name))
continue;
if (IgnoreSuffixes.Any(suffix => fi.Name.EndsWith(suffix, StringComparison.Ordinal)))
continue;
allFilesDeleted &= AttemptOperation(() => fi.Delete(), throwOnFailure: false);
}
@ -81,6 +89,9 @@ namespace osu.Game.IO
if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name))
continue;
if (IgnoreSuffixes.Any(suffix => dir.Name.EndsWith(suffix, StringComparison.Ordinal)))
continue;
allFilesDeleted &= AttemptOperation(() => dir.Delete(true), throwOnFailure: false);
}
@ -101,6 +112,9 @@ namespace osu.Game.IO
if (topLevelExcludes && IgnoreFiles.Contains(fileInfo.Name))
continue;
if (IgnoreSuffixes.Any(suffix => fileInfo.Name.EndsWith(suffix, StringComparison.Ordinal)))
continue;
AttemptOperation(() =>
{
fileInfo.Refresh();
@ -119,6 +133,9 @@ namespace osu.Game.IO
if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name))
continue;
if (IgnoreSuffixes.Any(suffix => dir.Name.EndsWith(suffix, StringComparison.Ordinal)))
continue;
CopyRecursive(dir, destination.CreateSubdirectory(dir.Name), false);
}
}

View File

@ -38,15 +38,20 @@ namespace osu.Game.IO
public override string[] IgnoreDirectories => new[]
{
"cache",
$"{OsuGameBase.CLIENT_DATABASE_FILENAME}.management",
};
public override string[] IgnoreFiles => new[]
{
"framework.ini",
"storage.ini",
$"{OsuGameBase.CLIENT_DATABASE_FILENAME}.note",
$"{OsuGameBase.CLIENT_DATABASE_FILENAME}.lock",
};
public override string[] IgnoreSuffixes => new[]
{
// Realm pipe files don't play well with copy operations
".note",
".lock",
".management",
};
public OsuStorage(GameHost host, Storage defaultStorage)

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
@ -15,10 +13,11 @@ namespace osu.Game.Input.Bindings
{
public class GlobalActionContainer : DatabasedKeyBindingContainer<GlobalAction>, IHandleGlobalKeyboardInput
{
private readonly Drawable handler;
private InputManager parentInputManager;
private readonly Drawable? handler;
public GlobalActionContainer(OsuGameBase game)
private InputManager? parentInputManager;
public GlobalActionContainer(OsuGameBase? game)
: base(matchingMode: KeyCombinationMatchingMode.Modifiers)
{
if (game is IKeyBindingHandler<GlobalAction>)
@ -32,7 +31,10 @@ namespace osu.Game.Input.Bindings
parentInputManager = GetContainingInputManager();
}
// IMPORTANT: Do not change the order of key bindings in this list.
// It is used to decide the order of precedence (see note in DatabasedKeyBindingContainer).
public override IEnumerable<IKeyBinding> DefaultKeyBindings => GlobalKeyBindings
.Concat(OverlayKeyBindings)
.Concat(EditorKeyBindings)
.Concat(InGameKeyBindings)
.Concat(SongSelectKeyBindings)
@ -40,25 +42,6 @@ namespace osu.Game.Input.Bindings
public IEnumerable<KeyBinding> GlobalKeyBindings => new[]
{
new KeyBinding(InputKey.F6, GlobalAction.ToggleNowPlaying),
new KeyBinding(InputKey.F8, GlobalAction.ToggleChat),
new KeyBinding(InputKey.F9, GlobalAction.ToggleSocial),
new KeyBinding(InputKey.F10, GlobalAction.ToggleGameplayMouseButtons),
new KeyBinding(InputKey.F12, GlobalAction.TakeScreenshot),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay),
new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings),
new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar),
new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings),
new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing),
new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor),
new KeyBinding(InputKey.Escape, GlobalAction.Back),
new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back),
new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home),
new KeyBinding(InputKey.Up, GlobalAction.SelectPrevious),
new KeyBinding(InputKey.Down, GlobalAction.SelectNext),
@ -69,7 +52,32 @@ namespace osu.Game.Input.Bindings
new KeyBinding(InputKey.Enter, GlobalAction.Select),
new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select),
new KeyBinding(InputKey.Escape, GlobalAction.Back),
new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back),
new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay),
new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor),
new KeyBinding(new[] { InputKey.Control, InputKey.P }, GlobalAction.ToggleProfile),
new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.R }, GlobalAction.RandomSkin),
new KeyBinding(InputKey.F10, GlobalAction.ToggleGameplayMouseButtons),
new KeyBinding(InputKey.F12, GlobalAction.TakeScreenshot),
};
public IEnumerable<KeyBinding> OverlayKeyBindings => new[]
{
new KeyBinding(InputKey.F8, GlobalAction.ToggleChat),
new KeyBinding(InputKey.F6, GlobalAction.ToggleNowPlaying),
new KeyBinding(InputKey.F9, GlobalAction.ToggleSocial),
new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing),
new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings),
new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications),
};
public IEnumerable<KeyBinding> EditorKeyBindings => new[]
@ -332,5 +340,8 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleFPSCounter))]
ToggleFPSDisplay,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleProfile))]
ToggleProfile,
}
}

View File

@ -3,8 +3,9 @@
#nullable disable
using System.ComponentModel;
using osu.Framework.Input;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Input
{
@ -17,18 +18,20 @@ namespace osu.Game.Input
/// <summary>
/// The mouse cursor will be free to move outside the game window.
/// </summary>
[LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.NeverConfine))]
Never,
/// <summary>
/// The mouse cursor will be locked to the window bounds during gameplay,
/// but may otherwise move freely.
/// </summary>
[Description("During Gameplay")]
[LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.ConfineDuringGameplay))]
DuringGameplay,
/// <summary>
/// The mouse cursor will always be locked to the window bounds while the game has focus.
/// </summary>
[LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.AlwaysConfine))]
Always
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
@ -89,6 +89,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString Collections => new TranslatableString(getKey(@"collections"), @"Collections");
/// <summary>
/// "Name"
/// </summary>
public static LocalisableString Name => new TranslatableString(getKey(@"name"), @"Name");
/// <summary>
/// "Description"
/// </summary>
public static LocalisableString Description => new TranslatableString(getKey(@"description"), @"Description");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -0,0 +1,29 @@
// 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.Localisation
{
public static class DeleteConfirmationDialogStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.DeleteConfirmationDialog";
/// <summary>
/// "Confirm deletion of"
/// </summary>
public static LocalisableString HeaderText => new TranslatableString(getKey(@"header_text"), @"Confirm deletion of");
/// <summary>
/// "Yes. Go for it."
/// </summary>
public static LocalisableString Confirm => new TranslatableString(getKey(@"confirm"), @"Yes. Go for it.");
/// <summary>
/// "No! Abort mission"
/// </summary>
public static LocalisableString Cancel => new TranslatableString(getKey(@"cancel"), @"No! Abort mission");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -0,0 +1,204 @@
// 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.Localisation
{
public static class EditorSetupStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.EditorSetup";
/// <summary>
/// "Beatmap Setup"
/// </summary>
public static LocalisableString BeatmapSetup => new TranslatableString(getKey(@"beatmap_setup"), @"Beatmap Setup");
/// <summary>
/// "change general settings of your beatmap"
/// </summary>
public static LocalisableString BeatmapSetupDescription => new TranslatableString(getKey(@"beatmap_setup_description"), @"change general settings of your beatmap");
/// <summary>
/// "Colours"
/// </summary>
public static LocalisableString ColoursHeader => new TranslatableString(getKey(@"colours_header"), @"Colours");
/// <summary>
/// "Hit circle / Slider Combos"
/// </summary>
public static LocalisableString HitCircleSliderCombos => new TranslatableString(getKey(@"hit_circle_slider_combos"), @"Hit circle / Slider Combos");
/// <summary>
/// "Design"
/// </summary>
public static LocalisableString DesignHeader => new TranslatableString(getKey(@"design_header"), @"Design");
/// <summary>
/// "Enable countdown"
/// </summary>
public static LocalisableString EnableCountdown => new TranslatableString(getKey(@"enable_countdown"), @"Enable countdown");
/// <summary>
/// "If enabled, an &quot;Are you ready? 3, 2, 1, GO!&quot; countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so."
/// </summary>
public static LocalisableString CountdownDescription => new TranslatableString(getKey(@"countdown_description"), @"If enabled, an ""Are you ready? 3, 2, 1, GO!"" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so.");
/// <summary>
/// "Countdown speed"
/// </summary>
public static LocalisableString CountdownSpeed => new TranslatableString(getKey(@"countdown_speed"), @"Countdown speed");
/// <summary>
/// "If the countdown sounds off-time, use this to make it appear one or more beats early."
/// </summary>
public static LocalisableString CountdownOffsetDescription => new TranslatableString(getKey(@"countdown_offset_description"), @"If the countdown sounds off-time, use this to make it appear one or more beats early.");
/// <summary>
/// "Countdown offset"
/// </summary>
public static LocalisableString CountdownOffset => new TranslatableString(getKey(@"countdown_offset"), @"Countdown offset");
/// <summary>
/// "Widescreen support"
/// </summary>
public static LocalisableString WidescreenSupport => new TranslatableString(getKey(@"widescreen_support"), @"Widescreen support");
/// <summary>
/// "Allows storyboards to use the full screen space, rather than be confined to a 4:3 area."
/// </summary>
public static LocalisableString WidescreenSupportDescription => new TranslatableString(getKey(@"widescreen_support_description"), @"Allows storyboards to use the full screen space, rather than be confined to a 4:3 area.");
/// <summary>
/// "Epilepsy warning"
/// </summary>
public static LocalisableString EpilepsyWarning => new TranslatableString(getKey(@"epilepsy_warning"), @"Epilepsy warning");
/// <summary>
/// "Recommended if the storyboard or video contain scenes with rapidly flashing colours."
/// </summary>
public static LocalisableString EpilepsyWarningDescription => new TranslatableString(getKey(@"epilepsy_warning_description"), @"Recommended if the storyboard or video contain scenes with rapidly flashing colours.");
/// <summary>
/// "Letterbox during breaks"
/// </summary>
public static LocalisableString LetterboxDuringBreaks => new TranslatableString(getKey(@"letterbox_during_breaks"), @"Letterbox during breaks");
/// <summary>
/// "Adds horizontal letterboxing to give a cinematic look during breaks."
/// </summary>
public static LocalisableString LetterboxDuringBreaksDescription => new TranslatableString(getKey(@"letterbox_during_breaks_description"), @"Adds horizontal letterboxing to give a cinematic look during breaks.");
/// <summary>
/// "Samples match playback rate"
/// </summary>
public static LocalisableString SamplesMatchPlaybackRate => new TranslatableString(getKey(@"samples_match_playback_rate"), @"Samples match playback rate");
/// <summary>
/// "When enabled, all samples will speed up or slow down when rate-changing mods are enabled."
/// </summary>
public static LocalisableString SamplesMatchPlaybackRateDescription => new TranslatableString(getKey(@"samples_match_playback_rate_description"), @"When enabled, all samples will speed up or slow down when rate-changing mods are enabled.");
/// <summary>
/// "The size of all hit objects"
/// </summary>
public static LocalisableString CircleSizeDescription => new TranslatableString(getKey(@"circle_size_description"), @"The size of all hit objects");
/// <summary>
/// "The rate of passive health drain throughout playable time"
/// </summary>
public static LocalisableString DrainRateDescription => new TranslatableString(getKey(@"drain_rate_description"), @"The rate of passive health drain throughout playable time");
/// <summary>
/// "The speed at which objects are presented to the player"
/// </summary>
public static LocalisableString ApproachRateDescription => new TranslatableString(getKey(@"approach_rate_description"), @"The speed at which objects are presented to the player");
/// <summary>
/// "The harshness of hit windows and difficulty of special objects (ie. spinners)"
/// </summary>
public static LocalisableString OverallDifficultyDescription => new TranslatableString(getKey(@"overall_difficulty_description"), @"The harshness of hit windows and difficulty of special objects (ie. spinners)");
/// <summary>
/// "Metadata"
/// </summary>
public static LocalisableString MetadataHeader => new TranslatableString(getKey(@"metadata_header"), @"Metadata");
/// <summary>
/// "Romanised Artist"
/// </summary>
public static LocalisableString RomanisedArtist => new TranslatableString(getKey(@"romanised_artist"), @"Romanised Artist");
/// <summary>
/// "Romanised Title"
/// </summary>
public static LocalisableString RomanisedTitle => new TranslatableString(getKey(@"romanised_title"), @"Romanised Title");
/// <summary>
/// "Creator"
/// </summary>
public static LocalisableString Creator => new TranslatableString(getKey(@"creator"), @"Creator");
/// <summary>
/// "Difficulty Name"
/// </summary>
public static LocalisableString DifficultyName => new TranslatableString(getKey(@"difficulty_name"), @"Difficulty Name");
/// <summary>
/// "Resources"
/// </summary>
public static LocalisableString ResourcesHeader => new TranslatableString(getKey(@"resources_header"), @"Resources");
/// <summary>
/// "Audio Track"
/// </summary>
public static LocalisableString AudioTrack => new TranslatableString(getKey(@"audio_track"), @"Audio Track");
/// <summary>
/// "Click to select a track"
/// </summary>
public static LocalisableString ClickToSelectTrack => new TranslatableString(getKey(@"click_to_select_track"), @"Click to select a track");
/// <summary>
/// "Click to replace the track"
/// </summary>
public static LocalisableString ClickToReplaceTrack => new TranslatableString(getKey(@"click_to_replace_track"), @"Click to replace the track");
/// <summary>
/// "Click to select a background image"
/// </summary>
public static LocalisableString ClickToSelectBackground => new TranslatableString(getKey(@"click_to_select_background"), @"Click to select a background image");
/// <summary>
/// "Click to replace the background image"
/// </summary>
public static LocalisableString ClickToReplaceBackground => new TranslatableString(getKey(@"click_to_replace_background"), @"Click to replace the background image");
/// <summary>
/// "Ruleset ({0})"
/// </summary>
public static LocalisableString RulesetHeader(string arg0) => new TranslatableString(getKey(@"ruleset"), @"Ruleset ({0})", arg0);
/// <summary>
/// "Combo"
/// </summary>
public static LocalisableString ComboColourPrefix => new TranslatableString(getKey(@"combo_colour_prefix"), @"Combo");
/// <summary>
/// "Artist"
/// </summary>
public static LocalisableString Artist => new TranslatableString(getKey(@"artist"), @"Artist");
/// <summary>
/// "Title"
/// </summary>
public static LocalisableString Title => new TranslatableString(getKey(@"title"), @"Title");
/// <summary>
/// "Difficulty"
/// </summary>
public static LocalisableString DifficultyHeader => new TranslatableString(getKey(@"difficulty_header"), @"Difficulty");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -104,6 +104,31 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString IncreaseFirstObjectVisibility => new TranslatableString(getKey(@"increase_first_object_visibility"), @"Increase visibility of first object when visual impairment mods are enabled");
/// <summary>
/// "Hide during gameplay"
/// </summary>
public static LocalisableString HideDuringGameplay => new TranslatableString(getKey(@"hide_during_gameplay"), @"Hide during gameplay");
/// <summary>
/// "Always"
/// </summary>
public static LocalisableString AlwaysShowHUD => new TranslatableString(getKey(@"always_show_hud"), @"Always");
/// <summary>
/// "Never"
/// </summary>
public static LocalisableString NeverShowHUD => new TranslatableString(getKey(@"never_show_hud"), @"Never");
/// <summary>
/// "Standardised"
/// </summary>
public static LocalisableString StandardisedScoreDisplay => new TranslatableString(getKey(@"standardised_score_display"), @"Standardised");
/// <summary>
/// "Classic"
/// </summary>
public static LocalisableString ClassicScoreDisplay => new TranslatableString(getKey(@"classic_score_display"), @"Classic");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -149,6 +149,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString ToggleNotifications => new TranslatableString(getKey(@"toggle_notifications"), @"Toggle notifications");
/// <summary>
/// "Toggle profile"
/// </summary>
public static LocalisableString ToggleProfile => new TranslatableString(getKey(@"toggle_profile"), @"Toggle profile");
/// <summary>
/// "Pause gameplay"
/// </summary>

View File

@ -129,6 +129,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString UseHardwareAcceleration => new TranslatableString(getKey(@"use_hardware_acceleration"), @"Use hardware acceleration");
/// <summary>
/// "JPG (web-friendly)"
/// </summary>
public static LocalisableString Jpg => new TranslatableString(getKey(@"jpg_web_friendly"), @"JPG (web-friendly)");
/// <summary>
/// "PNG (lossless)"
/// </summary>
public static LocalisableString Png => new TranslatableString(getKey(@"png_lossless"), @"PNG (lossless)");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -19,6 +19,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString GlobalKeyBindingHeader => new TranslatableString(getKey(@"global_key_binding_header"), @"Global");
/// <summary>
/// "Overlays"
/// </summary>
public static LocalisableString OverlaysSection => new TranslatableString(getKey(@"overlays_section"), @"Overlays");
/// <summary>
/// "Song Select"
/// </summary>

View File

@ -29,6 +29,26 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString FullscreenMacOSNote => new TranslatableString(getKey(@"fullscreen_macos_note"), @"Using fullscreen on macOS makes interacting with the menu bar and spaces no longer work, and may lead to freezes if a system dialog is presented. Using borderless is recommended.");
/// <summary>
/// "Excluding overlays"
/// </summary>
public static LocalisableString ScaleEverythingExcludingOverlays => new TranslatableString(getKey(@"scale_everything_excluding_overlays"), @"Excluding overlays");
/// <summary>
/// "Everything"
/// </summary>
public static LocalisableString ScaleEverything => new TranslatableString(getKey(@"scale_everything"), @"Everything");
/// <summary>
/// "Gameplay"
/// </summary>
public static LocalisableString ScaleGameplay => new TranslatableString(getKey(@"scale_gameplay"), @"Gameplay");
/// <summary>
/// "Off"
/// </summary>
public static LocalisableString ScalingOff => new TranslatableString(getKey(@"scaling_off"), @"Off");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -74,6 +74,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString RestoreAllRecentlyDeletedBeatmaps => new TranslatableString(getKey(@"restore_all_recently_deleted_beatmaps"), @"Restore all recently deleted beatmaps");
/// <summary>
/// "Delete ALL mod presets"
/// </summary>
public static LocalisableString DeleteAllModPresets => new TranslatableString(getKey(@"delete_all_mod_presets"), @"Delete ALL mod presets");
/// <summary>
/// "Restore all recently deleted mod presets"
/// </summary>
public static LocalisableString RestoreAllRecentlyDeletedModPresets => new TranslatableString(getKey(@"restore_all_recently_deleted_mod_presets"), @"Restore all recently deleted mod presets");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -29,6 +29,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString PersonalPresets => new TranslatableString(getKey(@"personal_presets"), @"Personal Presets");
/// <summary>
/// "Add preset"
/// </summary>
public static LocalisableString AddPreset => new TranslatableString(getKey(@"add_preset"), @"Add preset");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -64,6 +64,21 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString HighPrecisionPlatformWarning => new TranslatableString(getKey(@"high_precision_platform_warning"), @"This setting has known issues on your platform. If you encounter problems, it is recommended to adjust sensitivity externally and keep this disabled for now.");
/// <summary>
/// "Always"
/// </summary>
public static LocalisableString AlwaysConfine => new TranslatableString(getKey(@"always_confine"), @"Always");
/// <summary>
/// "During Gameplay"
/// </summary>
public static LocalisableString ConfineDuringGameplay => new TranslatableString(getKey(@"confine_during_gameplay"), @"During Gameplay");
/// <summary>
/// "Never"
/// </summary>
public static LocalisableString NeverConfine => new TranslatableString(getKey(@"never_confine"), @"Never");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -64,6 +64,21 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString ShowExplicitContent => new TranslatableString(getKey(@"show_explicit_content"), @"Show explicit content in search results");
/// <summary>
/// "Hide identifiable information"
/// </summary>
public static LocalisableString HideIdentifiableInformation => new TranslatableString(getKey(@"hide_identifiable_information"), @"Hide identifiable information");
/// <summary>
/// "Full"
/// </summary>
public static LocalisableString DiscordPresenceFull => new TranslatableString(getKey(@"discord_presence_full"), @"Full");
/// <summary>
/// "Off"
/// </summary>
public static LocalisableString DiscordPresenceOff => new TranslatableString(getKey(@"discord_presence_off"), @"Off");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -14,6 +14,21 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString Rulesets => new TranslatableString(getKey(@"rulesets"), @"Rulesets");
/// <summary>
/// "None"
/// </summary>
public static LocalisableString BorderNone => new TranslatableString(getKey(@"no_borders"), @"None");
/// <summary>
/// "Corners"
/// </summary>
public static LocalisableString BorderCorners => new TranslatableString(getKey(@"corner_borders"), @"Corners");
/// <summary>
/// "Full"
/// </summary>
public static LocalisableString BorderFull => new TranslatableString(getKey(@"full_borders"), @"Full");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class SongSelectStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.SongSelect";
/// <summary>
/// "Local"
/// </summary>
public static LocalisableString LocallyModified => new TranslatableString(getKey(@"locally_modified"), @"Local");
/// <summary>
/// "Has been locally modified"
/// </summary>
public static LocalisableString LocallyModifiedTooltip => new TranslatableString(getKey(@"locally_modified_tooltip"), @"Has been locally modified");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class ToolbarStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.Toolbar";
/// <summary>
/// "Connection interrupted, will try to reconnect..."
/// </summary>
public static LocalisableString AttemptingToReconnect => new TranslatableString(getKey(@"attempting_to_reconnect"), @"Connection interrupted, will try to reconnect...");
/// <summary>
/// "Connecting..."
/// </summary>
public static LocalisableString Connecting => new TranslatableString(getKey(@"connecting"), @"Connecting...");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -114,6 +114,46 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString NoLimit => new TranslatableString(getKey(@"no_limit"), @"no limit");
/// <summary>
/// "Beatmap (with storyboard / video)"
/// </summary>
public static LocalisableString BeatmapWithStoryboard => new TranslatableString(getKey(@"beatmap_with_storyboard"), @"Beatmap (with storyboard / video)");
/// <summary>
/// "Always"
/// </summary>
public static LocalisableString AlwaysSeasonalBackground => new TranslatableString(getKey(@"always_seasonal_backgrounds"), @"Always");
/// <summary>
/// "Never"
/// </summary>
public static LocalisableString NeverSeasonalBackground => new TranslatableString(getKey(@"never_seasonal_backgrounds"), @"Never");
/// <summary>
/// "Sometimes"
/// </summary>
public static LocalisableString SometimesSeasonalBackground => new TranslatableString(getKey(@"sometimes_seasonal_backgrounds"), @"Sometimes");
/// <summary>
/// "Sequential"
/// </summary>
public static LocalisableString SequentialHotkeyStyle => new TranslatableString(getKey(@"mods_sequential_hotkeys"), @"Sequential");
/// <summary>
/// "Classic"
/// </summary>
public static LocalisableString ClassicHotkeyStyle => new TranslatableString(getKey(@"mods_classic_hotkeys"), @"Classic");
/// <summary>
/// "Never repeat"
/// </summary>
public static LocalisableString NeverRepeat => new TranslatableString(getKey(@"never_repeat_random"), @"Never repeat");
/// <summary>
/// "True Random"
/// </summary>
public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True Random");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -104,120 +104,39 @@ namespace osu.Game.Online.API
/// </summary>
private int failureCount;
/// <summary>
/// The main API thread loop, which will continue to run until the game is shut down.
/// </summary>
private void run()
{
while (!cancellationToken.IsCancellationRequested)
{
switch (State.Value)
if (state.Value == APIState.Failing)
{
case APIState.Failing:
//todo: replace this with a ping request.
log.Add(@"In a failing state, waiting a bit before we try again...");
Thread.Sleep(5000);
// To recover from a failing state, falling through and running the full reconnection process seems safest for now.
// This could probably be replaced with a ping-style request if we want to avoid the reconnection overheads.
log.Add($@"{nameof(APIAccess)} is in a failing state, waiting a bit before we try again...");
Thread.Sleep(5000);
}
if (!IsLoggedIn) goto case APIState.Connecting;
// Ensure that we have valid credentials.
// If not, setting the offline state will allow the game to prompt the user to provide new credentials.
if (!HasLogin)
{
state.Value = APIState.Offline;
Thread.Sleep(50);
continue;
}
if (queue.Count == 0)
{
log.Add(@"Queueing a ping request");
Queue(new GetUserRequest());
}
Debug.Assert(HasLogin);
break;
// Ensure that we are in an online state. If not, attempt a connect.
if (state.Value != APIState.Online)
{
attemptConnect();
case APIState.Offline:
case APIState.Connecting:
// work to restore a connection...
if (!HasLogin)
{
state.Value = APIState.Offline;
Thread.Sleep(50);
continue;
}
state.Value = APIState.Connecting;
// save the username at this point, if the user requested for it to be.
config.SetValue(OsuSetting.Username, config.Get<bool>(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty);
if (!authentication.HasValidAccessToken)
{
LastLoginError = null;
try
{
authentication.AuthenticateWithLogin(ProvidedUsername, password);
}
catch (Exception e)
{
//todo: this fails even on network-related issues. we should probably handle those differently.
LastLoginError = e;
log.Add(@"Login failed!");
password = null;
authentication.Clear();
continue;
}
}
var userReq = new GetUserRequest();
userReq.Failure += ex =>
{
if (ex is APIException)
{
LastLoginError = ex;
log.Add("Login failed on local user retrieval!");
Logout();
}
else if (ex is WebException webException && webException.Message == @"Unauthorized")
{
log.Add(@"Login no longer valid");
Logout();
}
else
failConnectionProcess();
};
userReq.Success += u =>
{
localUser.Value = u;
// todo: save/pull from settings
localUser.Value.Status.Value = new UserStatusOnline();
failureCount = 0;
};
if (!handleRequest(userReq))
{
failConnectionProcess();
continue;
}
// getting user's friends is considered part of the connection process.
var friendsReq = new GetFriendsRequest();
friendsReq.Failure += _ => failConnectionProcess();
friendsReq.Success += res =>
{
friends.AddRange(res);
//we're connected!
state.Value = APIState.Online;
};
if (!handleRequest(friendsReq))
{
failConnectionProcess();
continue;
}
// The Success callback event is fired on the main thread, so we should wait for that to run before proceeding.
// Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests
// before actually going online.
while (State.Value > APIState.Offline && State.Value < APIState.Online)
Thread.Sleep(500);
break;
if (state.Value != APIState.Online)
continue;
}
// hard bail if we can't get a valid access token.
@ -227,31 +146,132 @@ namespace osu.Game.Online.API
continue;
}
while (true)
{
APIRequest req;
lock (queue)
{
if (queue.Count == 0) break;
req = queue.Dequeue();
}
handleRequest(req);
}
processQueuedRequests();
Thread.Sleep(50);
}
}
void failConnectionProcess()
/// <summary>
/// Dequeue from the queue and run each request synchronously until the queue is empty.
/// </summary>
private void processQueuedRequests()
{
while (true)
{
// if something went wrong during the connection process, we want to reset the state (but only if still connecting).
if (State.Value == APIState.Connecting)
state.Value = APIState.Failing;
APIRequest req;
lock (queue)
{
if (queue.Count == 0) return;
req = queue.Dequeue();
}
handleRequest(req);
}
}
/// <summary>
/// From a non-connected state, perform a full connection flow, obtaining OAuth tokens and populating the local user and friends.
/// </summary>
/// <remarks>
/// This method takes control of <see cref="state"/> and transitions from <see cref="APIState.Connecting"/> to either
/// - <see cref="APIState.Online"/> (successful connection)
/// - <see cref="APIState.Failing"/> (failed connection but retrying)
/// - <see cref="APIState.Offline"/> (failed and can't retry, clear credentials and require user interaction)
/// </remarks>
/// <returns>Whether the connection attempt was successful.</returns>
private void attemptConnect()
{
state.Value = APIState.Connecting;
if (localUser.IsDefault)
{
// Show a placeholder user if saved credentials are available.
// This is useful for storing local scores and showing a placeholder username after starting the game,
// until a valid connection has been established.
setLocalUser(new APIUser
{
Username = ProvidedUsername,
});
}
// save the username at this point, if the user requested for it to be.
config.SetValue(OsuSetting.Username, config.Get<bool>(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty);
if (!authentication.HasValidAccessToken)
{
LastLoginError = null;
try
{
authentication.AuthenticateWithLogin(ProvidedUsername, password);
}
catch (Exception e)
{
//todo: this fails even on network-related issues. we should probably handle those differently.
LastLoginError = e;
log.Add($@"Login failed for username {ProvidedUsername} ({LastLoginError.Message})!");
Logout();
return;
}
}
var userReq = new GetUserRequest();
userReq.Failure += ex =>
{
if (ex is APIException)
{
LastLoginError = ex;
log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!");
Logout();
}
else if (ex is WebException webException && webException.Message == @"Unauthorized")
{
log.Add(@"Login no longer valid");
Logout();
}
else
{
state.Value = APIState.Failing;
}
};
userReq.Success += user =>
{
// todo: save/pull from settings
user.Status.Value = new UserStatusOnline();
setLocalUser(user);
// we're connected!
state.Value = APIState.Online;
failureCount = 0;
};
if (!handleRequest(userReq))
{
state.Value = APIState.Failing;
return;
}
var friendsReq = new GetFriendsRequest();
friendsReq.Failure += _ => state.Value = APIState.Failing;
friendsReq.Success += res => friends.AddRange(res);
if (!handleRequest(friendsReq))
{
state.Value = APIState.Failing;
return;
}
// The Success callback event is fired on the main thread, so we should wait for that to run before proceeding.
// Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests
// before actually going online.
while (State.Value == APIState.Connecting && !cancellationToken.IsCancellationRequested)
Thread.Sleep(500);
}
public void Perform(APIRequest request)
{
try
@ -327,8 +347,7 @@ namespace osu.Game.Online.API
if (req.CompletionState != APIRequestCompletionState.Completed)
return false;
// we could still be in initialisation, at which point we don't want to say we're Online yet.
if (IsLoggedIn) state.Value = APIState.Online;
// Reset failure count if this request succeeded.
failureCount = 0;
return true;
}
@ -402,7 +421,7 @@ namespace osu.Game.Online.API
}
}
public bool IsLoggedIn => localUser.Value.Id > 1; // TODO: should this also be true if attempting to connect?
public bool IsLoggedIn => State.Value > APIState.Offline;
public void Queue(APIRequest request)
{
@ -442,7 +461,7 @@ namespace osu.Game.Online.API
// Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present
Schedule(() =>
{
localUser.Value = createGuestUser();
setLocalUser(createGuestUser());
friends.Clear();
});
@ -452,6 +471,8 @@ namespace osu.Game.Online.API
private static APIUser createGuestUser() => new GuestUser();
private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false);
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

View File

@ -1,11 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using MessagePack;
using Newtonsoft.Json;
@ -23,7 +20,7 @@ namespace osu.Game.Online.API
{
[JsonProperty("acronym")]
[Key(0)]
public string Acronym { get; set; }
public string Acronym { get; set; } = string.Empty;
[JsonProperty("settings")]
[Key(1)]
@ -49,7 +46,7 @@ namespace osu.Game.Online.API
}
}
public Mod ToMod([NotNull] Ruleset ruleset)
public Mod ToMod(Ruleset ruleset)
{
Mod resultMod = ruleset.CreateModFromAcronym(Acronym);
@ -80,6 +77,8 @@ namespace osu.Game.Online.API
return resultMod;
}
public bool ShouldSerializeSettings() => Settings.Count > 0;
public bool Equals(APIMod other)
{
if (ReferenceEquals(null, other)) return false;

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

@ -13,19 +13,16 @@ namespace osu.Game.Online.API
{
/// <summary>
/// The local user.
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
/// </summary>
IBindable<APIUser> LocalUser { get; }
/// <summary>
/// The user's friends.
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
/// </summary>
IBindableList<APIUser> Friends { get; }
/// <summary>
/// The current user's activity.
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
/// </summary>
IBindable<UserActivity> Activity { get; }

View File

@ -74,6 +74,9 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty("statistics")]
public Dictionary<HitResult, int> Statistics { get; set; } = new Dictionary<HitResult, int>();
[JsonProperty("maximum_statistics")]
public Dictionary<HitResult, int> MaximumStatistics { get; set; } = new Dictionary<HitResult, int>();
#region osu-web API additions (not stored to database).
[JsonProperty("id")]
@ -102,6 +105,14 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty("pp")]
public double? PP { get; set; }
public bool ShouldSerializeID() => false;
public bool ShouldSerializeUser() => false;
public bool ShouldSerializeBeatmap() => false;
public bool ShouldSerializeBeatmapSet() => false;
public bool ShouldSerializePP() => false;
public bool ShouldSerializeOnlineID() => false;
public bool ShouldSerializeHasReplay() => false;
#endregion
public override string ToString() => $"score_id: {ID} user_id: {UserID}";
@ -145,6 +156,7 @@ namespace osu.Game.Online.API.Requests.Responses
MaxCombo = MaxCombo,
Rank = Rank,
Statistics = Statistics,
MaximumStatistics = MaximumStatistics,
Date = EndedAt,
Hash = HasReplay ? "online" : string.Empty, // TODO: temporary?
Mods = mods,
@ -165,7 +177,8 @@ namespace osu.Game.Online.API.Requests.Responses
RulesetID = score.RulesetID,
Passed = score.Passed,
Mods = score.APIMods,
Statistics = score.Statistics,
Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
};
public long OnlineID => ID ?? -1;

View File

@ -1,27 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Platform;
using osu.Game.Configuration;
using osu.Game.Overlays;
using osu.Game.Overlays.Chat;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Online.Chat
{
public class ExternalLinkOpener : Component
{
[Resolved]
private GameHost host { get; set; }
private GameHost host { get; set; } = null!;
[Resolved(CanBeNull = true)]
private IDialogOverlay dialogOverlay { get; set; }
private IDialogOverlay? dialogOverlay { get; set; }
private Bindable<bool> externalLinkWarning;
private Bindable<bool> externalLinkWarning = null!;
[BackgroundDependencyLoader(true)]
private void load(OsuConfigManager config)
@ -31,10 +31,39 @@ namespace osu.Game.Online.Chat
public void OpenUrlExternally(string url, bool bypassWarning = false)
{
if (!bypassWarning && externalLinkWarning.Value)
dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url)));
if (!bypassWarning && externalLinkWarning.Value && dialogOverlay != null)
dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => host.GetClipboard()?.SetText(url)));
else
host.OpenUrlExternally(url);
}
public class ExternalLinkDialog : PopupDialog
{
public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copyExternalLinkAction)
{
HeaderText = "Just checking...";
BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}";
Icon = FontAwesome.Solid.ExclamationTriangle;
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
{
Text = @"Yes. Go for it.",
Action = openExternalLinkAction
},
new PopupDialogCancelButton
{
Text = @"Copy URL to the clipboard instead.",
Action = copyExternalLinkAction
},
new PopupDialogCancelButton
{
Text = @"No! Abort mission!"
},
};
}
}
}
}

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

@ -196,6 +196,9 @@ namespace osu.Game.Online.Multiplayer
APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem));
APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId);
// The server will null out the end date upon the host joining the room, but the null value is never communicated to the client.
APIRoom.EndDate.Value = null;
Debug.Assert(LocalUser != null);
addUserToAPIRoom(LocalUser);

View File

@ -16,7 +16,7 @@ namespace osu.Game.Online
/// <summary>
/// A component which requires a constant polling process.
/// </summary>
public abstract class PollingComponent : CompositeDrawable // switch away from Component because InternalChildren are used in usages.
public abstract class PollingComponent : CompositeComponent
{
private double? lastTimePolled;

View File

@ -27,13 +27,10 @@ namespace osu.Game.Online.Rooms
/// This differs from a regular download tracking composite as this accounts for the
/// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap.
/// </summary>
public class OnlinePlayBeatmapAvailabilityTracker : CompositeDrawable
public class OnlinePlayBeatmapAvailabilityTracker : CompositeComponent
{
public readonly IBindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>();
// Required to allow child components to update. Can potentially be replaced with a `CompositeComponent` class if or when we make one.
protected override bool RequiresChildrenUpdate => true;
[Resolved]
private RealmAccess realm { get; set; } = null!;

View File

@ -1138,6 +1138,13 @@ namespace osu.Game
mouseDisableButtons.Value = !mouseDisableButtons.Value;
return true;
case GlobalAction.ToggleProfile:
if (userProfile.State.Value == Visibility.Visible)
userProfile.Hide();
else
ShowUser(API.LocalUser.Value);
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.

View File

@ -248,7 +248,7 @@ namespace osu.Game
dependencies.CacheAs(Storage);
var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures")));
var largeStore = new LargeTextureStore(Host.Renderer, Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures")));
largeStore.AddTextureSource(Host.CreateTextureLoaderStore(new OnlineStore()));
dependencies.Cache(largeStore);
@ -588,6 +588,6 @@ namespace osu.Game
ControlPointInfo IBeatSyncProvider.ControlPoints => Beatmap.Value.BeatmapLoaded ? Beatmap.Value.Beatmap.ControlPointInfo : null;
IClock IBeatSyncProvider.Clock => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track : (IClock)null;
ChannelAmplitudes? IBeatSyncProvider.Amplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : null;
ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : ChannelAmplitudes.Empty;
}
}

View File

@ -104,11 +104,11 @@ namespace osu.Game.Overlays
filterControl.CardSize.BindValueChanged(_ => onCardSizeChanged());
apiUser = api.LocalUser.GetBoundCopy();
apiUser.BindValueChanged(_ =>
apiUser.BindValueChanged(_ => Schedule(() =>
{
if (api.IsLoggedIn)
addContentToResultsArea(Drawable.Empty());
});
}));
}
public void ShowWithSearch(string query)

View File

@ -147,7 +147,10 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons
{
// beatmapset may have changed.
if (Preview != preview)
{
preview?.Dispose();
return;
}
AddInternal(preview);
loading = false;

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

@ -1,35 +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.Graphics.Sprites;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Overlays.Chat
{
public class ExternalLinkDialog : PopupDialog
{
public ExternalLinkDialog(string url, Action openExternalLinkAction)
{
HeaderText = "Just checking...";
BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}";
Icon = FontAwesome.Solid.ExclamationTriangle;
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
{
Text = @"Yes. Go for it.",
Action = openExternalLinkAction
},
new PopupDialogCancelButton
{
Text = @"No! Abort mission!"
},
};
}
}
}

View File

@ -4,7 +4,6 @@
#nullable disable
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Game.Graphics.Containers.Markdown;
@ -12,16 +11,8 @@ namespace osu.Game.Overlays.Comments
{
public class CommentMarkdownContainer : OsuMarkdownContainer
{
public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer();
protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock);
private class CommentMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer
{
// Don't render image in comment for now
protected override void AddImage(LinkInline linkInline) { }
}
private class CommentMarkdownHeading : OsuMarkdownHeading
{
public CommentMarkdownHeading(HeadingBlock headingBlock)

View File

@ -0,0 +1,42 @@
// 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.Graphics.Sprites;
using osu.Game.Localisation;
namespace osu.Game.Overlays.Dialog
{
/// <summary>
/// Base class for various confirmation dialogs that concern deletion actions.
/// Differs from <see cref="ConfirmDialog"/> in that the confirmation button is a "dangerous" one
/// (requires the confirm button to be held).
/// </summary>
public abstract class DeleteConfirmationDialog : PopupDialog
{
/// <summary>
/// The action which performs the deletion.
/// </summary>
protected Action? DeleteAction { get; set; }
protected DeleteConfirmationDialog()
{
HeaderText = DeleteConfirmationDialogStrings.HeaderText;
Icon = FontAwesome.Regular.TrashAlt;
Buttons = new PopupDialogButton[]
{
new PopupDialogDangerousButton
{
Text = DeleteConfirmationDialogStrings.Confirm,
Action = () => DeleteAction?.Invoke()
},
new PopupDialogCancelButton
{
Text = DeleteConfirmationDialogStrings.Cancel
}
};
}
}
}

View File

@ -13,6 +13,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Users;
using osuTK;
@ -109,7 +110,7 @@ namespace osu.Game.Overlays.Login
Origin = Anchor.TopCentre,
TextAnchor = Anchor.TopCentre,
AutoSizeAxes = Axes.Both,
Text = state.NewValue == APIState.Failing ? "Connection is failing, will attempt to reconnect... " : "Attempting to connect... ",
Text = state.NewValue == APIState.Failing ? ToolbarStrings.AttemptingToReconnect : ToolbarStrings.Connecting,
Margin = new MarginPadding { Top = 10, Bottom = 10 },
},
};

View File

@ -0,0 +1,67 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Mods;
using osuTK;
namespace osu.Game.Overlays.Mods
{
public class AddPresetButton : ShearedToggleButton, IHasPopover
{
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private Bindable<IReadOnlyList<Mod>> selectedMods { get; set; } = null!;
public AddPresetButton()
: base(1)
{
RelativeSizeAxes = Axes.X;
Height = ModSelectPanel.HEIGHT;
// shear will be applied at a higher level in `ModPresetColumn`.
Content.Shear = Vector2.Zero;
Padding = new MarginPadding();
Text = "+";
TextSize = 30;
}
protected override void LoadComplete()
{
base.LoadComplete();
selectedMods.BindValueChanged(mods => Enabled.Value = mods.NewValue.Any(), true);
Enabled.BindValueChanged(enabled =>
{
if (!enabled.NewValue)
Active.Value = false;
});
}
protected override void UpdateActiveState()
{
DarkerColour = Active.Value ? colours.Orange1 : ColourProvider.Background3;
LighterColour = Active.Value ? colours.Orange0 : ColourProvider.Background1;
TextColour = Active.Value ? ColourProvider.Background6 : ColourProvider.Content1;
if (Active.Value)
this.ShowPopover();
else
this.HidePopover();
}
public Popover GetPopover() => new AddPresetPopover(this);
}
}

View File

@ -0,0 +1,120 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osuTK;
namespace osu.Game.Overlays.Mods
{
internal class AddPresetPopover : OsuPopover
{
private readonly AddPresetButton button;
private readonly LabelledTextBox nameTextBox;
private readonly LabelledTextBox descriptionTextBox;
private readonly ShearedButton createButton;
[Resolved]
private Bindable<RulesetInfo> ruleset { get; set; } = null!;
[Resolved]
private Bindable<IReadOnlyList<Mod>> selectedMods { get; set; } = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
public AddPresetPopover(AddPresetButton addPresetButton)
{
button = addPresetButton;
Child = new FillFlowContainer
{
Width = 300,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(7),
Children = new Drawable[]
{
nameTextBox = new LabelledTextBox
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Label = CommonStrings.Name,
TabbableContentContainer = this
},
descriptionTextBox = new LabelledTextBox
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Label = CommonStrings.Description,
TabbableContentContainer = this
},
createButton = new ShearedButton
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = ModSelectOverlayStrings.AddPreset,
Action = tryCreatePreset
}
}
};
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, OsuColour colours)
{
Body.BorderThickness = 3;
Body.BorderColour = colours.Orange1;
createButton.DarkerColour = colours.Orange1;
createButton.LighterColour = colours.Orange0;
createButton.TextColour = colourProvider.Background6;
}
protected override void LoadComplete()
{
base.LoadComplete();
ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox));
}
private void tryCreatePreset()
{
if (string.IsNullOrWhiteSpace(nameTextBox.Current.Value))
{
Body.Shake();
return;
}
realm.Write(r => r.Add(new ModPreset
{
Name = nameTextBox.Current.Value,
Description = descriptionTextBox.Current.Value,
Mods = selectedMods.Value.ToArray(),
Ruleset = r.Find<RulesetInfo>(ruleset.Value.ShortName)
}));
this.HidePopover();
}
protected override void UpdateState(ValueChangedEvent<Visibility> state)
{
base.UpdateState(state);
if (state.NewValue == Visibility.Hidden)
button.Active.Value = false;
}
}
}

View File

@ -0,0 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Database;
using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Overlays.Mods
{
public class DeleteModPresetDialog : DeleteConfirmationDialog
{
public DeleteModPresetDialog(Live<ModPreset> modPreset)
{
BodyText = modPreset.PerformRead(preset => preset.Name);
DeleteAction = () => modPreset.PerformWrite(preset => preset.DeletePending = true);
}
}
}

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

@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
using osu.Game.Rulesets.Mods;
using osu.Game.Localisation;
namespace osu.Game.Overlays.Mods.Input
{
@ -15,6 +17,7 @@ namespace osu.Game.Overlays.Mods.Input
/// Individual letters in a row trigger the mods in a sequential fashion.
/// Uses <see cref="SequentialModHotkeyHandler"/>.
/// </summary>
[LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.SequentialHotkeyStyle))]
Sequential,
/// <summary>
@ -22,6 +25,7 @@ namespace osu.Game.Overlays.Mods.Input
/// One keybinding can toggle between what used to be <see cref="MultiMod"/>s on stable,
/// and some mods in a column may not have any hotkeys at all.
/// </summary>
[LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.ClassicHotkeyStyle))]
Classic
}
}

View File

@ -66,7 +66,10 @@ namespace osu.Game.Overlays.Mods
private IModHotkeyHandler hotkeyHandler = null!;
private Task? latestLoadTask;
internal bool ItemsLoaded => latestLoadTask == null;
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,20 +133,14 @@ 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;
Task? loadTask;
latestLoadTask = loadTask = LoadComponentsAsync(panels, loaded =>
latestLoadTask = LoadComponentsAsync(panels, loaded =>
{
ItemsFlow.ChildrenEnumerable = loaded;
updateState();
}, (cancellationTokenSource = new CancellationTokenSource()).Token);
loadTask.ContinueWith(_ =>
{
if (loadTask == latestLoadTask)
latestLoadTask = null;
});
}
private void updateState()

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

@ -2,12 +2,12 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Localisation;
@ -31,6 +31,10 @@ namespace osu.Game.Overlays.Mods
{
AccentColour = colours.Orange1;
HeaderText = ModSelectOverlayStrings.PersonalPresets;
AddPresetButton addPresetButton;
ItemsFlow.Add(addPresetButton = new AddPresetButton());
ItemsFlow.SetLayoutPosition(addPresetButton, float.PositiveInfinity);
}
protected override void LoadComplete()
@ -46,44 +50,41 @@ namespace osu.Game.Overlays.Mods
{
presetSubscription?.Dispose();
presetSubscription = realm.RegisterForNotifications(r =>
r.All<ModPreset>()
.Filter($"{nameof(ModPreset.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0"
+ $" && {nameof(ModPreset.DeletePending)} == false", ruleset.Value.ShortName)
.OrderBy(preset => preset.Name),
(presets, _, _) => asyncLoadPanels(presets));
r.All<ModPreset>()
.Filter($"{nameof(ModPreset.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0"
+ $" && {nameof(ModPreset.DeletePending)} == false", ruleset.Value.ShortName)
.OrderBy(preset => preset.Name), asyncLoadPanels);
}
private CancellationTokenSource? cancellationTokenSource;
private Task? latestLoadTask;
internal bool ItemsLoaded => latestLoadTask == null;
internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true;
private void asyncLoadPanels(IReadOnlyList<ModPreset> presets)
private void asyncLoadPanels(IRealmCollection<ModPreset> presets, ChangeSet changes, Exception error)
{
cancellationTokenSource?.Cancel();
if (!presets.Any())
{
ItemsFlow.Clear();
removeAndDisposePresetPanels();
return;
}
var panels = presets.Select(preset => new ModPresetPanel(preset.ToLive(realm))
latestLoadTask = LoadComponentsAsync(presets.Select(p => new ModPresetPanel(p.ToLive(realm))
{
Shear = Vector2.Zero
});
Task? loadTask;
latestLoadTask = loadTask = LoadComponentsAsync(panels, loaded =>
}), loaded =>
{
ItemsFlow.ChildrenEnumerable = loaded;
removeAndDisposePresetPanels();
ItemsFlow.AddRange(loaded);
}, (cancellationTokenSource = new CancellationTokenSource()).Token);
loadTask.ContinueWith(_ =>
void removeAndDisposePresetPanels()
{
if (loadTask == latestLoadTask)
latestLoadTask = null;
});
foreach (var panel in ItemsFlow.OfType<ModPresetPanel>().ToArray())
panel.RemoveAndDisposeImmediately();
}
}
protected override void Dispose(bool isDisposing)

View File

@ -1,21 +1,36 @@
// 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.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Overlays.Mods
{
public class ModPresetPanel : ModSelectPanel, IHasCustomTooltip<ModPreset>
public class ModPresetPanel : ModSelectPanel, IHasCustomTooltip<ModPreset>, IHasContextMenu
{
public readonly Live<ModPreset> Preset;
public override BindableBool Active { get; } = new BindableBool();
[Resolved]
private IDialogOverlay? dialogOverlay { get; set; }
[Resolved]
private Bindable<IReadOnlyList<Mod>> selectedMods { get; set; } = null!;
private ModSettingChangeTracker? settingChangeTracker;
public ModPresetPanel(Live<ModPreset> preset)
{
Preset = preset;
@ -30,7 +45,62 @@ namespace osu.Game.Overlays.Mods
AccentColour = colours.Orange1;
}
protected override void LoadComplete()
{
base.LoadComplete();
selectedMods.BindValueChanged(_ => selectedModsChanged(), true);
}
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 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 = Array.Empty<Mod>();
}
private void selectedModsChanged()
{
settingChangeTracker?.Dispose();
settingChangeTracker = new ModSettingChangeTracker(selectedMods.Value);
settingChangeTracker.SettingChanged = _ => updateActiveState();
updateActiveState();
}
private void updateActiveState()
{
Active.Value = new HashSet<Mod>(Preset.Value.Mods).SetEquals(selectedMods.Value);
}
#region IHasCustomTooltip
public ModPreset TooltipContent => Preset.Value;
public ITooltip<ModPreset> GetCustomTooltip() => new ModPresetTooltip(ColourProvider);
#endregion
#region IHasContextMenu
public MenuItem[] ContextMenuItems => new MenuItem[]
{
new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new DeleteModPresetDialog(Preset)))
};
#endregion
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
settingChangeTracker?.Dispose();
}
}
}

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

@ -11,12 +11,14 @@ using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
@ -72,6 +74,11 @@ namespace osu.Game.Overlays.Mods
/// </summary>
protected virtual bool AllowCustomisation => true;
/// <summary>
/// Whether the column with available mod presets should be shown.
/// </summary>
protected virtual bool ShowPresets => false;
protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false);
protected virtual IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection) => newSelection;
@ -80,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 }
@ -100,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;
@ -139,40 +146,37 @@ namespace osu.Game.Overlays.Mods
MainAreaContent.AddRange(new Drawable[]
{
new Container
new OsuContextMenuContainer
{
Padding = new MarginPadding
{
Top = (ShowTotalMultiplier ? DifficultyMultiplierDisplay.HEIGHT : 0) + PADDING,
Bottom = PADDING
},
RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Both,
Children = new Drawable[]
Child = new PopoverContainer
{
columnScroll = new ColumnScrollContainer
Padding = new MarginPadding
{
RelativeSizeAxes = Axes.Both,
Masking = false,
ClampExtension = 100,
ScrollbarOverlapsContent = false,
Child = columnFlow = new ColumnFlowContainer
Top = (ShowTotalMultiplier ? DifficultyMultiplierDisplay.HEIGHT : 0) + PADDING,
Bottom = PADDING
},
RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Both,
Children = new Drawable[]
{
columnScroll = new ColumnScrollContainer
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Direction = FillDirection.Horizontal,
Shear = new Vector2(SHEAR, 0),
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Margin = new MarginPadding { Horizontal = 70 },
Padding = new MarginPadding { Bottom = 10 },
Children = new[]
RelativeSizeAxes = Axes.Both,
Masking = false,
ClampExtension = 100,
ScrollbarOverlapsContent = false,
Child = columnFlow = new ColumnFlowContainer
{
createModColumnContent(ModType.DifficultyReduction),
createModColumnContent(ModType.DifficultyIncrease),
createModColumnContent(ModType.Automation),
createModColumnContent(ModType.Conversion),
createModColumnContent(ModType.Fun)
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Direction = FillDirection.Horizontal,
Shear = new Vector2(SHEAR, 0),
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Margin = new MarginPadding { Horizontal = 70 },
Padding = new MarginPadding { Bottom = 10 },
ChildrenEnumerable = createColumns()
}
}
}
@ -210,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,
@ -243,8 +247,8 @@ namespace osu.Game.Overlays.Mods
modSettingChangeTracker?.Dispose();
updateMultiplier();
updateCustomisation(val);
updateFromExternalSelection();
updateCustomisation();
if (AllowCustomisation)
{
@ -269,7 +273,7 @@ namespace osu.Game.Overlays.Mods
/// </summary>
public void SelectAll()
{
foreach (var column in columnFlow.Columns)
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.SelectAll();
}
@ -278,10 +282,27 @@ namespace osu.Game.Overlays.Mods
/// </summary>
public void DeselectAll()
{
foreach (var column in columnFlow.Columns)
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.DeselectAll();
}
private IEnumerable<ColumnDimContainer> createColumns()
{
if (ShowPresets)
{
yield return new ColumnDimContainer(new ModPresetColumn
{
Margin = new MarginPadding { Right = 10 }
});
}
yield return createModColumnContent(ModType.DifficultyReduction);
yield return createModColumnContent(ModType.DifficultyIncrease);
yield return createModColumnContent(ModType.Automation);
yield return createModColumnContent(ModType.Conversion);
yield return createModColumnContent(ModType.Fun);
}
private ColumnDimContainer createModColumnContent(ModType modType)
{
var column = CreateModColumn(modType).With(column =>
@ -290,12 +311,7 @@ namespace osu.Game.Overlays.Mods
column.Margin = new MarginPadding { Right = 10 };
});
return new ColumnDimContainer(column)
{
AutoSizeAxes = Axes.X,
RelativeSizeAxes = Axes.Y,
RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140),
};
return new ColumnDimContainer(column);
}
private void createLocalMods()
@ -317,7 +333,7 @@ namespace osu.Game.Overlays.Mods
AvailableMods.Value = newLocalAvailableMods;
filterMods();
foreach (var column in columnFlow.Columns)
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.AvailableMods = AvailableMods.Value.GetValueOrDefault(column.ModType, Array.Empty<ModState>());
}
@ -340,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
@ -378,7 +395,7 @@ namespace osu.Game.Overlays.Mods
foreach (var button in footerButtonFlow)
{
if (button != customisationButton)
if (button != CustomisationButton)
button.Enabled.Value = !customisationVisible.Value;
}
@ -458,7 +475,7 @@ namespace osu.Game.Overlays.Mods
{
var column = columnFlow[i].Column;
bool allFiltered = column.AvailableMods.All(modState => modState.Filtered.Value);
bool allFiltered = column is ModColumn modColumn && modColumn.AvailableMods.All(modState => modState.Filtered.Value);
double delay = allFiltered ? 0 : nonFilteredColumnCount * 30;
double duration = allFiltered ? 0 : fade_in_duration;
@ -516,14 +533,19 @@ namespace osu.Game.Overlays.Mods
{
var column = columnFlow[i].Column;
bool allFiltered = column.AvailableMods.All(modState => modState.Filtered.Value);
bool allFiltered = false;
if (column is ModColumn modColumn)
{
allFiltered = modColumn.AvailableMods.All(modState => modState.Filtered.Value);
modColumn.FlushPendingSelections();
}
double duration = allFiltered ? 0 : fade_out_duration;
float newYPosition = 0;
if (!allFiltered)
newYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance;
column.FlushPendingSelections();
column.TopLevelContent
.MoveToY(newYPosition, duration, Easing.OutQuint)
.FadeOut(duration, Easing.OutQuint);
@ -566,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();
}
}
@ -589,6 +611,7 @@ namespace osu.Game.Overlays.Mods
/// <summary>
/// Manages horizontal scrolling of mod columns, along with the "active" states of each column based on visibility.
/// </summary>
[Cached]
internal class ColumnScrollContainer : OsuScrollContainer<ColumnFlowContainer>
{
public ColumnScrollContainer()
@ -632,7 +655,7 @@ namespace osu.Game.Overlays.Mods
/// </summary>
internal class ColumnFlowContainer : FillFlowContainer<ColumnDimContainer>
{
public IEnumerable<ModColumn> Columns => Children.Select(dimWrapper => dimWrapper.Column);
public IEnumerable<ModSelectColumn> Columns => Children.Select(dimWrapper => dimWrapper.Column);
public override void Add(ColumnDimContainer dimContainer)
{
@ -648,7 +671,7 @@ namespace osu.Game.Overlays.Mods
/// </summary>
internal class ColumnDimContainer : Container
{
public ModColumn Column { get; }
public ModSelectColumn Column { get; }
/// <summary>
/// Tracks whether this column is in an interactive state. Generally only the case when the column is on-screen.
@ -663,12 +686,21 @@ namespace osu.Game.Overlays.Mods
[Resolved]
private OsuColour colours { get; set; } = null!;
public ColumnDimContainer(ModColumn column)
public ColumnDimContainer(ModSelectColumn column)
{
AutoSizeAxes = Axes.X;
RelativeSizeAxes = Axes.Y;
Child = Column = column;
column.Active.BindTo(Active);
}
[BackgroundDependencyLoader]
private void load(ColumnScrollContainer columnScroll)
{
RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140);
}
protected override void LoadComplete()
{
base.LoadComplete();
@ -677,7 +709,18 @@ namespace osu.Game.Overlays.Mods
FinishTransforms();
}
protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate || Column.SelectionAnimationRunning;
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

@ -43,8 +43,7 @@ namespace osu.Game.Overlays.Mods
}
public const float CORNER_RADIUS = 7;
protected const float HEIGHT = 42;
public const float HEIGHT = 42;
protected virtual float IdleSwitchWidth => 14;
protected virtual float ExpandedSwitchWidth => 30;
@ -144,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

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Linq;
using osu.Framework.Bindables;
@ -17,10 +15,12 @@ namespace osu.Game.Overlays.Music
{
public class Playlist : OsuRearrangeableListContainer<Live<BeatmapSetInfo>>
{
public Action<Live<BeatmapSetInfo>> RequestSelection;
public Action<Live<BeatmapSetInfo>>? RequestSelection;
public readonly Bindable<Live<BeatmapSetInfo>> SelectedSet = new Bindable<Live<BeatmapSetInfo>>();
private FilterCriteria currentCriteria = new FilterCriteria();
public new MarginPadding Padding
{
get => base.Padding;
@ -31,26 +31,22 @@ namespace osu.Game.Overlays.Music
{
var items = (SearchContainer<RearrangeableListItem<Live<BeatmapSetInfo>>>)ListContainer;
string[] currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray());
string[]? currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray());
foreach (var item in items.OfType<PlaylistItem>())
{
if (currentCollectionHashes == null)
item.InSelectedCollection = true;
else
{
item.InSelectedCollection = item.Model.Value.Beatmaps.Select(b => b.MD5Hash)
.Any(currentCollectionHashes.Contains);
}
item.InSelectedCollection = currentCollectionHashes == null || item.Model.Value.Beatmaps.Select(b => b.MD5Hash).Any(currentCollectionHashes.Contains);
}
items.SearchTerm = criteria.SearchText;
currentCriteria = criteria;
}
public Live<BeatmapSetInfo> FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter);
public Live<BeatmapSetInfo>? FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter);
protected override OsuRearrangeableListItem<Live<BeatmapSetInfo>> CreateOsuDrawable(Live<BeatmapSetInfo> item) => new PlaylistItem(item)
{
InSelectedCollection = currentCriteria.Collection?.PerformRead(c => item.Value.Beatmaps.Select(b => b.MD5Hash).Any(c.BeatmapMD5Hashes.Contains)) != false,
SelectedSet = { BindTarget = SelectedSet },
RequestSelection = set => RequestSelection?.Invoke(set)
};

View File

@ -26,8 +26,6 @@ namespace osu.Game.Overlays.Music
private const float transition_duration = 600;
private const float playlist_height = 510;
public IBindableList<Live<BeatmapSetInfo>> BeatmapSets => beatmapSets;
private readonly BindableList<Live<BeatmapSetInfo>> beatmapSets = new BindableList<Live<BeatmapSetInfo>>();
private readonly Bindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>();
@ -104,9 +102,7 @@ namespace osu.Game.Overlays.Music
{
base.LoadComplete();
// tests might bind externally, in which case we don't want to involve realm.
if (beatmapSets.Count == 0)
beatmapSubscription = realm.RegisterForNotifications(r => r.All<BeatmapSetInfo>().Where(s => !s.DeletePending), beatmapsChanged);
beatmapSubscription = realm.RegisterForNotifications(r => r.All<BeatmapSetInfo>().Where(s => !s.DeletePending), beatmapsChanged);
list.Items.BindTo(beatmapSets);
beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true);

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

@ -120,7 +120,13 @@ namespace osu.Game.Overlays.Profile.Sections.Recent
};
default:
return Empty();
return new RecentActivityIcon(activity)
{
RelativeSizeAxes = Axes.X,
Height = 11,
FillMode = FillMode.Fit,
Margin = new MarginPadding { Top = 2, Vertical = 2 }
};
}
}

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