Merge branch 'master' into transformers-per-skin

This commit is contained in:
Dean Herbert
2021-06-22 16:03:15 +09:00
141 changed files with 2514 additions and 979 deletions

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Lists;
@ -66,6 +67,7 @@ namespace osu.Game.Beatmaps.ControlPoints
/// </summary>
/// <param name="time">The time to find the difficulty control point at.</param>
/// <returns>The difficulty control point.</returns>
[NotNull]
public DifficultyControlPoint DifficultyPointAt(double time) => binarySearchWithFallback(DifficultyPoints, time, DifficultyControlPoint.DEFAULT);
/// <summary>
@ -73,6 +75,7 @@ namespace osu.Game.Beatmaps.ControlPoints
/// </summary>
/// <param name="time">The time to find the effect control point at.</param>
/// <returns>The effect control point.</returns>
[NotNull]
public EffectControlPoint EffectPointAt(double time) => binarySearchWithFallback(EffectPoints, time, EffectControlPoint.DEFAULT);
/// <summary>
@ -80,6 +83,7 @@ namespace osu.Game.Beatmaps.ControlPoints
/// </summary>
/// <param name="time">The time to find the sound control point at.</param>
/// <returns>The sound control point.</returns>
[NotNull]
public SampleControlPoint SamplePointAt(double time) => binarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : SampleControlPoint.DEFAULT);
/// <summary>
@ -87,6 +91,7 @@ namespace osu.Game.Beatmaps.ControlPoints
/// </summary>
/// <param name="time">The time to find the timing control point at.</param>
/// <returns>The timing control point.</returns>
[NotNull]
public TimingControlPoint TimingPointAt(double time) => binarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : TimingControlPoint.DEFAULT);
/// <summary>

View File

@ -0,0 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using Newtonsoft.Json;
using Realms;
namespace osu.Game.Database
{
public interface IHasGuidPrimaryKey
{
[JsonIgnore]
[PrimaryKey]
Guid ID { get; set; }
}
}

View File

@ -0,0 +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.
using Realms;
namespace osu.Game.Database
{
public interface IRealmFactory
{
/// <summary>
/// The main realm context, bound to the update thread.
/// </summary>
Realm Context { get; }
/// <summary>
/// Get a fresh context for read usage.
/// </summary>
RealmContextFactory.RealmUsage GetForRead();
/// <summary>
/// Request a context for write usage.
/// This method may block if a write is already active on a different thread.
/// </summary>
/// <returns>A usage containing a usable context.</returns>
RealmContextFactory.RealmWriteUsage GetForWrite();
}
}

View File

@ -24,13 +24,15 @@ namespace osu.Game.Database
public DbSet<BeatmapDifficulty> BeatmapDifficulty { get; set; }
public DbSet<BeatmapMetadata> BeatmapMetadata { get; set; }
public DbSet<BeatmapSetInfo> BeatmapSetInfo { get; set; }
public DbSet<DatabasedKeyBinding> DatabasedKeyBinding { get; set; }
public DbSet<DatabasedSetting> DatabasedSetting { get; set; }
public DbSet<FileInfo> FileInfo { get; set; }
public DbSet<RulesetInfo> RulesetInfo { get; set; }
public DbSet<SkinInfo> SkinInfo { get; set; }
public DbSet<ScoreInfo> ScoreInfo { get; set; }
// migrated to realm
public DbSet<DatabasedKeyBinding> DatabasedKeyBinding { get; set; }
private readonly string connectionString;
private static readonly Lazy<OsuDbLoggerFactory> logger = new Lazy<OsuDbLoggerFactory>(() => new OsuDbLoggerFactory());

View File

@ -0,0 +1,208 @@
// 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.Threading;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Database
{
public class RealmContextFactory : Component, IRealmFactory
{
private readonly Storage storage;
private const string database_name = @"client";
private const int schema_version = 6;
/// <summary>
/// Lock object which is held for the duration of a write operation (via <see cref="GetForWrite"/>).
/// </summary>
private readonly object writeLock = new object();
private static readonly GlobalStatistic<int> reads = GlobalStatistics.Get<int>("Realm", "Get (Read)");
private static readonly GlobalStatistic<int> writes = GlobalStatistics.Get<int>("Realm", "Get (Write)");
private static readonly GlobalStatistic<int> refreshes = GlobalStatistics.Get<int>("Realm", "Dirty Refreshes");
private static readonly GlobalStatistic<int> contexts_created = GlobalStatistics.Get<int>("Realm", "Contexts (Created)");
private static readonly GlobalStatistic<int> pending_writes = GlobalStatistics.Get<int>("Realm", "Pending writes");
private static readonly GlobalStatistic<int> active_usages = GlobalStatistics.Get<int>("Realm", "Active usages");
private readonly ManualResetEventSlim blockingResetEvent = new ManualResetEventSlim(true);
private Realm context;
public Realm Context
{
get
{
if (IsDisposed)
throw new InvalidOperationException($"Attempted to access {nameof(Context)} on a disposed context factory");
if (context == null)
{
context = createContext();
Logger.Log($"Opened realm \"{context.Config.DatabasePath}\" at version {context.Config.SchemaVersion}");
}
// creating a context will ensure our schema is up-to-date and migrated.
return context;
}
}
public RealmContextFactory(Storage storage)
{
this.storage = storage;
}
public RealmUsage GetForRead()
{
reads.Value++;
return new RealmUsage(this);
}
public RealmWriteUsage GetForWrite()
{
writes.Value++;
pending_writes.Value++;
Monitor.Enter(writeLock);
return new RealmWriteUsage(this);
}
protected override void Update()
{
base.Update();
if (context?.Refresh() == true)
refreshes.Value++;
}
private Realm createContext()
{
blockingResetEvent.Wait();
contexts_created.Value++;
return Realm.GetInstance(new RealmConfiguration(storage.GetFullPath($"{database_name}.realm", true))
{
SchemaVersion = schema_version,
MigrationCallback = onMigration,
});
}
private void onMigration(Migration migration, ulong lastSchemaVersion)
{
switch (lastSchemaVersion)
{
case 5:
// let's keep things simple. changing the type of the primary key is a bit involved.
migration.NewRealm.RemoveAll<RealmKeyBinding>();
break;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
BlockAllOperations();
}
public IDisposable BlockAllOperations()
{
blockingResetEvent.Reset();
flushContexts();
return new InvokeOnDisposal<RealmContextFactory>(this, r => endBlockingSection());
}
private void endBlockingSection()
{
blockingResetEvent.Set();
}
private void flushContexts()
{
var previousContext = context;
context = null;
// wait for all threaded usages to finish
while (active_usages.Value > 0)
Thread.Sleep(50);
previousContext?.Dispose();
}
/// <summary>
/// A usage of realm from an arbitrary thread.
/// </summary>
public class RealmUsage : IDisposable
{
public readonly Realm Realm;
protected readonly RealmContextFactory Factory;
internal RealmUsage(RealmContextFactory factory)
{
active_usages.Value++;
Factory = factory;
Realm = factory.createContext();
}
/// <summary>
/// Disposes this instance, calling the initially captured action.
/// </summary>
public virtual void Dispose()
{
Realm?.Dispose();
active_usages.Value--;
}
}
/// <summary>
/// A transaction used for making changes to realm data.
/// </summary>
public class RealmWriteUsage : RealmUsage
{
private readonly Transaction transaction;
internal RealmWriteUsage(RealmContextFactory factory)
: base(factory)
{
transaction = Realm.BeginWrite();
}
/// <summary>
/// Commit all changes made in this transaction.
/// </summary>
public void Commit() => transaction.Commit();
/// <summary>
/// Revert all changes made in this transaction.
/// </summary>
public void Rollback() => transaction.Rollback();
/// <summary>
/// Disposes this instance, calling the initially captured action.
/// </summary>
public override void Dispose()
{
// rollback if not explicitly committed.
transaction?.Dispose();
base.Dispose();
Monitor.Exit(Factory.writeLock);
pending_writes.Value--;
}
}
}
}

View File

@ -0,0 +1,51 @@
// 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 AutoMapper;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Database
{
public static class RealmExtensions
{
private static readonly IMapper mapper = new MapperConfiguration(c =>
{
c.ShouldMapField = fi => false;
c.ShouldMapProperty = pi => pi.SetMethod != null && pi.SetMethod.IsPublic;
c.CreateMap<RealmKeyBinding, RealmKeyBinding>();
}).CreateMapper();
/// <summary>
/// Create a detached copy of the each item in the collection.
/// </summary>
/// <param name="items">A list of managed <see cref="RealmObject"/>s to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A list containing non-managed copies of provided items.</returns>
public static List<T> Detach<T>(this IEnumerable<T> items) where T : RealmObject
{
var list = new List<T>();
foreach (var obj in items)
list.Add(obj.Detach());
return list;
}
/// <summary>
/// Create a detached copy of the item.
/// </summary>
/// <param name="item">The managed <see cref="RealmObject"/> to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A non-managed copy of provided item. Will return the provided item if already detached.</returns>
public static T Detach<T>(this T item) where T : RealmObject
{
if (!item.IsManaged)
return item;
return mapper.Map<T>(item);
}
}
}

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.Globalization;
using osu.Game.Localisation;
namespace osu.Game.Extensions
{
/// <summary>
/// Conversion utilities for the <see cref="Language"/> enum.
/// </summary>
public static class LanguageExtensions
{
/// <summary>
/// Returns the culture code of the <see cref="CultureInfo"/> that corresponds to the supplied <paramref name="language"/>.
/// </summary>
/// <remarks>
/// This is required as enum member names are not allowed to contain hyphens.
/// </remarks>
public static string ToCultureCode(this Language language)
=> language.ToString().Replace("_", "-");
/// <summary>
/// Attempts to parse the supplied <paramref name="cultureCode"/> to a <see cref="Language"/> value.
/// </summary>
/// <param name="cultureCode">The code of the culture to parse.</param>
/// <param name="language">The parsed <see cref="Language"/>. Valid only if the return value of the method is <see langword="true" />.</param>
/// <returns>Whether the parsing succeeded.</returns>
public static bool TryParseCultureCode(string cultureCode, out Language language)
=> Enum.TryParse(cultureCode.Replace("-", "_"), out language);
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osuTK.Graphics;
using System.Collections.Generic;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Graphics.Containers
{
@ -20,7 +21,8 @@ namespace osu.Game.Graphics.Containers
protected virtual IEnumerable<Drawable> EffectTargets => new[] { Content };
public OsuHoverContainer()
public OsuHoverContainer(HoverSampleSet sampleSet = HoverSampleSet.Default)
: base(sampleSet)
{
Enabled.ValueChanged += e =>
{

View File

@ -13,13 +13,13 @@ namespace osu.Game.Graphics.UserInterface
[Description("button")]
Button,
[Description("softer")]
Soft,
[Description("toolbar")]
Toolbar,
[Description("songselect")]
SongSelect
[Description("tabselect")]
TabSelect,
[Description("scrolltotop")]
ScrollToTop
}
}

View File

@ -4,6 +4,8 @@
using System.Linq;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -57,6 +59,9 @@ namespace osu.Game.Graphics.UserInterface
{
public override bool HandleNonPositionalInput => State == MenuState.Open;
private Sample sampleOpen;
private Sample sampleClose;
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
public OsuDropdownMenu()
{
@ -69,9 +74,30 @@ namespace osu.Game.Graphics.UserInterface
ItemsContainer.Padding = new MarginPadding(5);
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sampleOpen = audio.Samples.Get(@"UI/dropdown-open");
sampleClose = audio.Samples.Get(@"UI/dropdown-close");
}
// todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed.
private bool wasOpened;
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
protected override void AnimateOpen() => this.FadeIn(300, Easing.OutQuint);
protected override void AnimateClose() => this.FadeOut(300, Easing.OutQuint);
protected override void AnimateOpen()
{
wasOpened = true;
this.FadeIn(300, Easing.OutQuint);
sampleOpen?.Play();
}
protected override void AnimateClose()
{
this.FadeOut(300, Easing.OutQuint);
if (wasOpened)
sampleClose?.Play();
}
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
protected override void UpdateSize(Vector2 newSize)
@ -155,7 +181,7 @@ namespace osu.Game.Graphics.UserInterface
nonAccentSelectedColour = Color4.Black.Opacity(0.5f);
updateColours();
AddInternal(new HoverClickSounds(HoverSampleSet.Soft));
AddInternal(new HoverSounds());
}
protected override void UpdateForegroundColour()
@ -262,7 +288,7 @@ namespace osu.Game.Graphics.UserInterface
},
};
AddInternal(new HoverClickSounds());
AddInternal(new HoverSounds());
}
[BackgroundDependencyLoader]

View File

@ -160,7 +160,7 @@ namespace osu.Game.Graphics.UserInterface
Margin = new MarginPadding { Top = 5, Bottom = 5 },
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
Text = (value as IHasDescription)?.Description ?? (value as Enum)?.GetDescription() ?? value.ToString(),
Text = (value as IHasDescription)?.Description ?? (value as Enum)?.GetLocalisableDescription() ?? value.ToString(),
Font = OsuFont.GetFont(size: 14)
},
Bar = new Box
@ -172,7 +172,7 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
new HoverClickSounds()
new HoverClickSounds(HoverSampleSet.TabSelect)
};
}

