1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-18 10:53:21 +08:00

Merge branch 'master' into ios-beatmap-export

This commit is contained in:
Dean Herbert 2024-12-10 23:59:15 -08:00 committed by GitHub
commit 2a4f596f90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 894 additions and 158 deletions

View File

@ -115,7 +115,7 @@ jobs:
steps:
- name: Check permissions
run: |
ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte)
ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte tsunyoku stanriders)
for i in "${ALLOWED_USERS[@]}"; do
if [[ "${{ github.actor }}" == "$i" ]]; then
exit 0

View File

@ -57,11 +57,22 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
[BackgroundDependencyLoader]
private void load()
{
const string base_lookup = @"hitcircle";
var drawableOsuObject = (DrawableOsuHitObject?)drawableObject;
// As a precondition, ensure that any prefix lookups are run against the skin which is providing "hitcircle".
// This is to correctly handle a case such as:
//
// - Beatmap provides `hitcircle`
// - User skin provides `sliderstartcircle`
//
// In such a case, the `hitcircle` should be used for slider start circles rather than the user's skin override.
var provider = skin.FindProvider(s => s.GetTexture(base_lookup) != null) ?? skin;
// if a base texture for the specified prefix exists, continue using it for subsequent lookups.
// otherwise fall back to the default prefix "hitcircle".
string circleName = (priorityLookupPrefix != null && skin.GetTexture(priorityLookupPrefix) != null) ? priorityLookupPrefix : @"hitcircle";
string circleName = (priorityLookupPrefix != null && provider.GetTexture(priorityLookupPrefix) != null) ? priorityLookupPrefix : base_lookup;
Vector2 maxSize = OsuHitObject.OBJECT_DIMENSIONS * 2;
@ -70,7 +81,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
// expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png.
InternalChildren = new[]
{
CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName)?.WithMaximumSize(maxSize) })
CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = provider.GetTexture(circleName)?.WithMaximumSize(maxSize) })
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -79,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(@$"{circleName}overlay")?.WithMaximumSize(maxSize) })
Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = provider.GetTexture(@$"{circleName}overlay")?.WithMaximumSize(maxSize) })
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -109,9 +109,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
95901, 106450, 116999, 119637, 130186, 140735, 151285,
161834, 164471, 175020, 185570, 196119, 206669, 209306
};
Assert.AreEqual(expectedBookmarks.Length, beatmap.BeatmapInfo.Bookmarks.Length);
Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length);
for (int i = 0; i < expectedBookmarks.Length; i++)
Assert.AreEqual(expectedBookmarks[i], beatmap.BeatmapInfo.Bookmarks[i]);
Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]);
Assert.AreEqual(1.8, beatmap.DistanceSpacing);
Assert.AreEqual(4, beatmap.BeatmapInfo.BeatDivisor);
Assert.AreEqual(4, beatmap.GridSize);

View File

@ -73,9 +73,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
95901, 106450, 116999, 119637, 130186, 140735, 151285,
161834, 164471, 175020, 185570, 196119, 206669, 209306
};
Assert.AreEqual(expectedBookmarks.Length, beatmapInfo.Bookmarks.Length);
Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length);
for (int i = 0; i < expectedBookmarks.Length; i++)
Assert.AreEqual(expectedBookmarks[i], beatmapInfo.Bookmarks[i]);
Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]);
Assert.AreEqual(1.8, beatmap.DistanceSpacing);
Assert.AreEqual(4, beatmapInfo.BeatDivisor);
Assert.AreEqual(4, beatmap.GridSize);

View File

