1
0
mirror of https://github.com/ppy/osu.git synced 2025-03-19 04:17:27 +08:00

Merge pull request #12361 from Naxesss/verify-tab

Add basic AiMod-like features
This commit is contained in:
Dean Herbert 2021-04-14 00:35:15 +09:00 committed by GitHub
commit d076be82a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1348 additions and 136 deletions

View File

@ -0,0 +1,260 @@
// 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 NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Checks;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
{
[TestFixture]
public class CheckOffscreenObjectsTest
{
private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE * 0.5f;
private CheckOffscreenObjects check;
[SetUp]
public void Setup()
{
check = new CheckOffscreenObjects();
}
[Test]
public void TestCircleInCenter()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 3000,
Position = playfield_centre // Playfield is 640 x 480.
}
}
};
Assert.That(check.Run(beatmap), Is.Empty);
}
[Test]
public void TestCircleNearEdge()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 3000,
Position = new Vector2(5, 5)
}
}
};
Assert.That(check.Run(beatmap), Is.Empty);
}
[Test]
public void TestCircleNearEdgeStackedOffscreen()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 3000,
Position = new Vector2(5, 5),
StackHeight = 5
}
}
};
assertOffscreenCircle(beatmap);
}
[Test]
public void TestCircleOffscreen()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle
{
StartTime = 3000,
Position = new Vector2(0, 0)
}
}
};
assertOffscreenCircle(beatmap);
}
[Test]
public void TestSliderInCenter()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new Slider
{
StartTime = 3000,
Position = new Vector2(420, 240),
Path = new SliderPath(new[]
{
new PathControlPoint(new Vector2(0, 0), PathType.Linear),
new PathControlPoint(new Vector2(-100, 0))
}),
}
}
};
Assert.That(check.Run(beatmap), Is.Empty);
}
[Test]
public void TestSliderNearEdge()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new Slider
{
StartTime = 3000,
Position = playfield_centre,
Path = new SliderPath(new[]
{
new PathControlPoint(new Vector2(0, 0), PathType.Linear),
new PathControlPoint(new Vector2(0, -playfield_centre.Y + 5))
}),
}
}
};
Assert.That(check.Run(beatmap), Is.Empty);
}
[Test]
public void TestSliderNearEdgeStackedOffscreen()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new Slider
{
StartTime = 3000,
Position = playfield_centre,
Path = new SliderPath(new[]
{
new PathControlPoint(new Vector2(0, 0), PathType.Linear),
new PathControlPoint(new Vector2(0, -playfield_centre.Y + 5))
}),
StackHeight = 5
}
}
};
assertOffscreenSlider(beatmap);
}
[Test]
public void TestSliderOffscreenStart()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new Slider
{
StartTime = 3000,
Position = new Vector2(0, 0),
Path = new SliderPath(new[]
{
new PathControlPoint(new Vector2(0, 0), PathType.Linear),
new PathControlPoint(playfield_centre)
}),
}
}
};
assertOffscreenSlider(beatmap);
}
[Test]
public void TestSliderOffscreenEnd()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new Slider
{
StartTime = 3000,
Position = playfield_centre,
Path = new SliderPath(new[]
{
new PathControlPoint(new Vector2(0, 0), PathType.Linear),
new PathControlPoint(-playfield_centre)
}),
}
}
};
assertOffscreenSlider(beatmap);
}
[Test]
public void TestSliderOffscreenPath()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new Slider
{
StartTime = 3000,
Position = playfield_centre,
Path = new SliderPath(new[]
{
// Circular arc shoots over the top of the screen.
new PathControlPoint(new Vector2(0, 0), PathType.PerfectCurve),
new PathControlPoint(new Vector2(-100, -200)),
new PathControlPoint(new Vector2(100, -200))
}),
}
}
};
assertOffscreenSlider(beatmap);
}
private void assertOffscreenCircle(IBeatmap beatmap)
{
var issues = check.Run(beatmap).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckOffscreenObjects.IssueTemplateOffscreenCircle);
}
private void assertOffscreenSlider(IBeatmap beatmap)
{
var issues = check.Run(beatmap).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckOffscreenObjects.IssueTemplateOffscreenSlider);
}
}
}

