mirror of
https://github.com/ppy/osu.git
synced 2026-05-18 06:29:52 +08:00
db93c2ad6f
Reported at https://osu.ppy.sh/comments/3681620, with appropriate levels of rage bait (DID ANYONE TEST THIS?!?!?!?!?!?!?!?!?!111!!) Reasoning for this is that without this, users' skin names can be dropped after an external edit because they're never persisted anywhere outside of realm. The only other choice I see is to stop re-populating skin metadata from the `.ini` upon completing an external edit, which is very doable but seems worse than this. Dunno.
378 lines
14 KiB
C#
378 lines
14 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
#nullable disable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.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
|
|
{
|
|
/// <summary>
|
|
/// Handles the storage and retrieval of <see cref="Skin"/>s.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is also exposed and cached as <see cref="ISkinSource"/> to allow for any component to potentially have skinning support.
|
|
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
|
|
/// </remarks>
|
|
public class SkinManager : ModelManager<SkinInfo>, ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>
|
|
{
|
|
/// <summary>
|
|
/// The default "classic" skin.
|
|
/// </summary>
|
|
public Skin DefaultClassicSkin { get; }
|
|
|
|
private readonly AudioManager audio;
|
|
|
|
private readonly Scheduler scheduler;
|
|
|
|
private readonly GameHost host;
|
|
|
|
private readonly IResourceStore<byte[]> resources;
|
|
|
|
public readonly Bindable<Skin> CurrentSkin = new Bindable<Skin>();
|
|
|
|
public readonly Bindable<Live<SkinInfo>> CurrentSkinInfo = new Bindable<Live<SkinInfo>>(ArgonSkin.CreateInfo().ToLiveUnmanaged());
|
|
|
|
private readonly SkinImporter skinImporter;
|
|
|
|
private readonly LegacySkinExporter skinExporter;
|
|
|
|
private readonly IResourceStore<byte[]> 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<byte[]> 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<SkinInfo>(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 =>
|
|
{
|
|
// can be the case when the current skin is externally mounted for editing
|
|
if (CurrentSkinInfo.Disabled)
|
|
return;
|
|
|
|
// Required local for iOS. Will cause runtime crash if inlined.
|
|
Guid currentSkinId = CurrentSkinInfo.Value.ID;
|
|
|
|
// choose from only user skins, removing the current selection to ensure a new one is chosen.
|
|
var randomChoices = r.All<SkinInfo>()
|
|
.Where(s => !s.DeletePending && s.ID != currentSkinId)
|
|
.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);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieve a <see cref="Skin"/> instance for the provided <see cref="SkinInfo"/>
|
|
/// </summary>
|
|
/// <param name="skinInfo">The skin to lookup.</param>
|
|
/// <returns>A <see cref="Skin"/> instance correlating to the provided <see cref="SkinInfo"/>.</returns>
|
|
public Skin GetSkin(SkinInfo skinInfo) => skinInfo.CreateInstance(this);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// Whether a new skin was created to allow for mutation.
|
|
/// </returns>
|
|
public bool EnsureMutableSkin()
|
|
{
|
|
return CurrentSkinInfo.Value.PerformRead(s =>
|
|
{
|
|
if (!s.Protected)
|
|
return false;
|
|
|
|
string[] existingSkinNames = Realm.Run(r => r.All<SkinInfo>()
|
|
.Where(skin => !skin.DeletePending)
|
|
.AsEnumerable()
|
|
.Select(skin => skin.Name).ToArray());
|
|
|
|
// if the user is attempting to save one of the default skin implementations, create a copy first.
|
|
var skinInfo = new SkinInfo
|
|
{
|
|
Creator = s.Creator,
|
|
InstantiationInfo = s.InstantiationInfo,
|
|
Name = NamingUtils.GetNextBestName(existingSkinNames, $@"{s.Name} (modified)")
|
|
};
|
|
|
|
var result = skinImporter.ImportModel(skinInfo, parameters: new ImportParameters
|
|
{
|
|
ImportImmediately = true // to avoid possible deadlocks when editing skin during gameplay.
|
|
});
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save a skin, serialising any changes to skin layouts to relevant JSON structures.
|
|
/// </summary>
|
|
/// <returns>Whether any change actually occurred.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Perform a lookup query on available <see cref="SkinInfo"/>s.
|
|
/// </summary>
|
|
/// <param name="query">The query.</param>
|
|
/// <returns>The first result for the provided query, or null if no results were found.</returns>
|
|
public Live<SkinInfo> Query(Expression<Func<SkinInfo, bool>> query)
|
|
{
|
|
return Realm.Run(r => r.All<SkinInfo>().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<TValue> GetConfig<TLookup, TValue>(TLookup lookup) => lookupWithFallback(s => s.GetConfig<TLookup, TValue>(lookup));
|
|
|
|
public ISkin FindProvider(Func<ISkin, bool> lookupFunction)
|
|
{
|
|
foreach (var source in AllSources)
|
|
{
|
|
if (lookupFunction(source))
|
|
return source;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public IEnumerable<ISkin> 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<T>(Func<ISkin, T> lookupFunction)
|
|
where T : class
|
|
{
|
|
try
|
|
{
|
|
Skin.LogLookupDebug(this, lookupFunction, Skin.LookupDebugType.Enter);
|
|
|
|
foreach (var source in AllSources)
|
|
{
|
|
if (lookupFunction(source) is T skinSourced)
|
|
return skinSourced;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
Skin.LogLookupDebug(this, lookupFunction, Skin.LookupDebugType.Exit);
|
|
}
|
|
}
|
|
|
|
#region IResourceStorageProvider
|
|
|
|
IRenderer IStorageResourceProvider.Renderer => host.Renderer;
|
|
AudioManager IStorageResourceProvider.AudioManager => audio;
|
|
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
|
|
IResourceStore<byte[]> IStorageResourceProvider.Files => userFiles;
|
|
RealmAccess IStorageResourceProvider.RealmAccess => Realm;
|
|
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host.CreateTextureLoaderStore(underlyingStore);
|
|
|
|
#endregion
|
|
|
|
#region Implementation of IModelImporter<SkinInfo>
|
|
|
|
public Action<IEnumerable<Live<SkinInfo>>> 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<string> HandledExtensions => skinImporter.HandledExtensions;
|
|
|
|
public Task<IEnumerable<Live<SkinInfo>>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) =>
|
|
skinImporter.Import(notification, tasks, parameters);
|
|
|
|
public Task<Live<SkinInfo>> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) =>
|
|
skinImporter.ImportAsUpdate(notification, task, original);
|
|
|
|
public Task<ExternalEditOperation<SkinInfo>> BeginExternalEditing(SkinInfo model) => skinImporter.BeginExternalEditing(model);
|
|
|
|
public Task<Live<SkinInfo>> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) =>
|
|
skinImporter.Import(task, parameters, cancellationToken);
|
|
|
|
public Task ExportCurrentSkin() => ExportSkin(CurrentSkinInfo.Value);
|
|
|
|
public Task ExportSkin(Live<SkinInfo> skin) => skinExporter.ExportAsync(skin);
|
|
|
|
#endregion
|
|
|
|
public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false)
|
|
{
|
|
Realm.Run(r =>
|
|
{
|
|
var items = r.All<SkinInfo>()
|
|
.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 Rename(Live<SkinInfo> skin, string newName)
|
|
{
|
|
skin.PerformWrite(s =>
|
|
{
|
|
s.Name = newName;
|
|
skinImporter.UpdateSkinIniMetadata(s, s.Realm!);
|
|
});
|
|
}
|
|
|
|
public void SetSkinFromConfiguration(string guidString)
|
|
{
|
|
Live<SkinInfo> 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;
|
|
}
|
|
}
|
|
}
|