1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-14 19:22:54 +08:00

Merge branch 'master' into fix-exporting-a-skin-with-too-long-file-name

This commit is contained in:
Cootz 2023-03-03 20:02:09 +03:00 committed by GitHub
commit 322f3e86ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 570 additions and 141 deletions

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.131.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2023.228.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged. <!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -11,6 +11,7 @@ using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
@ -26,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Mods
private const double flash_duration = 1000; private const double flash_duration = 1000;
private DrawableRuleset<OsuHitObject> ruleset = null!; private DrawableOsuRuleset ruleset = null!;
protected OsuAction? LastAcceptedAction { get; private set; } protected OsuAction? LastAcceptedAction { get; private set; }
@ -42,8 +43,8 @@ namespace osu.Game.Rulesets.Osu.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset) public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{ {
ruleset = drawableRuleset; ruleset = (DrawableOsuRuleset)drawableRuleset;
drawableRuleset.KeyBindingInputManager.Add(new InputInterceptor(this)); ruleset.KeyBindingInputManager.Add(new InputInterceptor(this));
var periods = new List<Period>(); var periods = new List<Period>();

View File

@ -11,6 +11,7 @@ using osu.Game.Graphics;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Osu.Mods namespace osu.Game.Rulesets.Osu.Mods
@ -55,7 +56,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset) public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{ {
// Grab the input manager to disable the user's cursor, and for future use // Grab the input manager to disable the user's cursor, and for future use
inputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; inputManager = ((DrawableOsuRuleset)drawableRuleset).KeyBindingInputManager;
inputManager.AllowUserCursorMovement = false; inputManager.AllowUserCursorMovement = false;
// Generate the replay frames the cursor should follow // Generate the replay frames the cursor should follow

View File

@ -10,6 +10,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
@ -42,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset) public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{ {
// grab the input manager for future use. // grab the input manager for future use.
osuInputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; osuInputManager = ((DrawableOsuRuleset)drawableRuleset).KeyBindingInputManager;
} }
public void ApplyToPlayer(Player player) public void ApplyToPlayer(Player player)

View File

@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.UI
{ {
protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config; protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config;
public new OsuInputManager KeyBindingInputManager => (OsuInputManager)base.KeyBindingInputManager;
public new OsuPlayfield Playfield => (OsuPlayfield)base.Playfield; public new OsuPlayfield Playfield => (OsuPlayfield)base.Playfield;
public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null) public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null)

View File

@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
{ {
public class TaikoModRelax : ModRelax public class TaikoModRelax : ModRelax
{ {
public override LocalisableString Description => @"No ninja-like spinners, demanding drumrolls or unexpected katu's."; public override LocalisableString Description => @"No ninja-like spinners, demanding drumrolls or unexpected katus.";
} }
} }

View File

@ -12,7 +12,7 @@ using osu.Game.Overlays.Settings;
namespace osu.Game.Tests.Mods namespace osu.Game.Tests.Mods
{ {
[TestFixture] [TestFixture]
public partial class SettingsSourceAttributeTest public partial class SettingSourceAttributeTest
{ {
[Test] [Test]
public void TestOrdering() public void TestOrdering()

View File

@ -7,6 +7,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Audio;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@ -93,6 +94,7 @@ namespace osu.Game.Tests.NonVisual
remove => throw new InvalidOperationException($"{nameof(RevertResult)} operations not supported in test context"); remove => throw new InvalidOperationException($"{nameof(RevertResult)} operations not supported in test context");
} }
public override IAdjustableAudioComponent Audio { get; }
public override Playfield Playfield { get; } public override Playfield Playfield { get; }
public override Container Overlays { get; } public override Container Overlays { get; }
public override Container FrameStableComponents { get; } public override Container FrameStableComponents { get; }

View File

@ -7,16 +7,20 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Timing; using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Storyboards; using osu.Game.Storyboards;
using osuTK;
using osuTK.Input; using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
@ -36,13 +40,16 @@ namespace osu.Game.Tests.Visual.Gameplay
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
{ {
ControlPointInfo controlPointInfo = new LegacyControlPointInfo();
beatmap = new Beatmap beatmap = new Beatmap
{ {
BeatmapInfo = new BeatmapInfo BeatmapInfo = new BeatmapInfo
{ {
Difficulty = new BeatmapDifficulty { CircleSize = 6, SliderMultiplier = 3 }, Difficulty = new BeatmapDifficulty { CircleSize = 6, SliderMultiplier = 3 },
Ruleset = ruleset Ruleset = ruleset
} },
ControlPointInfo = controlPointInfo
}; };
const double start_offset = 8000; const double start_offset = 8000;
@ -51,7 +58,7 @@ namespace osu.Game.Tests.Visual.Gameplay
// intentionally start objects a bit late so we can test the case of no alive objects. // intentionally start objects a bit late so we can test the case of no alive objects.
double t = start_offset; double t = start_offset;
beatmap.HitObjects.AddRange(new[] beatmap.HitObjects.AddRange(new HitObject[]
{ {
new HitCircle new HitCircle
{ {
@ -71,12 +78,24 @@ namespace osu.Game.Tests.Visual.Gameplay
}, },
new HitCircle new HitCircle
{ {
StartTime = t + spacing, StartTime = t += spacing,
},
new Slider
{
StartTime = t += spacing,
Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, Vector2.UnitY * 200 }),
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) }, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) },
SampleControlPoint = new SampleControlPoint { SampleBank = "soft" }, SampleControlPoint = new SampleControlPoint { SampleBank = "soft" },
}, },
}); });
// Add a change in volume halfway through final slider.
controlPointInfo.Add(t, new SampleControlPoint
{
SampleBank = "normal",
SampleVolume = 20,
});
return beatmap; return beatmap;
} }
@ -129,14 +148,36 @@ namespace osu.Game.Tests.Visual.Gameplay
waitForAliveObjectIndex(3); waitForAliveObjectIndex(3);
checkValidObjectIndex(3); checkValidObjectIndex(3);
AddStep("Seek into future", () => Beatmap.Value.Track.Seek(beatmap.HitObjects.Last().GetEndTime() + 10000)); seekBeforeIndex(4);
waitForAliveObjectIndex(4);
// Even before the object, we should prefer the first nested object's sample.
// This is because the (parent) object will only play its sample at the final EndTime.
AddAssert("check valid object is slider's first nested", () => sampleTriggerSource.GetMostValidObject(), () => Is.EqualTo(beatmap.HitObjects[4].NestedHitObjects.First()));
AddStep("seek to just before slider ends", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects[4].GetEndTime() - 100));
waitForCatchUp();
AddUntilStep("wait until valid object is slider's last nested", () => sampleTriggerSource.GetMostValidObject(), () => Is.EqualTo(beatmap.HitObjects[4].NestedHitObjects.Last()));
// After we get far enough away, the samples of the object itself should be used, not any nested object.
AddStep("seek to further after slider", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects[4].GetEndTime() + 1000));
waitForCatchUp();
AddUntilStep("wait until valid object is slider itself", () => sampleTriggerSource.GetMostValidObject(), () => Is.EqualTo(beatmap.HitObjects[4]));
AddStep("Seek into future", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects.Last().GetEndTime() + 10000));
waitForCatchUp();
waitForAliveObjectIndex(null); waitForAliveObjectIndex(null);
checkValidObjectIndex(3); checkValidObjectIndex(4);
} }
private void seekBeforeIndex(int index) => private void seekBeforeIndex(int index)
AddStep($"seek to just before object {index}", () => Beatmap.Value.Track.Seek(beatmap.HitObjects[index].StartTime - 100)); {
AddStep($"seek to just before object {index}", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects[index].StartTime - 100));
waitForCatchUp();
}
private void waitForCatchUp() =>
AddUntilStep("wait for frame stable clock to catch up", () => Precision.AlmostEquals(Player.GameplayClockContainer.CurrentTime, Player.DrawableRuleset.FrameStableClock.CurrentTime));
private void waitForAliveObjectIndex(int? index) private void waitForAliveObjectIndex(int? index)
{ {

View File

@ -9,6 +9,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -281,6 +282,7 @@ namespace osu.Game.Tests.Visual.Gameplay
remove => throw new InvalidOperationException($"{nameof(RevertResult)} operations not supported in test context"); remove => throw new InvalidOperationException($"{nameof(RevertResult)} operations not supported in test context");
} }
public override IAdjustableAudioComponent Audio { get; }
public override Playfield Playfield { get; } public override Playfield Playfield { get; }
public override Container Overlays { get; } public override Container Overlays { get; }
public override Container FrameStableComponents { get; } public override Container FrameStableComponents { get; }