View File

@ -0,0 +1,115 @@
// 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.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit.Checks
{
public class CheckOffscreenObjects : ICheck
{
// A close approximation for the bounding box of the screen in gameplay on 4:3 aspect ratio.
// Uses gameplay space coordinates (512 x 384 playfield / 640 x 480 screen area).
// See https://github.com/ppy/osu/pull/12361#discussion_r612199777 for reference.
private const int min_x = -67;
private const int min_y = -60;
private const int max_x = 579;
private const int max_y = 428;
// The amount of milliseconds to step through a slider path at a time
// (higher = more performant, but higher false-negative chance).
private const int path_step_size = 5;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Offscreen hitobjects");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateOffscreenCircle(this),
new IssueTemplateOffscreenSlider(this)
};
public IEnumerable<Issue> Run(IBeatmap beatmap)
{
foreach (var hitobject in beatmap.HitObjects)
{
switch (hitobject)
{
case Slider slider:
{
foreach (var issue in sliderIssues(slider))
yield return issue;
break;
}
case HitCircle circle:
{
if (isOffscreen(circle.StackedPosition, circle.Radius))
yield return new IssueTemplateOffscreenCircle(this).Create(circle);
break;
}
}
}
}
/// <summary>
/// Steps through points on the slider to ensure the entire path is on-screen.
/// Returns at most one issue.
/// </summary>
/// <param name="slider">The slider whose path to check.</param>
/// <returns></returns>
private IEnumerable<Issue> sliderIssues(Slider slider)
{
for (int i = 0; i < slider.Distance; i += path_step_size)
{
double progress = i / slider.Distance;
Vector2 position = slider.StackedPositionAt(progress);
if (!isOffscreen(position, slider.Radius))
continue;
// `SpanDuration` ensures we don't include reverses.
double time = slider.StartTime + progress * slider.SpanDuration;
yield return new IssueTemplateOffscreenSlider(this).Create(slider, time);
yield break;
}
// Above loop may skip the last position in the slider due to step size.
if (!isOffscreen(slider.StackedEndPosition, slider.Radius))
yield break;
yield return new IssueTemplateOffscreenSlider(this).Create(slider, slider.EndTime);
}
private bool isOffscreen(Vector2 position, double radius)
{
return position.X - radius < min_x || position.X + radius > max_x ||
position.Y - radius < min_y || position.Y + radius > max_y;
}
public class IssueTemplateOffscreenCircle : IssueTemplate
{
public IssueTemplateOffscreenCircle(ICheck check)
: base(check, IssueType.Problem, "This circle goes offscreen on a 4:3 aspect ratio.")
{
}
public Issue Create(HitCircle circle) => new Issue(circle, this);
}
public class IssueTemplateOffscreenSlider : IssueTemplate
{
public IssueTemplateOffscreenSlider(ICheck check)
: base(check, IssueType.Problem, "This slider goes offscreen here on a 4:3 aspect ratio.")
{
}
public Issue Create(Slider slider, double offscreenTime) => new Issue(slider, this) { Time = offscreenTime };
}
}
}

View File

@ -0,0 +1,22 @@
// 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.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Osu.Edit.Checks;
namespace osu.Game.Rulesets.Osu.Edit
{
public class OsuBeatmapVerifier : IBeatmapVerifier
{
private readonly List<ICheck> checks = new List<ICheck>
{
new CheckOffscreenObjects()
};
public IEnumerable<Issue> Run(IBeatmap beatmap) => checks.SelectMany(check => check.Run(beatmap));
}
}

View File