@ -4,11 +4,13 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
@ -28,6 +30,7 @@ using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose.Components.Timeline;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Skinning;
using osu.Game.Storyboards;
using osu.Game.Tests.Resources;
using osuTK;
@ -99,44 +102,15 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("enter compose mode", () => InputManager.Key(Key.F1));
AddUntilStep("wait for timeline load", () => Editor.ChildrenOfType<Timeline>().FirstOrDefault()?.IsLoaded == true);
AddStep("enter setup mode", () => InputManager.Key(Key.F4));
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddAssert("track is virtual", () => Beatmap.Value.Track is TrackVirtual);
AddAssert("switch track to real track", () =>
{
var setup = Editor.ChildrenOfType<SetupScreen>().First();
string temp = TestResources.GetTestBeatmapForImport();
string extractedFolder = $"{temp}_extracted";
Directory.CreateDirectory(extractedFolder);
try
{
using (var zip = ZipArchive.Open(temp))
zip.WriteToDirectory(extractedFolder);
bool success = setup.ChildrenOfType<ResourcesSection>().First().ChangeAudioTrack(new FileInfo(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3")));
// ensure audio file is copied to beatmap as "audio.mp3" rather than original filename.
Assert.That(Beatmap.Value.Metadata.AudioFile == "audio.mp3");
return success;
}
finally
{
File.Delete(temp);
Directory.Delete(extractedFolder, true);
}
});
AddAssert("switch track to real track", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3"));
AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual);
AddUntilStep("track length changed", () => Beatmap.Value.Track.Length > 60000);
AddStep("test play", () => Editor.TestGameplay());
AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null);
AddStep("confirm save", () => InputManager.Key(Key.Number1));
AddUntilStep("wait for return to editor", () => Editor.IsCurrentScreen());
AddAssert("track is still not virtual", () => Beatmap.Value.Track is not TrackVirtual);
@ -635,5 +609,228 @@ namespace osu.Game.Tests.Visual.Editing
return set != null && set.PerformRead(s => s.Beatmaps.Count == 3 && s.Files.Count == 3);
});
}
[Test]
public void TestSingleBackgroundFile()
{
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddAssert("set background", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg"));
createNewDifficulty();
createNewDifficulty();
switchToDifficulty(1);
AddAssert("set background on second diff only", () => setBackground(applyToAllDifficulties: false, expected: "bg (1).jpg"));
AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg"));
switchToDifficulty(0);
AddAssert("set background on first diff only", () => setBackground(applyToAllDifficulties: false, expected: "bg (2).jpg"));
AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (2).jpg"));
AddAssert("set background on all diff", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg"));
AddAssert("all diff uses one background", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.BackgroundFile == "bg.jpg"));
AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg.jpg"));
AddAssert("other files removed", () => !Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg" || f.Filename == "bg (2).jpg"));
}
[Test]
public void TestBackgroundFileChangesPreserveOnEncode()
{
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddAssert("set background", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg"));
createNewDifficulty();
createNewDifficulty();
switchToDifficulty(0);
AddAssert("set different background on all diff", () => setBackgroundDifferentExtension(applyToAllDifficulties: true, expected: "bg.jpeg"));
AddAssert("all diff uses one background", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.BackgroundFile == "bg.jpeg"));
AddAssert("all diff encode same background", () =>
{
return Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b =>
{
var files = new RealmFileStore(Realm, Dependencies.Get<GameHost>().Storage);
using var store = new RealmBackedResourceStore<BeatmapSetInfo>(b.BeatmapSet!.ToLive(Realm), files.Store, Realm);
string[] osu = Encoding.UTF8.GetString(store.Get(b.File!.Filename)).Split(Environment.NewLine);
Assert.That(osu, Does.Contain("0,0,\"bg.jpeg\",0,0"));
return true;
});
});
}
[Test]
public void TestSingleAudioFile()
{
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddAssert("set audio", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3"));
createNewDifficulty();
createNewDifficulty();
switchToDifficulty(1);
AddAssert("set audio on second diff only", () => setAudio(applyToAllDifficulties: false, expected: "audio (1).mp3"));
AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3"));
switchToDifficulty(0);
AddAssert("set audio on first diff only", () => setAudio(applyToAllDifficulties: false, expected: "audio (2).mp3"));
AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (2).mp3"));
AddAssert("set audio on all diff", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3"));
AddAssert("all diff uses one audio", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.AudioFile == "audio.mp3"));
AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio.mp3"));
AddAssert("other files removed", () => !Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3" || f.Filename == "audio (2).mp3"));
}
[Test]
public void TestMultipleBackgroundFiles()
{
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddAssert("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg.jpg"));
createNewDifficulty();
AddAssert("new difficulty uses same background", () => Beatmap.Value.Metadata.BackgroundFile == "bg.jpg");
AddAssert("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg (1).jpg"));
AddAssert("new difficulty uses new background", () => Beatmap.Value.Metadata.BackgroundFile == "bg (1).jpg");
switchToDifficulty(0);
AddAssert("old difficulty uses old background", () => Beatmap.Value.Metadata.BackgroundFile == "bg.jpg");
AddAssert("old background not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg.jpg"));
AddStep("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg.jpg"));
AddAssert("other background not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg"));
}
[Test]
public void TestMultipleAudioFiles()
{
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddAssert("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio.mp3"));
createNewDifficulty();
AddAssert("new difficulty uses same audio", () => Beatmap.Value.Metadata.AudioFile == "audio.mp3");
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddUntilStep("wait for load", () => Editor.ChildrenOfType<SetupScreen>().Any());
AddAssert("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio (1).mp3"));
AddAssert("new difficulty uses new audio", () => Beatmap.Value.Metadata.AudioFile == "audio (1).mp3");
switchToDifficulty(0);
AddAssert("old difficulty uses old audio", () => Beatmap.Value.Metadata.AudioFile == "audio.mp3");
AddAssert("old audio not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio.mp3"));
AddStep("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio.mp3"));
AddAssert("other audio not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3"));
}
private void createNewDifficulty()
{
string? currentDifficulty = null;
AddStep("save", () => Editor.Save());
AddStep("create new difficulty", () =>
{
currentDifficulty = EditorBeatmap.BeatmapInfo.DifficultyName;
Editor.CreateNewDifficulty(new OsuRuleset().RulesetInfo);
});
AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog);
AddStep("confirm creation with no objects", () => DialogOverlay.CurrentDialog!.PerformOkAction());
AddUntilStep("wait for created", () =>
{
string? difficultyName = Editor.ChildrenOfType<EditorBeatmap>().SingleOrDefault()?.BeatmapInfo.DifficultyName;
return difficultyName != null && difficultyName != currentDifficulty;
});
AddUntilStep("wait for editor load", () => Editor.IsLoaded);
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddUntilStep("wait for load", () => Editor.ChildrenOfType<SetupScreen>().Any());
}
private void switchToDifficulty(int index)
{
AddStep("save", () => Editor.Save());
AddStep($"switch to difficulty #{index + 1}", () =>
Editor.SwitchToDifficulty(Beatmap.Value.BeatmapSetInfo.Beatmaps.ElementAt(index)));
AddUntilStep("wait for editor load", () => Editor.IsLoaded);
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddUntilStep("wait for load", () => Editor.ChildrenOfType<SetupScreen>().Any());
}
private bool setBackground(bool applyToAllDifficulties, string expected)
{
var setup = Editor.ChildrenOfType<SetupScreen>().First();
return setFile(TestResources.GetQuickTestBeatmapForImport(), extractedFolder =>
{
bool success = setup.ChildrenOfType<ResourcesSection>().First().ChangeBackgroundImage(
new FileInfo(Path.Combine(extractedFolder, @"machinetop_background.jpg")),
applyToAllDifficulties);
Assert.That(Beatmap.Value.Metadata.BackgroundFile, Is.EqualTo(expected));
return success;
});
}
private bool setBackgroundDifferentExtension(bool applyToAllDifficulties, string expected)
{
var setup = Editor.ChildrenOfType<SetupScreen>().First();
return setFile(TestResources.GetQuickTestBeatmapForImport(), extractedFolder =>
{
File.Move(
Path.Combine(extractedFolder, @"machinetop_background.jpg"),
Path.Combine(extractedFolder, @"machinetop_background.jpeg"));
bool success = setup.ChildrenOfType<ResourcesSection>().First().ChangeBackgroundImage(
new FileInfo(Path.Combine(extractedFolder, @"machinetop_background.jpeg")),
applyToAllDifficulties);
Assert.That(Beatmap.Value.Metadata.BackgroundFile, Is.EqualTo(expected));
return success;
});
}
private bool setAudio(bool applyToAllDifficulties, string expected)
{
var setup = Editor.ChildrenOfType<SetupScreen>().First();
return setFile(TestResources.GetTestBeatmapForImport(), extractedFolder =>
{
bool success = setup.ChildrenOfType<ResourcesSection>().First().ChangeAudioTrack(
new FileInfo(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3")),
applyToAllDifficulties);
Assert.That(Beatmap.Value.Metadata.AudioFile, Is.EqualTo(expected));
return success;
});
}
private bool setFile(string archivePath, Func<string, bool> func)
{
string temp = archivePath;
string extractedFolder = $"{temp}_extracted";
Directory.CreateDirectory(extractedFolder);
try
{
using (var zip = ZipArchive.Open(temp))
zip.WriteToDirectory(extractedFolder);
return func(extractedFolder);
}
finally
{
File.Delete(temp);
Directory.Delete(extractedFolder, true);
}
}
}
}

View File

