1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 09:27:29 +08:00

Merge branch 'master' of https://github.com/ppy/osu into justusft/mania-color-snap

This commit is contained in:
Justus Franklin Tumacder 2021-05-01 11:20:28 +08:00
commit ecb053b0de
87 changed files with 2162 additions and 854 deletions

View File

@ -25,6 +25,6 @@ Please check:
*please attach logs here, which are located at:*
- `%AppData%/osu/logs` *(on Windows),*
- `~/.local/share/osu/logs` *(on Linux & macOS).*
- `Android/Data/sh.ppy.osulazer/logs` *(on Android)*,
- `Android/data/sh.ppy.osulazer/files/logs` *(on Android)*,
- on iOS they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
-->

46
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,46 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: monthly
time: "17:00"
open-pull-requests-limit: 99
ignore:
- dependency-name: Microsoft.EntityFrameworkCore.Design
versions:
- "> 2.2.6"
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite
versions:
- "> 2.2.6"
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite.Core
versions:
- "> 2.2.6"
- dependency-name: Microsoft.Extensions.DependencyInjection
versions:
- ">= 5.a, < 6"
- dependency-name: NUnit3TestAdapter
versions:
- ">= 3.16.a, < 3.17"
- dependency-name: Microsoft.NET.Test.Sdk
versions:
- 16.9.1
- dependency-name: Microsoft.Extensions.DependencyInjection
versions:
- 3.1.11
- 3.1.12
- dependency-name: Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson
versions:
- 3.1.11
- dependency-name: Microsoft.NETCore.Targets
versions:
- 5.0.0
- dependency-name: Microsoft.AspNetCore.SignalR.Protocols.MessagePack
versions:
- 5.0.2
- dependency-name: NUnit
versions:
- 3.13.1
- dependency-name: Microsoft.AspNetCore.SignalR.Client
versions:
- 3.1.11

View File

@ -24,7 +24,7 @@ Issues, bug reports and feature suggestions are welcomed, though please keep in
* the in-game logs, which are located at:
* `%AppData%/osu/logs` (on Windows),
* `~/.local/share/osu/logs` (on Linux and macOS),
* `Android/Data/sh.ppy.osulazer/logs` (on Android),
* `Android/data/sh.ppy.osulazer/files/logs` (on Android),
* on iOS they can be obtained by connecting your device to your desktop and [copying the `logs` directory from the app's own document storage using iTunes](https://support.apple.com/en-us/HT201301#copy-to-computer),
* your system specifications (including the operating system and platform you are playing on),
* a reproduction scenario (list of steps you have performed leading up to the occurrence of the bug),

View File

@ -17,7 +17,7 @@ The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Commo
This project is under heavy development, but is in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update.
**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passses come at the end of development, preceeded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet.
**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet.
We are accepting bug reports (please report with as much detail as possible and follow the existing issue templates). Feature requests are also welcome, but understand that our focus is on completing the game to feature parity before adding new features. A few resources are available as starting points to getting involved and understanding the project:

View File

@ -11,7 +11,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>

View File

@ -11,7 +11,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>

View File

@ -11,7 +11,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>

View File

@ -11,7 +11,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>

View File

@ -52,6 +52,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.422.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.422.1" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.427.0" />
</ItemGroup>
</Project>

View File

@ -9,6 +9,7 @@ using System.Reflection;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Microsoft.Win32;
using osu.Desktop.Security;
using osu.Desktop.Overlays;
using osu.Framework.Platform;
using osu.Game;
@ -113,6 +114,8 @@ namespace osu.Desktop
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
LoadComponentAsync(new GameplayWinKeyBlocker(), Add);
LoadComponentAsync(new ElevatedPrivilegesChecker(), Add);
}
protected override void ScreenChanged(IScreen lastScreen, IScreen newScreen)

View File

@ -0,0 +1,83 @@
// 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.Security.Principal;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
namespace osu.Desktop.Security
{
/// <summary>
/// Checks if the game is running with elevated privileges (as admin in Windows, root in Unix) and displays a warning notification if so.
/// </summary>
public class ElevatedPrivilegesChecker : Component
{
[Resolved]
private NotificationOverlay notifications { get; set; }
private bool elevated;
[BackgroundDependencyLoader]
private void load()
{
elevated = checkElevated();
}
protected override void LoadComplete()
{
base.LoadComplete();
if (elevated)
notifications.Post(new ElevatedPrivilegesNotification());
}
private bool checkElevated()
{
try
{
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
if (!OperatingSystem.IsWindows()) return false;
var windowsIdentity = WindowsIdentity.GetCurrent();
var windowsPrincipal = new WindowsPrincipal(windowsIdentity);
return windowsPrincipal.IsInRole(WindowsBuiltInRole.Administrator);
case RuntimeInfo.Platform.macOS:
case RuntimeInfo.Platform.Linux:
return Mono.Unix.Native.Syscall.geteuid() == 0;
}
}
catch
{
}
return false;
}
private class ElevatedPrivilegesNotification : SimpleNotification
{
public override bool IsImportant => true;
public ElevatedPrivilegesNotification()
{
Text = $"Running osu! as {(RuntimeInfo.IsUnix ? "root" : "administrator")} does not improve performance, may break integrations and poses a security risk. Please run the game as a normal user.";
}
[BackgroundDependencyLoader]
private void load(OsuColour colours, NotificationOverlay notificationOverlay)
{
Icon = FontAwesome.Solid.ShieldAlt;
IconBackgound.Colour = colours.YellowDark;
}
}
}
}

View File

@ -25,6 +25,7 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NETCore.Targets" Version="5.0.0" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="System.IO.Packaging" Version="5.0.0" />
<PackageReference Include="ppy.squirrel.windows" Version="1.9.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />

View File

@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.12.1" />
<PackageReference Include="nunit" Version="3.13.1" />
<PackageReference Include="nunit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
</ItemGroup>

View File

@ -3,7 +3,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>

View File

@ -127,7 +127,8 @@ namespace osu.Game.Rulesets.Catch
case ModType.Fun:
return new Mod[]
{
new MultiMod(new ModWindUp(), new ModWindDown())
new MultiMod(new ModWindUp(), new ModWindDown()),
new CatchModFloatingFruits()
};
default:

View File

@ -0,0 +1,29 @@
// 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.Sprites;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osuTK;
namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModFloatingFruits : Mod, IApplicableToDrawableRuleset<CatchHitObject>
{
public override string Name => "Floating Fruits";
public override string Acronym => "FF";
public override string Description => "The fruits are... floating?";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => FontAwesome.Solid.Cloud;
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
{
drawableRuleset.Anchor = Anchor.Centre;
drawableRuleset.Origin = Anchor.Centre;
drawableRuleset.Scale = new Vector2(1, -1);
}
}
}

View File

@ -3,7 +3,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>

View File

@ -12,16 +12,16 @@ using osu.Game.Rulesets.UI.Scrolling;
namespace osu.Game.Rulesets.Mania.Edit
{
public class DrawableManiaEditRuleset : DrawableManiaRuleset
public class DrawableManiaEditorRuleset : DrawableManiaRuleset
{
public new IScrollingInfo ScrollingInfo => base.ScrollingInfo;
public DrawableManiaEditRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
public DrawableManiaEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
: base(ruleset, beatmap, mods)
{
}
protected override Playfield CreatePlayfield() => new ManiaEditPlayfield(Beatmap.Stages)
protected override Playfield CreatePlayfield() => new ManiaEditorPlayfield(Beatmap.Stages)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -4,6 +4,7 @@
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Screens.Edit.Compose.Components;
@ -30,6 +31,6 @@ namespace osu.Game.Rulesets.Mania.Edit
return base.CreateBlueprintFor(hitObject);
}
protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler();
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new ManiaSelectionHandler();
}
}

View File

@ -7,9 +7,9 @@ using System.Collections.Generic;
namespace osu.Game.Rulesets.Mania.Edit
{
public class ManiaEditPlayfield : ManiaPlayfield
public class ManiaEditorPlayfield : ManiaPlayfield
{
public ManiaEditPlayfield(List<StageDefinition> stages)
public ManiaEditorPlayfield(List<StageDefinition> stages)
: base(stages)
{
}

View File

@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Edit
{
public class ManiaHitObjectComposer : HitObjectComposer<ManiaHitObject>
{
private DrawableManiaEditRuleset drawableRuleset;
private DrawableManiaEditorRuleset drawableRuleset;
private ManiaBeatSnapGrid beatSnapGrid;
private InputManager inputManager;
@ -80,7 +80,7 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override DrawableRuleset<ManiaHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null)
{
drawableRuleset = new DrawableManiaEditRuleset(ruleset, beatmap, mods);
drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, mods);
// This is the earliest we can cache the scrolling info to ourselves, before masks are added to the hierarchy and inject it
dependencies.CacheAs(drawableRuleset.ScrollingInfo);

View File

@ -7,12 +7,13 @@ using osu.Framework.Allocation;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit.Compose.Components;
namespace osu.Game.Rulesets.Mania.Edit
{
public class ManiaSelectionHandler : SelectionHandler
public class ManiaSelectionHandler : EditorSelectionHandler
{
[Resolved]
private IScrollingInfo scrollingInfo { get; set; }
@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Edit
[Resolved]
private HitObjectComposer composer { get; set; }
public override bool HandleMovement(MoveSelectionEvent moveEvent)
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent)
{
var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint;
int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column;
@ -30,11 +31,11 @@ namespace osu.Game.Rulesets.Mania.Edit
return true;
}
private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent)
private void performColumnMovement(int lastColumn, MoveSelectionEvent<HitObject> moveEvent)
{
var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield;
var currentColumn = maniaPlayfield.GetColumnByPosition(moveEvent.ScreenSpacePosition);
var currentColumn = maniaPlayfield.GetColumnByPosition(moveEvent.Blueprint.ScreenSpaceSelectionPoint + moveEvent.ScreenSpaceDelta);
if (currentColumn == null)
return;

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Game.Rulesets.Mania.Objects.Types;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;

View File

@ -34,6 +34,18 @@ namespace osu.Game.Rulesets.Osu.Tests
private List<JudgementResult> judgementResults;
[Test]
public void TestPressBothKeysSimultaneouslyAndReleaseOne()
{
performTest(new List<ReplayFrame>
{
new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start },
new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.RightButton }, Time = time_during_slide_1 },
});
AddAssert("Tracking retained", assertMaxJudge);
}
/// <summary>
/// Scenario:
/// - Press a key before a slider starts

View File

@ -3,7 +3,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>

View File

@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
public abstract class OsuSelectionBlueprint<T> : OverlaySelectionBlueprint
where T : OsuHitObject
{
protected new T HitObject => (T)DrawableObject.HitObject;
protected T HitObject => (T)DrawableObject.HitObject;
protected override bool AlwaysShowWhenSelected => true;

View File

@ -207,7 +207,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
var lastPiece = controlPointVisualiser.Pieces.Single(p => p.ControlPoint == last);
lastPoint = last;
return lastPiece?.IsHovered != true;
return lastPiece.IsHovered != true;
}
private void updateSlider()

View File

@ -11,24 +11,25 @@ using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.UI;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public class DrawableOsuEditRuleset : DrawableOsuRuleset
public class DrawableOsuEditorRuleset : DrawableOsuRuleset
{
public DrawableOsuEditRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
public DrawableOsuEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
: base(ruleset, beatmap, mods)
{
}
protected override Playfield CreatePlayfield() => new OsuEditPlayfield();
protected override Playfield CreatePlayfield() => new OsuEditorPlayfield();
public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { Size = Vector2.One };
private class OsuEditPlayfield : OsuPlayfield
private class OsuEditorPlayfield : OsuPlayfield
{
private Bindable<bool> hitAnimations;
@ -56,43 +57,45 @@ namespace osu.Game.Rulesets.Osu.Edit
if (state == ArmedState.Idle || hitAnimations.Value)
return;
// adjust the visuals of certain object types to make them stay on screen for longer than usual.
switch (hitObject)
if (hitObject is DrawableHitCircle circle)
{
default:
// there are quite a few drawable hit types we don't want to extend (spinners, ticks etc.)
return;
circle.ApproachCircle
.FadeOutFromOne(editor_hit_object_fade_out_extension * 4)
.Expire();
case DrawableSlider _:
// no specifics to sliders but let them fade slower below.
break;
case DrawableHitCircle circle: // also handles slider heads
circle.ApproachCircle
.FadeOutFromOne(editor_hit_object_fade_out_extension * 4)
.Expire();
circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint);
var circlePieceDrawable = circle.CirclePiece.Drawable;
// clear any explode animation logic.
circlePieceDrawable.ApplyTransformsAt(circle.HitStateUpdateTime, true);
circlePieceDrawable.ClearTransformsAfter(circle.HitStateUpdateTime, true);
break;
circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint);
}
// Get the existing fade out transform
var existing = hitObject.Transforms.LastOrDefault(t => t.TargetMember == nameof(Alpha));
if (hitObject is IHasMainCirclePiece mainPieceContainer)
{
// clear any explode animation logic.
mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.HitStateUpdateTime, true);
mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.HitStateUpdateTime, true);
}
if (existing == null)
return;
if (hitObject is DrawableSliderRepeat repeat)
{
repeat.Arrow.ApplyTransformsAt(hitObject.HitStateUpdateTime, true);
repeat.Arrow.ClearTransformsAfter(hitObject.HitStateUpdateTime, true);
}
hitObject.RemoveTransform(existing);
// adjust the visuals of top-level object types to make them stay on screen for longer than usual.
switch (hitObject)
{
case DrawableSlider _:
case DrawableHitCircle _:
// Get the existing fade out transform
var existing = hitObject.Transforms.LastOrDefault(t => t.TargetMember == nameof(Alpha));
using (hitObject.BeginAbsoluteSequence(hitObject.HitStateUpdateTime))
hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire();
if (existing == null)
return;
hitObject.RemoveTransform(existing);
using (hitObject.BeginAbsoluteSequence(hitObject.HitStateUpdateTime))
hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire();
break;
}
}
}
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders;
@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
}
protected override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler();
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new OsuSelectionHandler();
public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject)
{

View File

@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit
}
protected override DrawableRuleset<OsuHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null)
=> new DrawableOsuEditRuleset(ruleset, beatmap, mods);
=> new DrawableOsuEditorRuleset(ruleset, beatmap, mods);
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{
@ -147,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Edit
if (b.IsSelected)
continue;
var hitObject = (OsuHitObject)b.HitObject;
var hitObject = (OsuHitObject)b.Item;
Vector2? snap = checkSnap(hitObject.Position);
if (snap == null && hitObject.Position != hitObject.EndPosition)

View File

@ -7,6 +7,7 @@ using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Utils;
using osu.Game.Extensions;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
@ -15,7 +16,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public class OsuSelectionHandler : SelectionHandler
public class OsuSelectionHandler : EditorSelectionHandler
{
protected override void OnSelectionChanged()
{
@ -36,13 +37,13 @@ namespace osu.Game.Rulesets.Osu.Edit
referencePathTypes = null;
}
public override bool HandleMovement(MoveSelectionEvent moveEvent)
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent)
{
var hitObjects = selectedMovableObjects;
// this will potentially move the selection out of bounds...
foreach (var h in hitObjects)
h.Position += moveEvent.InstantDelta;
h.Position += this.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta);
// but this will be corrected.
moveSelectionInBounds();
@ -374,8 +375,7 @@ namespace osu.Game.Rulesets.Osu.Edit
/// <summary>
/// All osu! hitobjects which can be moved/rotated/scaled.
/// </summary>
private OsuHitObject[] selectedMovableObjects => EditorBeatmap.SelectedHitObjects
.OfType<OsuHitObject>()
private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType<OsuHitObject>()
.Where(h => !(h is Spinner))
.ToArray();

View File

@ -2,53 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.UI;
using osuTK;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModBarrelRoll : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToDrawableHitObjects
public class OsuModBarrelRoll : ModBarrelRoll<OsuHitObject>, IApplicableToDrawableHitObjects
{
private float currentRotation;
[SettingSource("Roll speed", "Rotations per minute")]
public BindableNumber<double> SpinSpeed { get; } = new BindableDouble(0.5)
{
MinValue = 0.02,
MaxValue = 12,
Precision = 0.01,
};
[SettingSource("Direction", "The direction of rotation")]
public Bindable<RotationDirection> Direction { get; } = new Bindable<RotationDirection>(RotationDirection.Clockwise);
public override string Name => "Barrel Roll";
public override string Acronym => "BR";
public override string Description => "The whole playfield is on a wheel!";
public override double ScoreMultiplier => 1;
public override string SettingDescription => $"{SpinSpeed.Value} rpm {Direction.Value.GetDescription().ToLowerInvariant()}";
public void Update(Playfield playfield)
{
playfield.Rotation = currentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value);
}
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{
// scale the playfield to allow all hitobjects to stay within the visible region.
drawableRuleset.Playfield.Scale = new Vector2(OsuPlayfield.BASE_SIZE.Y / OsuPlayfield.BASE_SIZE.X);
}
public void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables)
{
foreach (var d in drawables)
@ -58,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
switch (d)
{
case DrawableHitCircle circle:
circle.CirclePiece.Rotation = -currentRotation;
circle.CirclePiece.Rotation = -CurrentRotation;
break;
}
};

