// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Database; using osu.Game.IO; using osu.Game.Overlays.Notifications; using osu.Game.Utils; namespace osu.Game.Skinning { /// /// Handles the storage and retrieval of s. /// /// /// This is also exposed and cached as to allow for any component to potentially have skinning support. /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. /// public class SkinManager : ModelManager, ISkinSource, IStorageResourceProvider, IModelImporter { /// /// The default "classic" skin. /// public Skin DefaultClassicSkin { get; } private readonly AudioManager audio; private readonly Scheduler scheduler; private readonly GameHost host; private readonly IResourceStore resources; public readonly Bindable CurrentSkin = new Bindable(); public readonly Bindable> CurrentSkinInfo = new Bindable>(ArgonSkin.CreateInfo().ToLiveUnmanaged()); private readonly SkinImporter skinImporter; private readonly LegacySkinExporter skinExporter; private readonly IResourceStore userFiles; private Skin argonSkin { get; } private Skin trianglesSkin { get; } public override bool PauseImports { get => base.PauseImports; set { base.PauseImports = value; skinImporter.PauseImports = value; } } public SkinManager(Storage storage, RealmAccess realm, GameHost host, IResourceStore resources, AudioManager audio, Scheduler scheduler) : base(storage, realm) { this.audio = audio; this.scheduler = scheduler; this.host = host; this.resources = resources; userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files")); skinImporter = new SkinImporter(storage, realm, this) { PostNotification = obj => PostNotification?.Invoke(obj), }; var defaultSkins = new[] { DefaultClassicSkin = new DefaultLegacySkin(this), trianglesSkin = new TrianglesSkin(this), argonSkin = new ArgonSkin(this), new ArgonProSkin(this), }; // Ensure the default entries are present. realm.Write(r => { foreach (var skin in defaultSkins) { if (r.Find(skin.SkinInfo.ID) == null) r.Add(skin.SkinInfo.Value); } }); CurrentSkinInfo.ValueChanged += skin => { CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); }; CurrentSkin.Value = argonSkin; CurrentSkin.ValueChanged += skin => { if (!skin.NewValue.SkinInfo.Equals(CurrentSkinInfo.Value)) throw new InvalidOperationException($"Setting {nameof(CurrentSkin)}'s value directly is not supported. Use {nameof(CurrentSkinInfo)} instead."); SourceChanged?.Invoke(); }; skinExporter = new LegacySkinExporter(storage) { PostNotification = obj => PostNotification?.Invoke(obj) }; } public void SelectRandomSkin() { Realm.Run(r => { // choose from only user skins, removing the current selection to ensure a new one is chosen. var randomChoices = r.All() .Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID) .ToArray(); if (randomChoices.Length == 0) { CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged(); return; } var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); CurrentSkinInfo.Value = chosen.ToLive(Realm); }); } /// /// Retrieve a instance for the provided /// /// The skin to lookup. /// A instance correlating to the provided . public Skin GetSkin(SkinInfo skinInfo) => skinInfo.CreateInstance(this); /// /// Ensure that the current skin is in a state it can accept user modifications. /// This will create a copy of any internal skin and being tracking in the database if not already. /// /// /// Whether a new skin was created to allow for mutation. /// public bool EnsureMutableSkin() { return CurrentSkinInfo.Value.PerformRead(s => { if (!s.Protected) return false; string[] existingSkinNames = Realm.Run(r => r.All() .Where(skin => !skin.DeletePending) .AsEnumerable() .Select(skin => skin.Name).ToArray()); // if the user is attempting to save one of the default skin implementations, create a copy first. var skinInfo = new SkinInfo { Creator = s.Creator, InstantiationInfo = s.InstantiationInfo, Name = NamingUtils.GetNextBestName(existingSkinNames, $@"{s.Name} (modified)") }; var result = skinImporter.ImportModel(skinInfo); if (result != null) { // save once to ensure the required json content is populated. // currently this only happens on save. result.PerformRead(skin => Save(skin.CreateInstance(this))); CurrentSkinInfo.Value = result; return true; } return false; }); } /// /// Save a skin, serialising any changes to skin layouts to relevant JSON structures. /// /// Whether any change actually occurred. public bool Save(Skin skin) { if (!skin.SkinInfo.IsManaged) throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first."); return skinImporter.Save(skin); } /// /// Perform a lookup query on available s. /// /// The query. /// The first result for the provided query, or null if no results were found. public Live Query(Expression> query) { return Realm.Run(r => r.All().FirstOrDefault(query)?.ToLive(Realm)); } public event Action SourceChanged; public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => lookupWithFallback(s => s.GetDrawableComponent(lookup)); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => lookupWithFallback(s => s.GetTexture(componentName, wrapModeS, wrapModeT)); public ISample GetSample(ISampleInfo sampleInfo) => lookupWithFallback(s => s.GetSample(sampleInfo)); public IBindable GetConfig(TLookup lookup) => lookupWithFallback(s => s.GetConfig(lookup)); public ISkin FindProvider(Func lookupFunction) { foreach (var source in AllSources) { if (lookupFunction(source)) return source; } return null; } public IEnumerable AllSources { get { yield return CurrentSkin.Value; // Skin manager provides default fallbacks. // This handles cases where a user skin doesn't have the required resources for complete display of // certain elements. if (CurrentSkin.Value is LegacySkin && CurrentSkin.Value != DefaultClassicSkin) yield return DefaultClassicSkin; if (CurrentSkin.Value != trianglesSkin) yield return trianglesSkin; } } private T lookupWithFallback(Func lookupFunction) where T : class { foreach (var source in AllSources) { if (lookupFunction(source) is T skinSourced) return skinSourced; } return null; } #region IResourceStorageProvider IRenderer IStorageResourceProvider.Renderer => host.Renderer; AudioManager IStorageResourceProvider.AudioManager => audio; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.Files => userFiles; RealmAccess IStorageResourceProvider.RealmAccess => Realm; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); #endregion #region Implementation of IModelImporter public Action>> PresentImport { set => skinImporter.PresentImport = value; } public Task Import(params string[] paths) => skinImporter.Import(paths); public Task Import(ImportTask[] imports, ImportParameters parameters = default) => skinImporter.Import(imports, parameters); public IEnumerable HandledExtensions => skinImporter.HandledExtensions; public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => skinImporter.Import(notification, tasks, parameters); public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) => skinImporter.ImportAsUpdate(notification, task, original); public Task> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => skinImporter.Import(task, parameters, cancellationToken); public Task ExportCurrentSkin() => ExportSkin(CurrentSkinInfo.Value); public Task ExportSkin(Live skin) => skinExporter.ExportAsync(skin); #endregion public void Delete([CanBeNull] Expression> filter = null, bool silent = false) { Realm.Run(r => { var items = r.All() .Where(s => !s.Protected && !s.DeletePending); if (filter != null) items = items.Where(filter); // check the removed skin is not the current user choice. if it is, switch back to default. Guid currentUserSkin = CurrentSkinInfo.Value.ID; if (items.Any(s => s.ID == currentUserSkin)) scheduler.Add(() => CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged()); Delete(items.ToList(), silent); }); } public void SetSkinFromConfiguration(string guidString) { Live skinInfo = null; if (Guid.TryParse(guidString, out var guid)) skinInfo = Query(s => s.ID == guid); if (skinInfo == null) { if (guid == SkinInfo.CLASSIC_SKIN) skinInfo = DefaultClassicSkin.SkinInfo; } CurrentSkinInfo.Value = skinInfo ?? trianglesSkin.SkinInfo; } } }