@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.Editing
beatmap.ControlPointInfo.Add(50000, new DifficultyControlPoint { SliderVelocity = 2 });
beatmap.ControlPointInfo.Add(80000, new EffectControlPoint { KiaiMode = true });
beatmap.ControlPointInfo.Add(110000, new EffectControlPoint { KiaiMode = false });
beatmap.BeatmapInfo.Bookmarks = new[] { 75000, 125000 };
beatmap.Bookmarks = new[] { 75000, 125000 };
beatmap.Breaks.Add(new ManualBreakPeriod(90000, 120000));
editorBeatmap = new EditorBeatmap(beatmap);

View File

@ -9,6 +9,7 @@ using osu.Game.Beatmaps;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
using osu.Game.Screens.Edit.Setup;
using osuTK;
namespace osu.Game.Tests.Visual.UserInterface
@ -89,8 +90,13 @@ namespace osu.Game.Tests.Visual.UserInterface
},
new FormFileSelector
{
Caption = "Audio file",
PlaceholderText = "Select an audio file",
Caption = "File selector",
PlaceholderText = "Select a file",
},
new FormBeatmapFileSelector(true)
{
Caption = "File selector with intermediate choice dialog",
PlaceholderText = "Select a file",
},
new FormColourPalette
{

View File

@ -139,6 +139,8 @@ namespace osu.Game.Beatmaps
public int CountdownOffset { get; set; }
public int[] Bookmarks { get; set; } = Array.Empty<int>();
IBeatmap IBeatmap.Clone() => Clone();
public Beatmap<T> Clone() => (Beatmap<T>)MemberwiseClone();

View File

@ -85,6 +85,7 @@ namespace osu.Game.Beatmaps
beatmap.TimelineZoom = original.TimelineZoom;
beatmap.Countdown = original.Countdown;
beatmap.CountdownOffset = original.CountdownOffset;
beatmap.Bookmarks = original.Bookmarks;
return beatmap;
}

View File

@ -231,9 +231,6 @@ namespace osu.Game.Beatmaps
[Obsolete("Use ScoreManager.GetMaximumAchievableComboAsync instead.")]
public int? MaxCombo { get; set; }
[Ignored]
public int[] Bookmarks { get; set; } = Array.Empty<int>();
public int BeatmapVersion;
public BeatmapInfo Clone() => (BeatmapInfo)this.Detach().MemberwiseClone();

View File

@ -51,7 +51,7 @@ namespace osu.Game.Beatmaps.Formats
}
/// <summary>
/// Whether or not beatmap or runtime offsets should be applied. Defaults on; only disable for testing purposes.
/// Whether beatmap or runtime offsets should be applied. Defaults on; only disable for testing purposes.
/// </summary>
public bool ApplyOffsets = true;
@ -305,7 +305,7 @@ namespace osu.Game.Beatmaps.Formats
switch (pair.Key)
{
case @"Bookmarks":
beatmap.BeatmapInfo.Bookmarks = pair.Value.Split(',').Select(v =>
beatmap.Bookmarks = pair.Value.Split(',').Select(v =>
{
bool result = int.TryParse(v, out int val);
return new { result, val };

View File

@ -110,8 +110,8 @@ namespace osu.Game.Beatmaps.Formats
{
writer.WriteLine("[Editor]");
if (beatmap.BeatmapInfo.Bookmarks.Length > 0)
writer.WriteLine(FormattableString.Invariant($"Bookmarks: {string.Join(',', beatmap.BeatmapInfo.Bookmarks)}"));
if (beatmap.Bookmarks.Length > 0)
writer.WriteLine(FormattableString.Invariant($"Bookmarks: {string.Join(',', beatmap.Bookmarks)}"));
writer.WriteLine(FormattableString.Invariant($"DistanceSpacing: {beatmap.DistanceSpacing}"));
writer.WriteLine(FormattableString.Invariant($"BeatDivisor: {beatmap.BeatmapInfo.BeatDivisor}"));
writer.WriteLine(FormattableString.Invariant($"GridSize: {beatmap.GridSize}"));

View File

@ -107,6 +107,8 @@ namespace osu.Game.Beatmaps
/// </summary>
int CountdownOffset { get; internal set; }
int[] Bookmarks { get; internal set; }
/// <summary>
/// Creates a shallow-clone of this beatmap and returns it.
/// </summary>

View File

@ -9,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Platform;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Overlays.Notifications;
using osu.Game.Utils;
using Realms;
@ -46,7 +47,7 @@ namespace osu.Game.Database
protected LegacyExporter(Storage storage)
{
exportStorage = storage.GetStorageForDirectory(@"exports");
exportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports");
UserFileStorage = storage.GetStorageForDirectory(@"files");
}

View File

@ -242,20 +242,26 @@ namespace osu.Game.Graphics.UserInterfaceV2
Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException();
protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable<FileInfo?> current, string? chooserPath) => new FileChooserPopover(handledExtensions, current, chooserPath);
public Popover GetPopover()
{
var popover = new FileChooserPopover(handledExtensions, Current, initialChooserPath);
var popover = CreatePopover(handledExtensions, Current, initialChooserPath);
popoverState.UnbindBindings();
popoverState.BindTo(popover.State);
return popover;
}
private partial class FileChooserPopover : OsuPopover
protected partial class FileChooserPopover : OsuPopover
{
protected override string PopInSampleName => "UI/overlay-big-pop-in";
protected override string PopOutSampleName => "UI/overlay-big-pop-out";
public FileChooserPopover(string[] handledExtensions, Bindable<FileInfo?> currentFile, string? chooserPath)
private readonly Bindable<FileInfo?> current = new Bindable<FileInfo?>();
protected OsuFileSelector FileSelector;
public FileChooserPopover(string[] handledExtensions, Bindable<FileInfo?> current, string? chooserPath)
: base(false)
{
Child = new Container
@ -264,12 +270,13 @@ namespace osu.Game.Graphics.UserInterfaceV2
// simplest solution to avoid underlying text to bleed through the bottom border
// https://github.com/ppy/osu/pull/30005#issuecomment-2378884430
Padding = new MarginPadding { Bottom = 1 },
Child = new OsuFileSelector(chooserPath, handledExtensions)
Child = FileSelector = new OsuFileSelector(chooserPath, handledExtensions)
{
RelativeSizeAxes = Axes.Both,
CurrentFile = { BindTarget = currentFile }
},
};
this.current.BindTo(current);
}
[BackgroundDependencyLoader]
@ -292,6 +299,19 @@ namespace osu.Game.Graphics.UserInterfaceV2
}
});
}
protected override void LoadComplete()
{
base.LoadComplete();
FileSelector.CurrentFile.ValueChanged += f =>
{
if (f.NewValue != null)
OnFileSelected(f.NewValue);
};
}
protected virtual void OnFileSelected(FileInfo file) => current.Value = file;
}
}
}