View File

@ -19,7 +19,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public class DrawableHitCircle : DrawableOsuHitObject
public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece
{
public OsuAction? HitAction => HitArea.HitAction;
protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle;

View File

@ -15,7 +15,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking
public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking, IHasMainCirclePiece
{
public new SliderRepeat HitObject => (SliderRepeat)base.HitObject;
@ -26,9 +26,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
private double animDuration;
public Drawable CirclePiece { get; private set; }
public SkinnableDrawable CirclePiece { get; private set; }
public ReverseArrowPiece Arrow { get; private set; }
private Drawable scaleContainer;
private ReverseArrowPiece arrow;
public override bool DisplayResult => false;
@ -53,11 +55,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new[]
Children = new Drawable[]
{
// no default for this; only visible in legacy skins.
CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()),
arrow = new ReverseArrowPiece(),
CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
Arrow = new ReverseArrowPiece(),
}
};
@ -91,6 +97,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
base.UpdateHitStateTransforms(state);
(CirclePiece.Drawable as IMainCirclePiece)?.Animate(state);
switch (state)
{
case ArmedState.Idle:
@ -102,8 +110,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
break;
case ArmedState.Hit:
this.FadeOut(animDuration, Easing.Out)
.ScaleTo(Scale * 1.5f, animDuration, Easing.Out);
this.FadeOut(animDuration, Easing.Out);
const float final_scale = 1.5f;
Arrow.ScaleTo(Scale * final_scale, animDuration, Easing.Out);
CirclePiece.ScaleTo(Scale * final_scale, animDuration, Easing.Out);
break;
}
}
@ -139,18 +151,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
}
float aimRotation = MathUtils.RadiansToDegrees(MathF.Atan2(aimRotationVector.Y - Position.Y, aimRotationVector.X - Position.X));
while (Math.Abs(aimRotation - arrow.Rotation) > 180)
aimRotation += aimRotation < arrow.Rotation ? 360 : -360;
while (Math.Abs(aimRotation - Arrow.Rotation) > 180)
aimRotation += aimRotation < Arrow.Rotation ? 360 : -360;
if (!hasRotation)
{
arrow.Rotation = aimRotation;
Arrow.Rotation = aimRotation;
hasRotation = true;
}
else
{
// If we're already snaking, interpolate to smooth out sharp curves (linear sliders, mainly).
arrow.Rotation = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 100), arrow.Rotation, aimRotation, 0, 50, Easing.OutQuint);
Arrow.Rotation = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 100), Arrow.Rotation, aimRotation, 0, 50, Easing.OutQuint);
}
}
}

View File

@ -7,12 +7,13 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, ITrackSnaking
public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, ITrackSnaking, IHasMainCirclePiece
{
public new SliderTailCircle HitObject => (SliderTailCircle)base.HitObject;
@ -34,7 +35,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public bool Tracking { get; set; }
private SkinnableDrawable circlePiece;
public SkinnableDrawable CirclePiece { get; private set; }
private Container scaleContainer;
public DrawableSliderTail()
@ -63,7 +65,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Children = new Drawable[]
{
// no default for this; only visible in legacy skins.
circlePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty())
CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty())
}
},
};
@ -75,7 +77,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
base.UpdateInitialTransforms();
circlePiece.FadeInFromZero(HitObject.TimeFadeIn);
CirclePiece.FadeInFromZero(HitObject.TimeFadeIn);
}
protected override void UpdateHitStateTransforms(ArmedState state)
@ -84,6 +86,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Debug.Assert(HitObject.HitWindows != null);
(CirclePiece.Drawable as IMainCirclePiece)?.Animate(state);
switch (state)
{
case ArmedState.Idle:

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.Game.Skinning;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public interface IHasMainCirclePiece
{
SkinnableDrawable CirclePiece { get; }
}
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -134,6 +135,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
/// </summary>
private double? timeToAcceptAnyKeyAfter;
/// <summary>
/// The actions that were pressed in the previous frame.
/// </summary>
private readonly List<OsuAction> lastPressedActions = new List<OsuAction>();
protected override void Update()
{
base.Update();
@ -152,8 +158,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
{
var otherKey = headCircleHitAction == OsuAction.RightButton ? OsuAction.LeftButton : OsuAction.RightButton;
// we can return to accepting all keys if the initial head circle key is the *only* key pressed, or all keys have been released.
if (actions?.Contains(otherKey) != true)
// we can start accepting any key once all other keys have been released in the previous frame.
if (!lastPressedActions.Contains(otherKey))
timeToAcceptAnyKeyAfter = Time.Current;
}
@ -164,6 +170,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
lastScreenSpaceMousePosition.HasValue && followCircle.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) &&
// valid action
(actions?.Any(isValidTrackingAction) ?? false);
lastPressedActions.Clear();
if (actions != null)
lastPressedActions.AddRange(actions);
}
/// <summary>

View File

@ -3,7 +3,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
</ItemGroup>

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
using osu.Game.Screens.Edit.Compose.Components;
@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
protected override SelectionHandler CreateSelectionHandler() => new TaikoSelectionHandler();
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new TaikoSelectionHandler();
public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) =>
new TaikoSelectionBlueprint(hitObject);

View File

@ -8,12 +8,13 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Screens.Edit.Compose.Components;
namespace osu.Game.Rulesets.Taiko.Edit
{
public class TaikoSelectionHandler : SelectionHandler
public class TaikoSelectionHandler : EditorSelectionHandler
{
private readonly Bindable<TernaryState> selectionRimState = new Bindable<TernaryState>();
private readonly Bindable<TernaryState> selectionStrongState = new Bindable<TernaryState>();
@ -72,16 +73,19 @@ namespace osu.Game.Rulesets.Taiko.Edit
});
}
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint> selection)
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
{
if (selection.All(s => s.HitObject is Hit))
if (selection.All(s => s.Item is Hit))
yield return new TernaryStateMenuItem("Rim") { State = { BindTarget = selectionRimState } };
if (selection.All(s => s.HitObject is TaikoHitObject))
if (selection.All(s => s.Item is TaikoHitObject))
yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } };
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;
}
public override bool HandleMovement(MoveSelectionEvent moveEvent) => true;
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent) => true;
protected override void UpdateTernaryStates()
{

View File

@ -0,0 +1,194 @@
// 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 Moq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Tests.Editing.Checks
{
[TestFixture]
public class CheckConcurrentObjectsTest
{
private CheckConcurrentObjects check;
[SetUp]
public void Setup()
{
check = new CheckConcurrentObjects();
}
[Test]
public void TestCirclesSeparate()
{
assertOk(new List<HitObject>
{
new HitCircle { StartTime = 100 },
new HitCircle { StartTime = 150 }
});
}
[Test]
public void TestCirclesConcurrent()
{
assertConcurrentSame(new List<HitObject>
{
new HitCircle { StartTime = 100 },
new HitCircle { StartTime = 100 }
});
}
[Test]
public void TestCirclesAlmostConcurrent()
{
assertConcurrentSame(new List<HitObject>
{
new HitCircle { StartTime = 100 },
new HitCircle { StartTime = 101 }
});
}
[Test]
public void TestSlidersSeparate()
{
assertOk(new List<HitObject>
{
getSliderMock(startTime: 100, endTime: 400.75d).Object,
getSliderMock(startTime: 500, endTime: 900.75d).Object
});
}
[Test]
public void TestSlidersConcurrent()
{
assertConcurrentSame(new List<HitObject>
{
getSliderMock(startTime: 100, endTime: 400.75d).Object,
getSliderMock(startTime: 300, endTime: 700.75d).Object
});
}
[Test]
public void TestSlidersAlmostConcurrent()
{
assertConcurrentSame(new List<HitObject>
{
getSliderMock(startTime: 100, endTime: 400.75d).Object,
getSliderMock(startTime: 402, endTime: 902.75d).Object
});
}
[Test]
public void TestSliderAndCircleConcurrent()
{
assertConcurrentDifferent(new List<HitObject>
{
getSliderMock(startTime: 100, endTime: 400.75d).Object,
new HitCircle { StartTime = 300 }
});
}
[Test]
public void TestManyObjectsConcurrent()
{
var hitobjects = new List<HitObject>
{
getSliderMock(startTime: 100, endTime: 400.75d).Object,
getSliderMock(startTime: 200, endTime: 500.75d).Object,
new HitCircle { StartTime = 300 }
};
var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList();
Assert.That(issues, Has.Count.EqualTo(3));
Assert.That(issues.Where(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent).ToList(), Has.Count.EqualTo(2));
Assert.That(issues.Any(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame));
}
[Test]
public void TestHoldNotesSeparateOnSameColumn()
{
assertOk(new List<HitObject>
{
getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object,
getHoldNoteMock(startTime: 500, endTime: 900.75d, column: 1).Object
});
}
[Test]
public void TestHoldNotesConcurrentOnDifferentColumns()
{
assertOk(new List<HitObject>
{
getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object,
getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 2).Object
});
}
[Test]
public void TestHoldNotesConcurrentOnSameColumn()
{
assertConcurrentSame(new List<HitObject>
{
getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object,
getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 1).Object
});
}
private Mock<Slider> getSliderMock(double startTime, double endTime, int repeats = 0)
{
var mock = new Mock<Slider>();
mock.SetupGet(s => s.StartTime).Returns(startTime);
mock.As<IHasRepeats>().Setup(r => r.RepeatCount).Returns(repeats);
mock.As<IHasDuration>().Setup(d => d.EndTime).Returns(endTime);
return mock;
}
private Mock<HoldNote> getHoldNoteMock(double startTime, double endTime, int column)
{
var mock = new Mock<HoldNote>();
mock.SetupGet(s => s.StartTime).Returns(startTime);
mock.As<IHasDuration>().Setup(d => d.EndTime).Returns(endTime);
mock.As<IHasColumn>().Setup(c => c.Column).Returns(column);
return mock;
}
private void assertOk(List<HitObject> hitobjects)
{
Assert.That(check.Run(getPlayableBeatmap(hitobjects), null), Is.Empty);
}
private void assertConcurrentSame(List<HitObject> hitobjects, int count = 1)
{
var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame));
}
private void assertConcurrentDifferent(List<HitObject> hitobjects, int count = 1)
{
var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent));
}
private IBeatmap getPlayableBeatmap(List<HitObject> hitobjects)
{
return new Beatmap<HitObject>
{
HitObjects = hitobjects
};
}
}
}

View File

@ -0,0 +1,155 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using Moq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Tests.Editing.Checks
{
[TestFixture]
public class CheckUnsnappedObjectsTest
{
private CheckUnsnappedObjects check;
private ControlPointInfo cpi;
[SetUp]
public void Setup()
{
check = new CheckUnsnappedObjects();
cpi = new ControlPointInfo();
cpi.Add(100, new TimingControlPoint { BeatLength = 100 });
}
[Test]
public void TestCircleSnapped()
{
assertOk(new List<HitObject>
{
new HitCircle { StartTime = 100 }
});
}
[Test]
public void TestCircleUnsnapped1Ms()
{
assert1Ms(new List<HitObject>
{
new HitCircle { StartTime = 101 }
});
assert1Ms(new List<HitObject>
{
new HitCircle { StartTime = 99 }
});
}
[Test]
public void TestCircleUnsnapped2Ms()
{
assert2Ms(new List<HitObject>
{
new HitCircle { StartTime = 102 }
});
assert2Ms(new List<HitObject>
{
new HitCircle { StartTime = 98 }
});
}
[Test]
public void TestSliderSnapped()
{
// Slider ends are naturally < 1 ms unsnapped because of how SV works.
assertOk(new List<HitObject>
{
getSliderMock(startTime: 100, endTime: 400.75d).Object
});
}
[Test]
public void TestSliderUnsnapped1Ms()
{
assert1Ms(new List<HitObject>
{
getSliderMock(startTime: 101, endTime: 401.75d).Object
}, count: 2);
// End is only off by 0.25 ms, hence count 1.
assert1Ms(new List<HitObject>
{
getSliderMock(startTime: 99, endTime: 399.75d).Object
}, count: 1);
}
[Test]
public void TestSliderUnsnapped2Ms()
{
assert2Ms(new List<HitObject>
{
getSliderMock(startTime: 102, endTime: 402.75d).Object
}, count: 2);
// Start and end are 2 ms and 1.25 ms off respectively, hence two different issues in one object.
var hitobjects = new List<HitObject>
{
getSliderMock(startTime: 98, endTime: 398.75d).Object
};
var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList();
Assert.That(issues, Has.Count.EqualTo(2));
Assert.That(issues.Any(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateSmallUnsnap));
Assert.That(issues.Any(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateLargeUnsnap));
}
private Mock<Slider> getSliderMock(double startTime, double endTime, int repeats = 0)
{
var mockSlider = new Mock<Slider>();
mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
mockSlider.As<IHasRepeats>().Setup(r => r.RepeatCount).Returns(repeats);
mockSlider.As<IHasDuration>().Setup(d => d.EndTime).Returns(endTime);
return mockSlider;
}
private void assertOk(List<HitObject> hitobjects)
{
Assert.That(check.Run(getPlayableBeatmap(hitobjects), null), Is.Empty);
}
private void assert1Ms(List<HitObject> hitobjects, int count = 1)
{
var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateSmallUnsnap));
}
private void assert2Ms(List<HitObject> hitobjects, int count = 1)
{
var issues = check.Run(getPlayableBeatmap(hitobjects), null).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateLargeUnsnap));
}
private IBeatmap getPlayableBeatmap(List<HitObject> hitobjects)
{
return new Beatmap<HitObject>
{
ControlPointInfo = cpi,
HitObjects = hitobjects
};
}
}
}

View File

@ -0,0 +1,94 @@
// 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 NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Gameplay
{
[HeadlessTest]
public class TestSceneDrawableHitObject : OsuTestScene
{
[Test]
public void TestEntryLifetime()
{
TestDrawableHitObject dho = null;
var initialHitObject = new HitObject
{
StartTime = 1000
};
var entry = new TestLifetimeEntry(new HitObject
{
StartTime = 2000
});
AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject(initialHitObject));
AddAssert("Correct initial lifetime", () => dho.LifetimeStart == initialHitObject.StartTime - TestDrawableHitObject.INITIAL_LIFETIME_OFFSET);
AddStep("Apply entry", () => dho.Apply(entry));
AddAssert("Correct initial lifetime", () => dho.LifetimeStart == entry.HitObject.StartTime - TestLifetimeEntry.INITIAL_LIFETIME_OFFSET);
AddStep("Set lifetime", () => dho.LifetimeEnd = 3000);
AddAssert("Entry lifetime is updated", () => entry.LifetimeEnd == 3000);
}
[Test]
public void TestKeepAlive()
{
TestDrawableHitObject dho = null;
TestLifetimeEntry entry = null;
AddStep("Create DHO", () =>
{
dho = new TestDrawableHitObject(null);
dho.Apply(entry = new TestLifetimeEntry(new HitObject())
{
LifetimeStart = 0,
LifetimeEnd = 1000,
});
Child = dho;
});
AddStep("KeepAlive = true", () => entry.KeepAlive = true);
AddAssert("Lifetime is overriden", () => entry.LifetimeStart == double.MinValue && entry.LifetimeEnd == double.MaxValue);
AddStep("Set LifetimeStart", () => dho.LifetimeStart = 500);
AddStep("KeepAlive = false", () => entry.KeepAlive = false);
AddAssert("Lifetime is correct", () => entry.LifetimeStart == 500 && entry.LifetimeEnd == 1000);
AddStep("Set LifetimeStart while KeepAlive", () =>
{
entry.KeepAlive = true;
dho.LifetimeStart = double.MinValue;
entry.KeepAlive = false;
});
AddAssert("Lifetime is changed", () => entry.LifetimeStart == double.MinValue && entry.LifetimeEnd == 1000);
}
private class TestDrawableHitObject : DrawableHitObject
{
public const double INITIAL_LIFETIME_OFFSET = 100;
protected override double InitialLifetimeOffset => INITIAL_LIFETIME_OFFSET;
public TestDrawableHitObject(HitObject hitObject)
: base(hitObject)
{
}
}
private class TestLifetimeEntry : HitObjectLifetimeEntry
{
public const double INITIAL_LIFETIME_OFFSET = 200;
protected override double InitialLifetimeOffset => INITIAL_LIFETIME_OFFSET;
public TestLifetimeEntry(HitObject hitObject)
: base(hitObject)
{
}
}
}
}

View File