@ -206,6 +206,8 @@ namespace osu.Game.Rulesets.Osu
public override HitObjectComposer CreateHitObjectComposer() => new OsuHitObjectComposer(this);
public override IBeatmapVerifier CreateBeatmapVerifier() => new OsuBeatmapVerifier();
public override string Description => "osu!";
public override string ShortName => SHORT_NAME;

View File

@ -0,0 +1,67 @@
// 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 NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Tests.Editing.Checks
{
[TestFixture]
public class CheckBackgroundTest
{
private CheckBackground check;
private IBeatmap beatmap;
[SetUp]
public void Setup()
{
check = new CheckBackground();
beatmap = new Beatmap<HitObject>
{
BeatmapInfo = new BeatmapInfo
{
Metadata = new BeatmapMetadata { BackgroundFile = "abc123.jpg" },
BeatmapSet = new BeatmapSetInfo
{
Files = new List<BeatmapSetFileInfo>(new[]
{
new BeatmapSetFileInfo { Filename = "abc123.jpg" }
})
}
}
};
}
[Test]
public void TestBackgroundSetAndInFiles()
{
Assert.That(check.Run(beatmap), Is.Empty);
}
[Test]
public void TestBackgroundSetAndNotInFiles()
{
beatmap.BeatmapInfo.BeatmapSet.Files.Clear();
var issues = check.Run(beatmap).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckBackground.IssueTemplateDoesNotExist);
}
[Test]
public void TestBackgroundNotSet()
{
beatmap.Metadata.BackgroundFile = null;
var issues = check.Run(beatmap).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckBackground.IssueTemplateNoneSet);
}
}
}

View File

@ -70,6 +70,7 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.F2 }, GlobalAction.EditorDesignMode),
new KeyBinding(new[] { InputKey.F3 }, GlobalAction.EditorTimingMode),
new KeyBinding(new[] { InputKey.F4 }, GlobalAction.EditorSetupMode),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.A }, GlobalAction.EditorVerifyMode),
};
public IEnumerable<KeyBinding> InGameKeyBindings => new[]
@ -247,5 +248,8 @@ namespace osu.Game.Input.Bindings
[Description("Beatmap Options")]
ToggleBeatmapOptions,
[Description("Verify mode")]
EditorVerifyMode,
}
}

View File

@ -0,0 +1,24 @@
// 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.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Edit
{
/// <summary>
/// A ruleset-agnostic beatmap verifier that identifies issues in common metadata or mapping standards.
/// </summary>
public class BeatmapVerifier : IBeatmapVerifier
{
private readonly List<ICheck> checks = new List<ICheck>
{
new CheckBackground(),
};
public IEnumerable<Issue> Run(IBeatmap beatmap) => checks.SelectMany(check => check.Run(beatmap));
}
}

View File

@ -0,0 +1,61 @@
// 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.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Edit.Checks
{
public class CheckBackground : ICheck
{
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Resources, "Missing background");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateNoneSet(this),
new IssueTemplateDoesNotExist(this)
};
public IEnumerable<Issue> Run(IBeatmap beatmap)
{
if (beatmap.Metadata.BackgroundFile == null)
{
yield return new IssueTemplateNoneSet(this).Create();
yield break;
}
// If the background is set, also make sure it still exists.
var set = beatmap.BeatmapInfo.BeatmapSet;
var file = set.Files.FirstOrDefault(f => f.Filename == beatmap.Metadata.BackgroundFile);
if (file != null)
yield break;
yield return new IssueTemplateDoesNotExist(this).Create(beatmap.Metadata.BackgroundFile);
}
public class IssueTemplateNoneSet : IssueTemplate
{
public IssueTemplateNoneSet(ICheck check)
: base(check, IssueType.Problem, "No background has been set.")
{
}
public Issue Create() => new Issue(this);
}
public class IssueTemplateDoesNotExist : IssueTemplate
{
public IssueTemplateDoesNotExist(ICheck check)
: base(check, IssueType.Problem, "The background file \"{0}\" does not exist.")
{
}
public Issue Create(string filename) => new Issue(this, filename);
}
}
}

View File

