mirror of
https://github.com/ppy/osu.git
synced 2026-05-13 19:54:15 +08:00
18d4ba5874
Most of this is as everywhere else, but there's also interesting code inspection fixes from the InspectCode bump, so I'll talk about that a little. ## [Fix suspicious equality in `Hotkey`](https://github.com/ppy/osu/commit/948136e49e88a721827d54e51c5759fe9aca811d) Inspection: https://www.jetbrains.com/help/resharper/TypeWithSuspiciousEqualityIsUsedInRecord.Global.html Pretty annoying to fix, nullable array types are a pain. Does look legit though. ## [Fix `StarDifficulty` using inefficient struct equality](https://github.com/ppy/osu/commit/2db775ebb0bb9f18de67677ef84b993465d26545) Inspection: https://www.jetbrains.com/help/resharper/DefaultStructEqualityIsUsed.Global.html This is a dodgy one because there's no real sane way to define equality on `StarDifficulty` now that it has difficulty and performance attributes jammed into it. So I just basically shut the inspection up with a `record` modifier and move on. Unclear where the equality is used precisely. It's from a global inspection. F12 is very unhelpful when trying to track down usages of `Equals()`. We definitely have `Bindable<StarDifficulty>` instances and those do use equality. Maybe more than that. ## [Use `nameof` expressions to reference enum member names](https://github.com/ppy/osu/commit/aa08175c803bc725f3b15a92174dfe6d1b812d91) Inspection: https://www.jetbrains.com/help/resharper/CanSimplifyDictionaryRemovingWithSingleCall.html Pretty quaint. ## [Prefer using concrete values over `default` or `new()`](https://github.com/ppy/osu/commit/b21ee08d7748be10d42268d5c2eb77369026545d) Inspection: https://www.jetbrains.com/help/resharper/PreferConcreteValueOverDefault.html I could see this one going both ways, but I'm kinda sold on this inspection. Explicit is always better. Saves some allocations in the `CancellationToken` cases as well. ## [Explicitly call `.AsEnumerable()` in some realm usages](https://github.com/ppy/osu/commit/c8ce1ecd42b9d8abb8b9e2ab93d471f463e80401) Inspection: https://www.jetbrains.com/help/resharper/PossibleUnintendedQueryableAsEnumerable.html Not fully sold on this one but it's quick and simple so might as well. ## [Simplify dictionary removal with single `.Remove()` call](https://github.com/ppy/osu/commit/5964ceccea900302df726b7a8ecbf6b74eb2e427) Inspection: https://www.jetbrains.com/help/resharper/CanSimplifyDictionaryRemovingWithSingleCall.html Not much to say.
379 lines
14 KiB
C#
379 lines
14 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
#nullable disable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using JetBrains.Annotations;
|
|
using osu.Framework.Audio;
|
|
using osu.Framework.Audio.Track;
|
|
using osu.Framework.Extensions;
|
|
using osu.Framework.Graphics.Textures;
|
|
using osu.Framework.Logging;
|
|
using osu.Game.Overlays;
|
|
using osu.Game.Rulesets;
|
|
using osu.Game.Rulesets.Mods;
|
|
using osu.Game.Rulesets.UI;
|
|
using osu.Game.Skinning;
|
|
using osu.Game.Storyboards;
|
|
|
|
namespace osu.Game.Beatmaps
|
|
{
|
|
public abstract class WorkingBeatmap : IWorkingBeatmap
|
|
{
|
|
public readonly BeatmapInfo BeatmapInfo;
|
|
public readonly BeatmapSetInfo BeatmapSetInfo;
|
|
|
|
// TODO: remove once the fallback lookup is not required (and access via `working.BeatmapInfo.Metadata` directly).
|
|
public BeatmapMetadata Metadata => BeatmapInfo.Metadata;
|
|
|
|
public Storyboard Storyboard => storyboard.Value;
|
|
|
|
public ISkin Skin => skin.Value;
|
|
|
|
private AudioManager audioManager { get; }
|
|
|
|
private CancellationTokenSource loadCancellationSource = new CancellationTokenSource();
|
|
|
|
private readonly object beatmapFetchLock = new object();
|
|
|
|
private readonly Lazy<Storyboard> storyboard;
|
|
private readonly Lazy<ISkin> skin;
|
|
|
|
private Track track; // track is not Lazy as we allow transferring and loading multiple times.
|
|
private Waveform waveform; // waveform is also not Lazy as the track may change.
|
|
|
|
protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager)
|
|
{
|
|
this.audioManager = audioManager;
|
|
|
|
BeatmapInfo = beatmapInfo;
|
|
BeatmapSetInfo = beatmapInfo.BeatmapSet ?? new BeatmapSetInfo();
|
|
|
|
storyboard = new Lazy<Storyboard>(GetStoryboard);
|
|
skin = new Lazy<ISkin>(GetSkin);
|
|
}
|
|
|
|
#region Resource getters
|
|
|
|
protected virtual Waveform GetWaveform() => new Waveform(null);
|
|
|
|
protected virtual Storyboard GetStoryboard() => new Storyboard
|
|
{
|
|
BeatmapInfo = BeatmapInfo,
|
|
Beatmap = Beatmap,
|
|
};
|
|
|
|
protected abstract IBeatmap GetBeatmap();
|
|
public abstract Texture GetBackground();
|
|
public virtual Texture GetPanelBackground() => GetBackground();
|
|
protected abstract Track GetBeatmapTrack();
|
|
|
|
/// <summary>
|
|
/// Creates a new skin instance for this beatmap.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This should only be called externally in scenarios where it is explicitly desired to get a new instance of a skin
|
|
/// (e.g. for editing purposes, to avoid state pollution).
|
|
/// For standard reading purposes, <see cref="Skin"/> should always be used directly.
|
|
/// </remarks>
|
|
protected internal abstract ISkin GetSkin();
|
|
|
|
#endregion
|
|
|
|
#region Async load control
|
|
|
|
public void BeginAsyncLoad() => loadBeatmapAsync();
|
|
|
|
public void CancelAsyncLoad()
|
|
{
|
|
lock (beatmapFetchLock)
|
|
{
|
|
loadCancellationSource?.Cancel();
|
|
loadCancellationSource = new CancellationTokenSource();
|
|
|
|
if (beatmapLoadTask?.IsCompleted != true)
|
|
beatmapLoadTask = null;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Track
|
|
|
|
public virtual bool TrackLoaded => track != null;
|
|
|
|
public Track LoadTrack()
|
|
{
|
|
track = GetBeatmapTrack() ?? GetVirtualTrack(1000);
|
|
|
|
// the track may have changed, recycle the current waveform.
|
|
waveform?.Dispose();
|
|
waveform = null;
|
|
|
|
return track;
|
|
}
|
|
|
|
public void PrepareTrackForPreview(bool looping, double? offsetFromPreviewPoint = null)
|
|
{
|
|
Track.Looping = looping;
|
|
Track.RestartPoint = Metadata.PreviewTime;
|
|
|
|
if (!Track.IsLoaded)
|
|
{
|
|
// force length to be populated (https://github.com/ppy/osu-framework/issues/4202)
|
|
Track.Seek(Track.CurrentTime);
|
|
}
|
|
|
|
if (Track.RestartPoint < 0 || Track.RestartPoint > Track.Length)
|
|
Track.RestartPoint = 0.4f * Track.Length;
|
|
|
|
offsetFromPreviewPoint ??= -MusicController.TRACK_FADE_IN_TIME;
|
|
|
|
Track.RestartPoint = Math.Clamp(Track.RestartPoint + offsetFromPreviewPoint.Value, 0, Track.Length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to transfer the audio track to a target working beatmap, if valid for transferring.
|
|
/// Used as an optimisation to avoid reload / track swap across difficulties in the same beatmap set.
|
|
/// </summary>
|
|
/// <param name="target">The target working beatmap to transfer this track to.</param>
|
|
/// <returns>Whether the track has been transferred to the <paramref name="target"/>.</returns>
|
|
public virtual bool TryTransferTrack([NotNull] WorkingBeatmap target)
|
|
{
|
|
if (BeatmapInfo?.AudioEquals(target.BeatmapInfo) != true || Track.IsDummyDevice)
|
|
return false;
|
|
|
|
target.track = Track;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the loaded audio track instance. <see cref="LoadTrack"/> must have first been called.
|
|
/// This generally happens via MusicController when changing the global beatmap.
|
|
/// </summary>
|
|
[NotNull]
|
|
public Track Track
|
|
{
|
|
get
|
|
{
|
|
if (!TrackLoaded)
|
|
throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}.");
|
|
|
|
return track;
|
|
}
|
|
}
|
|
|
|
protected Track GetVirtualTrack(double emptyLength = 0)
|
|
{
|
|
const double excess_length = 1000;
|
|
|
|
double length = (BeatmapInfo?.Length + excess_length) ?? emptyLength;
|
|
|
|
return audioManager.Tracks.GetVirtual(length);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Waveform
|
|
|
|
public Waveform Waveform => waveform ??= GetWaveform();
|
|
|
|
#endregion
|
|
|
|
#region Beatmap
|
|
|
|
public virtual bool BeatmapLoaded
|
|
{
|
|
get
|
|
{
|
|
lock (beatmapFetchLock)
|
|
return beatmapLoadTask?.IsCompleted ?? false;
|
|
}
|
|
}
|
|
|
|
public IBeatmap Beatmap
|
|
{
|
|
get
|
|
{
|
|
try
|
|
{
|
|
// TODO: This is a touch expensive and can become an issue if being accessed every Update call.
|
|
// Optimally we would not involve the async flow if things are already loaded.
|
|
return loadBeatmapAsync().GetResultSafely();
|
|
}
|
|
catch (AggregateException ae)
|
|
{
|
|
// This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load
|
|
if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException)
|
|
return null;
|
|
|
|
Logger.Error(ae, $"Beatmap failed to load ({BeatmapInfo})");
|
|
return null;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Logger.Error(e, $"Beatmap failed to load ({BeatmapInfo})");
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private Task<IBeatmap> beatmapLoadTask;
|
|
|
|
private Task<IBeatmap> loadBeatmapAsync()
|
|
{
|
|
lock (beatmapFetchLock)
|
|
{
|
|
return beatmapLoadTask ??= Task.Factory.StartNew(() =>
|
|
{
|
|
// Todo: Handle cancellation during beatmap parsing
|
|
var b = GetBeatmap() ?? new Beatmap();
|
|
|
|
// Copy across values of key properties for which the database-backed model has data that the decoded beatmap isn't going to.
|
|
b.BeatmapInfo.ID = BeatmapInfo.ID;
|
|
b.BeatmapInfo.UserSettings = BeatmapInfo.UserSettings;
|
|
b.BeatmapInfo.BeatmapSet = BeatmapInfo.BeatmapSet;
|
|
b.BeatmapInfo.Status = BeatmapInfo.Status;
|
|
b.BeatmapInfo.OnlineID = BeatmapInfo.OnlineID;
|
|
b.BeatmapInfo.OnlineMD5Hash = BeatmapInfo.OnlineMD5Hash;
|
|
b.BeatmapInfo.LastLocalUpdate = BeatmapInfo.LastLocalUpdate;
|
|
b.BeatmapInfo.LastOnlineUpdate = BeatmapInfo.LastOnlineUpdate;
|
|
b.BeatmapInfo.LastPlayed = BeatmapInfo.LastPlayed;
|
|
b.BeatmapInfo.EditorTimestamp = BeatmapInfo.EditorTimestamp;
|
|
b.BeatmapInfo.StarRating = BeatmapInfo.StarRating; // this could be recomputed in the decoding process but it's a bit annoying to do.
|
|
|
|
return b;
|
|
}, loadCancellationSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Playable beatmap
|
|
|
|
public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList<Mod> mods = null)
|
|
{
|
|
try
|
|
{
|
|
using (var cancellationTokenSource = new CancellationTokenSource(10_000))
|
|
{
|
|
// don't apply the default timeout when debugger is attached (may be breakpointing / debugging).
|
|
return GetPlayableBeatmap(ruleset, mods ?? Array.Empty<Mod>(), Debugger.IsAttached ? CancellationToken.None : cancellationTokenSource.Token);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw new BeatmapLoadTimeoutException(BeatmapInfo);
|
|
}
|
|
}
|
|
|
|
public virtual IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList<Mod> mods, CancellationToken token)
|
|
{
|
|
var rulesetInstance = ruleset.CreateInstance();
|
|
|
|
if (rulesetInstance == null)
|
|
throw new RulesetLoadException("Creating ruleset instance failed when attempting to create playable beatmap.");
|
|
|
|
IBeatmapConverter converter = CreateBeatmapConverter(Beatmap, rulesetInstance);
|
|
|
|
// Check if the beatmap can be converted
|
|
if (Beatmap.HitObjects.Count > 0 && !converter.CanConvert())
|
|
throw new BeatmapInvalidForRulesetException($"{nameof(Beatmaps.Beatmap)} can not be converted for the ruleset (ruleset: {ruleset.InstantiationInfo}, converter: {converter}).");
|
|
|
|
// Apply conversion mods
|
|
foreach (var mod in mods.OfType<IApplicableToBeatmapConverter>())
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
mod.ApplyToBeatmapConverter(converter);
|
|
}
|
|
|
|
// Convert
|
|
IBeatmap converted = converter.Convert(token);
|
|
|
|
// Apply conversion mods to the result
|
|
foreach (var mod in mods.OfType<IApplicableAfterBeatmapConversion>())
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
mod.ApplyToBeatmap(converted);
|
|
}
|
|
|
|
// Apply difficulty mods
|
|
if (mods.Any(m => m is IApplicableToDifficulty))
|
|
{
|
|
foreach (var mod in mods.OfType<IApplicableToDifficulty>())
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
mod.ApplyToDifficulty(converted.Difficulty);
|
|
}
|
|
}
|
|
|
|
var processor = rulesetInstance.CreateBeatmapProcessor(converted);
|
|
|
|
if (processor != null)
|
|
{
|
|
foreach (var mod in mods.OfType<IApplicableToBeatmapProcessor>())
|
|
mod.ApplyToBeatmapProcessor(processor);
|
|
|
|
processor.PreProcess();
|
|
}
|
|
|
|
// Compute default values for hitobjects, including creating nested hitobjects in-case they're needed
|
|
foreach (var obj in converted.HitObjects)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
obj.ApplyDefaults(converted.ControlPointInfo, converted.Difficulty, token);
|
|
}
|
|
|
|
foreach (var mod in mods.OfType<IApplicableToHitObject>())
|
|
{
|
|
foreach (var obj in converted.HitObjects)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
mod.ApplyToHitObject(obj);
|
|
}
|
|
}
|
|
|
|
processor?.PostProcess();
|
|
|
|
foreach (var mod in mods.OfType<IApplicableToBeatmap>())
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
mod.ApplyToBeatmap(converted);
|
|
}
|
|
|
|
return converted;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a <see cref="IBeatmapConverter"/> to convert a <see cref="IBeatmap"/> for a specified <see cref="Ruleset"/>.
|
|
/// </summary>
|
|
/// <param name="beatmap">The <see cref="IBeatmap"/> to be converted.</param>
|
|
/// <param name="ruleset">The <see cref="Ruleset"/> for which <paramref name="beatmap"/> should be converted.</param>
|
|
/// <returns>The applicable <see cref="IBeatmapConverter"/>.</returns>
|
|
protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap);
|
|
|
|
#endregion
|
|
|
|
public override string ToString() => BeatmapInfo.ToString();
|
|
|
|
public abstract Stream GetStream(string storagePath);
|
|
|
|
IBeatmapInfo IWorkingBeatmap.BeatmapInfo => BeatmapInfo;
|
|
|
|
private class BeatmapLoadTimeoutException : TimeoutException
|
|
{
|
|
public BeatmapLoadTimeoutException(BeatmapInfo beatmapInfo)
|
|
: base($"Timed out while loading beatmap ({beatmapInfo}).")
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|