View File

@ -61,6 +61,11 @@ namespace osu.Game.IO
TryChangeToCustomStorage(out Error);
}
/// <summary>
/// Returns the <see cref="Storage"/> used for storing exported files.
/// </summary>
public virtual Storage GetExportStorage() => GetStorageForDirectory(@"exports");
/// <summary>
/// Resets the custom storage path, changing the target storage to the default location.
/// </summary>

View File

@ -152,6 +152,10 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.Control, InputKey.Right }, GlobalAction.EditorSeekToNextHitObject),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Left }, GlobalAction.EditorSeekToPreviousSamplePoint),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Right }, GlobalAction.EditorSeekToNextSamplePoint),
new KeyBinding(new[] { InputKey.Control, InputKey.B }, GlobalAction.EditorAddBookmark),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.B }, GlobalAction.EditorRemoveClosestBookmark),
new KeyBinding(new[] { InputKey.Alt, InputKey.Left }, GlobalAction.EditorSeekToPreviousBookmark),
new KeyBinding(new[] { InputKey.Alt, InputKey.Right }, GlobalAction.EditorSeekToNextBookmark),
};
private static IEnumerable<KeyBinding> editorTestPlayKeyBindings => new[]
@ -476,6 +480,18 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridType))]
EditorCycleGridType,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorAddBookmark))]
EditorAddBookmark,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorRemoveClosestBookmark))]
EditorRemoveClosestBookmark,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToPreviousBookmark))]
EditorSeekToPreviousBookmark,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextBookmark))]
EditorSeekToNextBookmark,
}
public enum GlobalActionCategory

View File

@ -198,6 +198,21 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString ClickToSelectBackground => new TranslatableString(getKey(@"click_to_select_background"), @"Click to select a background image");
/// <summary>
/// "Apply this change to all difficulties?"
/// </summary>
public static LocalisableString ApplicationScopeSelectionTitle => new TranslatableString(getKey(@"application_scope_selection_title"), @"Apply this change to all difficulties?");
/// <summary>
/// "Apply to all difficulties"
/// </summary>
public static LocalisableString ApplyToAllDifficulties => new TranslatableString(getKey(@"apply_to_all_difficulties"), @"Apply to all difficulties");
/// <summary>
/// "Only apply to this difficulty"
/// </summary>
public static LocalisableString ApplyToThisDifficulty => new TranslatableString(getKey(@"apply_to_this_difficulty"), @"Only apply to this difficulty");
/// <summary>
/// "Ruleset ({0})"
/// </summary>

View File

@ -154,6 +154,36 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString TimelineShowTicks => new TranslatableString(getKey(@"timeline_show_ticks"), @"Show ticks");
/// <summary>
/// "Bookmarks"
/// </summary>
public static LocalisableString Bookmarks => new TranslatableString(getKey(@"bookmarks"), @"Bookmarks");
/// <summary>
/// "Add bookmark"
/// </summary>
public static LocalisableString AddBookmark => new TranslatableString(getKey(@"add_bookmark"), @"Add bookmark");
/// <summary>
/// "Remove closest bookmark"
/// </summary>
public static LocalisableString RemoveClosestBookmark => new TranslatableString(getKey(@"remove_closest_bookmark"), @"Remove closest bookmark");
/// <summary>
/// "Seek to previous bookmark"
/// </summary>
public static LocalisableString SeekToPreviousBookmark => new TranslatableString(getKey(@"seek_to_previous_bookmark"), @"Seek to previous bookmark");
/// <summary>
/// "Seek to next bookmark"
/// </summary>
public static LocalisableString SeekToNextBookmark => new TranslatableString(getKey(@"seek_to_next_bookmark"), @"Seek to next bookmark");
/// <summary>
/// "Reset bookmarks"
/// </summary>
public static LocalisableString ResetBookmarks => new TranslatableString(getKey(@"reset_bookmarks"), @"Reset bookmarks");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
}

View File