View File

@ -4,6 +4,8 @@
using osuTK;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -43,6 +45,8 @@ namespace osu.Game.Graphics.UserInterface
}
private const float transition_length = 500;
private Sample sampleChecked;
private Sample sampleUnchecked;
public OsuTabControlCheckbox()
{
@ -77,8 +81,7 @@ namespace osu.Game.Graphics.UserInterface
Colour = Color4.White,
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
new HoverClickSounds()
}
};
Current.ValueChanged += selected =>
@ -91,10 +94,13 @@ namespace osu.Game.Graphics.UserInterface
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
private void load(OsuColour colours, AudioManager audio)
{
if (accentColour == null)
AccentColour = colours.Blue;
sampleChecked = audio.Samples.Get(@"UI/check-on");
sampleUnchecked = audio.Samples.Get(@"UI/check-off");
}
protected override bool OnHover(HoverEvent e)
@ -111,6 +117,16 @@ namespace osu.Game.Graphics.UserInterface
base.OnHoverLost(e);
}
protected override void OnUserChange(bool value)
{
base.OnUserChange(value);
if (value)
sampleChecked?.Play();
else
sampleUnchecked?.Play();
}
private void updateFade()
{
box.FadeTo(Current.Value || IsHovered ? 1 : 0, transition_length, Easing.OutQuint);

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics.Sprites;
namespace osu.Game.Graphics.UserInterface
@ -75,13 +76,13 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
new HoverClickSounds()
new HoverClickSounds(HoverSampleSet.TabSelect)
};
Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Torus, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true);
}
protected virtual string CreateText() => (Value as Enum)?.GetDescription() ?? Value.ToString();
protected virtual LocalisableString CreateText() => (Value as Enum)?.GetLocalisableDescription() ?? Value.ToString();
protected override bool OnHover(HoverEvent e)
{

View File

@ -33,12 +33,18 @@ namespace osu.Game.IO
private readonly StorageConfigManager storageConfig;
private readonly Storage defaultStorage;
public override string[] IgnoreDirectories => new[] { "cache" };
public override string[] IgnoreDirectories => new[]
{
"cache",
"client.realm.management"
};
public override string[] IgnoreFiles => new[]
{
"framework.ini",
"storage.ini"
"storage.ini",
"client.realm.note",
"client.realm.lock",
};
public OsuStorage(GameHost host, Storage defaultStorage)

View File

@ -8,7 +8,7 @@ using osu.Game.Database;
namespace osu.Game.Input.Bindings
{
[Table("KeyBinding")]
public class DatabasedKeyBinding : KeyBinding, IHasPrimaryKey
public class DatabasedKeyBinding : IKeyBinding, IHasPrimaryKey
{
public int ID { get; set; }
@ -17,17 +17,23 @@ namespace osu.Game.Input.Bindings
public int? Variant { get; set; }
[Column("Keys")]
public string KeysString
{
get => KeyCombination.ToString();
private set => KeyCombination = value;
}
public string KeysString { get; set; }
[Column("Action")]
public int IntAction
public int IntAction { get; set; }
[NotMapped]
public KeyCombination KeyCombination
{
get => (int)Action;
set => Action = value;
get => KeysString;
set => KeysString = value.ToString();
}
[NotMapped]
public object Action
{
get => IntAction;
set => IntAction = (int)value;
}
}
}

View File

@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Input.Bindings;
using osu.Game.Database;
using osu.Game.Rulesets;
using System.Linq;
using Realms;
namespace osu.Game.Input.Bindings
{
@ -21,7 +23,11 @@ namespace osu.Game.Input.Bindings
private readonly int? variant;
private KeyBindingStore store;
private IDisposable realmSubscription;
private IQueryable<RealmKeyBinding> realmKeyBindings;
[Resolved]
private RealmContextFactory realmFactory { get; set; }
public override IEnumerable<IKeyBinding> DefaultKeyBindings => ruleset.CreateInstance().GetDefaultKeyBindings(variant ?? 0);
@ -42,24 +48,34 @@ namespace osu.Game.Input.Bindings
throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided.");
}
[BackgroundDependencyLoader]
private void load(KeyBindingStore keyBindings)
{
store = keyBindings;
}
protected override void LoadComplete()
{
if (ruleset == null || ruleset.ID.HasValue)
{
var rulesetId = ruleset?.ID;
realmKeyBindings = realmFactory.Context.All<RealmKeyBinding>()
.Where(b => b.RulesetID == rulesetId && b.Variant == variant);
realmSubscription = realmKeyBindings
.SubscribeForNotifications((sender, changes, error) =>
{
// first subscription ignored as we are handling this in LoadComplete.
if (changes == null)
return;
ReloadMappings();
});
}
base.LoadComplete();
store.KeyBindingChanged += ReloadMappings;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (store != null)
store.KeyBindingChanged -= ReloadMappings;
realmSubscription?.Dispose();
}
protected override void ReloadMappings()
@ -67,17 +83,17 @@ namespace osu.Game.Input.Bindings
var defaults = DefaultKeyBindings.ToList();
if (ruleset != null && !ruleset.ID.HasValue)
// if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings.
// fallback to defaults instead.
// some tests instantiate a ruleset which is not present in the database.
// in these cases we still want key bindings to work, but matching to database instances would result in none being present,
// so let's populate the defaults directly.
KeyBindings = defaults;
else
{
KeyBindings = store.Query(ruleset?.ID, variant)
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction))
// this ordering is important to ensure that we read entries from the database in the order
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order.
.ToList();
KeyBindings = realmKeyBindings.Detach()
// this ordering is important to ensure that we read entries from the database in the order
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order.
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList();
}
}
}

View File

@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Input.Bindings;
using osu.Game.Database;
using Realms;
namespace osu.Game.Input.Bindings
{
[MapTo(nameof(KeyBinding))]
public class RealmKeyBinding : RealmObject, IHasGuidPrimaryKey, IKeyBinding
{
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
public int? RulesetID { get; set; }
public int? Variant { get; set; }
public KeyCombination KeyCombination
{
get => KeyCombinationString;
set => KeyCombinationString = value.ToString();
}
public object Action
{
get => ActionInt;
set => ActionInt = (int)value;
}
[MapTo(nameof(Action))]
public int ActionInt { get; set; }
[MapTo(nameof(KeyCombination))]
public string KeyCombinationString { get; set; }
}
}

View File