@ -4,7 +4,10 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
namespace osu.Game.Tests.Mods
{
@ -26,6 +29,16 @@ namespace osu.Game.Tests.Mods
Assert.That(orderedSettings[3].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.UnorderedSetting)));
}
[Test]
public void TestCustomControl()
{
var objectWithCustomSettingControl = new ClassWithCustomSettingControl();
var settings = objectWithCustomSettingControl.CreateSettingsControls().ToArray();
Assert.That(settings, Has.Length.EqualTo(1));
Assert.That(settings[0], Is.TypeOf<CustomSettingsControl>());
}
private class ClassWithSettings
{
[SettingSource("Unordered setting", "Should be last")]
@ -40,5 +53,21 @@ namespace osu.Game.Tests.Mods
[SettingSource("Third setting", "Yet another description", 3)]
public BindableInt ThirdSetting { get; set; } = new BindableInt();
}
private class ClassWithCustomSettingControl
{
[SettingSource("Custom setting", "Should be a custom control", SettingControlType = typeof(CustomSettingsControl))]
public BindableInt UnorderedSetting { get; set; } = new BindableInt();
}
private class CustomSettingsControl : SettingsItem<int>
{
protected override Drawable CreateControl() => new CustomControl();
private class CustomControl : Drawable, IHasCurrentValue<int>
{
public Bindable<int> Current { get; set; } = new Bindable<int>();
}
}
}
}

View File

@ -0,0 +1,91 @@
// 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 NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Tests.NonVisual
{
public class ClosestBeatDivisorTest
{
[Test]
public void TestExactDivisors()
{
var cpi = new ControlPointInfo();
cpi.Add(-1000, new TimingControlPoint { BeatLength = 1000 });
double[] divisors = { 3, 1, 16, 12, 8, 6, 4, 3, 2, 1 };
assertClosestDivisors(divisors, divisors, cpi);
}
[Test]
public void TestExactDivisorWithTempoChanges()
{
int offset = 0;
int[] beatLengths = { 1000, 200, 100, 50 };
var cpi = new ControlPointInfo();
foreach (int beatLength in beatLengths)
{
cpi.Add(offset, new TimingControlPoint { BeatLength = beatLength });
offset += beatLength * 2;
}
double[] divisors = { 3, 1, 16, 12, 8, 6, 4, 3 };
assertClosestDivisors(divisors, divisors, cpi);
}
[Test]
public void TestExactDivisorsHighBPMStream()
{
var cpi = new ControlPointInfo();
cpi.Add(-50, new TimingControlPoint { BeatLength = 50 }); // 1200 BPM 1/4 (limit testing)
// A 1/4 stream should land on 1/1, 1/2 and 1/4 divisors.
double[] divisors = { 4, 4, 4, 4, 4, 4, 4, 4 };
double[] closestDivisors = { 4, 2, 4, 1, 4, 2, 4, 1 };
assertClosestDivisors(divisors, closestDivisors, cpi, step: 1 / 4d);
}
[Test]
public void TestApproximateDivisors()
{
var cpi = new ControlPointInfo();
cpi.Add(-1000, new TimingControlPoint { BeatLength = 1000 });
double[] divisors = { 3.03d, 0.97d, 14, 13, 7.94d, 6.08d, 3.93d, 2.96d, 2.02d, 64 };
double[] closestDivisors = { 3, 1, 16, 12, 8, 6, 4, 3, 2, 1 };
assertClosestDivisors(divisors, closestDivisors, cpi);
}
private void assertClosestDivisors(IReadOnlyList<double> divisors, IReadOnlyList<double> closestDivisors, ControlPointInfo cpi, double step = 1)
{
List<HitObject> hitobjects = new List<HitObject>();
double offset = cpi.TimingPoints[0].Time;
for (int i = 0; i < divisors.Count; ++i)
{
double beatLength = cpi.TimingPointAt(offset).BeatLength;
hitobjects.Add(new HitObject { StartTime = offset + beatLength / divisors[i] });
offset += beatLength * step;
}
var beatmap = new Beatmap
{
HitObjects = hitobjects,
ControlPointInfo = cpi
};
for (int i = 0; i < divisors.Count; ++i)
Assert.AreEqual(closestDivisors[i], beatmap.ControlPointInfo.GetClosestBeatDivisor(beatmap.HitObjects[i].StartTime), $"at index {i}");
}
}
}

View File

@ -4,7 +4,6 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Judgements;
@ -51,7 +50,7 @@ namespace osu.Game.Tests.Rulesets.Scoring
};
scoreProcessor.ApplyResult(judgementResult);
Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value));
Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d));
}
/// <summary>
@ -84,8 +83,8 @@ namespace osu.Game.Tests.Rulesets.Scoring
[TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 925_000)] // (3 * 10) / (4 * 10) * 300_000 + 700_000 (max combo 0)
[TestCase(ScoringMode.Standardised, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (3 * 0) / (4 * 30) * 300_000 + (0 / 4) * 700_000
[TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 575_000)] // (3 * 30) / (4 * 30) * 300_000 + (0 / 4) * 700_000
[TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 700_030)] // 0 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points)
[TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 700_150)] // 0 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points)
[TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points)
[TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points)
[TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] // (0 * 4 * 300) * (1 + 0 / 25)
[TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 156)] // (((3 * 50) / (4 * 300)) * 4 * 300) * (1 + 1 / 25)
[TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 312)] // (((3 * 100) / (4 * 300)) * 4 * 300) * (1 + 1 / 25)
@ -96,8 +95,9 @@ namespace osu.Game.Tests.Rulesets.Scoring
[TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 225)] // (((3 * 10) / (4 * 10)) * 1 * 300) * (1 + 0 / 25)
[TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (0 * 4 * 300) * (1 + 0 / 25)
[TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 936)] // (((3 * 50) / (4 * 50)) * 4 * 300) * (1 + 1 / 25)
[TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 30)] // (0 * 1 * 300) * (1 + 0 / 25) + 3 * 10 (bonus points)
[TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 150)] // (0 * 1 * 300) * (1 + 0 / 25) * 3 * 50 (bonus points)
// TODO: The following two cases don't match expectations currently (a single hit is registered in acc portion when it shouldn't be). See https://github.com/ppy/osu/issues/12604.
[TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 330)] // (1 * 1 * 300) * (1 + 0 / 25) + 3 * 10 (bonus points)
[TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 450)] // (1 * 1 * 300) * (1 + 0 / 25) + 3 * 50 (bonus points)
public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore)
{
var minResult = new TestJudgement(hitResult).MinResult;
@ -118,7 +118,7 @@ namespace osu.Game.Tests.Rulesets.Scoring
scoreProcessor.ApplyResult(judgementResult);
}
Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5));
Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d));
}
/// <remarks>
@ -158,7 +158,7 @@ namespace osu.Game.Tests.Rulesets.Scoring
};
scoreProcessor.ApplyResult(lastJudgementResult);
Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5));
Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d));
}
[Test]
@ -169,7 +169,7 @@ namespace osu.Game.Tests.Rulesets.Scoring
scoreProcessor.Mode.Value = scoringMode;
scoreProcessor.ApplyBeatmap(new TestBeatmap(new RulesetInfo()));
Assert.IsTrue(Precision.AlmostEquals(0, scoreProcessor.TotalScore.Value));
Assert.That(scoreProcessor.TotalScore.Value, Is.Zero);
}
[TestCase(HitResult.IgnoreHit, HitResult.IgnoreMiss)]
@ -287,6 +287,23 @@ namespace osu.Game.Tests.Rulesets.Scoring
Assert.AreEqual(expectedReturnValue, hitResult.IsScorable());
}
[TestCase(HitResult.Perfect, 1_000_000)]
[TestCase(HitResult.SmallTickHit, 1_000_000)]
[TestCase(HitResult.LargeTickHit, 1_000_000)]
[TestCase(HitResult.SmallBonus, 1_000_000 + Judgement.SMALL_BONUS_SCORE)]
[TestCase(HitResult.LargeBonus, 1_000_000 + Judgement.LARGE_BONUS_SCORE)]
public void TestGetScoreWithExternalStatistics(HitResult result, int expectedScore)
{
var statistic = new Dictionary<HitResult, int> { { result, 1 } };
scoreProcessor.ApplyBeatmap(new Beatmap
{
HitObjects = { new TestHitObject(result) }
});
Assert.That(scoreProcessor.GetImmediateScore(ScoringMode.Standardised, result.AffectsCombo() ? 1 : 0, statistic), Is.EqualTo(expectedScore).Within(0.5d));
}
private class TestJudgement : Judgement
{
public override HitResult MaxResult { get; }

View File

@ -23,8 +23,8 @@ namespace osu.Game.Tests.Visual.Editing
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
private BlueprintContainer blueprintContainer
=> Editor.ChildrenOfType<BlueprintContainer>().First();
private EditorBlueprintContainer blueprintContainer
=> Editor.ChildrenOfType<EditorBlueprintContainer>().First();
[Test]
public void TestSelectedObjectHasPriorityWhenOverlapping()

View File

@ -132,8 +132,8 @@ namespace osu.Game.Tests.Visual.Editing
{
AddStep("deselect", () => EditorBeatmap.SelectedHitObjects.Clear());
AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType<Timeline>().First().ChildrenOfType<SelectionHandler>().First().Alpha == 0);
AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType<HitObjectComposer>().First().ChildrenOfType<SelectionHandler>().First().Alpha == 0);
AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType<Timeline>().First().ChildrenOfType<EditorSelectionHandler>().First().Alpha == 0);
AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType<HitObjectComposer>().First().ChildrenOfType<EditorSelectionHandler>().First().Alpha == 0);
}
AddStep("paste hitobject", () => Editor.Paste());
@ -142,8 +142,8 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000);
AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType<Timeline>().First().ChildrenOfType<SelectionHandler>().First().Alpha > 0);
AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType<HitObjectComposer>().First().ChildrenOfType<SelectionHandler>().First().Alpha > 0);
AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType<Timeline>().First().ChildrenOfType<EditorSelectionHandler>().First().Alpha > 0);
AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType<HitObjectComposer>().First().ChildrenOfType<EditorSelectionHandler>().First().Alpha > 0);
}
[Test]

View File

@ -26,15 +26,15 @@ namespace osu.Game.Tests.Visual.Editing
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
private BlueprintContainer blueprintContainer
=> Editor.ChildrenOfType<BlueprintContainer>().First();
private EditorBlueprintContainer blueprintContainer
=> Editor.ChildrenOfType<EditorBlueprintContainer>().First();
private void moveMouseToObject(Func<HitObject> targetFunc)
{
AddStep("move mouse to object", () =>
{
var pos = blueprintContainer.SelectionBlueprints
.First(s => s.HitObject == targetFunc())
.First(s => s.Item == targetFunc())
.ChildrenOfType<HitCirclePiece>()
.First().ScreenSpaceDrawQuad.Centre;

View File

@ -43,7 +43,10 @@ namespace osu.Game.Tests.Visual.Online
createButtonWithBeatmap(createSoleily());
AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
AddStep("import soleily", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()));
AddUntilStep("wait for beatmap import", () => beatmaps.GetAllUsableBeatmapSets().Any(b => b.OnlineBeatmapSetID == 241526));
AddAssert("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable);
createButtonWithBeatmap(createSoleily());
AddAssert("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable);
ensureSoleilyRemoved();

View File

@ -4,7 +4,7 @@
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="DeepEqual" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
<PackageReference Include="Moq" Version="4.16.1" />

View File

@ -6,7 +6,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
</ItemGroup>
<PropertyGroup Label="Project">

View File

@ -7,6 +7,7 @@ using System.Linq;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Lists;
using osu.Game.Screens.Edit;
namespace osu.Game.Beatmaps.ControlPoints
{
@ -160,6 +161,58 @@ namespace osu.Game.Beatmaps.ControlPoints
groups.Remove(group);
}
/// <summary>
/// Returns the time on the given beat divisor closest to the given time.
/// </summary>
/// <param name="time">The time to find the closest snapped time to.</param>
/// <param name="beatDivisor">The beat divisor to snap to.</param>
/// <param name="referenceTime">An optional reference point to use for timing point lookup.</param>
public double GetClosestSnappedTime(double time, int beatDivisor, double? referenceTime = null)
{
var timingPoint = TimingPointAt(referenceTime ?? time);
return getClosestSnappedTime(timingPoint, time, beatDivisor);
}
/// <summary>
/// Returns the time on *ANY* valid beat divisor, favouring the divisor closest to the given time.
/// </summary>
/// <param name="time">The time to find the closest snapped time to.</param>
public double GetClosestSnappedTime(double time) => GetClosestSnappedTime(time, GetClosestBeatDivisor(time));
/// <summary>
/// Returns the beat snap divisor closest to the given time. If two are equally close, the smallest divisor is returned.
/// </summary>
/// <param name="time">The time to find the closest beat snap divisor to.</param>
/// <param name="referenceTime">An optional reference point to use for timing point lookup.</param>
public int GetClosestBeatDivisor(double time, double? referenceTime = null)
{
TimingControlPoint timingPoint = TimingPointAt(referenceTime ?? time);
int closestDivisor = 0;
double closestTime = double.MaxValue;
foreach (int divisor in BindableBeatDivisor.VALID_DIVISORS)
{
double distanceFromSnap = Math.Abs(time - getClosestSnappedTime(timingPoint, time, divisor));
if (distanceFromSnap < closestTime)
{
closestDivisor = divisor;
closestTime = distanceFromSnap;
}
}
return closestDivisor;
}
private static double getClosestSnappedTime(TimingControlPoint timingPoint, double time, int beatDivisor)
{
var beatLength = timingPoint.BeatLength / beatDivisor;
var beatLengths = (int)Math.Round((time - timingPoint.Time) / beatLength, MidpointRounding.AwayFromZero);
return timingPoint.Time + beatLengths * beatLength;
}
/// <summary>
/// Binary searches one of the control point lists to find the active control point at <paramref name="time"/>.
/// Includes logic for returning a specific point when no matching point is found.

View File

@ -1,12 +1,15 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Overlays.Settings;
@ -31,7 +34,15 @@ namespace osu.Game.Configuration
public int? OrderPosition { get; }
public SettingSourceAttribute(string label, string description = null)
/// <summary>
/// The type of the settings control which handles this setting source.
/// </summary>
/// <remarks>
/// Must be a type deriving <see cref="SettingsItem{T}"/> with a public parameterless constructor.
/// </remarks>
public Type? SettingControlType { get; set; }
public SettingSourceAttribute(string? label, string? description = null)
{
Label = label ?? string.Empty;
Description = description ?? string.Empty;
@ -67,6 +78,22 @@ namespace osu.Game.Configuration
{
object value = property.GetValue(obj);
if (attr.SettingControlType != null)
{
var controlType = attr.SettingControlType;
if (controlType.EnumerateBaseTypes().All(t => !t.IsGenericType || t.GetGenericTypeDefinition() != typeof(SettingsItem<>)))
throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})");
var control = (Drawable)Activator.CreateInstance(controlType);
controlType.GetProperty(nameof(SettingsItem<object>.LabelText))?.SetValue(control, attr.Label);
controlType.GetProperty(nameof(SettingsItem<object>.TooltipText))?.SetValue(control, attr.Description);
controlType.GetProperty(nameof(SettingsItem<object>.Current))?.SetValue(control, value);
yield return control;
continue;
}
switch (value)
{
case BindableNumber<float> bNumber:

View File

@ -156,33 +156,44 @@ namespace osu.Game.Database
bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size;
await Task.WhenAll(tasks.Select(async task =>
try
{
notification.CancellationToken.ThrowIfCancellationRequested();
try
await Task.WhenAll(tasks.Select(async task =>
{
var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false);
notification.CancellationToken.ThrowIfCancellationRequested();
lock (imported)
try
{
if (model != null)
imported.Add(model);
current++;
var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false);
notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s";
notification.Progress = (float)current / tasks.Length;
lock (imported)
{
if (model != null)
imported.Add(model);
current++;
notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s";
notification.Progress = (float)current / tasks.Length;
}
}
}
catch (TaskCanceledException)
catch (TaskCanceledException)
{
throw;
}
catch (Exception e)
{
Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database);
}
})).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (imported.Count == 0)
{
throw;
notification.State = ProgressNotificationState.Cancelled;
return imported;
}
catch (Exception e)
{
Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database);
}
})).ConfigureAwait(false);
}
if (imported.Count == 0)
{
@ -422,15 +433,25 @@ namespace osu.Game.Database
if (retrievedItem == null)
throw new ArgumentException("Specified model could not be found", nameof(item));
using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create))
ExportModelTo(retrievedItem, outputStream);
exportStorage.OpenInNativeExplorer();
}
/// <summary>
/// Exports an item to the given output stream.
/// </summary>
/// <param name="model">The item to export.</param>
/// <param name="outputStream">The output stream to export to.</param>
protected virtual void ExportModelTo(TModel model, Stream outputStream)
{
using (var archive = ZipArchive.Create())
{
foreach (var file in retrievedItem.Files)
foreach (var file in model.Files)
archive.AddEntry(file.Filename, Files.Storage.GetStream(file.FileInfo.StoragePath));
using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create))
archive.SaveTo(outputStream);
exportStorage.OpenInNativeExplorer();
archive.SaveTo(outputStream);
}
}

View File