@ -0,0 +1,61 @@
// 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.
namespace osu.Game.Rulesets.Edit.Checks.Components
{
/// <summary>
/// The category of an issue.
/// </summary>
public enum CheckCategory
{
/// <summary>
/// Anything to do with control points.
/// </summary>
Timing,
/// <summary>
/// Anything to do with artist, title, creator, etc.
/// </summary>
Metadata,
/// <summary>
/// Anything to do with non-audio files, e.g. background, skin, sprites, and video.
/// </summary>
Resources,
/// <summary>
/// Anything to do with audio files, e.g. song and hitsounds.
/// </summary>
Audio,
/// <summary>
/// Anything to do with files that don't fit into the above, e.g. unused, osu, or osb.
/// </summary>
Files,
/// <summary>
/// Anything to do with hitobjects unrelated to spread.
/// </summary>
Compose,
/// <summary>
/// Anything to do with difficulty levels or their progression.
/// </summary>
Spread,
/// <summary>
/// Anything to do with variables like CS, OD, AR, HP, and global SV.
/// </summary>
Settings,
/// <summary>
/// Anything to do with hitobject feedback.
/// </summary>
HitObjects,
/// <summary>
/// Anything to do with storyboarding, breaks, video offset, etc.
/// </summary>
Events
}
}

View File

@ -0,0 +1,24 @@
// 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.
namespace osu.Game.Rulesets.Edit.Checks.Components
{
public class CheckMetadata
{
/// <summary>
/// The category this check belongs to. E.g. <see cref="CheckCategory.Metadata"/>, <see cref="CheckCategory.Timing"/>, or <see cref="CheckCategory.Compose"/>.
/// </summary>
public readonly CheckCategory Category;
/// <summary>
/// Describes the issue(s) that this check looks for. Keep this brief, such that it fits into "No {description}". E.g. "Offscreen objects" / "Too short sliders".
/// </summary>
public readonly string Description;
public CheckMetadata(CheckCategory category, string description)
{
Category = category;
Description = description;
}
}
}

View File

@ -0,0 +1,30 @@
// 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;
namespace osu.Game.Rulesets.Edit.Checks.Components
{
/// <summary>
/// A specific check that can be run on a beatmap to verify or find issues.
/// </summary>
public interface ICheck
{
/// <summary>
/// The metadata for this check.
/// </summary>
public CheckMetadata Metadata { get; }
/// <summary>
/// All possible templates for issues that this check may return.
/// </summary>
public IEnumerable<IssueTemplate> PossibleTemplates { get; }
/// <summary>
/// Runs this check and returns any issues detected for the provided beatmap.
/// </summary>
/// <param name="beatmap">The beatmap to run the check on.</param>
public IEnumerable<Issue> Run(IBeatmap beatmap);
}
}

View File

@ -0,0 +1,77 @@
// 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.Game.Extensions;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Edit.Checks.Components
{
public class Issue
{
/// <summary>
/// The time which this issue is associated with, if any, otherwise null.
/// </summary>
public double? Time;
/// <summary>
/// The hitobjects which this issue is associated with. Empty by default.
/// </summary>
public IReadOnlyList<HitObject> HitObjects;
/// <summary>
/// The template which this issue is using. This provides properties such as the <see cref="IssueType"/>, and the <see cref="IssueTemplate.UnformattedMessage"/>.
/// </summary>
public IssueTemplate Template;
/// <summary>
/// The check that this issue originates from.
/// </summary>
public ICheck Check => Template.Check;
/// <summary>
/// The arguments that give this issue its context, based on the <see cref="IssueTemplate"/>. These are then substituted into the <see cref="IssueTemplate.UnformattedMessage"/>.
/// This could for instance include timestamps, which diff is being compared to, what some volume is, etc.
/// </summary>
public object[] Arguments;
public Issue(IssueTemplate template, params object[] args)
{
Time = null;
HitObjects = Array.Empty<HitObject>();
Template = template;
Arguments = args;
}
public Issue(double? time, IssueTemplate template, params object[] args)
: this(template, args)
{
Time = time;
}
public Issue(HitObject hitObject, IssueTemplate template, params object[] args)
: this(template, args)
{
Time = hitObject.StartTime;
HitObjects = new[] { hitObject };
}
public Issue(IEnumerable<HitObject> hitObjects, IssueTemplate template, params object[] args)
: this(template, args)
{
var hitObjectList = hitObjects.ToList();
Time = hitObjectList.FirstOrDefault()?.StartTime;
HitObjects = hitObjectList;
}
public override string ToString() => Template.GetMessage(Arguments);
public string GetEditorTimestamp()
{
return Time == null ? string.Empty : Time.Value.ToEditorFormattedString();
}
}
}

