Merge branch 'refactor-combo-colour-retrieval' into legacy-beatmap-combo-offset

This commit is contained in:
Salman Ahmed
2021-07-20 10:11:52 +03:00
1214 changed files with 36441 additions and 11055 deletions

View File

@ -8,11 +8,11 @@ namespace osu.Game.Rulesets.Difficulty
{
public class DifficultyAttributes
{
public Mod[] Mods;
public Skill[] Skills;
public Mod[] Mods { get; set; }
public Skill[] Skills { get; set; }
public double StarRating;
public int MaxCombo;
public double StarRating { get; set; }
public int MaxCombo { get; set; }
public DifficultyAttributes()
{

View File

@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Difficulty
/// <returns>A structure describing the difficulty of the beatmap.</returns>
public DifficultyAttributes Calculate(params Mod[] mods)
{
mods = mods.Select(m => m.CreateCopy()).ToArray();
mods = mods.Select(m => m.DeepClone()).ToArray();
IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods);
@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Difficulty
private DifficultyAttributes calculate(IBeatmap beatmap, Mod[] mods, double clockRate)
{
var skills = CreateSkills(beatmap, mods);
var skills = CreateSkills(beatmap, mods, clockRate);
if (!beatmap.HitObjects.Any())
return CreateDifficultyAttributes(beatmap, mods, skills, clockRate);
@ -180,7 +180,8 @@ namespace osu.Game.Rulesets.Difficulty
/// </summary>
/// <param name="beatmap">The <see cref="IBeatmap"/> whose difficulty will be calculated.</param>
/// <param name="mods">Mods to calculate difficulty with.</param>
/// <param name="clockRate">Clockrate to calculate difficulty with.</param>
/// <returns>The <see cref="Skill"/>s.</returns>
protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods);
protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate);
}
}

View File

@ -3,7 +3,6 @@
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Edit.Checks.Components;
@ -23,15 +22,18 @@ namespace osu.Game.Rulesets.Edit
// Audio
new CheckAudioPresence(),
new CheckAudioQuality(),
new CheckMutedObjects(),
new CheckFewHitsounds(),
// Compose
new CheckUnsnappedObjects(),
new CheckConcurrentObjects()
new CheckConcurrentObjects(),
new CheckZeroLengthObjects(),
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, WorkingBeatmap workingBeatmap)
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
return checks.SelectMany(check => check.Run(playableBeatmap, workingBeatmap));
return checks.SelectMany(check => check.Run(context));
}
}
}

View File

@ -0,0 +1,38 @@
// 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 osu.Game.Beatmaps;
#nullable enable
namespace osu.Game.Rulesets.Edit
{
/// <summary>
/// Represents the context provided by the beatmap verifier to the checks it runs.
/// Contains information about what is being checked and how it should be checked.
/// </summary>
public class BeatmapVerifierContext
{
/// <summary>
/// The playable beatmap instance of the current beatmap.
/// </summary>
public readonly IBeatmap Beatmap;
/// <summary>
/// The working beatmap instance of the current beatmap.
/// </summary>
public readonly IWorkingBeatmap WorkingBeatmap;
/// <summary>
/// The difficulty level which the current beatmap is considered to be.
/// </summary>
public DifficultyRating InterpretedDifficulty;
public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus)
{
Beatmap = beatmap;
WorkingBeatmap = workingBeatmap;
InterpretedDifficulty = difficultyRating;
}
}
}

View File

@ -10,6 +10,6 @@ namespace osu.Game.Rulesets.Edit.Checks
{
protected override CheckCategory Category => CheckCategory.Audio;
protected override string TypeOfFile => "audio";
protected override string GetFilename(IBeatmap playableBeatmap) => playableBeatmap.Metadata?.AudioFile;
protected override string GetFilename(IBeatmap beatmap) => beatmap.Metadata?.AudioFile;
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Edit.Checks
@ -26,13 +25,13 @@ namespace osu.Game.Rulesets.Edit.Checks
new IssueTemplateNoBitrate(this)
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap)
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var audioFile = playableBeatmap.Metadata?.AudioFile;
var audioFile = context.Beatmap.Metadata?.AudioFile;
if (audioFile == null)
yield break;
var track = workingBeatmap.Track;
var track = context.WorkingBeatmap.Track;
if (track?.Bitrate == null || track.Bitrate.Value == 0)
yield return new IssueTemplateNoBitrate(this).Create();

View File

@ -10,6 +10,6 @@ namespace osu.Game.Rulesets.Edit.Checks
{
protected override CheckCategory Category => CheckCategory.Resources;
protected override string TypeOfFile => "background";
protected override string GetFilename(IBeatmap playableBeatmap) => playableBeatmap.Metadata?.BackgroundFile;
protected override string GetFilename(IBeatmap beatmap) => beatmap.Metadata?.BackgroundFile;
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Edit.Checks
@ -30,13 +29,13 @@ namespace osu.Game.Rulesets.Edit.Checks
new IssueTemplateTooUncompressed(this)
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap)
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var backgroundFile = playableBeatmap.Metadata?.BackgroundFile;
var backgroundFile = context.Beatmap.Metadata?.BackgroundFile;
if (backgroundFile == null)
yield break;
var texture = workingBeatmap.Background;
var texture = context.WorkingBeatmap.Background;
if (texture == null)
yield break;
@ -48,8 +47,8 @@ namespace osu.Game.Rulesets.Edit.Checks
else if (texture.Width < low_width || texture.Height < low_height)
yield return new IssueTemplateLowResolution(this).Create(texture.Width, texture.Height);
string storagePath = playableBeatmap.BeatmapInfo.BeatmapSet.GetPathForFile(backgroundFile);
double filesizeMb = workingBeatmap.GetStream(storagePath).Length / (1024d * 1024d);
string storagePath = context.Beatmap.BeatmapInfo.BeatmapSet.GetPathForFile(backgroundFile);
double filesizeMb = context.WorkingBeatmap.GetStream(storagePath).Length / (1024d * 1024d);
if (filesizeMb > max_filesize_mb)
yield return new IssueTemplateTooUncompressed(this).Create(filesizeMb);

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
@ -22,15 +21,17 @@ namespace osu.Game.Rulesets.Edit.Checks
new IssueTemplateConcurrentDifferent(this)
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap)
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
for (int i = 0; i < playableBeatmap.HitObjects.Count - 1; ++i)
{
var hitobject = playableBeatmap.HitObjects[i];
var hitObjects = context.Beatmap.HitObjects;
for (int j = i + 1; j < playableBeatmap.HitObjects.Count; ++j)
for (int i = 0; i < hitObjects.Count - 1; ++i)
{
var hitobject = hitObjects[i];
for (int j = i + 1; j < hitObjects.Count; ++j)
{
var nextHitobject = playableBeatmap.HitObjects[j];
var nextHitobject = hitObjects[j];
// Accounts for rulesets with hitobjects separated by columns, such as Mania.
// In these cases we only care about concurrent objects within the same column.

View File

@ -0,0 +1,164 @@
// 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.Collections.Generic;
using System.Linq;
using osu.Game.Audio;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Edit.Checks
{
public class CheckFewHitsounds : ICheck
{
/// <summary>
/// 2 measures (4/4) of 120 BPM, typically makes up a few patterns in the map.
/// This is almost always ok, but can still be useful for the mapper to make sure hitsounding coverage is good.
/// </summary>
private const int negligible_threshold_time = 4000;
/// <summary>
/// 4 measures (4/4) of 120 BPM, typically makes up a large portion of a section in the song.
/// This is ok if the section is a quiet intro, for example.
/// </summary>
private const int warning_threshold_time = 8000;
/// <summary>
/// 12 measures (4/4) of 120 BPM, typically makes up multiple sections in the song.
/// </summary>
private const int problem_threshold_time = 24000;
// Should pass at least this many objects without hitsounds to be considered an issue (should work for Easy diffs too).
private const int warning_threshold_objects = 4;
private const int problem_threshold_objects = 16;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Few or no hitsounds");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateLongPeriodProblem(this),
new IssueTemplateLongPeriodWarning(this),
new IssueTemplateLongPeriodNegligible(this),
new IssueTemplateNoHitsounds(this)
};
private bool mapHasHitsounds;
private int objectsWithoutHitsounds;
private double lastHitsoundTime;
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
if (!context.Beatmap.HitObjects.Any())
yield break;
mapHasHitsounds = false;
objectsWithoutHitsounds = 0;
lastHitsoundTime = context.Beatmap.HitObjects.First().StartTime;
var hitObjectsIncludingNested = new List<HitObject>();
foreach (var hitObject in context.Beatmap.HitObjects)
{
// Samples play on the end of objects. Some objects have nested objects to accomplish playing them elsewhere (e.g. slider head/repeat).
foreach (var nestedHitObject in hitObject.NestedHitObjects)
hitObjectsIncludingNested.Add(nestedHitObject);
hitObjectsIncludingNested.Add(hitObject);
}
var hitObjectsByEndTime = hitObjectsIncludingNested.OrderBy(o => o.GetEndTime()).ToList();
var hitObjectCount = hitObjectsByEndTime.Count;
for (int i = 0; i < hitObjectCount; ++i)
{
var hitObject = hitObjectsByEndTime[i];
// This is used to perform an update at the end so that the period after the last hitsounded object can be an issue.
bool isLastObject = i == hitObjectCount - 1;
foreach (var issue in applyHitsoundUpdate(hitObject, isLastObject))
yield return issue;
}
if (!mapHasHitsounds)
yield return new IssueTemplateNoHitsounds(this).Create();
}
private IEnumerable<Issue> applyHitsoundUpdate(HitObject hitObject, bool isLastObject = false)
{
var time = hitObject.GetEndTime();
bool hasHitsound = hitObject.Samples.Any(isHitsound);
bool couldHaveHitsound = hitObject.Samples.Any(isHitnormal);
// Only generating issues on hitsounded or last objects ensures we get one issue per long period.
// If there are no hitsounds we let the "No hitsounds" template take precedence.
if (hasHitsound || (isLastObject && mapHasHitsounds))
{
var timeWithoutHitsounds = time - lastHitsoundTime;
if (timeWithoutHitsounds > problem_threshold_time && objectsWithoutHitsounds > problem_threshold_objects)
yield return new IssueTemplateLongPeriodProblem(this).Create(lastHitsoundTime, timeWithoutHitsounds);
else if (timeWithoutHitsounds > warning_threshold_time && objectsWithoutHitsounds > warning_threshold_objects)
yield return new IssueTemplateLongPeriodWarning(this).Create(lastHitsoundTime, timeWithoutHitsounds);
else if (timeWithoutHitsounds > negligible_threshold_time && objectsWithoutHitsounds > warning_threshold_objects)
yield return new IssueTemplateLongPeriodNegligible(this).Create(lastHitsoundTime, timeWithoutHitsounds);
}
if (hasHitsound)
{
mapHasHitsounds = true;
objectsWithoutHitsounds = 0;
lastHitsoundTime = time;
}
else if (couldHaveHitsound)
++objectsWithoutHitsounds;
}
private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.AllAdditions.Any(sample.Name.Contains);
private bool isHitnormal(HitSampleInfo sample) => sample.Name.Contains(HitSampleInfo.HIT_NORMAL);
public abstract class IssueTemplateLongPeriod : IssueTemplate
{
protected IssueTemplateLongPeriod(ICheck check, IssueType type)
: base(check, type, "Long period without hitsounds ({0:F1} seconds).")
{
}
public Issue Create(double time, double duration) => new Issue(this, duration / 1000f) { Time = time };
}
public class IssueTemplateLongPeriodProblem : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodProblem(ICheck check)
: base(check, IssueType.Problem)
{
}
}
public class IssueTemplateLongPeriodWarning : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodWarning(ICheck check)
: base(check, IssueType.Warning)
{
}
}
public class IssueTemplateLongPeriodNegligible : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodNegligible(ICheck check)
: base(check, IssueType.Negligible)
{
}
}
public class IssueTemplateNoHitsounds : IssueTemplate
{
public IssueTemplateNoHitsounds(ICheck check)
: base(check, IssueType.Problem, "There are no hitsounds.")
{
}
public Issue Create() => new Issue(this);
}
}
}

View File

@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Edit.Checks
{
protected abstract CheckCategory Category { get; }
protected abstract string TypeOfFile { get; }
protected abstract string GetFilename(IBeatmap playableBeatmap);
protected abstract string GetFilename(IBeatmap beatmap);
public CheckMetadata Metadata => new CheckMetadata(Category, $"Missing {TypeOfFile}");
@ -21,9 +21,9 @@ namespace osu.Game.Rulesets.Edit.Checks
new IssueTemplateDoesNotExist(this)
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap)
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var filename = GetFilename(playableBeatmap);
var filename = GetFilename(context.Beatmap);
if (filename == null)
{
@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Edit.Checks
}
// If the file is set, also make sure it still exists.
var storagePath = playableBeatmap.BeatmapInfo.BeatmapSet.GetPathForFile(filename);
var storagePath = context.Beatmap.BeatmapInfo.BeatmapSet.GetPathForFile(filename);
if (storagePath != null)
yield break;

View File