@ -429,6 +429,26 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString EditorSeekToNextSamplePoint => new TranslatableString(getKey(@"editor_seek_to_next_sample_point"), @"Seek to next sample point");
/// <summary>
/// "Add bookmark"
/// </summary>
public static LocalisableString EditorAddBookmark => new TranslatableString(getKey(@"editor_add_bookmark"), @"Add bookmark");
/// <summary>
/// "Remove closest bookmark"
/// </summary>
public static LocalisableString EditorRemoveClosestBookmark => new TranslatableString(getKey(@"editor_remove_closest_bookmark"), @"Remove closest bookmark");
/// <summary>
/// "Seek to previous bookmark"
/// </summary>
public static LocalisableString EditorSeekToPreviousBookmark => new TranslatableString(getKey(@"editor_seek_to_previous_bookmark"), @"Seek to previous bookmark");
/// <summary>
/// "Seek to next bookmark"
/// </summary>
public static LocalisableString EditorSeekToNextBookmark => new TranslatableString(getKey(@"editor_seek_to_next_bookmark"), @"Seek to next bookmark");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -12,7 +12,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
@ -35,7 +34,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
private readonly FontUsage smallFont = OsuFont.GetFont(size: 16);
private readonly FontUsage largeFont = OsuFont.GetFont(size: 22, weight: FontWeight.Light);
private readonly TextColumn totalScoreColumn;
private readonly TotalScoreColumn totalScoreColumn;
private readonly TextColumn accuracyColumn;
private readonly TextColumn maxComboColumn;
private readonly TextColumn ppColumn;
@ -67,7 +66,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
Spacing = new Vector2(margin, 0),
Children = new Drawable[]
{
totalScoreColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersScoreTotal, largeFont, top_columns_min_width),
totalScoreColumn = new TotalScoreColumn(BeatmapsetsStrings.ShowScoreboardHeadersScoreTotal, largeFont, top_columns_min_width),
accuracyColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, largeFont, top_columns_min_width),
maxComboColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersCombo, largeFont, top_columns_min_width)
}
@ -226,7 +225,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
}
}
private partial class TextColumn : InfoColumn, IHasCurrentValue<string>
private partial class TextColumn : InfoColumn
{
private readonly OsuTextFlowContainer text;
@ -249,18 +248,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
}
}
private Bindable<string> current;
public Bindable<string> Current
{
get => current;
set
{
text.Clear();
text.AddText(value.Value, t => t.Current = current = value);
}
}
public TextColumn(LocalisableString title, FontUsage font, float? minWidth = null)
: this(title, new OsuTextFlowContainer(t => t.Font = font)
{
@ -276,6 +263,28 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
}
}
private partial class TotalScoreColumn : TextColumn
{
private readonly BindableWithCurrent<string> current = new BindableWithCurrent<string>();
public TotalScoreColumn(LocalisableString title, FontUsage font, float? minWidth = null)
: base(title, font, minWidth)
{
}
public Bindable<string> Current
{
get => current;
set => current.Current = value;
}
protected override void LoadComplete()
{
base.LoadComplete();
Current.BindValueChanged(_ => Text = current.Value, true);
}
}
private partial class ModsInfoColumn : InfoColumn
{
private readonly FillFlowContainer modsContainer;

View File

@ -245,18 +245,19 @@ namespace osu.Game.Overlays
this.FadeOut(200);
}
public void Dismiss()
public bool Dismiss()
{
if (drawableMedal != null && drawableMedal.State != DisplayState.Full)
{
// if we haven't yet, play out the animation fully
drawableMedal.State = DisplayState.Full;
FinishTransforms(true);
return;
return false;
}
Hide();
Expire();
return true;
}
private partial class BackgroundStrip : Container

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
@ -35,7 +34,7 @@ namespace osu.Game.Overlays
private IAPIProvider api { get; set; } = null!;
private Container<Drawable> medalContainer = null!;
private MedalAnimation? lastAnimation;
private MedalAnimation? currentMedalDisplay;
[BackgroundDependencyLoader]
private void load()
@ -54,11 +53,12 @@ namespace osu.Game.Overlays
{
base.LoadComplete();
OverlayActivationMode.BindValueChanged(val =>
{
if (val.NewValue == OverlayActivation.All && (queuedMedals.Any() || medalContainer.Any() || lastAnimation?.IsLoaded == false))
Show();
}, true);
OverlayActivationMode.BindValueChanged(_ => showNextMedal(), true);
}
public override void Hide()
{
// don't allow hiding the overlay via any method other than our own.
}
private void handleMedalMessages(SocketMessage obj)
@ -83,34 +83,18 @@ namespace osu.Game.Overlays
var medalAnimation = new MedalAnimation(medal);
queuedMedals.Enqueue(medalAnimation);
Logger.Log($"Queueing medal unlock for \"{medal.Name}\" ({queuedMedals.Count} to display)");
if (OverlayActivationMode.Value == OverlayActivation.All)
Scheduler.AddOnce(Show);
}
protected override void Update()
{
base.Update();
if (medalContainer.Any() || lastAnimation?.IsLoaded == false)
return;
if (!queuedMedals.TryDequeue(out lastAnimation))
Schedule(() => LoadComponentAsync(medalAnimation, m =>
{
Logger.Log("All queued medals have been displayed!");
Hide();
return;
}
Logger.Log($"Preparing to display \"{lastAnimation.Medal.Name}\"");
LoadComponentAsync(lastAnimation, medalContainer.Add);
queuedMedals.Enqueue(m);
showNextMedal();
}));
}
protected override bool OnClick(ClickEvent e)
{
lastAnimation?.Dismiss();
progressDisplayByUser();
return true;
}
@ -118,19 +102,54 @@ namespace osu.Game.Overlays
{
if (e.Action == GlobalAction.Back)
{
lastAnimation?.Dismiss();
progressDisplayByUser();
return true;
}
return base.OnPressed(e);
}
private void progressDisplayByUser()
{
// Dismissing may sometimes play out the medal animation rather than immediately dismissing.
if (currentMedalDisplay?.Dismiss() == false)
return;
currentMedalDisplay = null;
showNextMedal();
}
private void showNextMedal()
{
// If already displayed, keep displaying medals regardless of activation mode changes.
if (OverlayActivationMode.Value != OverlayActivation.All && State.Value == Visibility.Hidden)
return;
// A medal is already displaying.
if (currentMedalDisplay != null)
return;
if (queuedMedals.TryDequeue(out currentMedalDisplay))
{
Logger.Log($"Displaying \"{currentMedalDisplay.Medal.Name}\"");
medalContainer.Add(currentMedalDisplay);
Show();
}
else if (State.Value == Visibility.Visible)
{
Logger.Log("All queued medals have been displayed, hiding overlay!");
base.Hide();
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
// this event subscription fires async loads, which hard-fail if `CompositeDrawable.disposalCancellationSource` is canceled, which happens in the base call.
// therefore, unsubscribe from this event early to reduce the chances of a stray event firing at an inconvenient spot.
if (api.IsNotNull())
api.NotificationsClient.MessageReceived -= handleMedalMessages;
base.Dispose(isDisposing);
}
}
}

View File

@ -413,6 +413,12 @@ namespace osu.Game.Rulesets.Difficulty
set => baseBeatmap.CountdownOffset = value;
}
public int[] Bookmarks
{
get => baseBeatmap.Bookmarks;
set => baseBeatmap.Bookmarks = value;
}
#endregion
}
}

View File

@ -2,7 +2,11 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Extensions;
using osu.Game.Graphics;
@ -15,24 +19,69 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
/// </summary>
public partial class BookmarkPart : TimelinePart
{
private readonly BindableList<int> bookmarks = new BindableList<int>();
private DrawablePool<BookmarkVisualisation> pool = null!;
[BackgroundDependencyLoader]
private void load()
{
AddInternal(pool = new DrawablePool<BookmarkVisualisation>(10));
}
protected override void LoadBeatmap(EditorBeatmap beatmap)
{
base.LoadBeatmap(beatmap);
foreach (int bookmark in beatmap.BeatmapInfo.Bookmarks)
Add(new BookmarkVisualisation(bookmark));
bookmarks.UnbindAll();
bookmarks.BindTo(beatmap.Bookmarks);
}
private partial class BookmarkVisualisation : PointVisualisation, IHasTooltip
protected override void LoadComplete()
{
public BookmarkVisualisation(double startTime)
: base(startTime)
base.LoadComplete();
bookmarks.BindCollectionChanged((_, _) =>
{
Clear(disposeChildren: false);
foreach (int bookmark in bookmarks)
Add(pool.Get(v => v.StartTime = bookmark));
}, true);
}
private partial class BookmarkVisualisation : PoolableDrawable, IHasTooltip
{
private int startTime;
public int StartTime
{
get => startTime;
set
{
if (startTime == value)
return;
startTime = value;
X = startTime;
}
}
[BackgroundDependencyLoader]
private void load(OsuColour colours) => Colour = colours.Blue;
private void load(OsuColour colours)
{
RelativePositionAxes = Axes.Both;
RelativeSizeAxes = Axes.Y;
public LocalisableString TooltipText => $"{StartTime.ToEditorFormattedString()} bookmark";
Anchor = Anchor.CentreLeft;
Origin = Anchor.Centre;
Width = PointVisualisation.MAX_WIDTH;
Height = 0.4f;
Colour = colours.Blue;
InternalChild = new FastCircle { RelativeSizeAxes = Axes.Both };
}
public LocalisableString TooltipText => $"{((double)StartTime).ToEditorFormattedString()} bookmark";
}
}
}