View File

@ -0,0 +1,74 @@
// 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 Humanizer;
using osu.Framework.Graphics;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Edit.Checks.Components
{
public class IssueTemplate
{
private static readonly Color4 problem_red = new Colour4(1.0f, 0.4f, 0.4f, 1.0f);
private static readonly Color4 warning_yellow = new Colour4(1.0f, 0.8f, 0.2f, 1.0f);
private static readonly Color4 negligible_green = new Colour4(0.33f, 0.8f, 0.5f, 1.0f);
private static readonly Color4 error_gray = new Colour4(0.5f, 0.5f, 0.5f, 1.0f);
/// <summary>
/// The check that this template originates from.
/// </summary>
public readonly ICheck Check;
/// <summary>
/// The type of the issue.
/// </summary>
public readonly IssueType Type;
/// <summary>
/// The unformatted message given when this issue is detected.
/// This gets populated later when an issue is constructed with this template.
/// E.g. "Inconsistent snapping (1/{0}) with [{1}] (1/{2})."
/// </summary>
public readonly string UnformattedMessage;
public IssueTemplate(ICheck check, IssueType type, string unformattedMessage)
{
Check = check;
Type = type;
UnformattedMessage = unformattedMessage;
}
/// <summary>
/// Returns the formatted message given the arguments used to format it.
/// </summary>
/// <param name="args">The arguments used to format the message.</param>
public string GetMessage(params object[] args) => UnformattedMessage.FormatWith(args);
/// <summary>
/// Returns the colour corresponding to the type of this issue.
/// </summary>
public Colour4 Colour
{
get
{
switch (Type)
{
case IssueType.Problem:
return problem_red;
case IssueType.Warning:
return warning_yellow;
case IssueType.Negligible:
return negligible_green;
case IssueType.Error:
return error_gray;
default:
return Color4.White;
}
}
}
}
}

View File

@ -0,0 +1,25 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Rulesets.Edit.Checks.Components
{
/// <summary>
/// The type, or severity, of an issue.
/// </summary>
public enum IssueType
{
/// <summary> A must-fix in the vast majority of cases. </summary>
Problem,
/// <summary> A possible mistake. Often requires critical thinking. </summary>
Warning,
// TODO: Try/catch all checks run and return error templates if exceptions occur.
/// <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

@ -0,0 +1,17 @@
// 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.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Edit
{
/// <summary>
/// A class which can run against a beatmap and surface issues to the user which could go against known criteria or hinder gameplay.
/// </summary>
public interface IBeatmapVerifier
{
public IEnumerable<Issue> Run(IBeatmap beatmap);
}
}

View File

@ -201,6 +201,8 @@ namespace osu.Game.Rulesets
public virtual HitObjectComposer CreateHitObjectComposer() => null;
public virtual IBeatmapVerifier CreateBeatmapVerifier() => null;
public virtual Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.QuestionCircle };
public virtual IResourceStore<byte[]> CreateResourceStore() => new NamespacedResourceStore<byte[]>(new DllResourceStore(GetType().Assembly), @"Resources");

View File

@ -35,6 +35,7 @@ using osu.Game.Screens.Edit.Compose;
using osu.Game.Screens.Edit.Design;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Edit.Verify;
using osu.Game.Screens.Play;
using osu.Game.Users;
using osuTK.Graphics;
@ -444,6 +445,10 @@ namespace osu.Game.Screens.Edit
menuBar.Mode.Value = EditorScreenMode.SongSetup;
return true;
case GlobalAction.EditorVerifyMode:
menuBar.Mode.Value = EditorScreenMode.Verify;
return true;
default:
return false;
}
@ -631,6 +636,10 @@ namespace osu.Game.Screens.Edit
case EditorScreenMode.Timing:
currentScreen = new TimingScreen();
break;
case EditorScreenMode.Verify:
currentScreen = new VerifyScreen();
break;
}
LoadComponentAsync(currentScreen, newScreen =>

View File

@ -18,5 +18,8 @@ namespace osu.Game.Screens.Edit
[Description("timing")]
Timing,
[Description("verify")]
Verify,
}
}