@ -0,0 +1,158 @@
// 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.Collections.Generic;
using System.Linq;
using osu.Framework.Utils;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Edit.Checks
{
public class CheckMutedObjects : ICheck
{
/// <summary>
/// Volume percentages lower than or equal to this are typically inaudible.
/// </summary>
private const int muted_threshold = 5;
/// <summary>
/// Volume percentages lower than or equal to this can sometimes be inaudible depending on sample used and music volume.
/// </summary>
private const int low_volume_threshold = 20;
private enum EdgeType
{
Head,
Repeat,
Tail,
None
}
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Low volume hitobjects");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateMutedActive(this),
new IssueTemplateLowVolumeActive(this),
new IssueTemplateMutedPassive(this)
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
foreach (var hitObject in context.Beatmap.HitObjects)
{
// Worth keeping in mind: The samples of an object always play at its end time.
// Objects like spinners have no sound at its start because of this, while hold notes have nested objects to accomplish this.
foreach (var nestedHitObject in hitObject.NestedHitObjects)
{
foreach (var issue in getVolumeIssues(hitObject, nestedHitObject))
yield return issue;
}
foreach (var issue in getVolumeIssues(hitObject))
yield return issue;
}
}
private IEnumerable<Issue> getVolumeIssues(HitObject hitObject, HitObject sampledHitObject = null)
{
sampledHitObject ??= hitObject;
if (!sampledHitObject.Samples.Any())
yield break;
// Samples that allow themselves to be overridden by control points have a volume of 0.
int maxVolume = sampledHitObject.Samples.Max(sample => sample.Volume > 0 ? sample.Volume : sampledHitObject.SampleControlPoint.SampleVolume);
double samplePlayTime = sampledHitObject.GetEndTime();
EdgeType edgeType = getEdgeAtTime(hitObject, samplePlayTime);
// We only care about samples played on the edges of objects, not ones like spinnerspin or slidertick.
if (edgeType == EdgeType.None)
yield break;
string postfix = hitObject is IHasDuration ? edgeType.ToString().ToLower() : null;
if (maxVolume <= muted_threshold)
{
if (edgeType == EdgeType.Head)
yield return new IssueTemplateMutedActive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix);
else
yield return new IssueTemplateMutedPassive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix);
}
else if (maxVolume <= low_volume_threshold && edgeType == EdgeType.Head)
{
yield return new IssueTemplateLowVolumeActive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix);
}
}
private EdgeType getEdgeAtTime(HitObject hitObject, double time)
{
if (Precision.AlmostEquals(time, hitObject.StartTime, 1f))
return EdgeType.Head;
if (Precision.AlmostEquals(time, hitObject.GetEndTime(), 1f))
return EdgeType.Tail;
if (hitObject is IHasRepeats hasRepeats)
{
double spanDuration = hasRepeats.Duration / hasRepeats.SpanCount();
if (spanDuration <= 0)
// Prevents undefined behaviour in cases like where zero/negative-length sliders/hold notes exist.
return EdgeType.None;
double spans = (time - hitObject.StartTime) / spanDuration;
double acceptableDifference = 1 / spanDuration; // 1 ms of acceptable difference, as with head/tail above.
if (Precision.AlmostEquals(spans, Math.Ceiling(spans), acceptableDifference) ||
Precision.AlmostEquals(spans, Math.Floor(spans), acceptableDifference))
{
return EdgeType.Repeat;
}
}
return EdgeType.None;
}
public abstract class IssueTemplateMuted : IssueTemplate
{
protected IssueTemplateMuted(ICheck check, IssueType type, string unformattedMessage)
: base(check, type, unformattedMessage)
{
}
public Issue Create(HitObject hitobject, double volume, double time, string postfix = "")
{
string objectName = hitobject.GetType().Name;
if (!string.IsNullOrEmpty(postfix))
objectName += " " + postfix;
return new Issue(hitobject, this, objectName, volume) { Time = time };
}
}
public class IssueTemplateMutedActive : IssueTemplateMuted
{
public IssueTemplateMutedActive(ICheck check)
: base(check, IssueType.Problem, "{0} has a volume of {1:0%}. Clickable objects must have clearly audible feedback.")
{
}
}
public class IssueTemplateLowVolumeActive : IssueTemplateMuted
{
public IssueTemplateLowVolumeActive(ICheck check)
: base(check, IssueType.Warning, "{0} has a volume of {1:0%}, ensure this is audible.")
{
}
}
public class IssueTemplateMutedPassive : IssueTemplateMuted
{
public IssueTemplateMutedPassive(ICheck check)
: base(check, IssueType.Negligible, "{0} has a volume of {1:0%}, ensure there is no distinct sound here in the song if inaudible.")
{
}
}
}
}

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
@ -22,11 +21,11 @@ namespace osu.Game.Rulesets.Edit.Checks
new IssueTemplateSmallUnsnap(this)
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap)
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var controlPointInfo = playableBeatmap.ControlPointInfo;
var controlPointInfo = context.Beatmap.ControlPointInfo;
foreach (var hitobject in playableBeatmap.HitObjects)
foreach (var hitobject in context.Beatmap.HitObjects)
{
double startUnsnap = hitobject.StartTime - controlPointInfo.GetClosestSnappedTime(hitobject.StartTime);
string startPostfix = hitobject is IHasDuration ? "start" : "";

View File

@ -0,0 +1,47 @@
// 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.Collections.Generic;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Edit.Checks
{
public class CheckZeroLengthObjects : ICheck
{
/// <summary>
/// The duration can be this low before being treated as having no length, in case of precision errors. Unit is milliseconds.
/// </summary>
private const double leniency = 0.5d;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Zero-length hitobjects");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateZeroLength(this)
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
foreach (var hitObject in context.Beatmap.HitObjects)
{
if (!(hitObject is IHasDuration hasDuration))
continue;
if (hasDuration.Duration < leniency)
yield return new IssueTemplateZeroLength(this).Create(hitObject, hasDuration.Duration);
}
}
public class IssueTemplateZeroLength : IssueTemplate
{
public IssueTemplateZeroLength(ICheck check)
: base(check, IssueType.Problem, "{0} has a duration of {1:0}.")
{
}
public Issue Create(HitObject hitobject, double duration) => new Issue(hitobject, this, hitobject.GetType(), duration);
}
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
namespace osu.Game.Rulesets.Edit.Checks.Components
{
@ -24,8 +23,7 @@ namespace osu.Game.Rulesets.Edit.Checks.Components
/// <summary>
/// Runs this check and returns any issues detected for the provided beatmap.
/// </summary>
/// <param name="playableBeatmap">The playable beatmap of the beatmap to run the check on.</param>
/// <param name="workingBeatmap">The working beatmap of the beatmap to run the check on.</param>
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap);
/// <param name="context">The beatmap verifier context associated with the beatmap.</param>
public IEnumerable<Issue> Run(BeatmapVerifierContext context);
}
}

View File

@ -18,7 +18,6 @@ namespace osu.Game.Rulesets.Edit.Checks.Components
/// <summary> An error occurred and a complete check could not be made. </summary>
Error,
// TODO: Negligible issues should be hidden by default.
/// <summary> A possible mistake so minor/unlikely that it can often be safely ignored. </summary>
Negligible,
}

View File

@ -1,9 +1,11 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
@ -52,15 +54,21 @@ namespace osu.Game.Rulesets.Edit
if (changeHandler != null)
{
// for now only regenerate replay on a finalised state change, not HitObjectUpdated.
changeHandler.OnStateChange += updateReplay;
changeHandler.OnStateChange += () => Scheduler.AddOnce(regenerateAutoplay);
}
else
{
beatmap.HitObjectUpdated += _ => updateReplay();
beatmap.HitObjectUpdated += _ => Scheduler.AddOnce(regenerateAutoplay);
}
Scheduler.AddOnce(regenerateAutoplay);
}
private void updateReplay() => drawableRuleset.RegenerateAutoplay();
private void regenerateAutoplay()
{
var autoplayMod = drawableRuleset.Mods.OfType<ModAutoplay>().Single();
drawableRuleset.SetReplayScore(autoplayMod.CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods));
}
private void addHitObject(HitObject hitObject)
{

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
@ -43,6 +44,9 @@ namespace osu.Game.Rulesets.Edit
protected readonly Ruleset Ruleset;
// Provides `Playfield`
private DependencyContainer dependencies;
[Resolved]
protected EditorClock EditorClock { get; private set; }
@ -60,15 +64,20 @@ namespace osu.Game.Rulesets.Edit
private InputManager inputManager;
private RadioButtonCollection toolboxCollection;
private EditorRadioButtonCollection toolboxCollection;
private FillFlowContainer togglesCollection;
private IBindable<bool> hasTiming;
protected HitObjectComposer(Ruleset ruleset)
{
Ruleset = ruleset;
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
[BackgroundDependencyLoader]
private void load()
{
@ -88,6 +97,8 @@ namespace osu.Game.Rulesets.Edit
return;
}
dependencies.CacheAs(Playfield);
const float toolbar_width = 200;
InternalChildren = new Drawable[]
@ -118,7 +129,7 @@ namespace osu.Game.Rulesets.Edit
{
new ToolboxGroup("toolbox (1-9)")
{
Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X }
Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X }
},
new ToolboxGroup("toggles (Q~P)")
{
@ -152,6 +163,14 @@ namespace osu.Game.Rulesets.Edit
base.LoadComplete();
inputManager = GetContainingInputManager();
hasTiming = EditorBeatmap.HasTiming.GetBoundCopy();
hasTiming.BindValueChanged(timing =>
{
// it's important this is performed before the similar code in EditorRadioButton disables the button.
if (!timing.NewValue)
setSelectTool();
});
}
public override Playfield Playfield => drawableRulesetWrapper.Playfield;
@ -211,7 +230,8 @@ namespace osu.Game.Rulesets.Edit
if (item != null)
{
item.Select();
if (!item.Selected.Disabled)
item.Select();
return true;
}
}

View File

@ -9,24 +9,23 @@ using osuTK;
namespace osu.Game.Rulesets.Edit
{
public abstract class OverlaySelectionBlueprint : SelectionBlueprint<HitObject>
public abstract class HitObjectSelectionBlueprint : SelectionBlueprint<HitObject>
{
/// <summary>
/// The <see cref="DrawableHitObject"/> which this <see cref="OverlaySelectionBlueprint"/> applies to.
/// The <see cref="DrawableHitObject"/> which this <see cref="HitObjectSelectionBlueprint"/> applies to.
/// </summary>
public readonly DrawableHitObject DrawableObject;
public DrawableHitObject DrawableObject { get; internal set; }
/// <summary>
/// Whether the blueprint should be shown even when the <see cref="DrawableObject"/> is not alive.
/// </summary>
protected virtual bool AlwaysShowWhenSelected => false;
protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected);
protected override bool ShouldBeAlive => (DrawableObject?.IsAlive == true && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected);
protected OverlaySelectionBlueprint(DrawableHitObject drawableObject)
: base(drawableObject.HitObject)
protected HitObjectSelectionBlueprint(HitObject hitObject)
: base(hitObject)
{
DrawableObject = drawableObject;
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos);
@ -35,4 +34,15 @@ namespace osu.Game.Rulesets.Edit
public override Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad;
}
public abstract class HitObjectSelectionBlueprint<T> : HitObjectSelectionBlueprint
where T : HitObject
{
public T HitObject => (T)Item;
protected HitObjectSelectionBlueprint(T item)
: base(item)
{
}
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Edit
@ -12,6 +11,6 @@ namespace osu.Game.Rulesets.Edit
/// </summary>
public interface IBeatmapVerifier
{
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, WorkingBeatmap workingBeatmap);
public IEnumerable<Issue> Run(BeatmapVerifierContext context);
}
}

View File

@ -36,7 +36,8 @@ namespace osu.Game.Rulesets.Edit
[Resolved(canBeNull: true)]
protected EditorClock EditorClock { get; private set; }
private readonly IBindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>();
[Resolved]
private EditorBeatmap beatmap { get; set; }
private Bindable<double> startTimeBindable;
@ -58,10 +59,8 @@ namespace osu.Game.Rulesets.Edit
}
[BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap)
private void load()
{
this.beatmap.BindTo(beatmap);
startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy();
startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true);
}
@ -113,7 +112,7 @@ namespace osu.Game.Rulesets.Edit
/// Invokes <see cref="Objects.HitObject.ApplyDefaults(ControlPointInfo,BeatmapDifficulty, CancellationToken)"/>,
/// refreshing <see cref="Objects.HitObject.NestedHitObjects"/> and parameters for the <see cref="HitObject"/>.
/// </summary>
protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.Value.Beatmap.ControlPointInfo, beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty);
protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty);
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false;

View File

@ -0,0 +1,32 @@
// 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 osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
namespace osu.Game.Rulesets.Edit
{
public class ScrollingToolboxGroup : ToolboxGroup
{
protected readonly OsuScrollContainer Scroll;
protected override Container<Drawable> Content { get; }
public ScrollingToolboxGroup(string title, float scrollAreaHeight)
: base(title)
{
base.Content.Add(Scroll = new OsuScrollContainer
{
RelativeSizeAxes = Axes.X,
Height = scrollAreaHeight,
Child = Content = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
},
});
}
}
}

View File

@ -105,34 +105,34 @@ namespace osu.Game.Rulesets.Edit
protected override bool ShouldBeConsideredForInput(Drawable child) => State == SelectionState.Selected;
/// <summary>
/// Selects this <see cref="OverlaySelectionBlueprint"/>, causing it to become visible.
/// Selects this <see cref="SelectionBlueprint{T}"/>, causing it to become visible.
/// </summary>
public void Select() => State = SelectionState.Selected;
/// <summary>
/// Deselects this <see cref="OverlaySelectionBlueprint"/>, causing it to become invisible.
/// Deselects this <see cref="HitObjectSelectionBlueprint"/>, causing it to become invisible.
/// </summary>
public void Deselect() => State = SelectionState.NotSelected;
/// <summary>
/// Toggles the selection state of this <see cref="OverlaySelectionBlueprint"/>.
/// Toggles the selection state of this <see cref="HitObjectSelectionBlueprint"/>.
/// </summary>
public void ToggleSelection() => State = IsSelected ? SelectionState.NotSelected : SelectionState.Selected;
public bool IsSelected => State == SelectionState.Selected;
/// <summary>
/// The <see cref="MenuItem"/>s to be displayed in the context menu for this <see cref="OverlaySelectionBlueprint"/>.
/// The <see cref="MenuItem"/>s to be displayed in the context menu for this <see cref="HitObjectSelectionBlueprint"/>.
/// </summary>
public virtual MenuItem[] ContextMenuItems => Array.Empty<MenuItem>();
/// <summary>
/// The screen-space point that causes this <see cref="OverlaySelectionBlueprint"/> to be selected via a drag.
/// The screen-space point that causes this <see cref="HitObjectSelectionBlueprint"/> to be selected via a drag.
/// </summary>
public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre;
/// <summary>
/// The screen-space quad that outlines this <see cref="OverlaySelectionBlueprint"/> for selections.
/// The screen-space quad that outlines this <see cref="HitObjectSelectionBlueprint"/> for selections.
/// </summary>
public virtual Quad SelectionQuad => ScreenSpaceDrawQuad;