View File

@ -422,6 +422,29 @@ namespace osu.Game.Screens.Edit
Items = new MenuItem[]
{
new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime),
new EditorMenuItem(EditorStrings.Bookmarks)
{
Items = new MenuItem[]
{
new EditorMenuItem(EditorStrings.AddBookmark, MenuItemType.Standard, addBookmarkAtCurrentTime)
{
Hotkey = new Hotkey(GlobalAction.EditorAddBookmark),
},
new EditorMenuItem(EditorStrings.RemoveClosestBookmark, MenuItemType.Destructive, removeBookmarksInProximityToCurrentTime)
{
Hotkey = new Hotkey(GlobalAction.EditorRemoveClosestBookmark)
},
new EditorMenuItem(EditorStrings.SeekToPreviousBookmark, MenuItemType.Standard, () => seekBookmark(-1))
{
Hotkey = new Hotkey(GlobalAction.EditorSeekToPreviousBookmark)
},
new EditorMenuItem(EditorStrings.SeekToNextBookmark, MenuItemType.Standard, () => seekBookmark(1))
{
Hotkey = new Hotkey(GlobalAction.EditorSeekToNextBookmark)
},
new EditorMenuItem(EditorStrings.ResetBookmarks, MenuItemType.Destructive, () => editorBeatmap.Bookmarks.Clear())
}
}
}
}
}
@ -753,6 +776,14 @@ namespace osu.Game.Screens.Edit
case GlobalAction.EditorSeekToNextSamplePoint:
seekSamplePoint(1);
return true;
case GlobalAction.EditorSeekToPreviousBookmark:
seekBookmark(-1);
return true;
case GlobalAction.EditorSeekToNextBookmark:
seekBookmark(1);
return true;
}
if (e.Repeat)
@ -760,6 +791,14 @@ namespace osu.Game.Screens.Edit
switch (e.Action)
{
case GlobalAction.EditorAddBookmark:
addBookmarkAtCurrentTime();
return true;
case GlobalAction.EditorRemoveClosestBookmark:
removeBookmarksInProximityToCurrentTime();
return true;
case GlobalAction.EditorCloneSelection:
Clone();
return true;
@ -792,6 +831,19 @@ namespace osu.Game.Screens.Edit
return false;
}
private void addBookmarkAtCurrentTime()
{
int bookmark = (int)clock.CurrentTimeAccurate;
int idx = editorBeatmap.Bookmarks.BinarySearch(bookmark);
if (idx < 0)
editorBeatmap.Bookmarks.Insert(~idx, bookmark);
}
private void removeBookmarksInProximityToCurrentTime()
{
editorBeatmap.Bookmarks.RemoveAll(b => Math.Abs(b - clock.CurrentTimeAccurate) < 2000);
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
@ -1127,6 +1179,16 @@ namespace osu.Game.Screens.Edit
clock.SeekSmoothlyTo(found.StartTime);
}
private void seekBookmark(int direction)
{
int? targetBookmark = direction < 1
? editorBeatmap.Bookmarks.Cast<int?>().LastOrDefault(b => b < clock.CurrentTimeAccurate)
: editorBeatmap.Bookmarks.Cast<int?>().FirstOrDefault(b => b > clock.CurrentTimeAccurate);
if (targetBookmark != null)
clock.SeekSmoothlyTo(targetBookmark.Value);
}
private void seekSamplePoint(int direction)
{
double currentTime = clock.CurrentTimeAccurate;

View File

@ -118,6 +118,14 @@ namespace osu.Game.Screens.Edit
playableBeatmap.Breaks.AddRange(Breaks);
});
Bookmarks = new BindableList<int>(playableBeatmap.Bookmarks);
Bookmarks.BindCollectionChanged((_, _) =>
{
BeginChange();
playableBeatmap.Bookmarks = Bookmarks.OrderBy(x => x).Distinct().ToArray();
EndChange();
});
PreviewTime = new BindableInt(BeatmapInfo.Metadata.PreviewTime);
PreviewTime.BindValueChanged(s =>
{
@ -270,6 +278,14 @@ namespace osu.Game.Screens.Edit
set => PlayableBeatmap.CountdownOffset = value;
}
public readonly BindableList<int> Bookmarks;
int[] IBeatmap.Bookmarks
{
get => PlayableBeatmap.Bookmarks;
set => PlayableBeatmap.Bookmarks = value;
}
public IBeatmap Clone() => (EditorBeatmap)MemberwiseClone();
private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects;

View File

@ -46,6 +46,7 @@ namespace osu.Game.Screens.Edit
processHitObjects(result, () => newBeatmap ??= readBeatmap(newState));
processTimingPoints(() => newBeatmap ??= readBeatmap(newState));
processBreaks(() => newBeatmap ??= readBeatmap(newState));
processBookmarks(() => newBeatmap ??= readBeatmap(newState));
processHitObjectLocalData(() => newBeatmap ??= readBeatmap(newState));
editorBeatmap.EndChange();
}
@ -97,6 +98,27 @@ namespace osu.Game.Screens.Edit
}
}
private void processBookmarks(Func<IBeatmap> getNewBeatmap)
{
var newBookmarks = getNewBeatmap().Bookmarks.ToHashSet();
foreach (int oldBookmark in editorBeatmap.Bookmarks.ToArray())
{
if (newBookmarks.Contains(oldBookmark))
continue;
editorBeatmap.Bookmarks.Remove(oldBookmark);
}
foreach (int newBookmark in newBookmarks)
{
if (editorBeatmap.Bookmarks.Contains(newBookmark))
continue;
editorBeatmap.Bookmarks.Add(newBookmark);
}
}
private void processHitObjects(DiffResult result, Func<IBeatmap> getNewBeatmap)
{
findChangedIndices(result, LegacyDecoder<Beatmap>.Section.HitObjects, out var removedIndices, out var addedIndices);

View File

@ -0,0 +1,155 @@
// 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.Diagnostics;
using System.IO;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osuTK;
using osu.Game.Localisation;
namespace osu.Game.Screens.Edit.Setup
{
/// <summary>
/// A type of <see cref="FormFileSelector"/> dedicated to beatmap resources.
/// </summary>
/// <remarks>
/// This expands on <see cref="FormFileSelector"/> by adding an intermediate step before finalisation
/// to choose whether the selected file should be applied to the current difficulty or all difficulties in the set,
/// the user's choice is saved in <see cref="ApplyToAllDifficulties"/> before the file selection is finalised and propagated to <see cref="FormFileSelector.Current"/>.
/// </remarks>
public partial class FormBeatmapFileSelector : FormFileSelector
{
private readonly bool beatmapHasMultipleDifficulties;
public readonly Bindable<bool> ApplyToAllDifficulties = new Bindable<bool>(true);
public FormBeatmapFileSelector(bool beatmapHasMultipleDifficulties, params string[] handledExtensions)
: base(handledExtensions)
{
this.beatmapHasMultipleDifficulties = beatmapHasMultipleDifficulties;
}
protected override FileChooserPopover CreatePopover(string[] handledExtensions, Bindable<FileInfo?> current, string? chooserPath)
{
var popover = new BeatmapFileChooserPopover(handledExtensions, current, chooserPath, beatmapHasMultipleDifficulties);
popover.ApplyToAllDifficulties.BindTo(ApplyToAllDifficulties);
return popover;
}
private partial class BeatmapFileChooserPopover : FileChooserPopover
{
private readonly bool beatmapHasMultipleDifficulties;
public readonly Bindable<bool> ApplyToAllDifficulties = new Bindable<bool>(true);
private Container selectApplicationScopeContainer = null!;
public BeatmapFileChooserPopover(string[] handledExtensions, Bindable<FileInfo?> current, string? chooserPath, bool beatmapHasMultipleDifficulties)
: base(handledExtensions, current, chooserPath)
{
this.beatmapHasMultipleDifficulties = beatmapHasMultipleDifficulties;
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, OsuColour colours)
{
Add(selectApplicationScopeContainer = new InputBlockingContainer
{
Alpha = 0f,
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = colourProvider.Background6.Opacity(0.9f),
RelativeSizeAxes = Axes.Both,
},
new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Masking = true,
CornerRadius = 10f,
AutoSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = colourProvider.Background5,
RelativeSizeAxes = Axes.Both,
},
new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, 10f),
Margin = new MarginPadding(30),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = EditorSetupStrings.ApplicationScopeSelectionTitle,
Margin = new MarginPadding { Bottom = 20f },
},
new RoundedButton
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 300f,
Text = EditorSetupStrings.ApplyToAllDifficulties,
Action = () =>
{
ApplyToAllDifficulties.Value = true;
updateFileSelection();
},
BackgroundColour = colours.Red2,
},
new RoundedButton
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 300f,
Text = EditorSetupStrings.ApplyToThisDifficulty,
Action = () =>
{
ApplyToAllDifficulties.Value = false;
updateFileSelection();
},
},
}
}
}
},
}
});
}
protected override void OnFileSelected(FileInfo file)
{
if (beatmapHasMultipleDifficulties)
selectApplicationScopeContainer.FadeIn(200, Easing.InQuint);
else
base.OnFileSelected(file);
}
private void updateFileSelection()
{
Debug.Assert(FileSelector.CurrentFile.Value != null);
base.OnFileSelected(FileSelector.CurrentFile.Value);
}
}
}
}