@ -1,133 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Input.Bindings;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
namespace osu.Game.Input
{
public class KeyBindingStore : DatabaseBackedStore
{
public event Action KeyBindingChanged;
/// <summary>
/// Keys which should not be allowed for gameplay input purposes.
/// </summary>
private static readonly IEnumerable<InputKey> banned_keys = new[]
{
InputKey.MouseWheelDown,
InputKey.MouseWheelLeft,
InputKey.MouseWheelUp,
InputKey.MouseWheelRight
};
public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null)
: base(contextFactory, storage)
{
using (ContextFactory.GetForWrite())
{
foreach (var info in rulesets.AvailableRulesets)
{
var ruleset = info.CreateInstance();
foreach (var variant in ruleset.AvailableVariants)
insertDefaults(ruleset.GetDefaultKeyBindings(variant), info.ID, variant);
}
}
}
public void Register(KeyBindingContainer manager) => insertDefaults(manager.DefaultKeyBindings);
/// <summary>
/// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action.
/// </summary>
/// <param name="globalAction">The action to lookup.</param>
/// <returns>A set of display strings for all the user's key configuration for the action.</returns>
public IEnumerable<string> GetReadableKeyCombinationsFor(GlobalAction globalAction)
{
foreach (var action in Query().Where(b => (GlobalAction)b.Action == globalAction))
{
string str = action.KeyCombination.ReadableString();
// even if found, the readable string may be empty for an unbound action.
if (str.Length > 0)
yield return str;
}
}
private void insertDefaults(IEnumerable<IKeyBinding> defaults, int? rulesetId = null, int? variant = null)
{
using (var usage = ContextFactory.GetForWrite())
{
// compare counts in database vs defaults
foreach (var group in defaults.GroupBy(k => k.Action))
{
int count = Query(rulesetId, variant).Count(k => (int)k.Action == (int)group.Key);
int aimCount = group.Count();
if (aimCount <= count)
continue;
foreach (var insertable in group.Skip(count).Take(aimCount - count))
{
// insert any defaults which are missing.
usage.Context.DatabasedKeyBinding.Add(new DatabasedKeyBinding
{
KeyCombination = insertable.KeyCombination,
Action = insertable.Action,
RulesetID = rulesetId,
Variant = variant
});
// required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686)
usage.Context.SaveChanges();
}
}
}
}
/// <summary>
/// Retrieve <see cref="DatabasedKeyBinding"/>s for a specified ruleset/variant content.
/// </summary>
/// <param name="rulesetId">The ruleset's internal ID.</param>
/// <param name="variant">An optional variant.</param>
public List<DatabasedKeyBinding> Query(int? rulesetId = null, int? variant = null) =>
ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList();
public void Update(KeyBinding keyBinding)
{
using (ContextFactory.GetForWrite())
{
var dbKeyBinding = (DatabasedKeyBinding)keyBinding;
Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination));
Refresh(ref dbKeyBinding);
if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination))
return;
dbKeyBinding.KeyCombination = keyBinding.KeyCombination;
}
KeyBindingChanged?.Invoke();
}
public static bool CheckValidForGameplay(KeyCombination combination)
{
foreach (var key in banned_keys)
{
if (combination.Keys.Contains(key))
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,117 @@
// 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.Input.Bindings;
using osu.Game.Database;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
#nullable enable
namespace osu.Game.Input
{
public class RealmKeyBindingStore
{
private readonly RealmContextFactory realmFactory;
public RealmKeyBindingStore(RealmContextFactory realmFactory)
{
this.realmFactory = realmFactory;
}
/// <summary>
/// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action.
/// </summary>
/// <param name="globalAction">The action to lookup.</param>
/// <returns>A set of display strings for all the user's key configuration for the action.</returns>
public IReadOnlyList<string> GetReadableKeyCombinationsFor(GlobalAction globalAction)
{
List<string> combinations = new List<string>();
using (var context = realmFactory.GetForRead())
{
foreach (var action in context.Realm.All<RealmKeyBinding>().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction))
{
string str = action.KeyCombination.ReadableString();
// even if found, the readable string may be empty for an unbound action.
if (str.Length > 0)
combinations.Add(str);
}
}
return combinations;
}
/// <summary>
/// Register a new type of <see cref="KeyBindingContainer{T}"/>, adding default bindings from <see cref="KeyBindingContainer.DefaultKeyBindings"/>.
/// </summary>
/// <param name="container">The container to populate defaults from.</param>
public void Register(KeyBindingContainer container) => insertDefaults(container.DefaultKeyBindings);
/// <summary>
/// Register a ruleset, adding default bindings for each of its variants.
/// </summary>
/// <param name="ruleset">The ruleset to populate defaults from.</param>
public void Register(RulesetInfo ruleset)
{
var instance = ruleset.CreateInstance();
foreach (var variant in instance.AvailableVariants)
insertDefaults(instance.GetDefaultKeyBindings(variant), ruleset.ID, variant);
}
private void insertDefaults(IEnumerable<IKeyBinding> defaults, int? rulesetId = null, int? variant = null)
{
using (var usage = realmFactory.GetForWrite())
{
// compare counts in database vs defaults
foreach (var defaultsForAction in defaults.GroupBy(k => k.Action))
{
int existingCount = usage.Realm.All<RealmKeyBinding>().Count(k => k.RulesetID == rulesetId && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key);
if (defaultsForAction.Count() <= existingCount)
continue;
foreach (var k in defaultsForAction.Skip(existingCount))
{
// insert any defaults which are missing.
usage.Realm.Add(new RealmKeyBinding
{
KeyCombinationString = k.KeyCombination.ToString(),
ActionInt = (int)k.Action,
RulesetID = rulesetId,
Variant = variant
});
}
}
usage.Commit();
}
}
/// <summary>
/// Keys which should not be allowed for gameplay input purposes.
/// </summary>
private static readonly IEnumerable<InputKey> banned_keys = new[]
{
InputKey.MouseWheelDown,
InputKey.MouseWheelLeft,
InputKey.MouseWheelUp,
InputKey.MouseWheelRight
};
public static bool CheckValidForGameplay(KeyCombination combination)
{
foreach (var key in banned_keys)
{
if (combination.Keys.Contains(key))
return false;
}
return true;
}
}
}

View File

@ -10,7 +10,103 @@ namespace osu.Game.Localisation
[Description(@"English")]
en,
// TODO: Requires Arabic glyphs to be added to resources (and possibly also RTL support).
// [Description(@"اَلْعَرَبِيَّةُ")]
// ar,
[Description(@"Беларуская мова")]
be,
[Description(@"Български")]
bg,
[Description(@"Česky")]
cs,
[Description(@"Dansk")]
da,
[Description(@"Deutsch")]
de,
[Description(@"Ελληνικά")]
el,
[Description(@"español")]
es,
[Description(@"Suomi")]
fi,
[Description(@"français")]
fr,
[Description(@"Magyar")]
hu,
[Description(@"Bahasa Indonesia")]
id,
[Description(@"Italiano")]
it,
[Description(@"日本語")]
ja
ja,
[Description(@"한국어")]
ko,
[Description(@"Nederlands")]
nl,
[Description(@"Norsk")]
no,
[Description(@"polski")]
pl,
[Description(@"Português")]
pt,
[Description(@"Português (Brasil)")]
pt_br,
[Description(@"Română")]
ro,
[Description(@"Русский")]
ru,
[Description(@"Slovenčina")]
sk,
[Description(@"Svenska")]
sv,
[Description(@"ไทย")]
th,
// Tagalog has no associated localisations yet, and is not supported on Xamarin platforms or Windows versions <10.
// Can be revisited if localisations ever arrive.
//[Description(@"Tagalog")]
//tl,
[Description(@"Türkçe")]
tr,
[Description(@"Українська мова")]
uk,
[Description(@"Tiếng Việt")]
vi,
[Description(@"简体中文")]
zh,
[Description(@"繁體中文(香港)")]
zh_hk,
[Description(@"繁體中文(台灣)")]
zh_tw
}
}

View File

@ -154,6 +154,10 @@ namespace osu.Game.Online.Chat
case "beatmapsets":
case "d":
{
if (mainArg == "discussions")
// handle discussion links externally for now
return new LinkDetails(LinkAction.External, url);
if (args.Length > 4 && int.TryParse(args[4], out var id))
// https://osu.ppy.sh/beatmapsets/1154158#osu/2768184
return new LinkDetails(LinkAction.OpenBeatmap, id.ToString());

View File

@ -6,7 +6,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using MessagePack;
using osu.Game.Online.API;
@ -28,11 +27,9 @@ namespace osu.Game.Online.Multiplayer
[Key(3)]
public string Name { get; set; } = "Unnamed room";
[NotNull]
[Key(4)]
public IEnumerable<APIMod> RequiredMods { get; set; } = Enumerable.Empty<APIMod>();
[NotNull]
[Key(5)]
public IEnumerable<APIMod> AllowedMods { get; set; } = Enumerable.Empty<APIMod>();

View File

@ -6,7 +6,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using MessagePack;
using Newtonsoft.Json;
using osu.Game.Online.API;
@ -35,7 +34,6 @@ namespace osu.Game.Online.Multiplayer
/// Any mods applicable only to the local user.
/// </summary>
[Key(3)]
[NotNull]
public IEnumerable<APIMod> Mods { get; set; } = Enumerable.Empty<APIMod>();
[IgnoreMember]

View File

@ -50,8 +50,10 @@ using osu.Game.Updater;
using osu.Game.Utils;
using LogLevel = osu.Framework.Logging.LogLevel;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Localisation;
using osu.Game.Performance;
using osu.Game.Skinning.Editor;
namespace osu.Game
@ -487,6 +489,8 @@ namespace osu.Game
protected virtual UpdateManager CreateUpdateManager() => new UpdateManager();
protected virtual HighPerformanceSession CreateHighPerformanceSession() => new HighPerformanceSession();
protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything);
#region Beatmap progression
@ -580,8 +584,16 @@ namespace osu.Game
foreach (var language in Enum.GetValues(typeof(Language)).OfType<Language>())
{
var cultureCode = language.ToString();
Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode));
var cultureCode = language.ToCultureCode();
try
{
Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode));
}
catch (Exception ex)
{
Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\"");
}
}
// The next time this is updated is in UpdateAfterChildren, which occurs too late and results
@ -604,9 +616,9 @@ namespace osu.Game
LocalConfig.LookupKeyBindings = l =>
{
var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l).ToArray();
var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l);
if (combinations.Length == 0)
if (combinations.Count == 0)
return "none";
return string.Join(" or ", combinations);
@ -755,6 +767,8 @@ namespace osu.Game
loadComponentSingleFile(new AccountCreationOverlay(), topMostOverlayContent.Add, true);
loadComponentSingleFile(new DialogOverlay(), topMostOverlayContent.Add, true);
loadComponentSingleFile(CreateHighPerformanceSession(), Add);
chatOverlay.State.ValueChanged += state => channelManager.HighPollRate.Value = state.NewValue == Visibility.Visible;
Add(difficultyRecommender);

View File

@ -95,7 +95,7 @@ namespace osu.Game
protected RulesetStore RulesetStore { get; private set; }
protected KeyBindingStore KeyBindingStore { get; private set; }
protected RealmKeyBindingStore KeyBindingStore { get; private set; }
protected MenuCursorContainer MenuCursorContainer { get; private set; }
@ -144,6 +144,8 @@ namespace osu.Game
private DatabaseContextFactory contextFactory;
private RealmContextFactory realmFactory;
protected override Container<Drawable> Content => content;
private Container content;
@ -179,6 +181,9 @@ namespace osu.Game
dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage));
dependencies.Cache(realmFactory = new RealmContextFactory(Storage));
AddInternal(realmFactory);
dependencies.CacheAs(Storage);
var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures")));
@ -190,20 +195,29 @@ namespace osu.Game
AddFont(Resources, @"Fonts/osuFont");
AddFont(Resources, @"Fonts/Torus-Regular");
AddFont(Resources, @"Fonts/Torus-Light");
AddFont(Resources, @"Fonts/Torus-SemiBold");
AddFont(Resources, @"Fonts/Torus-Bold");
AddFont(Resources, @"Fonts/Torus/Torus-Regular");
AddFont(Resources, @"Fonts/Torus/Torus-Light");
AddFont(Resources, @"Fonts/Torus/Torus-SemiBold");
AddFont(Resources, @"Fonts/Torus/Torus-Bold");
AddFont(Resources, @"Fonts/Noto-Basic");
AddFont(Resources, @"Fonts/Noto-Hangul");
AddFont(Resources, @"Fonts/Noto-CJK-Basic");
AddFont(Resources, @"Fonts/Noto-CJK-Compatibility");
AddFont(Resources, @"Fonts/Noto-Thai");
AddFont(Resources, @"Fonts/Inter/Inter-Regular");
AddFont(Resources, @"Fonts/Inter/Inter-RegularItalic");
AddFont(Resources, @"Fonts/Inter/Inter-Light");
AddFont(Resources, @"Fonts/Inter/Inter-LightItalic");
AddFont(Resources, @"Fonts/Inter/Inter-SemiBold");
AddFont(Resources, @"Fonts/Inter/Inter-SemiBoldItalic");
AddFont(Resources, @"Fonts/Inter/Inter-Bold");
AddFont(Resources, @"Fonts/Inter/Inter-BoldItalic");
AddFont(Resources, @"Fonts/Venera-Light");
AddFont(Resources, @"Fonts/Venera-Bold");
AddFont(Resources, @"Fonts/Venera-Black");
AddFont(Resources, @"Fonts/Noto/Noto-Basic");
AddFont(Resources, @"Fonts/Noto/Noto-Hangul");
AddFont(Resources, @"Fonts/Noto/Noto-CJK-Basic");
AddFont(Resources, @"Fonts/Noto/Noto-CJK-Compatibility");
AddFont(Resources, @"Fonts/Noto/Noto-Thai");
AddFont(Resources, @"Fonts/Venera/Venera-Light");
AddFont(Resources, @"Fonts/Venera/Venera-Bold");
AddFont(Resources, @"Fonts/Venera/Venera-Black");
Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY;
@ -275,7 +289,8 @@ namespace osu.Game
dependencies.Cache(scorePerformanceManager);
AddInternal(scorePerformanceManager);
dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore));
migrateDataToRealm();
dependencies.Cache(settingsStore = new SettingsStore(contextFactory));
dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(settingsStore));
@ -323,7 +338,12 @@ namespace osu.Game
base.Content.Add(CreateScalingContainer().WithChildren(mainContent));
KeyBindingStore = new RealmKeyBindingStore(realmFactory);
KeyBindingStore.Register(globalBindings);
foreach (var r in RulesetStore.AvailableRulesets)
KeyBindingStore.Register(r);
dependencies.Cache(globalBindings);
PreviewTrackManager previewTrackManager;
@ -378,8 +398,11 @@ namespace osu.Game
public void Migrate(string path)
{
contextFactory.FlushConnections();
(Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
using (realmFactory.BlockAllOperations())
{
contextFactory.FlushConnections();
(Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
}
}
protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager();
@ -390,6 +413,34 @@ namespace osu.Game
protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage);
private void migrateDataToRealm()
{
using (var db = contextFactory.GetForWrite())
using (var usage = realmFactory.GetForWrite())
{
var existingBindings = db.Context.DatabasedKeyBinding;
// only migrate data if the realm database is empty.
if (!usage.Realm.All<RealmKeyBinding>().Any())
{
foreach (var dkb in existingBindings)
{
usage.Realm.Add(new RealmKeyBinding
{
KeyCombinationString = dkb.KeyCombination.ToString(),
ActionInt = (int)dkb.Action,
RulesetID = dkb.RulesetID,
Variant = dkb.Variant
});
}
}
db.Context.RemoveRange(existingBindings);
usage.Commit();
}
}
private void onRulesetChanged(ValueChangedEvent<RulesetInfo> r)
{
var dict = new Dictionary<ModType, IReadOnlyList<Mod>>();

View File

@ -1,9 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Extensions;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Scoring;
@ -33,38 +33,7 @@ namespace osu.Game.Overlays.BeatmapListing
{
}
protected override LocalisableString LabelFor(ScoreRank value)
{
switch (value)
{
case ScoreRank.XH:
return BeatmapsStrings.RankXH;
case ScoreRank.X:
return BeatmapsStrings.RankX;
case ScoreRank.SH:
return BeatmapsStrings.RankSH;
case ScoreRank.S:
return BeatmapsStrings.RankS;
case ScoreRank.A:
return BeatmapsStrings.RankA;
case ScoreRank.B:
return BeatmapsStrings.RankB;
case ScoreRank.C:
return BeatmapsStrings.RankC;
case ScoreRank.D:
return BeatmapsStrings.RankD;
default:
throw new ArgumentException("Unsupported value.", nameof(value));
}
}
protected override LocalisableString LabelFor(ScoreRank value) => value.GetLocalisableDescription();
}
}
}