@ -2,8 +2,10 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Graphics;
using osu.Framework.Input.Bindings;
using osu.Framework.Threading;
using osuTK;
namespace osu.Game.Extensions
{
@ -32,5 +34,14 @@ namespace osu.Game.Extensions
scheduler.Add(repeatDelegate);
return repeatDelegate;
}
/// <summary>
/// Accepts a delta vector in screen-space coordinates and converts it to one which can be applied to this drawable's position.
/// </summary>
/// <param name="drawable">The drawable.</param>
/// <param name="delta">A delta in screen-space coordinates.</param>
/// <returns>The delta vector in Parent's coordinates.</returns>
public static Vector2 ScreenSpaceDeltaToParentSpace(this Drawable drawable, Vector2 delta) =>
drawable.Parent.ToLocalSpace(drawable.Parent.ToScreenSpace(Vector2.Zero) + delta);
}
}

View File

@ -47,52 +47,44 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons
{
FillFlowContainer textSprites;
AddRangeInternal(new Drawable[]
AddInternal(shakeContainer = new ShakeContainer
{
shakeContainer = new ShakeContainer
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 5,
Child = button = new HeaderButton { RelativeSizeAxes = Axes.Both },
});
button.AddRange(new Drawable[]
{
new Container
{
Depth = -1,
Padding = new MarginPadding { Horizontal = 10 },
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 5,
Children = new Drawable[]
{
button = new HeaderButton { RelativeSizeAxes = Axes.Both },
new Container
textSprites = new FillFlowContainer
{
// cannot nest inside here due to the structure of button (putting things in its own content).
// requires framework fix.
Padding = new MarginPadding { Horizontal = 10 },
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
textSprites = new FillFlowContainer
{
Depth = -1,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
AutoSizeDuration = 500,
AutoSizeEasing = Easing.OutQuint,
Direction = FillDirection.Vertical,
},
new SpriteIcon
{
Depth = -1,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Icon = FontAwesome.Solid.Download,
Size = new Vector2(18),
},
}
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
AutoSizeDuration = 500,
AutoSizeEasing = Easing.OutQuint,
Direction = FillDirection.Vertical,
},
new DownloadProgressBar(BeatmapSet.Value)
new SpriteIcon
{
Depth = -2,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Icon = FontAwesome.Solid.Download,
Size = new Vector2(18),
},
},
}
},
new DownloadProgressBar(BeatmapSet.Value)
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
},
});

View File

@ -22,7 +22,11 @@ namespace osu.Game.Rulesets.Edit
// Audio
new CheckAudioPresence(),
new CheckAudioQuality()
new CheckAudioQuality(),
// Compose
new CheckUnsnappedObjects(),
new CheckConcurrentObjects()
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, WorkingBeatmap workingBeatmap)

View File

@ -0,0 +1,88 @@
// 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.Objects;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Edit.Checks
{
public class CheckConcurrentObjects : ICheck
{
// We guarantee that the objects are either treated as concurrent or unsnapped when near the same beat divisor.
private const double ms_leniency = CheckUnsnappedObjects.UNSNAP_MS_THRESHOLD;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Concurrent hitobjects");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateConcurrentSame(this),
new IssueTemplateConcurrentDifferent(this)
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap)
{
for (int i = 0; i < playableBeatmap.HitObjects.Count - 1; ++i)
{
var hitobject = playableBeatmap.HitObjects[i];
for (int j = i + 1; j < playableBeatmap.HitObjects.Count; ++j)
{
var nextHitobject = playableBeatmap.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.
if ((hitobject as IHasColumn)?.Column != (nextHitobject as IHasColumn)?.Column)
continue;
// Two hitobjects cannot be concurrent without also being concurrent with all objects in between.
// So if the next object is not concurrent, then we know no future objects will be either.
if (!areConcurrent(hitobject, nextHitobject))
break;
if (hitobject.GetType() == nextHitobject.GetType())
yield return new IssueTemplateConcurrentSame(this).Create(hitobject, nextHitobject);
else
yield return new IssueTemplateConcurrentDifferent(this).Create(hitobject, nextHitobject);
}
}
}
private bool areConcurrent(HitObject hitobject, HitObject nextHitobject) => nextHitobject.StartTime <= hitobject.GetEndTime() + ms_leniency;
public abstract class IssueTemplateConcurrent : IssueTemplate
{
protected IssueTemplateConcurrent(ICheck check, string unformattedMessage)
: base(check, IssueType.Problem, unformattedMessage)
{
}
public Issue Create(HitObject hitobject, HitObject nextHitobject)
{
var hitobjects = new List<HitObject> { hitobject, nextHitobject };
return new Issue(hitobjects, this, hitobject.GetType().Name, nextHitobject.GetType().Name)
{
Time = nextHitobject.StartTime
};
}
}
public class IssueTemplateConcurrentSame : IssueTemplateConcurrent
{
public IssueTemplateConcurrentSame(ICheck check)
: base(check, "{0}s are concurrent here.")
{
}
}
public class IssueTemplateConcurrentDifferent : IssueTemplateConcurrent
{
public IssueTemplateConcurrentDifferent(ICheck check)
: base(check, "{0} and {1} are concurrent here.")
{
}
}
}
}

View File

@ -0,0 +1,100 @@
// 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.Game.Beatmaps;
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 CheckUnsnappedObjects : ICheck
{
public const double UNSNAP_MS_THRESHOLD = 2;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Timing, "Unsnapped hitobjects");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateLargeUnsnap(this),
new IssueTemplateSmallUnsnap(this)
};
public IEnumerable<Issue> Run(IBeatmap playableBeatmap, IWorkingBeatmap workingBeatmap)
{
var controlPointInfo = playableBeatmap.ControlPointInfo;
foreach (var hitobject in playableBeatmap.HitObjects)
{
double startUnsnap = hitobject.StartTime - controlPointInfo.GetClosestSnappedTime(hitobject.StartTime);
string startPostfix = hitobject is IHasDuration ? "start" : "";
foreach (var issue in getUnsnapIssues(hitobject, startUnsnap, hitobject.StartTime, startPostfix))
yield return issue;
if (hitobject is IHasRepeats hasRepeats)
{
for (int repeatIndex = 0; repeatIndex < hasRepeats.RepeatCount; ++repeatIndex)
{
double spanDuration = hasRepeats.Duration / (hasRepeats.RepeatCount + 1);
double repeatTime = hitobject.StartTime + spanDuration * (repeatIndex + 1);
double repeatUnsnap = repeatTime - controlPointInfo.GetClosestSnappedTime(repeatTime);
foreach (var issue in getUnsnapIssues(hitobject, repeatUnsnap, repeatTime, "repeat"))
yield return issue;
}
}
if (hitobject is IHasDuration hasDuration)
{
double endUnsnap = hasDuration.EndTime - controlPointInfo.GetClosestSnappedTime(hasDuration.EndTime);
foreach (var issue in getUnsnapIssues(hitobject, endUnsnap, hasDuration.EndTime, "end"))
yield return issue;
}
}
}
private IEnumerable<Issue> getUnsnapIssues(HitObject hitobject, double unsnap, double time, string postfix = "")
{
if (Math.Abs(unsnap) >= UNSNAP_MS_THRESHOLD)
yield return new IssueTemplateLargeUnsnap(this).Create(hitobject, unsnap, time, postfix);
else if (Math.Abs(unsnap) >= 1)
yield return new IssueTemplateSmallUnsnap(this).Create(hitobject, unsnap, time, postfix);
// We don't care about unsnaps < 1 ms, as all object ends have these due to the way SV works.
}
public abstract class IssueTemplateUnsnap : IssueTemplate
{
protected IssueTemplateUnsnap(ICheck check, IssueType type)
: base(check, type, "{0} is unsnapped by {1:0.##} ms.")
{
}
public Issue Create(HitObject hitobject, double unsnap, double time, string postfix = "")
{
string objectName = hitobject.GetType().Name;
if (!string.IsNullOrEmpty(postfix))
objectName += " " + postfix;
return new Issue(hitobject, this, objectName, unsnap) { Time = time };
}
}
public class IssueTemplateLargeUnsnap : IssueTemplateUnsnap
{
public IssueTemplateLargeUnsnap(ICheck check)
: base(check, IssueType.Problem)
{
}
}
public class IssueTemplateSmallUnsnap : IssueTemplateUnsnap
{
public IssueTemplateSmallUnsnap(ICheck check)
: base(check, IssueType.Negligible)
{
}
}
}
}

View File

@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Edit
/// <summary>
/// A wrapper for a <see cref="DrawableRuleset{TObject}"/>. Handles adding visual representations of <see cref="HitObject"/>s to the underlying <see cref="DrawableRuleset{TObject}"/>.
/// </summary>
internal class DrawableEditRulesetWrapper<TObject> : CompositeDrawable
internal class DrawableEditorRulesetWrapper<TObject> : CompositeDrawable
where TObject : HitObject
{
public Playfield Playfield => drawableRuleset.Playfield;
@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Edit
[Resolved]
private EditorBeatmap beatmap { get; set; }
public DrawableEditRulesetWrapper(DrawableRuleset<TObject> drawableRuleset)
public DrawableEditorRulesetWrapper(DrawableRuleset<TObject> drawableRuleset)
{
this.drawableRuleset = drawableRuleset;

View File

@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Edit
protected ComposeBlueprintContainer BlueprintContainer { get; private set; }
private DrawableEditRulesetWrapper<TObject> drawableRulesetWrapper;
private DrawableEditorRulesetWrapper<TObject> drawableRulesetWrapper;
protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both };
@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Edit
try
{
drawableRulesetWrapper = new DrawableEditRulesetWrapper<TObject>(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() }))
drawableRulesetWrapper = new DrawableEditorRulesetWrapper<TObject>(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() }))
{
Clock = EditorClock,
ProcessCustomClock = false
@ -182,8 +182,7 @@ namespace osu.Game.Rulesets.Edit
/// <summary>
/// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic.
/// </summary>
protected virtual ComposeBlueprintContainer CreateBlueprintContainer()
=> new ComposeBlueprintContainer(this);
protected virtual ComposeBlueprintContainer CreateBlueprintContainer() => new ComposeBlueprintContainer(this);
/// <summary>
/// Construct a drawable ruleset for the provided ruleset.

View File

@ -3,12 +3,13 @@
using osu.Framework.Graphics.Primitives;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK;
namespace osu.Game.Rulesets.Edit
{
public abstract class OverlaySelectionBlueprint : SelectionBlueprint
public abstract class OverlaySelectionBlueprint : SelectionBlueprint<HitObject>
{
/// <summary>
/// The <see cref="DrawableHitObject"/> which this <see cref="OverlaySelectionBlueprint"/> applies to.
@ -33,7 +34,5 @@ namespace osu.Game.Rulesets.Edit
public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.ScreenSpaceDrawQuad.Centre;
public override Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad;
public override Vector2 GetInstantDelta(Vector2 screenSpacePosition) => DrawableObject.Parent.ToLocalSpace(screenSpacePosition) - DrawableObject.Position;
}
}

View File

@ -3,44 +3,38 @@
using System;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK;
namespace osu.Game.Rulesets.Edit
{
/// <summary>
/// A blueprint placed above a <see cref="DrawableHitObject"/> adding editing functionality.
/// A blueprint placed above a displaying item adding editing functionality.
/// </summary>
public abstract class SelectionBlueprint : CompositeDrawable, IStateful<SelectionState>
public abstract class SelectionBlueprint<T> : CompositeDrawable, IStateful<SelectionState>
{
public readonly HitObject HitObject;
public readonly T Item;
/// <summary>
/// Invoked when this <see cref="SelectionBlueprint"/> has been selected.
/// Invoked when this <see cref="SelectionBlueprint{T}"/> has been selected.
/// </summary>
public event Action<SelectionBlueprint> Selected;
public event Action<SelectionBlueprint<T>> Selected;
/// <summary>
/// Invoked when this <see cref="SelectionBlueprint"/> has been deselected.
/// Invoked when this <see cref="SelectionBlueprint{T}"/> has been deselected.
/// </summary>
public event Action<SelectionBlueprint> Deselected;
public event Action<SelectionBlueprint<T>> Deselected;
public override bool HandlePositionalInput => ShouldBeAlive;
public override bool RemoveWhenNotAlive => false;
[Resolved(CanBeNull = true)]
private HitObjectComposer composer { get; set; }
protected SelectionBlueprint(HitObject hitObject)
protected SelectionBlueprint(T item)
{
HitObject = hitObject;
Item = item;
RelativeSizeAxes = Axes.Both;
AlwaysPresent = true;
@ -91,7 +85,7 @@ namespace osu.Game.Rulesets.Edit
protected virtual void OnDeselected()
{
// selection blueprints are AlwaysPresent while the related DrawableHitObject is visible
// selection blueprints are AlwaysPresent while the related item is visible
// set the body piece's alpha directly to avoid arbitrarily rendering frame buffers etc. of children.
foreach (var d in InternalChildren)
d.Hide();
@ -133,7 +127,7 @@ namespace osu.Game.Rulesets.Edit
public virtual MenuItem[] ContextMenuItems => Array.Empty<MenuItem>();
/// <summary>
/// The screen-space point that causes this <see cref="OverlaySelectionBlueprint"/> to be selected.
/// The screen-space point that causes this <see cref="OverlaySelectionBlueprint"/> to be selected via a drag.
/// </summary>
public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre;
@ -142,8 +136,6 @@ namespace osu.Game.Rulesets.Edit
/// </summary>
public virtual Quad SelectionQuad => ScreenSpaceDrawQuad;
public virtual Vector2 GetInstantDelta(Vector2 screenSpacePosition) => Parent.ToLocalSpace(screenSpacePosition) - Position;
/// <summary>
/// Handle to perform a partial deletion when the user requests a quick delete (Shift+Right Click).
/// </summary>

View File

@ -0,0 +1,57 @@
// 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.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Configuration;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osuTK;
namespace osu.Game.Rulesets.Mods
{
public abstract class ModBarrelRoll<TObject> : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset<TObject>
where TObject : HitObject
{
/// <summary>
/// The current angle of rotation being applied by this mod.
/// Generally should be used to apply inverse rotation to elements which should not be rotated.
/// </summary>
protected float CurrentRotation { get; private set; }
[SettingSource("Roll speed", "Rotations per minute")]
public BindableNumber<double> SpinSpeed { get; } = new BindableDouble(0.5)
{
MinValue = 0.02,
MaxValue = 12,
Precision = 0.01,
};
[SettingSource("Direction", "The direction of rotation")]
public Bindable<RotationDirection> Direction { get; } = new Bindable<RotationDirection>(RotationDirection.Clockwise);
public override string Name => "Barrel Roll";
public override string Acronym => "BR";
public override string Description => "The whole playfield is on a wheel!";
public override double ScoreMultiplier => 1;
public override string SettingDescription => $"{SpinSpeed.Value} rpm {Direction.Value.GetDescription().ToLowerInvariant()}";
public void Update(Playfield playfield)
{
playfield.Rotation = CurrentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value);
}
public void ApplyToDrawableRuleset(DrawableRuleset<TObject> drawableRuleset)
{
// scale the playfield to allow all hitobjects to stay within the visible region.
var playfieldSize = drawableRuleset.Playfield.DrawSize;
var minSide = MathF.Min(playfieldSize.X, playfieldSize.Y);
var maxSide = MathF.Max(playfieldSize.X, playfieldSize.Y);
drawableRuleset.Playfield.Scale = new Vector2(minSide / maxSide);
}
}
}

View File

@ -11,21 +11,23 @@ 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;
using osu.Game.Configuration;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Pooling;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Skinning;
using osu.Game.Configuration;
using osu.Game.Rulesets.UI;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Objects.Drawables
{
[Cached(typeof(DrawableHitObject))]
public abstract class DrawableHitObject : SkinReloadableDrawable
public abstract class DrawableHitObject : PoolableDrawableWithLifetime<HitObjectLifetimeEntry>
{
/// <summary>
/// Invoked after this <see cref="DrawableHitObject"/>'s applied <see cref="HitObject"/> has had its defaults applied.
@ -40,7 +42,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// <summary>
/// The <see cref="HitObject"/> currently represented by this <see cref="DrawableHitObject"/>.
/// </summary>
public HitObject HitObject => lifetimeEntry?.HitObject;
public HitObject HitObject => Entry?.HitObject;
/// <summary>
/// The parenting <see cref="DrawableHitObject"/>, if any.
@ -109,7 +111,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// <summary>
/// The scoring result of this <see cref="DrawableHitObject"/>.
/// </summary>
public JudgementResult Result => lifetimeEntry?.Result;
public JudgementResult Result => Entry?.Result;
/// <summary>
/// The relative X position of this hit object for sample playback balance adjustment.
@ -125,8 +127,6 @@ namespace osu.Game.Rulesets.Objects.Drawables
private readonly Bindable<bool> userPositionalHitSounds = new Bindable<bool>();
private readonly Bindable<int> comboIndexBindable = new Bindable<int>();
public override bool RemoveWhenNotAlive => false;
public override bool RemoveCompletedTransforms => false;
protected override bool RequiresChildrenUpdate => true;
public override bool IsPresent => base.IsPresent || (State.Value == ArmedState.Idle && Clock?.CurrentTime >= LifetimeStart);
@ -141,18 +141,6 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// </remarks>
public IBindable<ArmedState> State => state;
/// <summary>
/// Whether a <see cref="HitObjectLifetimeEntry"/> is currently applied.
/// </summary>
private bool hasEntryApplied;
/// <summary>
/// The <see cref="HitObjectLifetimeEntry"/> controlling the lifetime of the currently-attached <see cref="HitObject"/>.
/// </summary>
/// <remarks>Even if it is not null, it may not be fully applied until loaded (<see cref="hasEntryApplied"/> is false).</remarks>
[CanBeNull]
private HitObjectLifetimeEntry lifetimeEntry;
[Resolved(CanBeNull = true)]
private IPooledHitObjectProvider pooledObjectProvider { get; set; }
@ -166,32 +154,25 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// </summary>
/// <param name="initialHitObject">
/// The <see cref="HitObject"/> to be initially applied to this <see cref="DrawableHitObject"/>.
/// If <c>null</c>, a hitobject is expected to be later applied via <see cref="Apply(osu.Game.Rulesets.Objects.HitObjectLifetimeEntry)"/> (or automatically via pooling).
/// 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 (initialHitObject != null)
{
lifetimeEntry = new SyntheticHitObjectEntry(initialHitObject);
if (Entry != null)
ensureEntryHasResult();
}
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
private void load(OsuConfigManager config, ISkinSource skinSource)
{
config.BindWith(OsuSetting.PositionalHitSounds, userPositionalHitSounds);
// Explicit non-virtual function call.
base.AddInternal(Samples = new PausableSkinnableSound());
}
protected override void LoadAsyncComplete()
{
base.LoadAsyncComplete();
if (lifetimeEntry != null && !hasEntryApplied)
Apply(lifetimeEntry);
CurrentSkin = skinSource;
CurrentSkin.SourceChanged += onSkinSourceChanged;
}
protected override void LoadComplete()
@ -227,22 +208,12 @@ namespace osu.Game.Rulesets.Objects.Drawables
Apply(new SyntheticHitObjectEntry(hitObject));
}
/// <summary>
/// Applies a new <see cref="HitObjectLifetimeEntry"/> to be represented by this <see cref="DrawableHitObject"/>.
/// </summary>
public void Apply([NotNull] HitObjectLifetimeEntry newEntry)
protected sealed override void OnApply(HitObjectLifetimeEntry entry)
{
free();
lifetimeEntry = newEntry;
// LifetimeStart is already computed using HitObjectLifetimeEntry's InitialLifetimeOffset.
// We override this with DHO's InitialLifetimeOffset for a non-pooled DHO.
if (newEntry is SyntheticHitObjectEntry)
lifetimeEntry.LifetimeStart = HitObject.StartTime - InitialLifetimeOffset;
LifetimeStart = lifetimeEntry.LifetimeStart;
LifetimeEnd = lifetimeEntry.LifetimeEnd;
if (entry is SyntheticHitObjectEntry)
LifetimeStart = HitObject.StartTime - InitialLifetimeOffset;
ensureEntryHasResult();
@ -293,17 +264,10 @@ namespace osu.Game.Rulesets.Objects.Drawables
else
updateState(ArmedState.Idle, true);
}
hasEntryApplied = true;
}
/// <summary>
/// Removes the currently applied <see cref="lifetimeEntry"/>
/// </summary>
private void free()
protected sealed override void OnFree(HitObjectLifetimeEntry entry)
{
if (!hasEntryApplied) return;
StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable);
if (HitObject is IHasComboInformation combo)
comboIndexBindable.UnbindFrom(combo.ComboIndexBindable);
@ -335,22 +299,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
OnFree();
ParentHitObject = null;
lifetimeEntry = null;
clearExistingStateTransforms();
hasEntryApplied = false;
}
protected sealed override void FreeAfterUse()
{
base.FreeAfterUse();
// Freeing while not in a pool would cause the DHO to not be usable elsewhere in the hierarchy without being re-applied.
if (!IsInPool)
return;
free();
}
/// <summary>
@ -398,8 +348,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
private void onDefaultsApplied(HitObject hitObject)
{
Debug.Assert(lifetimeEntry != null);
Apply(lifetimeEntry);
Debug.Assert(Entry != null);
Apply(Entry);
DefaultsApplied?.Invoke(this);
}
@ -480,9 +430,14 @@ namespace osu.Game.Rulesets.Objects.Drawables
base.ClearTransformsAfter(double.MinValue, true);
}
/// <summary>
/// Reapplies the current <see cref="ArmedState"/>.
/// </summary>
protected void RefreshStateTransforms() => updateState(State.Value, true);
/// <summary>
/// Apply (generally fade-in) transforms leading into the <see cref="HitObject"/> start time.
/// The local drawable hierarchy is recursively delayed to <see cref="LifetimeStart"/> for convenience.
/// 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.
/// </summary>
@ -536,17 +491,19 @@ namespace osu.Game.Rulesets.Objects.Drawables
#endregion
protected sealed override void SkinChanged(ISkinSource skin, bool allowFallback)
{
base.SkinChanged(skin, allowFallback);
#region Skinning
protected ISkinSource CurrentSkin { get; private set; }
private void onSkinSourceChanged() => Scheduler.AddOnce(() =>
{
UpdateComboColour();
ApplySkin(skin, allowFallback);
ApplySkin(CurrentSkin, true);
if (IsLoaded)
updateState(State.Value, true);
}
});
protected void UpdateComboColour()
{
@ -616,6 +573,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
Samples.Stop();
}
#endregion
protected override void Update()
{
base.Update();
@ -653,30 +612,6 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// </remarks>
protected internal new ScheduledDelegate Schedule(Action action) => base.Schedule(action);
public override double LifetimeStart
{
get => base.LifetimeStart;
set => setLifetime(value, LifetimeEnd);
}
public override double LifetimeEnd
{
get => base.LifetimeEnd;
set => setLifetime(LifetimeStart, value);
}
private void setLifetime(double lifetimeStart, double lifetimeEnd)
{
base.LifetimeStart = lifetimeStart;
base.LifetimeEnd = lifetimeEnd;
if (lifetimeEntry != null)
{
lifetimeEntry.LifetimeStart = lifetimeStart;
lifetimeEntry.LifetimeEnd = lifetimeEnd;
}
}
/// <summary>
/// A safe 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"/>.
@ -684,7 +619,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// <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="LifetimeStart"/> should be set for further optimisation (in <see cref="LoadComplete"/>, for example).
/// 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.
@ -800,9 +735,9 @@ namespace osu.Game.Rulesets.Objects.Drawables
private void ensureEntryHasResult()
{
Debug.Assert(lifetimeEntry != null);
lifetimeEntry.Result ??= CreateResult(HitObject.CreateJudgement())
?? throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}.");
Debug.Assert(Entry != null);
Entry.Result ??= CreateResult(HitObject.CreateJudgement())
?? throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}.");
}
protected override void Dispose(bool isDisposing)
@ -811,6 +746,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (HitObject != null)
HitObject.DefaultsApplied -= onDefaultsApplied;
CurrentSkin.SourceChanged -= onSkinSourceChanged;
}
}