View File

@ -1,9 +1,11 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing; using osu.Framework.Testing;
@ -12,6 +14,7 @@ using osu.Game.Overlays.Settings;
using osu.Game.Overlays.SkinEditor; using osu.Game.Overlays.SkinEditor;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK.Input; using osuTK.Input;
@ -20,33 +23,85 @@ namespace osu.Game.Tests.Visual.Gameplay
{ {
public partial class TestSceneSkinEditor : PlayerTestScene public partial class TestSceneSkinEditor : PlayerTestScene
{ {
private SkinEditor? skinEditor; private SkinEditor skinEditor = null!;
protected override bool Autoplay => true; protected override bool Autoplay => true;
[Cached] [Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
[Cached]
public readonly EditorClipboard Clipboard = new EditorClipboard();
private SkinComponentsContainer targetContainer => Player.ChildrenOfType<SkinComponentsContainer>().First();
[SetUpSteps] [SetUpSteps]
public override void SetUpSteps() public override void SetUpSteps()
{ {
base.SetUpSteps(); base.SetUpSteps();
AddUntilStep("wait for hud load", () => Player.ChildrenOfType<SkinComponentsContainer>().All(c => c.ComponentsLoaded)); AddUntilStep("wait for hud load", () => targetContainer.ComponentsLoaded);
AddStep("reload skin editor", () => AddStep("reload skin editor", () =>
{ {
skinEditor?.Expire(); if (skinEditor.IsNotNull())
skinEditor.Expire();
Player.ScaleTo(0.4f); Player.ScaleTo(0.4f);
LoadComponentAsync(skinEditor = new SkinEditor(Player), Add); LoadComponentAsync(skinEditor = new SkinEditor(Player), Add);
}); });
AddUntilStep("wait for loaded", () => skinEditor!.IsLoaded); AddUntilStep("wait for loaded", () => skinEditor.IsLoaded);
}
[TestCase(false)]
[TestCase(true)]
public void TestBringToFront(bool alterSelectionOrder)
{
AddAssert("Ensure over three components available", () => targetContainer.Components.Count, () => Is.GreaterThan(3));
IEnumerable<ISerialisableDrawable> originalOrder = null!;
AddStep("Save order of components before operation", () => originalOrder = targetContainer.Components.Take(3).ToArray());
if (alterSelectionOrder)
AddStep("Select first three components in reverse order", () => skinEditor.SelectedComponents.AddRange(originalOrder.Reverse()));
else
AddStep("Select first three components", () => skinEditor.SelectedComponents.AddRange(originalOrder));
AddAssert("Components are not front-most", () => targetContainer.Components.TakeLast(3).ToArray(), () => Is.Not.EqualTo(skinEditor.SelectedComponents));
AddStep("Bring to front", () => skinEditor.BringSelectionToFront());
AddAssert("Ensure components are now front-most in original order", () => targetContainer.Components.TakeLast(3).ToArray(), () => Is.EqualTo(originalOrder));
AddStep("Bring to front again", () => skinEditor.BringSelectionToFront());
AddAssert("Ensure components are still front-most in original order", () => targetContainer.Components.TakeLast(3).ToArray(), () => Is.EqualTo(originalOrder));
}
[TestCase(false)]
[TestCase(true)]
public void TestSendToBack(bool alterSelectionOrder)
{
AddAssert("Ensure over three components available", () => targetContainer.Components.Count, () => Is.GreaterThan(3));
IEnumerable<ISerialisableDrawable> originalOrder = null!;
AddStep("Save order of components before operation", () => originalOrder = targetContainer.Components.TakeLast(3).ToArray());
if (alterSelectionOrder)
AddStep("Select last three components in reverse order", () => skinEditor.SelectedComponents.AddRange(originalOrder.Reverse()));
else
AddStep("Select last three components", () => skinEditor.SelectedComponents.AddRange(originalOrder));
AddAssert("Components are not back-most", () => targetContainer.Components.Take(3).ToArray(), () => Is.Not.EqualTo(skinEditor.SelectedComponents));
AddStep("Send to back", () => skinEditor.SendSelectionToBack());
AddAssert("Ensure components are now back-most in original order", () => targetContainer.Components.Take(3).ToArray(), () => Is.EqualTo(originalOrder));
AddStep("Send to back again", () => skinEditor.SendSelectionToBack());
AddAssert("Ensure components are still back-most in original order", () => targetContainer.Components.Take(3).ToArray(), () => Is.EqualTo(originalOrder));
} }
[Test] [Test]
public void TestToggleEditor() public void TestToggleEditor()
{ {
AddToggleStep("toggle editor visibility", _ => skinEditor!.ToggleVisibility()); AddToggleStep("toggle editor visibility", _ => skinEditor.ToggleVisibility());
} }
[Test] [Test]
@ -59,7 +114,7 @@ namespace osu.Game.Tests.Visual.Gameplay
var blueprint = skinEditor.ChildrenOfType<SkinBlueprint>().First(b => b.Item is BarHitErrorMeter); var blueprint = skinEditor.ChildrenOfType<SkinBlueprint>().First(b => b.Item is BarHitErrorMeter);
hitErrorMeter = (BarHitErrorMeter)blueprint.Item; hitErrorMeter = (BarHitErrorMeter)blueprint.Item;
skinEditor!.SelectedComponents.Clear(); skinEditor.SelectedComponents.Clear();
skinEditor.SelectedComponents.Add(blueprint.Item); skinEditor.SelectedComponents.Add(blueprint.Item);
}); });

View File

@ -12,6 +12,7 @@ using osu.Game.Overlays.SkinEditor;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Tests.Gameplay; using osu.Game.Tests.Gameplay;
using osuTK.Input; using osuTK.Input;
@ -32,6 +33,9 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(IGameplayClock))] [Cached(typeof(IGameplayClock))]
private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock());
[Cached]
public readonly EditorClipboard Clipboard = new EditorClipboard();
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public void SetUpSteps()
{ {

View File

@ -61,6 +61,18 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("scroll to 500", () => scroll.ScrollTo(500)); AddStep("scroll to 500", () => scroll.ScrollTo(500));
AddUntilStep("scrolled to 500", () => Precision.AlmostEquals(scroll.Current, 500, 0.1f)); AddUntilStep("scrolled to 500", () => Precision.AlmostEquals(scroll.Current, 500, 0.1f));
AddAssert("button is visible", () => scroll.Button.State == Visibility.Visible); AddAssert("button is visible", () => scroll.Button.State == Visibility.Visible);
AddStep("click button", () =>
{
InputManager.MoveMouseTo(scroll.Button);
InputManager.Click(MouseButton.Left);
});
AddAssert("button is visible", () => scroll.Button.State == Visibility.Visible);
AddStep("user scroll down by 1", () => InputManager.ScrollVerticalBy(-1));
AddAssert("button is hidden", () => scroll.Button.State == Visibility.Hidden);
} }
[Test] [Test]
@ -71,6 +83,10 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("invoke action", () => scroll.Button.Action.Invoke()); AddStep("invoke action", () => scroll.Button.Action.Invoke());
AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f)); AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f));
AddStep("invoke action", () => scroll.Button.Action.Invoke());
AddAssert("scrolled to end", () => scroll.IsScrolledToEnd());
} }
[Test] [Test]
@ -85,6 +101,14 @@ namespace osu.Game.Tests.Visual.UserInterface
}); });
AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f)); AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f));
AddStep("click button", () =>
{
InputManager.MoveMouseTo(scroll.Button);
InputManager.Click(MouseButton.Left);
});
AddAssert("scrolled to end", () => scroll.IsScrolledToEnd());
} }
[Test] [Test]
@ -97,12 +121,12 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("hover button", () => InputManager.MoveMouseTo(scroll.Button)); AddStep("hover button", () => InputManager.MoveMouseTo(scroll.Button));
AddRepeatStep("click button", () => InputManager.Click(MouseButton.Left), 3); AddRepeatStep("click button", () => InputManager.Click(MouseButton.Left), 3);
AddAssert("invocation count is 1", () => invocationCount == 1); AddAssert("invocation count is 3", () => invocationCount == 3);
} }
private partial class TestScrollContainer : OverlayScrollContainer private partial class TestScrollContainer : OverlayScrollContainer
{ {
public new ScrollToTopButton Button => base.Button; public new ScrollBackButton Button => base.Button;
} }
} }
} }