View File

@ -67,7 +67,7 @@ namespace osu.Game.Overlays.BeatmapListing
/// <summary>
/// Returns the label text to be used for the supplied <paramref name="value"/>.
/// </summary>
protected virtual LocalisableString LabelFor(T value) => (value as Enum)?.GetDescription() ?? value.ToString();
protected virtual LocalisableString LabelFor(T value) => (value as Enum)?.GetLocalisableDescription() ?? value.ToString();
private void updateState()
{

View File

@ -1,10 +1,14 @@
// 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.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.BeatmapListing
{
[LocalisableEnum(typeof(SearchCategoryEnumLocalisationMapper))]
public enum SearchCategory
{
Any,
@ -23,4 +27,43 @@ namespace osu.Game.Overlays.BeatmapListing
[Description("My Maps")]
Mine,
}
public class SearchCategoryEnumLocalisationMapper : EnumLocalisationMapper<SearchCategory>
{
public override LocalisableString Map(SearchCategory value)
{
switch (value)
{
case SearchCategory.Any:
return BeatmapsStrings.StatusAny;
case SearchCategory.Leaderboard:
return BeatmapsStrings.StatusLeaderboard;
case SearchCategory.Ranked:
return BeatmapsStrings.StatusRanked;
case SearchCategory.Qualified:
return BeatmapsStrings.StatusQualified;
case SearchCategory.Loved:
return BeatmapsStrings.StatusLoved;
case SearchCategory.Favourites:
return BeatmapsStrings.StatusFavourites;
case SearchCategory.Pending:
return BeatmapsStrings.StatusPending;
case SearchCategory.Graveyard:
return BeatmapsStrings.StatusGraveyard;
case SearchCategory.Mine:
return BeatmapsStrings.StatusMine;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -1,11 +1,34 @@
// 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.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.BeatmapListing
{
[LocalisableEnum(typeof(SearchExplicitEnumLocalisationMapper))]
public enum SearchExplicit
{
Hide,
Show
}
public class SearchExplicitEnumLocalisationMapper : EnumLocalisationMapper<SearchExplicit>
{
public override LocalisableString Map(SearchExplicit value)
{
switch (value)
{
case SearchExplicit.Hide:
return BeatmapsStrings.NsfwExclude;
case SearchExplicit.Show:
return BeatmapsStrings.NsfwInclude;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -1,10 +1,14 @@
// 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.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.BeatmapListing
{
[LocalisableEnum(typeof(SearchExtraEnumLocalisationMapper))]
public enum SearchExtra
{
[Description("Has Video")]
@ -13,4 +17,22 @@ namespace osu.Game.Overlays.BeatmapListing
[Description("Has Storyboard")]
Storyboard
}
public class SearchExtraEnumLocalisationMapper : EnumLocalisationMapper<SearchExtra>
{
public override LocalisableString Map(SearchExtra value)
{
switch (value)
{
case SearchExtra.Video:
return BeatmapsStrings.ExtraVideo;
case SearchExtra.Storyboard:
return BeatmapsStrings.ExtraStoryboard;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -1,10 +1,14 @@
// 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.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.BeatmapListing
{
[LocalisableEnum(typeof(SearchGeneralEnumLocalisationMapper))]
public enum SearchGeneral
{
[Description("Recommended difficulty")]
@ -16,4 +20,25 @@ namespace osu.Game.Overlays.BeatmapListing
[Description("Subscribed mappers")]
Follows
}
public class SearchGeneralEnumLocalisationMapper : EnumLocalisationMapper<SearchGeneral>
{
public override LocalisableString Map(SearchGeneral value)
{
switch (value)
{
case SearchGeneral.Recommended:
return BeatmapsStrings.GeneralRecommended;
case SearchGeneral.Converts:
return BeatmapsStrings.GeneralConverts;
case SearchGeneral.Follows:
return BeatmapsStrings.GeneralFollows;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -1,10 +1,14 @@
// 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.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.BeatmapListing
{
[LocalisableEnum(typeof(SearchGenreEnumLocalisationMapper))]
public enum SearchGenre
{
Any = 0,
@ -26,4 +30,58 @@ namespace osu.Game.Overlays.BeatmapListing
Folk = 13,
Jazz = 14
}
public class SearchGenreEnumLocalisationMapper : EnumLocalisationMapper<SearchGenre>
{
public override LocalisableString Map(SearchGenre value)
{
switch (value)
{
case SearchGenre.Any:
return BeatmapsStrings.GenreAny;
case SearchGenre.Unspecified:
return BeatmapsStrings.GenreUnspecified;
case SearchGenre.VideoGame:
return BeatmapsStrings.GenreVideoGame;
case SearchGenre.Anime:
return BeatmapsStrings.GenreAnime;
case SearchGenre.Rock:
return BeatmapsStrings.GenreRock;
case SearchGenre.Pop:
return BeatmapsStrings.GenrePop;
case SearchGenre.Other:
return BeatmapsStrings.GenreOther;
case SearchGenre.Novelty:
return BeatmapsStrings.GenreNovelty;
case SearchGenre.HipHop:
return BeatmapsStrings.GenreHipHop;
case SearchGenre.Electronic:
return BeatmapsStrings.GenreElectronic;
case SearchGenre.Metal:
return BeatmapsStrings.GenreMetal;
case SearchGenre.Classical:
return BeatmapsStrings.GenreClassical;
case SearchGenre.Folk:
return BeatmapsStrings.GenreFolk;
case SearchGenre.Jazz:
return BeatmapsStrings.GenreJazz;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -1,10 +1,14 @@
// 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.Localisation;
using osu.Framework.Utils;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.BeatmapListing
{
[LocalisableEnum(typeof(SearchLanguageEnumLocalisationMapper))]
[HasOrderedElements]
public enum SearchLanguage
{
@ -53,4 +57,61 @@ namespace osu.Game.Overlays.BeatmapListing
[Order(13)]
Other
}
public class SearchLanguageEnumLocalisationMapper : EnumLocalisationMapper<SearchLanguage>
{
public override LocalisableString Map(SearchLanguage value)
{
switch (value)
{
case SearchLanguage.Any:
return BeatmapsStrings.LanguageAny;
case SearchLanguage.Unspecified:
return BeatmapsStrings.LanguageUnspecified;
case SearchLanguage.English:
return BeatmapsStrings.LanguageEnglish;
case SearchLanguage.Japanese:
return BeatmapsStrings.LanguageJapanese;
case SearchLanguage.Chinese:
return BeatmapsStrings.LanguageChinese;
case SearchLanguage.Instrumental:
return BeatmapsStrings.LanguageInstrumental;
case SearchLanguage.Korean:
return BeatmapsStrings.LanguageKorean;
case SearchLanguage.French:
return BeatmapsStrings.LanguageFrench;
case SearchLanguage.German:
return BeatmapsStrings.LanguageGerman;
case SearchLanguage.Swedish:
return BeatmapsStrings.LanguageSwedish;
case SearchLanguage.Spanish:
return BeatmapsStrings.LanguageSpanish;
case SearchLanguage.Italian:
return BeatmapsStrings.LanguageItalian;
case SearchLanguage.Russian:
return BeatmapsStrings.LanguageRussian;
case SearchLanguage.Polish:
return BeatmapsStrings.LanguagePolish;
case SearchLanguage.Other:
return BeatmapsStrings.LanguageOther;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -1,12 +1,38 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.BeatmapListing
{
[LocalisableEnum(typeof(SearchPlayedEnumLocalisationMapper))]
public enum SearchPlayed
{
Any,
Played,
Unplayed
}
public class SearchPlayedEnumLocalisationMapper : EnumLocalisationMapper<SearchPlayed>
{
public override LocalisableString Map(SearchPlayed value)
{
switch (value)
{
case SearchPlayed.Any:
return BeatmapsStrings.PlayedAny;
case SearchPlayed.Played:
return BeatmapsStrings.PlayedPlayed;
case SearchPlayed.Unplayed:
return BeatmapsStrings.PlayedUnplayed;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -1,8 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.BeatmapListing
{
[LocalisableEnum(typeof(SortCriteriaLocalisationMapper))]
public enum SortCriteria
{
Title,
@ -14,4 +19,40 @@ namespace osu.Game.Overlays.BeatmapListing
Favourites,
Relevance
}
public class SortCriteriaLocalisationMapper : EnumLocalisationMapper<SortCriteria>
{
public override LocalisableString Map(SortCriteria value)
{
switch (value)
{
case SortCriteria.Title:
return BeatmapsStrings.ListingSearchSortingTitle;
case SortCriteria.Artist:
return BeatmapsStrings.ListingSearchSortingArtist;
case SortCriteria.Difficulty:
return BeatmapsStrings.ListingSearchSortingDifficulty;
case SortCriteria.Ranked:
return BeatmapsStrings.ListingSearchSortingRanked;
case SortCriteria.Rating:
return BeatmapsStrings.ListingSearchSortingRating;
case SortCriteria.Plays:
return BeatmapsStrings.ListingSearchSortingPlays;
case SortCriteria.Favourites:
return BeatmapsStrings.ListingSearchSortingFavourites;
case SortCriteria.Relevance:
return BeatmapsStrings.ListingSearchSortingRelevance;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -22,8 +23,8 @@ namespace osu.Game.Overlays.BeatmapSet
{
private const float height = 50;
private readonly UpdateableAvatar avatar;
private readonly FillFlowContainer fields;
private UpdateableAvatar avatar;
private FillFlowContainer fields;
private BeatmapSetInfo beatmapSet;
@ -35,11 +36,46 @@ namespace osu.Game.Overlays.BeatmapSet
if (value == beatmapSet) return;
beatmapSet = value;
updateDisplay();
Scheduler.AddOnce(updateDisplay);
}
}
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.X;
Height = height;
Children = new Drawable[]
{
new Container
{
AutoSizeAxes = Axes.Both,
CornerRadius = 4,
Masking = true,
Child = avatar = new UpdateableAvatar(showGuestOnNull: false)
{
Size = new Vector2(height),
},
EdgeEffect = new EdgeEffectParameters
{
Colour = Color4.Black.Opacity(0.25f),
Type = EdgeEffectType.Shadow,
Radius = 4,
Offset = new Vector2(0f, 1f),
},
},
fields = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Padding = new MarginPadding { Left = height + 5 },
},
};
Scheduler.AddOnce(updateDisplay);
}
private void updateDisplay()
{
avatar.User = BeatmapSet?.Metadata.Author;
@ -69,45 +105,6 @@ namespace osu.Game.Overlays.BeatmapSet
}
}
public AuthorInfo()
{
RelativeSizeAxes = Axes.X;
Height = height;
Children = new Drawable[]
{
new Container
{
AutoSizeAxes = Axes.Both,
CornerRadius = 4,
Masking = true,
Child = avatar = new UpdateableAvatar
{
ShowGuestOnNull = false,
Size = new Vector2(height),
},
EdgeEffect = new EdgeEffectParameters
{
Colour = Color4.Black.Opacity(0.25f),
Type = EdgeEffectType.Shadow,
Radius = 4,
Offset = new Vector2(0f, 1f),
},
},
fields = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Padding = new MarginPadding { Left = height + 5 },
},
};
}
private void load()
{
updateDisplay();
}
private class Field : FillFlowContainer
{
public Field(string first, string second, FontUsage secondFont)

View File

@ -61,7 +61,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
},
}
},
avatar = new UpdateableAvatar
avatar = new UpdateableAvatar(showGuestOnNull: false)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -75,7 +75,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
Offset = new Vector2(0, 2),
Radius = 1,
},
ShowGuestOnNull = false,
},
new FillFlowContainer
{

View File

@ -51,7 +51,7 @@ namespace osu.Game.Overlays.Chat.Tabs
Child = new DelayedLoadWrapper(avatar = new ClickableAvatar(value.Users.First())
{
RelativeSizeAxes = Axes.Both,
OpenOnClick = { Value = false },
OpenOnClick = false,
})
{
RelativeSizeAxes = Axes.Both,

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Framework.Bindables;
@ -66,6 +68,8 @@ namespace osu.Game.Overlays.Comments
public readonly BindableBool Checked = new BindableBool();
private readonly SpriteIcon checkboxIcon;
private Sample sampleChecked;
private Sample sampleUnchecked;
public ShowDeletedButton()
{
@ -93,6 +97,13 @@ namespace osu.Game.Overlays.Comments
});
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sampleChecked = audio.Samples.Get(@"UI/check-on");
sampleUnchecked = audio.Samples.Get(@"UI/check-off");
}
protected override void LoadComplete()
{
Checked.BindValueChanged(isChecked => checkboxIcon.Icon = isChecked.NewValue ? FontAwesome.Solid.CheckSquare : FontAwesome.Regular.Square, true);
@ -102,6 +113,12 @@ namespace osu.Game.Overlays.Comments
protected override bool OnClick(ClickEvent e)
{
Checked.Value = !Checked.Value;
if (Checked.Value)
sampleChecked?.Play();
else
sampleUnchecked?.Play();
return true;
}
}

View File

@ -6,7 +6,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Comments
{
@ -39,7 +38,6 @@ namespace osu.Game.Overlays.Comments
Origin = Anchor.Centre,
Margin = new MarginPadding { Horizontal = 10 }
},
new HoverClickSounds(),
});
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
@ -13,6 +14,7 @@ using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@ -27,7 +29,7 @@ namespace osu.Game.Overlays.KeyBinding
public class KeyBindingRow : Container, IFilterable
{
private readonly object action;
private readonly IEnumerable<Framework.Input.Bindings.KeyBinding> bindings;
private readonly IEnumerable<RealmKeyBinding> bindings;
private const float transition_time = 150;
@ -62,7 +64,7 @@ namespace osu.Game.Overlays.KeyBinding
public IEnumerable<string> FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend(text.Text.ToString());
public KeyBindingRow(object action, IEnumerable<Framework.Input.Bindings.KeyBinding> bindings)
public KeyBindingRow(object action, List<RealmKeyBinding> bindings)
{
this.action = action;
this.bindings = bindings;
@ -72,7 +74,7 @@ namespace osu.Game.Overlays.KeyBinding
}
[Resolved]
private KeyBindingStore store { get; set; }
private RealmContextFactory realmFactory { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
@ -153,7 +155,8 @@ namespace osu.Game.Overlays.KeyBinding
{
var button = buttons[i++];
button.UpdateKeyCombination(d);
store.Update(button.KeyBinding);
updateStoreFromButton(button);
}
isDefault.Value = true;
@ -314,7 +317,7 @@ namespace osu.Game.Overlays.KeyBinding
{
if (bindTarget != null)
{
store.Update(bindTarget.KeyBinding);
updateStoreFromButton(bindTarget);
updateIsDefaultValue();
@ -361,6 +364,17 @@ namespace osu.Game.Overlays.KeyBinding
if (bindTarget != null) bindTarget.IsBinding = true;
}
private void updateStoreFromButton(KeyButton button)
{
using (var usage = realmFactory.GetForWrite())
{
var binding = usage.Realm.Find<RealmKeyBinding>(((IHasGuidPrimaryKey)button.KeyBinding).ID);
binding.KeyCombinationString = button.KeyBinding.KeyCombinationString;
usage.Commit();
}
}
private void updateIsDefaultValue()
{
isDefault.Value = bindings.Select(b => b.KeyCombination).SequenceEqual(Defaults);
@ -386,7 +400,7 @@ namespace osu.Game.Overlays.KeyBinding
public class KeyButton : Container
{
public readonly Framework.Input.Bindings.KeyBinding KeyBinding;
public readonly RealmKeyBinding KeyBinding;
private readonly Box box;
public readonly OsuSpriteText Text;
@ -408,8 +422,11 @@ namespace osu.Game.Overlays.KeyBinding
}
}
public KeyButton(Framework.Input.Bindings.KeyBinding keyBinding)
public KeyButton(RealmKeyBinding keyBinding)
{
if (keyBinding.IsManaged)
throw new ArgumentException("Key binding should not be attached as we make temporary changes", nameof(keyBinding));
KeyBinding = keyBinding;
Margin = new MarginPadding(padding);
@ -478,7 +495,7 @@ namespace osu.Game.Overlays.KeyBinding
public void UpdateKeyCombination(KeyCombination newCombination)
{
if ((KeyBinding as DatabasedKeyBinding)?.RulesetID != null && !KeyBindingStore.CheckValidForGameplay(newCombination))
if (KeyBinding.RulesetID != null && !RealmKeyBindingStore.CheckValidForGameplay(newCombination))
return;
KeyBinding.KeyCombination = newCombination;

View File

@ -6,8 +6,9 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets;
using osuTK;
@ -31,16 +32,21 @@ namespace osu.Game.Overlays.KeyBinding
}
[BackgroundDependencyLoader]
private void load(KeyBindingStore store)
private void load(RealmContextFactory realmFactory)
{
var bindings = store.Query(Ruleset?.ID, variant);
var rulesetId = Ruleset?.ID;
List<RealmKeyBinding> bindings;
using (var usage = realmFactory.GetForRead())
bindings = usage.Realm.All<RealmKeyBinding>().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach();
foreach (var defaultGroup in Defaults.GroupBy(d => d.Action))
{
int intKey = (int)defaultGroup.Key;
// one row per valid action.
Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => ((int)b.Action).Equals(intKey)))
Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => b.ActionInt.Equals(intKey)).ToList())
{
AllowMainMouseButtons = Ruleset != null,
Defaults = defaultGroup.Select(d => d.KeyCombination)

View File

@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Mods
base.OnModSelected(mod);
foreach (var section in ModSectionsContainer.Children)
section.DeselectTypes(mod.IncompatibleMods, true);
section.DeselectTypes(mod.IncompatibleMods, true, mod);
}
}
}

View File

@ -302,7 +302,7 @@ namespace osu.Game.Overlays.Mods
Anchor = Anchor.TopCentre,
Font = OsuFont.GetFont(size: 18)
},
new HoverClickSounds(buttons: new[] { MouseButton.Left, MouseButton.Right })
new HoverSounds()
};
Mod = mod;

View File

@ -159,12 +159,16 @@ namespace osu.Game.Overlays.Mods
/// </summary>
/// <param name="modTypes">The types of <see cref="Mod"/>s which should be deselected.</param>
/// <param name="immediate">Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow.</param>
public void DeselectTypes(IEnumerable<Type> modTypes, bool immediate = false)
/// <param name="newSelection">If this deselection is triggered by a user selection, this should contain the newly selected type. This type will never be deselected, even if it matches one provided in <paramref name="modTypes"/>.</param>
public void DeselectTypes(IEnumerable<Type> modTypes, bool immediate = false, Mod newSelection = null)
{
foreach (var button in Buttons)
{
if (button.SelectedMod == null) continue;
if (button.SelectedMod == newSelection)
continue;
foreach (var type in modTypes)
{
if (type.IsInstanceOfType(button.SelectedMod))

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osuTK;
using osuTK.Graphics;
@ -84,6 +85,7 @@ namespace osu.Game.Overlays
private readonly Box background;
public ScrollToTopButton()
: base(HoverSampleSet.ScrollToTop)
{
Size = new Vector2(50);
Alpha = 0;

View File

@ -18,6 +18,7 @@ using JetBrains.Annotations;
using System;
using osu.Framework.Extensions;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays
{
@ -54,7 +55,7 @@ namespace osu.Game.Overlays
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold),
Text = @"Sort by"
Text = SortStrings.Default
},
CreateControl().With(c =>
{
@ -143,10 +144,12 @@ namespace osu.Game.Overlays
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold),
Text = (value as Enum)?.GetDescription() ?? value.ToString()
Text = (value as Enum)?.GetLocalisableDescription() ?? value.ToString()
}
}
});
AddInternal(new HoverClickSounds());
}
protected override void LoadComplete()

View File

@ -99,7 +99,7 @@ namespace osu.Game.Overlays
ExpandedSize = 5f,
CollapsedSize = 0
},
new HoverClickSounds()
new HoverClickSounds(HoverSampleSet.TabSelect)
};
}

View File

@ -58,13 +58,11 @@ namespace osu.Game.Overlays.Profile.Header
Origin = Anchor.CentreLeft,
Children = new Drawable[]
{
avatar = new UpdateableAvatar
avatar = new UpdateableAvatar(openOnClick: false, showGuestOnNull: false)
{
Size = new Vector2(avatar_size),
Masking = true,
CornerRadius = avatar_size * 0.25f,
OpenOnClick = { Value = false },
ShowGuestOnNull = false,
},
new Container
{

View File

@ -1,11 +1,11 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Graphics;
using osu.Game.Extensions;
using osu.Game.Localisation;
namespace osu.Game.Overlays.Settings.Sections.General
@ -35,11 +35,11 @@ namespace osu.Game.Overlays.Settings.Sections.General
},
};
if (!Enum.TryParse<Language>(frameworkLocale.Value, out var locale))
if (!LanguageExtensions.TryParseCultureCode(frameworkLocale.Value, out var locale))
locale = Language.en;
languageSelection.Current.Value = locale;
languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToString());
languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToCultureCode());
}
}
}

View File

@ -19,6 +19,8 @@ namespace osu.Game.Overlays.Settings
Margin = new MarginPadding { Top = 5 };
RelativeSizeAxes = Axes.X;
}
protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 200);
}
}
}

View File

@ -106,7 +106,19 @@ namespace osu.Game.Overlays
public OverlayHeaderTabItem(T value)
: base(value)
{
Text.Text = ((Value as Enum)?.GetDescription() ?? Value.ToString()).ToLower();
if (!(Value is Enum enumValue))
Text.Text = Value.ToString().ToLower();
else
{
var localisableDescription = enumValue.GetLocalisableDescription();
var nonLocalisableDescription = enumValue.GetDescription();
// If localisable == non-localisable, then we must have a basic string, so .ToLower() is used.
Text.Text = localisableDescription.Equals(nonLocalisableDescription)
? nonLocalisableDescription.ToLower()
: localisableDescription;
}
Text.Font = OsuFont.GetFont(size: 14);
Text.Margin = new MarginPadding { Vertical = 16.5f }; // 15px padding + 1.5px line-height difference compensation
Bar.Margin = new MarginPadding { Bottom = bar_height };

View File

@ -1,8 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
@ -13,13 +13,13 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osuTK;
using osuTK.Graphics;
@ -76,7 +76,7 @@ namespace osu.Game.Overlays.Toolbar
protected FillFlowContainer Flow;
[Resolved]
private KeyBindingStore keyBindings { get; set; }
private RealmContextFactory realmFactory { get; set; }
protected ToolbarButton()
: base(HoverSampleSet.Toolbar)
@ -159,27 +159,28 @@ namespace osu.Game.Overlays.Toolbar
};
}
private readonly Cached tooltipKeyBinding = new Cached();
private RealmKeyBinding realmKeyBinding;
[BackgroundDependencyLoader]
private void load()
protected override void LoadComplete()
{
keyBindings.KeyBindingChanged += () => tooltipKeyBinding.Invalidate();
base.LoadComplete();
if (Hotkey == null) return;
realmKeyBinding = realmFactory.Context.All<RealmKeyBinding>().FirstOrDefault(rkb => rkb.RulesetID == null && rkb.ActionInt == (int)Hotkey.Value);
if (realmKeyBinding != null)
{
realmKeyBinding.PropertyChanged += (sender, args) =>
{
if (args.PropertyName == nameof(realmKeyBinding.KeyCombinationString))
updateKeyBindingTooltip();
};
}
updateKeyBindingTooltip();
}
private void updateKeyBindingTooltip()
{
if (tooltipKeyBinding.IsValid)
return;
var binding = keyBindings.Query().Find(b => (GlobalAction)b.Action == Hotkey);
var keyBindingString = binding?.KeyCombination.ReadableString();
keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) ? $" ({keyBindingString})" : string.Empty;
tooltipKeyBinding.Validate();
}
protected override bool OnMouseDown(MouseDownEvent e) => true;
protected override bool OnClick(ClickEvent e)
@ -218,6 +219,17 @@ namespace osu.Game.Overlays.Toolbar
public void OnReleased(GlobalAction action)
{
}
private void updateKeyBindingTooltip()
{
if (realmKeyBinding != null)
{
var keyBindingString = realmKeyBinding.KeyCombination.ReadableString();
if (!string.IsNullOrEmpty(keyBindingString))
keyBindingTooltip.Text = $" ({keyBindingString})";
}
}
}
public class OpaqueBackground : Container

View File

@ -32,14 +32,13 @@ namespace osu.Game.Overlays.Toolbar
Add(new OpaqueBackground { Depth = 1 });
Flow.Add(avatar = new UpdateableAvatar
Flow.Add(avatar = new UpdateableAvatar(openOnClick: false)
{
Masking = true,
Size = new Vector2(32),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
CornerRadius = 4,
OpenOnClick = { Value = false },
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,

View File

@ -0,0 +1,41 @@
// 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.Bindables;
using osu.Framework.Graphics;
namespace osu.Game.Performance
{
public class HighPerformanceSession : Component
{
private readonly IBindable<bool> localUserPlaying = new Bindable<bool>();
[BackgroundDependencyLoader]
private void load(OsuGame game)
{
localUserPlaying.BindTo(game.LocalUserPlaying);
}
protected override void LoadComplete()
{
base.LoadComplete();
localUserPlaying.BindValueChanged(playing =>
{
if (playing.NewValue)
EnableHighPerformanceSession();
else
DisableHighPerformanceSession();
}, true);
}
protected virtual void EnableHighPerformanceSession()
{
}
protected virtual void DisableHighPerformanceSession()
{
}
}
}

View File

@ -43,6 +43,9 @@ namespace osu.Game.Rulesets.Edit
protected readonly Ruleset Ruleset;
// Provides `Playfield`
private DependencyContainer dependencies;
[Resolved]
protected EditorClock EditorClock { get; private set; }
@ -69,6 +72,9 @@ namespace osu.Game.Rulesets.Edit
Ruleset = ruleset;
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
[BackgroundDependencyLoader]
private void load()
{
@ -88,6 +94,8 @@ namespace osu.Game.Rulesets.Edit
return;
}
dependencies.CacheAs(Playfield);
const float toolbar_width = 200;
InternalChildren = new Drawable[]

View File

@ -1,7 +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.
using System.Collections.Generic;
using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.Mods
@ -9,13 +8,12 @@ namespace osu.Game.Rulesets.Mods
/// <summary>
/// An interface for <see cref="Mod"/>s that can be applied to <see cref="DrawableHitObject"/>s.
/// </summary>
public interface IApplicableToDrawableHitObjects : IApplicableMod
public interface IApplicableToDrawableHitObject : IApplicableMod
{
/// <summary>
/// Applies this <see cref="IApplicableToDrawableHitObjects"/> to a list of <see cref="DrawableHitObject"/>s.
/// Applies this <see cref="IApplicableToDrawableHitObject"/> to a <see cref="DrawableHitObject"/>.
/// This will only be invoked with top-level <see cref="DrawableHitObject"/>s. Access <see cref="DrawableHitObject.NestedHitObjects"/> if adjusting nested objects is necessary.
/// </summary>
/// <param name="drawables">The list of <see cref="DrawableHitObject"/>s to apply to.</param>
void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables);
void ApplyToDrawableHitObject(DrawableHitObject drawable);
}
}

View File

@ -0,0 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.Mods
{
[Obsolete(@"Use the singular version IApplicableToDrawableHitObject instead.")] // Can be removed 20211216
public interface IApplicableToDrawableHitObjects : IApplicableToDrawableHitObject
{
void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables);
void IApplicableToDrawableHitObject.ApplyToDrawableHitObject(DrawableHitObject drawable) => ApplyToDrawableHitObjects(drawable.Yield());
}
}

View File

@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods
/// A <see cref="Mod"/> which applies visibility adjustments to <see cref="DrawableHitObject"/>s
/// with an optional increased visibility adjustment depending on the user's "increase first object visibility" setting.
/// </summary>
public abstract class ModWithVisibilityAdjustment : Mod, IReadFromConfig, IApplicableToBeatmap, IApplicableToDrawableHitObjects
public abstract class ModWithVisibilityAdjustment : Mod, IReadFromConfig, IApplicableToBeatmap, IApplicableToDrawableHitObject
{
/// <summary>
/// The first adjustable object.
@ -73,19 +73,16 @@ namespace osu.Game.Rulesets.Mods
}
}
public virtual void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables)
public virtual void ApplyToDrawableHitObject(DrawableHitObject dho)
{
foreach (var dho in drawables)
dho.ApplyCustomUpdateState += (o, state) =>
{
dho.ApplyCustomUpdateState += (o, state) =>
{
// Increased visibility is applied to the entire first object, including all of its nested hitobjects.
if (IncreaseFirstObjectVisibility.Value && isObjectEqualToOrNestedIn(o.HitObject, FirstObject))
ApplyIncreasedVisibilityState(o, state);
else
ApplyNormalVisibilityState(o, state);
};
}
// Increased visibility is applied to the entire first object, including all of its nested hitobjects.
if (IncreaseFirstObjectVisibility.Value && isObjectEqualToOrNestedIn(o.HitObject, FirstObject))
ApplyIncreasedVisibilityState(o, state);
else
ApplyNormalVisibilityState(o, state);
};
}
/// <summary>