View File

@ -38,40 +38,23 @@ namespace osu.Game.Rulesets.Objects
startTimeBindable.BindValueChanged(onStartTimeChanged, true);
}
// The lifetime start, as set by the hitobject.
// The lifetime, as set by the hitobject.
private double realLifetimeStart = double.MinValue;
/// <summary>
/// The time at which the <see cref="HitObject"/> should become alive.
/// </summary>
public new double LifetimeStart
{
get => realLifetimeStart;
set => setLifetime(realLifetimeStart = value, LifetimeEnd);
}
// The lifetime end, as set by the hitobject.
private double realLifetimeEnd = double.MaxValue;
/// <summary>
/// The time at which the <see cref="HitObject"/> should become dead.
/// </summary>
public new double LifetimeEnd
// This method is called even if `start == LifetimeStart` when `KeepAlive` is true (necessary to update `realLifetimeStart`).
protected override void SetLifetimeStart(double start)
{
get => realLifetimeEnd;
set => setLifetime(LifetimeStart, realLifetimeEnd = value);
realLifetimeStart = start;
if (!keepAlive)
base.SetLifetimeStart(start);
}
private void setLifetime(double start, double end)
protected override void SetLifetimeEnd(double end)
{
if (keepAlive)
{
start = double.MinValue;
end = double.MaxValue;
}
base.LifetimeStart = start;
base.LifetimeEnd = end;
realLifetimeEnd = end;
if (!keepAlive)
base.SetLifetimeEnd(end);
}
private bool keepAlive;
@ -87,7 +70,10 @@ namespace osu.Game.Rulesets.Objects
return;
keepAlive = value;
setLifetime(realLifetimeStart, realLifetimeEnd);
if (keepAlive)
SetLifetime(double.MinValue, double.MaxValue);
else
SetLifetime(realLifetimeStart, realLifetimeEnd);
}
}
@ -98,12 +84,12 @@ namespace osu.Game.Rulesets.Objects
/// <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="LifetimeStart"/> should be set for further optimisation (in <see cref="DrawableHitObject.LoadComplete"/>, for example).
/// A more accurate <see cref="LifetimeEntry.LifetimeStart"/> should be set for further optimisation (in <see cref="DrawableHitObject.LoadComplete"/>, for example).
/// </remarks>
protected virtual double InitialLifetimeOffset => 10000;
/// <summary>
/// Resets <see cref="LifetimeStart"/> according to the change in start time of the <see cref="HitObject"/>.
/// Resets <see cref="LifetimeEntry.LifetimeStart"/> according to the change in start time of the <see cref="HitObject"/>.
/// </summary>
private void onStartTimeChanged(ValueChangedEvent<double> startTime) => LifetimeStart = HitObject.StartTime - InitialLifetimeOffset;
}

View File

@ -0,0 +1,122 @@
// 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.Diagnostics;
using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Pooling;
namespace osu.Game.Rulesets.Objects.Pooling
{
/// <summary>
/// A <see cref="PoolableDrawable"/> that is controlled by <see cref="Entry"/> to implement drawable pooling and replay rewinding.
/// </summary>
/// <typeparam name="TEntry">The <see cref="LifetimeEntry"/> type storing state and controlling this drawable.</typeparam>
public abstract class PoolableDrawableWithLifetime<TEntry> : PoolableDrawable where TEntry : LifetimeEntry
{
/// <summary>
/// The entry holding essential state of this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
/// </summary>
protected TEntry? Entry { get; private set; }
/// <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.
/// </summary>
protected bool HasEntryApplied { get; private set; }
public override double LifetimeStart
{
get => base.LifetimeStart;
set => setLifetime(value, LifetimeEnd);
}
public override double LifetimeEnd
{
get => base.LifetimeEnd;
set => setLifetime(LifetimeStart, value);
}
public override bool RemoveWhenNotAlive => false;
public override bool RemoveCompletedTransforms => false;
protected PoolableDrawableWithLifetime(TEntry? initialEntry = null)
{
Entry = initialEntry;
}
protected override void LoadAsyncComplete()
{
base.LoadAsyncComplete();
// Apply the initial entry given in the constructor.
if (Entry != null && !HasEntryApplied)
Apply(Entry);
}
/// <summary>
/// Applies a new entry to be represented by this drawable.
/// If there is an existing entry applied, the entry will be replaced.
/// </summary>
public void Apply(TEntry entry)
{
if (HasEntryApplied)
free();
setLifetime(entry.LifetimeStart, entry.LifetimeEnd);
Entry = entry;
OnApply(entry);
HasEntryApplied = true;
}
protected sealed override void FreeAfterUse()
{
base.FreeAfterUse();
// We preserve the existing entry in case we want to move a non-pooled drawable between different parent drawables.
if (HasEntryApplied && IsInPool)
free();
}
/// <summary>
/// Invoked to apply a new entry to this drawable.
/// </summary>
protected virtual void OnApply(TEntry entry)
{
}
/// <summary>
/// Invoked to revert application of the entry to this drawable.
/// </summary>
protected virtual void OnFree(TEntry entry)
{
}
private void setLifetime(double start, double end)
{
base.LifetimeStart = start;
base.LifetimeEnd = end;
if (Entry != null)
{
Entry.LifetimeStart = start;
Entry.LifetimeEnd = end;
}
}
private void free()
{
Debug.Assert(Entry != null && HasEntryApplied);
OnFree(Entry);
Entry = null;
setLifetime(double.MaxValue, double.MaxValue);
HasEntryApplied = false;
}
}
}

View File

@ -1,7 +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.
namespace osu.Game.Rulesets.Mania.Objects.Types
namespace osu.Game.Rulesets.Objects.Types
{
/// <summary>
/// A type of hit object which lies in one of a number of predetermined columns.

View File

@ -252,7 +252,7 @@ namespace osu.Game.Rulesets.Scoring
computedBaseScore += Judgement.ToNumericResult(pair.Key) * pair.Value;
}
return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), scoreResultCounts);
return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), statistics);
}
/// <summary>
@ -266,7 +266,7 @@ namespace osu.Game.Rulesets.Scoring
if (preferRolling && rollingMaxBaseScore != 0)
return baseScore / rollingMaxBaseScore;
return maxBaseScore > 0 ? baseScore / maxBaseScore : 0;
return maxBaseScore > 0 ? baseScore / maxBaseScore : 1;
}
private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1;

View File

@ -8,7 +8,6 @@ using System.Text;
using osu.Framework.Extensions;
using osu.Game.Beatmaps;
using osu.Game.IO.Legacy;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Replays.Types;
using SharpCompress.Compressors.LZMA;
@ -91,12 +90,14 @@ namespace osu.Game.Scoring.Legacy
if (score.Replay != null)
{
LegacyReplayFrame lastF = new LegacyReplayFrame(0, 0, 0, ReplayButtonState.None);
int lastTime = 0;
foreach (var f in score.Replay.Frames.OfType<IConvertibleReplayFrame>().Select(f => f.ToLegacy(beatmap)))
{
replayData.Append(FormattableString.Invariant($"{f.Time - lastF.Time}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},"));
lastF = f;
// Rounding because stable could only parse integral values
int time = (int)Math.Round(f.Time);
replayData.Append(FormattableString.Invariant($"{time - lastTime}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},"));
lastTime = time;
}
}

View File

@ -72,6 +72,16 @@ namespace osu.Game.Scoring
}
}
protected override void ExportModelTo(ScoreInfo model, Stream outputStream)
{
var file = model.Files.SingleOrDefault();
if (file == null)
return;
using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath))
inputStream.CopyTo(outputStream);
}
protected override IEnumerable<string> GetStableImportPaths(Storage storage)
=> storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false))
.Select(path => storage.GetFullPath(path));

View File