View File

@ -11,7 +11,6 @@ using osu.Framework.Allocation;
using System.Collections.Generic; using System.Collections.Generic;
using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Rendering.Vertices;
using osu.Framework.Graphics.Colour;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -259,8 +258,6 @@ namespace osu.Game.Graphics.Backgrounds
Vector2Extensions.Transform(triangleQuad.BottomRight * size, DrawInfo.Matrix) Vector2Extensions.Transform(triangleQuad.BottomRight * size, DrawInfo.Matrix)
); );
ColourInfo colourInfo = triangleColourInfo(DrawColourInfo.Colour, triangleQuad);
RectangleF textureCoords = new RectangleF( RectangleF textureCoords = new RectangleF(
triangleQuad.TopLeft.X - topLeft.X, triangleQuad.TopLeft.X - topLeft.X,
triangleQuad.TopLeft.Y - topLeft.Y, triangleQuad.TopLeft.Y - topLeft.Y,
@ -268,23 +265,12 @@ namespace osu.Game.Graphics.Backgrounds
triangleQuad.Height triangleQuad.Height
) / relativeSize; ) / relativeSize;
renderer.DrawQuad(texture, drawQuad, colourInfo, new RectangleF(0, 0, 1, 1), vertexBatch.AddAction, textureCoords: textureCoords); renderer.DrawQuad(texture, drawQuad, DrawColourInfo.Colour.Interpolate(triangleQuad), new RectangleF(0, 0, 1, 1), vertexBatch.AddAction, textureCoords: textureCoords);
} }
shader.Unbind(); shader.Unbind();
} }
private static ColourInfo triangleColourInfo(ColourInfo source, Quad quad)
{
return new ColourInfo
{
TopLeft = source.Interpolate(quad.TopLeft),
TopRight = source.Interpolate(quad.TopRight),
BottomLeft = source.Interpolate(quad.BottomLeft),
BottomRight = source.Interpolate(quad.BottomRight)
};
}
private static Quad clampToDrawable(Vector2 topLeft, Vector2 size) private static Quad clampToDrawable(Vector2 topLeft, Vector2 size)
{ {
float leftClamped = Math.Clamp(topLeft.X, 0f, 1f); float leftClamped = Math.Clamp(topLeft.X, 0f, 1f);

View File

@ -249,13 +249,7 @@ namespace osu.Game.Graphics.UserInterface
private ColourInfo getSegmentColour(SegmentInfo segment) private ColourInfo getSegmentColour(SegmentInfo segment)
{ {
var segmentColour = new ColourInfo var segmentColour = DrawColourInfo.Colour.Interpolate(new Quad(segment.Start, 0f, segment.End - segment.Start, 1f));
{
TopLeft = DrawColourInfo.Colour.Interpolate(new Vector2(segment.Start, 0f)),
TopRight = DrawColourInfo.Colour.Interpolate(new Vector2(segment.End, 0f)),
BottomLeft = DrawColourInfo.Colour.Interpolate(new Vector2(segment.Start, 1f)),
BottomRight = DrawColourInfo.Colour.Interpolate(new Vector2(segment.End, 1f))
};
var tierColour = segment.Tier >= 0 ? tierColours[segment.Tier] : new Colour4(0, 0, 0, 0); var tierColour = segment.Tier >= 0 ? tierColours[segment.Tier] : new Colour4(0, 0, 0, 0);
segmentColour.ApplyChild(tierColour); segmentColour.ApplyChild(tierColour);

View File

@ -39,6 +39,7 @@ namespace osu.Game.Overlays.Chat
private const float chatting_text_width = 220; private const float chatting_text_width = 220;
private const float search_icon_width = 40; private const float search_icon_width = 40;
private const float padding = 5;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider) private void load(OverlayColourProvider colourProvider)
@ -71,9 +72,10 @@ namespace osu.Game.Overlays.Chat
RelativeSizeAxes = Axes.Y, RelativeSizeAxes = Axes.Y,
Width = chatting_text_width, Width = chatting_text_width,
Masking = true, Masking = true,
Padding = new MarginPadding { Right = 5 }, Padding = new MarginPadding { Horizontal = padding },
Child = chattingText = new OsuSpriteText Child = chattingText = new OsuSpriteText
{ {
MaxWidth = chatting_text_width - padding * 2,
Font = OsuFont.Torus.With(size: 20), Font = OsuFont.Torus.With(size: 20),
Colour = colourProvider.Background1, Colour = colourProvider.Background1,
Anchor = Anchor.CentreRight, Anchor = Anchor.CentreRight,
@ -97,7 +99,7 @@ namespace osu.Game.Overlays.Chat
new Container new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = 5 }, Padding = new MarginPadding { Right = padding },
Child = chatTextBox = new ChatTextBox Child = chatTextBox = new ChatTextBox
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,

View File

@ -5,6 +5,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -21,25 +22,29 @@ using osuTK.Graphics;
namespace osu.Game.Overlays namespace osu.Game.Overlays
{ {
/// <summary> /// <summary>
/// <see cref="UserTrackingScrollContainer"/> which provides <see cref="ScrollToTopButton"/>. Mostly used in <see cref="FullscreenOverlay{T}"/>. /// <see cref="UserTrackingScrollContainer"/> which provides <see cref="ScrollBackButton"/>. Mostly used in <see cref="FullscreenOverlay{T}"/>.
/// </summary> /// </summary>
public partial class OverlayScrollContainer : UserTrackingScrollContainer public partial class OverlayScrollContainer : UserTrackingScrollContainer
{ {
/// <summary> /// <summary>
/// Scroll position at which the <see cref="ScrollToTopButton"/> will be shown. /// Scroll position at which the <see cref="ScrollBackButton"/> will be shown.
/// </summary> /// </summary>
private const int button_scroll_position = 200; private const int button_scroll_position = 200;
protected readonly ScrollToTopButton Button; protected ScrollBackButton Button;
public OverlayScrollContainer() private readonly Bindable<float?> lastScrollTarget = new Bindable<float?>();
[BackgroundDependencyLoader]
private void load()
{ {
AddInternal(Button = new ScrollToTopButton AddInternal(Button = new ScrollBackButton
{ {
Anchor = Anchor.BottomRight, Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight, Origin = Anchor.BottomRight,
Margin = new MarginPadding(20), Margin = new MarginPadding(20),
Action = scrollToTop Action = scrollBack,
LastScrollTarget = { BindTarget = lastScrollTarget }
}); });
} }
@ -53,16 +58,31 @@ namespace osu.Game.Overlays
return; return;
} }
Button.State = Target > button_scroll_position ? Visibility.Visible : Visibility.Hidden; Button.State = Target > button_scroll_position || lastScrollTarget.Value != null ? Visibility.Visible : Visibility.Hidden;
} }
private void scrollToTop() protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default)
{ {
ScrollToStart(); base.OnUserScroll(value, animated, distanceDecay);
Button.State = Visibility.Hidden;
lastScrollTarget.Value = null;
} }
public partial class ScrollToTopButton : OsuHoverContainer private void scrollBack()
{
if (lastScrollTarget.Value == null)
{
lastScrollTarget.Value = Target;
ScrollToStart();
}
else
{
ScrollTo(lastScrollTarget.Value.Value);
lastScrollTarget.Value = null;
}
}
public partial class ScrollBackButton : OsuHoverContainer
{ {
private const int fade_duration = 500; private const int fade_duration = 500;
@ -88,8 +108,11 @@ namespace osu.Game.Overlays
private readonly Container content; private readonly Container content;
private readonly Box background; private readonly Box background;
private readonly SpriteIcon spriteIcon;
public ScrollToTopButton() public Bindable<float?> LastScrollTarget = new Bindable<float?>();
public ScrollBackButton()
: base(HoverSampleSet.ScrollToTop) : base(HoverSampleSet.ScrollToTop)
{ {
Size = new Vector2(50); Size = new Vector2(50);
@ -113,7 +136,7 @@ namespace osu.Game.Overlays
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
}, },
new SpriteIcon spriteIcon = new SpriteIcon
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
@ -134,6 +157,17 @@ namespace osu.Game.Overlays
flashColour = colourProvider.Light1; flashColour = colourProvider.Light1;
} }
protected override void LoadComplete()
{
base.LoadComplete();
LastScrollTarget.BindValueChanged(target =>
{
spriteIcon.RotateTo(target.NewValue != null ? 180 : 0, fade_duration, Easing.OutQuint);
TooltipText = target.NewValue != null ? CommonStrings.ButtonsBackToPrevious : CommonStrings.ButtonsBackToTop;
}, true);
}
protected override bool OnClick(ClickEvent e) protected override bool OnClick(ClickEvent e)
{ {
background.FlashColour(flashColour, 800, Easing.OutQuint); background.FlashColour(flashColour, 800, Easing.OutQuint);

View File

@ -82,6 +82,7 @@ namespace osu.Game.Overlays.SkinEditor
{ {
Text = Item.GetType().Name, Text = Item.GetType().Name,
Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold), Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold),
Alpha = 0,
Anchor = Anchor.BottomRight, Anchor = Anchor.BottomRight,
Origin = Anchor.TopRight, Origin = Anchor.TopRight,
}, },
@ -99,7 +100,6 @@ namespace osu.Game.Overlays.SkinEditor
base.LoadComplete(); base.LoadComplete();
updateSelectedState(); updateSelectedState();
this.FadeInFromZero(200, Easing.OutQuint);
} }
protected override bool OnHover(HoverEvent e) protected override bool OnHover(HoverEvent e)

View File

@ -7,6 +7,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
@ -22,12 +23,12 @@ namespace osu.Game.Overlays.SkinEditor
{ {
public Action<Type>? RequestPlacement; public Action<Type>? RequestPlacement;
private readonly CompositeDrawable? target; private readonly SkinComponentsContainer? target;
private FillFlowContainer fill = null!; private FillFlowContainer fill = null!;
public SkinComponentToolbox(CompositeDrawable? target = null) public SkinComponentToolbox(SkinComponentsContainer? target = null)
: base(SkinEditorStrings.Components) : base(target?.Lookup.Ruleset == null ? SkinEditorStrings.Components : LocalisableString.Interpolate($"{SkinEditorStrings.Components} ({target.Lookup.Ruleset.Name})"))
{ {
this.target = target; this.target = target;
} }
@ -50,7 +51,7 @@ namespace osu.Game.Overlays.SkinEditor
{ {
fill.Clear(); fill.Clear();
var skinnableTypes = SerialisedDrawableInfo.GetAllAvailableDrawables(); var skinnableTypes = SerialisedDrawableInfo.GetAllAvailableDrawables(target?.Lookup.Ruleset);
foreach (var type in skinnableTypes) foreach (var type in skinnableTypes)
attemptAddComponent(type); attemptAddComponent(type);
} }

View File

@ -7,6 +7,7 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -24,6 +25,7 @@ using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Overlays.OSD; using osu.Game.Overlays.OSD;
using osu.Game.Overlays.Settings;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Components.Menus;
@ -60,12 +62,17 @@ namespace osu.Game.Overlays.SkinEditor
[Resolved] [Resolved]
private RealmAccess realm { get; set; } = null!; private RealmAccess realm { get; set; } = null!;
[Resolved]
private EditorClipboard clipboard { get; set; } = null!;
[Resolved] [Resolved]
private SkinEditorOverlay? skinEditorOverlay { get; set; } private SkinEditorOverlay? skinEditorOverlay { get; set; }
[Cached] [Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
private readonly Bindable<SkinComponentsContainerLookup?> selectedTarget = new Bindable<SkinComponentsContainerLookup?>();
private bool hasBegunMutating; private bool hasBegunMutating;
private Container? content; private Container? content;
@ -78,6 +85,15 @@ namespace osu.Game.Overlays.SkinEditor
private EditorMenuItem undoMenuItem = null!; private EditorMenuItem undoMenuItem = null!;
private EditorMenuItem redoMenuItem = null!; private EditorMenuItem redoMenuItem = null!;
private EditorMenuItem cutMenuItem = null!;
private EditorMenuItem copyMenuItem = null!;
private EditorMenuItem cloneMenuItem = null!;
private EditorMenuItem pasteMenuItem = null!;
private readonly BindableWithCurrent<bool> canCut = new BindableWithCurrent<bool>();
private readonly BindableWithCurrent<bool> canCopy = new BindableWithCurrent<bool>();
private readonly BindableWithCurrent<bool> canPaste = new BindableWithCurrent<bool>();
[Resolved] [Resolved]
private OnScreenDisplay? onScreenDisplay { get; set; } private OnScreenDisplay? onScreenDisplay { get; set; }
@ -143,6 +159,11 @@ namespace osu.Game.Overlays.SkinEditor
{ {
undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo),
redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo),
new EditorMenuItemSpacer(),
cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut),
copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy),
pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste),
cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone),
} }
}, },
} }
@ -201,6 +222,21 @@ namespace osu.Game.Overlays.SkinEditor
{ {
base.LoadComplete(); base.LoadComplete();
canCut.Current.BindValueChanged(cut => cutMenuItem.Action.Disabled = !cut.NewValue, true);
canCopy.Current.BindValueChanged(copy =>
{
copyMenuItem.Action.Disabled = !copy.NewValue;
cloneMenuItem.Action.Disabled = !copy.NewValue;
}, true);
canPaste.Current.BindValueChanged(paste => pasteMenuItem.Action.Disabled = !paste.NewValue, true);
SelectedComponents.BindCollectionChanged((_, _) =>
{
canCopy.Value = canCut.Value = SelectedComponents.Any();
}, true);
clipboard.Content.BindValueChanged(content => canPaste.Value = !string.IsNullOrEmpty(content.NewValue), true);
Show(); Show();
game?.RegisterImportHandler(this); game?.RegisterImportHandler(this);
@ -218,12 +254,26 @@ namespace osu.Game.Overlays.SkinEditor
}, true); }, true);
SelectedComponents.BindCollectionChanged((_, _) => Scheduler.AddOnce(populateSettings), true); SelectedComponents.BindCollectionChanged((_, _) => Scheduler.AddOnce(populateSettings), true);
selectedTarget.BindValueChanged(targetChanged, true);
} }
public bool OnPressed(KeyBindingPressEvent<PlatformAction> e) public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
{ {
switch (e.Action) switch (e.Action)
{ {
case PlatformAction.Cut:
Cut();
return true;
case PlatformAction.Copy:
Copy();
return true;
case PlatformAction.Paste:
Paste();
return true;
case PlatformAction.Undo: case PlatformAction.Undo:
Undo(); Undo();
return true; return true;
@ -253,8 +303,6 @@ namespace osu.Game.Overlays.SkinEditor
changeHandler?.Dispose(); changeHandler?.Dispose();
SelectedComponents.Clear();
// Immediately clear the previous blueprint container to ensure it doesn't try to interact with the old target. // Immediately clear the previous blueprint container to ensure it doesn't try to interact with the old target.
content?.Clear(); content?.Clear();
@ -263,18 +311,73 @@ namespace osu.Game.Overlays.SkinEditor
void loadBlueprintContainer() void loadBlueprintContainer()
{ {
selectedTarget.Default = getFirstTarget()?.Lookup;
if (!availableTargets.Any(t => t.Lookup.Equals(selectedTarget.Value)))
selectedTarget.SetDefault();
}
}
private void targetChanged(ValueChangedEvent<SkinComponentsContainerLookup?> target)
{
foreach (var toolbox in componentsSidebar.OfType<SkinComponentToolbox>())
toolbox.Expire();
if (target.NewValue == null)
return;
Debug.Assert(content != null); Debug.Assert(content != null);
changeHandler = new SkinEditorChangeHandler(targetScreen); SelectedComponents.Clear();
var skinComponentsContainer = getTarget(target.NewValue);
if (skinComponentsContainer == null)
return;
changeHandler = new SkinEditorChangeHandler(skinComponentsContainer);
changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true);
changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true);
content.Child = new SkinBlueprintContainer(targetScreen); content.Child = new SkinBlueprintContainer(skinComponentsContainer);
componentsSidebar.Child = new SkinComponentToolbox(getFirstTarget() as CompositeDrawable) componentsSidebar.Children = new[]
{ {
RequestPlacement = placeComponent new EditorSidebarSection("Current working layer")
{
Children = new Drawable[]
{
new SettingsDropdown<SkinComponentsContainerLookup?>
{
Items = availableTargets.Select(t => t.Lookup),
Current = selectedTarget,
}
}
},
}; };
// If the new target has a ruleset, let's show ruleset-specific items at the top, and the rest below.
if (target.NewValue.Ruleset != null)
{
componentsSidebar.Add(new SkinComponentToolbox(skinComponentsContainer)
{
RequestPlacement = requestPlacement
});
}
// Remove the ruleset from the lookup to get base components.
componentsSidebar.Add(new SkinComponentToolbox(getTarget(new SkinComponentsContainerLookup(target.NewValue.Target)))
{
RequestPlacement = requestPlacement
});
void requestPlacement(Type type)
{
if (!(Activator.CreateInstance(type) is ISerialisableDrawable component))
throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISerialisableDrawable)}.");
SelectedComponents.Clear();
placeComponent(component);
} }
} }
@ -300,20 +403,18 @@ namespace osu.Game.Overlays.SkinEditor
hasBegunMutating = true; hasBegunMutating = true;
} }
private void placeComponent(Type type) /// <summary>
/// Attempt to place a given component in the current target. If successful, the new component will be added to <see cref="SelectedComponents"/>.
/// </summary>
/// <param name="component">The component to be placed.</param>
/// <param name="applyDefaults">Whether to apply default anchor / origin / position values.</param>
/// <returns>Whether placement succeeded. Could fail if no target is available, or if the current target has missing dependency requirements for the component.</returns>
private bool placeComponent(ISerialisableDrawable component, bool applyDefaults = true)
{ {
if (!(Activator.CreateInstance(type) is ISerialisableDrawable component)) var targetContainer = getTarget(selectedTarget.Value);
throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISerialisableDrawable)}.");
placeComponent(component);
}
private void placeComponent(ISerialisableDrawable component, bool applyDefaults = true)
{
var targetContainer = getFirstTarget();
if (targetContainer == null) if (targetContainer == null)
return; return false;
var drawableComponent = (Drawable)component; var drawableComponent = (Drawable)component;
@ -325,10 +426,18 @@ namespace osu.Game.Overlays.SkinEditor
drawableComponent.Y = targetContainer.DrawSize.Y / 2; drawableComponent.Y = targetContainer.DrawSize.Y / 2;
} }
try
{
targetContainer.Add(component); targetContainer.Add(component);
}
catch
{
// May fail if dependencies are not available, for instance.
return false;
}
SelectedComponents.Clear();
SelectedComponents.Add(component); SelectedComponents.Add(component);
return true;
} }
private void populateSettings() private void populateSettings()
@ -341,11 +450,11 @@ namespace osu.Game.Overlays.SkinEditor
private IEnumerable<SkinComponentsContainer> availableTargets => targetScreen.ChildrenOfType<SkinComponentsContainer>(); private IEnumerable<SkinComponentsContainer> availableTargets => targetScreen.ChildrenOfType<SkinComponentsContainer>();
private ISerialisableDrawableContainer? getFirstTarget() => availableTargets.FirstOrDefault(); private SkinComponentsContainer? getFirstTarget() => availableTargets.FirstOrDefault();
private ISerialisableDrawableContainer? getTarget(SkinComponentsContainerLookup.TargetArea target) private SkinComponentsContainer? getTarget(SkinComponentsContainerLookup? target)
{ {
return availableTargets.FirstOrDefault(c => c.Lookup.Target == target); return availableTargets.FirstOrDefault(c => c.Lookup.Equals(target));
} }
private void revert() private void revert()
@ -357,10 +466,52 @@ namespace osu.Game.Overlays.SkinEditor
currentSkin.Value.ResetDrawableTarget(t); currentSkin.Value.ResetDrawableTarget(t);
// add back default components // add back default components
getTarget(t.Lookup.Target)?.Reload(); getTarget(t.Lookup)?.Reload();
} }
} }
protected void Cut()
{
Copy();
DeleteItems(SelectedComponents.ToArray());
}
protected void Copy()
{
clipboard.Content.Value = JsonConvert.SerializeObject(SelectedComponents.Cast<Drawable>().Select(s => s.CreateSerialisedInfo()).ToArray());
}
protected void Clone()
{
// Avoid attempting to clone if copying is not available (as it may result in pasting something unexpected).
if (!canCopy.Value)
return;
Copy();
Paste();
}
protected void Paste()
{
changeHandler?.BeginChange();
var drawableInfo = JsonConvert.DeserializeObject<SerialisedDrawableInfo[]>(clipboard.Content.Value);
if (drawableInfo == null)
return;
var instances = drawableInfo.Select(d => d.CreateInstance())
.OfType<ISerialisableDrawable>()
.ToArray();
SelectedComponents.Clear();
foreach (var i in instances)
placeComponent(i, false);
changeHandler?.EndChange();
}
protected void Undo() => changeHandler?.RestoreState(-1); protected void Undo() => changeHandler?.RestoreState(-1);
protected void Redo() => changeHandler?.RestoreState(1); protected void Redo() => changeHandler?.RestoreState(1);
@ -402,8 +553,55 @@ namespace osu.Game.Overlays.SkinEditor
public void DeleteItems(ISerialisableDrawable[] items) public void DeleteItems(ISerialisableDrawable[] items)
{ {
changeHandler?.BeginChange();
foreach (var item in items) foreach (var item in items)
availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item); availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item, true);
changeHandler?.EndChange();
}
public void BringSelectionToFront()
{
if (getTarget(selectedTarget.Value) is not SkinComponentsContainer target)
return;
changeHandler?.BeginChange();
// Iterating by target components order ensures we maintain the same order across selected components, regardless
// of the order they were selected in.
foreach (var d in target.Components.ToArray())
{
if (!SelectedComponents.Contains(d))
continue;
target.Remove(d, false);
// Selection would be reset by the remove.
SelectedComponents.Add(d);
target.Add(d);
}
changeHandler?.EndChange();
}
public void SendSelectionToBack()
{
if (getTarget(selectedTarget.Value) is not SkinComponentsContainer target)
return;
changeHandler?.BeginChange();
foreach (var d in target.Components.ToArray())
{
if (SelectedComponents.Contains(d))
continue;
target.Remove(d, false);
target.Add(d);
}
changeHandler?.EndChange();
} }
#region Drag & drop import handling #region Drag & drop import handling
@ -440,6 +638,7 @@ namespace osu.Game.Overlays.SkinEditor
Position = skinnableTarget.ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position), Position = skinnableTarget.ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position),
}; };
SelectedComponents.Clear();
placeComponent(sprite, false); placeComponent(sprite, false);
SkinSelectionHandler.ApplyClosestAnchor(sprite); SkinSelectionHandler.ApplyClosestAnchor(sprite);