View File

@ -156,10 +156,11 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// If <c>null</c>, a hitobject is expected to be later applied via <see cref="PoolableDrawableWithLifetime{TEntry}.Apply"/> (or automatically via pooling).
/// </param>
protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null)
: base(initialHitObject != null ? new SyntheticHitObjectEntry(initialHitObject) : null)
{
if (Entry != null)
ensureEntryHasResult();
if (initialHitObject == null) return;
Entry = new SyntheticHitObjectEntry(initialHitObject);
ensureEntryHasResult();
}
[BackgroundDependencyLoader]

View File

@ -5,6 +5,7 @@
using System;
using System.Diagnostics;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Pooling;
@ -16,14 +17,32 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// <typeparam name="TEntry">The <see cref="LifetimeEntry"/> type storing state and controlling this drawable.</typeparam>
public abstract class PoolableDrawableWithLifetime<TEntry> : PoolableDrawable where TEntry : LifetimeEntry
{
private TEntry? entry;
/// <summary>
/// The entry holding essential state of this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
/// </summary>
public TEntry? Entry { get; private set; }
/// <remarks>
/// If a non-null value is set before loading is started, the entry is applied when the loading is completed.
/// It is not valid to set an entry while this <see cref="PoolableDrawableWithLifetime{TEntry}"/> is loading.
/// </remarks>
public TEntry? Entry
{
get => entry;
set
{
if (LoadState == LoadState.NotLoaded)
entry = value;
else if (value != null)
Apply(value);
else if (HasEntryApplied)
free();
}
}
/// <summary>
/// Whether <see cref="Entry"/> is applied to this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
/// When an initial entry is specified in the constructor, <see cref="Entry"/> is set but not applied until loading is completed.
/// When an <see cref="Entry"/> is set during initialization, it is not applied until loading is completed.
/// </summary>
protected bool HasEntryApplied { get; private set; }
@ -65,9 +84,9 @@ namespace osu.Game.Rulesets.Objects.Pooling
{
base.LoadAsyncComplete();
// Apply the initial entry given in the constructor.
// Apply the initial entry.
if (Entry != null && !HasEntryApplied)
Apply(Entry);
apply(Entry);
}
/// <summary>
@ -76,16 +95,10 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// </summary>
public void Apply(TEntry entry)
{
if (HasEntryApplied)
free();
if (LoadState == LoadState.Loading)
throw new InvalidOperationException($"Cannot apply a new {nameof(TEntry)} while currently loading.");
Entry = entry;
entry.LifetimeChanged += setLifetimeFromEntry;
setLifetimeFromEntry(entry);
OnApply(entry);
HasEntryApplied = true;
apply(entry);
}
protected sealed override void FreeAfterUse()
@ -111,6 +124,20 @@ namespace osu.Game.Rulesets.Objects.Pooling
{
}
private void apply(TEntry entry)
{
if (HasEntryApplied)
free();
this.entry = entry;
entry.LifetimeChanged += setLifetimeFromEntry;
setLifetimeFromEntry(entry);
OnApply(entry);
HasEntryApplied = true;
}
private void free()
{
Debug.Assert(Entry != null && HasEntryApplied);
@ -118,7 +145,7 @@ namespace osu.Game.Rulesets.Objects.Pooling
OnFree(Entry);
Entry.LifetimeChanged -= setLifetimeFromEntry;
Entry = null;
entry = null;
base.LifetimeStart = double.MinValue;
base.LifetimeEnd = double.MaxValue;

View File

@ -6,7 +6,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Game.Input.Handlers;
using osu.Game.Replays;
@ -117,7 +116,7 @@ namespace osu.Game.Rulesets.Replays
}
}
protected virtual bool IsImportant([NotNull] TFrame frame) => false;
protected virtual bool IsImportant(TFrame frame) => false;
/// <summary>
/// Update the current frame based on an incoming time value.