View File

@ -0,0 +1,140 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osuTK.Graphics;
namespace osu.Game.Screens.Edit
{
public abstract class EditorTable : TableContainer
{
private const float horizontal_inset = 20;
protected const float ROW_HEIGHT = 25;
protected const int TEXT_SIZE = 14;
protected readonly FillFlowContainer<RowBackground> BackgroundFlow;
protected EditorTable()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Padding = new MarginPadding { Horizontal = horizontal_inset };
RowSize = new Dimension(GridSizeMode.Absolute, ROW_HEIGHT);
AddInternal(BackgroundFlow = new FillFlowContainer<RowBackground>
{
RelativeSizeAxes = Axes.Both,
Depth = 1f,
Padding = new MarginPadding { Horizontal = -horizontal_inset },
Margin = new MarginPadding { Top = ROW_HEIGHT }
});
}
protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty);
private class HeaderText : OsuSpriteText
{
public HeaderText(string text)
{
Text = text.ToUpper();
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold);
}
}
public class RowBackground : OsuClickableContainer
{
public readonly object Item;
private const int fade_duration = 100;
private readonly Box hoveredBackground;
[Resolved]
private EditorClock clock { get; set; }
public RowBackground(object item)
{
Item = item;
RelativeSizeAxes = Axes.X;
Height = 25;
AlwaysPresent = true;
CornerRadius = 3;
Masking = true;
Children = new Drawable[]
{
hoveredBackground = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
},
};
// todo delete
Action = () =>
{
};
}
private Color4 colourHover;
private Color4 colourSelected;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
hoveredBackground.Colour = colourHover = colours.BlueDarker;
colourSelected = colours.YellowDarker;
}
private bool selected;
public bool Selected
{
get => selected;
set
{
if (value == selected)
return;
selected = value;
updateState();
}
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
base.OnHoverLost(e);
}
private void updateState()
{
hoveredBackground.FadeColour(selected ? colourSelected : colourHover, 450, Easing.OutQuint);
if (selected || IsHovered)
hoveredBackground.FadeIn(fade_duration, Easing.OutQuint);
else
hoveredBackground.FadeOut(fade_duration, Easing.OutQuint);
}
}
}
}

View File