@ -16,7 +16,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Components.RadioButtons
{
public class DrawableRadioButton : TriangleButton
public class DrawableRadioButton : OsuButton
{
/// <summary>
/// Invoked when this <see cref="DrawableRadioButton"/> has been selected.
@ -49,8 +49,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
selectedBackgroundColour = colours.BlueDark;
selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f);
Triangles.Alpha = 0;
Content.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,

View File

@ -15,7 +15,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Components.TernaryButtons
{
internal class DrawableTernaryButton : TriangleButton
internal class DrawableTernaryButton : OsuButton
{
private Color4 defaultBackgroundColour;
private Color4 defaultBubbleColour;
@ -43,8 +43,6 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
selectedBackgroundColour = colours.BlueDark;
selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f);
Triangles.Alpha = 0;
Content.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,

View File

@ -3,11 +3,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
@ -16,46 +14,33 @@ using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK;
using osuTK.Input;
namespace osu.Game.Screens.Edit.Compose.Components
{
/// <summary>
/// A container which provides a "blueprint" display of hitobjects.
/// Includes selection and manipulation support via a <see cref="Components.SelectionHandler"/>.
/// A container which provides a "blueprint" display of items.
/// Includes selection and manipulation support via a <see cref="Components.SelectionHandler{T}"/>.
/// </summary>
public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler<PlatformAction>
public abstract class BlueprintContainer<T> : CompositeDrawable, IKeyBindingHandler<PlatformAction>
{
protected DragBox DragBox { get; private set; }
public Container<SelectionBlueprint> SelectionBlueprints { get; private set; }
public Container<SelectionBlueprint<T>> SelectionBlueprints { get; private set; }
protected SelectionHandler SelectionHandler { get; private set; }
protected SelectionHandler<T> SelectionHandler { get; private set; }
protected readonly HitObjectComposer Composer;
[Resolved(CanBeNull = true)]
private IEditorChangeHandler changeHandler { get; set; }
[Resolved]
protected EditorClock EditorClock { get; private set; }
[Resolved]
protected EditorBeatmap Beatmap { get; private set; }
private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
private readonly Dictionary<HitObject, SelectionBlueprint> blueprintMap = new Dictionary<HitObject, SelectionBlueprint>();
private readonly Dictionary<T, SelectionBlueprint<T>> blueprintMap = new Dictionary<T, SelectionBlueprint<T>>();
[Resolved(canBeNull: true)]
private IPositionSnapProvider snapProvider { get; set; }
protected BlueprintContainer(HitObjectComposer composer)
{
Composer = composer;
[Resolved(CanBeNull = true)]
private IEditorChangeHandler changeHandler { get; set; }
protected BlueprintContainer()
{
RelativeSizeAxes = Axes.Both;
}
@ -73,66 +58,28 @@ namespace osu.Game.Screens.Edit.Compose.Components
SelectionHandler.CreateProxy(),
DragBox.CreateProxy().With(p => p.Depth = float.MinValue)
});
// For non-pooled rulesets, hitobjects are already present in the playfield which allows the blueprints to be loaded in the async context.
if (Composer != null)
{
foreach (var obj in Composer.HitObjects)
addBlueprintFor(obj.HitObject);
}
selectedHitObjects.BindTo(Beatmap.SelectedHitObjects);
selectedHitObjects.CollectionChanged += (selectedObjects, args) =>
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (var o in args.NewItems)
SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Select();
break;
case NotifyCollectionChangedAction.Remove:
foreach (var o in args.OldItems)
SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Deselect();
break;
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Beatmap.HitObjectAdded += addBlueprintFor;
Beatmap.HitObjectRemoved += removeBlueprintFor;
if (Composer != null)
{
// For pooled rulesets, blueprints must be added for hitobjects already "current" as they would've not been "current" during the async load addition process above.
foreach (var obj in Composer.HitObjects)
addBlueprintFor(obj.HitObject);
Composer.Playfield.HitObjectUsageBegan += addBlueprintFor;
Composer.Playfield.HitObjectUsageFinished += removeBlueprintFor;
}
}
protected virtual Container<SelectionBlueprint> CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both };
protected virtual Container<SelectionBlueprint<T>> CreateSelectionBlueprintContainer() => new Container<SelectionBlueprint<T>> { RelativeSizeAxes = Axes.Both };
/// <summary>
/// Creates a <see cref="Components.SelectionHandler"/> which outlines <see cref="DrawableHitObject"/>s and handles movement of selections.
/// Creates a <see cref="Components.SelectionHandler{T}"/> which outlines items and handles movement of selections.
/// </summary>
protected virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler();
protected abstract SelectionHandler<T> CreateSelectionHandler();
/// <summary>
/// Creates a <see cref="SelectionBlueprint"/> for a specific <see cref="DrawableHitObject"/>.
/// Creates a <see cref="SelectionBlueprint{T}"/> for a specific item.
/// </summary>
/// <param name="hitObject">The <see cref="DrawableHitObject"/> to create the overlay for.</param>
protected virtual SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => null;
/// <param name="item">The item to create the overlay for.</param>
protected virtual SelectionBlueprint<T> CreateBlueprintFor(T item) => null;
protected virtual DragBox CreateDragBox(Action<RectangleF> performSelect) => new DragBox(performSelect);
/// <summary>
/// Whether this component is in a state where items outside a drag selection should be deselected. If false, selection will only be added to.
/// </summary>
protected virtual bool AllowDeselectionDuringDrag => true;
protected override bool OnMouseDown(MouseDownEvent e)
{
bool selectionPerformed = performMouseDownActions(e);
@ -143,7 +90,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
return selectionPerformed || e.Button == MouseButton.Left;
}
private SelectionBlueprint clickedBlueprint;
protected SelectionBlueprint<T> ClickedBlueprint { get; private set; }
protected override bool OnClick(ClickEvent e)
{
@ -151,11 +98,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
return false;
// store for double-click handling
clickedBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered);
ClickedBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered);
// Deselection should only occur if no selected blueprints are hovered
// A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the hitobject and should not trigger deselection
if (endClickSelection(e) || clickedBlueprint != null)
// A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the item and should not trigger deselection
if (endClickSelection(e) || ClickedBlueprint != null)
return true;
deselectAll();
@ -168,10 +115,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
return false;
// ensure the blueprint which was hovered for the first click is still the hovered blueprint.
if (clickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint)
if (ClickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != ClickedBlueprint)
return false;
EditorClock?.SeekSmoothlyTo(clickedBlueprint.HitObject.StartTime);
return true;
}
@ -227,10 +173,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (isDraggingBlueprint)
{
// handle positional change etc.
foreach (var obj in selectedHitObjects)
Beatmap.Update(obj);
DragOperationCompleted();
changeHandler?.EndChange();
}
@ -238,6 +181,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
DragBox.Hide();
}
/// <summary>
/// Called whenever a drag operation completes, before any change transaction is committed.
/// </summary>
protected virtual void DragOperationCompleted()
{
}
protected override bool OnKeyDown(KeyDownEvent e)
{
switch (e.Key)
@ -258,7 +208,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
switch (action.ActionType)
{
case PlatformActionType.SelectAll:
selectAll();
SelectAll();
return true;
}
@ -271,61 +221,58 @@ namespace osu.Game.Screens.Edit.Compose.Components
#region Blueprint Addition/Removal
private void addBlueprintFor(HitObject hitObject)
protected virtual void AddBlueprintFor(T item)
{
if (hitObject is IBarLine)
if (blueprintMap.ContainsKey(item))
return;
if (blueprintMap.ContainsKey(hitObject))
return;
var blueprint = CreateBlueprintFor(hitObject);
var blueprint = CreateBlueprintFor(item);
if (blueprint == null)
return;
blueprintMap[hitObject] = blueprint;
blueprintMap[item] = blueprint;
blueprint.Selected += onBlueprintSelected;
blueprint.Deselected += onBlueprintDeselected;
if (Beatmap.SelectedHitObjects.Contains(hitObject))
blueprint.Select();
blueprint.Selected += OnBlueprintSelected;
blueprint.Deselected += OnBlueprintDeselected;
SelectionBlueprints.Add(blueprint);
OnBlueprintAdded(hitObject);
if (SelectionHandler.SelectedItems.Contains(item))
blueprint.Select();
OnBlueprintAdded(blueprint.Item);
}
private void removeBlueprintFor(HitObject hitObject)
protected void RemoveBlueprintFor(T item)
{
if (!blueprintMap.Remove(hitObject, out var blueprint))
if (!blueprintMap.Remove(item, out var blueprint))
return;
blueprint.Deselect();
blueprint.Selected -= onBlueprintSelected;
blueprint.Deselected -= onBlueprintDeselected;
blueprint.Selected -= OnBlueprintSelected;
blueprint.Deselected -= OnBlueprintDeselected;
SelectionBlueprints.Remove(blueprint);
if (movementBlueprints?.Contains(blueprint) == true)
finishSelectionMovement();
OnBlueprintRemoved(hitObject);
OnBlueprintRemoved(blueprint.Item);
}
/// <summary>
/// Called after a <see cref="HitObject"/> blueprint has been added.
/// Called after an item's blueprint has been added.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> for which the blueprint has been added.</param>
protected virtual void OnBlueprintAdded(HitObject hitObject)
/// <param name="item">The item for which the blueprint has been added.</param>
protected virtual void OnBlueprintAdded(T item)
{
}
/// <summary>
/// Called after a <see cref="HitObject"/> blueprint has been removed.
/// Called after an item's blueprint has been removed.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> for which the blueprint has been removed.</param>
protected virtual void OnBlueprintRemoved(HitObject hitObject)
/// <param name="item">The item for which the blueprint has been removed.</param>
protected virtual void OnBlueprintRemoved(T item)
{
}
@ -347,7 +294,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
// Iterate from the top of the input stack (blueprints closest to the front of the screen first).
// Priority is given to already-selected blueprints.
foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected))
foreach (SelectionBlueprint<T> blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected))
{
if (!blueprint.IsHovered) continue;
@ -371,7 +318,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
// Iterate from the top of the input stack (blueprints closest to the front of the screen first).
// Priority is given to already-selected blueprints.
foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected))
foreach (SelectionBlueprint<T> blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected))
{
if (!blueprint.IsHovered) continue;
@ -404,8 +351,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
break;
case SelectionState.Selected:
// if the editor is playing, we generally don't want to deselect objects even if outside the selection area.
if (!EditorClock.IsRunning && !isValidForSelection())
if (AllowDeselectionDuringDrag && !isValidForSelection())
blueprint.Deselect();
break;
}
@ -413,35 +359,29 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
/// <summary>
/// Selects all <see cref="SelectionBlueprint"/>s.
/// Selects all <see cref="SelectionBlueprint{T}"/>s.
/// </summary>
private void selectAll()
protected virtual void SelectAll()
{
Composer.Playfield.KeepAllAlive();
// Scheduled to allow the change in lifetime to take place.
Schedule(() => SelectionBlueprints.ToList().ForEach(m => m.Select()));
}
/// <summary>
/// Deselects all selected <see cref="SelectionBlueprint"/>s.
/// Deselects all selected <see cref="SelectionBlueprint{T}"/>s.
/// </summary>
private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect());
private void onBlueprintSelected(SelectionBlueprint blueprint)
protected virtual void OnBlueprintSelected(SelectionBlueprint<T> blueprint)
{
SelectionHandler.HandleSelected(blueprint);
SelectionBlueprints.ChangeChildDepth(blueprint, 1);
Composer.Playfield.SetKeepAlive(blueprint.HitObject, true);
}
private void onBlueprintDeselected(SelectionBlueprint blueprint)
protected virtual void OnBlueprintDeselected(SelectionBlueprint<T> blueprint)
{
SelectionBlueprints.ChangeChildDepth(blueprint, 0);
SelectionHandler.HandleDeselected(blueprint);
Composer.Playfield.SetKeepAlive(blueprint.HitObject, false);
}
#endregion
@ -449,7 +389,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
#region Selection Movement
private Vector2[] movementBlueprintOriginalPositions;
private SelectionBlueprint[] movementBlueprints;
private SelectionBlueprint<T>[] movementBlueprints;
private bool isDraggingBlueprint;
/// <summary>
@ -460,16 +400,23 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (!SelectionHandler.SelectedBlueprints.Any())
return;
// Any selected blueprint that is hovered can begin the movement of the group, however only the earliest hitobject is used for movement
// Any selected blueprint that is hovered can begin the movement of the group, however only the first item (according to SortForMovement) is used for movement.
// A special case is added for when a click selection occurred before the drag
if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered))
return;
// Movement is tracked from the blueprint of the earliest hitobject, since it only makes sense to distance snap from that hitobject
movementBlueprints = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).ToArray();
// Movement is tracked from the blueprint of the earliest item, since it only makes sense to distance snap from that item
movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).ToArray();
movementBlueprintOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSelectionPoint).ToArray();
}
/// <summary>
/// Apply sorting of selected blueprints before performing movement. Generally used to surface the "main" item to the beginning of the collection.
/// </summary>
/// <param name="blueprints">The blueprints to be moved.</param>
/// <returns>Sorted blueprints.</returns>
protected virtual IEnumerable<SelectionBlueprint<T>> SortForMovement(IReadOnlyList<SelectionBlueprint<T>> blueprints) => blueprints;
/// <summary>
/// Moves the current selected blueprints.
/// </summary>
@ -480,52 +427,50 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (movementBlueprints == null)
return false;
if (snapProvider == null)
return true;
Debug.Assert(movementBlueprintOriginalPositions != null);
Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition;
// check for positional snap for every object in selection (for things like object-object snapping)
for (var i = 0; i < movementBlueprintOriginalPositions.Length; i++)
if (snapProvider != null)
{
var testPosition = movementBlueprintOriginalPositions[i] + distanceTravelled;
// check for positional snap for every object in selection (for things like object-object snapping)
for (var i = 0; i < movementBlueprintOriginalPositions.Length; i++)
{
Vector2 originalPosition = movementBlueprintOriginalPositions[i];
var testPosition = originalPosition + distanceTravelled;
var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition);
var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition);
if (positionalResult.ScreenSpacePosition == testPosition) continue;
if (positionalResult.ScreenSpacePosition == testPosition) continue;
// attempt to move the objects, and abort any time based snapping if we can.
if (SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints[i], positionalResult.ScreenSpacePosition)))
return true;
var delta = positionalResult.ScreenSpacePosition - movementBlueprints[i].ScreenSpaceSelectionPoint;
// attempt to move the objects, and abort any time based snapping if we can.
if (SelectionHandler.HandleMovement(new MoveSelectionEvent<T>(movementBlueprints[i], delta)))
return true;
}
}
// if no positional snapping could be performed, try unrestricted snapping from the earliest
// hitobject in the selection.
// item in the selection.
// The final movement position, relative to movementBlueprintOriginalPosition.
Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled;
// Retrieve a snapped position.
var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition);
var result = snapProvider?.SnapScreenSpacePositionToValidTime(movePosition);
// Move the hitobjects.
if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), result.ScreenSpacePosition)))
return true;
if (result.Time.HasValue)
if (result == null)
{
// Apply the start time at the newly snapped-to position
double offset = result.Time.Value - movementBlueprints.First().HitObject.StartTime;
if (offset != 0)
Beatmap.PerformOnSelection(obj => obj.StartTime += offset);
return SelectionHandler.HandleMovement(new MoveSelectionEvent<T>(movementBlueprints.First(), movePosition - movementBlueprints.First().ScreenSpaceSelectionPoint));
}
return true;
return ApplySnapResult(movementBlueprints, result);
}
protected virtual bool ApplySnapResult(SelectionBlueprint<T>[] blueprints, SnapResult result) =>
SelectionHandler.HandleMovement(new MoveSelectionEvent<T>(blueprints.First(), result.ScreenSpacePosition - blueprints.First().ScreenSpaceSelectionPoint));
/// <summary>
/// Finishes the current movement of selected blueprints.
/// </summary>
@ -542,22 +487,5 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
#endregion
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (Beatmap != null)
{
Beatmap.HitObjectAdded -= addBlueprintFor;
Beatmap.HitObjectRemoved -= removeBlueprintFor;
}
if (Composer != null)
{
Composer.Playfield.HitObjectUsageBegan -= addBlueprintFor;
Composer.Playfield.HitObjectUsageFinished -= removeBlueprintFor;
}
}
}
}

View File