View File

@ -96,13 +96,25 @@ namespace osu.Game.Rulesets
context.SaveChanges();
// add any other modes
var existingRulesets = context.RulesetInfo.ToList();
// add any other rulesets which have assemblies present but are not yet in the database.
foreach (var r in instances.Where(r => !(r is ILegacyRuleset)))
{
if (existingRulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null)
context.RulesetInfo.Add(r.RulesetInfo);
{
var existingSameShortName = existingRulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName);
if (existingSameShortName != null)
{
// even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName.
// this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one.
// in such cases, update the instantiation info of the existing entry to point to the new one.
existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo;
}
else
context.RulesetInfo.Add(r.RulesetInfo);
}
}
context.SaveChanges();

View File

@ -199,8 +199,11 @@ namespace osu.Game.Rulesets.UI
Playfield.PostProcess();
foreach (var mod in Mods.OfType<IApplicableToDrawableHitObjects>())
mod.ApplyToDrawableHitObjects(Playfield.AllHitObjects);
foreach (var mod in Mods.OfType<IApplicableToDrawableHitObject>())
{
foreach (var drawableHitObject in Playfield.AllHitObjects)
mod.ApplyToDrawableHitObject(drawableHitObject);
}
}
public override void RequestResume(Action continueResume)

View File