View File

@ -1,7 +1,6 @@
// 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.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Allocation;
@ -32,18 +31,6 @@ namespace osu.Game.Rulesets.Judgements
private readonly Container aboveHitObjectsContent;
/// <summary>
/// Duration of initial fade in.
/// </summary>
[Obsolete("Apply any animations manually via ApplyHitAnimations / ApplyMissAnimations. Defaults were moved inside skinned components.")]
protected virtual double FadeInDuration => 100;
/// <summary>
/// Duration to wait until fade out begins. Defaults to <see cref="FadeInDuration"/>.
/// </summary>
[Obsolete("Apply any animations manually via ApplyHitAnimations / ApplyMissAnimations. Defaults were moved inside skinned components.")]
protected virtual double FadeOutDelay => FadeInDuration;
/// <summary>
/// Creates a drawable which visualises a <see cref="Judgements.Judgement"/>.
/// </summary>
@ -130,7 +117,7 @@ namespace osu.Game.Rulesets.Judgements
LifetimeStart = Result.TimeAbsolute;
using (BeginAbsoluteSequence(Result.TimeAbsolute, true))
using (BeginAbsoluteSequence(Result.TimeAbsolute))
{
// not sure if this should remain going forward.
JudgementBody.ResetAnimation();

View File

@ -1,7 +1,6 @@
// 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 osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
@ -69,14 +68,6 @@ namespace osu.Game.Rulesets.Judgements
/// </summary>
public double MaxHealthIncrease => HealthIncreaseFor(MaxResult);
/// <summary>
/// Retrieves the numeric score representation of a <see cref="HitResult"/>.
/// </summary>
/// <param name="result">The <see cref="HitResult"/> to find the numeric score representation for.</param>
/// <returns>The numeric score representation of <paramref name="result"/>.</returns>
[Obsolete("Has no effect. Use ToNumericResult(HitResult) (standardised across all rulesets).")] // Can be made non-virtual 20210328
protected virtual int NumericResultFor(HitResult result) => ToNumericResult(result);
/// <summary>
/// Retrieves the numeric score representation of a <see cref="JudgementResult"/>.
/// </summary>

View File

@ -0,0 +1,112 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Beatmaps;
using osu.Game.Overlays.Settings;
namespace osu.Game.Rulesets.Mods
{
public class DifficultyAdjustSettingsControl : SettingsItem<float?>
{
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; }
/// <summary>
/// Used to track the display value on the setting slider.
/// </summary>
/// <remarks>
/// When the mod is overriding a default, this will match the value of <see cref="Current"/>.
/// When there is no override (ie. <see cref="Current"/> is null), this value will match the beatmap provided default via <see cref="updateCurrentFromSlider"/>.
/// </remarks>
private readonly BindableNumber<float> sliderDisplayCurrent = new BindableNumber<float>();
protected override Drawable CreateControl() => new SliderControl(sliderDisplayCurrent);
/// <summary>
/// Guards against beatmap values displayed on slider bars being transferred to user override.
/// </summary>
private bool isInternalChange;
private DifficultyBindable difficultyBindable;
public override Bindable<float?> Current
{
get => base.Current;
set
{
// Intercept and extract the internal number bindable from DifficultyBindable.
// This will provide bounds and precision specifications for the slider bar.
difficultyBindable = ((DifficultyBindable)value).GetBoundCopy();
sliderDisplayCurrent.BindTo(difficultyBindable.CurrentNumber);
base.Current = difficultyBindable;
}
}
protected override void LoadComplete()
{
base.LoadComplete();
Current.BindValueChanged(current => updateCurrentFromSlider());
beatmap.BindValueChanged(b => updateCurrentFromSlider(), true);
sliderDisplayCurrent.BindValueChanged(number =>
{
// this handles the transfer of the slider value to the main bindable.
// as such, should be skipped if the slider is being updated via updateFromDifficulty().
if (!isInternalChange)
Current.Value = number.NewValue;
});
}
private void updateCurrentFromSlider()
{
if (Current.Value != null)
{
// a user override has been added or updated.
sliderDisplayCurrent.Value = Current.Value.Value;
return;
}
var difficulty = beatmap.Value.BeatmapInfo.BaseDifficulty;
if (difficulty == null)
return;
// generally should always be implemented, else the slider will have a zero default.
if (difficultyBindable.ReadCurrentFromDifficulty == null)
return;
isInternalChange = true;
sliderDisplayCurrent.Value = difficultyBindable.ReadCurrentFromDifficulty(difficulty);
isInternalChange = false;
}
private class SliderControl : CompositeDrawable, IHasCurrentValue<float?>
{
// This is required as SettingsItem relies heavily on this bindable for internal use.
// The actual update flow is done via the bindable provided in the constructor.
public Bindable<float?> Current { get; set; } = new Bindable<float?>();
public SliderControl(BindableNumber<float> currentNumber)
{
InternalChildren = new Drawable[]
{
new SettingsSlider<float>
{
ShowsDefaultIndicator = false,
Current = currentNumber,
}
};
AutoSizeAxes = Axes.Y;
RelativeSizeAxes = Axes.X;
}
}
}
}

View File

@ -0,0 +1,133 @@
// 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 osu.Framework.Bindables;
using osu.Game.Beatmaps;
namespace osu.Game.Rulesets.Mods
{
public class DifficultyBindable : Bindable<float?>
{
/// <summary>
/// Whether the extended limits should be applied to this bindable.
/// </summary>
public readonly BindableBool ExtendedLimits = new BindableBool();
/// <summary>
/// An internal numeric bindable to hold and propagate min/max/precision.
/// The value of this bindable should not be set.
/// </summary>
internal readonly BindableFloat CurrentNumber = new BindableFloat
{
MinValue = 0,
MaxValue = 10,
};
/// <summary>
/// A function that can extract the current value of this setting from a beatmap difficulty for display purposes.
/// </summary>
public Func<BeatmapDifficulty, float> ReadCurrentFromDifficulty;
public float Precision
{
set => CurrentNumber.Precision = value;
}
public float MinValue
{
set => CurrentNumber.MinValue = value;
}
private float maxValue;
public float MaxValue
{
set
{
if (value == maxValue)
return;
maxValue = value;
updateMaxValue();
}
}
private float? extendedMaxValue;
/// <summary>
/// The maximum value to be used when extended limits are applied.
/// </summary>
public float? ExtendedMaxValue
{
set
{
if (value == extendedMaxValue)
return;
extendedMaxValue = value;
updateMaxValue();
}
}
public DifficultyBindable()
: this(null)
{
}
public DifficultyBindable(float? defaultValue = null)
: base(defaultValue)
{
ExtendedLimits.BindValueChanged(_ => updateMaxValue());
}
public override float? Value
{
get => base.Value;
set
{
// Ensure that in the case serialisation runs in the wrong order (and limit extensions aren't applied yet) the deserialised value is still propagated.
if (value != null)
CurrentNumber.MaxValue = MathF.Max(CurrentNumber.MaxValue, value.Value);
base.Value = value;
}
}
private void updateMaxValue()
{
CurrentNumber.MaxValue = ExtendedLimits.Value && extendedMaxValue != null ? extendedMaxValue.Value : maxValue;
}
public override void BindTo(Bindable<float?> them)
{
if (!(them is DifficultyBindable otherDifficultyBindable))
throw new InvalidOperationException($"Cannot bind to a non-{nameof(DifficultyBindable)}.");
ReadCurrentFromDifficulty = otherDifficultyBindable.ReadCurrentFromDifficulty;
// the following max value copies are only safe as long as these values are effectively constants.
MaxValue = otherDifficultyBindable.maxValue;
ExtendedMaxValue = otherDifficultyBindable.extendedMaxValue;
ExtendedLimits.BindTarget = otherDifficultyBindable.ExtendedLimits;
// the actual values need to be copied after the max value constraints.
CurrentNumber.BindTarget = otherDifficultyBindable.CurrentNumber;
base.BindTo(them);
}
public override void UnbindFrom(IUnbindable them)
{
if (!(them is DifficultyBindable otherDifficultyBindable))
throw new InvalidOperationException($"Cannot unbind from a non-{nameof(DifficultyBindable)}.");
base.UnbindFrom(them);
CurrentNumber.UnbindFrom(otherDifficultyBindable.CurrentNumber);
ExtendedLimits.UnbindFrom(otherDifficultyBindable.ExtendedLimits);
}
public new DifficultyBindable GetBoundCopy() => new DifficultyBindable { BindTarget = this };
}
}

View File

@ -0,0 +1,18 @@
// 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 osu.Game.Beatmaps;
namespace osu.Game.Rulesets.Mods
{
/// <summary>
/// Interface for a <see cref="Mod"/> that applies changes to a <see cref="BeatmapProcessor"/>.
/// </summary>
public interface IApplicableToBeatmapProcessor : IApplicableMod
{
/// <summary>
/// Applies this <see cref="Mod"/> to a <see cref="BeatmapProcessor"/>.
/// </summary>
void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor);
}
}

View File

@ -10,13 +10,6 @@ namespace osu.Game.Rulesets.Mods
/// </summary>
public interface IApplicableToDifficulty : IApplicableMod
{
/// <summary>
/// Called when a beatmap is changed. Can be used to read default values.
/// Any changes made will not be preserved.
/// </summary>
/// <param name="difficulty">The difficulty to read from.</param>
void ReadFromDifficulty(BeatmapDifficulty difficulty);
/// <summary>
/// Called post beatmap conversion. Can be used to apply changes to difficulty attributes.
/// </summary>

View File

@ -1,7 +1,6 @@
// 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.Collections.Generic;
using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.Mods
@ -9,13 +8,12 @@ namespace osu.Game.Rulesets.Mods
/// <summary>
/// An interface for <see cref="Mod"/>s that can be applied to <see cref="DrawableHitObject"/>s.
/// </summary>
public interface IApplicableToDrawableHitObjects : IApplicableMod
public interface IApplicableToDrawableHitObject : IApplicableMod
{
/// <summary>
/// Applies this <see cref="IApplicableToDrawableHitObjects"/> to a list of <see cref="DrawableHitObject"/>s.
/// Applies this <see cref="IApplicableToDrawableHitObject"/> to a <see cref="DrawableHitObject"/>.
/// This will only be invoked with top-level <see cref="DrawableHitObject"/>s. Access <see cref="DrawableHitObject.NestedHitObjects"/> if adjusting nested objects is necessary.
/// </summary>
/// <param name="drawables">The list of <see cref="DrawableHitObject"/>s to apply to.</param>
void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables);
void ApplyToDrawableHitObject(DrawableHitObject drawable);
}
}

View File

@ -0,0 +1,18 @@
// 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.Collections.Generic;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.Mods
{
[Obsolete(@"Use the singular version IApplicableToDrawableHitObject instead.")] // Can be removed 20211216
public interface IApplicableToDrawableHitObjects : IApplicableToDrawableHitObject
{
void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables);
void IApplicableToDrawableHitObject.ApplyToDrawableHitObject(DrawableHitObject drawable) => ApplyToDrawableHitObjects(drawable.Yield());
}
}

View File

@ -0,0 +1,14 @@
// 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.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mods
{
public interface ICreateReplay
{
public Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList<Mod> mods);
}
}

View File

@ -0,0 +1,12 @@
// 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 osu.Framework.Bindables;
namespace osu.Game.Rulesets.Mods
{
public interface IHasSeed
{
Bindable<int?> Seed { get; }
}
}

View File

@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods
/// The base class for gameplay modifiers.
/// </summary>
[ExcludeFromDynamicCompile]
public abstract class Mod : IMod, IEquatable<Mod>, IJsonSerializable
public abstract class Mod : IMod, IEquatable<Mod>, IJsonSerializable, IDeepCloneable<Mod>
{
/// <summary>
/// The name of this mod.
@ -108,9 +108,13 @@ namespace osu.Game.Rulesets.Mods
public virtual bool HasImplementation => this is IApplicableMod;
/// <summary>
/// Returns if this mod is ranked.
/// Whether this mod is playable by an end user.
/// Should be <c>false</c> for cases where the user is not interacting with the game (so it can be excluded from mutliplayer selection, for example).
/// </summary>
[JsonIgnore]
public virtual bool UserPlayable => true;
[Obsolete("Going forward, the concept of \"ranked\" doesn't exist. The only exceptions are automation mods, which should now override and set UserPlayable to false.")] // Can be removed 20211009
public virtual bool Ranked => false;
/// <summary>
@ -128,7 +132,7 @@ namespace osu.Game.Rulesets.Mods
/// <summary>
/// Creates a copy of this <see cref="Mod"/> initialised to a default state.
/// </summary>
public virtual Mod CreateCopy()
public virtual Mod DeepClone()
{
var result = (Mod)Activator.CreateInstance(GetType());
result.CopyFrom(this);

View File

@ -7,22 +7,11 @@ using osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Replays;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mods
{
public abstract class ModAutoplay<T> : ModAutoplay, IApplicableToDrawableRuleset<T>
where T : HitObject
{
public virtual void ApplyToDrawableRuleset(DrawableRuleset<T> drawableRuleset)
{
drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods));
}
}
public abstract class ModAutoplay : Mod, IApplicableFailOverride
public abstract class ModAutoplay : Mod, IApplicableFailOverride, ICreateReplay
{
public override string Name => "Autoplay";
public override string Acronym => "AT";
@ -35,6 +24,8 @@ namespace osu.Game.Rulesets.Mods
public bool RestartOnFail => false;
public override bool UserPlayable => false;
public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) };
public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0;

View File

@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mods
};
[SettingSource("Direction", "The direction of rotation")]
public Bindable<RotationDirection> Direction { get; } = new Bindable<RotationDirection>(RotationDirection.Clockwise);
public Bindable<RotationDirection> Direction { get; } = new Bindable<RotationDirection>();
public override string Name => "Barrel Roll";
public override string Acronym => "BR";

View File

@ -17,8 +17,6 @@ namespace osu.Game.Rulesets.Mods
public override string Description => "Feeling nostalgic?";
public override bool Ranked => false;
public override ModType Type => ModType.Conversion;
}
}