View File

@ -11,6 +11,7 @@ using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using osu.Game.Screens; using osu.Game.Screens;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components;
using osuTK; using osuTK;
@ -28,6 +29,9 @@ namespace osu.Game.Overlays.SkinEditor
private SkinEditor? skinEditor; private SkinEditor? skinEditor;
[Cached]
public readonly EditorClipboard Clipboard = new EditorClipboard();
[Resolved] [Resolved]
private OsuGame game { get; set; } = null!; private OsuGame game { get; set; } = null!;

View File

@ -13,6 +13,7 @@ using osu.Framework.Utils;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
@ -206,6 +207,14 @@ namespace osu.Game.Overlays.SkinEditor
((Drawable)blueprint.Item).Position = Vector2.Zero; ((Drawable)blueprint.Item).Position = Vector2.Zero;
}); });
yield return new EditorMenuItemSpacer();
yield return new OsuMenuItem("Bring to front", MenuItemType.Standard, () => skinEditor.BringSelectionToFront());
yield return new OsuMenuItem("Send to back", MenuItemType.Standard, () => skinEditor.SendSelectionToBack());
yield return new EditorMenuItemSpacer();
foreach (var item in base.GetContextMenuItemsForSelection(selection)) foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item; yield return item;

View File

@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Mods
flashlight.Colour = Color4.Black; flashlight.Colour = Color4.Black;
flashlight.Combo.BindTo(Combo); flashlight.Combo.BindTo(Combo);
drawableRuleset.KeyBindingInputManager.Add(flashlight); drawableRuleset.Overlays.Add(flashlight);
} }
protected abstract Flashlight CreateFlashlight(); protected abstract Flashlight CreateFlashlight();