@ -356,8 +356,8 @@ namespace osu.Game.Rulesets.UI
// This is done before Apply() so that the state is updated once when the hitobject is applied.
if (mods != null)
{
foreach (var m in mods.OfType<IApplicableToDrawableHitObjects>())
m.ApplyToDrawableHitObjects(dho.Yield());
foreach (var m in mods.OfType<IApplicableToDrawableHitObject>())
m.ApplyToDrawableHitObject(dho);
}
}

View File

@ -177,7 +177,7 @@ namespace osu.Game.Rulesets.UI
{
base.ReloadMappings();
KeyBindings = KeyBindings.Where(b => KeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
}
}
}

View File

@ -69,7 +69,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
/// </remarks>
public double TimeAtPosition(float localPosition, double currentTime)
{
float scrollPosition = axisInverted ? scrollLength - localPosition : localPosition;
float scrollPosition = axisInverted ? -localPosition : localPosition;
return scrollingInfo.Algorithm.TimeAt(scrollPosition, currentTime, timeRange.Value, scrollLength);
}
@ -81,8 +81,10 @@ namespace osu.Game.Rulesets.UI.Scrolling
/// </remarks>
public double TimeAtScreenSpacePosition(Vector2 screenSpacePosition)
{
Vector2 localPosition = ToLocalSpace(screenSpacePosition);
return TimeAtPosition(scrollingAxis == Direction.Horizontal ? localPosition.X : localPosition.Y, Time.Current);
Vector2 pos = ToLocalSpace(screenSpacePosition);
float localPosition = scrollingAxis == Direction.Horizontal ? pos.X : pos.Y;
localPosition -= axisInverted ? scrollLength : 0;
return TimeAtPosition(localPosition, Time.Current);
}
/// <summary>
@ -91,7 +93,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
public float PositionAtTime(double time, double currentTime)
{
float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength);
return axisInverted ? scrollLength - scrollPosition : scrollPosition;
return axisInverted ? -scrollPosition : scrollPosition;
}
/// <summary>
@ -106,6 +108,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
public Vector2 ScreenSpacePositionAtTime(double time)
{
float localPosition = PositionAtTime(time, Time.Current);
localPosition += axisInverted ? scrollLength : 0;
return scrollingAxis == Direction.Horizontal
? ToScreenSpace(new Vector2(localPosition, DrawHeight / 2))
: ToScreenSpace(new Vector2(DrawWidth / 2, localPosition));
@ -236,14 +239,10 @@ namespace osu.Game.Rulesets.UI.Scrolling
{
float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime);
// The position returned from `PositionAtTime` is assuming the `TopLeft` anchor.
// A correction is needed because the hit objects are using a different anchor for each direction (e.g. `BottomCentre` for `Bottom` direction).
float anchorCorrection = axisInverted ? scrollLength : 0;
if (scrollingAxis == Direction.Horizontal)
hitObject.X = position - anchorCorrection;
hitObject.X = position;
else
hitObject.Y = position - anchorCorrection;
hitObject.Y = position;
}
}
}

View File

@ -1,10 +1,14 @@
// 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.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Scoring
{
[LocalisableEnum(typeof(ScoreRankEnumLocalisationMapper))]
public enum ScoreRank
{
[Description(@"D")]
@ -31,4 +35,40 @@ namespace osu.Game.Scoring
[Description(@"SS+")]
XH,
}
public class ScoreRankEnumLocalisationMapper : EnumLocalisationMapper<ScoreRank>
{
public override LocalisableString Map(ScoreRank value)
{
switch (value)
{
case ScoreRank.XH:
return BeatmapsStrings.RankXH;
case ScoreRank.X:
return BeatmapsStrings.RankX;
case ScoreRank.SH:
return BeatmapsStrings.RankSH;
case ScoreRank.S:
return BeatmapsStrings.RankS;
case ScoreRank.A:
return BeatmapsStrings.RankA;
case ScoreRank.B:
return BeatmapsStrings.RankB;
case ScoreRank.C:
return BeatmapsStrings.RankC;
case ScoreRank.D:
return BeatmapsStrings.RankD;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
}
}

View File

@ -5,7 +5,6 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Threading;
using osu.Game.Users;
@ -91,7 +90,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
});
}
private class UserTile : CompositeDrawable, IHasTooltip
private class UserTile : CompositeDrawable
{
public User User
{
@ -99,8 +98,6 @@ namespace osu.Game.Screens.OnlinePlay.Components
set => avatar.User = value;
}
public string TooltipText => User?.Username ?? string.Empty;
private readonly UpdateableAvatar avatar;
public UserTile()
@ -116,7 +113,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex(@"27252d"),
},
avatar = new UpdateableAvatar { RelativeSizeAxes = Axes.Both },
avatar = new UpdateableAvatar(showUsernameTooltip: true) { RelativeSizeAxes = Axes.Both },
};
}
}

View File

@ -4,6 +4,7 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Screens;
@ -54,9 +55,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
return new PlaylistsResultsScreen(score, RoomId.Value.Value, PlaylistItem, true);
}
protected override void PrepareScoreForResults()
protected override async Task PrepareScoreForResultsAsync(Score score)
{
base.PrepareScoreForResults();
await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false);
Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore());
}

View File