View File

@ -1,13 +1,12 @@
// 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 osu.Game.Beatmaps;
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using System.Linq;
namespace osu.Game.Rulesets.Mods
{
@ -33,24 +32,24 @@ namespace osu.Game.Rulesets.Mods
protected const int LAST_SETTING_ORDER = 2;
[SettingSource("HP Drain", "Override a beatmap's set HP.", FIRST_SETTING_ORDER)]
public BindableNumber<float> DrainRate { get; } = new BindableFloatWithLimitExtension
[SettingSource("HP Drain", "Override a beatmap's set HP.", FIRST_SETTING_ORDER, SettingControlType = typeof(DifficultyAdjustSettingsControl))]
public DifficultyBindable DrainRate { get; } = new DifficultyBindable
{
Precision = 0.1f,
MinValue = 0,
MaxValue = 10,
Default = 5,
Value = 5,
ExtendedMaxValue = 11,
ReadCurrentFromDifficulty = diff => diff.DrainRate,
};
[SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER)]
public BindableNumber<float> OverallDifficulty { get; } = new BindableFloatWithLimitExtension
[SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER, SettingControlType = typeof(DifficultyAdjustSettingsControl))]
public DifficultyBindable OverallDifficulty { get; } = new DifficultyBindable
{
Precision = 0.1f,
MinValue = 0,
MaxValue = 10,
Default = 5,
Value = 5,
ExtendedMaxValue = 11,
ReadCurrentFromDifficulty = diff => diff.OverallDifficulty,
};
[SettingSource("Extended Limits", "Adjust difficulty beyond sane limits.")]
@ -58,17 +57,11 @@ namespace osu.Game.Rulesets.Mods
protected ModDifficultyAdjust()
{
ExtendedLimits.BindValueChanged(extend => ApplyLimits(extend.NewValue));
}
/// <summary>
/// Changes the difficulty adjustment limits. Occurs when the value of <see cref="ExtendedLimits"/> is changed.
/// </summary>
/// <param name="extended">Whether limits should extend beyond sane ranges.</param>
protected virtual void ApplyLimits(bool extended)
{
DrainRate.MaxValue = extended ? 11 : 10;
OverallDifficulty.MaxValue = extended ? 11 : 10;
foreach (var (_, property) in this.GetOrderedSettingsSourceProperties())
{
if (property.GetValue(this) is DifficultyBindable diffAdjustBindable)
diffAdjustBindable.ExtendedLimits.BindTo(ExtendedLimits);
}
}
public override string SettingDescription
@ -86,146 +79,20 @@ namespace osu.Game.Rulesets.Mods
}
}
private BeatmapDifficulty difficulty;
public void ReadFromDifficulty(BeatmapDifficulty difficulty)
{
if (this.difficulty == null || this.difficulty.ID != difficulty.ID)
{
TransferSettings(difficulty);
this.difficulty = difficulty;
}
}
public void ApplyToDifficulty(BeatmapDifficulty difficulty) => ApplySettings(difficulty);
/// <summary>
/// Transfer initial settings from the beatmap to settings.
/// </summary>
/// <param name="difficulty">The beatmap's initial values.</param>
protected virtual void TransferSettings(BeatmapDifficulty difficulty)
{
TransferSetting(DrainRate, difficulty.DrainRate);
TransferSetting(OverallDifficulty, difficulty.OverallDifficulty);
}
private readonly Dictionary<IBindable, bool> userChangedSettings = new Dictionary<IBindable, bool>();
/// <summary>
/// Transfer a setting from <see cref="BeatmapDifficulty"/> to a configuration bindable.
/// Only performs the transfer if the user is not currently overriding.
/// </summary>
protected void TransferSetting<T>(BindableNumber<T> bindable, T beatmapDefault)
where T : struct, IComparable<T>, IConvertible, IEquatable<T>
{
bindable.UnbindEvents();
userChangedSettings.TryAdd(bindable, false);
bindable.Default = beatmapDefault;
// users generally choose a difficulty setting and want it to stick across multiple beatmap changes.
// we only want to value transfer if the user hasn't changed the value previously.
if (!userChangedSettings[bindable])
bindable.Value = beatmapDefault;
bindable.ValueChanged += _ => userChangedSettings[bindable] = !bindable.IsDefault;
}
internal override void CopyAdjustedSetting(IBindable target, object source)
{
// if the value is non-bindable, it's presumably coming from an external source (like the API) - therefore presume it is not default.
// if the value is bindable, defer to the source's IsDefault to be able to tell.
userChangedSettings[target] = !(source is IBindable bindableSource) || !bindableSource.IsDefault;
base.CopyAdjustedSetting(target, source);
}
/// <summary>
/// Applies a setting from a configuration bindable using <paramref name="applyFunc"/>, if it has been changed by the user.
/// </summary>
protected void ApplySetting<T>(BindableNumber<T> setting, Action<T> applyFunc)
where T : struct, IComparable<T>, IConvertible, IEquatable<T>
{
if (userChangedSettings.TryGetValue(setting, out bool userChangedSetting) && userChangedSetting)
applyFunc.Invoke(setting.Value);
}
/// <summary>
/// Apply all custom settings to the provided beatmap.
/// </summary>
/// <param name="difficulty">The beatmap to have settings applied.</param>
protected virtual void ApplySettings(BeatmapDifficulty difficulty)
{
ApplySetting(DrainRate, dr => difficulty.DrainRate = dr);
ApplySetting(OverallDifficulty, od => difficulty.OverallDifficulty = od);
}
public override void ResetSettingsToDefaults()
{
base.ResetSettingsToDefaults();
if (difficulty != null)
{
// base implementation potentially overwrite modified defaults that came from a beatmap selection.
TransferSettings(difficulty);
}
}
/// <summary>
/// A <see cref="BindableDouble"/> that extends its min/max values to support any assigned value.
/// </summary>
protected class BindableDoubleWithLimitExtension : BindableDouble
{
public override double Value
{
get => base.Value;
set
{
if (value < MinValue)
MinValue = value;
if (value > MaxValue)
MaxValue = value;
base.Value = value;
}
}
}
/// <summary>
/// A <see cref="BindableFloat"/> that extends its min/max values to support any assigned value.
/// </summary>
protected class BindableFloatWithLimitExtension : BindableFloat
{
public override float Value
{
get => base.Value;
set
{
if (value < MinValue)
MinValue = value;
if (value > MaxValue)
MaxValue = value;
base.Value = value;
}
}
}
/// <summary>
/// A <see cref="BindableInt"/> that extends its min/max values to support any assigned value.
/// </summary>
protected class BindableIntWithLimitExtension : BindableInt
{
public override int Value
{
get => base.Value;
set
{
if (value < MinValue)
MinValue = value;
if (value > MaxValue)
MaxValue = value;
base.Value = value;
}
}
if (DrainRate.Value != null) difficulty.DrainRate = DrainRate.Value.Value;
if (OverallDifficulty.Value != null) difficulty.OverallDifficulty = OverallDifficulty.Value.Value;
}
}
}

View File

@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => OsuIcon.ModDoubletime;
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Zoooooooooom...";
public override bool Ranked => true;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModHalfTime)).ToArray();

View File

@ -15,7 +15,6 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => OsuIcon.ModEasy;
public override ModType Type => ModType.DifficultyReduction;
public override double ScoreMultiplier => 0.5;
public override bool Ranked => true;
public override Type[] IncompatibleMods => new[] { typeof(ModHardRock), typeof(ModDifficultyAdjust) };
public virtual void ReadFromDifficulty(BeatmapDifficulty difficulty)

View File

@ -31,7 +31,6 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => OsuIcon.ModFlashlight;
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Restricted view area.";
public override bool Ranked => true;
internal ModFlashlight()
{

View File

@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => OsuIcon.ModHalftime;
public override ModType Type => ModType.DifficultyReduction;
public override string Description => "Less zoom...";
public override bool Ranked => true;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModDoubleTime)).ToArray();

View File

@ -1,9 +1,7 @@
// 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 osu.Game.Graphics;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Framework.Graphics.Sprites;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
@ -16,15 +14,6 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "HD";
public override IconUsage? Icon => OsuIcon.ModHidden;
public override ModType Type => ModType.DifficultyIncrease;
public override bool Ranked => true;
/// <summary>
/// Check whether the provided hitobject should be considered the "first" hideable object.
/// Can be used to skip spinners, for instance.
/// </summary>
/// <param name="hitObject">The hitobject to check.</param>
[Obsolete("Use IsFirstAdjustableObject() instead.")] // Can be removed 20210506
protected virtual bool IsFirstHideableObject(DrawableHitObject hitObject) => true;
public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
@ -46,39 +35,5 @@ namespace osu.Game.Rulesets.Mods
return rank;
}
}
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
#pragma warning disable 618
ApplyFirstObjectIncreaseVisibilityState(hitObject, state);
#pragma warning restore 618
}
protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
#pragma warning disable 618
ApplyHiddenState(hitObject, state);
#pragma warning restore 618
}
/// <summary>
/// Apply a special visibility state to the first object in a beatmap, if the user chooses to turn on the "increase first object visibility" setting.
/// </summary>
/// <param name="hitObject">The hit object to apply the state change to.</param>
/// <param name="state">The state of the hit object.</param>
[Obsolete("Use ApplyIncreasedVisibilityState() instead.")] // Can be removed 20210506
protected virtual void ApplyFirstObjectIncreaseVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
}
/// <summary>
/// Apply a hidden state to the provided object.
/// </summary>
/// <param name="hitObject">The hit object to apply the state change to.</param>
/// <param name="state">The state of the hit object.</param>
[Obsolete("Use ApplyNormalVisibilityState() instead.")] // Can be removed 20210506
protected virtual void ApplyHiddenState(DrawableHitObject hitObject, ArmedState state)
{
}
}
}

View File

@ -15,7 +15,6 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.DifficultyReduction;
public override string Description => "You can't fail, no matter what.";
public override double ScoreMultiplier => 0.5;
public override bool Ranked => true;
public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModAutoplay) };
}
}

View File

@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "PF";
public override IconUsage? Icon => OsuIcon.ModPerfect;
public override ModType Type => ModType.DifficultyIncrease;
public override bool Ranked => true;
public override double ScoreMultiplier => 1;
public override string Description => "SS or quit.";

View File

@ -1,17 +1,27 @@
// 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 osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Overlays.Settings;
namespace osu.Game.Rulesets.Mods
{
public abstract class ModRandom : Mod
public abstract class ModRandom : Mod, IHasSeed
{
public override string Name => "Random";
public override string Acronym => "RD";
public override ModType Type => ModType.Conversion;
public override IconUsage? Icon => OsuIcon.Dice;
public override double ScoreMultiplier => 1;
[SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> Seed { get; } = new Bindable<int?>
{
Default = null,
Value = null
};
}
}

View File

@ -18,7 +18,6 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Miss and fail.";
public override double ScoreMultiplier => 1;
public override bool Ranked => true;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray();

View File

@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods
/// A <see cref="Mod"/> which applies visibility adjustments to <see cref="DrawableHitObject"/>s
/// with an optional increased visibility adjustment depending on the user's "increase first object visibility" setting.
/// </summary>
public abstract class ModWithVisibilityAdjustment : Mod, IReadFromConfig, IApplicableToBeatmap, IApplicableToDrawableHitObjects
public abstract class ModWithVisibilityAdjustment : Mod, IReadFromConfig, IApplicableToBeatmap, IApplicableToDrawableHitObject
{
/// <summary>
/// The first adjustable object.
@ -73,19 +73,16 @@ namespace osu.Game.Rulesets.Mods
}
}
public virtual void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables)
public virtual void ApplyToDrawableHitObject(DrawableHitObject dho)
{
foreach (var dho in drawables)
dho.ApplyCustomUpdateState += (o, state) =>
{
dho.ApplyCustomUpdateState += (o, state) =>
{
// Increased visibility is applied to the entire first object, including all of its nested hitobjects.
if (IncreaseFirstObjectVisibility.Value && isObjectEqualToOrNestedIn(o.HitObject, FirstObject))
ApplyIncreasedVisibilityState(o, state);
else
ApplyNormalVisibilityState(o, state);
};
}
// Increased visibility is applied to the entire first object, including all of its nested hitobjects.
if (IncreaseFirstObjectVisibility.Value && isObjectEqualToOrNestedIn(o.HitObject, FirstObject))
ApplyIncreasedVisibilityState(o, state);
else
ApplyNormalVisibilityState(o, state);
};
}
/// <summary>

View File

@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mods
Mods = mods;
}
public override Mod CreateCopy() => new MultiMod(Mods.Select(m => m.CreateCopy()).ToArray());
public override Mod DeepClone() => new MultiMod(Mods.Select(m => m.DeepClone()).ToArray());
public override Type[] IncompatibleMods => Mods.SelectMany(m => m.IncompatibleMods).ToArray();
}

View File