View File

@ -67,7 +67,8 @@ namespace osu.Game.Rulesets.Mods
{ {
MetronomeBeat metronomeBeat; MetronomeBeat metronomeBeat;
drawableRuleset.Overlays.Add(metronomeBeat = new MetronomeBeat(drawableRuleset.Beatmap.HitObjects.First().StartTime)); // Importantly, this is added to FrameStableComponents and not Overlays as the latter would cause it to be self-muted by the mod's volume adjustment.
drawableRuleset.FrameStableComponents.Add(metronomeBeat = new MetronomeBeat(drawableRuleset.Beatmap.HitObjects.First().StartTime));
metronomeBeat.AddAdjustment(AdjustableProperty.Volume, metronomeVolumeAdjust); metronomeBeat.AddAdjustment(AdjustableProperty.Volume, metronomeVolumeAdjust);
} }

View File

@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.UI
/// <summary> /// <summary>
/// The key conversion input manager for this DrawableRuleset. /// The key conversion input manager for this DrawableRuleset.
/// </summary> /// </summary>
public PassThroughInputManager KeyBindingInputManager; protected PassThroughInputManager KeyBindingInputManager;
public override double GameplayStartTime => Objects.FirstOrDefault()?.StartTime - 2000 ?? 0; public override double GameplayStartTime => Objects.FirstOrDefault()?.StartTime - 2000 ?? 0;
@ -66,6 +66,10 @@ namespace osu.Game.Rulesets.UI
public override Container Overlays { get; } = new Container { RelativeSizeAxes = Axes.Both }; public override Container Overlays { get; } = new Container { RelativeSizeAxes = Axes.Both };
public override IAdjustableAudioComponent Audio => audioContainer;
private readonly AudioContainer audioContainer = new AudioContainer { RelativeSizeAxes = Axes.Both };
public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both }; public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both };
public override IFrameStableClock FrameStableClock => frameStabilityContainer; public override IFrameStableClock FrameStableClock => frameStabilityContainer;
@ -102,14 +106,6 @@ namespace osu.Game.Rulesets.UI
private DrawableRulesetDependencies dependencies; private DrawableRulesetDependencies dependencies;
/// <summary>
/// Audio adjustments which are applied to the playfield.
/// </summary>
/// <remarks>
/// Does not affect <see cref="Overlays"/>.
/// </remarks>
public IAdjustableAudioComponent Audio { get; private set; }
/// <summary> /// <summary>
/// Creates a ruleset visualisation for the provided ruleset and beatmap. /// Creates a ruleset visualisation for the provided ruleset and beatmap.
/// </summary> /// </summary>
@ -172,28 +168,22 @@ namespace osu.Game.Rulesets.UI
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(CancellationToken? cancellationToken) private void load(CancellationToken? cancellationToken)
{ {
AudioContainer audioContainer;
InternalChild = frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) InternalChild = frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime)
{ {
FrameStablePlayback = FrameStablePlayback, FrameStablePlayback = FrameStablePlayback,
Children = new Drawable[] Children = new Drawable[]
{ {
FrameStableComponents, FrameStableComponents,
audioContainer = new AudioContainer audioContainer.WithChild(KeyBindingInputManager
.WithChildren(new Drawable[]
{ {
RelativeSizeAxes = Axes.Both, CreatePlayfieldAdjustmentContainer()
Child = KeyBindingInputManager .WithChild(Playfield),
.WithChild(CreatePlayfieldAdjustmentContainer() Overlays
.WithChild(Playfield) })),
),
},
Overlays,
} }
}; };
Audio = audioContainer;
if ((ResumeOverlay = CreateResumeOverlay()) != null) if ((ResumeOverlay = CreateResumeOverlay()) != null)
{ {
AddInternal(CreateInputManager() AddInternal(CreateInputManager()
@ -436,13 +426,18 @@ namespace osu.Game.Rulesets.UI
/// </summary> /// </summary>
public readonly BindableBool IsPaused = new BindableBool(); public readonly BindableBool IsPaused = new BindableBool();
/// <summary>
/// Audio adjustments which are applied to the playfield.
/// </summary>
public abstract IAdjustableAudioComponent Audio { get; }
/// <summary> /// <summary>
/// The playfield. /// The playfield.
/// </summary> /// </summary>
public abstract Playfield Playfield { get; } public abstract Playfield Playfield { get; }
/// <summary> /// <summary>
/// Content to be placed above hitobjects. Will be affected by frame stability. /// Content to be placed above hitobjects. Will be affected by frame stability and adjustments applied to <see cref="Audio"/>.
/// </summary> /// </summary>
public abstract Container Overlays { get; } public abstract Container Overlays { get; }

View File

@ -7,6 +7,7 @@ using System.Linq;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Rulesets.UI namespace osu.Game.Rulesets.UI
@ -68,11 +69,17 @@ namespace osu.Game.Rulesets.UI
protected HitObject GetMostValidObject() protected HitObject GetMostValidObject()
{ {
// The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time. // The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time.
var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true)?.HitObject; var drawableHitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true);
if (drawableHitObject != null)
{
// A hit object may have a more valid nested object.
drawableHitObject = getMostValidNestedDrawable(drawableHitObject);
return drawableHitObject.HitObject;
}
// In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play. // In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play.
if (hitObject == null)
{
// This lookup can be skipped if the last entry is still valid (in the future and not yet hit). // This lookup can be skipped if the last entry is still valid (in the future and not yet hit).
if (fallbackObject == null || fallbackObject.Result?.HasResult == true) if (fallbackObject == null || fallbackObject.Result?.HasResult == true)
{ {
@ -81,14 +88,42 @@ namespace osu.Game.Rulesets.UI
fallbackObject = hitObjectContainer.Entries fallbackObject = hitObjectContainer.Entries
.Where(e => e.Result?.HasResult != true).MinBy(e => e.HitObject.StartTime); .Where(e => e.Result?.HasResult != true).MinBy(e => e.HitObject.StartTime);
// In the case there are no unjudged objects, the last hit object should be used instead. if (fallbackObject != null)
return getEarliestNestedObject(fallbackObject.HitObject);
// In the case there are no non-judged objects, the last hit object should be used instead.
fallbackObject ??= hitObjectContainer.Entries.LastOrDefault(); fallbackObject ??= hitObjectContainer.Entries.LastOrDefault();
} }
hitObject = fallbackObject?.HitObject; if (fallbackObject == null)
return null;
bool fallbackHasResult = fallbackObject.Result?.HasResult == true;
// If the fallback has been judged then we want the sample from the object itself.
if (fallbackHasResult)
return fallbackObject.HitObject;
// Else we want the earliest (including nested).
// In cases of nested objects, they will always have earlier sample data than their parent object.
return getEarliestNestedObject(fallbackObject.HitObject);
} }
return hitObject; private DrawableHitObject getMostValidNestedDrawable(DrawableHitObject o)
{
var nestedWithoutResult = o.NestedHitObjects.FirstOrDefault(n => n.Result?.HasResult != true);
if (nestedWithoutResult == null)
return o;
return getMostValidNestedDrawable(nestedWithoutResult);
}
private HitObject getEarliestNestedObject(HitObject hitObject)
{
var nested = hitObject.NestedHitObjects.FirstOrDefault();
return nested != null ? getEarliestNestedObject(nested) : hitObject;
} }
private SkinnableSound getNextSample() private SkinnableSound getNextSample()

View File

@ -45,6 +45,7 @@ namespace osu.Game.Skinning
/// Remove an existing skinnable component from this target. /// Remove an existing skinnable component from this target.
/// </summary> /// </summary>
/// <param name="component">The component to remove.</param> /// <param name="component">The component to remove.</param>
void Remove(ISerialisableDrawable component); /// <param name="disposeImmediately">Whether removed items should be immediately disposed.</param>
void Remove(ISerialisableDrawable component, bool disposeImmediately);
} }
} }