View File

@ -1,23 +1,25 @@
// 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.IO;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osu.Game.Localisation;
using osu.Game.Models;
using osu.Game.Utils;
namespace osu.Game.Screens.Edit.Setup
{
public partial class ResourcesSection : SetupSection
{
private FormFileSelector audioTrackChooser = null!;
private FormFileSelector backgroundChooser = null!;
private FormBeatmapFileSelector audioTrackChooser = null!;
private FormBeatmapFileSelector backgroundChooser = null!;
public override LocalisableString Title => EditorSetupStrings.ResourcesHeader;
@ -30,9 +32,6 @@ namespace osu.Game.Screens.Edit.Setup
[Resolved]
private IBindable<WorkingBeatmap> working { get; set; } = null!;
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
[Resolved]
private Editor? editor { get; set; }
@ -47,14 +46,16 @@ namespace osu.Game.Screens.Edit.Setup
Height = 110,
};
bool beatmapHasMultipleDifficulties = working.Value.BeatmapSetInfo.Beatmaps.Count > 1;
Children = new Drawable[]
{
backgroundChooser = new FormFileSelector(SupportedExtensions.IMAGE_EXTENSIONS)
backgroundChooser = new FormBeatmapFileSelector(beatmapHasMultipleDifficulties, SupportedExtensions.IMAGE_EXTENSIONS)
{
Caption = GameplaySettingsStrings.BackgroundHeader,
PlaceholderText = EditorSetupStrings.ClickToSelectBackground,
},
audioTrackChooser = new FormFileSelector(SupportedExtensions.AUDIO_EXTENSIONS)
audioTrackChooser = new FormBeatmapFileSelector(beatmapHasMultipleDifficulties, SupportedExtensions.AUDIO_EXTENSIONS)
{
Caption = EditorSetupStrings.AudioTrack,
PlaceholderText = EditorSetupStrings.ClickToSelectTrack,
@ -73,75 +74,110 @@ namespace osu.Game.Screens.Edit.Setup
audioTrackChooser.Current.BindValueChanged(audioTrackChanged);
}
public bool ChangeBackgroundImage(FileInfo source)
public bool ChangeBackgroundImage(FileInfo source, bool applyToAllDifficulties)
{
if (!source.Exists)
return false;
var set = working.Value.BeatmapSetInfo;
changeResource(source, applyToAllDifficulties, @"bg",
metadata => metadata.BackgroundFile,
(metadata, name) => metadata.BackgroundFile = name);
var destination = new FileInfo($@"bg{source.Extension}");
// remove the previous background for now.
// in the future we probably want to check if this is being used elsewhere (other difficulties?)
var oldFile = set.GetFile(working.Value.Metadata.BackgroundFile);
using (var stream = source.OpenRead())
{
if (oldFile != null)
beatmaps.DeleteFile(set, oldFile);
beatmaps.AddFile(set, stream, destination.Name);
}
editorBeatmap.SaveState();
working.Value.Metadata.BackgroundFile = destination.Name;
headerBackground.UpdateBackground();
editor?.ApplyToBackground(bg => bg.RefreshBackground());
return true;
}
public bool ChangeAudioTrack(FileInfo source)
public bool ChangeAudioTrack(FileInfo source, bool applyToAllDifficulties)
{
if (!source.Exists)
return false;
changeResource(source, applyToAllDifficulties, @"audio",
metadata => metadata.AudioFile,
(metadata, name) => metadata.AudioFile = name);
music.ReloadCurrentTrack();
return true;
}
private void changeResource(FileInfo source, bool applyToAllDifficulties, string baseFilename, Func<BeatmapMetadata, string> readFilename, Action<BeatmapMetadata, string> writeFilename)
{
var set = working.Value.BeatmapSetInfo;
var beatmap = working.Value.BeatmapInfo;
var destination = new FileInfo($@"audio{source.Extension}");
var otherBeatmaps = set.Beatmaps.Where(b => !b.Equals(beatmap));
// remove the previous audio track for now.
// in the future we probably want to check if this is being used elsewhere (other difficulties?)
var oldFile = set.GetFile(working.Value.Metadata.AudioFile);
using (var stream = source.OpenRead())
// First, clean up files which will no longer be used.
if (applyToAllDifficulties)
{
if (oldFile != null)
beatmaps.DeleteFile(set, oldFile);
foreach (var b in set.Beatmaps)
{
if (set.GetFile(readFilename(b.Metadata)) is RealmNamedFileUsage otherExistingFile)
beatmaps.DeleteFile(set, otherExistingFile);
}
}
else
{
RealmNamedFileUsage? oldFile = set.GetFile(readFilename(working.Value.Metadata));
beatmaps.AddFile(set, stream, destination.Name);
if (oldFile != null)
{
bool oldFileUsedInOtherDiff = otherBeatmaps
.Any(b => readFilename(b.Metadata) == oldFile.Filename);
if (!oldFileUsedInOtherDiff)
beatmaps.DeleteFile(set, oldFile);
}
}
working.Value.Metadata.AudioFile = destination.Name;
// Choose a new filename that doesn't clash with any other existing files.
string newFilename = $"{baseFilename}{source.Extension}";
editorBeatmap.SaveState();
music.ReloadCurrentTrack();
if (set.GetFile(newFilename) != null)
{
string[] existingFilenames = set.Files.Select(f => f.Filename).Where(f =>
f.StartsWith(baseFilename, StringComparison.OrdinalIgnoreCase) &&
f.EndsWith(source.Extension, StringComparison.OrdinalIgnoreCase)).ToArray();
newFilename = NamingUtils.GetNextBestFilename(existingFilenames, $@"{baseFilename}{source.Extension}");
}
return true;
using (var stream = source.OpenRead())
beatmaps.AddFile(set, stream, newFilename);
if (applyToAllDifficulties)
{
foreach (var b in otherBeatmaps)
{
// This operation is quite expensive, so only perform it if required.
if (readFilename(b.Metadata) == newFilename) continue;
writeFilename(b.Metadata, newFilename);
// save the difficulty to re-encode the .osu file, updating any reference of the old filename.
//
// note that this triggers a full save flow, including triggering a difficulty calculation.
// this is not a cheap operation and should be reconsidered in the future.
var beatmapWorking = beatmaps.GetWorkingBeatmap(b);
beatmaps.Save(b, beatmapWorking.Beatmap, beatmapWorking.GetSkin());
}
}
writeFilename(beatmap.Metadata, newFilename);
// editor change handler cannot be aware of any file changes or other difficulties having their metadata modified.
// for simplicity's sake, trigger a save when changing any resource to ensure the change is correctly saved.
editor?.Save();
}
private void backgroundChanged(ValueChangedEvent<FileInfo?> file)
{
if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue))
if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue, backgroundChooser.ApplyToAllDifficulties.Value))
backgroundChooser.Current.Value = file.OldValue;
}
private void audioTrackChanged(ValueChangedEvent<FileInfo?> file)
{
if (file.NewValue == null || !ChangeAudioTrack(file.NewValue))
if (file.NewValue == null || !ChangeAudioTrack(file.NewValue, audioTrackChooser.ApplyToAllDifficulties.Value))
audioTrackChooser.Current.Value = file.OldValue;
}
}