@ -8,59 +8,43 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Timing
{
public class ControlPointTable : TableContainer
public class ControlPointTable : EditorTable
{
private const float horizontal_inset = 20;
private const float row_height = 25;
private const int text_size = 14;
private readonly FillFlowContainer backgroundFlow;
[Resolved]
private Bindable<ControlPointGroup> selectedGroup { get; set; }
public ControlPointTable()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Padding = new MarginPadding { Horizontal = horizontal_inset };
RowSize = new Dimension(GridSizeMode.Absolute, row_height);
AddInternal(backgroundFlow = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Depth = 1f,
Padding = new MarginPadding { Horizontal = -horizontal_inset },
Margin = new MarginPadding { Top = row_height }
});
}
[Resolved]
private EditorClock clock { get; set; }
public IEnumerable<ControlPointGroup> ControlGroups
{
set
{
Content = null;
backgroundFlow.Clear();
BackgroundFlow.Clear();
if (value?.Any() != true)
return;
foreach (var group in value)
{
backgroundFlow.Add(new RowBackground(group));
BackgroundFlow.Add(new RowBackground(group)
{
Action = () =>
{
selectedGroup.Value = group;
clock.SeekSmoothlyTo(group.Time);
}
});
}
Columns = createHeaders();
@ -68,6 +52,16 @@ namespace osu.Game.Screens.Edit.Timing
}
}
protected override void LoadComplete()
{
base.LoadComplete();
selectedGroup.BindValueChanged(group =>
{
foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue;
}, true);
}
private TableColumn[] createHeaders()
{
var columns = new List<TableColumn>
@ -86,13 +80,13 @@ namespace osu.Game.Screens.Edit.Timing
new OsuSpriteText
{
Text = $"#{index + 1}",
Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold),
Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold),
Margin = new MarginPadding(10)
},
new OsuSpriteText
{
Text = group.Time.ToEditorFormattedString(),
Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold)
Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold)
},
null,
new ControlGroupAttributes(group),
@ -163,111 +157,5 @@ namespace osu.Game.Screens.Edit.Timing
return null;
}
}
protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty);
private class HeaderText : OsuSpriteText
{
public HeaderText(string text)
{
Text = text.ToUpper();
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold);
}
}
public class RowBackground : OsuClickableContainer
{
private readonly ControlPointGroup controlGroup;
private const int fade_duration = 100;
private readonly Box hoveredBackground;
[Resolved]
private EditorClock clock { get; set; }
[Resolved]
private Bindable<ControlPointGroup> selectedGroup { get; set; }
public RowBackground(ControlPointGroup controlGroup)
{
this.controlGroup = controlGroup;
RelativeSizeAxes = Axes.X;
Height = 25;
AlwaysPresent = true;
CornerRadius = 3;
Masking = true;
Children = new Drawable[]
{
hoveredBackground = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
},
};
Action = () =>
{
selectedGroup.Value = controlGroup;
clock.SeekSmoothlyTo(controlGroup.Time);
};
}
private Color4 colourHover;
private Color4 colourSelected;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
hoveredBackground.Colour = colourHover = colours.BlueDarker;
colourSelected = colours.YellowDarker;
}
protected override void LoadComplete()
{
base.LoadComplete();
selectedGroup.BindValueChanged(group => { Selected = controlGroup == group.NewValue; }, true);
}
private bool selected;
protected bool Selected
{
get => selected;
set
{
if (value == selected)
return;
selected = value;
updateState();
}
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
base.OnHoverLost(e);
}
private void updateState()
{
hoveredBackground.FadeColour(selected ? colourSelected : colourHover, 450, Easing.OutQuint);
if (selected || IsHovered)
hoveredBackground.FadeIn(fade_duration, Easing.OutQuint);
else
hoveredBackground.FadeOut(fade_duration, Easing.OutQuint);
}
}
}
}

View File

@ -0,0 +1,46 @@
// 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.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
namespace osu.Game.Screens.Edit.Verify
{
public class IssueSettings : CompositeDrawable
{
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
new Box
{
Colour = colours.Gray3,
RelativeSizeAxes = Axes.Both,
},
new OsuScrollContainer
{
RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Children = createSections()
},
}
};
}
private IReadOnlyList<Drawable> createSections() => new Drawable[]
{
};
}
}

View File