View File

@ -100,7 +100,7 @@ namespace osu.Game.Skinning
/// <inheritdoc cref="ISerialisableDrawableContainer"/> /// <inheritdoc cref="ISerialisableDrawableContainer"/>
/// <exception cref="NotSupportedException">Thrown when attempting to add an element to a target which is not supported by the current skin.</exception> /// <exception cref="NotSupportedException">Thrown when attempting to add an element to a target which is not supported by the current skin.</exception>
/// <exception cref="ArgumentException">Thrown if the provided instance is not a <see cref="Drawable"/>.</exception> /// <exception cref="ArgumentException">Thrown if the provided instance is not a <see cref="Drawable"/>.</exception>
public void Remove(ISerialisableDrawable component) public void Remove(ISerialisableDrawable component, bool disposeImmediately)
{ {
if (content == null) if (content == null)
throw new NotSupportedException("Attempting to remove a new component from a target container which is not supported by the current skin."); throw new NotSupportedException("Attempting to remove a new component from a target container which is not supported by the current skin.");
@ -108,7 +108,7 @@ namespace osu.Game.Skinning
if (!(component is Drawable drawable)) if (!(component is Drawable drawable))
throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component)); throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component));
content.Remove(drawable, true); content.Remove(drawable, disposeImmediately);
components.Remove(component); components.Remove(component);
} }