@ -27,12 +27,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <summary>
/// A blueprint container generally displayed as an overlay to a ruleset's playfield.
/// </summary>
public class ComposeBlueprintContainer : BlueprintContainer
public class ComposeBlueprintContainer : EditorBlueprintContainer
{
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
private readonly Container<PlacementBlueprint> placementBlueprintContainer;
protected new EditorSelectionHandler SelectionHandler => (EditorSelectionHandler)base.SelectionHandler;
private PlacementBlueprint currentPlacement;
private InputManager inputManager;
@ -113,7 +115,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
// convert to game space coordinates
delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero);
SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, firstBlueprint.ScreenSpaceSelectionPoint + delta));
SelectionHandler.HandleMovement(new MoveSelectionEvent<HitObject>(firstBlueprint, delta));
}
private void updatePlacementNewCombo()
@ -237,9 +239,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
updatePlacementPosition();
}
protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject hitObject)
protected sealed override SelectionBlueprint<HitObject> CreateBlueprintFor(HitObject item)
{
var drawable = Composer.HitObjects.FirstOrDefault(d => d.HitObject == hitObject);
var drawable = Composer.HitObjects.FirstOrDefault(d => d.HitObject == item);
if (drawable == null)
return null;
@ -249,9 +251,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
public virtual OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null;
protected override void OnBlueprintAdded(HitObject hitObject)
protected override void OnBlueprintAdded(HitObject item)
{
base.OnBlueprintAdded(hitObject);
base.OnBlueprintAdded(item);
refreshTool();

View File

@ -0,0 +1,171 @@
// 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.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.Events;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit.Compose.Components
{
public class EditorBlueprintContainer : BlueprintContainer<HitObject>
{
[Resolved]
protected EditorClock EditorClock { get; private set; }
[Resolved]
protected EditorBeatmap Beatmap { get; private set; }
protected readonly HitObjectComposer Composer;
private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
protected EditorBlueprintContainer(HitObjectComposer composer)
{
Composer = composer;
}
[BackgroundDependencyLoader]
private void load()
{
// For non-pooled rulesets, hitobjects are already present in the playfield which allows the blueprints to be loaded in the async context.
if (Composer != null)
{
foreach (var obj in Composer.HitObjects)
AddBlueprintFor(obj.HitObject);
}
selectedHitObjects.BindTo(Beatmap.SelectedHitObjects);
selectedHitObjects.CollectionChanged += (selectedObjects, args) =>
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (var o in args.NewItems)
SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select();
break;
case NotifyCollectionChangedAction.Remove:
foreach (var o in args.OldItems)
SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect();
break;
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Beatmap.HitObjectAdded += AddBlueprintFor;
Beatmap.HitObjectRemoved += RemoveBlueprintFor;
if (Composer != null)
{
// For pooled rulesets, blueprints must be added for hitobjects already "current" as they would've not been "current" during the async load addition process above.
foreach (var obj in Composer.HitObjects)
AddBlueprintFor(obj.HitObject);
Composer.Playfield.HitObjectUsageBegan += AddBlueprintFor;
Composer.Playfield.HitObjectUsageFinished += RemoveBlueprintFor;
}
}
protected override IEnumerable<SelectionBlueprint<HitObject>> SortForMovement(IReadOnlyList<SelectionBlueprint<HitObject>> blueprints)
=> blueprints.OrderBy(b => b.Item.StartTime);
protected override bool AllowDeselectionDuringDrag => !EditorClock.IsRunning;
protected override bool ApplySnapResult(SelectionBlueprint<HitObject>[] blueprints, SnapResult result)
{
if (!base.ApplySnapResult(blueprints, result))
return false;
if (result.Time.HasValue)
{
// Apply the start time at the newly snapped-to position
double offset = result.Time.Value - blueprints.First().Item.StartTime;
if (offset != 0)
Beatmap.PerformOnSelection(obj => obj.StartTime += offset);
}
return true;
}
protected override void AddBlueprintFor(HitObject item)
{
if (item is IBarLine)
return;
base.AddBlueprintFor(item);
}
protected override void DragOperationCompleted()
{
base.DragOperationCompleted();
// handle positional change etc.
foreach (var blueprint in SelectionBlueprints)
Beatmap.Update(blueprint.Item);
}
protected override bool OnDoubleClick(DoubleClickEvent e)
{
if (!base.OnDoubleClick(e))
return false;
EditorClock?.SeekSmoothlyTo(ClickedBlueprint.Item.StartTime);
return true;
}
protected override Container<SelectionBlueprint<HitObject>> CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both };
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new EditorSelectionHandler();
protected override void SelectAll()
{
Composer.Playfield.KeepAllAlive();
base.SelectAll();
}
protected override void OnBlueprintSelected(SelectionBlueprint<HitObject> blueprint)
{
base.OnBlueprintSelected(blueprint);
Composer.Playfield.SetKeepAlive(blueprint.Item, true);
}
protected override void OnBlueprintDeselected(SelectionBlueprint<HitObject> blueprint)
{
base.OnBlueprintDeselected(blueprint);
Composer.Playfield.SetKeepAlive(blueprint.Item, false);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (Beatmap != null)
{
Beatmap.HitObjectAdded -= AddBlueprintFor;
Beatmap.HitObjectRemoved -= RemoveBlueprintFor;
}
if (Composer != null)
{
Composer.Playfield.HitObjectUsageBegan -= AddBlueprintFor;
Composer.Playfield.HitObjectUsageFinished -= RemoveBlueprintFor;
}
}
}
}

View File

@ -0,0 +1,194 @@
// 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 Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Audio;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Screens.Edit.Compose.Components
{
public class EditorSelectionHandler : SelectionHandler<HitObject>
{
[Resolved]
protected EditorBeatmap EditorBeatmap { get; private set; }
[BackgroundDependencyLoader]
private void load()
{
createStateBindables();
// bring in updates from selection changes
EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates);
SelectedItems.BindTo(EditorBeatmap.SelectedHitObjects);
SelectedItems.CollectionChanged += (sender, args) =>
{
Scheduler.AddOnce(UpdateTernaryStates);
};
}
protected override void DeleteItems(IEnumerable<HitObject> items) => EditorBeatmap.RemoveRange(items);
#region Selection State
/// <summary>
/// The state of "new combo" for all selected hitobjects.
/// </summary>
public readonly Bindable<TernaryState> SelectionNewComboState = new Bindable<TernaryState>();
/// <summary>
/// The state of each sample type for all selected hitobjects. Keys match with <see cref="HitSampleInfo"/> constant specifications.
/// </summary>
public readonly Dictionary<string, Bindable<TernaryState>> SelectionSampleStates = new Dictionary<string, Bindable<TernaryState>>();
/// <summary>
/// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions)
/// </summary>
private void createStateBindables()
{
foreach (var sampleName in HitSampleInfo.AllAdditions)
{
var bindable = new Bindable<TernaryState>
{
Description = sampleName.Replace("hit", string.Empty).Titleize()
};
bindable.ValueChanged += state =>
{
switch (state.NewValue)
{
case TernaryState.False:
RemoveHitSample(sampleName);
break;
case TernaryState.True:
AddHitSample(sampleName);
break;
}
};
SelectionSampleStates[sampleName] = bindable;
}
// new combo
SelectionNewComboState.ValueChanged += state =>
{
switch (state.NewValue)
{
case TernaryState.False:
SetNewCombo(false);
break;
case TernaryState.True:
SetNewCombo(true);
break;
}
};
}
/// <summary>
/// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated).
/// </summary>
protected virtual void UpdateTernaryStates()
{
SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType<IHasComboInformation>(), h => h.NewCombo);
foreach (var (sampleName, bindable) in SelectionSampleStates)
{
bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.Any(s => s.Name == sampleName));
}
}
/// <summary>
/// Given a selection target and a function of truth, retrieve the correct ternary state for display.
/// </summary>
protected TernaryState GetStateFromSelection<T>(IEnumerable<T> selection, Func<T, bool> func)
{
if (selection.Any(func))
return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
return TernaryState.False;
}
#endregion
#region Ternary state changes
/// <summary>
/// Adds a hit sample to all selected <see cref="HitObject"/>s.
/// </summary>
/// <param name="sampleName">The name of the hit sample.</param>
public void AddHitSample(string sampleName)
{
EditorBeatmap.PerformOnSelection(h =>
{
// Make sure there isn't already an existing sample
if (h.Samples.Any(s => s.Name == sampleName))
return;
h.Samples.Add(new HitSampleInfo(sampleName));
});
}
/// <summary>
/// Removes a hit sample from all selected <see cref="HitObject"/>s.
/// </summary>
/// <param name="sampleName">The name of the hit sample.</param>
public void RemoveHitSample(string sampleName)
{
EditorBeatmap.PerformOnSelection(h => h.SamplesBindable.RemoveAll(s => s.Name == sampleName));
}
/// <summary>
/// Set the new combo state of all selected <see cref="HitObject"/>s.
/// </summary>
/// <param name="state">Whether to set or unset.</param>
/// <exception cref="InvalidOperationException">Throws if any selected object doesn't implement <see cref="IHasComboInformation"/></exception>
public void SetNewCombo(bool state)
{
EditorBeatmap.PerformOnSelection(h =>
{
var comboInfo = h as IHasComboInformation;
if (comboInfo == null || comboInfo.NewCombo == state) return;
comboInfo.NewCombo = state;
EditorBeatmap.Update(h);
});
}
#endregion
#region Context Menu
/// <summary>
/// Provide context menu items relevant to current selection. Calling base is not required.
/// </summary>
/// <param name="selection">The current selection.</param>
/// <returns>The relevant menu items.</returns>
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
{
if (SelectedBlueprints.All(b => b.Item is IHasComboInformation))
{
yield return new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } };
}
yield return new OsuMenuItem("Sound")
{
Items = SelectionSampleStates.Select(kvp =>
new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
};
}
#endregion
}
}

View File

@ -11,17 +11,17 @@ using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit.Compose.Components
{
/// <summary>
/// A container for <see cref="SelectionBlueprint"/> ordered by their <see cref="HitObject"/> start times.
/// A container for <see cref="SelectionBlueprint{HitObject}"/> ordered by their <see cref="HitObject"/> start times.
/// </summary>
public sealed class HitObjectOrderedSelectionContainer : Container<SelectionBlueprint>
public sealed class HitObjectOrderedSelectionContainer : Container<SelectionBlueprint<HitObject>>
{
public override void Add(SelectionBlueprint drawable)
public override void Add(SelectionBlueprint<HitObject> drawable)
{
base.Add(drawable);
bindStartTime(drawable);
}
public override bool Remove(SelectionBlueprint drawable)
public override bool Remove(SelectionBlueprint<HitObject> drawable)
{
if (!base.Remove(drawable))
return false;
@ -36,11 +36,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
unbindAllStartTimes();
}
private readonly Dictionary<SelectionBlueprint, IBindable> startTimeMap = new Dictionary<SelectionBlueprint, IBindable>();
private readonly Dictionary<SelectionBlueprint<HitObject>, IBindable> startTimeMap = new Dictionary<SelectionBlueprint<HitObject>, IBindable>();
private void bindStartTime(SelectionBlueprint blueprint)
private void bindStartTime(SelectionBlueprint<HitObject> blueprint)
{
var bindable = blueprint.HitObject.StartTimeBindable.GetBoundCopy();
var bindable = blueprint.Item.StartTimeBindable.GetBoundCopy();
bindable.BindValueChanged(_ =>
{
@ -51,7 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
startTimeMap[blueprint] = bindable;
}
private void unbindStartTime(SelectionBlueprint blueprint)
private void unbindStartTime(SelectionBlueprint<HitObject> blueprint)
{
startTimeMap[blueprint].UnbindAll();
startTimeMap.Remove(blueprint);
@ -66,16 +66,16 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected override int Compare(Drawable x, Drawable y)
{
var xObj = (SelectionBlueprint)x;
var yObj = (SelectionBlueprint)y;
var xObj = (SelectionBlueprint<HitObject>)x;
var yObj = (SelectionBlueprint<HitObject>)y;
// Put earlier blueprints towards the end of the list, so they handle input first
int i = yObj.HitObject.StartTime.CompareTo(xObj.HitObject.StartTime);
int i = yObj.Item.StartTime.CompareTo(xObj.Item.StartTime);
if (i != 0) return i;
// Fall back to end time if the start time is equal.
i = yObj.HitObject.GetEndTime().CompareTo(xObj.HitObject.GetEndTime());
i = yObj.Item.GetEndTime().CompareTo(xObj.Item.GetEndTime());
return i == 0 ? CompareReverseChildID(y, x) : i;
}

View File

@ -9,29 +9,22 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <summary>
/// An event which occurs when a <see cref="OverlaySelectionBlueprint"/> is moved.
/// </summary>
public class MoveSelectionEvent
public class MoveSelectionEvent<T>
{
/// <summary>
/// The <see cref="SelectionBlueprint"/> that triggered this <see cref="MoveSelectionEvent"/>.
/// The <see cref="SelectionBlueprint{T}"/> that triggered this <see cref="MoveSelectionEvent{T}"/>.
/// </summary>
public readonly SelectionBlueprint Blueprint;
public readonly SelectionBlueprint<T> Blueprint;
/// <summary>
/// The expected screen-space position of the hitobject at the current cursor position.
/// The screen-space delta of this move event.
/// </summary>
public readonly Vector2 ScreenSpacePosition;
public readonly Vector2 ScreenSpaceDelta;
/// <summary>
/// The distance between <see cref="ScreenSpacePosition"/> and the hitobject's current position, in the coordinate-space of the hitobject's parent.
/// </summary>
public readonly Vector2 InstantDelta;
public MoveSelectionEvent(SelectionBlueprint blueprint, Vector2 screenSpacePosition)
public MoveSelectionEvent(SelectionBlueprint<T> blueprint, Vector2 screenSpaceDelta)
{
Blueprint = blueprint;
ScreenSpacePosition = screenSpacePosition;
InstantDelta = Blueprint.GetInstantDelta(ScreenSpacePosition);
ScreenSpaceDelta = screenSpaceDelta;
}
}
}

View File

@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -15,32 +14,33 @@ using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Audio;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osuTK;
using osuTK.Input;
namespace osu.Game.Screens.Edit.Compose.Components
{
/// <summary>
/// A component which outlines <see cref="DrawableHitObject"/>s and handles movement of selections.
/// A component which outlines items and handles movement of selections.
/// </summary>
public class SelectionHandler : CompositeDrawable, IKeyBindingHandler<PlatformAction>, IHasContextMenu
public abstract class SelectionHandler<T> : CompositeDrawable, IKeyBindingHandler<PlatformAction>, IHasContextMenu
{
/// <summary>
/// The currently selected blueprints.
/// Should be used when operations are dealing directly with the visible blueprints.
/// For more general selection operations, use <see cref="osu.Game.Screens.Edit.EditorBeatmap.SelectedHitObjects"/> instead.
/// For more general selection operations, use <see cref="SelectedItems"/> instead.
/// </summary>
public IEnumerable<SelectionBlueprint> SelectedBlueprints => selectedBlueprints;
public IReadOnlyList<SelectionBlueprint<T>> SelectedBlueprints => selectedBlueprints;
private readonly List<SelectionBlueprint> selectedBlueprints;
/// <summary>
/// The currently selected items.
/// </summary>
public readonly BindableList<T> SelectedItems = new BindableList<T>();
private readonly List<SelectionBlueprint<T>> selectedBlueprints;
private Drawable content;
@ -48,15 +48,12 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected SelectionBox SelectionBox { get; private set; }
[Resolved]
protected EditorBeatmap EditorBeatmap { get; private set; }
[Resolved(CanBeNull = true)]
protected IEditorChangeHandler ChangeHandler { get; private set; }
public SelectionHandler()
protected SelectionHandler()
{
selectedBlueprints = new List<SelectionBlueprint>();
selectedBlueprints = new List<SelectionBlueprint<T>>();
RelativeSizeAxes = Axes.Both;
AlwaysPresent = true;
@ -66,8 +63,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
createStateBindables();
InternalChild = content = new Container
{
Children = new Drawable[]
@ -95,6 +90,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
SelectionBox = CreateSelectionBox(),
}
};
SelectedItems.CollectionChanged += (sender, args) =>
{
Scheduler.AddOnce(updateVisibility);
};
}
public SelectionBox CreateSelectionBox()
@ -128,45 +128,44 @@ namespace osu.Game.Screens.Edit.Compose.Components
#region User Input Handling
/// <summary>
/// Handles the selected <see cref="DrawableHitObject"/>s being moved.
/// Handles the selected items being moved.
/// </summary>
/// <remarks>
/// Just returning true is enough to allow <see cref="HitObject.StartTime"/> updates to take place.
/// Just returning true is enough to allow default movement to take place.
/// Custom implementation is only required if other attributes are to be considered, like changing columns.
/// </remarks>
/// <param name="moveEvent">The move event.</param>
/// <returns>
/// Whether any <see cref="DrawableHitObject"/>s could be moved.
/// Returning true will also propagate StartTime changes provided by the closest <see cref="IPositionSnapProvider.SnapScreenSpacePositionToValidTime"/>.
/// Whether any items could be moved.
/// </returns>
public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false;
public virtual bool HandleMovement(MoveSelectionEvent<T> moveEvent) => false;
/// <summary>
/// Handles the selected <see cref="DrawableHitObject"/>s being rotated.
/// Handles the selected items being rotated.
/// </summary>
/// <param name="angle">The delta angle to apply to the selection.</param>
/// <returns>Whether any <see cref="DrawableHitObject"/>s could be rotated.</returns>
/// <returns>Whether any items could be rotated.</returns>
public virtual bool HandleRotation(float angle) => false;
/// <summary>
/// Handles the selected <see cref="DrawableHitObject"/>s being scaled.
/// Handles the selected items being scaled.
/// </summary>
/// <param name="scale">The delta scale to apply, in playfield local coordinates.</param>
/// <param name="scale">The delta scale to apply, in local coordinates.</param>
/// <param name="anchor">The point of reference where the scale is originating from.</param>
/// <returns>Whether any <see cref="DrawableHitObject"/>s could be scaled.</returns>
/// <returns>Whether any items could be scaled.</returns>
public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false;
/// <summary>
/// Handles the selected <see cref="DrawableHitObject"/>s being flipped.
/// Handles the selected items being flipped.
/// </summary>
/// <param name="direction">The direction to flip</param>
/// <returns>Whether any <see cref="DrawableHitObject"/>s could be flipped.</returns>
/// <returns>Whether any items could be flipped.</returns>
public virtual bool HandleFlip(Direction direction) => false;
/// <summary>
/// Handles the selected <see cref="DrawableHitObject"/>s being reversed pattern-wise.
/// Handles the selected items being reversed pattern-wise.
/// </summary>
/// <returns>Whether any <see cref="DrawableHitObject"/>s could be reversed.</returns>
/// <returns>Whether any items could be reversed.</returns>
public virtual bool HandleReverse() => false;
public bool OnPressed(PlatformAction action)
@ -174,7 +173,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
switch (action.ActionMethod)
{
case PlatformActionMethod.Delete:
deleteSelected();
DeleteSelected();
return true;
}
@ -198,24 +197,23 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// Handle a blueprint becoming selected.
/// </summary>
/// <param name="blueprint">The blueprint.</param>
internal void HandleSelected(SelectionBlueprint blueprint)
internal virtual void HandleSelected(SelectionBlueprint<T> blueprint)
{
selectedBlueprints.Add(blueprint);
// there are potentially multiple SelectionHandlers active, but we only want to add items to the selected list once.
if (!SelectedItems.Contains(blueprint.Item))
SelectedItems.Add(blueprint.Item);
// there are potentially multiple SelectionHandlers active, but we only want to add hitobjects to the selected list once.
if (!EditorBeatmap.SelectedHitObjects.Contains(blueprint.HitObject))
EditorBeatmap.SelectedHitObjects.Add(blueprint.HitObject);
selectedBlueprints.Add(blueprint);
}
/// <summary>
/// Handle a blueprint becoming deselected.
/// </summary>
/// <param name="blueprint">The blueprint.</param>
internal void HandleDeselected(SelectionBlueprint blueprint)
internal virtual void HandleDeselected(SelectionBlueprint<T> blueprint)
{
SelectedItems.Remove(blueprint.Item);
selectedBlueprints.Remove(blueprint);
EditorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject);
}
/// <summary>
@ -224,7 +222,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <param name="blueprint">The blueprint.</param>
/// <param name="e">The mouse event responsible for selection.</param>
/// <returns>Whether a selection was performed.</returns>
internal bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e)
internal bool MouseDownSelectionRequested(SelectionBlueprint<T> blueprint, MouseButtonEvent e)
{
if (e.ShiftPressed && e.Button == MouseButton.Right)
{
@ -248,7 +246,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <param name="blueprint">The blueprint.</param>
/// <param name="e">The mouse event responsible for deselection.</param>
/// <returns>Whether a deselection was performed.</returns>
internal bool MouseUpSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e)
internal bool MouseUpSelectionRequested(SelectionBlueprint<T> blueprint, MouseButtonEvent e)
{
if (blueprint.IsSelected)
{
@ -259,23 +257,29 @@ namespace osu.Game.Screens.Edit.Compose.Components
return false;
}
private void handleQuickDeletion(SelectionBlueprint blueprint)
private void handleQuickDeletion(SelectionBlueprint<T> blueprint)
{
if (blueprint.HandleQuickDeletion())
return;
if (!blueprint.IsSelected)
EditorBeatmap.Remove(blueprint.HitObject);
DeleteItems(new[] { blueprint.Item });
else
deleteSelected();
DeleteSelected();
}
/// <summary>
/// Called whenever the deletion of items has been requested.
/// </summary>
/// <param name="items">The items to be deleted.</param>
protected abstract void DeleteItems(IEnumerable<T> items);
/// <summary>
/// Ensure the blueprint is in a selected state.
/// </summary>
/// <param name="blueprint">The blueprint to select.</param>
/// <returns>Whether selection state was changed.</returns>
private bool ensureSelected(SelectionBlueprint blueprint)
private bool ensureSelected(SelectionBlueprint<T> blueprint)
{
if (blueprint.IsSelected)
return false;
@ -285,9 +289,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
return true;
}
private void deleteSelected()
protected void DeleteSelected()
{
EditorBeatmap.RemoveRange(selectedBlueprints.Select(b => b.HitObject));
DeleteItems(selectedBlueprints.Select(b => b.Item));
}
#endregion
@ -295,11 +299,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
#region Outline Display
/// <summary>
/// Updates whether this <see cref="SelectionHandler"/> is visible.
/// Updates whether this <see cref="SelectionHandler{T}"/> is visible.
/// </summary>
private void updateVisibility()
{
int count = EditorBeatmap.SelectedHitObjects.Count;
int count = SelectedItems.Count;
selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty;
@ -308,7 +312,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
/// <summary>
/// Triggered whenever the set of selected objects changes.
/// Triggered whenever the set of selected items changes.
/// Should update the selection box's state to match supported operations.
/// </summary>
protected virtual void OnSelectionChanged()
@ -322,7 +326,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (selectedBlueprints.Count == 0)
return;
// Move the rectangle to cover the hitobjects
// Move the rectangle to cover the items
var topLeft = new Vector2(float.MaxValue, float.MaxValue);
var bottomRight = new Vector2(float.MinValue, float.MinValue);
@ -341,174 +345,23 @@ namespace osu.Game.Screens.Edit.Compose.Components
#endregion
#region Sample Changes
/// <summary>
/// Adds a hit sample to all selected <see cref="HitObject"/>s.
/// </summary>
/// <param name="sampleName">The name of the hit sample.</param>
public void AddHitSample(string sampleName)
{
EditorBeatmap.PerformOnSelection(h =>
{
// Make sure there isn't already an existing sample
if (h.Samples.Any(s => s.Name == sampleName))
return;
h.Samples.Add(new HitSampleInfo(sampleName));
});
}
/// <summary>
/// Set the new combo state of all selected <see cref="HitObject"/>s.
/// </summary>
/// <param name="state">Whether to set or unset.</param>
/// <exception cref="InvalidOperationException">Throws if any selected object doesn't implement <see cref="IHasComboInformation"/></exception>
public void SetNewCombo(bool state)
{
EditorBeatmap.PerformOnSelection(h =>
{
var comboInfo = h as IHasComboInformation;
if (comboInfo == null || comboInfo.NewCombo == state) return;
comboInfo.NewCombo = state;
EditorBeatmap.Update(h);
});
}
/// <summary>
/// Removes a hit sample from all selected <see cref="HitObject"/>s.
/// </summary>
/// <param name="sampleName">The name of the hit sample.</param>
public void RemoveHitSample(string sampleName)
{
EditorBeatmap.PerformOnSelection(h => h.SamplesBindable.RemoveAll(s => s.Name == sampleName));
}
#endregion
#region Selection State
/// <summary>
/// The state of "new combo" for all selected hitobjects.
/// </summary>
public readonly Bindable<TernaryState> SelectionNewComboState = new Bindable<TernaryState>();
/// <summary>
/// The state of each sample type for all selected hitobjects. Keys match with <see cref="HitSampleInfo"/> constant specifications.
/// </summary>
public readonly Dictionary<string, Bindable<TernaryState>> SelectionSampleStates = new Dictionary<string, Bindable<TernaryState>>();
/// <summary>
/// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions)
/// </summary>
private void createStateBindables()
{
foreach (var sampleName in HitSampleInfo.AllAdditions)
{
var bindable = new Bindable<TernaryState>
{
Description = sampleName.Replace("hit", string.Empty).Titleize()
};
bindable.ValueChanged += state =>
{
switch (state.NewValue)
{
case TernaryState.False:
RemoveHitSample(sampleName);
break;
case TernaryState.True:
AddHitSample(sampleName);
break;
}
};
SelectionSampleStates[sampleName] = bindable;
}
// new combo
SelectionNewComboState.ValueChanged += state =>
{
switch (state.NewValue)
{
case TernaryState.False:
SetNewCombo(false);
break;
case TernaryState.True:
SetNewCombo(true);
break;
}
};
// bring in updates from selection changes
EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates);
EditorBeatmap.SelectedHitObjects.CollectionChanged += (sender, args) =>
{
Scheduler.AddOnce(updateVisibility);
Scheduler.AddOnce(UpdateTernaryStates);
};
}
/// <summary>
/// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated).
/// </summary>
protected virtual void UpdateTernaryStates()
{
SelectionNewComboState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType<IHasComboInformation>(), h => h.NewCombo);
foreach (var (sampleName, bindable) in SelectionSampleStates)
{
bindable.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects, h => h.Samples.Any(s => s.Name == sampleName));
}
}
/// <summary>
/// Given a selection target and a function of truth, retrieve the correct ternary state for display.
/// </summary>
protected TernaryState GetStateFromSelection<T>(IEnumerable<T> selection, Func<T, bool> func)
{
if (selection.Any(func))
return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
return TernaryState.False;
}
#endregion
#region Context Menu
public MenuItem[] ContextMenuItems
{
get
{
if (!selectedBlueprints.Any(b => b.IsHovered))
if (!SelectedBlueprints.Any(b => b.IsHovered))
return Array.Empty<MenuItem>();
var items = new List<MenuItem>();
items.AddRange(GetContextMenuItemsForSelection(selectedBlueprints));
items.AddRange(GetContextMenuItemsForSelection(SelectedBlueprints));
if (selectedBlueprints.All(b => b.HitObject is IHasComboInformation))
{
items.Add(new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } });
}
if (SelectedBlueprints.Count == 1)
items.AddRange(SelectedBlueprints[0].ContextMenuItems);
if (selectedBlueprints.Count == 1)
items.AddRange(selectedBlueprints[0].ContextMenuItems);
items.AddRange(new[]
{
new OsuMenuItem("Sound")
{
Items = SelectionSampleStates.Select(kvp =>
new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
},
new OsuMenuItem("Delete", MenuItemType.Destructive, deleteSelected),
});
items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, DeleteSelected));
return items.ToArray();
}
@ -519,7 +372,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// </summary>
/// <param name="selection">The current selection.</param>
/// <returns>The relevant menu items.</returns>
protected virtual IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint> selection)
protected virtual IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<T>> selection)
=> Enumerable.Empty<MenuItem>();
#endregion