@ -11,7 +11,6 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Threading;
using osu.Game.Audio;
@ -157,10 +156,11 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// If <c>null</c>, a hitobject is expected to be later applied via <see cref="PoolableDrawableWithLifetime{TEntry}.Apply"/> (or automatically via pooling).
/// </param>
protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null)
: base(initialHitObject != null ? new SyntheticHitObjectEntry(initialHitObject) : null)
{
if (Entry != null)
ensureEntryHasResult();
if (initialHitObject == null) return;
Entry = new SyntheticHitObjectEntry(initialHitObject);
ensureEntryHasResult();
}
[BackgroundDependencyLoader]
@ -172,7 +172,13 @@ namespace osu.Game.Rulesets.Objects.Drawables
base.AddInternal(Samples = new PausableSkinnableSound());
CurrentSkin = skinSource;
CurrentSkin.SourceChanged += onSkinSourceChanged;
CurrentSkin.SourceChanged += skinSourceChanged;
}
protected override void LoadAsyncComplete()
{
base.LoadAsyncComplete();
skinChanged();
}
protected override void LoadComplete()
@ -187,7 +193,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// <summary>
/// Applies a hit object to be represented by this <see cref="DrawableHitObject"/>.
/// </summary>
[Obsolete("Use either overload of Apply that takes a single argument of type HitObject or HitObjectLifetimeEntry")]
[Obsolete("Use either overload of Apply that takes a single argument of type HitObject or HitObjectLifetimeEntry")] // Can be removed 20211021.
public void Apply([NotNull] HitObject hitObject, [CanBeNull] HitObjectLifetimeEntry lifetimeEntry)
{
if (lifetimeEntry != null)
@ -305,6 +311,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// <summary>
/// Invoked for this <see cref="DrawableHitObject"/> to take on any values from a newly-applied <see cref="HitObject"/>.
/// This is also fired after any changes which occurred via an <see cref="osu.Game.Rulesets.Objects.HitObject.ApplyDefaults"/> call.
/// </summary>
protected virtual void OnApply()
{
@ -312,6 +319,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// <summary>
/// Invoked for this <see cref="DrawableHitObject"/> to revert any values previously taken on from the currently-applied <see cref="HitObject"/>.
/// This is also fired after any changes which occurred via an <see cref="osu.Game.Rulesets.Objects.HitObject.ApplyDefaults"/> call.
/// </summary>
protected virtual void OnFree()
{
@ -396,18 +404,13 @@ namespace osu.Game.Rulesets.Objects.Drawables
clearExistingStateTransforms();
using (BeginAbsoluteSequence(transformTime, true))
using (BeginAbsoluteSequence(transformTime))
UpdateInitialTransforms();
using (BeginAbsoluteSequence(StateUpdateTime, true))
using (BeginAbsoluteSequence(StateUpdateTime))
UpdateStartTimeStateTransforms();
#pragma warning disable 618
using (BeginAbsoluteSequence(StateUpdateTime + (Result?.TimeOffset ?? 0), true))
UpdateStateTransforms(newState);
#pragma warning restore 618
using (BeginAbsoluteSequence(HitStateUpdateTime, true))
using (BeginAbsoluteSequence(HitStateUpdateTime))
UpdateHitStateTransforms(newState);
state.Value = newState;
@ -437,12 +440,10 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// <summary>
/// Apply (generally fade-in) transforms leading into the <see cref="HitObject"/> start time.
/// The local drawable hierarchy is recursively delayed to <see cref="LifetimeEntry.LifetimeStart"/> for convenience.
///
/// By default this will fade in the object from zero with no duration.
/// By default, this will fade in the object from zero with no duration.
/// </summary>
/// <remarks>
/// This is called once before every <see cref="UpdateStateTransforms"/>. This is to ensure a good state in the case
/// This is called once before every <see cref="UpdateHitStateTransforms"/>. This is to ensure a good state in the case
/// the <see cref="JudgementResult.TimeOffset"/> was negative and potentially altered the pre-hit transforms.
/// </remarks>
protected virtual void UpdateInitialTransforms()
@ -450,16 +451,6 @@ namespace osu.Game.Rulesets.Objects.Drawables
this.FadeInFromZero();
}
/// <summary>
/// Apply transforms based on the current <see cref="ArmedState"/>. Previous states are automatically cleared.
/// In the case of a non-idle <see cref="ArmedState"/>, and if <see cref="Drawable.LifetimeEnd"/> was not set during this call, <see cref="Drawable.Expire"/> will be invoked.
/// </summary>
/// <param name="state">The new armed state.</param>
[Obsolete("Use UpdateStartTimeStateTransforms and UpdateHitStateTransforms instead")] // Can be removed 20210504
protected virtual void UpdateStateTransforms(ArmedState state)
{
}
/// <summary>
/// Apply passive transforms at the <see cref="HitObject"/>'s StartTime.
/// This is called each time <see cref="State"/> changes.
@ -495,7 +486,9 @@ namespace osu.Game.Rulesets.Objects.Drawables
protected ISkinSource CurrentSkin { get; private set; }
private void onSkinSourceChanged() => Scheduler.AddOnce(() =>
private void skinSourceChanged() => Scheduler.AddOnce(skinChanged);
private void skinChanged()
{
UpdateComboColour();
@ -503,7 +496,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (IsLoaded)
updateState(State.Value, true);
});
}
protected void UpdateComboColour()
{
@ -512,23 +505,6 @@ namespace osu.Game.Rulesets.Objects.Drawables
AccentColour.Value = combo.GetComboColour(CurrentSkin);
}
/// <summary>
/// Called to retrieve the combo colour. Automatically assigned to <see cref="AccentColour"/>.
/// Defaults to using <see cref="IHasComboInformation.ComboIndex"/> to decide on a colour.
/// </summary>
/// <remarks>
/// This will only be called if the <see cref="HitObject"/> implements <see cref="IHasComboInformation"/>.
/// </remarks>
/// <param name="comboColours">A list of combo colours provided by the beatmap or skin. Can be null if not available.</param>
[Obsolete("Unused. Implement IHasComboInformation and IHasComboInformation.GetComboColour() on the HitObject model instead.")] // Can be removed 20210527
protected virtual Color4 GetComboColour(IReadOnlyList<Color4> comboColours)
{
if (!(HitObject is IHasComboInformation combo))
throw new InvalidOperationException($"{nameof(HitObject)} must implement {nameof(IHasComboInformation)}");
return comboColours?.Count > 0 ? comboColours[combo.ComboIndex % comboColours.Count] : Color4.White;
}
/// <summary>
/// Called when a change is made to the skin.
/// </summary>
@ -612,23 +588,17 @@ namespace osu.Game.Rulesets.Objects.Drawables
protected internal new ScheduledDelegate Schedule(Action action) => base.Schedule(action);
/// <summary>
/// A safe offset prior to the start time of <see cref="HitObject"/> at which this <see cref="DrawableHitObject"/> may begin displaying contents.
/// An offset prior to the start time of <see cref="HitObject"/> at which this <see cref="DrawableHitObject"/> may begin displaying contents.
/// By default, <see cref="DrawableHitObject"/>s are assumed to display their contents within 10 seconds prior to the start time of <see cref="HitObject"/>.
/// </summary>
/// <remarks>
/// This is only used as an optimisation to delay the initial update of this <see cref="DrawableHitObject"/> and may be tuned more aggressively if required.
/// It is indirectly used to decide the automatic transform offset provided to <see cref="UpdateInitialTransforms"/>.
/// A more accurate <see cref="LifetimeEntry.LifetimeStart"/> should be set for further optimisation (in <see cref="LoadComplete"/>, for example).
/// <para>
/// Only has an effect if this <see cref="DrawableHitObject"/> is not being pooled.
/// For pooled <see cref="DrawableHitObject"/>s, use <see cref="HitObjectLifetimeEntry.InitialLifetimeOffset"/> instead.
/// </para>
/// The initial transformation (<see cref="UpdateInitialTransforms"/>) starts at this offset before the start time of <see cref="HitObject"/>.
/// </remarks>
protected virtual double InitialLifetimeOffset => 10000;
/// <summary>
/// The time at which state transforms should be applied that line up to <see cref="HitObject"/>'s StartTime.
/// This is used to offset calls to <see cref="UpdateStateTransforms"/>.
/// This is used to offset calls to <see cref="UpdateStartTimeStateTransforms"/>.
/// </summary>
public double StateUpdateTime => HitObject.StartTime;
@ -746,7 +716,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (HitObject != null)
HitObject.DefaultsApplied -= onDefaultsApplied;
CurrentSkin.SourceChanged -= onSkinSourceChanged;
if (CurrentSkin != null)
CurrentSkin.SourceChanged -= skinSourceChanged;
}
}

View File

@ -1,31 +0,0 @@
// 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 osu.Framework.Bindables;
using osu.Framework.Graphics;
namespace osu.Game.Rulesets.Objects.Drawables
{
/// <summary>
/// An interface that exposes properties required for scrolling hit objects to be properly displayed.
/// </summary>
internal interface IScrollingHitObject : IDrawable
{
/// <summary>
/// Time offset before the hit object start time at which this <see cref="IScrollingHitObject"/> becomes visible and the time offset
/// after the hit object's end time after which it expires.
///
/// <para>
/// This provides only a default life time range, however classes inheriting from <see cref="IScrollingHitObject"/> should override
/// their life times if more tight control is desired.
/// </para>
/// </summary>
BindableDouble LifetimeOffset { get; }
/// <summary>
/// Axes which this <see cref="IScrollingHitObject"/> will scroll through.
/// This is set by the container which this scrolls through.
/// </summary>
Axes ScrollingAxes { set; }
}
}

View File

@ -35,7 +35,11 @@ namespace osu.Game.Rulesets.Objects
HitObject = hitObject;
startTimeBindable.BindTo(HitObject.StartTimeBindable);
startTimeBindable.BindValueChanged(onStartTimeChanged, true);
startTimeBindable.BindValueChanged(_ => SetInitialLifetime(), true);
// Subscribe to this event before the DrawableHitObject so that the local callback is invoked before the entry is re-applied as a result of DefaultsApplied.
// This way, the DrawableHitObject can use OnApply() to overwrite the LifetimeStart that was set inside setInitialLifetime().
HitObject.DefaultsApplied += _ => SetInitialLifetime();
}
// The lifetime, as set by the hitobject.
@ -82,15 +86,14 @@ namespace osu.Game.Rulesets.Objects
/// By default, <see cref="HitObject"/>s are assumed to display their contents within 10 seconds prior to their start time.
/// </summary>
/// <remarks>
/// This is only used as an optimisation to delay the initial update of the <see cref="HitObject"/> and may be tuned more aggressively if required.
/// It is indirectly used to decide the automatic transform offset provided to <see cref="DrawableHitObject.UpdateInitialTransforms"/>.
/// A more accurate <see cref="LifetimeEntry.LifetimeStart"/> should be set for further optimisation (in <see cref="DrawableHitObject.LoadComplete"/>, for example).
/// This is only used as an optimisation to delay the initial application of the <see cref="HitObject"/> to a <see cref="DrawableHitObject"/>.
/// A more accurate <see cref="LifetimeEntry.LifetimeStart"/> should be set on the hit object application, for further optimisation.
/// </remarks>
protected virtual double InitialLifetimeOffset => 10000;
/// <summary>
/// Resets <see cref="LifetimeEntry.LifetimeStart"/> according to the change in start time of the <see cref="HitObject"/>.
/// Set <see cref="LifetimeEntry.LifetimeStart"/> using <see cref="InitialLifetimeOffset"/>.
/// </summary>
private void onStartTimeChanged(ValueChangedEvent<double> startTime) => LifetimeStart = HitObject.StartTime - InitialLifetimeOffset;
internal void SetInitialLifetime() => LifetimeStart = HitObject.StartTime - InitialLifetimeOffset;
}
}

View File

@ -2,15 +2,20 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Objects.Legacy.Catch
{
/// <summary>
/// Legacy osu!catch Hit-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertHit : ConvertHitObject, IHasCombo, IHasXPosition
internal sealed class ConvertHit : ConvertHitObject, IHasPosition, IHasCombo
{
public float X { get; set; }
public float X => Position.X;
public float Y => Position.Y;
public Vector2 Position { get; set; }
public bool NewCombo { get; set; }
}

View File

@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch
return new ConvertHit
{
X = position.X,
Position = position,
NewCombo = newCombo,
};
}
@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch
return new ConvertSlider
{
X = position.X,
Position = position,
NewCombo = FirstObject || newCombo,
Path = new SliderPath(controlPoints, length),
NodeSamples = nodeSamples,

View File

@ -2,15 +2,20 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Objects.Legacy.Catch
{
/// <summary>
/// Legacy osu!catch Slider-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasXPosition, IHasCombo
internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition, IHasCombo
{
public float X { get; set; }
public float X => Position.X;
public float Y => Position.Y;
public Vector2 Position { get; set; }
public bool NewCombo { get; set; }
}

View File

@ -3,7 +3,9 @@
#nullable enable
using System;
using System.Diagnostics;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Pooling;
@ -15,27 +17,59 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// <typeparam name="TEntry">The <see cref="LifetimeEntry"/> type storing state and controlling this drawable.</typeparam>
public abstract class PoolableDrawableWithLifetime<TEntry> : PoolableDrawable where TEntry : LifetimeEntry
{
private TEntry? entry;
/// <summary>
/// The entry holding essential state of this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
/// </summary>
protected TEntry? Entry { get; private set; }
/// <remarks>
/// If a non-null value is set before loading is started, the entry is applied when the loading is completed.
/// It is not valid to set an entry while this <see cref="PoolableDrawableWithLifetime{TEntry}"/> is loading.
/// </remarks>
public TEntry? Entry
{
get => entry;
set
{
if (LoadState == LoadState.NotLoaded)
entry = value;
else if (value != null)
Apply(value);
else if (HasEntryApplied)
free();
}
}
/// <summary>
/// Whether <see cref="Entry"/> is applied to this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
/// When an initial entry is specified in the constructor, <see cref="Entry"/> is set but not applied until loading is completed.
/// When an <see cref="Entry"/> is set during initialization, it is not applied until loading is completed.
/// </summary>
protected bool HasEntryApplied { get; private set; }
public override double LifetimeStart
{
get => base.LifetimeStart;
set => setLifetime(value, LifetimeEnd);
set
{
if (Entry == null && LifetimeStart != value)
throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime<TEntry>)} when entry is not set");
if (Entry != null)
Entry.LifetimeStart = value;
}
}
public override double LifetimeEnd
{
get => base.LifetimeEnd;
set => setLifetime(LifetimeStart, value);
set
{
if (Entry == null && LifetimeEnd != value)
throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime<TEntry>)} when entry is not set");
if (Entry != null)
Entry.LifetimeEnd = value;
}
}
public override bool RemoveWhenNotAlive => false;
@ -50,9 +84,9 @@ namespace osu.Game.Rulesets.Objects.Pooling
{
base.LoadAsyncComplete();
// Apply the initial entry given in the constructor.
// Apply the initial entry.
if (Entry != null && !HasEntryApplied)
Apply(Entry);
apply(Entry);
}
/// <summary>
@ -61,15 +95,10 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// </summary>
public void Apply(TEntry entry)
{
if (HasEntryApplied)
free();
if (LoadState == LoadState.Loading)
throw new InvalidOperationException($"Cannot apply a new {nameof(TEntry)} while currently loading.");
setLifetime(entry.LifetimeStart, entry.LifetimeEnd);
Entry = entry;
OnApply(entry);
HasEntryApplied = true;
apply(entry);
}
protected sealed override void FreeAfterUse()
@ -95,16 +124,18 @@ namespace osu.Game.Rulesets.Objects.Pooling
{
}
private void setLifetime(double start, double end)
private void apply(TEntry entry)
{
base.LifetimeStart = start;
base.LifetimeEnd = end;
if (HasEntryApplied)
free();
if (Entry != null)
{
Entry.LifetimeStart = start;
Entry.LifetimeEnd = end;
}
this.entry = entry;
entry.LifetimeChanged += setLifetimeFromEntry;
setLifetimeFromEntry(entry);
OnApply(entry);
HasEntryApplied = true;
}
private void free()
@ -113,10 +144,19 @@ namespace osu.Game.Rulesets.Objects.Pooling
OnFree(Entry);
Entry = null;
setLifetime(double.MaxValue, double.MaxValue);
Entry.LifetimeChanged -= setLifetimeFromEntry;
entry = null;
base.LifetimeStart = double.MinValue;
base.LifetimeEnd = double.MaxValue;
HasEntryApplied = false;
}
private void setLifetimeFromEntry(LifetimeEntry entry)
{
Debug.Assert(entry == Entry);
base.LifetimeStart = entry.LifetimeStart;
base.LifetimeEnd = entry.LifetimeEnd;
}
}
}