View File

@ -1,6 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.ComponentModel;
using osu.Framework.Extensions;
using osu.Game.Rulesets; using osu.Game.Rulesets;
namespace osu.Game.Skinning namespace osu.Game.Skinning
@ -8,7 +11,7 @@ namespace osu.Game.Skinning
/// <summary> /// <summary>
/// Represents a lookup of a collection of elements that make up a particular skinnable <see cref="TargetArea"/> of the game. /// Represents a lookup of a collection of elements that make up a particular skinnable <see cref="TargetArea"/> of the game.
/// </summary> /// </summary>
public class SkinComponentsContainerLookup : ISkinComponentLookup public class SkinComponentsContainerLookup : ISkinComponentLookup, IEquatable<SkinComponentsContainerLookup>
{ {
/// <summary> /// <summary>
/// The target area / layer of the game for which skin components will be returned. /// The target area / layer of the game for which skin components will be returned.
@ -27,12 +30,44 @@ namespace osu.Game.Skinning
Ruleset = ruleset; Ruleset = ruleset;
} }
public override string ToString()
{
if (Ruleset == null) return Target.GetDescription();
return $"{Target.GetDescription()} (\"{Ruleset.Name}\" only)";
}
public bool Equals(SkinComponentsContainerLookup? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Target == other.Target && (ReferenceEquals(Ruleset, other.Ruleset) || Ruleset?.Equals(other.Ruleset) == true);
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != GetType()) return false;
return Equals((SkinComponentsContainerLookup)obj);
}
public override int GetHashCode()
{
return HashCode.Combine((int)Target, Ruleset);
}
/// <summary> /// <summary>
/// Represents a particular area or part of a game screen whose layout can be customised using the skin editor. /// Represents a particular area or part of a game screen whose layout can be customised using the skin editor.
/// </summary> /// </summary>
public enum TargetArea public enum TargetArea
{ {
[Description("HUD")]
MainHUDComponents, MainHUDComponents,
[Description("Song select")]
SongSelect SongSelect
} }
} }

View File

@ -35,8 +35,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Realm" Version="10.20.0" /> <PackageReference Include="Realm" Version="10.20.0" />
<PackageReference Include="ppy.osu.Framework" Version="2023.131.0" /> <PackageReference Include="ppy.osu.Framework" Version="2023.228.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.202.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2023.228.0" />
<PackageReference Include="Sentry" Version="3.28.1" /> <PackageReference Include="Sentry" Version="3.28.1" />
<PackageReference Include="SharpCompress" Version="0.32.2" /> <PackageReference Include="SharpCompress" Version="0.32.2" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />

View File

@ -16,6 +16,6 @@
<RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier> <RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2023.131.0" /> <PackageReference Include="ppy.osu.Framework.iOS" Version="2023.228.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>