View File

@ -25,20 +25,17 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
internal class TimelineBlueprintContainer : BlueprintContainer
internal class TimelineBlueprintContainer : EditorBlueprintContainer
{
[Resolved(CanBeNull = true)]
private Timeline timeline { get; set; }
[Resolved]
private EditorBeatmap beatmap { get; set; }
[Resolved]
private OsuColour colours { get; set; }
private DragEvent lastDragEvent;
private Bindable<HitObject> placement;
private SelectionBlueprint placementBlueprint;
private SelectionBlueprint<HitObject> placementBlueprint;
private SelectableAreaBackground backgroundBox;
@ -76,7 +73,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
base.LoadComplete();
DragBox.Alpha = 0;
placement = beatmap.PlacementObject.GetBoundCopy();
placement = Beatmap.PlacementObject.GetBoundCopy();
placement.ValueChanged += placementChanged;
}
@ -100,7 +97,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
}
}
protected override Container<SelectionBlueprint> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both };
protected override Container<SelectionBlueprint<HitObject>> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both };
protected override bool OnHover(HoverEvent e)
{
@ -160,7 +157,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
// remove objects from the stack as long as their end time is in the past.
while (currentConcurrentObjects.TryPeek(out HitObject hitObject))
{
if (Precision.AlmostBigger(hitObject.GetEndTime(), b.HitObject.StartTime, 1))
if (Precision.AlmostBigger(hitObject.GetEndTime(), b.Item.StartTime, 1))
break;
currentConcurrentObjects.Pop();
@ -168,7 +165,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
// if the stack gets too high, we should have space below it to display the next batch of objects.
// importantly, we only do this if time has incremented, else a stack of hitobjects all at the same time value would start to overlap themselves.
if (currentConcurrentObjects.TryPeek(out HitObject h) && !Precision.AlmostEquals(h.StartTime, b.HitObject.StartTime, 1))
if (currentConcurrentObjects.TryPeek(out HitObject h) && !Precision.AlmostEquals(h.StartTime, b.Item.StartTime, 1))
{
if (currentConcurrentObjects.Count >= stack_reset_count)
currentConcurrentObjects.Clear();
@ -176,15 +173,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
b.Y = -(stack_offset * currentConcurrentObjects.Count);
currentConcurrentObjects.Push(b.HitObject);
currentConcurrentObjects.Push(b.Item);
}
}
protected override SelectionHandler CreateSelectionHandler() => new TimelineSelectionHandler();
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new TimelineSelectionHandler();
protected override SelectionBlueprint CreateBlueprintFor(HitObject hitObject)
protected override SelectionBlueprint<HitObject> CreateBlueprintFor(HitObject item)
{
return new TimelineHitObjectBlueprint(hitObject)
return new TimelineHitObjectBlueprint(item)
{
OnDragHandled = handleScrollViaDrag
};
@ -239,10 +236,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
}
}
internal class TimelineSelectionHandler : SelectionHandler, IKeyBindingHandler<GlobalAction>
internal class TimelineSelectionHandler : EditorSelectionHandler, IKeyBindingHandler<GlobalAction>
{
// for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation
public override bool HandleMovement(MoveSelectionEvent moveEvent) => true;
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent) => true;
public bool OnPressed(GlobalAction action)
{
@ -344,13 +341,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
}
}
protected class TimelineSelectionBlueprintContainer : Container<SelectionBlueprint>
protected class TimelineSelectionBlueprintContainer : Container<SelectionBlueprint<HitObject>>
{
protected override Container<SelectionBlueprint> Content { get; }
protected override Container<SelectionBlueprint<HitObject>> Content { get; }
public TimelineSelectionBlueprintContainer()
{
AddInternal(new TimelinePart<SelectionBlueprint>(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both });
AddInternal(new TimelinePart<SelectionBlueprint<HitObject>>(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both });
}
}
}

View File

@ -26,7 +26,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
public class TimelineHitObjectBlueprint : SelectionBlueprint
public class TimelineHitObjectBlueprint : SelectionBlueprint<HitObject>
{
private const float circle_size = 38;
@ -49,13 +49,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
[Resolved]
private ISkinSource skin { get; set; }
public TimelineHitObjectBlueprint(HitObject hitObject)
: base(hitObject)
public TimelineHitObjectBlueprint(HitObject item)
: base(item)
{
Anchor = Anchor.CentreLeft;
Origin = Anchor.CentreLeft;
startTime = hitObject.StartTimeBindable.GetBoundCopy();
startTime = item.StartTimeBindable.GetBoundCopy();
startTime.BindValueChanged(time => X = (float)time.NewValue, true);
RelativePositionAxes = Axes.X;
@ -95,9 +95,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
},
});
if (hitObject is IHasDuration)
if (item is IHasDuration)
{
colouredComponents.Add(new DragArea(hitObject)
colouredComponents.Add(new DragArea(item)
{
OnDragHandled = e => OnDragHandled?.Invoke(e)
});
@ -108,7 +108,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
base.LoadComplete();
if (HitObject is IHasComboInformation comboInfo)
if (Item is IHasComboInformation comboInfo)
{
indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy();
indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true);
@ -136,7 +136,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private void updateComboColour()
{
if (!(HitObject is IHasComboInformation combo))
if (!(Item is IHasComboInformation combo))
return;
var comboColours = skin.GetConfig<GlobalSkinColours, IReadOnlyList<Color4>>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty<Color4>();
@ -152,7 +152,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
border.Hide();
}
if (HitObject is IHasDuration duration && duration.Duration > 0)
if (Item is IHasDuration duration && duration.Duration > 0)
circle.Colour = ColourInfo.GradientHorizontal(comboColour, comboColour.Lighten(0.4f));
else
circle.Colour = comboColour;
@ -166,14 +166,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
base.Update();
// no bindable so we perform this every update
float duration = (float)(HitObject.GetEndTime() - HitObject.StartTime);
float duration = (float)(Item.GetEndTime() - Item.StartTime);
if (Width != duration)
{
Width = duration;
// kind of haphazard but yeah, no bindables.
if (HitObject is IHasRepeats repeats)
if (Item is IHasRepeats repeats)
updateRepeats(repeats);
}
}

View File

@ -301,13 +301,7 @@ namespace osu.Game.Screens.Edit
return list.Count - 1;
}
public double SnapTime(double time, double? referenceTime)
{
var timingPoint = ControlPointInfo.TimingPointAt(referenceTime ?? time);
var beatLength = timingPoint.BeatLength / BeatDivisor;
return timingPoint.Time + (int)Math.Round((time - timingPoint.Time) / beatLength, MidpointRounding.AwayFromZero) * beatLength;
}
public double SnapTime(double time, double? referenceTime) => ControlPointInfo.GetClosestSnappedTime(time, BeatDivisor, referenceTime);
public double GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor;

View File

@ -63,8 +63,7 @@ namespace osu.Game.Screens.Play
/// </summary>
public virtual void Start()
{
// Ensure that the source clock is set.
ChangeSource(SourceClock);
ensureSourceClockSet();
if (!AdjustableSource.IsRunning)
{
@ -100,6 +99,7 @@ namespace osu.Game.Screens.Play
/// </summary>
public virtual void Reset()
{
ensureSourceClockSet();
Seek(0);
// Manually stop the source in order to not affect the IsPaused state.
@ -115,6 +115,19 @@ namespace osu.Game.Screens.Play
/// <param name="sourceClock">The new source.</param>
protected void ChangeSource(IClock sourceClock) => AdjustableSource.ChangeSource(SourceClock = sourceClock);
/// <summary>
/// Ensures that the <see cref="AdjustableSource"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
/// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode,
/// but not the actual source clock.
/// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor,
/// but it is not yet set on the adjustable source there.
/// </summary>
private void ensureSourceClockSet()
{
if (AdjustableSource.Source == null)
ChangeSource(SourceClock);
}
protected override void Update()
{
if (!IsPaused.Value)

View File

@ -19,21 +19,21 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="DiffPlex" Version="1.7.0" />
<PackageReference Include="Humanizer" Version="2.8.26" />
<PackageReference Include="Humanizer" Version="2.9.9" />
<PackageReference Include="MessagePack" Version="2.2.85" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="5.0.5" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="5.0.5" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="5.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.NETCore.Targets" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.422.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.427.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.422.0" />
<PackageReference Include="Sentry" Version="3.2.0" />
<PackageReference Include="SharpCompress" Version="0.28.1" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="Sentry" Version="3.3.4" />
<PackageReference Include="SharpCompress" Version="0.28.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup>
</Project>

View File

@ -70,7 +70,7 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.422.1" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.427.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.422.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
@ -89,13 +89,13 @@
<!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. -->
<ItemGroup Label="Transitive Dependencies">
<PackageReference Include="DiffPlex" Version="1.6.3" />
<PackageReference Include="Humanizer" Version="2.8.26" />
<PackageReference Include="Humanizer" Version="2.9.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="ppy.osu.Framework" Version="2021.422.1" />
<PackageReference Include="SharpCompress" Version="0.28.1" />
<PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.427.0" />
<PackageReference Include="SharpCompress" Version="0.28.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="SharpRaven" Version="2.4.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="ppy.osu.Framework.NativeLibs" Version="2021.115.0" ExcludeAssets="all" />