View File

@ -106,9 +106,12 @@ namespace osu.Game.Screens.Menu
foreach (var source in amplitudeSources)
addAmplitudesFromSource(source);
float kiaiMultiplier = beatSyncProvider.CheckIsKiaiTime() ? 1 : 0.5f;
for (int i = 0; i < bars_per_visualiser; i++)
{
float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (beatSyncProvider.CheckIsKiaiTime() ? 1 : 0.5f);
float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * kiaiMultiplier;
if (targetAmplitude > frequencyAmplitudes[i])
frequencyAmplitudes[i] = targetAmplitude;
}

View File

@ -68,6 +68,8 @@
<array>
<string>sh.ppy.osu.items</string>
</array>
<key>UTTypeDescription</key>
<string>osu! replay</string>
<key>UTTypeIdentifier</key>
<string>sh.ppy.osu.osr</string>
<key>UTTypeTagSpecification</key>
@ -81,6 +83,8 @@
<array>
<string>sh.ppy.osu.items</string>
</array>
<key>UTTypeDescription</key>
<string>osu! skin</string>
<key>UTTypeIdentifier</key>
<string>sh.ppy.osu.osk</string>
<key>UTTypeTagSpecification</key>
@ -94,6 +98,8 @@
<array>
<string>sh.ppy.osu.items</string>
</array>
<key>UTTypeDescription</key>
<string>osu! beatmap</string>
<key>UTTypeIdentifier</key>
<string>sh.ppy.osu.osz</string>
<key>UTTypeTagSpecification</key>
@ -107,6 +113,8 @@
<array>
<string>sh.ppy.osu.items</string>
</array>
<key>UTTypeDescription</key>
<string>osu! beatmap</string>
<key>UTTypeIdentifier</key>
<string>sh.ppy.osu.olz</string>
<key>UTTypeTagSpecification</key>

View File

@ -5,6 +5,8 @@ using System;
using Foundation;
using Microsoft.Maui.Devices;
using osu.Framework.Graphics;
using osu.Framework.iOS;
using osu.Framework.Platform;
using osu.Game;
using osu.Game.Updater;
using osu.Game.Utils;
@ -19,6 +21,8 @@ namespace osu.iOS
protected override BatteryInfo CreateBatteryInfo() => new IOSBatteryInfo();
protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorageIOS((IOSGameHost)host, defaultStorage);
protected override Edges SafeAreaOverrideEdges =>
// iOS shows a home indicator at the bottom, and adds a safe area to account for this.
// Because we have the home indicator (mostly) hidden we don't really care about drawing in this region.

23
osu.iOS/OsuStorageIOS.cs Normal file
View File

@ -0,0 +1,23 @@
// 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.IO;
using osu.Framework.iOS;
using osu.Framework.Platform;
using osu.Game.IO;
namespace osu.iOS
{
public class OsuStorageIOS : OsuStorage
{
private readonly IOSGameHost host;
public OsuStorageIOS(IOSGameHost host, Storage defaultStorage)
: base(host, defaultStorage)
{
this.host = host;
}
public override Storage GetExportStorage() => new IOSStorage(Path.GetTempPath(), host);
}
}