@ -181,12 +181,6 @@ namespace osu.Game.Screens.Play
DrawableRuleset.SetRecordTarget(Score);
}
protected virtual void PrepareScoreForResults()
{
// perform one final population to ensure everything is up-to-date.
ScoreProcessor.PopulateScore(Score.ScoreInfo);
}
[BackgroundDependencyLoader(true)]
private void load(AudioManager audio, OsuConfigManager config, OsuGameBase game)
{
@ -295,12 +289,12 @@ namespace osu.Game.Screens.Play
DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded =>
{
if (storyboardEnded.NewValue && completionProgressDelegate == null)
updateCompletionState();
if (storyboardEnded.NewValue)
progressToResults(true);
};
// Bind the judgement processors to ourselves
ScoreProcessor.HasCompleted.BindValueChanged(_ => updateCompletionState());
ScoreProcessor.HasCompleted.BindValueChanged(scoreCompletionChanged);
HealthProcessor.Failed += onFail;
foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>())
@ -376,7 +370,7 @@ namespace osu.Game.Screens.Play
},
skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0)
{
RequestSkip = () => updateCompletionState(true),
RequestSkip = () => progressToResults(false),
Alpha = 0
},
FailOverlay = new FailOverlay
@ -508,19 +502,25 @@ namespace osu.Game.Screens.Play
}
/// <summary>
/// Exits the <see cref="Player"/>.
/// Attempts to complete a user request to exit gameplay.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>This should only be called in response to a user interaction. Exiting is not guaranteed.</item>
/// <item>This will interrupt any pending progression to the results screen, even if the transition has begun.</item>
/// </list>
/// </remarks>
/// <param name="showDialogFirst">
/// Whether the pause or fail dialog should be shown before performing an exit.
/// If true and a dialog is not yet displayed, the exit will be blocked the the relevant dialog will display instead.
/// If <see langword="true"/> and a dialog is not yet displayed, the exit will be blocked and the relevant dialog will display instead.
/// </param>
protected void PerformExit(bool showDialogFirst)
{
// if a restart has been requested, cancel any pending completion (user has shown intent to restart).
completionProgressDelegate?.Cancel();
// if an exit has been requested, cancel any pending completion (the user has shown intention to exit).
resultsDisplayDelegate?.Cancel();
// there is a chance that the exit was performed after the transition to results has started.
// we want to give the user what they want, so forcefully return to this screen (to proceed with the upwards exit process).
// there is a chance that an exit request occurs after the transition to results has already started.
// even in such a case, the user has shown intent, so forcefully return to this screen (to proceed with the upwards exit process).
if (!this.IsCurrentScreen())
{
ValidForResume = false;
@ -543,7 +543,7 @@ namespace osu.Game.Screens.Play
return;
}
// there's a chance the pausing is not supported in the current state, at which point immediate exit should be preferred.
// even if this call has requested a dialog, there is a chance the current player mode doesn't support pausing.
if (pausingSupportedByCurrentState)
{
// in the case a dialog needs to be shown, attempt to pause and show it.
@ -551,14 +551,12 @@ namespace osu.Game.Screens.Play
Pause();
return;
}
// if the score is ready for display but results screen has not been pushed yet (e.g. storyboard is still playing beyond gameplay), then transition to results screen instead of exiting.
if (prepareScoreForDisplayTask != null && completionProgressDelegate == null)
{
updateCompletionState(true);
}
}
// The actual exit is performed if
// - the pause / fail dialog was not requested
// - the pause / fail dialog was requested but is already displayed (user showing intention to exit).
// - the pause / fail dialog was requested but couldn't be displayed due to the type or state of this Player instance.
this.Exit();
}
@ -622,98 +620,141 @@ namespace osu.Game.Screens.Play
PerformExit(false);
}
private ScheduledDelegate completionProgressDelegate;
/// <summary>
/// This delegate, when set, means the results screen has been queued to appear.
/// The display of the results screen may be delayed by any work being done in <see cref="PrepareScoreForResultsAsync"/>.
/// </summary>
/// <remarks>
/// Once set, this can *only* be cancelled by rewinding, ie. if <see cref="JudgementProcessor.HasCompleted">ScoreProcessor.HasCompleted</see> becomes <see langword="false"/>.
/// Even if the user requests an exit, it will forcefully proceed to the results screen (see special case in <see cref="OnExiting"/>).
/// </remarks>
private ScheduledDelegate resultsDisplayDelegate;
/// <summary>
/// A task which asynchronously prepares a completed score for display at results.
/// This may include performing net requests or importing the score into the database, generally to ensure things are in a sane state for the play session.
/// </summary>
private Task<ScoreInfo> prepareScoreForDisplayTask;
/// <summary>
/// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime.
/// </summary>
/// <param name="skipStoryboardOutro">If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it.</param>
/// <exception cref="InvalidOperationException">Thrown if this method is called more than once without changing state.</exception>
private void updateCompletionState(bool skipStoryboardOutro = false)
private void scoreCompletionChanged(ValueChangedEvent<bool> completed)
{
// screen may be in the exiting transition phase.
// If this player instance is in the middle of an exit, don't attempt any kind of state update.
if (!this.IsCurrentScreen())
return;
if (!ScoreProcessor.HasCompleted.Value)
// Special case to handle rewinding post-completion. This is the only way already queued forward progress can be cancelled.
// TODO: Investigate whether this can be moved to a RewindablePlayer subclass or similar.
// Currently, even if this scenario is hit, prepareScoreForDisplay has already been queued (and potentially run).
// In scenarios where rewinding is possible (replay, spectating) this is a non-issue as no submission/import work is done,
// but it still doesn't feel right that this exists here.
if (!completed.NewValue)
{
completionProgressDelegate?.Cancel();
completionProgressDelegate = null;
resultsDisplayDelegate?.Cancel();
resultsDisplayDelegate = null;
ValidForResume = true;
skipOutroOverlay.Hide();
return;
}
if (completionProgressDelegate != null)
throw new InvalidOperationException($"{nameof(updateCompletionState)} was fired more than once");
// Only show the completion screen if the player hasn't failed
if (HealthProcessor.HasFailed)
return;
// Setting this early in the process means that even if something were to go wrong in the order of events following, there
// is no chance that a user could return to the (already completed) Player instance from a child screen.
ValidForResume = false;
// ensure we are not writing to the replay any more, as we are about to consume and store the score.
// Ensure we are not writing to the replay any more, as we are about to consume and store the score.
DrawableRuleset.SetRecordTarget(null);
if (!Configuration.ShowResults) return;
prepareScoreForDisplayTask ??= Task.Run(async () =>
{
PrepareScoreForResults();
try
{
await PrepareScoreForResultsAsync(Score).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error(ex, "Score preparation failed!");
}
try
{
await ImportScore(Score).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error(ex, "Score import failed!");
}
return Score.ScoreInfo;
});
if (skipStoryboardOutro)
{
scheduleCompletion();
if (!Configuration.ShowResults)
return;
}
prepareScoreForDisplayTask ??= Task.Run(prepareScoreForResults);
bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value;
if (storyboardHasOutro)
{
// if the current beatmap has a storyboard, the progression to results will be handled by the storyboard ending
// or the user pressing the skip outro button.
skipOutroOverlay.Show();
return;
}
using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY))
scheduleCompletion();
progressToResults(true);
}
private void scheduleCompletion() => completionProgressDelegate = Schedule(() =>
/// <summary>
/// Asynchronously run score preparation operations (database import, online submission etc.).
/// </summary>
/// <returns>The final score.</returns>
private async Task<ScoreInfo> prepareScoreForResults()
{
if (!prepareScoreForDisplayTask.IsCompleted)
try
{
scheduleCompletion();
return;
await PrepareScoreForResultsAsync(Score).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error(ex, @"Score preparation failed!");
}
// screen may be in the exiting transition phase.
if (this.IsCurrentScreen())
try
{
await ImportScore(Score).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error(ex, @"Score import failed!");
}
return Score.ScoreInfo;
}
/// <summary>
/// Queue the results screen for display.
/// </summary>
/// <remarks>
/// A final display will only occur once all work is completed in <see cref="PrepareScoreForResultsAsync"/>. This means that even after calling this method, the results screen will never be shown until <see cref="JudgementProcessor.HasCompleted">ScoreProcessor.HasCompleted</see> becomes <see langword="true"/>.
///
/// Calling this method multiple times will have no effect.
/// </remarks>
/// <param name="withDelay">Whether a minimum delay (<see cref="RESULTS_DISPLAY_DELAY"/>) should be added before the screen is displayed.</param>
private void progressToResults(bool withDelay)
{
if (resultsDisplayDelegate != null)
// Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be
// accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued
// may take x00 more milliseconds than expected in the very rare edge case).
//
// If required we can handle this more correctly by rescheduling here.
return;
double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0;
resultsDisplayDelegate = new ScheduledDelegate(() =>
{
if (prepareScoreForDisplayTask?.IsCompleted != true)
// If the asynchronous preparation has not completed, keep repeating this delegate.
return;
resultsDisplayDelegate?.Cancel();
if (!this.IsCurrentScreen())
// This player instance may already be in the process of exiting.
return;
this.Push(CreateResults(prepareScoreForDisplayTask.Result));
});
}, Time.Current + delay, 50);
Scheduler.Add(resultsDisplayDelegate);
}
protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value;
@ -911,13 +952,6 @@ namespace osu.Game.Screens.Play
{
screenSuspension?.Expire();
if (completionProgressDelegate != null && !completionProgressDelegate.Cancelled && !completionProgressDelegate.Completed)
{
// proceed to result screen if beatmap already finished playing
completionProgressDelegate.RunTask();
return true;
}
// EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous.
// To resolve test failures, forcefully end playing synchronously when this screen exits.
// Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method.
@ -980,7 +1014,13 @@ namespace osu.Game.Screens.Play
/// </summary>
/// <param name="score">The <see cref="Scoring.Score"/> to prepare.</param>
/// <returns>A task that prepares the provided score. On completion, the score is assumed to be ready for display.</returns>
protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask;
protected virtual Task PrepareScoreForResultsAsync(Score score)
{
// perform one final population to ensure everything is up-to-date.
ScoreProcessor.PopulateScore(score.ScoreInfo);
return Task.CompletedTask;
}
/// <summary>
/// Creates the <see cref="ResultsScreen"/> for a <see cref="ScoreInfo"/>.

View File

@ -66,7 +66,7 @@ namespace osu.Game.Screens.Select
private readonly Box light;
public FooterButton()
: base(HoverSampleSet.SongSelect)
: base(HoverSampleSet.Button)
{
AutoSizeAxes = Axes.Both;
Shear = SHEAR;

View File

@ -18,6 +18,8 @@ namespace osu.Game.Skinning
private readonly BindableList<ISkinnableDrawable> components = new BindableList<ISkinnableDrawable>();
public bool ComponentsLoaded { get; private set; }
public SkinnableTargetContainer(SkinnableTarget target)
{
Target = target;
@ -30,6 +32,7 @@ namespace osu.Game.Skinning
{
ClearInternal();
components.Clear();
ComponentsLoaded = false;
content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer;
@ -39,8 +42,11 @@ namespace osu.Game.Skinning
{
AddInternal(wrapper);
components.AddRange(wrapper.Children.OfType<ISkinnableDrawable>());
ComponentsLoaded = true;
});
}
else
ComponentsLoaded = true;
}
/// <inheritdoc cref="ISkinnableTarget"/>

View File

@ -25,7 +25,7 @@ namespace osu.Game.Storyboards.Drawables
/// </summary>
public IBindable<bool> HasStoryboardEnded => hasStoryboardEnded;
private readonly BindableBool hasStoryboardEnded = new BindableBool();
private readonly BindableBool hasStoryboardEnded = new BindableBool(true);
protected override Container<DrawableStoryboardLayer> Content { get; }

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
@ -43,6 +44,8 @@ namespace osu.Game.Tests.Visual
LegacySkin.ResetDrawableTarget(t);
t.Reload();
}));
AddUntilStep("wait for components to load", () => this.ChildrenOfType<SkinnableTargetContainer>().All(t => t.ComponentsLoaded));
}
}
}

View File

@ -350,7 +350,7 @@ namespace osu.Game.Tests.Visual
if (CurrentTime >= Length)
{
Stop();
RaiseCompleted();
// `RaiseCompleted` is not called here to prevent transitioning to the next song.
}
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Textures;
@ -13,16 +12,32 @@ namespace osu.Game.Users.Drawables
{
public class ClickableAvatar : Container
{
private const string default_tooltip_text = "view profile";
/// <summary>
/// Whether to open the user's profile when clicked.
/// </summary>
public readonly BindableBool OpenOnClick = new BindableBool(true);
public bool OpenOnClick
{
set => clickableArea.Enabled.Value = value;
}
/// <summary>
/// By default, the tooltip will show "view profile" as avatars are usually displayed next to a username.
/// Setting this to <c>true</c> exposes the username via tooltip for special cases where this is not true.
/// </summary>
public bool ShowUsernameTooltip
{
set => clickableArea.TooltipText = value ? (user?.Username ?? string.Empty) : default_tooltip_text;
}
private readonly User user;
[Resolved(CanBeNull = true)]
private OsuGame game { get; set; }
private readonly ClickableArea clickableArea;
/// <summary>
/// A clickable avatar for the specified user, with UI sounds included.
/// If <see cref="OpenOnClick"/> is <c>true</c>, clicking will open the user's profile.
@ -31,35 +46,35 @@ namespace osu.Game.Users.Drawables
public ClickableAvatar(User user = null)
{
this.user = user;
}
[BackgroundDependencyLoader]
private void load(LargeTextureStore textures)
{
ClickableArea clickableArea;
Add(clickableArea = new ClickableArea
{
RelativeSizeAxes = Axes.Both,
Action = openProfile
});
}
[BackgroundDependencyLoader]
private void load(LargeTextureStore textures)
{
LoadComponentAsync(new DrawableAvatar(user), clickableArea.Add);
clickableArea.Enabled.BindTo(OpenOnClick);
}
private void openProfile()
{
if (!OpenOnClick.Value)
return;
if (user?.Id > 1)
game?.ShowUser(user.Id);
}
private class ClickableArea : OsuClickableContainer
{
public override string TooltipText => Enabled.Value ? @"view profile" : null;
private string tooltip = default_tooltip_text;
public override string TooltipText
{
get => Enabled.Value ? tooltip : null;
set => tooltip = value;
}
protected override bool OnClick(ClickEvent e)
{

View File

@ -1,7 +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.
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
@ -45,33 +44,38 @@ namespace osu.Game.Users.Drawables
protected override double LoadDelay => 200;
/// <summary>
/// Whether to show a default guest representation on null user (as opposed to nothing).
/// </summary>
public bool ShowGuestOnNull = true;
private readonly bool openOnClick;
private readonly bool showUsernameTooltip;
private readonly bool showGuestOnNull;
/// <summary>
/// Whether to open the user's profile when clicked.
/// Construct a new UpdateableAvatar.
/// </summary>
public readonly BindableBool OpenOnClick = new BindableBool(true);
public UpdateableAvatar(User user = null)
/// <param name="user">The initial user to display.</param>
/// <param name="openOnClick">Whether to open the user's profile when clicked.</param>
/// <param name="showUsernameTooltip">Whether to show the username rather than "view profile" on the tooltip.</param>
/// <param name="showGuestOnNull">Whether to show a default guest representation on null user (as opposed to nothing).</param>
public UpdateableAvatar(User user = null, bool openOnClick = true, bool showUsernameTooltip = false, bool showGuestOnNull = true)
{
this.openOnClick = openOnClick;
this.showUsernameTooltip = showUsernameTooltip;
this.showGuestOnNull = showGuestOnNull;
User = user;
}
protected override Drawable CreateDrawable(User user)
{
if (user == null && !ShowGuestOnNull)
if (user == null && !showGuestOnNull)
return null;
var avatar = new ClickableAvatar(user)
{
OpenOnClick = openOnClick,
ShowUsernameTooltip = showUsernameTooltip,
RelativeSizeAxes = Axes.Both,
};
avatar.OpenOnClick.BindTo(OpenOnClick);
return avatar;
}
}

View File

@ -48,11 +48,7 @@ namespace osu.Game.Users
statusIcon.FinishTransforms();
}
protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar
{
User = User,
OpenOnClick = { Value = false }
};
protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar(User, false);
protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country)
{

View File

@ -60,6 +60,9 @@ namespace osu.Game.Utils
{
foreach (var invalid in combination.Where(m => type.IsInstanceOfType(m)))
{
if (invalid == mod)
continue;
invalidMods ??= new List<Mod>();
invalidMods.Add(invalid);
}

View File

@ -18,6 +18,7 @@
</None>
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="DiffPlex" Version="1.7.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.33" />
<PackageReference Include="Humanizer" Version="2.10.1" />
@ -34,8 +35,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ppy.osu.Framework" Version="2021.614.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.614.0" />
<PackageReference Include="Realm" Version="10.2.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.616.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" />
<PackageReference Include="Sentry" Version="3.4.0" />
<PackageReference Include="SharpCompress" Version="0.28.2" />
<PackageReference Include="NUnit" Version="3.13.2" />