diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 4111a67b24..89f0e73f4f 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -88,6 +88,7 @@ namespace osu.Game.Configuration throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})"); var control = (Drawable)Activator.CreateInstance(controlType); + controlType.GetProperty(nameof(SettingsItem.SettingSourceObject))?.SetValue(control, obj); controlType.GetProperty(nameof(SettingsItem.LabelText))?.SetValue(control, attr.Label); controlType.GetProperty(nameof(SettingsItem.TooltipText))?.SetValue(control, attr.Description); controlType.GetProperty(nameof(SettingsItem.Current))?.SetValue(control, value); diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index e709be1343..098090bf78 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Containers; @@ -24,6 +25,11 @@ namespace osu.Game.Overlays.Settings protected Drawable Control { get; } + /// + /// The source component if this was created via . + /// + public object SettingSourceObject { get; internal set; } + private IHasCurrentValue controlWithCurrent => Control as IHasCurrentValue; protected override Container Content => FlowContent; diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 7c6d138f4c..119b0ec9ad 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -8,6 +8,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; @@ -46,13 +47,13 @@ namespace osu.Game.Skinning this.resources = resources; } - public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT); public override ISample GetSample(ISampleInfo sampleInfo) { foreach (string lookup in sampleInfo.LookupNames) { - var sample = resources.AudioManager.Samples.Get(lookup); + var sample = Samples?.Get(lookup) ?? resources.AudioManager.Samples.Get(lookup); if (sample != null) return sample; } @@ -157,6 +158,16 @@ namespace osu.Game.Skinning break; } + switch (component.LookupName) + { + // Temporary until default skin has a valid hit lighting. + case @"lighting": + return Drawable.Empty(); + } + + if (GetTexture(component.LookupName) is Texture t) + return new Sprite { Texture = t }; + return null; } diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 7bf4e94662..607a881d28 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -11,6 +13,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Testing; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; @@ -22,7 +25,7 @@ using osu.Game.Screens.Edit.Components.Menus; namespace osu.Game.Skinning.Editor { [Cached(typeof(SkinEditor))] - public class SkinEditor : VisibilityContainer + public class SkinEditor : VisibilityContainer, ICanAcceptFiles { public const double TRANSITION_DURATION = 500; @@ -36,12 +39,18 @@ namespace osu.Game.Skinning.Editor private Bindable currentSkin; + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + [Resolved] private SkinManager skins { get; set; } [Resolved] private OsuColour colours { get; set; } + [Resolved] + private RealmAccess realm { get; set; } + [Resolved(canBeNull: true)] private SkinEditorOverlay skinEditorOverlay { get; set; } @@ -171,6 +180,8 @@ namespace osu.Game.Skinning.Editor Show(); + game?.RegisterImportHandler(this); + // as long as the skin editor is loaded, let's make sure we can modify the current skin. currentSkin = skins.CurrentSkin.GetBoundCopy(); @@ -229,15 +240,20 @@ namespace osu.Game.Skinning.Editor } private void placeComponent(Type type) + { + if (!(Activator.CreateInstance(type) is ISkinnableDrawable component)) + throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}."); + + placeComponent(component); + } + + private void placeComponent(ISkinnableDrawable component) { var targetContainer = getFirstTarget(); if (targetContainer == null) return; - if (!(Activator.CreateInstance(type) is ISkinnableDrawable component)) - throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}."); - var drawableComponent = (Drawable)component; // give newly added components a sane starting location. @@ -313,5 +329,48 @@ namespace osu.Game.Skinning.Editor foreach (var item in items) availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item); } + + #region Drag & drop import handling + + public Task Import(params string[] paths) + { + Schedule(() => + { + var file = new FileInfo(paths.First()); + + // import to skin + currentSkin.Value.SkinInfo.PerformWrite(skinInfo => + { + using (var contents = file.OpenRead()) + skins.AddFile(skinInfo, contents, file.Name); + }); + + // Even though we are 100% on an update thread, we need to wait for realm callbacks to fire (to correctly invalidate caches in RealmBackedResourceStore). + // See https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-2483573 for further discussion. + // This is the best we can do for now. + realm.Run(r => r.Refresh()); + + // place component + placeComponent(new SkinnableSprite + { + SpriteName = { Value = file.Name } + }); + }); + + return Task.CompletedTask; + } + + public Task Import(params ImportTask[] tasks) => throw new NotImplementedException(); + + public IEnumerable HandledExtensions => new[] { ".jpg", ".jpeg", ".png" }; + + #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + game?.UnregisterImportHandler(this); + } } } diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 16a05f4197..70f5b35d00 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -11,6 +11,7 @@ using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.Database; using osu.Game.IO; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; @@ -37,11 +38,11 @@ namespace osu.Game.Skinning private static IResourceStore createRealmBackedStore(BeatmapInfo beatmapInfo, IStorageResourceProvider? resources) { - if (resources == null) + if (resources == null || beatmapInfo.BeatmapSet == null) // should only ever be used in tests. return new ResourceStore(); - return new RealmBackedResourceStore(beatmapInfo.BeatmapSet, resources.Files, new[] { @"ogg" }); + return new RealmBackedResourceStore(beatmapInfo.BeatmapSet.ToLive(resources.RealmAccess), resources.Files, resources.RealmAccess); } public override Drawable? GetDrawableComponent(ISkinComponent component) diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index 97084f34e0..9481fc7182 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -23,7 +23,7 @@ namespace osu.Game.Skinning /// The which is being transformed. /// [NotNull] - protected ISkin Skin { get; } + protected internal ISkin Skin { get; } protected LegacySkinTransformer([NotNull] ISkin skin) { diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs index fc9036727f..7fa24284ee 100644 --- a/osu.Game/Skinning/RealmBackedResourceStore.cs +++ b/osu.Game/Skinning/RealmBackedResourceStore.cs @@ -1,51 +1,77 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Game.Database; using osu.Game.Extensions; +using Realms; namespace osu.Game.Skinning { - public class RealmBackedResourceStore : ResourceStore + public class RealmBackedResourceStore : ResourceStore + where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey { - private readonly Dictionary fileToStoragePathMapping = new Dictionary(); + private Lazy> fileToStoragePathMapping; - public RealmBackedResourceStore(IHasRealmFiles source, IResourceStore underlyingStore, string[] extensions = null) + private readonly Live liveSource; + private readonly IDisposable? realmSubscription; + + public RealmBackedResourceStore(Live source, IResourceStore underlyingStore, RealmAccess? realm) : base(underlyingStore) { - // Must be initialised before the file cache. - if (extensions != null) - { - foreach (string extension in extensions) - AddExtension(extension); - } + liveSource = source; - initialiseFileCache(source); + invalidateCache(); + Debug.Assert(fileToStoragePathMapping != null); + + realmSubscription = realm?.RegisterForNotifications(r => r.All().Where(s => s.ID == source.ID), skinChanged); } - private void initialiseFileCache(IHasRealmFiles source) + protected override void Dispose(bool disposing) { - fileToStoragePathMapping.Clear(); - foreach (var f in source.Files) - fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath(); + base.Dispose(disposing); + realmSubscription?.Dispose(); } + private void skinChanged(IRealmCollection sender, ChangeSet changes, Exception error) => invalidateCache(); + protected override IEnumerable GetFilenames(string name) { foreach (string filename in base.GetFilenames(name)) { - string path = getPathForFile(filename.ToStandardisedPath()); + string? path = getPathForFile(filename.ToStandardisedPath()); if (path != null) yield return path; } } - private string getPathForFile(string filename) => - fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null; + private string? getPathForFile(string filename) + { + if (fileToStoragePathMapping.Value.TryGetValue(filename.ToLowerInvariant(), out string path)) + return path; - public override IEnumerable GetAvailableResources() => fileToStoragePathMapping.Keys; + return null; + } + + private void invalidateCache() => fileToStoragePathMapping = new Lazy>(initialiseFileCache); + + private Dictionary initialiseFileCache() => liveSource.PerformRead(source => + { + var dictionary = new Dictionary(); + dictionary.Clear(); + foreach (var f in source.Files) + dictionary[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath(); + + return dictionary; + }); + + public override IEnumerable GetAvailableResources() => fileToStoragePathMapping.Value.Keys; } } diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 5d4afc00c4..b9f9d3bd10 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -54,6 +54,8 @@ namespace osu.Game.Skinning where TLookup : notnull where TValue : notnull; + private readonly RealmBackedResourceStore? realmBackedStorage; + /// /// Construct a new skin. /// @@ -67,7 +69,9 @@ namespace osu.Game.Skinning { SkinInfo = skin.ToLive(resources.RealmAccess); - storage ??= new RealmBackedResourceStore(skin, resources.Files, new[] { @"ogg" }); + storage ??= realmBackedStorage = new RealmBackedResourceStore(SkinInfo, resources.Files, resources.RealmAccess); + + (storage as ResourceStore)?.AddExtension("ogg"); var samples = resources.AudioManager?.GetSampleStore(storage); if (samples != null) @@ -191,6 +195,8 @@ namespace osu.Game.Skinning Textures?.Dispose(); Samples?.Dispose(); + + realmBackedStorage?.Dispose(); } #endregion diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 71920fb166..01e7646644 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Linq.Expressions; using System.Threading; @@ -23,6 +24,7 @@ using osu.Game.Audio; using osu.Game.Database; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Models; using osu.Game.Overlays.Notifications; using osu.Game.Utils; @@ -36,7 +38,7 @@ namespace osu.Game.Skinning /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. /// [ExcludeFromDynamicCompile] - public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter + public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter, IModelManager, IModelFileManager { private readonly AudioManager audio; @@ -96,7 +98,10 @@ namespace osu.Game.Skinning } }); - CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); + CurrentSkinInfo.ValueChanged += skin => + { + CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); + }; CurrentSkin.Value = DefaultSkin; CurrentSkin.ValueChanged += skin => @@ -313,5 +318,45 @@ namespace osu.Game.Skinning } #endregion + + public bool Delete(SkinInfo item) + { + return skinModelManager.Delete(item); + } + + public void Delete(List items, bool silent = false) + { + skinModelManager.Delete(items, silent); + } + + public void Undelete(List items, bool silent = false) + { + skinModelManager.Undelete(items, silent); + } + + public void Undelete(SkinInfo item) + { + skinModelManager.Undelete(item); + } + + public bool IsAvailableLocally(SkinInfo model) + { + return skinModelManager.IsAvailableLocally(model); + } + + public void ReplaceFile(SkinInfo model, RealmNamedFileUsage file, Stream contents) + { + skinModelManager.ReplaceFile(model, file, contents); + } + + public void DeleteFile(SkinInfo model, RealmNamedFileUsage file) + { + skinModelManager.DeleteFile(model, file); + } + + public void AddFile(SkinInfo model, Stream contents, string filename) + { + skinModelManager.AddFile(model, contents, filename); + } } } diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index 72f64e2e12..45409694b5 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -31,7 +31,7 @@ namespace osu.Game.Skinning set => base.AutoSizeAxes = value; } - private readonly ISkinComponent component; + protected readonly ISkinComponent Component; private readonly ConfineMode confineMode; @@ -49,7 +49,7 @@ namespace osu.Game.Skinning protected SkinnableDrawable(ISkinComponent component, ConfineMode confineMode = ConfineMode.NoScaling) { - this.component = component; + Component = component; this.confineMode = confineMode; RelativeSizeAxes = Axes.Both; @@ -75,13 +75,13 @@ namespace osu.Game.Skinning protected override void SkinChanged(ISkinSource skin) { - Drawable = skin.GetDrawableComponent(component); + Drawable = skin.GetDrawableComponent(Component); isDefault = false; if (Drawable == null) { - Drawable = CreateDefault(component); + Drawable = CreateDefault(Component); isDefault = true; } diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 56e576d081..4b4d7fe2c6 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -1,26 +1,56 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Game.Configuration; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays.Settings; +using osuTK; namespace osu.Game.Skinning { /// - /// A skinnable element which uses a stable sprite and can therefore share implementation logic. + /// A skinnable element which uses a single texture backing. /// - public class SkinnableSprite : SkinnableDrawable + public class SkinnableSprite : SkinnableDrawable, ISkinnableDrawable { protected override bool ApplySizeRestrictionsToDefault => true; [Resolved] private TextureStore textures { get; set; } + [SettingSource("Sprite name", "The filename of the sprite", SettingControlType = typeof(SpriteSelectorControl))] + public Bindable SpriteName { get; } = new Bindable(string.Empty); + + [Resolved] + private ISkinSource source { get; set; } + public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling) : base(new SpriteComponent(textureName), confineMode) { + SpriteName.Value = textureName; + } + + public SkinnableSprite() + : base(new SpriteComponent(string.Empty), ConfineMode.NoScaling) + { + RelativeSizeAxes = Axes.None; + AutoSizeAxes = Axes.Both; + + SpriteName.BindValueChanged(name => + { + ((SpriteComponent)Component).LookupName = name.NewValue ?? string.Empty; + if (IsLoaded) + SkinChanged(CurrentSkin); + }); } protected override Drawable CreateDefault(ISkinComponent component) @@ -28,19 +58,85 @@ namespace osu.Game.Skinning var texture = textures.Get(component.LookupName); if (texture == null) - return null; + return new SpriteNotFound(component.LookupName); return new Sprite { Texture = texture }; } + public bool UsesFixedAnchor { get; set; } + private class SpriteComponent : ISkinComponent { + public string LookupName { get; set; } + public SpriteComponent(string textureName) { LookupName = textureName; } + } - public string LookupName { get; } + public class SpriteSelectorControl : SettingsDropdown + { + protected override void LoadComplete() + { + base.LoadComplete(); + + // Round-about way of getting the user's skin to find available resources. + // In the future we'll probably want to allow access to resources from the fallbacks, or potentially other skins + // but that requires further thought. + var highestPrioritySkin = getHighestPriorityUserSkin(((SkinnableSprite)SettingSourceObject).source.AllSources) as Skin; + + string[] availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files + .Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal) + || f.Filename.EndsWith(".jpg", StringComparison.Ordinal)) + .Select(f => f.Filename).Distinct()).ToArray(); + + if (availableFiles?.Length > 0) + Items = availableFiles; + + static ISkin getHighestPriorityUserSkin(IEnumerable skins) + { + foreach (var skin in skins) + { + if (skin is LegacySkinTransformer transformer && isUserSkin(transformer.Skin)) + return transformer.Skin; + + if (isUserSkin(skin)) + return skin; + } + + return null; + } + + // Temporarily used to exclude undesirable ISkin implementations + static bool isUserSkin(ISkin skin) + => skin.GetType() == typeof(DefaultSkin) + || skin.GetType() == typeof(DefaultLegacySkin) + || skin.GetType() == typeof(LegacySkin); + } + } + + public class SpriteNotFound : CompositeDrawable + { + public SpriteNotFound(string lookup) + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new SpriteIcon + { + Size = new Vector2(50), + Icon = FontAwesome.Solid.QuestionCircle + }, + new OsuSpriteText + { + Position = new Vector2(25, 50), + Text = $"missing: {lookup}", + Origin = Anchor.TopCentre, + } + }; + } } } }