@ -0,0 +1,128 @@
// 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.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Screens.Edit.Verify
{
public class IssueTable : EditorTable
{
[Resolved]
private Bindable<Issue> selectedIssue { get; set; }
[Resolved]
private EditorClock clock { get; set; }
[Resolved]
private EditorBeatmap editorBeatmap { get; set; }
[Resolved]
private Editor editor { get; set; }
public IEnumerable<Issue> Issues
{
set
{
Content = null;
BackgroundFlow.Clear();
if (value == null)
return;
foreach (var issue in value)
{
BackgroundFlow.Add(new RowBackground(issue)
{
Action = () =>
{
selectedIssue.Value = issue;
if (issue.Time != null)
{
clock.Seek(issue.Time.Value);
editor.OnPressed(GlobalAction.EditorComposeMode);
}
if (!issue.HitObjects.Any())
return;
editorBeatmap.SelectedHitObjects.Clear();
editorBeatmap.SelectedHitObjects.AddRange(issue.HitObjects);
},
});
}
Columns = createHeaders();
Content = value.Select((g, i) => createContent(i, g)).ToArray().ToRectangular();
}
}
protected override void LoadComplete()
{
base.LoadComplete();
selectedIssue.BindValueChanged(issue =>
{
foreach (var b in BackgroundFlow) b.Selected = b.Item == issue.NewValue;
}, true);
}
private TableColumn[] createHeaders()
{
var columns = new List<TableColumn>
{
new TableColumn(string.Empty, Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)),
new TableColumn("Type", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize, minSize: 60)),
new TableColumn("Time", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize, minSize: 60)),
new TableColumn("Message", Anchor.CentreLeft),
new TableColumn("Category", Anchor.CentreRight, new Dimension(GridSizeMode.AutoSize)),
};
return columns.ToArray();
}
private Drawable[] createContent(int index, Issue issue) => new Drawable[]
{
new OsuSpriteText
{
Text = $"#{index + 1}",
Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Medium),
Margin = new MarginPadding { Right = 10 }
},
new OsuSpriteText
{
Text = issue.Template.Type.ToString(),
Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold),
Margin = new MarginPadding { Right = 10 },
Colour = issue.Template.Colour
},
new OsuSpriteText
{
Text = issue.GetEditorTimestamp(),
Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold),
Margin = new MarginPadding { Right = 10 },
},
new OsuSpriteText
{
Text = issue.ToString(),
Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Medium)
},
new OsuSpriteText
{
Text = issue.Check.Metadata.Category.ToString(),
Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold),
Margin = new MarginPadding(10)
}
};
}
}

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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks.Components;
using osuTK;
namespace osu.Game.Screens.Edit.Verify
{
public class VerifyScreen : EditorScreen
{
[Cached]
private Bindable<Issue> selectedIssue = new Bindable<Issue>();
public VerifyScreen()
: base(EditorScreenMode.Verify)
{
}
[BackgroundDependencyLoader]
private void load()
{
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(20),
Child = new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.Absolute, 200),
},
Content = new[]
{
new Drawable[]
{
new IssueList(),
new IssueSettings(),
},
}
}
};
}
public class IssueList : CompositeDrawable
{
private IssueTable table;
[Resolved]
private EditorClock clock { get; set; }
[Resolved]
protected EditorBeatmap Beatmap { get; private set; }
[Resolved]
private Bindable<Issue> selectedIssue { get; set; }
private IBeatmapVerifier rulesetVerifier;
private BeatmapVerifier generalVerifier;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
generalVerifier = new BeatmapVerifier();
rulesetVerifier = Beatmap.BeatmapInfo.Ruleset?.CreateInstance()?.CreateBeatmapVerifier();
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
new Box
{
Colour = colours.Gray0,
RelativeSizeAxes = Axes.Both,
},
new OsuScrollContainer
{
RelativeSizeAxes = Axes.Both,
Child = table = new IssueTable(),
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding(20),
Children = new Drawable[]
{
new TriangleButton
{
Text = "Refresh",
Action = refresh,
Size = new Vector2(120, 40),
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
},
}
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
refresh();
}
private void refresh()
{
var issues = generalVerifier.Run(Beatmap);
if (rulesetVerifier != null)
issues = issues.Concat(rulesetVerifier.Run(Beatmap));
table.Issues = issues
.OrderBy(issue => issue.Template.Type)
.ThenBy(issue => issue.Check.Metadata.Category);
}
}
}
}