View File

@ -0,0 +1,163 @@
// 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 enable
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Performance;
namespace osu.Game.Rulesets.Objects.Pooling
{
/// <summary>
/// A container of <typeparamref name="TDrawable"/>s dynamically added/removed by model <typeparamref name="TEntry"/>s.
/// When an entry became alive, a drawable corresponding to the entry is obtained (potentially pooled), and added to this container.
/// The drawable is removed when the entry became dead.
/// </summary>
/// <typeparam name="TEntry">The type of entries managed by this container.</typeparam>
/// <typeparam name="TDrawable">The type of drawables corresponding to the entries.</typeparam>
public abstract class PooledDrawableWithLifetimeContainer<TEntry, TDrawable> : CompositeDrawable
where TEntry : LifetimeEntry
where TDrawable : Drawable
{
/// <summary>
/// All entries added to this container, including dead entries.
/// </summary>
/// <remarks>
/// The enumeration order is undefined.
/// </remarks>
public IEnumerable<TEntry> Entries => allEntries;
/// <summary>
/// All alive entries and drawables corresponding to the entries.
/// </summary>
/// <remarks>
/// The enumeration order is undefined.
/// </remarks>
public IEnumerable<(TEntry Entry, TDrawable Drawable)> AliveEntries => aliveDrawableMap.Select(x => (x.Key, x.Value));
/// <summary>
/// Whether to remove an entry when clock goes backward and crossed its <see cref="LifetimeEntry.LifetimeStart"/>.
/// Used when entries are dynamically added at its <see cref="LifetimeEntry.LifetimeStart"/> to prevent duplicated entries.
/// </summary>
protected virtual bool RemoveRewoundEntry => false;
/// <summary>
/// The amount of time prior to the current time within which entries should be considered alive.
/// </summary>
internal double PastLifetimeExtension { get; set; }
/// <summary>
/// The amount of time after the current time within which entries should be considered alive.
/// </summary>
internal double FutureLifetimeExtension { get; set; }
private readonly Dictionary<TEntry, TDrawable> aliveDrawableMap = new Dictionary<TEntry, TDrawable>();
private readonly HashSet<TEntry> allEntries = new HashSet<TEntry>();
private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager();
protected PooledDrawableWithLifetimeContainer()
{
lifetimeManager.EntryBecameAlive += entryBecameAlive;
lifetimeManager.EntryBecameDead += entryBecameDead;
lifetimeManager.EntryCrossedBoundary += entryCrossedBoundary;
}
/// <summary>
/// Add a <typeparamref name="TEntry"/> to be managed by this container.
/// </summary>
/// <remarks>
/// The aliveness of the entry is not updated until <see cref="CheckChildrenLife"/>.
/// </remarks>
public virtual void Add(TEntry entry)
{
allEntries.Add(entry);
lifetimeManager.AddEntry(entry);
}
/// <summary>
/// Remove a <typeparamref name="TEntry"/> from this container.
/// </summary>
/// <remarks>
/// If the entry was alive, the corresponding drawable is removed.
/// </remarks>
/// <returns>Whether the entry was in this container.</returns>
public virtual bool Remove(TEntry entry)
{
if (!lifetimeManager.RemoveEntry(entry)) return false;
allEntries.Remove(entry);
return true;
}
/// <summary>
/// Initialize new <typeparamref name="TDrawable"/> corresponding <paramref name="entry"/>.
/// </summary>
/// <returns>The <typeparamref name="TDrawable"/> corresponding to the entry.</returns>
protected abstract TDrawable GetDrawable(TEntry entry);
private void entryBecameAlive(LifetimeEntry lifetimeEntry)
{
var entry = (TEntry)lifetimeEntry;
Debug.Assert(!aliveDrawableMap.ContainsKey(entry));
TDrawable drawable = GetDrawable(entry);
aliveDrawableMap[entry] = drawable;
AddDrawable(entry, drawable);
}
/// <summary>
/// Add a <typeparamref name="TDrawable"/> corresponding to <paramref name="entry"/> to this container.
/// </summary>
/// <remarks>
/// Invoked when the entry became alive and a <typeparamref name="TDrawable"/> is obtained by <see cref="GetDrawable"/>.
/// </remarks>
protected virtual void AddDrawable(TEntry entry, TDrawable drawable) => AddInternal(drawable);
private void entryBecameDead(LifetimeEntry lifetimeEntry)
{
var entry = (TEntry)lifetimeEntry;
Debug.Assert(aliveDrawableMap.ContainsKey(entry));
TDrawable drawable = aliveDrawableMap[entry];
aliveDrawableMap.Remove(entry);
RemoveDrawable(entry, drawable);
}
/// <summary>
/// Remove a <typeparamref name="TDrawable"/> corresponding to <paramref name="entry"/> from this container.
/// </summary>
/// <remarks>
/// Invoked when the entry became dead.
/// </remarks>
protected virtual void RemoveDrawable(TEntry entry, TDrawable drawable) => RemoveInternal(drawable);
private void entryCrossedBoundary(LifetimeEntry lifetimeEntry, LifetimeBoundaryKind kind, LifetimeBoundaryCrossingDirection direction)
{
if (RemoveRewoundEntry && kind == LifetimeBoundaryKind.Start && direction == LifetimeBoundaryCrossingDirection.Backward)
Remove((TEntry)lifetimeEntry);
}
/// <summary>
/// Remove all <typeparamref name="TEntry"/>s.
/// </summary>
public void Clear()
{
foreach (var entry in Entries.ToArray())
Remove(entry);
Debug.Assert(aliveDrawableMap.Count == 0);
}
protected override bool CheckChildrenLife()
{
bool aliveChanged = base.CheckChildrenLife();
aliveChanged |= lifetimeManager.Update(Time.Current - PastLifetimeExtension, Time.Current + FutureLifetimeExtension);
return aliveChanged;
}
}
}

View File

@ -0,0 +1,19 @@
// 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 osu.Framework.Bindables;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Objects.Types
{
/// <summary>
/// A HitObject which has a preferred display colour. Will be used for editor timeline display.
/// </summary>
public interface IHasDisplayColour
{
/// <summary>
/// The current display colour of this hit object.
/// </summary>
Bindable<Color4> DisplayColour { get; }
}
}

View File

@ -1,40 +1,36 @@
// 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.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Replays
{
public abstract class AutoGenerator : IAutoGenerator
public abstract class AutoGenerator
{
/// <summary>
/// Creates the auto replay and returns it.
/// Every subclass of OsuAutoGeneratorBase should implement this!
/// The default duration of a key press in milliseconds.
/// </summary>
public abstract Replay Generate();
#region Parameters
public const double KEY_UP_DELAY = 50;
/// <summary>
/// The beatmap we're making.
/// The beatmap the autoplay is generated for.
/// </summary>
protected IBeatmap Beatmap;
#endregion
protected IBeatmap Beatmap { get; }
protected AutoGenerator(IBeatmap beatmap)
{
Beatmap = beatmap;
}
#region Constants
// Shared amongst all modes
public const double KEY_UP_DELAY = 50;
#endregion
/// <summary>
/// Generate the replay of the autoplay.
/// </summary>
public abstract Replay Generate();
protected virtual HitObject GetNextObject(int currentIndex)
{
@ -44,4 +40,37 @@ namespace osu.Game.Rulesets.Replays
return Beatmap.HitObjects[currentIndex + 1];
}
}
public abstract class AutoGenerator<TFrame> : AutoGenerator
where TFrame : ReplayFrame
{
/// <summary>
/// The replay frames of the autoplay.
/// </summary>
protected readonly List<TFrame> Frames = new List<TFrame>();
[CanBeNull]
protected TFrame LastFrame => Frames.Count == 0 ? null : Frames[^1];
protected AutoGenerator(IBeatmap beatmap)
: base(beatmap)
{
}
public sealed override Replay Generate()
{
Frames.Clear();
GenerateFrames();
return new Replay
{
Frames = Frames.OrderBy(frame => frame.Time).Cast<ReplayFrame>().ToList()
};
}
/// <summary>
/// Generate the replay frames of the autoplay and populate <see cref="Frames"/>.
/// </summary>
protected abstract void GenerateFrames();
}
}

View File

@ -5,7 +5,7 @@
using System;
using System.Collections.Generic;
using JetBrains.Annotations;
using System.Linq;
using osu.Game.Input.Handlers;
using osu.Game.Replays;
@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Replays
{
// TODO: This replay frame ordering should be enforced on the Replay type.
// Currently, the ordering can be broken if the frames are added after this construction.
replay.Frames.Sort((x, y) => x.Time.CompareTo(y.Time));
replay.Frames = replay.Frames.OrderBy(f => f.Time).ToList();
this.replay = replay;
currentFrameIndex = -1;
@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Replays
}
}
protected virtual bool IsImportant([NotNull] TFrame frame) => false;
protected virtual bool IsImportant(TFrame frame) => false;
/// <summary>
/// Update the current frame based on an incoming time value.

View File

@ -1,12 +0,0 @@
// 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 osu.Game.Replays;
namespace osu.Game.Rulesets.Replays
{
public interface IAutoGenerator
{
Replay Generate();
}
}

View File

@ -26,6 +26,7 @@ using JetBrains.Annotations;
using osu.Framework.Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Testing;
using osu.Game.Extensions;
using osu.Game.Rulesets.Filter;
using osu.Game.Screens.Ranking.Statistics;
@ -126,7 +127,7 @@ namespace osu.Game.Rulesets
[CanBeNull]
public ModAutoplay GetAutoplayMod() => GetAllMods().OfType<ModAutoplay>().FirstOrDefault();
public virtual ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => null;
public virtual ISkin CreateLegacySkinProvider([NotNull] ISkin skin, IBeatmap beatmap) => null;
protected Ruleset()
{
@ -135,7 +136,7 @@ namespace osu.Game.Rulesets
Name = Description,
ShortName = ShortName,
ID = (this as ILegacyRuleset)?.LegacyID,
InstantiationInfo = GetType().AssemblyQualifiedName,
InstantiationInfo = GetType().GetInvariantInstantiationInfo(),
Available = true,
};
}

View File

@ -3,8 +3,8 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Newtonsoft.Json;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
namespace osu.Game.Rulesets
@ -18,20 +18,7 @@ namespace osu.Game.Rulesets
public string ShortName { get; set; }
private string instantiationInfo;
public string InstantiationInfo
{
get => instantiationInfo;
set => instantiationInfo = abbreviateInstantiationInfo(value);
}
private string abbreviateInstantiationInfo(string value)
{
// exclude version onwards, matching only on namespace and type.
// this is mainly to allow for new versions of already loaded rulesets to "upgrade" from old.
return string.Join(',', value.Split(',').Take(2));
}
public string InstantiationInfo { get; set; }
[JsonIgnore]
public bool Available { get; set; }
@ -41,7 +28,7 @@ namespace osu.Game.Rulesets
{
if (!Available) return null;
var ruleset = (Ruleset)Activator.CreateInstance(Type.GetType(InstantiationInfo));
var ruleset = (Ruleset)Activator.CreateInstance(Type.GetType(InstantiationInfo).AsNonNull());
// overwrite the pre-populated RulesetInfo with a potentially database attached copy.
ruleset.RulesetInfo = this;

View File

@ -7,6 +7,7 @@ using System.IO;
using System.Linq;
using System.Reflection;
using osu.Framework;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Database;
@ -95,13 +96,25 @@ namespace osu.Game.Rulesets
context.SaveChanges();
// add any other modes
var existingRulesets = context.RulesetInfo.ToList();
// add any other rulesets which have assemblies present but are not yet in the database.
foreach (var r in instances.Where(r => !(r is ILegacyRuleset)))
{
if (existingRulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null)
context.RulesetInfo.Add(r.RulesetInfo);
{
var existingSameShortName = existingRulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName);
if (existingSameShortName != null)
{
// even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName.
// this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one.
// in such cases, update the instantiation info of the existing entry to point to the new one.
existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo;
}
else
context.RulesetInfo.Add(r.RulesetInfo);
}
}
context.SaveChanges();
@ -111,7 +124,7 @@ namespace osu.Game.Rulesets
{
try
{
var instanceInfo = ((Ruleset)Activator.CreateInstance(Type.GetType(r.InstantiationInfo))).RulesetInfo;
var instanceInfo = ((Ruleset)Activator.CreateInstance(Type.GetType(r.InstantiationInfo).AsNonNull())).RulesetInfo;
r.Name = instanceInfo.Name;
r.ShortName = instanceInfo.ShortName;

View File

@ -6,7 +6,6 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Utils;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
@ -146,7 +145,7 @@ namespace osu.Game.Rulesets.Scoring
rollingMaxBaseScore += result.Judgement.MaxNumericResult;
}
scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) + 1;
scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1;
hitEvents.Add(CreateHitEvent(result));
lastHitObject = result.HitObject;
@ -181,7 +180,7 @@ namespace osu.Game.Rulesets.Scoring
rollingMaxBaseScore -= result.Judgement.MaxNumericResult;
}
scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) - 1;
scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1;
Debug.Assert(hitEvents.Count > 0);
lastHitObject = hitEvents[^1].LastHitObject;
@ -272,8 +271,8 @@ namespace osu.Game.Rulesets.Scoring
private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1;
private double getBonusScore(Dictionary<HitResult, int> statistics)
=> statistics.GetOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE
+ statistics.GetOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE;
=> statistics.GetValueOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE
+ statistics.GetValueOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE;
private ScoreRank rankFrom(double acc)
{
@ -291,7 +290,7 @@ namespace osu.Game.Rulesets.Scoring
return ScoreRank.D;
}
public int GetStatistic(HitResult result) => scoreResultCounts.GetOrDefault(result);
public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result);
public double GetStandardisedScore() => getScore(ScoringMode.Standardised);
@ -339,7 +338,6 @@ namespace osu.Game.Rulesets.Scoring
score.MaxCombo = HighestCombo.Value;
score.Accuracy = Accuracy.Value;
score.Rank = Rank.Value;
score.Date = DateTimeOffset.Now;
foreach (var result in Enum.GetValues(typeof(HitResult)).OfType<HitResult>().Where(r => r.IsScorable()))
score.Statistics[result] = GetStatistic(result);

