1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-19 03:11:23 +08:00

Add way to add/remove custom beatmap samples to setup screen

Among others, this features a scary-looking change wherein
`WorkingBeatmap` now exposes a `RealmAccess` via
`IStorageResourceProvider`. This is necessary to make
`RealmBackedResourceStore` actually start firing callbacks when the
edited beatmap's skin is modified by adding new samples to it.
This commit is contained in:
Bartłomiej Dach
2025-12-22 13:21:59 +01:00
Unverified
parent 57cbe20c12
commit 334a54e6f7
10 changed files with 248 additions and 21 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ namespace osu.Game.Beatmaps
protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> 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);
+4 -2
View File
@@ -47,12 +47,13 @@ namespace osu.Game.Beatmaps
private readonly LargeTextureStore beatmapPanelTextureStore;
private readonly ITrackStore trackStore;
private readonly IResourceStore<byte[]> files;
private readonly RealmAccess realm;
[CanBeNull]
private readonly GameHost host;
public WorkingBeatmapCache(ITrackStore trackStore, AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> 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<byte[]> IStorageResourceProvider.Files => files;
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
@@ -42,7 +42,7 @@ namespace osu.Game.Screens.Edit.Components
set => current.Current = value;
}
public Func<FileInfo, string>? SampleAddRequested { get; init; }
public Func<FileInfo, string, string>? SampleAddRequested { get; init; }
public Action<string>? SampleRemoveRequested { get; init; }
private readonly BindableWithCurrent<EditorBeatmapSkin.SampleSet?> current = new BindableWithCurrent<EditorBeatmapSkin.SampleSet?>();
@@ -194,7 +194,7 @@ namespace osu.Game.Screens.Edit.Components
/// <summary>
/// Invoked when a new sample is selected via this button.
/// </summary>
public Func<FileInfo, string>? SampleAddRequested { get; init; }
public Func<FileInfo, string, string>? SampleAddRequested { get; init; }
/// <summary>
/// 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()
+8 -2
View File
@@ -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);
}
}
}
+40 -11
View File
@@ -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
/// <summary>
/// A beatmap skin which is being edited.
/// </summary>
public class EditorBeatmapSkin : ISkin
public class EditorBeatmapSkin : ISkin, IDisposable
{
public event Action? BeatmapSkinChanged;
/// <summary>
/// The underlying beatmap skin.
/// </summary>
protected internal readonly Skin Skin;
protected internal readonly LegacyBeatmapSkin Skin;
/// <summary>
/// The combo colours of this skin.
@@ -33,7 +34,7 @@ namespace osu.Game.Screens.Edit
/// </summary>
public BindableList<Colour4> 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<int> indices = new HashSet<int>();
Dictionary<int, SampleSet> sampleSets = new Dictionary<int, SampleSet>();
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
@@ -0,0 +1,144 @@
// 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 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<EditorBeatmapSkin.SampleSet?>, 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<int?> onCommit;
private int? committedIndex;
private LabelledNumberBox numberBox = null!;
public NewSampleSetPopover(int currentLargestIndex, Action<int?> 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();
}
}
}
}
@@ -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<EditorBeatmapSkin.SampleSet?> currentSampleSet = new Bindable<EditorBeatmapSkin.SampleSet?>();
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);
+2
View File
@@ -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<BeatmapSetInfo>? BeatmapSetResources => FallbackStore as RealmBackedResourceStore<BeatmapSetInfo>;
/// <summary>
/// Construct a new legacy beatmap skin instance.
/// </summary>
@@ -16,6 +16,8 @@ namespace osu.Game.Skinning
public class RealmBackedResourceStore<T> : ResourceStore<byte[]>
where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey
{
public event Action? CacheInvalidated;
private Lazy<Dictionary<string, string>> fileToStoragePathMapping;
private readonly Live<T> 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<Dictionary<string, string>>(initialiseFileCache);
private void invalidateCache()
{
fileToStoragePathMapping = new Lazy<Dictionary<string, string>>(initialiseFileCache);
CacheInvalidated?.Invoke();
}
private Dictionary<string, string> initialiseFileCache() => liveSource.PerformRead(source =>
{
+3
View File
@@ -63,6 +63,8 @@ namespace osu.Game.Skinning
public string Name { get; }
protected IResourceStore<byte[]>? FallbackStore { get; }
/// <summary>
/// Construct a new skin.
/// </summary>
@@ -102,6 +104,7 @@ namespace osu.Game.Skinning
SkinInfo = skin.ToLiveUnmanaged();
}
FallbackStore = fallbackStore;
if (fallbackStore != null)
store.AddStore(fallbackStore);