diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index d50862a369..c67c5521d3 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -95,7 +95,7 @@ namespace osu.Game.Beatmaps protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap? defaultBeatmap, GameHost? host) { - return new WorkingBeatmapCache(BeatmapTrackStore, audioManager, resources, storage, defaultBeatmap, host); + return new WorkingBeatmapCache(BeatmapTrackStore, audioManager, resources, storage, defaultBeatmap, host, Realm); } protected virtual BeatmapImporter CreateBeatmapImporter(Storage storage, RealmAccess realm) => new BeatmapImporter(storage, realm); diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 9957935977..0bb61798dc 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -47,12 +47,13 @@ namespace osu.Game.Beatmaps private readonly LargeTextureStore beatmapPanelTextureStore; private readonly ITrackStore trackStore; private readonly IResourceStore files; + private readonly RealmAccess realm; [CanBeNull] private readonly GameHost host; public WorkingBeatmapCache(ITrackStore trackStore, AudioManager audioManager, IResourceStore resources, IResourceStore files, WorkingBeatmap defaultBeatmap = null, - GameHost host = null) + GameHost host = null, RealmAccess realm = null) { DefaultBeatmap = defaultBeatmap; @@ -63,6 +64,7 @@ namespace osu.Game.Beatmaps largeTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), host?.CreateTextureLoaderStore(files)); beatmapPanelTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), new BeatmapPanelBackgroundTextureLoaderStore(host?.CreateTextureLoaderStore(files))); this.trackStore = trackStore; + this.realm = realm; } public void Invalidate(BeatmapSetInfo info) @@ -118,7 +120,7 @@ namespace osu.Game.Beatmaps ITrackStore IBeatmapResourceProvider.Tracks => trackStore; IRenderer IStorageResourceProvider.Renderer => host?.Renderer ?? new DummyRenderer(); AudioManager IStorageResourceProvider.AudioManager => audioManager; - RealmAccess IStorageResourceProvider.RealmAccess => null!; + RealmAccess IStorageResourceProvider.RealmAccess => realm; IResourceStore IStorageResourceProvider.Files => files; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore); diff --git a/osu.Game/Screens/Edit/Components/FormSampleSet.cs b/osu.Game/Screens/Edit/Components/FormSampleSet.cs index 50c0532c02..f2d31dea06 100644 --- a/osu.Game/Screens/Edit/Components/FormSampleSet.cs +++ b/osu.Game/Screens/Edit/Components/FormSampleSet.cs @@ -42,7 +42,7 @@ namespace osu.Game.Screens.Edit.Components set => current.Current = value; } - public Func? SampleAddRequested { get; init; } + public Func? SampleAddRequested { get; init; } public Action? SampleRemoveRequested { get; init; } private readonly BindableWithCurrent current = new BindableWithCurrent(); @@ -194,7 +194,7 @@ namespace osu.Game.Screens.Edit.Components /// /// Invoked when a new sample is selected via this button. /// - public Func? SampleAddRequested { get; init; } + public Func? SampleAddRequested { get; init; } /// /// Invoked when a sample removal is selected via this button. @@ -294,7 +294,18 @@ namespace osu.Game.Screens.Edit.Components AddInternal(hoverSounds = (ActualFilename.Value == null ? new HoverClickSounds(HoverSampleSet.Button) : new HoverSounds(HoverSampleSet.Button))); - sample = ActualFilename.Value == null ? null : editorBeatmap?.BeatmapSkin?.Skin.Samples?.Get(ActualFilename.Value); + if (ActualFilename.Value != null) + { + // to cover all bases, invalidate the extensionless filename (which gameplay is most likely to use) + // as well as the filename with extension (which we are using here). + editorBeatmap?.BeatmapSkin?.Skin.Samples?.Invalidate(ExpectedFilename.Value); + editorBeatmap?.BeatmapSkin?.Skin.Samples?.Invalidate(ActualFilename.Value); + sample = editorBeatmap?.BeatmapSkin?.Skin.Samples?.Get(ActualFilename.Value); + } + else + { + sample = null; + } } protected override bool OnHover(HoverEvent e) @@ -317,7 +328,7 @@ namespace osu.Game.Screens.Edit.Components return; this.HidePopover(); - ActualFilename.Value = SampleAddRequested?.Invoke(selectedFile.Value) ?? selectedFile.Value.ToString(); + ActualFilename.Value = SampleAddRequested?.Invoke(selectedFile.Value, ExpectedFilename.Value) ?? selectedFile.Value.ToString(); } private void deleteSample() diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 91ae4593dd..248779a9f8 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -101,9 +101,9 @@ namespace osu.Game.Screens.Edit this.beatmapInfo = beatmapInfo ?? playableBeatmap.BeatmapInfo; - if (beatmapSkin is Skin skin) + if (beatmapSkin is LegacyBeatmapSkin skin) { - BeatmapSkin = new EditorBeatmapSkin(skin); + BeatmapSkin = new EditorBeatmapSkin(playableBeatmap.BeatmapInfo!.BeatmapSet!, skin); BeatmapSkin.BeatmapSkinChanged += SaveState; } @@ -532,5 +532,11 @@ namespace osu.Game.Screens.Edit public double GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor; public int BeatDivisor => beatDivisor?.Value ?? 1; + + protected override void Dispose(bool isDisposing) + { + BeatmapSkin?.Dispose(); + base.Dispose(isDisposing); + } } } diff --git a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs index 531f77d238..0fbfb7b2d0 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs @@ -10,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Textures; using osu.Game.Audio; +using osu.Game.Beatmaps; using osu.Game.Skinning; using osuTK.Graphics; @@ -18,14 +19,14 @@ namespace osu.Game.Screens.Edit /// /// A beatmap skin which is being edited. /// - public class EditorBeatmapSkin : ISkin + public class EditorBeatmapSkin : ISkin, IDisposable { public event Action? BeatmapSkinChanged; /// /// The underlying beatmap skin. /// - protected internal readonly Skin Skin; + protected internal readonly LegacyBeatmapSkin Skin; /// /// The combo colours of this skin. @@ -33,7 +34,7 @@ namespace osu.Game.Screens.Edit /// public BindableList ComboColours { get; } - public EditorBeatmapSkin(Skin skin) + public EditorBeatmapSkin(BeatmapSetInfo beatmapSet, LegacyBeatmapSkin skin) { Skin = skin; @@ -50,9 +51,14 @@ namespace osu.Game.Screens.Edit } ComboColours.BindCollectionChanged((_, _) => updateColours()); + + if (skin.BeatmapSetResources != null) + skin.BeatmapSetResources.CacheInvalidated += InvokeSkinChanged; } - private void invokeSkinChanged() => BeatmapSkinChanged?.Invoke(); + public void InvokeSkinChanged() => BeatmapSkinChanged?.Invoke(); + + #region Combo colours private void updateColours() { @@ -60,9 +66,13 @@ namespace osu.Game.Screens.Edit Skin.Configuration.CustomComboColours.Clear(); for (int i = 0; i < ComboColours.Count; ++i) Skin.Configuration.CustomComboColours.Add(ComboColours[(ComboColours.Count + i - 1) % ComboColours.Count]); - invokeSkinChanged(); + InvokeSkinChanged(); } + #endregion + + #region Sample sets + public record SampleSet(int SampleSetIndex, string Name) { public SampleSet(int sampleSetIndex) @@ -88,7 +98,7 @@ namespace osu.Game.Screens.Edit string[] possiblePrefixes = possibleSounds.SelectMany(sound => possibleBanks.Select(bank => $@"{bank}-{sound}")).ToArray(); - HashSet indices = new HashSet(); + Dictionary sampleSets = new Dictionary(); if (Skin.Samples != null) { @@ -96,19 +106,38 @@ namespace osu.Game.Screens.Edit { foreach (string possiblePrefix in possiblePrefixes) { - if (!sample.StartsWith(possiblePrefix, StringComparison.InvariantCultureIgnoreCase)) + if (!sample.StartsWith(possiblePrefix, StringComparison.Ordinal)) continue; string indexString = Path.GetFileNameWithoutExtension(sample)[possiblePrefix.Length..]; + int? index = null; + if (string.IsNullOrEmpty(indexString)) - indices.Add(1); - if (int.TryParse(indexString, out int index)) - indices.Add(index); + index = 1; + if (int.TryParse(indexString, out int parsed)) + index = parsed; + + if (!index.HasValue) + continue; + + SampleSet? sampleSet; + if (!sampleSets.TryGetValue(index.Value, out sampleSet)) + sampleSet = sampleSets[index.Value] = new SampleSet(index.Value); + + sampleSet.Filenames.Add(sample); } } } - return indices.OrderBy(i => i).Select(i => new SampleSet(i)); + return sampleSets.OrderBy(i => i.Key).Select(i => i.Value); + } + + #endregion + + public void Dispose() + { + if (Skin.BeatmapSetResources != null) + Skin.BeatmapSetResources.CacheInvalidated -= InvokeSkinChanged; } #region Delegated ISkin implementation diff --git a/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs b/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs new file mode 100644 index 0000000000..750586dfe3 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs @@ -0,0 +1,144 @@ +// 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.Globalization; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Setup +{ + public partial class FormSampleSetChooser : FormDropdown, IHasPopover + { + private EditorBeatmapSkin? beatmapSkin; + + public FormSampleSetChooser() + { + Caption = "Custom sample sets"; + } + + [BackgroundDependencyLoader] + private void load(EditorBeatmap editorBeatmap) + { + beatmapSkin = editorBeatmap.BeatmapSkin; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + populateItems(); + if (beatmapSkin != null) + beatmapSkin.BeatmapSkinChanged += populateItems; + + Current.Value = Items.FirstOrDefault(i => i?.SampleSetIndex > 0); + Current.BindValueChanged(val => + { + if (val.NewValue?.SampleSetIndex == -1) + this.ShowPopover(); + }); + } + + private void populateItems() + { + var items = beatmapSkin?.GetAvailableSampleSets().ToList() ?? []; + items.Add(new EditorBeatmapSkin.SampleSet(-1, "Add new...")); + Items = items; + } + + protected override LocalisableString GenerateItemText(EditorBeatmapSkin.SampleSet? item) + { + if (item == null) + return string.Empty; + + return base.GenerateItemText(item); + } + + public Popover GetPopover() => new NewSampleSetPopover( + Items.Any(i => i?.SampleSetIndex > 0) ? Items.Max(i => i!.SampleSetIndex) : 0, + idx => + { + if (idx == null) + { + Current.Value = Items.FirstOrDefault(i => i?.SampleSetIndex > 0); + return; + } + + if (Items.SingleOrDefault(i => i?.SampleSetIndex == idx) is EditorBeatmapSkin.SampleSet existing) + { + Current.Value = existing; + return; + } + + var sampleSet = new EditorBeatmapSkin.SampleSet(idx.Value, $@"Custom #{idx}"); + var newItems = Items.ToList(); + newItems.Insert(newItems.Count - 1, sampleSet); + Items = newItems; + Current.Value = sampleSet; + }); + + protected override void Dispose(bool isDisposing) + { + if (beatmapSkin != null) + beatmapSkin.BeatmapSkinChanged -= populateItems; + + base.Dispose(isDisposing); + } + + private partial class NewSampleSetPopover : OsuPopover + { + private readonly int currentLargestIndex; + private readonly Action onCommit; + + private int? committedIndex; + + private LabelledNumberBox numberBox = null!; + + public NewSampleSetPopover(int currentLargestIndex, Action onCommit) + { + this.currentLargestIndex = currentLargestIndex; + this.onCommit = onCommit; + } + + [BackgroundDependencyLoader] + private void load() + { + Child = numberBox = new LabelledNumberBox + { + RelativeSizeAxes = Axes.None, + Width = 250, + Label = "Sample set index", + Current = { Value = (currentLargestIndex + 1).ToString(CultureInfo.InvariantCulture) } + }; + numberBox.OnCommit += (_, _) => + { + committedIndex = int.Parse(numberBox.Current.Value); + Hide(); + }; + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + // avoids infinite refocus loop + if (committedIndex == null) + GetContainingFocusManager()?.ChangeFocus(numberBox); + } + + public override void Hide() + { + if (State.Value == Visibility.Visible) + onCommit.Invoke(committedIndex > 0 ? committedIndex : null); + base.Hide(); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index f52d865d5f..8a55db8420 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -14,6 +14,7 @@ using osu.Game.Localisation; using osu.Game.Models; using osu.Game.Overlays; using osu.Game.Screens.Backgrounds; +using osu.Game.Screens.Edit.Components; using osu.Game.Utils; namespace osu.Game.Screens.Edit.Setup @@ -23,6 +24,8 @@ namespace osu.Game.Screens.Edit.Setup private FormBeatmapFileSelector audioTrackChooser = null!; private FormBeatmapFileSelector backgroundChooser = null!; + private readonly Bindable currentSampleSet = new Bindable(); + public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; [Resolved] @@ -65,6 +68,27 @@ namespace osu.Game.Screens.Edit.Setup Caption = EditorSetupStrings.AudioTrack, PlaceholderText = EditorSetupStrings.ClickToSelectTrack, }, + new FormSampleSetChooser + { + Current = { BindTarget = currentSampleSet }, + }, + new FormSampleSet + { + Current = { BindTarget = currentSampleSet }, + SampleAddRequested = (file, targetName) => + { + string actualFilename = string.Concat(targetName, file.Extension); + using var stream = file.OpenRead(); + beatmaps.AddFile(working.Value.BeatmapSetInfo, stream, actualFilename); + return actualFilename; + }, + SampleRemoveRequested = filename => + { + var file = working.Value.BeatmapSetInfo.GetFile(filename); + if (file != null) + beatmaps.DeleteFile(working.Value.BeatmapSetInfo, file); + } + }, }; backgroundChooser.PreviewContainer.Add(headerBackground); diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index e198d43be7..365652e6ad 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -26,6 +26,8 @@ namespace osu.Game.Skinning // 2. https://github.com/peppy/osu-stable-reference/blob/dc0994645801010d4b628fff5ff79cd3c286ca83/osu!/Graphics/Textures/TextureManager.cs#L158-L196 (user skin textures lookup) protected override bool AllowHighResolutionSprites => false; + public RealmBackedResourceStore? BeatmapSetResources => FallbackStore as RealmBackedResourceStore; + /// /// Construct a new legacy beatmap skin instance. /// diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs index 0932485349..36561e0d69 100644 --- a/osu.Game/Skinning/RealmBackedResourceStore.cs +++ b/osu.Game/Skinning/RealmBackedResourceStore.cs @@ -16,6 +16,8 @@ namespace osu.Game.Skinning public class RealmBackedResourceStore : ResourceStore where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey { + public event Action? CacheInvalidated; + private Lazy> fileToStoragePathMapping; private readonly Live liveSource; @@ -56,7 +58,11 @@ namespace osu.Game.Skinning private string? getPathForFile(string filename) => fileToStoragePathMapping.Value.GetValueOrDefault(filename.ToLowerInvariant()); - private void invalidateCache() => fileToStoragePathMapping = new Lazy>(initialiseFileCache); + private void invalidateCache() + { + fileToStoragePathMapping = new Lazy>(initialiseFileCache); + CacheInvalidated?.Invoke(); + } private Dictionary initialiseFileCache() => liveSource.PerformRead(source => { diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 3a0d0b16e5..8c287a010e 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -63,6 +63,8 @@ namespace osu.Game.Skinning public string Name { get; } + protected IResourceStore? FallbackStore { get; } + /// /// Construct a new skin. /// @@ -102,6 +104,7 @@ namespace osu.Game.Skinning SkinInfo = skin.ToLiveUnmanaged(); } + FallbackStore = fallbackStore; if (fallbackStore != null) store.AddStore(fallbackStore);