View File

@ -68,10 +68,7 @@ namespace osu.Game.Rulesets.UI
private bool frameStablePlayback = true;
/// <summary>
/// Whether to enable frame-stable playback.
/// </summary>
internal bool FrameStablePlayback
internal override bool FrameStablePlayback
{
get => frameStablePlayback;
set
@ -182,18 +179,11 @@ namespace osu.Game.Rulesets.UI
.WithChild(ResumeOverlay)));
}
RegenerateAutoplay();
applyRulesetMods(Mods, config);
loadObjects(cancellationToken ?? default);
}
public void RegenerateAutoplay()
{
// for now this is applying mods which aren't just autoplay.
// we'll need to reconsider this flow in the future.
applyRulesetMods(Mods, config);
}
/// <summary>
/// Creates and adds drawable representations of hit objects to the play field.
/// </summary>
@ -209,8 +199,11 @@ namespace osu.Game.Rulesets.UI
Playfield.PostProcess();
foreach (var mod in Mods.OfType<IApplicableToDrawableHitObjects>())
mod.ApplyToDrawableHitObjects(Playfield.AllHitObjects);
foreach (var mod in Mods.OfType<IApplicableToDrawableHitObject>())
{
foreach (var drawableHitObject in Playfield.AllHitObjects)
mod.ApplyToDrawableHitObject(drawableHitObject);
}
}
public override void RequestResume(Action continueResume)
@ -274,6 +267,12 @@ namespace osu.Game.Rulesets.UI
if (!(KeyBindingInputManager is IHasRecordingHandler recordingInputManager))
throw new InvalidOperationException($"A {nameof(KeyBindingInputManager)} which supports recording is not available");
if (score == null)
{
recordingInputManager.Recorder = null;
return;
}
var recorder = CreateReplayRecorder(score);
if (recorder == null)
@ -432,6 +431,11 @@ namespace osu.Game.Rulesets.UI
/// </summary>
public abstract IFrameStableClock FrameStableClock { get; }
/// <summary>
/// Whether to enable frame-stable playback.
/// </summary>
internal abstract bool FrameStablePlayback { get; set; }
/// <summary>
/// The mods which are to be applied.
/// </summary>
@ -485,15 +489,15 @@ namespace osu.Game.Rulesets.UI
{
get
{
foreach (var h in Objects)
foreach (var hitObject in Objects)
{
if (h.HitWindows.WindowFor(HitResult.Miss) > 0)
return h.HitWindows;
if (hitObject.HitWindows.WindowFor(HitResult.Miss) > 0)
return hitObject.HitWindows;
foreach (var n in h.NestedHitObjects)
foreach (var nested in hitObject.NestedHitObjects)
{
if (h.HitWindows.WindowFor(HitResult.Miss) > 0)
return n.HitWindows;
if (nested.HitWindows.WindowFor(HitResult.Miss) > 0)
return nested.HitWindows;
}
}
@ -518,7 +522,7 @@ namespace osu.Game.Rulesets.UI
/// Sets a replay to be used to record gameplay.
/// </summary>
/// <param name="score">The target to be recorded to.</param>
public abstract void SetRecordTarget(Score score);
public abstract void SetRecordTarget([CanBeNull] Score score);
/// <summary>
/// Invoked when the interactive user requests resuming from a paused state.

View File

@ -10,6 +10,7 @@ using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Shaders;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Platform;
@ -34,6 +35,11 @@ namespace osu.Game.Rulesets.UI
/// </remarks>
public ISampleStore SampleStore { get; }
/// <summary>
/// The shader manager to be used for the ruleset.
/// </summary>
public ShaderManager ShaderManager { get; }
/// <summary>
/// The ruleset config manager.
/// </summary>
@ -52,6 +58,9 @@ namespace osu.Game.Rulesets.UI
SampleStore = parent.Get<AudioManager>().GetSampleStore(new NamespacedResourceStore<byte[]>(resources, @"Samples"));
SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY;
CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get<ISampleStore>()));
ShaderManager = new ShaderManager(new NamespacedResourceStore<byte[]>(resources, @"Shaders"));
CacheAs(ShaderManager = new FallbackShaderManager(ShaderManager, parent.Get<ShaderManager>()));
}
RulesetConfigManager = parent.Get<RulesetConfigCache>().GetConfigFor(ruleset);
@ -84,6 +93,7 @@ namespace osu.Game.Rulesets.UI
SampleStore?.Dispose();
TextureStore?.Dispose();
ShaderManager?.Dispose();
RulesetConfigManager = null;
}
@ -172,5 +182,26 @@ namespace osu.Game.Rulesets.UI
primary?.Dispose();
}
}
private class FallbackShaderManager : ShaderManager
{
private readonly ShaderManager primary;
private readonly ShaderManager fallback;
public FallbackShaderManager(ShaderManager primary, ShaderManager fallback)
: base(new ResourceStore<byte[]>())
{
this.primary = primary;
this.fallback = fallback;
}
public override byte[] LoadRaw(string name) => primary.LoadRaw(name) ?? fallback.LoadRaw(name);
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
primary?.Dispose();
}
}
}
}

View File

@ -3,25 +3,23 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Performance;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Pooling;
namespace osu.Game.Rulesets.UI
{
public class HitObjectContainer : LifetimeManagementContainer, IHitObjectContainer
public class HitObjectContainer : PooledDrawableWithLifetimeContainer<HitObjectLifetimeEntry, DrawableHitObject>, IHitObjectContainer
{
public IEnumerable<DrawableHitObject> Objects => InternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime);
public IEnumerable<DrawableHitObject> AliveObjects => AliveInternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime);
public IEnumerable<DrawableHitObject> AliveObjects => AliveEntries.Select(pair => pair.Drawable).OrderBy(h => h.HitObject.StartTime);
/// <summary>
/// Invoked when a <see cref="DrawableHitObject"/> is judged.
@ -49,19 +47,9 @@ namespace osu.Game.Rulesets.UI
/// </remarks>
internal event Action<HitObject> HitObjectUsageFinished;
/// <summary>
/// The amount of time prior to the current time within which <see cref="HitObject"/>s should be considered alive.
/// </summary>
internal double PastLifetimeExtension { get; set; }
/// <summary>
/// The amount of time after the current time within which <see cref="HitObject"/>s should be considered alive.
/// </summary>
internal double FutureLifetimeExtension { get; set; }
private readonly Dictionary<DrawableHitObject, IBindable> startTimeMap = new Dictionary<DrawableHitObject, IBindable>();
private readonly Dictionary<HitObjectLifetimeEntry, DrawableHitObject> drawableMap = new Dictionary<HitObjectLifetimeEntry, DrawableHitObject>();
private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager();
private readonly Dictionary<HitObjectLifetimeEntry, DrawableHitObject> nonPooledDrawableMap = new Dictionary<HitObjectLifetimeEntry, DrawableHitObject>();
[Resolved(CanBeNull = true)]
private IPooledHitObjectProvider pooledObjectProvider { get; set; }
@ -69,9 +57,6 @@ namespace osu.Game.Rulesets.UI
public HitObjectContainer()
{
RelativeSizeAxes = Axes.Both;
lifetimeManager.EntryBecameAlive += entryBecameAlive;
lifetimeManager.EntryBecameDead += entryBecameDead;
}
protected override void LoadAsyncComplete()
@ -84,132 +69,88 @@ namespace osu.Game.Rulesets.UI
#region Pooling support
public void Add(HitObjectLifetimeEntry entry) => lifetimeManager.AddEntry(entry);
public bool Remove(HitObjectLifetimeEntry entry) => lifetimeManager.RemoveEntry(entry);
private void entryBecameAlive(LifetimeEntry entry) => addDrawable((HitObjectLifetimeEntry)entry);
private void entryBecameDead(LifetimeEntry entry) => removeDrawable((HitObjectLifetimeEntry)entry);
private void addDrawable(HitObjectLifetimeEntry entry)
public override bool Remove(HitObjectLifetimeEntry entry)
{
Debug.Assert(!drawableMap.ContainsKey(entry));
if (!base.Remove(entry)) return false;
var drawable = pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null);
if (drawable == null)
throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}.");
// This logic is not in `Remove(DrawableHitObject)` because a non-pooled drawable may be removed by specifying its entry.
if (nonPooledDrawableMap.Remove(entry, out var drawable))
removeDrawable(drawable);
return true;
}
protected sealed override DrawableHitObject GetDrawable(HitObjectLifetimeEntry entry)
{
if (nonPooledDrawableMap.TryGetValue(entry, out var drawable))
return drawable;
return pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null) ??
throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}.");
}
protected override void AddDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable)
{
if (nonPooledDrawableMap.ContainsKey(entry)) return;
addDrawable(drawable);
HitObjectUsageBegan?.Invoke(entry.HitObject);
}
protected override void RemoveDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable)
{
drawable.OnKilled();
if (nonPooledDrawableMap.ContainsKey(entry)) return;
removeDrawable(drawable);
HitObjectUsageFinished?.Invoke(entry.HitObject);
}
private void addDrawable(DrawableHitObject drawable)
{
drawable.OnNewResult += onNewResult;
drawable.OnRevertResult += onRevertResult;
bindStartTime(drawable);
AddInternal(drawableMap[entry] = drawable, false);
OnAdd(drawable);
HitObjectUsageBegan?.Invoke(entry.HitObject);
AddInternal(drawable);
}
private void removeDrawable(HitObjectLifetimeEntry entry)
private void removeDrawable(DrawableHitObject drawable)
{
Debug.Assert(drawableMap.ContainsKey(entry));
var drawable = drawableMap[entry];
// OnKilled can potentially change the hitobject's result, so it needs to run first before unbinding.
drawable.OnKilled();
drawable.OnNewResult -= onNewResult;
drawable.OnRevertResult -= onRevertResult;
drawableMap.Remove(entry);
OnRemove(drawable);
unbindStartTime(drawable);
RemoveInternal(drawable);
HitObjectUsageFinished?.Invoke(entry.HitObject);
RemoveInternal(drawable);
}
#endregion
#region Non-pooling support
public virtual void Add(DrawableHitObject hitObject)
public virtual void Add(DrawableHitObject drawable)
{
bindStartTime(hitObject);
if (drawable.Entry == null)
throw new InvalidOperationException($"May not add a {nameof(DrawableHitObject)} without {nameof(HitObject)} associated");
hitObject.OnNewResult += onNewResult;
hitObject.OnRevertResult += onRevertResult;
AddInternal(hitObject);
OnAdd(hitObject);
nonPooledDrawableMap.Add(drawable.Entry, drawable);
addDrawable(drawable);
Add(drawable.Entry);
}
public virtual bool Remove(DrawableHitObject hitObject)
public virtual bool Remove(DrawableHitObject drawable)
{
OnRemove(hitObject);
if (!RemoveInternal(hitObject))
if (drawable.Entry == null)
return false;
hitObject.OnNewResult -= onNewResult;
hitObject.OnRevertResult -= onRevertResult;
unbindStartTime(hitObject);
return true;
return Remove(drawable.Entry);
}
public int IndexOf(DrawableHitObject hitObject) => IndexOfInternal(hitObject);
protected override void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e)
{
if (!(e.Child is DrawableHitObject hitObject))
return;
if ((e.Kind == LifetimeBoundaryKind.End && e.Direction == LifetimeBoundaryCrossingDirection.Forward)
|| (e.Kind == LifetimeBoundaryKind.Start && e.Direction == LifetimeBoundaryCrossingDirection.Backward))
{
hitObject.OnKilled();
}
}
#endregion
/// <summary>
/// Invoked when a <see cref="DrawableHitObject"/> is added to this container.
/// </summary>
/// <remarks>
/// This method is not invoked for nested <see cref="DrawableHitObject"/>s.
/// </remarks>
protected virtual void OnAdd(DrawableHitObject drawableHitObject)
{
}
/// <summary>
/// Invoked when a <see cref="DrawableHitObject"/> is removed from this container.
/// </summary>
/// <remarks>
/// This method is not invoked for nested <see cref="DrawableHitObject"/>s.
/// </remarks>
protected virtual void OnRemove(DrawableHitObject drawableHitObject)
{
}
public virtual void Clear(bool disposeChildren = true)
{
lifetimeManager.ClearEntries();
ClearInternal(disposeChildren);
unbindAllStartTimes();
}
protected override bool CheckChildrenLife()
{
bool aliveChanged = base.CheckChildrenLife();
aliveChanged |= lifetimeManager.Update(Time.Current - PastLifetimeExtension, Time.Current + FutureLifetimeExtension);
return aliveChanged;
}
private void onNewResult(DrawableHitObject d, JudgementResult r) => NewResult?.Invoke(d, r);
private void onRevertResult(DrawableHitObject d, JudgementResult r) => RevertResult?.Invoke(d, r);

