1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-17 16:53:15 +08:00

Compare commits

..

525 Commits

372 changed files with 6480 additions and 2579 deletions
+2 -2
View File
@@ -27,10 +27,10 @@
]
},
"ppy.localisationanalyser.tools": {
"version": "2021.705.0",
"version": "2021.725.0",
"commands": [
"localisation"
]
}
}
}
}
+2
View File
@@ -190,3 +190,5 @@ dotnet_diagnostic.CA2225.severity = none
# Banned APIs
dotnet_diagnostic.RS0030.severity = error
dotnet_diagnostic.OLOC001.license_header = // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.\n// See the LICENCE file in the repository root for full licence text.
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Dual client test" type="CompoundRunConfigurationType">
<toRun name="osu!" type="DotNetProject" />
<toRun name="osu! (Second Client)" type="DotNetProject" />
<method v="2" />
</configuration>
</component>
+20
View File
@@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="osu! (Second Client)" type="DotNetProject" factoryName=".NET Project" folderName="osu!" activateToolWindowBeforeRun="false">
<option name="EXE_PATH" value="$PROJECT_DIR$/osu.Desktop/bin/Debug/net5.0/osu!.dll" />
<option name="PROGRAM_PARAMETERS" value="--debug-client-id=1" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/osu.Desktop/bin/Debug/net5.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/osu.Desktop/osu.Desktop.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net5.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>
+2 -2
View File
@@ -51,8 +51,8 @@
<Reference Include="Java.Interop" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.706.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.714.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.808.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.807.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
+1 -1
View File
@@ -64,7 +64,7 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Xamarin.Essentials" Version="1.6.1" />
<PackageReference Include="Xamarin.Essentials" Version="1.7.0" />
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath)\Xamarin\Android\Xamarin.Android.CSharp.targets" />
</Project>
+35 -12
View File
@@ -3,7 +3,6 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework;
@@ -17,13 +16,43 @@ namespace osu.Desktop
{
public static class Program
{
private const string base_game_name = @"osu";
[STAThread]
public static int Main(string[] args)
{
// Back up the cwd before DesktopGameHost changes it
var cwd = Environment.CurrentDirectory;
using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true))
string gameName = base_game_name;
bool tournamentClient = false;
foreach (var arg in args)
{
var split = arg.Split('=');
var key = split[0];
var val = split.Length > 1 ? split[1] : string.Empty;
switch (key)
{
case "--tournament":
tournamentClient = true;
break;
case "--debug-client-id":
if (!DebugUtils.IsDebugBuild)
throw new InvalidOperationException("Cannot use this argument in a non-debug build.");
if (!int.TryParse(val, out int clientID))
throw new ArgumentException("Provided client ID must be an integer.");
gameName = $"{base_game_name}-{clientID}";
break;
}
}
using (DesktopGameHost host = Host.GetSuitableHost(gameName, true))
{
host.ExceptionThrown += handleException;
@@ -48,16 +77,10 @@ namespace osu.Desktop
return 0;
}
switch (args.FirstOrDefault() ?? string.Empty)
{
default:
host.Run(new OsuGameDesktop(args));
break;
case "--tournament":
host.Run(new TournamentGame());
break;
}
if (tournamentClient)
host.Run(new TournamentGame());
else
host.Run(new OsuGameDesktop(args));
return 0;
}
@@ -1,10 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Catch.Tests.Editor
{
@@ -14,11 +21,52 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
protected override Container<Drawable> Content => contentContainer;
[Cached(typeof(EditorBeatmap))]
[Cached(typeof(IBeatSnapProvider))]
protected readonly EditorBeatmap EditorBeatmap;
private readonly CatchEditorTestSceneContainer contentContainer;
protected CatchSelectionBlueprintTestScene()
{
base.Content.Add(contentContainer = new CatchEditorTestSceneContainer());
EditorBeatmap = new EditorBeatmap(new CatchBeatmap());
EditorBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = 0;
EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint
{
BeatLength = 100
});
base.Content.Add(new EditorBeatmapDependencyContainer(EditorBeatmap, new BindableBeatDivisor())
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
EditorBeatmap,
contentContainer = new CatchEditorTestSceneContainer()
},
});
}
protected void AddMouseMoveStep(double time, float x) => AddStep($"move to time={time}, x={x}", () =>
{
float y = HitObjectContainer.PositionAtTime(time);
Vector2 pos = HitObjectContainer.ToScreenSpace(new Vector2(x, y + HitObjectContainer.DrawHeight));
InputManager.MoveMouseTo(pos);
});
private class EditorBeatmapDependencyContainer : Container
{
[Cached]
private readonly EditorClock editorClock;
[Cached]
private readonly BindableBeatDivisor beatDivisor;
public EditorBeatmapDependencyContainer(IBeatmap beatmap, BindableBeatDivisor beatDivisor)
{
editorClock = new EditorClock(beatmap, beatDivisor);
this.beatDivisor = beatDivisor;
}
}
}
}
@@ -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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public class TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene
{
private const double velocity = 0.5;
private JuiceStream lastObject => LastObject?.HitObject as JuiceStream;
[BackgroundDependencyLoader]
private void load()
{
Beatmap.Value.BeatmapInfo.BaseDifficulty.SliderTickRate = 5;
Beatmap.Value.BeatmapInfo.BaseDifficulty.SliderMultiplier = velocity * 10;
}
[Test]
public void TestBasicPlacement()
{
double[] times = { 300, 800 };
float[] positions = { 100, 200 };
addPlacementSteps(times, positions);
AddAssert("juice stream is placed", () => lastObject != null);
AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0]));
AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1]));
AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0]));
AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1]));
}
[Test]
public void TestEmptyNotCommitted()
{
addMoveAndClickSteps(100, 100);
addMoveAndClickSteps(100, 100);
addMoveAndClickSteps(100, 100, true);
AddAssert("juice stream not placed", () => lastObject == null);
}
[Test]
public void TestMultipleSegments()
{
double[] times = { 100, 300, 500, 700 };
float[] positions = { 100, 150, 100, 100 };
addPlacementSteps(times, positions);
AddAssert("has 4 vertices", () => lastObject.Path.ControlPoints.Count == 4);
addPathCheckStep(times, positions);
}
[Test]
public void TestVelocityLimit()
{
double[] times = { 100, 300 };
float[] positions = { 200, 500 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 200, 300 });
}
[Test]
public void TestPreviousVerticesAreFixed()
{
double[] times = { 100, 300, 500, 700 };
float[] positions = { 200, 400, 100, 500 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 200, 300, 200, 300 });
}
[Test]
public void TestClampedPositionIsRestored()
{
double[] times = { 100, 300, 500 };
float[] positions = { 200, 200, 0, 250 };
addMoveAndClickSteps(times[0], positions[0]);
addMoveAndClickSteps(times[1], positions[1]);
AddMoveStep(times[2], positions[2]);
addMoveAndClickSteps(times[2], positions[3], true);
addPathCheckStep(times, new float[] { 200, 200, 250 });
}
[Test]
public void TestFirstVertexIsFixed()
{
double[] times = { 100, 200 };
float[] positions = { 100, 300 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 100, 150 });
}
[Test]
public void TestOutOfOrder()
{
double[] times = { 100, 700, 500, 300 };
float[] positions = { 100, 200, 150, 50 };
addPlacementSteps(times, positions);
addPathCheckStep(times, positions);
}
[Test]
public void TestMoveBeforeFirstVertex()
{
double[] times = { 300, 500, 100 };
float[] positions = { 100, 100, 100 };
addPlacementSteps(times, positions);
AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0]));
AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1], 1e-3));
}
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableJuiceStream((JuiceStream)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint();
private void addMoveAndClickSteps(double time, float position, bool end = false)
{
AddMoveStep(time, position);
AddClickStep(end ? MouseButton.Right : MouseButton.Left);
}
private void addPlacementSteps(double[] times, float[] positions)
{
for (int i = 0; i < times.Length; i++)
addMoveAndClickSteps(times[i], positions[i], i == times.Length - 1);
}
private void addPathCheckStep(double[] times, float[] positions) => AddStep("assert path is correct", () =>
Assert.That(getPositions(times), Is.EqualTo(positions).Within(Precision.FLOAT_EPSILON)));
private float[] getPositions(IEnumerable<double> times)
{
JuiceStream hitObject = lastObject.AsNonNull();
return times
.Select(time => (time - hitObject.StartTime) * hitObject.Velocity)
.Select(distance => hitObject.EffectiveX + hitObject.Path.PositionAt(distance / hitObject.Distance).X)
.ToArray();
}
}
}
@@ -1,38 +1,286 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene
{
public TestSceneJuiceStreamSelectionBlueprint()
private JuiceStream hitObject;
private readonly ManualClock manualClock = new ManualClock();
[SetUp]
public void SetUp() => Schedule(() =>
{
var hitObject = new JuiceStream
EditorBeatmap.Clear();
Content.Clear();
manualClock.CurrentTime = 0;
Content.Clock = new FramedClock(manualClock);
InputManager.ReleaseButton(MouseButton.Left);
InputManager.ReleaseKey(Key.ShiftLeft);
InputManager.ReleaseKey(Key.ControlLeft);
});
[Test]
public void TestBasicComponentLayout()
{
double[] times = { 100, 300, 500 };
float[] positions = { 100, 200, 100 };
addBlueprintStep(times, positions);
for (int i = 0; i < times.Length; i++)
addVertexCheckStep(times.Length, i, times[i], positions[i]);
AddAssert("correct outline count", () =>
{
OriginalX = 100,
StartTime = 100,
Path = new SliderPath(PathType.PerfectCurve, new[]
var expected = hitObject.NestedHitObjects.Count(h => !(h is TinyDroplet));
return this.ChildrenOfType<FruitOutline>().Count() == expected;
});
AddAssert("correct vertex piece count", () =>
this.ChildrenOfType<VertexPiece>().Count() == times.Length);
AddAssert("first vertex is semitransparent", () =>
Precision.DefinitelyBigger(1, this.ChildrenOfType<VertexPiece>().First().Alpha));
}
[Test]
public void TestVertexDrag()
{
double[] times = { 100, 400, 700 };
float[] positions = { 100, 100, 100 };
addBlueprintStep(times, positions);
addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(500, 150);
addVertexCheckStep(3, 1, 500, 150);
addDragEndStep();
addDragStartStep(times[2], positions[2]);
AddMouseMoveStep(300, 50);
addVertexCheckStep(3, 1, 300, 50);
addVertexCheckStep(3, 2, 500, 150);
AddMouseMoveStep(-100, 100);
addVertexCheckStep(3, 1, times[0], positions[0]);
}
[Test]
public void TestMultipleDrag()
{
double[] times = { 100, 300, 500, 700 };
float[] positions = { 100, 100, 100, 100 };
addBlueprintStep(times, positions);
AddMouseMoveStep(times[1], positions[1]);
AddStep("press left", () => InputManager.PressButton(MouseButton.Left));
AddStep("release left", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft));
addDragStartStep(times[2], positions[2]);
AddMouseMoveStep(times[2] - 50, positions[2] - 50);
addVertexCheckStep(4, 1, times[1] - 50, positions[1] - 50);
addVertexCheckStep(4, 2, times[2] - 50, positions[2] - 50);
}
[Test]
public void TestClampedPositionIsRestored()
{
const double velocity = 0.25;
double[] times = { 100, 500, 700 };
float[] positions = { 100, 100, 100 };
addBlueprintStep(times, positions, velocity);
addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(times[1], 200);
addVertexCheckStep(3, 1, times[1], 200);
addVertexCheckStep(3, 2, times[2], 150);
AddMouseMoveStep(times[1], 100);
addVertexCheckStep(3, 1, times[1], 100);
// Stored position is restored.
addVertexCheckStep(3, 2, times[2], positions[2]);
AddMouseMoveStep(times[1], 300);
addDragEndStep();
addDragStartStep(times[1], 300);
AddMouseMoveStep(times[1], 100);
// Position is different because a changed position is committed when the previous drag is ended.
addVertexCheckStep(3, 2, times[2], 250);
}
[Test]
public void TestScrollWhileDrag()
{
double[] times = { 300, 500 };
float[] positions = { 100, 100 };
addBlueprintStep(times, positions);
addDragStartStep(times[1], positions[1]);
// This mouse move is necessary to start drag and capture the input.
AddMouseMoveStep(times[1], positions[1] + 50);
AddStep("scroll playfield", () => manualClock.CurrentTime += 200);
AddMouseMoveStep(times[1] + 200, positions[1] + 100);
addVertexCheckStep(2, 1, times[1] + 200, positions[1] + 100);
}
[Test]
public void TestUpdateFromHitObject()
{
double[] times = { 100, 300 };
float[] positions = { 200, 200 };
addBlueprintStep(times, positions);
AddStep("update hit object path", () =>
{
hitObject.Path = new SliderPath(PathType.PerfectCurve, new[]
{
Vector2.Zero,
new Vector2(200, 100),
new Vector2(100, 100),
new Vector2(0, 200),
}),
};
var controlPoint = new ControlPointInfo();
controlPoint.Add(0, new TimingControlPoint
{
BeatLength = 100
});
EditorBeatmap.Update(hitObject);
});
hitObject.ApplyDefaults(controlPoint, new BeatmapDifficulty { CircleSize = 0 });
AddAssert("path is updated", () => getVertices().Count > 2);
}
[Test]
public void TestAddVertex()
{
double[] times = { 100, 700 };
float[] positions = { 200, 200 };
addBlueprintStep(times, positions, 0.2);
addAddVertexSteps(500, 150);
addVertexCheckStep(3, 1, 500, 150);
addAddVertexSteps(90, 220);
addVertexCheckStep(4, 1, times[0], positions[0]);
addAddVertexSteps(750, 180);
addVertexCheckStep(5, 4, 750, 180);
AddAssert("duration is changed", () => Precision.AlmostEquals(hitObject.Duration, 800 - times[0], 1e-3));
}
[Test]
public void TestDeleteVertex()
{
double[] times = { 100, 300, 500 };
float[] positions = { 100, 200, 150 };
addBlueprintStep(times, positions);
addDeleteVertexSteps(times[1], positions[1]);
addVertexCheckStep(2, 1, times[2], positions[2]);
// The first vertex cannot be deleted.
addDeleteVertexSteps(times[0], positions[0]);
addVertexCheckStep(2, 0, times[0], positions[0]);
addDeleteVertexSteps(times[2], positions[2]);
addVertexCheckStep(1, 0, times[0], positions[0]);
}
[Test]
public void TestVertexResampling()
{
addBlueprintStep(100, 100, new SliderPath(PathType.PerfectCurve, new[]
{
Vector2.Zero,
new Vector2(100, 100),
new Vector2(50, 200),
}), 0.5);
AddAssert("1 vertex per 1 nested HO", () => getVertices().Count == hitObject.NestedHitObjects.Count);
AddAssert("slider path not yet changed", () => hitObject.Path.ControlPoints[0].Type.Value == PathType.PerfectCurve);
addAddVertexSteps(150, 150);
AddAssert("slider path change to linear", () => hitObject.Path.ControlPoints[0].Type.Value == PathType.Linear);
}
private void addBlueprintStep(double time, float x, SliderPath sliderPath, double velocity) => AddStep("add selection blueprint", () =>
{
hitObject = new JuiceStream
{
StartTime = time,
X = x,
Path = sliderPath,
};
EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = velocity;
EditorBeatmap.Add(hitObject);
EditorBeatmap.Update(hitObject);
Assert.That(hitObject.Velocity, Is.EqualTo(velocity));
AddBlueprint(new JuiceStreamSelectionBlueprint(hitObject));
});
private void addBlueprintStep(double[] times, float[] positions, double velocity = 0.5)
{
var path = new JuiceStreamPath();
for (int i = 1; i < times.Length; i++)
path.Add((times[i] - times[0]) * velocity, positions[i] - positions[0]);
var sliderPath = new SliderPath();
path.ConvertToSliderPath(sliderPath, 0);
addBlueprintStep(times[0], positions[0], sliderPath, velocity);
}
private IReadOnlyList<JuiceStreamPathVertex> getVertices() => this.ChildrenOfType<EditablePath>().Single().Vertices;
private void addVertexCheckStep(int count, int index, double time, float x) => AddAssert($"vertex {index} of {count} at {time}, {x}", () =>
{
double expectedDistance = (time - hitObject.StartTime) * hitObject.Velocity;
float expectedX = x - hitObject.OriginalX;
var vertices = getVertices();
return vertices.Count == count &&
Precision.AlmostEquals(vertices[index].Distance, expectedDistance, 1e-3) &&
Precision.AlmostEquals(vertices[index].X, expectedX);
});
private void addDragStartStep(double time, float x)
{
AddMouseMoveStep(time, x);
AddStep("start dragging", () => InputManager.PressButton(MouseButton.Left));
}
private void addDragEndStep() => AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left));
private void addAddVertexSteps(double time, float x)
{
AddMouseMoveStep(time, x);
AddStep("add vertex", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Click(MouseButton.Left);
InputManager.ReleaseKey(Key.ControlLeft);
});
}
private void addDeleteVertexSteps(double time, float x)
{
AddMouseMoveStep(time, x);
AddStep("delete vertex", () =>
{
InputManager.PressKey(Key.ShiftLeft);
InputManager.Click(MouseButton.Left);
InputManager.ReleaseKey(Key.ShiftLeft);
});
}
}
}
@@ -3,7 +3,6 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -24,16 +23,12 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public class TestSceneCatchSkinConfiguration : OsuTestScene
{
[Cached]
private readonly DroppedObjectContainer droppedObjectContainer;
private Catcher catcher;
private readonly Container container;
public TestSceneCatchSkinConfiguration()
{
Add(droppedObjectContainer = new DroppedObjectContainer());
Add(container = new Container { RelativeSizeAxes = Axes.Both });
}
@@ -46,7 +41,7 @@ namespace osu.Game.Rulesets.Catch.Tests
var skin = new TestSkin { FlipCatcherPlate = flip };
container.Child = new SkinProvidingContainer(skin)
{
Child = catcher = new Catcher(new Container())
Child = catcher = new Catcher(new DroppedObjectContainer())
{
Anchor = Anchor.Centre
}
@@ -31,23 +31,10 @@ namespace osu.Game.Rulesets.Catch.Tests
[Resolved]
private OsuConfigManager config { get; set; }
[Cached]
private readonly DroppedObjectContainer droppedObjectContainer;
private readonly Container trailContainer;
private DroppedObjectContainer droppedObjectContainer;
private TestCatcher catcher;
public TestSceneCatcher()
{
Add(trailContainer = new Container
{
Anchor = Anchor.Centre,
Depth = -1
});
Add(droppedObjectContainer = new DroppedObjectContainer());
}
[SetUp]
public void SetUp() => Schedule(() =>
{
@@ -56,13 +43,17 @@ namespace osu.Game.Rulesets.Catch.Tests
CircleSize = 0,
};
if (catcher != null)
Remove(catcher);
droppedObjectContainer = new DroppedObjectContainer();
Add(catcher = new TestCatcher(trailContainer, difficulty)
Child = new Container
{
Anchor = Anchor.Centre
});
Anchor = Anchor.Centre,
Children = new Drawable[]
{
droppedObjectContainer,
catcher = new TestCatcher(droppedObjectContainer, difficulty),
}
};
});
[Test]
@@ -299,8 +290,8 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public IEnumerable<CaughtObject> CaughtObjects => this.ChildrenOfType<CaughtObject>();
public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty)
: base(trailsTarget, difficulty)
public TestCatcher(DroppedObjectContainer droppedObjectTarget, BeatmapDifficulty difficulty)
: base(droppedObjectTarget, difficulty)
{
}
}
@@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Tests
{
area.OnNewResult(drawable, new CatchJudgementResult(fruit, new CatchJudgement())
{
Type = area.MovableCatcher.CanCatch(fruit) ? HitResult.Great : HitResult.Miss
Type = area.Catcher.CanCatch(fruit) ? HitResult.Great : HitResult.Miss
});
drawable.Expire();
@@ -119,16 +119,18 @@ namespace osu.Game.Rulesets.Catch.Tests
private class TestCatcherArea : CatcherArea
{
[Cached]
private readonly DroppedObjectContainer droppedObjectContainer;
public TestCatcherArea(BeatmapDifficulty beatmapDifficulty)
: base(beatmapDifficulty)
{
AddInternal(droppedObjectContainer = new DroppedObjectContainer());
var droppedObjectContainer = new DroppedObjectContainer();
Add(droppedObjectContainer);
Catcher = new Catcher(droppedObjectContainer, beatmapDifficulty)
{
X = CatchPlayfield.CENTER_X
};
}
public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1);
public void ToggleHyperDash(bool status) => Catcher.SetHyperDashState(status ? 2 : 1);
}
}
}
@@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Catch.Tests
private bool playfieldIsEmpty => !((CatchPlayfield)drawableRuleset.Playfield).AllHitObjects.Any(h => h.IsAlive);
private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).CatcherArea.MovableCatcher.CurrentState;
private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).Catcher.CurrentState;
private void spawnFruits(bool hit = false)
{
@@ -162,7 +162,7 @@ namespace osu.Game.Rulesets.Catch.Tests
float xCoords = CatchPlayfield.CENTER_X;
if (drawableRuleset.Playfield is CatchPlayfield catchPlayfield)
catchPlayfield.CatcherArea.MovableCatcher.X = xCoords - x_offset;
catchPlayfield.Catcher.X = xCoords - x_offset;
if (hit)
xCoords -= x_offset;
@@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.Tests
// this needs to be done within the frame stable context due to how quickly hyperdash state changes occur.
Player.DrawableRuleset.FrameStableComponents.OnUpdate += d =>
{
var catcher = Player.ChildrenOfType<CatcherArea>().FirstOrDefault()?.MovableCatcher;
var catcher = Player.ChildrenOfType<Catcher>().FirstOrDefault();
if (catcher == null)
return;
@@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Catch.Tests
}
[Test]
public void TestCustomEndGlowColour()
public void TestCustomAfterImageColour()
{
var skin = new TestSkin
{
@@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Tests
}
[Test]
public void TestCustomEndGlowColourPriority()
public void TestCustomAfterImageColourPriority()
{
var skin = new TestSkin
{
@@ -111,38 +111,45 @@ namespace osu.Game.Rulesets.Catch.Tests
checkHyperDashFruitColour(skin, skin.HyperDashColour);
}
private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedEndGlowColour = null)
private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedAfterImageColour = null)
{
CatcherArea catcherArea = null;
CatcherTrailDisplay trails = null;
Catcher catcher = null;
AddStep("create hyper-dashing catcher", () =>
{
Child = setupSkinHierarchy(catcherArea = new TestCatcherArea
CatcherArea catcherArea;
Child = setupSkinHierarchy(new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
Child = catcherArea = new CatcherArea
{
Catcher = catcher = new Catcher(new DroppedObjectContainer())
{
Scale = new Vector2(4)
}
}
}, skin);
trails = catcherArea.ChildrenOfType<CatcherTrailDisplay>().Single();
});
AddStep("get trails container", () =>
AddStep("start hyper-dash", () =>
{
trails = catcherArea.OfType<CatcherTrailDisplay>().Single();
catcherArea.MovableCatcher.SetHyperDashState(2);
catcher.SetHyperDashState(2);
});
AddUntilStep("catcher colour is correct", () => catcherArea.MovableCatcher.Colour == expectedCatcherColour);
AddUntilStep("catcher colour is correct", () => catcher.Colour == expectedCatcherColour);
AddAssert("catcher trails colours are correct", () => trails.HyperDashTrailsColour == expectedCatcherColour);
AddAssert("catcher end-glow colours are correct", () => trails.EndGlowSpritesColour == (expectedEndGlowColour ?? expectedCatcherColour));
AddAssert("catcher after-image colours are correct", () => trails.HyperDashAfterImageColour == (expectedAfterImageColour ?? expectedCatcherColour));
AddStep("finish hyper-dashing", () =>
{
catcherArea.MovableCatcher.SetHyperDashState();
catcherArea.MovableCatcher.FinishTransforms();
catcher.SetHyperDashState();
catcher.FinishTransforms();
});
AddAssert("catcher colour returned to white", () => catcherArea.MovableCatcher.Colour == Color4.White);
AddAssert("catcher colour returned to white", () => catcher.Colour == Color4.White);
}
private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour)
@@ -205,18 +212,5 @@ namespace osu.Game.Rulesets.Catch.Tests
{
}
}
private class TestCatcherArea : CatcherArea
{
[Cached]
private readonly DroppedObjectContainer droppedObjectContainer;
public TestCatcherArea()
{
Scale = new Vector2(4f);
AddInternal(droppedObjectContainer = new DroppedObjectContainer());
}
}
}
}
+2 -1
View File
@@ -130,7 +130,8 @@ namespace osu.Game.Rulesets.Catch
return new Mod[]
{
new MultiMod(new ModWindUp(), new ModWindDown()),
new CatchModFloatingFruits()
new CatchModFloatingFruits(),
new CatchModMuted(),
};
default:
@@ -0,0 +1,190 @@
// 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.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public abstract class EditablePath : CompositeDrawable
{
public int PathId => path.InvalidationID;
public IReadOnlyList<JuiceStreamPathVertex> Vertices => path.Vertices;
public int VertexCount => path.Vertices.Count;
protected readonly Func<float, double> PositionToDistance;
protected IReadOnlyList<VertexState> VertexStates => vertexStates;
private readonly JuiceStreamPath path = new JuiceStreamPath();
// Invariant: `path.Vertices.Count == vertexStates.Count`
private readonly List<VertexState> vertexStates = new List<VertexState>
{
new VertexState { IsFixed = true }
};
private readonly List<VertexState> previousVertexStates = new List<VertexState>();
[Resolved(CanBeNull = true)]
[CanBeNull]
private IBeatSnapProvider beatSnapProvider { get; set; }
protected EditablePath(Func<float, double> positionToDistance)
{
PositionToDistance = positionToDistance;
Anchor = Anchor.BottomLeft;
}
public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject)
{
while (path.Vertices.Count < InternalChildren.Count)
RemoveInternal(InternalChildren[^1]);
while (InternalChildren.Count < path.Vertices.Count)
AddInternal(new VertexPiece());
double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity);
for (int i = 0; i < VertexCount; i++)
{
var piece = (VertexPiece)InternalChildren[i];
var vertex = path.Vertices[i];
piece.Position = new Vector2(vertex.X, (float)(vertex.Distance * distanceToYFactor));
piece.UpdateFrom(vertexStates[i]);
}
}
public void InitializeFromHitObject(JuiceStream hitObject)
{
var sliderPath = hitObject.Path;
path.ConvertFromSliderPath(sliderPath);
// If the original slider path has non-linear type segments, resample the vertices at nested hit object times to reduce the number of vertices.
if (sliderPath.ControlPoints.Any(p => p.Type.Value != null && p.Type.Value != PathType.Linear))
{
path.ResampleVertices(hitObject.NestedHitObjects
.Skip(1).TakeWhile(h => !(h is Fruit)) // Only droplets in the first span are used.
.Select(h => (h.StartTime - hitObject.StartTime) * hitObject.Velocity));
}
vertexStates.Clear();
vertexStates.AddRange(path.Vertices.Select((_, i) => new VertexState
{
IsFixed = i == 0
}));
}
public void UpdateHitObjectFromPath(JuiceStream hitObject)
{
path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY);
if (beatSnapProvider == null) return;
double endTime = hitObject.StartTime + path.Distance / hitObject.Velocity;
double snappedEndTime = beatSnapProvider.SnapTime(endTime, hitObject.StartTime);
hitObject.Path.ExpectedDistance.Value = (snappedEndTime - hitObject.StartTime) * hitObject.Velocity;
}
public Vector2 ToRelativePosition(Vector2 screenSpacePosition)
{
return ToLocalSpace(screenSpacePosition) - new Vector2(0, DrawHeight);
}
protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
protected int AddVertex(double distance, float x)
{
int index = path.InsertVertex(distance);
path.SetVertexPosition(index, x);
vertexStates.Insert(index, new VertexState());
correctFixedVertexPositions();
Debug.Assert(vertexStates.Count == VertexCount);
return index;
}
protected bool RemoveVertex(int index)
{
if (index < 0 || index >= path.Vertices.Count)
return false;
if (vertexStates[index].IsFixed)
return false;
path.RemoveVertices((_, i) => i == index);
vertexStates.RemoveAt(index);
if (vertexStates.Count == 0)
vertexStates.Add(new VertexState());
Debug.Assert(vertexStates.Count == VertexCount);
return true;
}
protected void MoveSelectedVertices(double distanceDelta, float xDelta)
{
// Because the vertex list may be reordered due to distance change, the state list must be reordered as well.
previousVertexStates.Clear();
previousVertexStates.AddRange(vertexStates);
// We will recreate the path from scratch. Note that `Clear` leaves the first vertex.
int vertexCount = VertexCount;
path.Clear();
vertexStates.RemoveRange(1, vertexCount - 1);
for (int i = 1; i < vertexCount; i++)
{
var state = previousVertexStates[i];
double distance = state.VertexBeforeChange.Distance;
if (state.IsSelected)
distance += distanceDelta;
int newIndex = path.InsertVertex(Math.Max(0, distance));
vertexStates.Insert(newIndex, state);
}
// First, restore positions of the non-selected vertices.
for (int i = 0; i < vertexCount; i++)
{
if (!vertexStates[i].IsSelected && !vertexStates[i].IsFixed)
path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X);
}
// Then, move the selected vertices.
for (int i = 0; i < vertexCount; i++)
{
if (vertexStates[i].IsSelected && !vertexStates[i].IsFixed)
path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X + xDelta);
}
// Finally, correct the position of fixed vertices.
correctFixedVertexPositions();
}
private void correctFixedVertexPositions()
{
for (int i = 0; i < VertexCount; i++)
{
if (vertexStates[i].IsFixed)
path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X);
}
}
}
}
@@ -0,0 +1,49 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Catch.Objects;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public class PlacementEditablePath : EditablePath
{
/// <summary>
/// The original position of the last added vertex.
/// This is not same as the last vertex of the current path because the vertex ordering can change.
/// </summary>
private JuiceStreamPathVertex lastVertex;
public PlacementEditablePath(Func<float, double> positionToDistance)
: base(positionToDistance)
{
}
public void AddNewVertex()
{
var endVertex = Vertices[^1];
int index = AddVertex(endVertex.Distance, endVertex.X);
for (int i = 0; i < VertexCount; i++)
{
VertexStates[i].IsSelected = i == index;
VertexStates[i].IsFixed = i != index;
VertexStates[i].VertexBeforeChange = Vertices[i];
}
lastVertex = Vertices[index];
}
/// <summary>
/// Move the vertex added by <see cref="AddNewVertex"/> in the last time.
/// </summary>
public void MoveLastVertex(Vector2 screenSpacePosition)
{
Vector2 position = ToRelativePosition(screenSpacePosition);
double distanceDelta = PositionToDistance(position.Y) - lastVertex.Distance;
float xDelta = position.X - lastVertex.X;
MoveSelectedVertices(distanceDelta, xDelta);
}
}
}
@@ -0,0 +1,130 @@
// 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 JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Edit;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public class SelectionEditablePath : EditablePath, IHasContextMenu
{
public MenuItem[] ContextMenuItems => getContextMenuItems().ToArray();
// To handle when the editor is scrolled while dragging.
private Vector2 dragStartPosition;
[Resolved(CanBeNull = true)]
[CanBeNull]
private IEditorChangeHandler changeHandler { get; set; }
public SelectionEditablePath(Func<float, double> positionToDistance)
: base(positionToDistance)
{
}
public void AddVertex(Vector2 relativePosition)
{
double distance = Math.Max(0, PositionToDistance(relativePosition.Y));
int index = AddVertex(distance, relativePosition.X);
selectOnly(index);
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => InternalChildren.Any(d => d.ReceivePositionalInputAt(screenSpacePos));
protected override bool OnMouseDown(MouseDownEvent e)
{
int index = getMouseTargetVertex(e.ScreenSpaceMouseDownPosition);
if (index == -1 || VertexStates[index].IsFixed)
return false;
if (e.Button == MouseButton.Left && e.ShiftPressed)
{
RemoveVertex(index);
return true;
}
if (e.ControlPressed)
VertexStates[index].IsSelected = !VertexStates[index].IsSelected;
else if (!VertexStates[index].IsSelected)
selectOnly(index);
// Don't inhibit right click, to show the context menu
return e.Button != MouseButton.Right;
}
protected override bool OnDragStart(DragStartEvent e)
{
int index = getMouseTargetVertex(e.ScreenSpaceMouseDownPosition);
if (index == -1 || VertexStates[index].IsFixed)
return false;
if (e.Button != MouseButton.Left)
return false;
dragStartPosition = ToRelativePosition(e.ScreenSpaceMouseDownPosition);
for (int i = 0; i < VertexCount; i++)
VertexStates[i].VertexBeforeChange = Vertices[i];
changeHandler?.BeginChange();
return true;
}
protected override void OnDrag(DragEvent e)
{
Vector2 mousePosition = ToRelativePosition(e.ScreenSpaceMousePosition);
double distanceDelta = PositionToDistance(mousePosition.Y) - PositionToDistance(dragStartPosition.Y);
float xDelta = mousePosition.X - dragStartPosition.X;
MoveSelectedVertices(distanceDelta, xDelta);
}
protected override void OnDragEnd(DragEndEvent e)
{
changeHandler?.EndChange();
}
private int getMouseTargetVertex(Vector2 screenSpacePosition)
{
for (int i = InternalChildren.Count - 1; i >= 0; i--)
{
if (i < VertexCount && InternalChildren[i].ReceivePositionalInputAt(screenSpacePosition))
return i;
}
return -1;
}
private IEnumerable<MenuItem> getContextMenuItems()
{
int selectedCount = VertexStates.Count(state => state.IsSelected);
if (selectedCount != 0)
yield return new OsuMenuItem($"Delete selected {(selectedCount == 1 ? "vertex" : $"{selectedCount} vertices")}", MenuItemType.Destructive, deleteSelectedVertices);
}
private void selectOnly(int index)
{
for (int i = 0; i < VertexCount; i++)
VertexStates[i].IsSelected = i == index;
}
private void deleteSelectedVertices()
{
for (int i = VertexCount - 1; i >= 0; i--)
{
if (VertexStates[i].IsSelected)
RemoveVertex(i);
}
}
}
}
@@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public class VertexPiece : Circle
{
[Resolved]
private OsuColour osuColour { get; set; }
public VertexPiece()
{
Anchor = Anchor.BottomLeft;
Origin = Anchor.Centre;
Size = new Vector2(15);
}
public void UpdateFrom(VertexState state)
{
Colour = state.IsSelected ? osuColour.Yellow.Lighten(1) : osuColour.Yellow;
Alpha = state.IsFixed ? 0.5f : 1;
}
}
}
@@ -0,0 +1,31 @@
// 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.Rulesets.Catch.Objects;
#nullable enable
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
/// <summary>
/// Holds the state of a vertex in the path of a <see cref="EditablePath"/>.
/// </summary>
public class VertexState
{
/// <summary>
/// Whether the vertex is selected.
/// </summary>
public bool IsSelected { get; set; }
/// <summary>
/// Whether the vertex can be moved or deleted.
/// </summary>
public bool IsFixed { get; set; }
/// <summary>
/// The position of the vertex before a vertex moving operation starts.
/// This is used to implement "memory-less" moving operations (only the final position matters) to improve UX.
/// </summary>
public JuiceStreamPathVertex VertexBeforeChange { get; set; }
}
}
@@ -0,0 +1,128 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
public class JuiceStreamPlacementBlueprint : CatchPlacementBlueprint<JuiceStream>
{
private readonly ScrollingPath scrollingPath;
private readonly NestedOutlineContainer nestedOutlineContainer;
private readonly PlacementEditablePath editablePath;
private int lastEditablePathId = -1;
private InputManager inputManager;
public JuiceStreamPlacementBlueprint()
{
InternalChildren = new Drawable[]
{
scrollingPath = new ScrollingPath(),
nestedOutlineContainer = new NestedOutlineContainer(),
editablePath = new PlacementEditablePath(positionToDistance)
};
}
protected override void Update()
{
base.Update();
if (PlacementActive == PlacementState.Active)
editablePath.UpdateFrom(HitObjectContainer, HitObject);
}
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
}
protected override bool OnMouseDown(MouseDownEvent e)
{
switch (PlacementActive)
{
case PlacementState.Waiting:
if (e.Button != MouseButton.Left) break;
editablePath.AddNewVertex();
BeginPlacement(true);
return true;
case PlacementState.Active:
switch (e.Button)
{
case MouseButton.Left:
editablePath.AddNewVertex();
return true;
case MouseButton.Right:
EndPlacement(HitObject.Duration > 0);
return true;
}
break;
}
return base.OnMouseDown(e);
}
public override void UpdateTimeAndPosition(SnapResult result)
{
switch (PlacementActive)
{
case PlacementState.Waiting:
if (!(result.Time is double snappedTime)) return;
HitObject.OriginalX = ToLocalSpace(result.ScreenSpacePosition).X;
HitObject.StartTime = snappedTime;
break;
case PlacementState.Active:
Vector2 unsnappedPosition = inputManager.CurrentState.Mouse.Position;
editablePath.MoveLastVertex(unsnappedPosition);
break;
default:
return;
}
// Make sure the up-to-date position is used for outlines.
Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition;
updateHitObjectFromPath();
}
private void updateHitObjectFromPath()
{
if (lastEditablePathId == editablePath.PathId)
return;
editablePath.UpdateHitObjectFromPath(HitObject);
ApplyDefaultsToHitObject();
scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject);
nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject);
lastEditablePathId = editablePath.PathId;
}
private double positionToDistance(float relativeYPosition)
{
double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime);
return (time - HitObject.StartTime) * HitObject.Velocity;
}
}
}
@@ -1,15 +1,22 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
@@ -17,6 +24,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
public override Quad SelectionQuad => HitObjectContainer.ToScreenSpace(getBoundingBox().Offset(new Vector2(0, HitObjectContainer.DrawHeight)));
public override MenuItem[] ContextMenuItems => getContextMenuItems().ToArray();
private float minNestedX;
private float maxNestedX;
@@ -26,13 +35,34 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
private readonly Cached pathCache = new Cached();
private readonly SelectionEditablePath editablePath;
/// <summary>
/// The <see cref="JuiceStreamPath.InvalidationID"/> of the <see cref="JuiceStreamPath"/> corresponding the current <see cref="SliderPath"/> of the hit object.
/// When the path is edited, the change is detected and the <see cref="SliderPath"/> of the hit object is updated.
/// </summary>
private int lastEditablePathId = -1;
/// <summary>
/// The <see cref="SliderPath.Version"/> of the current <see cref="SliderPath"/> of the hit object.
/// When the <see cref="SliderPath"/> of the hit object is changed by external means, the change is detected and the <see cref="JuiceStreamPath"/> is re-initialized.
/// </summary>
private int lastSliderPathVersion = -1;
private Vector2 rightMouseDownPosition;
[Resolved(CanBeNull = true)]
[CanBeNull]
private EditorBeatmap editorBeatmap { get; set; }
public JuiceStreamSelectionBlueprint(JuiceStream hitObject)
: base(hitObject)
{
InternalChildren = new Drawable[]
{
scrollingPath = new ScrollingPath(),
nestedOutlineContainer = new NestedOutlineContainer()
nestedOutlineContainer = new NestedOutlineContainer(),
editablePath = new SelectionEditablePath(positionToDistance)
};
}
@@ -49,7 +79,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
if (!IsSelected) return;
nestedOutlineContainer.Position = scrollingPath.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
if (editablePath.PathId != lastEditablePathId)
updateHitObjectFromPath();
Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition;
editablePath.UpdateFrom(HitObjectContainer, HitObject);
if (pathCache.IsValid) return;
@@ -59,10 +95,38 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
pathCache.Validate();
}
protected override void OnSelected()
{
initializeJuiceStreamPath();
base.OnSelected();
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (!IsSelected) return base.OnMouseDown(e);
switch (e.Button)
{
case MouseButton.Left when e.ControlPressed:
editablePath.AddVertex(editablePath.ToRelativePosition(e.ScreenSpaceMouseDownPosition));
return true;
case MouseButton.Right:
// Record the mouse position to be used in the "add vertex" action.
rightMouseDownPosition = editablePath.ToRelativePosition(e.ScreenSpaceMouseDownPosition);
break;
}
return base.OnMouseDown(e);
}
private void onDefaultsApplied(HitObject _)
{
computeObjectBounds();
pathCache.Invalidate();
if (lastSliderPathVersion != HitObject.Path.Version.Value)
initializeJuiceStreamPath();
}
private void computeObjectBounds()
@@ -81,6 +145,38 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
return new RectangleF(left, top, right - left, bottom - top).Inflate(objectRadius);
}
private double positionToDistance(float relativeYPosition)
{
double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime);
return (time - HitObject.StartTime) * HitObject.Velocity;
}
private void initializeJuiceStreamPath()
{
editablePath.InitializeFromHitObject(HitObject);
// Record the current ID to update the hit object only when a change is made to the path.
lastEditablePathId = editablePath.PathId;
lastSliderPathVersion = HitObject.Path.Version.Value;
}
private void updateHitObjectFromPath()
{
editablePath.UpdateHitObjectFromPath(HitObject);
editorBeatmap?.Update(HitObject);
lastEditablePathId = editablePath.PathId;
lastSliderPathVersion = HitObject.Path.Version.Value;
}
private IEnumerable<MenuItem> getContextMenuItems()
{
yield return new OsuMenuItem("Add vertex", MenuItemType.Standard, () =>
{
editablePath.AddVertex(rightMouseDownPosition);
});
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Edit
base.LoadComplete();
// TODO: honor "hit animation" setting?
CatcherArea.MovableCatcher.CatchFruitOnPlate = false;
Catcher.CatchFruitOnPlate = false;
// TODO: disable hit lighting as well
}
@@ -38,6 +38,7 @@ namespace osu.Game.Rulesets.Catch.Edit
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{
new FruitCompositionTool(),
new JuiceStreamCompositionTool(),
new BananaShowerCompositionTool()
};
@@ -1,7 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
@@ -20,5 +23,41 @@ namespace osu.Game.Rulesets.Catch.Edit
{
return new Vector2(hitObject.OriginalX, hitObjectContainer.PositionAtTime(hitObject.StartTime));
}
/// <summary>
/// Get the range of horizontal position occupied by the hit object.
/// </summary>
/// <remarks>
/// <see cref="TinyDroplet"/>s are excluded and returns <see cref="PositionRange.EMPTY"/>.
/// </remarks>
public static PositionRange GetPositionRange(HitObject hitObject)
{
switch (hitObject)
{
case Fruit fruit:
return new PositionRange(fruit.OriginalX);
case Droplet droplet:
return droplet is TinyDroplet ? PositionRange.EMPTY : new PositionRange(droplet.OriginalX);
case JuiceStream _:
return GetPositionRange(hitObject.NestedHitObjects);
case BananaShower _:
// A banana shower occupies the whole screen width.
return new PositionRange(0, CatchPlayfield.WIDTH);
default:
return PositionRange.EMPTY;
}
}
/// <summary>
/// Get the range of horizontal position occupied by the hit objects.
/// </summary>
/// <remarks>
/// <see cref="TinyDroplet"/>s are excluded.
/// </remarks>
public static PositionRange GetPositionRange(IEnumerable<HitObject> hitObjects) => hitObjects.Select(GetPositionRange).Aggregate(PositionRange.EMPTY, PositionRange.Union);
}
}
@@ -12,6 +12,7 @@ using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
using Direction = osu.Framework.Graphics.Direction;
namespace osu.Game.Rulesets.Catch.Edit
{
@@ -29,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Edit
Vector2 targetPosition = HitObjectContainer.ToLocalSpace(blueprint.ScreenSpaceSelectionPoint + moveEvent.ScreenSpaceDelta);
float deltaX = targetPosition.X - originalPosition.X;
deltaX = limitMovement(deltaX, EditorBeatmap.SelectedHitObjects);
deltaX = limitMovement(deltaX, SelectedItems);
if (deltaX == 0)
{
@@ -39,18 +40,60 @@ namespace osu.Game.Rulesets.Catch.Edit
EditorBeatmap.PerformOnSelection(h =>
{
if (!(h is CatchHitObject hitObject)) return;
if (!(h is CatchHitObject catchObject)) return;
hitObject.OriginalX += deltaX;
catchObject.OriginalX += deltaX;
// Move the nested hit objects to give an instant result before nested objects are recreated.
foreach (var nested in hitObject.NestedHitObjects.OfType<CatchHitObject>())
foreach (var nested in catchObject.NestedHitObjects.OfType<CatchHitObject>())
nested.OriginalX += deltaX;
});
return true;
}
public override bool HandleFlip(Direction direction)
{
var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems);
bool changed = false;
EditorBeatmap.PerformOnSelection(h =>
{
if (h is CatchHitObject catchObject)
changed |= handleFlip(selectionRange, catchObject);
});
return changed;
}
public override bool HandleReverse()
{
double selectionStartTime = SelectedItems.Min(h => h.StartTime);
double selectionEndTime = SelectedItems.Max(h => h.GetEndTime());
EditorBeatmap.PerformOnSelection(hitObject =>
{
hitObject.StartTime = selectionEndTime - (hitObject.GetEndTime() - selectionStartTime);
if (hitObject is JuiceStream juiceStream)
{
juiceStream.Path.Reverse(out Vector2 positionalOffset);
juiceStream.OriginalX += positionalOffset.X;
juiceStream.LegacyConvertedY += positionalOffset.Y;
EditorBeatmap.Update(juiceStream);
}
});
return true;
}
protected override void OnSelectionChanged()
{
base.OnSelectionChanged();
var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems);
SelectionBox.CanFlipX = selectionRange.Length > 0 && SelectedItems.Any(h => h is CatchHitObject && !(h is BananaShower));
SelectionBox.CanReverse = SelectedItems.Count > 1 || SelectedItems.Any(h => h is JuiceStream);
}
/// <summary>
/// Limit positional movement of the objects by the constraint that moved objects should stay in bounds.
/// </summary>
@@ -59,20 +102,12 @@ namespace osu.Game.Rulesets.Catch.Edit
/// <returns>The positional movement with the restriction applied.</returns>
private float limitMovement(float deltaX, IEnumerable<HitObject> movingObjects)
{
float minX = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
foreach (float x in movingObjects.SelectMany(getOriginalPositions))
{
minX = Math.Min(minX, x);
maxX = Math.Max(maxX, x);
}
var range = CatchHitObjectUtils.GetPositionRange(movingObjects);
// To make an object with position `x` stay in bounds after `deltaX` movement, `0 <= x + deltaX <= WIDTH` should be satisfied.
// Subtracting `x`, we get `-x <= deltaX <= WIDTH - x`.
// We only need to apply the inequality to extreme values of `x`.
float lowerBound = -minX;
float upperBound = CatchPlayfield.WIDTH - maxX;
float lowerBound = -range.Min;
float upperBound = CatchPlayfield.WIDTH - range.Max;
// The inequality may be unsatisfiable if the objects were already out of bounds.
// In that case, don't move objects at all.
if (lowerBound > upperBound)
@@ -81,35 +116,25 @@ namespace osu.Game.Rulesets.Catch.Edit
return Math.Clamp(deltaX, lowerBound, upperBound);
}
/// <summary>
/// Enumerate X positions that should be contained in-bounds after move offset is applied.
/// </summary>
private IEnumerable<float> getOriginalPositions(HitObject hitObject)
private bool handleFlip(PositionRange selectionRange, CatchHitObject hitObject)
{
switch (hitObject)
{
case Fruit fruit:
yield return fruit.OriginalX;
break;
case BananaShower _:
return false;
case JuiceStream juiceStream:
foreach (var nested in juiceStream.NestedHitObjects.OfType<CatchHitObject>())
{
// Even if `OriginalX` is outside the playfield, tiny droplets can be moved inside the playfield after the random offset application.
if (!(nested is TinyDroplet))
yield return nested.OriginalX;
}
juiceStream.OriginalX = selectionRange.GetFlippedPosition(juiceStream.OriginalX);
break;
foreach (var point in juiceStream.Path.ControlPoints)
point.Position.Value *= new Vector2(-1, 1);
case BananaShower _:
// A banana shower occupies the whole screen width.
// If the selection contains a banana shower, the selection cannot be moved horizontally.
yield return 0;
yield return CatchPlayfield.WIDTH;
EditorBeatmap.Update(juiceStream);
return true;
break;
default:
hitObject.OriginalX = selectionRange.GetFlippedPosition(hitObject.OriginalX);
return true;
}
}
}
@@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class JuiceStreamCompositionTool : HitObjectCompositionTool
{
public JuiceStreamCompositionTool()
: base(nameof(JuiceStream))
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
}
}
@@ -0,0 +1,42 @@
// 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;
#nullable enable
namespace osu.Game.Rulesets.Catch.Edit
{
/// <summary>
/// Represents either the empty range or a closed interval of horizontal positions in the playfield.
/// A <see cref="PositionRange"/> represents a closed interval if it is <see cref="Min"/> &lt;= <see cref="Max"/>, and represents the empty range otherwise.
/// </summary>
public readonly struct PositionRange
{
public readonly float Min;
public readonly float Max;
public float Length => Math.Max(0, Max - Min);
public PositionRange(float value)
: this(value, value)
{
}
public PositionRange(float min, float max)
{
Min = min;
Max = max;
}
public static PositionRange Union(PositionRange a, PositionRange b) => new PositionRange(Math.Min(a.Min, b.Min), Math.Max(a.Max, b.Max));
/// <summary>
/// Get the given position flipped (mirrored) for the axis at the center of this range.
/// Returns the given position unchanged if the range was empty.
/// </summary>
public float GetFlippedPosition(float x) => Min <= Max ? Max - (x - Min) : x;
public static readonly PositionRange EMPTY = new PositionRange(float.PositiveInfinity, float.NegativeInfinity);
}
}
@@ -41,9 +41,7 @@ namespace osu.Game.Rulesets.Catch.Mods
{
base.Update();
var catcherArea = playfield.CatcherArea;
FlashlightPosition = catcherArea.ToSpaceOfOtherDrawable(catcherArea.MovableCatcher.DrawPosition, this);
FlashlightPosition = playfield.CatcherArea.ToSpaceOfOtherDrawable(playfield.Catcher.DrawPosition, this);
}
private float getSizeFor(int combo)
@@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Catch.Mods
var drawableCatchRuleset = (DrawableCatchRuleset)drawableRuleset;
var catchPlayfield = (CatchPlayfield)drawableCatchRuleset.Playfield;
catchPlayfield.CatcherArea.MovableCatcher.CatchFruitOnPlate = false;
catchPlayfield.Catcher.CatchFruitOnPlate = false;
}
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
@@ -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.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModMuted : ModMuted<CatchHitObject>
{
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ using osu.Game.Audio;
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning;
using osu.Game.Utils;
using osuTK.Graphics;
@@ -31,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.Objects
}
// override any external colour changes with banananana
Color4 IHasComboInformation.GetComboColour(IReadOnlyList<Color4> comboColours) => getBananaColour();
Color4 IHasComboInformation.GetComboColour(ISkin skin) => getBananaColour();
private Color4 getBananaColour()
{
@@ -95,6 +95,14 @@ namespace osu.Game.Rulesets.Catch.Objects
set => ComboIndexBindable.Value = value;
}
public Bindable<int> ComboIndexWithOffsetsBindable { get; } = new Bindable<int>();
public int ComboIndexWithOffsets
{
get => ComboIndexWithOffsetsBindable.Value;
set => ComboIndexWithOffsetsBindable.Value = value;
}
public Bindable<bool> LastInComboBindable { get; } = new Bindable<bool>();
/// <summary>
@@ -1,10 +1,10 @@
// 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 Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.Objects
@@ -45,6 +45,6 @@ namespace osu.Game.Rulesets.Catch.Objects
}
}
Color4 IHasComboInformation.GetComboColour(IReadOnlyList<Color4> comboColours) => comboColours[(IndexInBeatmap + 1) % comboColours.Count];
Color4 IHasComboInformation.GetComboColour(ISkin skin) => IHasComboInformation.GetSkinComboColour(this, skin, IndexInBeatmap + 1);
}
}
@@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Replays
bool impossibleJump = speedRequired > movement_speed * 2;
// todo: get correct catcher size, based on difficulty CS.
const float catcher_width_half = CatcherArea.CATCHER_SIZE * 0.3f * 0.5f;
const float catcher_width_half = Catcher.BASE_SIZE * 0.3f * 0.5f;
if (lastPosition - catcher_width_half < h.EffectiveX && lastPosition + catcher_width_half > h.EffectiveX)
{
+31 -22
View File
@@ -26,38 +26,47 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
public const float CENTER_X = WIDTH / 2;
[Cached]
private readonly DroppedObjectContainer droppedObjectContainer;
internal readonly CatcherArea CatcherArea;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
// only check the X position; handle all vertical space.
base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y));
internal Catcher Catcher { get; private set; }
internal CatcherArea CatcherArea { get; private set; }
private readonly BeatmapDifficulty difficulty;
public CatchPlayfield(BeatmapDifficulty difficulty)
{
CatcherArea = new CatcherArea(difficulty)
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.TopLeft,
};
InternalChildren = new[]
{
droppedObjectContainer = new DroppedObjectContainer(),
CatcherArea.MovableCatcher.CreateProxiedContent(),
HitObjectContainer.CreateProxy(),
// This ordering (`CatcherArea` before `HitObjectContainer`) is important to
// make sure the up-to-date catcher position is used for the catcher catching logic of hit objects.
CatcherArea,
HitObjectContainer,
};
this.difficulty = difficulty;
}
[BackgroundDependencyLoader]
private void load()
{
var droppedObjectContainer = new DroppedObjectContainer();
Catcher = new Catcher(droppedObjectContainer, difficulty)
{
X = CENTER_X
};
AddRangeInternal(new[]
{
droppedObjectContainer,
Catcher.CreateProxiedContent(),
HitObjectContainer.CreateProxy(),
// This ordering (`CatcherArea` before `HitObjectContainer`) is important to
// make sure the up-to-date catcher position is used for the catcher catching logic of hit objects.
CatcherArea = new CatcherArea
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.TopLeft,
Catcher = Catcher,
},
HitObjectContainer,
});
RegisterPool<Droplet, DrawableDroplet>(50);
RegisterPool<TinyDroplet, DrawableTinyDroplet>(50);
RegisterPool<Fruit, DrawableFruit>(100);
@@ -80,7 +89,7 @@ namespace osu.Game.Rulesets.Catch.UI
((DrawableCatchHitObject)d).CheckPosition = checkIfWeCanCatch;
}
private bool checkIfWeCanCatch(CatchHitObject obj) => CatcherArea.MovableCatcher.CanCatch(obj);
private bool checkIfWeCanCatch(CatchHitObject obj) => Catcher.CanCatch(obj);
private void onNewResult(DrawableHitObject judgedObject, JudgementResult result)
=> CatcherArea.OnNewResult((DrawableCatchHitObject)judgedObject, result);
@@ -21,6 +21,6 @@ namespace osu.Game.Rulesets.Catch.UI
}
protected override ReplayFrame HandleFrame(Vector2 mousePosition, List<CatchAction> actions, ReplayFrame previousFrame)
=> new CatchReplayFrame(Time.Current, playfield.CatcherArea.MovableCatcher.X, actions.Contains(CatchAction.Dash), previousFrame as CatchReplayFrame);
=> new CatchReplayFrame(Time.Current, playfield.Catcher.X, actions.Contains(CatchAction.Dash), previousFrame as CatchReplayFrame);
}
}
+28 -67
View File
@@ -26,8 +26,17 @@ namespace osu.Game.Rulesets.Catch.UI
public class Catcher : SkinReloadableDrawable
{
/// <summary>
/// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail
/// and end glow/after-image during a hyper-dash.
/// The size of the catcher at 1x scale.
/// </summary>
public const float BASE_SIZE = 106.75f;
/// <summary>
/// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable.
/// </summary>
public const float ALLOWED_CATCH_RANGE = 0.8f;
/// <summary>
/// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail and after-image during a hyper-dash.
/// </summary>
public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red;
@@ -61,11 +70,6 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
private const float caught_fruit_scale_adjust = 0.5f;
[NotNull]
private readonly Container trailsTarget;
private CatcherTrailDisplay trails;
/// <summary>
/// Contains caught objects on the plate.
/// </summary>
@@ -74,40 +78,26 @@ namespace osu.Game.Rulesets.Catch.UI
/// <summary>
/// Contains objects dropped from the plate.
/// </summary>
[Resolved]
private DroppedObjectContainer droppedObjectTarget { get; set; }
private readonly DroppedObjectContainer droppedObjectTarget;
public CatcherAnimationState CurrentState
{
get => Body.AnimationState.Value;
private set => Body.AnimationState.Value = value;
get => body.AnimationState.Value;
private set => body.AnimationState.Value = value;
}
/// <summary>
/// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable.
/// Whether the catcher is currently dashing.
/// </summary>
public const float ALLOWED_CATCH_RANGE = 0.8f;
private bool dashing;
public bool Dashing
{
get => dashing;
set
{
if (value == dashing) return;
dashing = value;
updateTrailVisibility();
}
}
public bool Dashing { get; set; }
/// <summary>
/// The currently facing direction.
/// </summary>
public Direction VisualDirection { get; set; } = Direction.Right;
public Vector2 BodyScale => Scale * body.Scale;
/// <summary>
/// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed.
/// </summary>
@@ -118,10 +108,9 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
private readonly float catchWidth;
internal readonly SkinnableCatcher Body;
private readonly SkinnableCatcher body;
private Color4 hyperDashColour = DEFAULT_HYPER_DASH_COLOUR;
private Color4 hyperDashEndGlowColour = DEFAULT_HYPER_DASH_COLOUR;
private double hyperDashModifier = 1;
private int hyperDashDirection;
@@ -134,13 +123,13 @@ namespace osu.Game.Rulesets.Catch.UI
private readonly DrawablePool<CaughtBanana> caughtBananaPool;
private readonly DrawablePool<CaughtDroplet> caughtDropletPool;
public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null)
public Catcher([NotNull] DroppedObjectContainer droppedObjectTarget, BeatmapDifficulty difficulty = null)
{
this.trailsTarget = trailsTarget;
this.droppedObjectTarget = droppedObjectTarget;
Origin = Anchor.TopCentre;
Size = new Vector2(CatcherArea.CATCHER_SIZE);
Size = new Vector2(BASE_SIZE);
if (difficulty != null)
Scale = calculateScale(difficulty);
@@ -159,7 +148,7 @@ namespace osu.Game.Rulesets.Catch.UI
// offset fruit vertically to better place "above" the plate.
Y = -5
},
Body = new SkinnableCatcher(),
body = new SkinnableCatcher(),
hitExplosionContainer = new HitExplosionContainer
{
Anchor = Anchor.TopCentre,
@@ -172,15 +161,6 @@ namespace osu.Game.Rulesets.Catch.UI
private void load(OsuConfigManager config)
{
hitLighting = config.GetBindable<bool>(OsuSetting.HitLighting);
trails = new CatcherTrailDisplay(this);
}
protected override void LoadComplete()
{
base.LoadComplete();
// don't add in above load as we may potentially modify a parent in an unsafe manner.
trailsTarget.Add(trails);
}
/// <summary>
@@ -197,7 +177,7 @@ namespace osu.Game.Rulesets.Catch.UI
/// Calculates the width of the area used for attempting catches in gameplay.
/// </summary>
/// <param name="scale">The scale of the catcher.</param>
public static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE;
public static float CalculateCatchWidth(Vector2 scale) => BASE_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE;
/// <summary>
/// Calculates the width of the area used for attempting catches in gameplay.
@@ -213,14 +193,9 @@ namespace osu.Game.Rulesets.Catch.UI
if (!(hitObject is PalpableCatchHitObject fruit))
return false;
var halfCatchWidth = catchWidth * 0.5f;
// this stuff wil disappear once we move fruit to non-relative coordinate space in the future.
var catchObjectPosition = fruit.EffectiveX;
var catcherPosition = Position.X;
return catchObjectPosition >= catcherPosition - halfCatchWidth &&
catchObjectPosition <= catcherPosition + halfCatchWidth;
float halfCatchWidth = catchWidth * 0.5f;
return fruit.EffectiveX >= X - halfCatchWidth &&
fruit.EffectiveX <= X + halfCatchWidth;
}
public void OnNewResult(DrawableCatchHitObject drawableObject, JudgementResult result)
@@ -307,10 +282,7 @@ namespace osu.Game.Rulesets.Catch.UI
hyperDashTargetPosition = targetPosition;
if (!wasHyperDashing)
{
trails.DisplayEndGlow();
runHyperDashStateTransition(true);
}
}
}
@@ -326,13 +298,9 @@ namespace osu.Game.Rulesets.Catch.UI
private void runHyperDashStateTransition(bool hyperDashing)
{
updateTrailVisibility();
this.FadeColour(hyperDashing ? hyperDashColour : Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint);
}
private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing;
protected override void SkinChanged(ISkinSource skin)
{
base.SkinChanged(skin);
@@ -341,13 +309,6 @@ namespace osu.Game.Rulesets.Catch.UI
skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDash)?.Value ??
DEFAULT_HYPER_DASH_COLOUR;
hyperDashEndGlowColour =
skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDashAfterImage)?.Value ??
hyperDashColour;
trails.HyperDashTrailsColour = hyperDashColour;
trails.EndGlowSpritesColour = hyperDashEndGlowColour;
flipCatcherPlate = skin.GetConfig<CatchSkinConfiguration, bool>(CatchSkinConfiguration.FlipCatcherPlate)?.Value ?? true;
runHyperDashStateTransition(HyperDashing);
@@ -358,7 +319,7 @@ namespace osu.Game.Rulesets.Catch.UI
base.Update();
var scaleFromDirection = new Vector2((int)VisualDirection, 1);
Body.Scale = scaleFromDirection;
body.Scale = scaleFromDirection;
caughtObjectContainer.Scale = hitExplosionContainer.Scale = flipCatcherPlate ? scaleFromDirection : Vector2.One;
// Correct overshooting.
+62 -19
View File
@@ -5,7 +5,6 @@ using System;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Replays;
@@ -16,13 +15,27 @@ using osuTK;
namespace osu.Game.Rulesets.Catch.UI
{
/// <summary>
/// The horizontal band at the bottom of the playfield the catcher is moving on.
/// It holds a <see cref="Catcher"/> as a child and translates input to the catcher movement.
/// It also holds a combo display that is above the catcher, and judgment results are translated to the catcher and the combo display.
/// </summary>
public class CatcherArea : Container, IKeyBindingHandler<CatchAction>
{
public const float CATCHER_SIZE = 106.75f;
public Catcher Catcher
{
get => catcher;
set => catcherContainer.Child = catcher = value;
}
private readonly Container<Catcher> catcherContainer;
public readonly Catcher MovableCatcher;
private readonly CatchComboDisplay comboDisplay;
private readonly CatcherTrailDisplay catcherTrails;
private Catcher catcher;
/// <summary>
/// <c>-1</c> when only left button is pressed.
/// <c>1</c> when only right button is pressed.
@@ -30,11 +43,19 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
private int currentDirection;
public CatcherArea(BeatmapDifficulty difficulty = null)
// TODO: support replay rewind
private bool lastHyperDashState;
/// <remarks>
/// <see cref="Catcher"/> must be set before loading.
/// </remarks>
public CatcherArea()
{
Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE);
Size = new Vector2(CatchPlayfield.WIDTH, Catcher.BASE_SIZE);
Children = new Drawable[]
{
catcherContainer = new Container<Catcher> { RelativeSizeAxes = Axes.Both },
catcherTrails = new CatcherTrailDisplay(),
comboDisplay = new CatchComboDisplay
{
RelativeSizeAxes = Axes.None,
@@ -43,14 +64,13 @@ namespace osu.Game.Rulesets.Catch.UI
Origin = Anchor.Centre,
Margin = new MarginPadding { Bottom = 350f },
X = CatchPlayfield.CENTER_X
},
MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X },
}
};
}
public void OnNewResult(DrawableCatchHitObject hitObject, JudgementResult result)
{
MovableCatcher.OnNewResult(hitObject, result);
Catcher.OnNewResult(hitObject, result);
if (!result.Type.IsScorable())
return;
@@ -58,9 +78,9 @@ namespace osu.Game.Rulesets.Catch.UI
if (hitObject.HitObject.LastInCombo)
{
if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result))
MovableCatcher.Explode();
Catcher.Explode();
else
MovableCatcher.Drop();
Catcher.Drop();
}
comboDisplay.OnNewResult(hitObject, result);
@@ -69,7 +89,7 @@ namespace osu.Game.Rulesets.Catch.UI
public void OnRevertResult(DrawableCatchHitObject hitObject, JudgementResult result)
{
comboDisplay.OnRevertResult(hitObject, result);
MovableCatcher.OnRevertResult(hitObject, result);
Catcher.OnRevertResult(hitObject, result);
}
protected override void Update()
@@ -80,27 +100,48 @@ namespace osu.Game.Rulesets.Catch.UI
SetCatcherPosition(
replayState?.CatcherX ??
(float)(MovableCatcher.X + MovableCatcher.Speed * currentDirection * Clock.ElapsedFrameTime));
(float)(Catcher.X + Catcher.Speed * currentDirection * Clock.ElapsedFrameTime));
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
comboDisplay.X = MovableCatcher.X;
comboDisplay.X = Catcher.X;
if (Time.Elapsed <= 0)
{
// This is probably a wrong value, but currently the true value is not recorded.
// Setting `true` will prevent generation of false-positive after-images (with more false-negatives).
lastHyperDashState = true;
return;
}
if (!lastHyperDashState && Catcher.HyperDashing)
displayCatcherTrail(CatcherTrailAnimation.HyperDashAfterImage);
if (Catcher.Dashing || Catcher.HyperDashing)
{
double generationInterval = Catcher.HyperDashing ? 25 : 50;
if (Time.Current - catcherTrails.LastDashTrailTime >= generationInterval)
displayCatcherTrail(Catcher.HyperDashing ? CatcherTrailAnimation.HyperDashing : CatcherTrailAnimation.Dashing);
}
lastHyperDashState = Catcher.HyperDashing;
}
public void SetCatcherPosition(float X)
{
float lastPosition = MovableCatcher.X;
float lastPosition = Catcher.X;
float newPosition = Math.Clamp(X, 0, CatchPlayfield.WIDTH);
MovableCatcher.X = newPosition;
Catcher.X = newPosition;
if (lastPosition < newPosition)
MovableCatcher.VisualDirection = Direction.Right;
Catcher.VisualDirection = Direction.Right;
else if (lastPosition > newPosition)
MovableCatcher.VisualDirection = Direction.Left;
Catcher.VisualDirection = Direction.Left;
}
public bool OnPressed(CatchAction action)
@@ -116,7 +157,7 @@ namespace osu.Game.Rulesets.Catch.UI
return true;
case CatchAction.Dash:
MovableCatcher.Dashing = true;
Catcher.Dashing = true;
return true;
}
@@ -136,9 +177,11 @@ namespace osu.Game.Rulesets.Catch.UI
break;
case CatchAction.Dash:
MovableCatcher.Dashing = false;
Catcher.Dashing = false;
break;
}
}
private void displayCatcherTrail(CatcherTrailAnimation animation) => catcherTrails.Add(new CatcherTrailEntry(Time.Current, Catcher.CurrentState, Catcher.X, Catcher.BodyScale, animation));
}
}
+35 -11
View File
@@ -2,8 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Timing;
using osu.Game.Rulesets.Objects.Pooling;
using osuTK;
namespace osu.Game.Rulesets.Catch.UI
@@ -12,18 +12,13 @@ namespace osu.Game.Rulesets.Catch.UI
/// A trail of the catcher.
/// It also represents a hyper dash afterimage.
/// </summary>
public class CatcherTrail : PoolableDrawable
public class CatcherTrail : PoolableDrawableWithLifetime<CatcherTrailEntry>
{
public CatcherAnimationState AnimationState
{
set => body.AnimationState.Value = value;
}
private readonly SkinnableCatcher body;
public CatcherTrail()
{
Size = new Vector2(CatcherArea.CATCHER_SIZE);
Size = new Vector2(Catcher.BASE_SIZE);
Origin = Anchor.TopCentre;
Blending = BlendingParameters.Additive;
InternalChild = body = new SkinnableCatcher
@@ -34,11 +29,40 @@ namespace osu.Game.Rulesets.Catch.UI
};
}
protected override void FreeAfterUse()
protected override void OnApply(CatcherTrailEntry entry)
{
Position = new Vector2(entry.Position, 0);
Scale = entry.Scale;
body.AnimationState.Value = entry.CatcherState;
using (BeginAbsoluteSequence(entry.LifetimeStart, false))
applyTransforms(entry.Animation);
}
protected override void OnFree(CatcherTrailEntry entry)
{
ApplyTransformsAt(double.MinValue);
ClearTransforms();
Alpha = 1;
base.FreeAfterUse();
}
private void applyTransforms(CatcherTrailAnimation animation)
{
switch (animation)
{
case CatcherTrailAnimation.Dashing:
case CatcherTrailAnimation.HyperDashing:
this.FadeTo(0.4f).FadeOut(800, Easing.OutQuint);
break;
case CatcherTrailAnimation.HyperDashAfterImage:
this.MoveToOffset(new Vector2(0, -10), 1200, Easing.In);
this.ScaleTo(Scale * 0.95f).ScaleTo(Scale * 1.2f, 1200, Easing.In);
this.FadeOut(1200);
break;
}
Expire();
}
}
}
@@ -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.
namespace osu.Game.Rulesets.Catch.UI
{
public enum CatcherTrailAnimation
{
Dashing,
HyperDashing,
HyperDashAfterImage
}
}
@@ -2,11 +2,13 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osuTK;
using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Objects.Pooling;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.UI
@@ -15,70 +17,32 @@ namespace osu.Game.Rulesets.Catch.UI
/// Represents a component responsible for displaying
/// the appropriate catcher trails when requested to.
/// </summary>
public class CatcherTrailDisplay : CompositeDrawable
public class CatcherTrailDisplay : PooledDrawableWithLifetimeContainer<CatcherTrailEntry, CatcherTrail>
{
private readonly Catcher catcher;
/// <summary>
/// The most recent time a dash trail was added to this container.
/// Only alive (not faded out) trails are considered.
/// Returns <see cref="double.NegativeInfinity"/> if no dash trail is alive.
/// </summary>
public double LastDashTrailTime => getLastDashTrailTime();
public Color4 HyperDashTrailsColour => hyperDashTrails.Colour;
public Color4 HyperDashAfterImageColour => hyperDashAfterImages.Colour;
protected override bool RemoveRewoundEntry => true;
private readonly DrawablePool<CatcherTrail> trailPool;
private readonly Container<CatcherTrail> dashTrails;
private readonly Container<CatcherTrail> hyperDashTrails;
private readonly Container<CatcherTrail> endGlowSprites;
private readonly Container<CatcherTrail> hyperDashAfterImages;
private Color4 hyperDashTrailsColour = Catcher.DEFAULT_HYPER_DASH_COLOUR;
[Resolved]
private ISkinSource skin { get; set; }
public Color4 HyperDashTrailsColour
public CatcherTrailDisplay()
{
get => hyperDashTrailsColour;
set
{
if (hyperDashTrailsColour == value)
return;
hyperDashTrailsColour = value;
hyperDashTrails.Colour = hyperDashTrailsColour;
}
}
private Color4 endGlowSpritesColour = Catcher.DEFAULT_HYPER_DASH_COLOUR;
public Color4 EndGlowSpritesColour
{
get => endGlowSpritesColour;
set
{
if (endGlowSpritesColour == value)
return;
endGlowSpritesColour = value;
endGlowSprites.Colour = endGlowSpritesColour;
}
}
private bool trail;
/// <summary>
/// Whether to start displaying trails following the catcher.
/// </summary>
public bool DisplayTrail
{
get => trail;
set
{
if (trail == value)
return;
trail = value;
if (trail)
displayTrail();
}
}
public CatcherTrailDisplay([NotNull] Catcher catcher)
{
this.catcher = catcher ?? throw new ArgumentNullException(nameof(catcher));
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
@@ -86,47 +50,86 @@ namespace osu.Game.Rulesets.Catch.UI
trailPool = new DrawablePool<CatcherTrail>(30),
dashTrails = new Container<CatcherTrail> { RelativeSizeAxes = Axes.Both },
hyperDashTrails = new Container<CatcherTrail> { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
endGlowSprites = new Container<CatcherTrail> { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
hyperDashAfterImages = new Container<CatcherTrail> { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
};
}
/// <summary>
/// Displays a single end-glow catcher sprite.
/// </summary>
public void DisplayEndGlow()
protected override void LoadComplete()
{
var endGlow = createTrailSprite(endGlowSprites);
base.LoadComplete();
endGlow.MoveToOffset(new Vector2(0, -10), 1200, Easing.In);
endGlow.ScaleTo(endGlow.Scale * 0.95f).ScaleTo(endGlow.Scale * 1.2f, 1200, Easing.In);
endGlow.FadeOut(1200);
endGlow.Expire(true);
skin.SourceChanged += skinSourceChanged;
skinSourceChanged();
}
private void displayTrail()
private void skinSourceChanged()
{
if (!DisplayTrail)
return;
var sprite = createTrailSprite(catcher.HyperDashing ? hyperDashTrails : dashTrails);
sprite.FadeTo(0.4f).FadeOut(800, Easing.OutQuint);
sprite.Expire(true);
Scheduler.AddDelayed(displayTrail, catcher.HyperDashing ? 25 : 50);
hyperDashTrails.Colour = skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDash)?.Value ?? Catcher.DEFAULT_HYPER_DASH_COLOUR;
hyperDashAfterImages.Colour = skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDashAfterImage)?.Value ?? hyperDashTrails.Colour;
}
private CatcherTrail createTrailSprite(Container<CatcherTrail> target)
protected override void AddDrawable(CatcherTrailEntry entry, CatcherTrail drawable)
{
CatcherTrail sprite = trailPool.Get();
switch (entry.Animation)
{
case CatcherTrailAnimation.Dashing:
dashTrails.Add(drawable);
break;
sprite.AnimationState = catcher.CurrentState;
sprite.Scale = catcher.Scale * catcher.Body.Scale;
sprite.Position = catcher.Position;
case CatcherTrailAnimation.HyperDashing:
hyperDashTrails.Add(drawable);
break;
target.Add(sprite);
case CatcherTrailAnimation.HyperDashAfterImage:
hyperDashAfterImages.Add(drawable);
break;
}
}
return sprite;
protected override void RemoveDrawable(CatcherTrailEntry entry, CatcherTrail drawable)
{
switch (entry.Animation)
{
case CatcherTrailAnimation.Dashing:
dashTrails.Remove(drawable);
break;
case CatcherTrailAnimation.HyperDashing:
hyperDashTrails.Remove(drawable);
break;
case CatcherTrailAnimation.HyperDashAfterImage:
hyperDashAfterImages.Remove(drawable);
break;
}
}
protected override CatcherTrail GetDrawable(CatcherTrailEntry entry)
{
CatcherTrail trail = trailPool.Get();
trail.Apply(entry);
return trail;
}
private double getLastDashTrailTime()
{
double maxTime = double.NegativeInfinity;
foreach (var trail in dashTrails)
maxTime = Math.Max(maxTime, trail.LifetimeStart);
foreach (var trail in hyperDashTrails)
maxTime = Math.Max(maxTime, trail.LifetimeStart);
return maxTime;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (skin != null)
skin.SourceChanged -= skinSourceChanged;
}
}
}
@@ -0,0 +1,31 @@
// 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.Performance;
using osuTK;
namespace osu.Game.Rulesets.Catch.UI
{
public class CatcherTrailEntry : LifetimeEntry
{
public readonly CatcherAnimationState CatcherState;
public readonly float Position;
/// <summary>
/// The scaling of the catcher body. It also represents a flipped catcher (negative x component).
/// </summary>
public readonly Vector2 Scale;
public readonly CatcherTrailAnimation Animation;
public CatcherTrailEntry(double startTime, CatcherAnimationState catcherState, float position, Vector2 scale, CatcherTrailAnimation animation)
{
LifetimeStart = startTime;
CatcherState = catcherState;
Position = position;
Scale = scale;
Animation = animation;
}
}
}
@@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Catch.UI
{
Anchor = Anchor.TopCentre;
// Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling.
OriginPosition = new Vector2(0.5f, 0.06f) * CatcherArea.CATCHER_SIZE;
OriginPosition = new Vector2(0.5f, 0.06f) * Catcher.BASE_SIZE;
}
}
}
+2 -1
View File
@@ -253,7 +253,8 @@ namespace osu.Game.Rulesets.Mania
case ModType.Fun:
return new Mod[]
{
new MultiMod(new ModWindUp(), new ModWindDown())
new MultiMod(new ModWindUp(), new ModWindDown()),
new ManiaModMuted(),
};
default:
@@ -10,13 +10,9 @@ using osu.Game.Rulesets.Mania.Beatmaps;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModMirror : Mod, IApplicableToBeatmap
public class ManiaModMirror : ModMirror, IApplicableToBeatmap
{
public override string Name => "Mirror";
public override string Acronym => "MR";
public override ModType Type => ModType.Conversion;
public override string Description => "Notes are flipped horizontally.";
public override double ScoreMultiplier => 1;
public void ApplyToBeatmap(IBeatmap beatmap)
{
@@ -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.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModMuted : ModMuted<ManiaHitObject>
{
}
}
@@ -0,0 +1,52 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Tests.Mods
{
public class TestSceneOsuModMuted : OsuModTestScene
{
/// <summary>
/// Ensures that a final volume combo of 0 (i.e. "always muted" mode) constantly plays metronome and completely mutes track.
/// </summary>
[Test]
public void TestZeroFinalCombo() => CreateModTest(new ModTestData
{
Mod = new OsuModMuted
{
MuteComboCount = { Value = 0 },
},
PassCondition = () => Beatmap.Value.Track.AggregateVolume.Value == 0.0 &&
Player.ChildrenOfType<Metronome>().SingleOrDefault()?.AggregateVolume.Value == 1.0,
});
/// <summary>
/// Ensures that copying from a normal mod with 0 final combo while originally inversed does not yield incorrect results.
/// </summary>
[Test]
public void TestModCopy()
{
OsuModMuted muted = null;
AddStep("create inversed mod", () => muted = new OsuModMuted
{
MuteComboCount = { Value = 100 },
InverseMuting = { Value = true },
});
AddStep("copy from normal", () => muted.CopyFrom(new OsuModMuted
{
MuteComboCount = { Value = 0 },
InverseMuting = { Value = false },
}));
AddAssert("mute combo count = 0", () => muted.MuteComboCount.Value == 0);
AddAssert("inverse muting = false", () => muted.InverseMuting.Value == false);
}
}
}
@@ -15,13 +15,13 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
[TestCase(6.9311451172574934d, "diffcalc-test")]
[TestCase(1.0736586907780401d, "zero-length-sliders")]
[TestCase(6.7568168283591499d, "diffcalc-test")]
[TestCase(1.0348244046058293d, "zero-length-sliders")]
public void Test(double expected, string name)
=> base.Test(expected, name);
[TestCase(8.7212283220412345d, "diffcalc-test")]
[TestCase(1.3212137158641493d, "zero-length-sliders")]
[TestCase(8.4783236764532557d, "diffcalc-test")]
[TestCase(1.2708532136987165d, "zero-length-sliders")]
public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new OsuModDoubleTime());
@@ -1,16 +1,21 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Skinning;
using osu.Game.Tests.Beatmaps;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Tests
{
@@ -77,23 +82,106 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("is custom user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestSkin.Colours));
}
[TestCase(true, true)]
[TestCase(true, false)]
[TestCase(false, true)]
[TestCase(false, false)]
public void TestComboOffsetWithBeatmapColours(bool userHasCustomColours, bool useBeatmapSkin)
{
PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true, getHitCirclesWithLegacyOffsets()));
ConfigureTest(useBeatmapSkin, true, userHasCustomColours);
assertCorrectObjectComboColours("is beatmap skin colours with combo offsets applied",
TestBeatmapSkin.Colours,
(i, obj) => i + 1 + obj.ComboOffset);
}
[TestCase(true)]
[TestCase(false)]
public void TestComboOffsetWithIgnoredBeatmapColours(bool useBeatmapSkin)
{
PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true, getHitCirclesWithLegacyOffsets()));
ConfigureTest(useBeatmapSkin, false, true);
assertCorrectObjectComboColours("is user skin colours without combo offsets applied",
TestSkin.Colours,
(i, _) => i + 1);
}
private void assertCorrectObjectComboColours(string description, Color4[] expectedColours, Func<int, OsuHitObject, int> nextExpectedComboIndex)
{
AddUntilStep("wait for objects to become alive", () =>
TestPlayer.DrawableRuleset.Playfield.AllHitObjects.Count() == TestPlayer.DrawableRuleset.Objects.Count());
AddAssert(description, () =>
{
int index = 0;
return TestPlayer.DrawableRuleset.Playfield.AllHitObjects.All(d =>
{
index = nextExpectedComboIndex(index, (OsuHitObject)d.HitObject);
return checkComboColour(d, expectedColours[index % expectedColours.Length]);
});
});
static bool checkComboColour(DrawableHitObject drawableHitObject, Color4 expectedColour)
{
return drawableHitObject.AccentColour.Value == expectedColour &&
drawableHitObject.NestedHitObjects.All(n => checkComboColour(n, expectedColour));
}
}
private static IEnumerable<OsuHitObject> getHitCirclesWithLegacyOffsets()
{
var hitObjects = new List<OsuHitObject>();
for (int i = 0; i < 10; i++)
{
var hitObject = i % 2 == 0
? (OsuHitObject)new HitCircle()
: new Slider
{
Path = new SliderPath(new[]
{
new PathControlPoint(new Vector2(0, 0)),
new PathControlPoint(new Vector2(100, 0)),
})
};
hitObject.StartTime = i;
hitObject.Position = new Vector2(256, 192);
hitObject.NewCombo = true;
hitObject.ComboOffset = i;
hitObjects.Add(hitObject);
}
return hitObjects;
}
private class OsuCustomSkinWorkingBeatmap : CustomSkinWorkingBeatmap
{
public OsuCustomSkinWorkingBeatmap(AudioManager audio, bool hasColours)
: base(createBeatmap(), audio, hasColours)
public OsuCustomSkinWorkingBeatmap(AudioManager audio, bool hasColours, IEnumerable<OsuHitObject> hitObjects = null)
: base(createBeatmap(hitObjects), audio, hasColours)
{
}
private static IBeatmap createBeatmap() =>
new Beatmap
private static IBeatmap createBeatmap(IEnumerable<OsuHitObject> hitObjects)
{
var beatmap = new Beatmap
{
BeatmapInfo =
{
BeatmapSet = new BeatmapSetInfo(),
Ruleset = new OsuRuleset().RulesetInfo,
},
HitObjects = { new HitCircle { Position = new Vector2(256, 192) } }
};
beatmap.HitObjects.AddRange(hitObjects ?? new[]
{
new HitCircle { Position = new Vector2(256, 192) }
});
return beatmap;
}
}
}
}
@@ -103,22 +103,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double approachRateTotalHitsFactor = 1.0 / (1.0 + Math.Exp(-(0.007 * (totalHits - 400))));
aimValue *= 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor;
double approachRateBonus = 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor;
// We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
if (mods.Any(h => h is OsuModHidden))
aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate);
double flashlightBonus = 1.0;
if (mods.Any(h => h is OsuModFlashlight))
{
// Apply object-based bonus for flashlight.
aimValue *= 1.0 + 0.35 * Math.Min(1.0, totalHits / 200.0) +
(totalHits > 200
? 0.3 * Math.Min(1.0, (totalHits - 200) / 300.0) +
(totalHits > 500 ? (totalHits - 500) / 1200.0 : 0.0)
: 0.0);
flashlightBonus = 1.0 + 0.35 * Math.Min(1.0, totalHits / 200.0) +
(totalHits > 200
? 0.3 * Math.Min(1.0, (totalHits - 200) / 300.0) +
(totalHits > 500 ? (totalHits - 500) / 1200.0 : 0.0)
: 0.0);
}
aimValue *= Math.Max(flashlightBonus, approachRateBonus);
// Scale the aim value with accuracy _slightly_
aimValue *= 0.5 + accuracy / 2.0;
// It is important to also consider accuracy difficulty when doing that
@@ -3,7 +3,6 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// <summary>
/// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
/// </summary>
public class Aim : StrainSkill
public class Aim : OsuStrainSkill
{
private const double angle_bonus_begin = Math.PI / 3;
private const double timing_threshold = 107;
@@ -47,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
Math.Max(osuPrevious.JumpDistance - scale, 0)
* Math.Pow(Math.Sin(osuCurrent.Angle.Value - angle_bonus_begin), 2)
* Math.Max(osuCurrent.JumpDistance - scale, 0));
result = 1.5 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime);
result = 1.4 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime);
}
}
@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using System.Linq;
using osu.Framework.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
public abstract class OsuStrainSkill : StrainSkill
{
/// <summary>
/// The number of sections with the highest strains, which the peak strain reductions will apply to.
/// This is done in order to decrease their impact on the overall difficulty of the map for this skill.
/// </summary>
protected virtual int ReducedSectionCount => 10;
/// <summary>
/// The baseline multiplier applied to the section with the biggest strain.
/// </summary>
protected virtual double ReducedStrainBaseline => 0.75;
/// <summary>
/// The final multiplier to be applied to <see cref="DifficultyValue"/> after all other calculations.
/// </summary>
protected virtual double DifficultyMultiplier => 1.06;
protected OsuStrainSkill(Mod[] mods)
: base(mods)
{
}
public override double DifficultyValue()
{
double difficulty = 0;
double weight = 1;
List<double> strains = GetCurrentStrainPeaks().OrderByDescending(d => d).ToList();
// We are reducing the highest strains first to account for extreme difficulty spikes
for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++)
{
double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / ReducedSectionCount, 0, 1)));
strains[i] *= Interpolation.Lerp(ReducedStrainBaseline, 1.0, scale);
}
// Difficulty is the weighted sum of the highest strains from every section.
// We're sorting from highest to lowest strain.
foreach (double strain in strains.OrderByDescending(d => d))
{
difficulty += strain * weight;
weight *= DecayWeight;
}
return difficulty * DifficultyMultiplier;
}
}
}
@@ -3,7 +3,6 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// <summary>
/// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit.
/// </summary>
public class Speed : StrainSkill
public class Speed : OsuStrainSkill
{
private const double single_spacing_threshold = 125;
@@ -23,6 +22,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override double SkillMultiplier => 1400;
protected override double StrainDecayBase => 0.3;
protected override int ReducedSectionCount => 5;
protected override double DifficultyMultiplier => 1.04;
private const double min_speed_bonus = 75; // ~200BPM
private const double max_speed_bonus = 45; // ~330BPM
@@ -129,9 +129,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public bool OnPressed(PlatformAction action)
{
switch (action.ActionMethod)
switch (action)
{
case PlatformActionMethod.Delete:
case PlatformAction.Delete:
return DeleteSelected();
}
@@ -35,8 +35,8 @@ namespace osu.Game.Rulesets.Osu.Edit
Quad quad = selectedMovableObjects.Length > 0 ? getSurroundingQuad(selectedMovableObjects) : new Quad();
SelectionBox.CanRotate = quad.Width > 0 || quad.Height > 0;
SelectionBox.CanScaleX = quad.Width > 0;
SelectionBox.CanScaleY = quad.Height > 0;
SelectionBox.CanFlipX = SelectionBox.CanScaleX = quad.Width > 0;
SelectionBox.CanFlipY = SelectionBox.CanScaleY = quad.Height > 0;
SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider);
}
@@ -76,32 +76,8 @@ namespace osu.Game.Rulesets.Osu.Edit
if (h is Slider slider)
{
var points = slider.Path.ControlPoints.ToArray();
Vector2 endPos = points.Last().Position.Value;
slider.Path.ControlPoints.Clear();
slider.Position += endPos;
PathType? lastType = null;
for (var i = 0; i < points.Length; i++)
{
var p = points[i];
p.Position.Value -= endPos;
// propagate types forwards to last null type
if (i == points.Length - 1)
p.Type.Value = lastType;
else if (p.Type.Value != null)
{
var newType = p.Type.Value;
p.Type.Value = lastType;
lastType = newType;
}
slider.Path.ControlPoints.Insert(0, p);
}
slider.Path.Reverse(out Vector2 offset);
slider.Position += offset;
}
}
+5 -16
View File
@@ -1,13 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osuTK;
using osu.Game.Rulesets.Osu.Utils;
namespace osu.Game.Rulesets.Osu.Mods
{
@@ -15,23 +14,13 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public override double ScoreMultiplier => 1.06;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModMirror)).ToArray();
public void ApplyToHitObject(HitObject hitObject)
{
var osuObject = (OsuHitObject)hitObject;
osuObject.Position = new Vector2(osuObject.Position.X, OsuPlayfield.BASE_SIZE.Y - osuObject.Y);
if (!(hitObject is Slider slider))
return;
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray();
foreach (var point in controlPoints)
point.Position.Value = new Vector2(point.Position.Value.X, -point.Position.Value.Y);
slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value);
OsuHitObjectGenerationUtils.ReflectVertically(osuObject);
}
}
}
@@ -0,0 +1,50 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Utils;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModMirror : ModMirror, IApplicableToHitObject
{
public override string Description => "Flip objects on the chosen axes.";
public override Type[] IncompatibleMods => new[] { typeof(ModHardRock) };
[SettingSource("Mirrored axes", "Choose which axes objects are mirrored over.")]
public Bindable<MirrorType> Reflection { get; } = new Bindable<MirrorType>();
public void ApplyToHitObject(HitObject hitObject)
{
var osuObject = (OsuHitObject)hitObject;
switch (Reflection.Value)
{
case MirrorType.Horizontal:
OsuHitObjectGenerationUtils.ReflectHorizontally(osuObject);
break;
case MirrorType.Vertical:
OsuHitObjectGenerationUtils.ReflectVertically(osuObject);
break;
case MirrorType.Both:
OsuHitObjectGenerationUtils.ReflectHorizontally(osuObject);
OsuHitObjectGenerationUtils.ReflectVertically(osuObject);
break;
}
}
public enum MirrorType
{
Horizontal,
Vertical,
Both
}
}
}
+12
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.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModMuted : ModMuted<OsuHitObject>
{
}
}
+1 -49
View File
@@ -4,8 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
@@ -16,7 +14,6 @@ using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
@@ -29,7 +26,6 @@ using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Osu.Utils;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
@@ -67,11 +63,6 @@ namespace osu.Game.Rulesets.Osu.Mods
/// </summary>
private const float distance_cap = 380f;
// The distances from the hit objects to the borders of the playfield they start to "turn around" and curve towards the middle.
// The closer the hit objects draw to the border, the sharper the turn
private const byte border_distance_x = 192;
private const byte border_distance_y = 144;
/// <summary>
/// The extent of rotation towards playfield centre when a circle is near the edge
/// </summary>
@@ -341,46 +332,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{
drawableRuleset.Overlays.Add(new TargetBeatContainer(drawableRuleset.Beatmap.HitObjects.First().StartTime));
}
public class TargetBeatContainer : BeatSyncedContainer
{
private readonly double firstHitTime;
private PausableSkinnableSound sample;
public TargetBeatContainer(double firstHitTime)
{
this.firstHitTime = firstHitTime;
Divisor = 1;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChildren = new Drawable[]
{
sample = new PausableSkinnableSound(new SampleInfo("Gameplay/catch-banana"))
};
}
protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{
base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes);
if (!IsBeatSyncedWithTrack) return;
int timeSignature = (int)timingPoint.TimeSignature;
// play metronome from one measure before the first object.
// TODO: Use BeatSyncClock from https://github.com/ppy/osu/pull/13894.
if (Clock.CurrentTime < firstHitTime - timingPoint.BeatLength * timeSignature)
return;
sample.Frequency.Value = beatIndex % timeSignature == 0 ? 1 : 0.5f;
sample.Play();
}
drawableRuleset.Overlays.Add(new Metronome(drawableRuleset.Beatmap.HitObjects.First().StartTime));
}
#endregion
@@ -97,6 +97,14 @@ namespace osu.Game.Rulesets.Osu.Objects
set => ComboIndexBindable.Value = value;
}
public Bindable<int> ComboIndexWithOffsetsBindable { get; } = new Bindable<int>();
public int ComboIndexWithOffsets
{
get => ComboIndexWithOffsetsBindable.Value;
set => ComboIndexWithOffsetsBindable.Value = value;
}
public Bindable<bool> LastInComboBindable { get; } = new Bindable<bool>();
public bool LastInCombo
+2
View File
@@ -166,6 +166,7 @@ namespace osu.Game.Rulesets.Osu
new OsuModDifficultyAdjust(),
new OsuModClassic(),
new OsuModRandom(),
new OsuModMirror(),
};
case ModType.Automation:
@@ -188,6 +189,7 @@ namespace osu.Game.Rulesets.Osu
new OsuModTraceable(),
new OsuModBarrelRoll(),
new OsuModApproachDifferent(),
new OsuModMuted(),
};
case ModType.System:
+5 -1
View File
@@ -104,7 +104,7 @@ namespace osu.Game.Rulesets.Osu.UI
private void onJudgementLoaded(DrawableOsuJudgement judgement)
{
judgementAboveHitObjectLayer.Add(judgement.GetProxyAboveHitObjectsContent());
judgementAboveHitObjectLayer.Add(judgement.ProxiedAboveHitObjectsContent);
}
[BackgroundDependencyLoader(true)]
@@ -150,6 +150,10 @@ namespace osu.Game.Rulesets.Osu.UI
DrawableOsuJudgement explosion = poolDictionary[result.Type].Get(doj => doj.Apply(result, judgedObject));
judgementLayer.Add(explosion);
// the proxied content is added to judgementAboveHitObjectLayer once, on first load, and never removed from it.
// ensure that ordering is consistent with expectations (latest judgement should be front-most).
judgementAboveHitObjectLayer.ChangeChildDepth(explosion.ProxiedAboveHitObjectsContent, (float)-result.TimeAbsolute);
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObjectContainer.ReceivePositionalInputAt(screenSpacePos);
@@ -2,7 +2,11 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Utils
@@ -100,5 +104,47 @@ namespace osu.Game.Rulesets.Osu.Utils
initial.Length * MathF.Sin(finalAngleRad)
);
}
/// <summary>
/// Reflects the position of the <see cref="OsuHitObject"/> in the playfield horizontally.
/// </summary>
/// <param name="osuObject">The object to reflect.</param>
public static void ReflectHorizontally(OsuHitObject osuObject)
{
osuObject.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - osuObject.X, osuObject.Position.Y);
if (!(osuObject is Slider slider))
return;
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y));
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y));
var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray();
foreach (var point in controlPoints)
point.Position.Value = new Vector2(-point.Position.Value.X, point.Position.Value.Y);
slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value);
}
/// <summary>
/// Reflects the position of the <see cref="OsuHitObject"/> in the playfield vertically.
/// </summary>
/// <param name="osuObject">The object to reflect.</param>
public static void ReflectVertically(OsuHitObject osuObject)
{
osuObject.Position = new Vector2(osuObject.Position.X, OsuPlayfield.BASE_SIZE.Y - osuObject.Y);
if (!(osuObject is Slider slider))
return;
slider.NestedHitObjects.OfType<SliderTick>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y));
var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray();
foreach (var point in controlPoints)
point.Position.Value = new Vector2(point.Position.Value.X, -point.Position.Value.Y);
slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value);
}
}
}
@@ -2,10 +2,30 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Taiko.Mods
{
public class TaikoModClassic : ModClassic
public class TaikoModClassic : ModClassic, IApplicableToDrawableRuleset<TaikoHitObject>, IUpdatableByPlayfield
{
private DrawableTaikoRuleset drawableTaikoRuleset;
public void ApplyToDrawableRuleset(DrawableRuleset<TaikoHitObject> drawableRuleset)
{
drawableTaikoRuleset = (DrawableTaikoRuleset)drawableRuleset;
}
public void Update(Playfield playfield)
{
// Classic taiko scrolls at a constant 100px per 1000ms. More notes become visible as the playfield is lengthened.
const float scroll_rate = 10;
// Since the time range will depend on a positional value, it is referenced to the x480 pixel space.
float ratio = drawableTaikoRuleset.DrawHeight / 480;
drawableTaikoRuleset.TimeRange.Value = (playfield.HitObjectContainer.DrawWidth / ratio) * scroll_rate;
}
}
}
+2 -30
View File
@@ -12,23 +12,11 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables;
namespace osu.Game.Rulesets.Taiko.Mods
{
public class TaikoModHidden : ModHidden, IApplicableToDifficulty
public class TaikoModHidden : ModHidden
{
public override string Description => @"Beats fade out before you hit them!";
public override double ScoreMultiplier => 1.06;
/// <summary>
/// In osu-stable, the hit position is 160, so the active playfield is essentially 160 pixels shorter
/// than the actual screen width. The normalized playfield height is 480, so on a 4:3 screen the
/// playfield ratio of the active area up to the hit position will actually be (640 - 160) / 480 = 1.
/// For custom resolutions/aspect ratios (x:y), the screen width given the normalized height becomes 480 * x / y instead,
/// and the playfield ratio becomes (480 * x / y - 160) / 480 = x / y - 1/3.
/// This constant is equal to the playfield ratio on 4:3 screens divided by the playfield ratio on 16:9 screens.
/// </summary>
private const double hd_sv_scale = (4.0 / 3.0 - 1.0 / 3.0) / (16.0 / 9.0 - 1.0 / 3.0);
private double originalSliderMultiplier;
private ControlPointInfo controlPointInfo;
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
@@ -41,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.Mods
double beatLength = controlPointInfo.TimingPointAt(position).BeatLength;
double speedMultiplier = controlPointInfo.DifficultyPointAt(position).SpeedMultiplier;
return originalSliderMultiplier * speedMultiplier * TimingControlPoint.DEFAULT_BEAT_LENGTH / beatLength;
return speedMultiplier * TimingControlPoint.DEFAULT_BEAT_LENGTH / beatLength;
}
protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state)
@@ -69,22 +57,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
}
}
public void ReadFromDifficulty(BeatmapDifficulty difficulty)
{
}
public void ApplyToDifficulty(BeatmapDifficulty difficulty)
{
// needs to be read after all processing has been run (TaikoBeatmapConverter applies an adjustment which would otherwise be omitted).
originalSliderMultiplier = difficulty.SliderMultiplier;
// osu-stable has an added playfield cover that essentially forces a 4:3 playfield ratio, by cutting off all objects past that size.
// This is not yet implemented; instead a playfield adjustment container is present which maintains a 16:9 ratio.
// For now, increase the slider multiplier proportionally so that the notes stay on the screen for the same amount of time as on stable.
// Note that this means that the notes will scroll faster as they have a longer distance to travel on the screen in that same amount of time.
difficulty.SliderMultiplier /= hd_sv_scale;
}
public override void ApplyToBeatmap(IBeatmap beatmap)
{
controlPointInfo = beatmap.ControlPointInfo;
@@ -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.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Mods
{
public class TaikoModMuted : ModMuted<TaikoHitObject>
{
}
}
+2 -1
View File
@@ -149,7 +149,8 @@ namespace osu.Game.Rulesets.Taiko
case ModType.Fun:
return new Mod[]
{
new MultiMod(new ModWindUp(), new ModWindDown())
new MultiMod(new ModWindUp(), new ModWindDown()),
new TaikoModMuted(),
};
default:
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects.Drawables;
@@ -24,12 +25,14 @@ namespace osu.Game.Rulesets.Taiko.UI
{
public class DrawableTaikoRuleset : DrawableScrollingRuleset<TaikoHitObject>
{
private SkinnableDrawable scroller;
public new BindableDouble TimeRange => base.TimeRange;
protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping;
protected override bool UserScrollSpeedAdjustment => false;
private SkinnableDrawable scroller;
public DrawableTaikoRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null)
: base(ruleset, beatmap, mods)
{
+1 -1
View File
@@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Taiko.UI
/// <summary>
/// Default height of a <see cref="TaikoPlayfield"/> when inside a <see cref="DrawableTaikoRuleset"/>.
/// </summary>
public const float DEFAULT_HEIGHT = 178;
public const float DEFAULT_HEIGHT = 212;
private Container<HitExplosion> hitExplosionContainer;
private Container<KiaiHitExplosion> kiaiExplosionContainer;
@@ -323,12 +323,12 @@ namespace osu.Game.Tests.Beatmaps.Formats
new OsuBeatmapProcessor(converted).PreProcess();
new OsuBeatmapProcessor(converted).PostProcess();
Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndex);
Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndex);
Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndex);
Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndex);
Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndex);
Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndex);
Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets);
Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets);
Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets);
Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets);
Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets);
Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets);
}
}
@@ -346,12 +346,12 @@ namespace osu.Game.Tests.Beatmaps.Formats
new CatchBeatmapProcessor(converted).PreProcess();
new CatchBeatmapProcessor(converted).PostProcess();
Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndex);
Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndex);
Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndex);
Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndex);
Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndex);
Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndex);
Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets);
Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets);
Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets);
Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets);
Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets);
Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets);
}
}
@@ -100,6 +100,14 @@ namespace osu.Game.Tests.Gameplay
set => ComboIndexBindable.Value = value;
}
public Bindable<int> ComboIndexWithOffsetsBindable { get; } = new Bindable<int>();
public int ComboIndexWithOffsets
{
get => ComboIndexWithOffsetsBindable.Value;
set => ComboIndexWithOffsetsBindable.Value = value;
}
public Bindable<bool> LastInComboBindable { get; } = new Bindable<bool>();
public bool LastInCombo
@@ -129,14 +137,8 @@ namespace osu.Game.Tests.Gameplay
{
switch (lookup)
{
case GlobalSkinColours global:
switch (global)
{
case GlobalSkinColours.ComboColours:
return SkinUtils.As<TValue>(new Bindable<IReadOnlyList<Color4>>(ComboColours));
}
break;
case SkinComboColourLookup comboColour:
return SkinUtils.As<TValue>(new Bindable<Color4>(ComboColours[comboColour.ColourIndex % ComboColours.Count]));
}
throw new NotImplementedException();
+1 -2
View File
@@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Globalization;
using NUnit.Framework;
using osu.Game.Utils;
@@ -20,7 +19,7 @@ namespace osu.Game.Tests.NonVisual
[TestCase(1, "100.00%")]
public void TestAccuracyFormatting(double input, string expectedOutput)
{
Assert.AreEqual(expectedOutput, input.FormatAccuracy(CultureInfo.InvariantCulture));
Assert.AreEqual(expectedOutput, input.FormatAccuracy().ToString());
}
}
}
@@ -0,0 +1,41 @@
// 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 NUnit.Framework;
using osu.Game.Extensions;
namespace osu.Game.Tests.NonVisual
{
[TestFixture]
public class TimeDisplayExtensionTest
{
private static readonly object[][] editor_formatted_duration_tests =
{
new object[] { new TimeSpan(0, 0, 0, 0, 50), "00:00:050" },
new object[] { new TimeSpan(0, 0, 0, 10, 50), "00:10:050" },
new object[] { new TimeSpan(0, 0, 5, 10), "05:10:000" },
new object[] { new TimeSpan(0, 1, 5, 10), "65:10:000" },
};
[TestCaseSource(nameof(editor_formatted_duration_tests))]
public void TestEditorFormat(TimeSpan input, string expectedOutput)
{
Assert.AreEqual(expectedOutput, input.ToEditorFormattedString());
}
private static readonly object[][] formatted_duration_tests =
{
new object[] { new TimeSpan(0, 0, 10), "00:10" },
new object[] { new TimeSpan(0, 5, 10), "05:10" },
new object[] { new TimeSpan(1, 5, 10), "01:05:10" },
new object[] { new TimeSpan(1, 1, 5, 10), "01:01:05:10" },
};
[TestCaseSource(nameof(formatted_duration_tests))]
public void TestFormattedDuration(TimeSpan input, string expectedOutput)
{
Assert.AreEqual(expectedOutput, input.ToFormattedDuration().ToString());
}
}
}
@@ -168,8 +168,8 @@ namespace osu.Game.Tests.Online
public override async Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
{
await AllowImport.Task;
return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken));
await AllowImport.Task.ConfigureAwait(false);
return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false);
}
}
@@ -0,0 +1,90 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Tests.Visual.Colours
{
public class TestSceneStarDifficultyColours : OsuTestScene
{
[Resolved]
private OsuColour colours { get; set; }
[Test]
public void TestColours()
{
AddStep("load colour displays", () =>
{
Child = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5f),
ChildrenEnumerable = Enumerable.Range(0, 10).Select(i => new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(10f),
ChildrenEnumerable = Enumerable.Range(0, 10).Select(j =>
{
var colour = colours.ForStarDifficulty(1f * i + 0.1f * j);
return new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, 10f),
Children = new Drawable[]
{
new CircularContainer
{
Masking = true,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Size = new Vector2(75f, 25f),
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colour,
},
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Colour = OsuColour.ForegroundTextColourFor(colour),
Text = colour.ToHex(),
},
}
},
new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = $"*{(1f * i + 0.1f * j):0.00}",
}
}
};
})
})
};
});
}
}
}
@@ -35,6 +35,8 @@ namespace osu.Game.Tests.Visual.Editing
CanRotate = true,
CanScaleX = true,
CanScaleY = true,
CanFlipX = true,
CanFlipY = true,
OnRotation = handleRotation,
OnScale = handleScale
@@ -75,7 +75,7 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("Hitcircle button not clickable", () => !hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").Enabled.Value);
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddAssert("Hitcircle button is clickable", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").Enabled.Value);
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").Click());
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").TriggerClick());
AddAssert("Tool changed", () => hitObjectComposer.ChildrenOfType<ComposeBlueprintContainer>().First().CurrentTool is HitCircleCompositionTool);
}
@@ -228,7 +228,7 @@ namespace osu.Game.Tests.Visual.Gameplay
var lastAction = pauseOverlay.OnRetry;
pauseOverlay.OnRetry = () => triggered = true;
getButton(1).Click();
getButton(1).TriggerClick();
pauseOverlay.OnRetry = lastAction;
});
@@ -13,6 +13,7 @@ using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.UI;
using osu.Game.Screens;
using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps.IO;
using osu.Game.Tests.Visual.Multiplayer;
@@ -25,41 +26,43 @@ namespace osu.Game.Tests.Visual.Gameplay
{
private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" };
[Cached(typeof(SpectatorClient))]
private TestSpectatorClient testSpectatorClient = new TestSpectatorClient();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
// used just to show beatmap card for the time being.
protected override bool UseOnlineAPI => true;
private SoloSpectator spectatorScreen;
[Resolved]
private OsuGameBase game { get; set; }
private BeatmapSetInfo importedBeatmap;
private TestSpectatorClient spectatorClient;
private SoloSpectator spectatorScreen;
private BeatmapSetInfo importedBeatmap;
private int importedBeatmapId;
public override void SetUpSteps()
[SetUpSteps]
public void SetupSteps()
{
base.SetUpSteps();
DependenciesScreen dependenciesScreen = null;
AddStep("load dependencies", () =>
{
spectatorClient = new TestSpectatorClient();
// The screen gets suspended so it stops receiving updates.
Child = spectatorClient;
LoadScreen(dependenciesScreen = new DependenciesScreen(spectatorClient));
});
AddUntilStep("wait for dependencies to load", () => dependenciesScreen.IsLoaded);
AddStep("import beatmap", () =>
{
importedBeatmap = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result;
importedBeatmapId = importedBeatmap.Beatmaps.First(b => b.RulesetID == 0).OnlineBeatmapID ?? -1;
});
AddStep("add streaming client", () =>
{
Remove(testSpectatorClient);
Add(testSpectatorClient);
});
finish();
}
[Test]
@@ -206,22 +209,36 @@ namespace osu.Game.Tests.Visual.Gameplay
private void waitForPlayer() => AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true);
private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
private void start(int? beatmapId = null) => AddStep("start play", () => spectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
private void finish() => AddStep("end play", () => testSpectatorClient.EndPlay(streamingUser.Id));
private void finish() => AddStep("end play", () => spectatorClient.EndPlay(streamingUser.Id));
private void checkPaused(bool state) =>
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state);
private void sendFrames(int count = 10)
{
AddStep("send frames", () => testSpectatorClient.SendFrames(streamingUser.Id, count));
AddStep("send frames", () => spectatorClient.SendFrames(streamingUser.Id, count));
}
private void loadSpectatingScreen()
{
AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser)));
AddStep("load spectator", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser)));
AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded);
}
/// <summary>
/// Used for the sole purpose of adding <see cref="TestSpectatorClient"/> as a resolvable dependency.
/// </summary>
private class DependenciesScreen : OsuScreen
{
[Cached(typeof(SpectatorClient))]
public readonly TestSpectatorClient Client;
public DependenciesScreen(TestSpectatorClient client)
{
Client = client;
}
}
}
}
@@ -4,6 +4,8 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Osu;
@@ -40,7 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("has 2 rooms", () => container.Rooms.Count == 2);
AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID.Value != 0));
AddStep("select first room", () => container.Rooms.First().Click());
AddStep("select first room", () => container.Rooms.First().TriggerClick());
AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First()));
}
@@ -62,6 +64,31 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("last room selected", () => checkRoomSelected(RoomManager.Rooms.Last()));
}
[Test]
public void TestKeyboardNavigationAfterOrderChange()
{
AddStep("add rooms", () => RoomManager.AddRooms(3));
AddStep("reorder rooms", () =>
{
var room = RoomManager.Rooms[1];
RoomManager.RemoveRoom(room);
RoomManager.AddRoom(room);
});
AddAssert("no selection", () => checkRoomSelected(null));
press(Key.Down);
AddAssert("first room selected", () => checkRoomSelected(getRoomInFlow(0)));
press(Key.Down);
AddAssert("second room selected", () => checkRoomSelected(getRoomInFlow(1)));
press(Key.Down);
AddAssert("third room selected", () => checkRoomSelected(getRoomInFlow(2)));
}
[Test]
public void TestClickDeselection()
{
@@ -121,5 +148,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
private bool checkRoomSelected(Room room) => SelectedRoom.Value == room;
private Room getRoomInFlow(int index) =>
(container.ChildrenOfType<FillFlowContainer<DrawableRoom>>().First().FlowingChildren.ElementAt(index) as DrawableRoom)?.Room;
}
}
@@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
@@ -79,6 +80,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("load multiplayer", () => LoadScreen(multiplayerScreen));
AddUntilStep("wait for multiplayer to load", () => multiplayerScreen.IsLoaded);
AddUntilStep("wait for lounge to load", () => this.ChildrenOfType<MultiplayerLoungeSubScreen>().FirstOrDefault()?.IsLoaded == true);
}
[Test]
@@ -87,6 +89,28 @@ namespace osu.Game.Tests.Visual.Multiplayer
// used to test the flow of multiplayer from visual tests.
}
[Test]
public void TestCreateRoomViaKeyboard()
{
// create room dialog
AddStep("Press new document", () => InputManager.Keys(PlatformAction.DocumentNew));
AddUntilStep("wait for settings", () => InputManager.ChildrenOfType<MultiplayerMatchSettingsOverlay>().FirstOrDefault() != null);
// edit playlist item
AddStep("Press select", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for song select", () => InputManager.ChildrenOfType<MultiplayerMatchSongSelect>().FirstOrDefault() != null);
// select beatmap
AddStep("Press select", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for return to screen", () => InputManager.ChildrenOfType<MultiplayerMatchSongSelect>().FirstOrDefault() == null);
// create room
AddStep("Press select", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddUntilStep("wait for join", () => client.Room != null);
}
[Test]
public void TestCreateRoomWithoutPassword()
{
@@ -105,11 +129,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
[Test]
public void TestJoinRoomWithoutPassword()
public void TestExitMidJoin()
{
Room room = null;
AddStep("create room", () =>
{
API.Queue(new CreateRoomRequest(new Room
room = new Room
{
Name = { Value = "Test Room" },
Playlist =
@@ -120,7 +146,35 @@ namespace osu.Game.Tests.Visual.Multiplayer
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
}));
};
});
AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria());
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("join room and immediately exit", () =>
{
multiplayerScreen.ChildrenOfType<LoungeSubScreen>().Single().Open(room);
Schedule(() => Stack.CurrentScreen.Exit());
});
}
[Test]
public void TestJoinRoomWithoutPassword()
{
AddStep("create room", () =>
{
multiplayerScreen.RoomManager.AddRoom(new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
});
AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria());
@@ -156,7 +210,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
AddStep("create room", () =>
{
API.Queue(new CreateRoomRequest(new Room
multiplayerScreen.RoomManager.AddRoom(new Room
{
Name = { Value = "Test Room" },
Password = { Value = "password" },
@@ -168,7 +222,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
}));
});
});
AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria());
@@ -178,7 +232,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
DrawableRoom.PasswordEntryPopover passwordEntryPopover = null;
AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType<DrawableRoom.PasswordEntryPopover>().FirstOrDefault()) != null);
AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType<TextBox>().First().Text = "password");
AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType<OsuButton>().First().Click());
AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType<OsuButton>().First().TriggerClick());
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddUntilStep("wait for join", () => client.Room != null);
@@ -342,7 +396,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
});
AddStep("open mod overlay", () => this.ChildrenOfType<PurpleTriangleButton>().ElementAt(2).Click());
AddStep("open mod overlay", () => this.ChildrenOfType<PurpleTriangleButton>().ElementAt(2).TriggerClick());
AddStep("invoke on back button", () => multiplayerScreen.OnBackButton());
@@ -350,7 +404,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden);
testLeave("lounge tab item", () => this.ChildrenOfType<BreadcrumbControl<IScreen>.BreadcrumbTabItem>().First().Click());
testLeave("lounge tab item", () => this.ChildrenOfType<BreadcrumbControl<IScreen>.BreadcrumbTabItem>().First().TriggerClick());
testLeave("back button", () => multiplayerScreen.OnBackButton());
@@ -402,9 +456,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer
{
public new TestMultiplayerRoomManager RoomManager { get; private set; }
public new TestRequestHandlingMultiplayerRoomManager RoomManager { get; private set; }
protected override RoomManager CreateRoomManager() => RoomManager = new TestMultiplayerRoomManager();
protected override RoomManager CreateRoomManager() => RoomManager = new TestRequestHandlingMultiplayerRoomManager();
}
}
}
@@ -74,7 +74,23 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("attempt join room", () => InputManager.Key(Key.Enter));
AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType<DrawableRoom.PasswordEntryPopover>().FirstOrDefault()) != null);
AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType<TextBox>().First().Text = "password");
AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType<OsuButton>().First().Click());
AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType<OsuButton>().First().TriggerClick());
AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First());
AddAssert("room join password correct", () => lastJoinedPassword == "password");
}
[Test]
public void TestJoinRoomWithPasswordViaKeyboardOnly()
{
DrawableRoom.PasswordEntryPopover passwordEntryPopover = null;
AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true));
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("attempt join room", () => InputManager.Key(Key.Enter));
AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType<DrawableRoom.PasswordEntryPopover>().FirstOrDefault()) != null);
AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType<TextBox>().First().Text = "password");
AddStep("press enter", () => InputManager.Key(Key.Enter));
AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First());
AddAssert("room join password correct", () => lastJoinedPassword == "password");
@@ -0,0 +1,191 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens;
using osu.Game.Screens.OnlinePlay.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Screens.OnlinePlay.Multiplayer.Participants;
using osu.Game.Tests.Resources;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneTeamVersus : ScreenTestScene
{
private BeatmapManager beatmaps;
private RulesetStore rulesets;
private BeatmapSetInfo importedSet;
private DependenciesScreen dependenciesScreen;
private TestMultiplayer multiplayerScreen;
private TestMultiplayerClient client;
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default));
}
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("import beatmap", () =>
{
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait();
importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First();
});
AddStep("create multiplayer screen", () => multiplayerScreen = new TestMultiplayer());
AddStep("load dependencies", () =>
{
client = new TestMultiplayerClient(multiplayerScreen.RoomManager);
// The screen gets suspended so it stops receiving updates.
Child = client;
LoadScreen(dependenciesScreen = new DependenciesScreen(client));
});
AddUntilStep("wait for dependencies to load", () => dependenciesScreen.IsLoaded);
AddStep("load multiplayer", () => LoadScreen(multiplayerScreen));
AddUntilStep("wait for multiplayer to load", () => multiplayerScreen.IsLoaded);
AddUntilStep("wait for lounge to load", () => this.ChildrenOfType<MultiplayerLoungeSubScreen>().FirstOrDefault()?.IsLoaded == true);
}
[Test]
public void TestCreateWithType()
{
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Type = { Value = MatchType.TeamVersus },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddAssert("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus);
AddAssert("user state arrived", () => client.Room?.Users.FirstOrDefault()?.MatchState is TeamVersusUserState);
}
[Test]
public void TestChangeTeamsViaButton()
{
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Type = { Value = MatchType.TeamVersus },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddAssert("user on team 0", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
AddStep("press button", () =>
{
InputManager.MoveMouseTo(multiplayerScreen.ChildrenOfType<TeamDisplay>().First());
InputManager.Click(MouseButton.Left);
});
AddAssert("user on team 1", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1);
AddStep("press button", () => InputManager.Click(MouseButton.Left));
AddAssert("user on team 0", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
}
[Test]
public void TestChangeTypeViaMatchSettings()
{
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddAssert("room type is head to head", () => client.Room?.Settings.MatchType == MatchType.HeadToHead);
AddStep("change to team vs", () => client.ChangeSettings(matchType: MatchType.TeamVersus));
AddAssert("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus);
}
private void createRoom(Func<Room> room)
{
AddStep("open room", () =>
{
multiplayerScreen.OpenNewRoom(room());
});
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddWaitStep("wait for transition", 2);
AddStep("create room", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>().Single());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("wait for join", () => client.Room != null);
}
/// <summary>
/// Used for the sole purpose of adding <see cref="TestMultiplayerClient"/> as a resolvable dependency.
/// </summary>
private class DependenciesScreen : OsuScreen
{
[Cached(typeof(MultiplayerClient))]
public readonly TestMultiplayerClient Client;
public DependenciesScreen(TestMultiplayerClient client)
{
Client = client;
}
}
private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer
{
public new TestRequestHandlingMultiplayerRoomManager RoomManager { get; private set; }
protected override RoomManager CreateRoomManager() => RoomManager = new TestRequestHandlingMultiplayerRoomManager();
}
}
}
@@ -353,10 +353,10 @@ namespace osu.Game.Tests.Visual.Navigation
public TestMultiplayer()
{
Client = new TestMultiplayerClient((TestMultiplayerRoomManager)RoomManager);
Client = new TestMultiplayerClient((TestRequestHandlingMultiplayerRoomManager)RoomManager);
}
protected override RoomManager CreateRoomManager() => new TestMultiplayerRoomManager();
protected override RoomManager CreateRoomManager() => new TestRequestHandlingMultiplayerRoomManager();
}
}
}
@@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.Online
AddStep("show manually", () => accountCreation.Show());
AddUntilStep("overlay is visible", () => accountCreation.State.Value == Visibility.Visible);
AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().Click());
AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType<ScreenWarning>().SingleOrDefault()?.IsPresent == true);
AddStep("log back in", () => API.Login("dummy", "password"));
@@ -330,22 +330,11 @@ namespace osu.Game.Tests.Visual.Online
InputManager.ReleaseKey(Key.AltLeft);
}
private void pressCloseDocumentKeys() => pressKeysFor(PlatformActionType.DocumentClose);
private void pressCloseDocumentKeys() => InputManager.Keys(PlatformAction.DocumentClose);
private void pressNewTabKeys() => pressKeysFor(PlatformActionType.TabNew);
private void pressNewTabKeys() => InputManager.Keys(PlatformAction.TabNew);
private void pressRestoreTabKeys() => pressKeysFor(PlatformActionType.TabRestore);
private void pressKeysFor(PlatformActionType type)
{
var binding = host.PlatformKeyBindings.First(b => ((PlatformAction)b.Action).ActionType == type);
foreach (var k in binding.KeyCombination.Keys)
InputManager.PressKey((Key)k);
foreach (var k in binding.KeyCombination.Keys)
InputManager.ReleaseKey((Key)k);
}
private void pressRestoreTabKeys() => InputManager.Keys(PlatformAction.TabRestore);
private void clickDrawable(Drawable d)
{
@@ -1,13 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Online.Spectator;
@@ -21,51 +20,44 @@ namespace osu.Game.Tests.Visual.Online
{
private readonly User streamingUser = new User { Id = 2, Username = "Test user" };
[Cached(typeof(SpectatorClient))]
private TestSpectatorClient testSpectatorClient = new TestSpectatorClient();
private TestSpectatorClient spectatorClient;
private CurrentlyPlayingDisplay currentlyPlaying;
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
private Container nestedContainer;
[SetUpSteps]
public void SetUpSteps()
{
AddStep("add streaming client", () =>
{
nestedContainer?.Remove(testSpectatorClient);
Remove(lookupCache);
spectatorClient = new TestSpectatorClient();
var lookupCache = new TestUserLookupCache();
Children = new Drawable[]
{
lookupCache,
nestedContainer = new Container
spectatorClient,
new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
CachedDependencies = new (Type, object)[]
{
testSpectatorClient,
currentlyPlaying = new CurrentlyPlayingDisplay
{
RelativeSizeAxes = Axes.Both,
}
(typeof(SpectatorClient), spectatorClient),
(typeof(UserLookupCache), lookupCache)
},
Child = currentlyPlaying = new CurrentlyPlayingDisplay
{
RelativeSizeAxes = Axes.Both,
}
},
};
});
AddStep("Reset players", () => testSpectatorClient.EndPlay(streamingUser.Id));
}
[Test]
public void TestBasicDisplay()
{
AddStep("Add playing user", () => testSpectatorClient.StartPlay(streamingUser.Id, 0));
AddStep("Add playing user", () => spectatorClient.StartPlay(streamingUser.Id, 0));
AddUntilStep("Panel loaded", () => currentlyPlaying.ChildrenOfType<UserGridPanel>()?.FirstOrDefault()?.User.Id == 2);
AddStep("Remove playing user", () => testSpectatorClient.EndPlay(streamingUser.Id));
AddStep("Remove playing user", () => spectatorClient.EndPlay(streamingUser.Id));
AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType<UserGridPanel>().Any());
}
@@ -0,0 +1,87 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Comments;
namespace osu.Game.Tests.Visual.Online
{
public class TestSceneDrawableComment : OsuTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
private Container container;
[SetUp]
public void SetUp() => Schedule(() =>
{
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4,
},
container = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
};
});
[TestCaseSource(nameof(comments))]
public void TestComment(string description, string text)
{
AddStep(description, () =>
{
comment.Message = text;
container.Add(new DrawableComment(comment));
});
}
private static readonly Comment comment = new Comment
{
Id = 1,
LegacyName = "Test User",
CreatedAt = DateTimeOffset.Now,
VotesCount = 0,
};
private static object[] comments =
{
new[] { "Plain", "This is plain comment" },
new[] { "Link", "Please visit https://osu.ppy.sh" },
new[]
{
"Heading", @"# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6"
},
// Taken from https://github.com/ppy/osu/issues/13993#issuecomment-885994077
new[]
{
"Problematic", @"My tablet doesn't work :(
It's a Huion 420 and it's apparently incompatible with OpenTablet Driver. The warning I get is: ""DeviceInUseException: Device is currently in use by another kernel module. To fix this issue, please follow the instructions from https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Linux-FAQ#arg umentoutofrangeexception-value-0-15"" and it repeats 4 times on the notification before logging subsequent warnings.
Checking the logs, it looks for other Huion tablets before sending the notification (e.g.
""2021-07-23 03:52:33 [verbose]: Detect: Searching for tablet 'Huion WH1409 V2'
20 2021-07-23 03:52:33 [error]: DeviceInUseException: Device is currently in use by another kernel module. To fix this issue, please follow the instructions from https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Linux-FAQ#arg umentoutofrangeexception-value-0-15"")
I use an Arch based installation of Linux and the tablet runs perfectly with Digimend kernel driver, with area configuration, pen pressure, etc. On osu!lazer the cursor disappears until I set it to ""Borderless"" instead of ""Fullscreen"" and even after it shows up, it goes to the bottom left corner as soon as a map starts.
I have honestly 0 idea of whats going on at this point."
}
};
}
}
@@ -29,7 +29,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestControl()
{
AddAssert("Front page selected", () => header.Current.Value == "frontpage");
AddAssert("Front page selected", () => header.Current.Value == NewsHeader.FrontPageString);
AddAssert("1 tab total", () => header.TabCount == 1);
AddStep("Set article 1", () => header.SetArticle("1"));
@@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("2 tabs total", () => header.TabCount == 2);
AddStep("Set front page", () => header.SetFrontPage());
AddAssert("Front page selected", () => header.Current.Value == "frontpage");
AddAssert("Front page selected", () => header.Current.Value == NewsHeader.FrontPageString);
AddAssert("1 tab total", () => header.TabCount == 1);
}
@@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.Online
AddStep("Show", () => overlay.Show());
AddUntilStep("Show More button is visible", () => showMoreButton?.Alpha == 1);
setUpNewsResponse(responseWithNoCursor, "Set up no cursor response");
AddStep("Click Show More", () => showMoreButton?.Click());
AddStep("Click Show More", () => showMoreButton?.TriggerClick());
AddUntilStep("Show More button is hidden", () => showMoreButton?.Alpha == 0);
}
@@ -32,19 +32,19 @@ namespace osu.Game.Tests.Visual.Online
}
});
AddStep("click button", () => button.Click());
AddStep("click button", () => button.TriggerClick());
AddAssert("action fired once", () => fireCount == 1);
AddAssert("is in loading state", () => button.IsLoading);
AddStep("click button", () => button.Click());
AddStep("click button", () => button.TriggerClick());
AddAssert("action not fired", () => fireCount == 1);
AddAssert("is in loading state", () => button.IsLoading);
AddUntilStep("wait for loaded", () => !button.IsLoading);
AddStep("click button", () => button.Click());
AddStep("click button", () => button.TriggerClick());
AddAssert("action fired twice", () => fireCount == 2);
AddAssert("is in loading state", () => button.IsLoading);
@@ -45,7 +45,7 @@ namespace osu.Game.Tests.Visual.Online
AddStep("Log in", logIn);
AddStep("User comment", () => addVotePill(getUserComment()));
AddAssert("Background is transparent", () => votePill.Background.Alpha == 0);
AddStep("Click", () => votePill.Click());
AddStep("Click", () => votePill.TriggerClick());
AddAssert("Not loading", () => !votePill.IsLoading);
}
@@ -56,7 +56,7 @@ namespace osu.Game.Tests.Visual.Online
AddStep("Log in", logIn);
AddStep("Random comment", () => addVotePill(getRandomComment()));
AddAssert("Background is visible", () => votePill.Background.Alpha == 1);
AddStep("Click", () => votePill.Click());
AddStep("Click", () => votePill.TriggerClick());
AddAssert("Loading", () => votePill.IsLoading);
}
@@ -66,7 +66,7 @@ namespace osu.Game.Tests.Visual.Online
AddStep("Hide login overlay", () => login.Hide());
AddStep("Log out", API.Logout);
AddStep("Random comment", () => addVotePill(getRandomComment()));
AddStep("Click", () => votePill.Click());
AddStep("Click", () => votePill.TriggerClick());
AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible);
}
@@ -44,7 +44,7 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestWikiHeader()
{
AddAssert("Current is index", () => checkCurrent("index"));
AddAssert("Current is index", () => checkCurrent(WikiHeader.IndexPageString));
AddStep("Change wiki page data", () => wikiPageData.Value = new APIWikiPage
{
@@ -54,8 +54,8 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("Current is welcome", () => checkCurrent("Welcome"));
AddAssert("Check breadcrumb", checkBreadcrumb);
AddStep("Change current to index", () => header.Current.Value = "index");
AddAssert("Current is index", () => checkCurrent("index"));
AddStep("Change current to index", () => header.Current.Value = WikiHeader.IndexPageString);
AddAssert("Current is index", () => checkCurrent(WikiHeader.IndexPageString));
AddStep("Change wiki page data", () => wikiPageData.Value = new APIWikiPage
{
@@ -71,7 +71,7 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("Check breadcrumb", checkBreadcrumb);
}
private bool checkCurrent(string expectedCurrent) => header.Current.Value == expectedCurrent;
private bool checkCurrent(LocalisableString expectedCurrent) => header.Current.Value == expectedCurrent;
private bool checkBreadcrumb()
{
@@ -10,6 +10,7 @@ using osu.Game.Screens.OnlinePlay.Lounge;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Tests.Visual.OnlinePlay;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Playlists
{
@@ -30,17 +31,35 @@ namespace osu.Game.Tests.Visual.Playlists
private RoomsContainer roomsContainer => loungeScreen.ChildrenOfType<RoomsContainer>().First();
[Test]
public void TestScrollByDraggingRooms()
{
AddStep("reset mouse", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("add rooms", () => RoomManager.AddRooms(30));
AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0]));
AddStep("move mouse to third room", () => InputManager.MoveMouseTo(roomsContainer.Rooms[2]));
AddStep("hold down", () => InputManager.PressButton(MouseButton.Left));
AddStep("drag to top", () => InputManager.MoveMouseTo(roomsContainer.Rooms[0]));
AddAssert("first and second room masked", ()
=> !checkRoomVisible(roomsContainer.Rooms[0]) &&
!checkRoomVisible(roomsContainer.Rooms[1]));
}
[Test]
public void TestScrollSelectedIntoView()
{
AddStep("add rooms", () => RoomManager.AddRooms(30));
AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms.First()));
AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0]));
AddStep("select last room", () => roomsContainer.Rooms.Last().Click());
AddStep("select last room", () => roomsContainer.Rooms[^1].TriggerClick());
AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms.First()));
AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms.Last()));
AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms[0]));
AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms[^1]));
}
private bool checkRoomVisible(DrawableRoom room) =>
@@ -76,7 +76,7 @@ namespace osu.Game.Tests.Visual.Playlists
});
});
AddStep("start match", () => match.ChildrenOfType<PlaylistsReadyButton>().First().Click());
AddStep("start match", () => match.ChildrenOfType<PlaylistsReadyButton>().First().TriggerClick());
AddUntilStep("player loader loaded", () => Stack.CurrentScreen is PlayerLoader);
}
@@ -29,6 +29,12 @@ namespace osu.Game.Tests.Visual.Ranking
AddStep("show example score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo)));
}
[Test]
public void TestExcessMods()
{
AddStep("show excess mods score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo, true)));
}
private void showPanel(WorkingBeatmap workingBeatmap, ScoreInfo score)
{
Child = new ContractedPanelMiddleContentContainer(workingBeatmap, score);
@@ -12,6 +12,7 @@ using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking;
@@ -36,6 +37,17 @@ namespace osu.Game.Tests.Visual.Ranking
{
Beatmap = createTestBeatmap(author)
}));
}
[Test]
public void TestExcessMods()
{
var author = new User { Username = "mapper_name" };
AddStep("show excess mods score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo, true)
{
Beatmap = createTestBeatmap(author)
}));
AddAssert("mapper name present", () => this.ChildrenOfType<OsuSpriteText>().Any(spriteText => spriteText.Current.Value == "mapper_name"));
}
@@ -50,9 +62,33 @@ namespace osu.Game.Tests.Visual.Ranking
AddAssert("mapped by text not present", () =>
this.ChildrenOfType<OsuSpriteText>().All(spriteText => !containsAny(spriteText.Text.ToString(), "mapped", "by")));
AddAssert("play time displayed", () => this.ChildrenOfType<ExpandedPanelMiddleContent.PlayedOnText>().Any());
}
private void showPanel(ScoreInfo score) => Child = new ExpandedPanelMiddleContentContainer(score);
[Test]
public void TestWithDefaultDate()
{
AddStep("show autoplay score", () =>
{
var ruleset = new OsuRuleset();
var mods = new Mod[] { ruleset.GetAutoplayMod() };
var beatmap = createTestBeatmap(null);
showPanel(new TestScoreInfo(ruleset.RulesetInfo)
{
Mods = mods,
Beatmap = beatmap,
Date = default,
});
});
AddAssert("play time not displayed", () => !this.ChildrenOfType<ExpandedPanelMiddleContent.PlayedOnText>().Any());
}
private void showPanel(ScoreInfo score) =>
Child = new ExpandedPanelMiddleContentContainer(score);
private BeatmapInfo createTestBeatmap(User author)
{

Some files were not shown because too many files have changed in this diff Show More