View File

@ -13,6 +13,7 @@ using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Mods;
using osuTK;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
namespace osu.Game.Rulesets.UI
{
@ -29,7 +30,7 @@ namespace osu.Game.Rulesets.UI
private const float size = 80;
public virtual string TooltipText => showTooltip ? mod.IconTooltip : null;
public virtual LocalisableString TooltipText => showTooltip ? mod.IconTooltip : null;
private Mod mod;
private readonly bool showTooltip;

View File

@ -354,8 +354,11 @@ namespace osu.Game.Rulesets.UI
// If this is the first time this DHO is being used, then apply the DHO mods.
// This is done before Apply() so that the state is updated once when the hitobject is applied.
foreach (var m in mods.OfType<IApplicableToDrawableHitObjects>())
m.ApplyToDrawableHitObjects(dho.Yield());
if (mods != null)
{
foreach (var m in mods.OfType<IApplicableToDrawableHitObject>())
m.ApplyToDrawableHitObject(dho);
}
}
if (!lifetimeEntryMap.TryGetValue(hitObject, out var entry))

View File

@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.UI
public int RecordFrameRate = 60;
[Resolved(canBeNull: true)]
private SpectatorStreamingClient spectatorStreaming { get; set; }
private SpectatorClient spectatorClient { get; set; }
[Resolved]
private GameplayBeatmap gameplayBeatmap { get; set; }
@ -49,13 +49,13 @@ namespace osu.Game.Rulesets.UI
inputManager = GetContainingInputManager();
spectatorStreaming?.BeginPlaying(gameplayBeatmap, target);
spectatorClient?.BeginPlaying(gameplayBeatmap, target);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
spectatorStreaming?.EndPlaying();
spectatorClient?.EndPlaying();
}
protected override void Update()
@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.UI
{
target.Replay.Frames.Add(frame);
spectatorStreaming?.HandleFrame(frame);
spectatorClient?.HandleFrame(frame);
}
}

View File

@ -13,6 +13,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Input.StateChanges.Events;
using osu.Framework.Input.States;
using osu.Game.Configuration;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.Input.Handlers;
using osu.Game.Screens.Play;
@ -29,12 +30,14 @@ namespace osu.Game.Rulesets.UI
{
set
{
if (recorder != null)
if (value != null && recorder != null)
throw new InvalidOperationException("Cannot attach more than one recorder");
recorder?.Expire();
recorder = value;
KeyBindingContainer.Add(recorder);
if (recorder != null)
KeyBindingContainer.Add(recorder);
}
}
@ -169,6 +172,13 @@ namespace osu.Game.Rulesets.UI
: base(ruleset, variant, unique)
{
}
protected override void ReloadMappings()
{
base.ReloadMappings();
KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
}
}
}

View File

@ -2,10 +2,12 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Layout;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osuTK;
@ -18,12 +20,21 @@ namespace osu.Game.Rulesets.UI.Scrolling
private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
/// <summary>
/// Hit objects which require lifetime computation in the next update call.
/// Whether the scrolling direction is horizontal or vertical.
/// </summary>
private readonly HashSet<DrawableHitObject> toComputeLifetime = new HashSet<DrawableHitObject>();
private Direction scrollingAxis => direction.Value == ScrollingDirection.Left || direction.Value == ScrollingDirection.Right ? Direction.Horizontal : Direction.Vertical;
/// <summary>
/// A set containing all <see cref="HitObjectContainer.AliveObjects"/> which have an up-to-date layout.
/// The scrolling axis is inverted if objects temporally farther in the future have a smaller position value across the scrolling axis.
/// </summary>
/// <example>
/// <see cref="ScrollingDirection.Down"/> is inverted, because given two objects, one of which is at the current time and one of which is 1000ms in the future,
/// in the current time instant the future object is spatially above the current object, and therefore has a smaller value of the Y coordinate of its position.
/// </example>
private bool axisInverted => direction.Value == ScrollingDirection.Down || direction.Value == ScrollingDirection.Right;
/// <summary>
/// A set of top-level <see cref="DrawableHitObject"/>s which have an up-to-date layout.
/// </summary>
private readonly HashSet<DrawableHitObject> layoutComputed = new HashSet<DrawableHitObject>();
@ -50,181 +61,107 @@ namespace osu.Game.Rulesets.UI.Scrolling
timeRange.ValueChanged += _ => layoutCache.Invalidate();
}
public override void Clear(bool disposeChildren = true)
/// <summary>
/// Given a position at <paramref name="currentTime"/>, return the time of the object corresponding to the position.
/// </summary>
/// <remarks>
/// If there are multiple valid time values, one arbitrary time is returned.
/// </remarks>
public double TimeAtPosition(float localPosition, double currentTime)
{
base.Clear(disposeChildren);
toComputeLifetime.Clear();
layoutComputed.Clear();
float scrollPosition = axisInverted ? -localPosition : localPosition;
return scrollingInfo.Algorithm.TimeAt(scrollPosition, currentTime, timeRange.Value, scrollLength);
}
/// <summary>
/// Given a position in screen space, return the time within this column.
/// Given a position at the current time in screen space, return the time of the object corresponding the position.
/// </summary>
/// <remarks>
/// If there are multiple valid time values, one arbitrary time is returned.
/// </remarks>
public double TimeAtScreenSpacePosition(Vector2 screenSpacePosition)
{
// convert to local space of column so we can snap and fetch correct location.
Vector2 localPosition = ToLocalSpace(screenSpacePosition);
float position = 0;
switch (scrollingInfo.Direction.Value)
{
case ScrollingDirection.Up:
case ScrollingDirection.Down:
position = localPosition.Y;
break;
case ScrollingDirection.Right:
case ScrollingDirection.Left:
position = localPosition.X;
break;
}
flipPositionIfRequired(ref position);
return scrollingInfo.Algorithm.TimeAt(position, Time.Current, scrollingInfo.TimeRange.Value, getLength());
Vector2 pos = ToLocalSpace(screenSpacePosition);
float localPosition = scrollingAxis == Direction.Horizontal ? pos.X : pos.Y;
localPosition -= axisInverted ? scrollLength : 0;
return TimeAtPosition(localPosition, Time.Current);
}
/// <summary>
/// Given a time, return the screen space position within this column.
/// Given a time, return the position along the scrolling axis within this <see cref="HitObjectContainer"/> at time <paramref name="currentTime"/>.
/// </summary>
public float PositionAtTime(double time, double currentTime)
{
float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength);
return axisInverted ? -scrollPosition : scrollPosition;
}
/// <summary>
/// Given a time, return the position along the scrolling axis within this <see cref="HitObjectContainer"/> at the current time.
/// </summary>
public float PositionAtTime(double time) => PositionAtTime(time, Time.Current);
/// <summary>
/// Given a time, return the screen space position within this <see cref="HitObjectContainer"/>.
/// In the non-scrolling axis, the center of this <see cref="HitObjectContainer"/> is returned.
/// </summary>
public Vector2 ScreenSpacePositionAtTime(double time)
{
var pos = scrollingInfo.Algorithm.PositionAt(time, Time.Current, scrollingInfo.TimeRange.Value, getLength());
flipPositionIfRequired(ref pos);
switch (scrollingInfo.Direction.Value)
{
case ScrollingDirection.Up:
case ScrollingDirection.Down:
return ToScreenSpace(new Vector2(getBreadth() / 2, pos));
default:
return ToScreenSpace(new Vector2(pos, getBreadth() / 2));
}
}
private float getLength()
{
switch (scrollingInfo.Direction.Value)
{
case ScrollingDirection.Left:
case ScrollingDirection.Right:
return DrawWidth;
default:
return DrawHeight;
}
}
private float getBreadth()
{
switch (scrollingInfo.Direction.Value)
{
case ScrollingDirection.Up:
case ScrollingDirection.Down:
return DrawWidth;
default:
return DrawHeight;
}
}
private void flipPositionIfRequired(ref float position)
{
// We're dealing with screen coordinates in which the position decreases towards the centre of the screen resulting in an increase in start time.
// The scrolling algorithm instead assumes a top anchor meaning an increase in time corresponds to an increase in position,
// so when scrolling downwards the coordinates need to be flipped.
switch (scrollingInfo.Direction.Value)
{
case ScrollingDirection.Down:
position = DrawHeight - position;
break;
case ScrollingDirection.Right:
position = DrawWidth - position;
break;
}
}
protected override void OnAdd(DrawableHitObject drawableHitObject) => onAddRecursive(drawableHitObject);
protected override void OnRemove(DrawableHitObject drawableHitObject) => onRemoveRecursive(drawableHitObject);
private void onAddRecursive(DrawableHitObject hitObject)
{
invalidateHitObject(hitObject);
hitObject.DefaultsApplied += invalidateHitObject;
foreach (var nested in hitObject.NestedHitObjects)
onAddRecursive(nested);
}
private void onRemoveRecursive(DrawableHitObject hitObject)
{
toComputeLifetime.Remove(hitObject);
layoutComputed.Remove(hitObject);
hitObject.DefaultsApplied -= invalidateHitObject;
foreach (var nested in hitObject.NestedHitObjects)
onRemoveRecursive(nested);
float localPosition = PositionAtTime(time, Time.Current);
localPosition += axisInverted ? scrollLength : 0;
return scrollingAxis == Direction.Horizontal
? ToScreenSpace(new Vector2(localPosition, DrawHeight / 2))
: ToScreenSpace(new Vector2(DrawWidth / 2, localPosition));
}
/// <summary>
/// Make this <see cref="DrawableHitObject"/> lifetime and layout computed in next update.
/// Given a start time and end time of a scrolling object, return the length of the object along the scrolling axis.
/// </summary>
private void invalidateHitObject(DrawableHitObject hitObject)
public float LengthAtTime(double startTime, double endTime)
{
// Lifetime computation is delayed until next update because
// when the hit object is not pooled this container is not loaded here and `scrollLength` cannot be computed.
toComputeLifetime.Add(hitObject);
layoutComputed.Remove(hitObject);
return scrollingInfo.Algorithm.GetLength(startTime, endTime, timeRange.Value, scrollLength);
}
private float scrollLength;
private float scrollLength => scrollingAxis == Direction.Horizontal ? DrawWidth : DrawHeight;
protected override void AddDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable)
{
base.AddDrawable(entry, drawable);
invalidateHitObject(drawable);
drawable.DefaultsApplied += invalidateHitObject;
}
protected override void RemoveDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable)
{
base.RemoveDrawable(entry, drawable);
drawable.DefaultsApplied -= invalidateHitObject;
layoutComputed.Remove(drawable);
}
private void invalidateHitObject(DrawableHitObject hitObject)
{
hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject);
layoutComputed.Remove(hitObject);
}
protected override void Update()
{
base.Update();
if (!layoutCache.IsValid)
{
toComputeLifetime.Clear();
if (layoutCache.IsValid) return;
foreach (var hitObject in Objects)
{
if (hitObject.HitObject != null)
toComputeLifetime.Add(hitObject);
}
layoutComputed.Clear();
layoutComputed.Clear();
// Reset lifetime to the conservative estimation.
// If a drawable becomes alive by this lifetime, its lifetime will be updated to a more precise lifetime in the next update.
foreach (var entry in Entries)
entry.SetInitialLifetime();
scrollingInfo.Algorithm.Reset();
scrollingInfo.Algorithm.Reset();
switch (direction.Value)
{
case ScrollingDirection.Up:
case ScrollingDirection.Down:
scrollLength = DrawSize.Y;
break;
default:
scrollLength = DrawSize.X;
break;
}
layoutCache.Validate();
}
foreach (var hitObject in toComputeLifetime)
hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject);
toComputeLifetime.Clear();
layoutCache.Validate();
}
protected override void UpdateAfterChildrenLife()
@ -249,6 +186,9 @@ namespace osu.Game.Rulesets.UI.Scrolling
private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject)
{
// Origin position may be relative to the parent size
Debug.Assert(hitObject.Parent != null);
float originAdjustment = 0.0f;
// calculate the dimension of the part of the hitobject that should already be visible
@ -279,18 +219,11 @@ namespace osu.Game.Rulesets.UI.Scrolling
{
if (hitObject.HitObject is IHasDuration e)
{
switch (direction.Value)
{
case ScrollingDirection.Up:
case ScrollingDirection.Down:
hitObject.Height = scrollingInfo.Algorithm.GetLength(hitObject.HitObject.StartTime, e.EndTime, timeRange.Value, scrollLength);
break;
case ScrollingDirection.Left:
case ScrollingDirection.Right:
hitObject.Width = scrollingInfo.Algorithm.GetLength(hitObject.HitObject.StartTime, e.EndTime, timeRange.Value, scrollLength);
break;
}
float length = LengthAtTime(hitObject.HitObject.StartTime, e.EndTime);
if (scrollingAxis == Direction.Horizontal)
hitObject.Width = length;
else
hitObject.Height = length;
}
foreach (var obj in hitObject.NestedHitObjects)
@ -304,24 +237,12 @@ namespace osu.Game.Rulesets.UI.Scrolling
private void updatePosition(DrawableHitObject hitObject, double currentTime)
{
switch (direction.Value)
{
case ScrollingDirection.Up:
hitObject.Y = scrollingInfo.Algorithm.PositionAt(hitObject.HitObject.StartTime, currentTime, timeRange.Value, scrollLength);
break;
float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime);
case ScrollingDirection.Down:
hitObject.Y = -scrollingInfo.Algorithm.PositionAt(hitObject.HitObject.StartTime, currentTime, timeRange.Value, scrollLength);
break;
case ScrollingDirection.Left:
hitObject.X = scrollingInfo.Algorithm.PositionAt(hitObject.HitObject.StartTime, currentTime, timeRange.Value, scrollLength);
break;
case ScrollingDirection.Right:
hitObject.X = -scrollingInfo.Algorithm.PositionAt(hitObject.HitObject.StartTime, currentTime, timeRange.Value, scrollLength);
break;
}
if (scrollingAxis == Direction.Horizontal)
hitObject.X = position;
else
hitObject.Y = position;
}
}
}