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

Merge branch 'master' into prev-button-revamp

This commit is contained in:
Bartłomiej Dach 2019-11-06 22:41:33 +01:00 committed by GitHub
commit 70feab8316
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
149 changed files with 5108 additions and 1501 deletions

View File

@ -0,0 +1,8 @@
---
name: Mobile Report
about: ⚠ Due to current development priorities we are not accepting mobile reports at this time (unless you're willing to fix them yourself!)
---
**PLEASE READ** ⚠: Due to prioritising finishing the client for desktop first we are not accepting reports related to mobile platforms for the time being, unless you're willing to fix them.
If you'd like to report a problem or suggest a feature and then work on it, feel free to open an issue and highlight that you'd like to address it yourself in the issue body; mobile pull requests are also welcome.
Otherwise, please check back in the future when the focus of development shifts towards mobile!

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: osu!stable issues
url: https://github.com/ppy/osu-stable-issues
about: For issues regarding osu!stable (not osu!lazer), open them here.

View File

@ -1,7 +0,0 @@
---
name: Missing for Live
about: Features which are available in osu!stable but not yet in osu!lazer.
---
**Describe the missing feature:**
**Proposal designs of the feature:**

View File

@ -62,6 +62,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2019.1010.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2019.1023.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2019.1106.0" />
</ItemGroup>
</Project>

View File

@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Catch.Tests
private void load()
{
var controlPointInfo = new ControlPointInfo();
controlPointInfo.TimingPoints.Add(new TimingControlPoint());
controlPointInfo.Add(0, new TimingControlPoint());
WorkingBeatmap beatmap = CreateWorkingBeatmap(new Beatmap
{

View File

@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.Edit
editorClock = clock;
}
public override void HandleMovement(MoveSelectionEvent moveEvent)
public override bool HandleMovement(MoveSelectionEvent moveEvent)
{
var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint;
int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column;
@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Mania.Edit
performDragMovement(moveEvent);
performColumnMovement(lastColumn, moveEvent);
base.HandleMovement(moveEvent);
return true;
}
/// <summary>

View File

@ -0,0 +1,230 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables.Connections;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
public class TestSceneFollowPoints : OsuTestScene
{
private Container<DrawableOsuHitObject> hitObjectContainer;
private FollowPointRenderer followPointRenderer;
[SetUp]
public void Setup() => Schedule(() =>
{
Children = new Drawable[]
{
hitObjectContainer = new TestHitObjectContainer { RelativeSizeAxes = Axes.Both },
followPointRenderer = new FollowPointRenderer { RelativeSizeAxes = Axes.Both }
};
});
[Test]
public void TestAddObject()
{
addObjectsStep(() => new OsuHitObject[] { new HitCircle { Position = new Vector2(100, 100) } });
assertGroups();
}
[Test]
public void TestRemoveObject()
{
addObjectsStep(() => new OsuHitObject[] { new HitCircle { Position = new Vector2(100, 100) } });
removeObjectStep(() => getObject(0));
assertGroups();
}
[Test]
public void TestAddMultipleObjects()
{
addMultipleObjectsStep();
assertGroups();
}
[Test]
public void TestRemoveEndObject()
{
addMultipleObjectsStep();
removeObjectStep(() => getObject(4));
assertGroups();
}
[Test]
public void TestRemoveStartObject()
{
addMultipleObjectsStep();
removeObjectStep(() => getObject(0));
assertGroups();
}
[Test]
public void TestRemoveMiddleObject()
{
addMultipleObjectsStep();
removeObjectStep(() => getObject(2));
assertGroups();
}
[Test]
public void TestMoveObject()
{
addMultipleObjectsStep();
AddStep("move hitobject", () => getObject(2).HitObject.Position = new Vector2(300, 100));
assertGroups();
}
[TestCase(0, 0)] // Start -> Start
[TestCase(0, 2)] // Start -> Middle
[TestCase(0, 5)] // Start -> End
[TestCase(2, 0)] // Middle -> Start
[TestCase(1, 3)] // Middle -> Middle (forwards)
[TestCase(3, 1)] // Middle -> Middle (backwards)
[TestCase(4, 0)] // End -> Start
[TestCase(4, 2)] // End -> Middle
[TestCase(4, 4)] // End -> End
public void TestReorderObjects(int startIndex, int endIndex)
{
addMultipleObjectsStep();
reorderObjectStep(startIndex, endIndex);
assertGroups();
}
private void addMultipleObjectsStep() => addObjectsStep(() => new OsuHitObject[]
{
new HitCircle { Position = new Vector2(100, 100) },
new HitCircle { Position = new Vector2(200, 200) },
new HitCircle { Position = new Vector2(300, 300) },
new HitCircle { Position = new Vector2(400, 400) },
new HitCircle { Position = new Vector2(500, 500) },
});
private void addObjectsStep(Func<OsuHitObject[]> ctorFunc)
{
AddStep("add hitobjects", () =>
{
var objects = ctorFunc();
for (int i = 0; i < objects.Length; i++)
{
objects[i].StartTime = Time.Current + 1000 + 500 * (i + 1);
objects[i].ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
DrawableOsuHitObject drawableObject = null;
switch (objects[i])
{
case HitCircle circle:
drawableObject = new DrawableHitCircle(circle);
break;
case Slider slider:
drawableObject = new DrawableSlider(slider);
break;
case Spinner spinner:
drawableObject = new DrawableSpinner(spinner);
break;
}
hitObjectContainer.Add(drawableObject);
followPointRenderer.AddFollowPoints(drawableObject);
}
});
}
private void removeObjectStep(Func<DrawableOsuHitObject> getFunc)
{
AddStep("remove hitobject", () =>
{
var drawableObject = getFunc?.Invoke();
hitObjectContainer.Remove(drawableObject);
followPointRenderer.RemoveFollowPoints(drawableObject);
});
}
private void reorderObjectStep(int startIndex, int endIndex)
{
AddStep($"move object {startIndex} to {endIndex}", () =>
{
DrawableOsuHitObject toReorder = getObject(startIndex);
double targetTime;
if (endIndex < hitObjectContainer.Count)
targetTime = getObject(endIndex).HitObject.StartTime - 1;
else
targetTime = getObject(hitObjectContainer.Count - 1).HitObject.StartTime + 1;
hitObjectContainer.Remove(toReorder);
toReorder.HitObject.StartTime = targetTime;
hitObjectContainer.Add(toReorder);
});
}
private void assertGroups()
{
AddAssert("has correct group count", () => followPointRenderer.Connections.Count == hitObjectContainer.Count);
AddAssert("group endpoints are correct", () =>
{
for (int i = 0; i < hitObjectContainer.Count; i++)
{
DrawableOsuHitObject expectedStart = getObject(i);
DrawableOsuHitObject expectedEnd = i < hitObjectContainer.Count - 1 ? getObject(i + 1) : null;
if (getGroup(i).Start != expectedStart)
throw new AssertionException($"Object {i} expected to be the start of group {i}.");
if (getGroup(i).End != expectedEnd)
throw new AssertionException($"Object {(expectedEnd == null ? "null" : i.ToString())} expected to be the end of group {i}.");
}
return true;
});
}
private DrawableOsuHitObject getObject(int index) => hitObjectContainer[index];
private FollowPointConnection getGroup(int index) => followPointRenderer.Connections[index];
private class TestHitObjectContainer : Container<DrawableOsuHitObject>
{
protected override int Compare(Drawable x, Drawable y)
{
var osuX = (DrawableOsuHitObject)x;
var osuY = (DrawableOsuHitObject)y;
int compare = osuX.HitObject.StartTime.CompareTo(osuY.HitObject.StartTime);
if (compare == 0)
return base.Compare(x, y);
return compare;
}
}
}
}

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.MathUtils;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
@ -38,26 +39,33 @@ namespace osu.Game.Rulesets.Osu.Tests
[Cached]
private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor();
[Cached(typeof(IDistanceSnapProvider))]
private readonly SnapProvider snapProvider = new SnapProvider();
private TestOsuDistanceSnapGrid grid;
public TestSceneOsuDistanceSnapGrid()
{
editorBeatmap = new EditorBeatmap<OsuHitObject>(new OsuBeatmap());
createGrid();
}
[SetUp]
public void Setup() => Schedule(() =>
{
Clear();
editorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1;
editorBeatmap.ControlPointInfo.DifficultyPoints.Clear();
editorBeatmap.ControlPointInfo.TimingPoints.Clear();
editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beat_length });
editorBeatmap.ControlPointInfo.Clear();
editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length });
beatDivisor.Value = 1;
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.SlateGray
},
grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }),
new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }
};
});
[TestCase(1)]
@ -71,53 +79,11 @@ namespace osu.Game.Rulesets.Osu.Tests
public void TestBeatDivisor(int divisor)
{
AddStep($"set beat divisor = {divisor}", () => beatDivisor.Value = divisor);
createGrid();
}
[TestCase(100, 100)]
[TestCase(200, 100)]
public void TestBeatLength(float beatLength, float expectedSpacing)
{
AddStep($"set beat length = {beatLength}", () =>
{
editorBeatmap.ControlPointInfo.TimingPoints.Clear();
editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beatLength });
});
createGrid();
AddAssert($"spacing = {expectedSpacing}", () => Precision.AlmostEquals(expectedSpacing, grid.DistanceSpacing));
}
[TestCase(0.5f, 50)]
[TestCase(1, 100)]
[TestCase(1.5f, 150)]
public void TestSpeedMultiplier(float multiplier, float expectedSpacing)
{
AddStep($"set speed multiplier = {multiplier}", () =>
{
editorBeatmap.ControlPointInfo.DifficultyPoints.Clear();
editorBeatmap.ControlPointInfo.DifficultyPoints.Add(new DifficultyControlPoint { SpeedMultiplier = multiplier });
});
createGrid();
AddAssert($"spacing = {expectedSpacing}", () => Precision.AlmostEquals(expectedSpacing, grid.DistanceSpacing));
}
[TestCase(0.5f, 50)]
[TestCase(1, 100)]
[TestCase(1.5f, 150)]
public void TestSliderMultiplier(float multiplier, float expectedSpacing)
{
AddStep($"set speed multiplier = {multiplier}", () => editorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = multiplier);
createGrid();
AddAssert($"spacing = {expectedSpacing}", () => Precision.AlmostEquals(expectedSpacing, grid.DistanceSpacing));
}
[Test]
public void TestCursorInCentre()
{
createGrid();
AddStep("move mouse to centre", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position)));
assertSnappedDistance((float)beat_length);
}
@ -125,8 +91,6 @@ namespace osu.Game.Rulesets.Osu.Tests
[Test]
public void TestCursorBeforeMovementPoint()
{
createGrid();
AddStep("move mouse to just before movement point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 1.49f)));
assertSnappedDistance((float)beat_length);
}
@ -134,23 +98,14 @@ namespace osu.Game.Rulesets.Osu.Tests
[Test]
public void TestCursorAfterMovementPoint()
{
createGrid();
AddStep("move mouse to just after movement point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 1.51f)));
assertSnappedDistance((float)beat_length * 2);
}
private void assertSnappedDistance(float expectedDistance) => AddAssert($"snap distance = {expectedDistance}", () =>
[Test]
public void TestLimitedDistance()
{
Vector2 snappedPosition = grid.GetSnapPosition(grid.ToLocalSpace(InputManager.CurrentState.Mouse.Position));
float distance = Vector2.Distance(snappedPosition, grid_position);
return Precision.AlmostEquals(expectedDistance, distance);
});
private void createGrid()
{
AddStep("create grid", () =>
AddStep("create limited grid", () =>
{
Children = new Drawable[]
{
@ -159,12 +114,22 @@ namespace osu.Game.Rulesets.Osu.Tests
RelativeSizeAxes = Axes.Both,
Colour = Color4.SlateGray
},
grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }),
new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnapPosition(grid.ToLocalSpace(v)) }
grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }, new HitCircle { StartTime = 200 }),
new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }
};
});
AddStep("move mouse outside grid", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 3f)));
assertSnappedDistance((float)beat_length * 2);
}
private void assertSnappedDistance(float expectedDistance) => AddAssert($"snap distance = {expectedDistance}", () =>
{
Vector2 snappedPosition = grid.GetSnappedPosition(grid.ToLocalSpace(InputManager.CurrentState.Mouse.Position)).position;
return Precision.AlmostEquals(expectedDistance, Vector2.Distance(snappedPosition, grid_position));
});
private class SnappingCursorContainer : CompositeDrawable
{
public Func<Vector2, Vector2> GetSnapPosition;
@ -208,10 +173,25 @@ namespace osu.Game.Rulesets.Osu.Tests
{
public new float DistanceSpacing => base.DistanceSpacing;
public TestOsuDistanceSnapGrid(OsuHitObject hitObject)
: base(hitObject)
public TestOsuDistanceSnapGrid(OsuHitObject hitObject, OsuHitObject nextHitObject = null)
: base(hitObject, nextHitObject)
{
}
}
private class SnapProvider : IDistanceSnapProvider
{
public (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time) => (position, time);
public float GetBeatSnapDistanceAt(double referenceTime) => (float)beat_length;
public float DurationToDistance(double referenceTime, double duration) => (float)duration;
public double DistanceToDuration(double referenceTime, float distance) => distance;
public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0;
public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => 0;
}
}
}

View File

@ -308,7 +308,7 @@ namespace osu.Game.Rulesets.Osu.Tests
private Drawable createDrawable(Slider slider, float circleSize, double speedMultiplier)
{
var cpi = new ControlPointInfo();
cpi.DifficultyPoints.Add(new DifficultyControlPoint { SpeedMultiplier = speedMultiplier });
cpi.Add(0, new DifficultyControlPoint { SpeedMultiplier = speedMultiplier });
slider.ApplyDefaults(cpi, new BeatmapDifficulty { CircleSize = circleSize, SliderTickRate = 3 });

View File

@ -313,10 +313,6 @@ namespace osu.Game.Rulesets.Osu.Tests
}, 25),
}
},
ControlPointInfo =
{
DifficultyPoints = { new DifficultyControlPoint { SpeedMultiplier = 0.1f } }
},
BeatmapInfo =
{
BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 3 },
@ -324,6 +320,8 @@ namespace osu.Game.Rulesets.Osu.Tests
},
});
Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f });
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
p.OnLoadComplete += _ =>

View File

@ -16,6 +16,7 @@ using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Tests.Visual;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Tests
{
@ -85,6 +86,93 @@ namespace osu.Game.Rulesets.Osu.Tests
checkPositions();
}
[Test]
public void TestSingleControlPointSelection()
{
moveMouseToControlPoint(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
checkControlPointSelected(0, true);
checkControlPointSelected(1, false);
}
[Test]
public void TestSingleControlPointDeselectionViaOtherControlPoint()
{
moveMouseToControlPoint(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveMouseToControlPoint(1);
AddStep("click", () => InputManager.Click(MouseButton.Left));
checkControlPointSelected(0, false);
checkControlPointSelected(1, true);
}
[Test]
public void TestSingleControlPointDeselectionViaClickOutside()
{
moveMouseToControlPoint(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddStep("move mouse outside control point", () => InputManager.MoveMouseTo(drawableObject));
AddStep("click", () => InputManager.Click(MouseButton.Left));
checkControlPointSelected(0, false);
checkControlPointSelected(1, false);
}
[Test]
public void TestMultipleControlPointSelection()
{
moveMouseToControlPoint(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveMouseToControlPoint(1);
AddStep("ctrl + click", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Click(MouseButton.Left);
InputManager.ReleaseKey(Key.ControlLeft);
});
checkControlPointSelected(0, true);
checkControlPointSelected(1, true);
}
[Test]
public void TestMultipleControlPointDeselectionViaOtherControlPoint()
{
moveMouseToControlPoint(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveMouseToControlPoint(1);
AddStep("ctrl + click", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Click(MouseButton.Left);
InputManager.ReleaseKey(Key.ControlLeft);
});
moveMouseToControlPoint(2);
AddStep("click", () => InputManager.Click(MouseButton.Left));
checkControlPointSelected(0, false);
checkControlPointSelected(1, false);
}
[Test]
public void TestMultipleControlPointDeselectionViaClickOutside()
{
moveMouseToControlPoint(0);
AddStep("click", () => InputManager.Click(MouseButton.Left));
moveMouseToControlPoint(1);
AddStep("ctrl + click", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Click(MouseButton.Left);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddStep("move mouse outside control point", () => InputManager.MoveMouseTo(drawableObject));
AddStep("click", () => InputManager.Click(MouseButton.Left));
checkControlPointSelected(0, false);
checkControlPointSelected(1, false);
}
private void moveHitObject()
{
AddStep("move hitobject", () =>
@ -104,11 +192,24 @@ namespace osu.Game.Rulesets.Osu.Tests
() => Precision.AlmostEquals(blueprint.TailBlueprint.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre));
}
private void moveMouseToControlPoint(int index)
{
AddStep($"move mouse to control point {index}", () =>
{
Vector2 position = slider.Position + slider.Path.ControlPoints[index];
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position));
});
}
private void checkControlPointSelected(int index, bool selected)
=> AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser.Pieces[index].IsSelected.Value == selected);
private class TestSliderBlueprint : SliderSelectionBlueprint
{
public new SliderBodyPiece BodyPiece => base.BodyPiece;
public new TestSliderCircleBlueprint HeadBlueprint => (TestSliderCircleBlueprint)base.HeadBlueprint;
public new TestSliderCircleBlueprint TailBlueprint => (TestSliderCircleBlueprint)base.TailBlueprint;
public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser;
public TestSliderBlueprint(DrawableSlider slider)
: base(slider)

View File

@ -1,26 +1,33 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Lines;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{
public class PathControlPointPiece : BlueprintPiece<Slider>
{
private readonly Slider slider;
private readonly int index;
public Action<int> RequestSelection;
public Action<Vector2[]> ControlPointsChanged;
public readonly BindableBool IsSelected = new BindableBool();
public readonly int Index;
private readonly Slider slider;
private readonly Path path;
private readonly CircularContainer marker;
private readonly Container marker;
private readonly Drawable markerRing;
[Resolved]
private OsuColour colours { get; set; }
@ -28,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public PathControlPointPiece(Slider slider, int index)
{
this.slider = slider;
this.index = index;
Index = index;
Origin = Anchor.Centre;
AutoSizeAxes = Axes.Both;
@ -40,13 +47,36 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
Anchor = Anchor.Centre,
PathRadius = 1
},
marker = new CircularContainer
marker = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(10),
Masking = true,
Child = new Box { RelativeSizeAxes = Axes.Both }
AutoSizeAxes = Axes.Both,
Children = new[]
{
new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(10),
},
markerRing = new CircularContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(14),
Masking = true,
BorderThickness = 2,
BorderColour = Color4.White,
Alpha = 0,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true
}
}
}
}
};
}
@ -55,30 +85,66 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{
base.Update();
Position = slider.StackedPosition + slider.Path.ControlPoints[index];
Position = slider.StackedPosition + slider.Path.ControlPoints[Index];
marker.Colour = isSegmentSeparator ? colours.Red : colours.Yellow;
updateMarkerDisplay();
updateConnectingPath();
}
/// <summary>
/// Updates the state of the circular control point marker.
/// </summary>
private void updateMarkerDisplay()
{
markerRing.Alpha = IsSelected.Value ? 1 : 0;
Color4 colour = isSegmentSeparator ? colours.Red : colours.Yellow;
if (IsHovered || IsSelected.Value)
colour = Color4.White;
marker.Colour = colour;
}
/// <summary>
/// Updates the path connecting this control point to the previous one.
/// </summary>
private void updateConnectingPath()
{
path.ClearVertices();
if (index != slider.Path.ControlPoints.Length - 1)
if (Index != slider.Path.ControlPoints.Length - 1)
{
path.AddVertex(Vector2.Zero);
path.AddVertex(slider.Path.ControlPoints[index + 1] - slider.Path.ControlPoints[index]);
path.AddVertex(slider.Path.ControlPoints[Index + 1] - slider.Path.ControlPoints[Index]);
}
path.OriginPosition = path.PositionInBoundingBox(Vector2.Zero);
}
// The connecting path is excluded from positional input
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => marker.ReceivePositionalInputAt(screenSpacePos);
protected override bool OnMouseDown(MouseDownEvent e)
{
if (RequestSelection != null)
{
RequestSelection.Invoke(Index);
return true;
}
return false;
}
protected override bool OnMouseUp(MouseUpEvent e) => RequestSelection != null;
protected override bool OnClick(ClickEvent e) => RequestSelection != null;
protected override bool OnDragStart(DragStartEvent e) => true;
protected override bool OnDrag(DragEvent e)
{
var newControlPoints = slider.Path.ControlPoints.ToArray();
if (index == 0)
if (Index == 0)
{
// Special handling for the head - only the position of the slider changes
slider.Position += e.Delta;
@ -88,15 +154,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
newControlPoints[i] -= e.Delta;
}
else
newControlPoints[index] += e.Delta;
newControlPoints[Index] += e.Delta;
if (isSegmentSeparatorWithNext)
newControlPoints[index + 1] = newControlPoints[index];
newControlPoints[Index + 1] = newControlPoints[Index];
if (isSegmentSeparatorWithPrevious)
newControlPoints[index - 1] = newControlPoints[index];
newControlPoints[Index - 1] = newControlPoints[Index];
slider.Path = new SliderPath(slider.Path.Type, newControlPoints);
ControlPointsChanged?.Invoke(newControlPoints);
return true;
}
@ -105,8 +171,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
private bool isSegmentSeparator => isSegmentSeparatorWithNext || isSegmentSeparatorWithPrevious;
private bool isSegmentSeparatorWithNext => index < slider.Path.ControlPoints.Length - 1 && slider.Path.ControlPoints[index + 1] == slider.Path.ControlPoints[index];
private bool isSegmentSeparatorWithNext => Index < slider.Path.ControlPoints.Length - 1 && slider.Path.ControlPoints[Index + 1] == slider.Path.ControlPoints[Index];
private bool isSegmentSeparatorWithPrevious => index > 0 && slider.Path.ControlPoints[index - 1] == slider.Path.ControlPoints[index];
private bool isSegmentSeparatorWithPrevious => Index > 0 && slider.Path.ControlPoints[Index - 1] == slider.Path.ControlPoints[Index];
}
}

View File

@ -1,33 +1,133 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{
public class PathControlPointVisualiser : CompositeDrawable
public class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler<PlatformAction>
{
public Action<Vector2[]> ControlPointsChanged;
internal readonly Container<PathControlPointPiece> Pieces;
private readonly Slider slider;
private readonly bool allowSelection;
private readonly Container<PathControlPointPiece> pieces;
private InputManager inputManager;
public PathControlPointVisualiser(Slider slider)
[Resolved(CanBeNull = true)]
private IPlacementHandler placementHandler { get; set; }
public PathControlPointVisualiser(Slider slider, bool allowSelection)
{
this.slider = slider;
this.allowSelection = allowSelection;
InternalChild = pieces = new Container<PathControlPointPiece> { RelativeSizeAxes = Axes.Both };
RelativeSizeAxes = Axes.Both;
InternalChild = Pieces = new Container<PathControlPointPiece> { RelativeSizeAxes = Axes.Both };
}
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
}
protected override void Update()
{
base.Update();
while (slider.Path.ControlPoints.Length > pieces.Count)
pieces.Add(new PathControlPointPiece(slider, pieces.Count));
while (slider.Path.ControlPoints.Length < pieces.Count)
pieces.Remove(pieces[pieces.Count - 1]);
while (slider.Path.ControlPoints.Length > Pieces.Count)
{
var piece = new PathControlPointPiece(slider, Pieces.Count)
{
ControlPointsChanged = c => ControlPointsChanged?.Invoke(c),
};
if (allowSelection)
piece.RequestSelection = selectPiece;
Pieces.Add(piece);
}
while (slider.Path.ControlPoints.Length < Pieces.Count)
Pieces.Remove(Pieces[Pieces.Count - 1]);
}
protected override bool OnClick(ClickEvent e)
{
foreach (var piece in Pieces)
piece.IsSelected.Value = false;
return false;
}
private void selectPiece(int index)
{
if (inputManager.CurrentState.Keyboard.ControlPressed)
Pieces[index].IsSelected.Toggle();
else
{
foreach (var piece in Pieces)
piece.IsSelected.Value = piece.Index == index;
}
}
public bool OnPressed(PlatformAction action)
{
switch (action.ActionMethod)
{
case PlatformActionMethod.Delete:
var newControlPoints = new List<Vector2>();
foreach (var piece in Pieces)
{
if (!piece.IsSelected.Value)
newControlPoints.Add(slider.Path.ControlPoints[piece.Index]);
}
// Ensure that there are any points to be deleted
if (newControlPoints.Count == slider.Path.ControlPoints.Length)
return false;
// If there are 0 remaining control points, treat the slider as being deleted
if (newControlPoints.Count == 0)
{
placementHandler?.Delete(slider);
return true;
}
// Make control points relative
Vector2 first = newControlPoints[0];
for (int i = 0; i < newControlPoints.Count; i++)
newControlPoints[i] = newControlPoints[i] - first;
// The slider's position defines the position of the first control point, and all further control points are relative to that point
slider.Position = slider.Position + first;
// Since pieces are re-used, they will not point to the deleted control points while remaining selected
foreach (var piece in Pieces)
piece.IsSelected.Value = false;
ControlPointsChanged?.Invoke(newControlPoints.ToArray());
return true;
}
return false;
}
public bool OnReleased(PlatformAction action) => action.ActionMethod == PlatformActionMethod.Delete;
}
}

View File

@ -43,5 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
Size = body.Size;
OriginPosition = body.PathOffset;
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => body.ReceivePositionalInputAt(screenSpacePos);
}
}

View File

@ -33,6 +33,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private PlacementState state;
[Resolved(CanBeNull = true)]
private HitObjectComposer composer { get; set; }
public SliderPlacementBlueprint()
: base(new Objects.Slider())
{
@ -48,7 +51,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
bodyPiece = new SliderBodyPiece(),
headCirclePiece = new HitCirclePiece(),
tailCirclePiece = new HitCirclePiece(),
new PathControlPointVisualiser(HitObject),
new PathControlPointVisualiser(HitObject, false) { ControlPointsChanged = _ => updateSlider() },
};
setState(PlacementState.Initial);
@ -131,8 +134,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void updateSlider()
{
var newControlPoints = segments.SelectMany(s => s.ControlPoints).Concat(cursor.Yield()).ToArray();
HitObject.Path = new SliderPath(newControlPoints.Length > 2 ? PathType.Bezier : PathType.Linear, newControlPoints);
Vector2[] newControlPoints = segments.SelectMany(s => s.ControlPoints).Concat(cursor.Yield()).ToArray();
var unsnappedPath = new SliderPath(newControlPoints.Length > 2 ? PathType.Bezier : PathType.Linear, newControlPoints);
var snappedDistance = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)unsnappedPath.Distance) ?? (float)unsnappedPath.Distance;
HitObject.Path = new SliderPath(unsnappedPath.Type, newControlPoints, snappedDistance);
bodyPiece.UpdateFrom(HitObject);
headCirclePiece.UpdateFrom(HitObject.HeadCircle);

View File

@ -1,7 +1,11 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
@ -14,6 +18,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
protected readonly SliderBodyPiece BodyPiece;
protected readonly SliderCircleSelectionBlueprint HeadBlueprint;
protected readonly SliderCircleSelectionBlueprint TailBlueprint;
protected readonly PathControlPointVisualiser ControlPointVisualiser;
[Resolved(CanBeNull = true)]
private HitObjectComposer composer { get; set; }
public SliderSelectionBlueprint(DrawableSlider slider)
: base(slider)
@ -25,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
BodyPiece = new SliderBodyPiece(),
HeadBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.Start),
TailBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.End),
new PathControlPointVisualiser(sliderObject),
ControlPointVisualiser = new PathControlPointVisualiser(sliderObject, true) { ControlPointsChanged = onNewControlPoints },
};
}
@ -36,8 +44,20 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
BodyPiece.UpdateFrom(HitObject);
}
private void onNewControlPoints(Vector2[] controlPoints)
{
var unsnappedPath = new SliderPath(controlPoints.Length > 2 ? PathType.Bezier : PathType.Linear, controlPoints);
var snappedDistance = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)unsnappedPath.Distance) ?? (float)unsnappedPath.Distance;
HitObject.Path = new SliderPath(unsnappedPath.Type, controlPoints, snappedDistance);
UpdateHitObject();
}
public override Vector2 SelectionPoint => HeadBlueprint.SelectionPoint;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => BodyPiece.ReceivePositionalInputAt(screenSpacePos);
protected virtual SliderCircleSelectionBlueprint CreateCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) => new SliderCircleSelectionBlueprint(slider, position);
}
}

View File

@ -1,8 +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 osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components;
@ -10,20 +8,10 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public class OsuDistanceSnapGrid : CircularDistanceSnapGrid
{
public OsuDistanceSnapGrid(OsuHitObject hitObject)
: base(hitObject, hitObject.StackedEndPosition)
public OsuDistanceSnapGrid(OsuHitObject hitObject, OsuHitObject nextHitObject)
: base(hitObject, nextHitObject, hitObject.StackedEndPosition)
{
Masking = true;
}
protected override float GetVelocity(double time, ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty)
{
TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(time);
DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(time);
double scoringDistance = OsuHitObject.BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier;
return (float)(scoringDistance / timingPoint.BeatLength);
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
@ -60,25 +61,40 @@ namespace osu.Game.Rulesets.Osu.Edit
var objects = selectedHitObjects.ToList();
if (objects.Count == 0)
return createGrid(h => h.StartTime <= EditorClock.CurrentTime);
double minTime = objects.Min(h => h.StartTime);
return createGrid(h => h.StartTime < minTime, objects.Count + 1);
}
/// <summary>
/// Creates a grid from the last <see cref="HitObject"/> matching a predicate to a target <see cref="HitObject"/>.
/// </summary>
/// <param name="sourceSelector">A predicate that matches <see cref="HitObject"/>s where the grid can start from.
/// Only the last <see cref="HitObject"/> matching the predicate is used.</param>
/// <param name="targetOffset">An offset from the <see cref="HitObject"/> selected via <paramref name="sourceSelector"/> at which the grid should stop.</param>
/// <returns>The <see cref="OsuDistanceSnapGrid"/> from a selected <see cref="HitObject"/> to a target <see cref="HitObject"/>.</returns>
private OsuDistanceSnapGrid createGrid(Func<HitObject, bool> sourceSelector, int targetOffset = 1)
{
if (targetOffset < 1) throw new ArgumentOutOfRangeException(nameof(targetOffset));
int sourceIndex = -1;
for (int i = 0; i < EditorBeatmap.HitObjects.Count; i++)
{
var lastObject = EditorBeatmap.HitObjects.LastOrDefault(h => h.StartTime <= EditorClock.CurrentTime);
if (!sourceSelector(EditorBeatmap.HitObjects[i]))
break;
if (lastObject == null)
return null;
return new OsuDistanceSnapGrid(lastObject);
sourceIndex = i;
}
else
{
double minTime = objects.Min(h => h.StartTime);
var lastObject = EditorBeatmap.HitObjects.LastOrDefault(h => h.StartTime < minTime);
if (sourceIndex == -1)
return null;
if (lastObject == null)
return null;
OsuHitObject sourceObject = EditorBeatmap.HitObjects[sourceIndex];
OsuHitObject targetObject = sourceIndex + targetOffset < EditorBeatmap.HitObjects.Count ? EditorBeatmap.HitObjects[sourceIndex + targetOffset] : null;
return new OsuDistanceSnapGrid(lastObject);
}
return new OsuDistanceSnapGrid(sourceObject, targetObject);
}
}
}

View File

@ -4,13 +4,34 @@
using System.Linq;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public class OsuSelectionHandler : SelectionHandler
{
public override void HandleMovement(MoveSelectionEvent moveEvent)
public override bool HandleMovement(MoveSelectionEvent moveEvent)
{
Vector2 minPosition = new Vector2(float.MaxValue, float.MaxValue);
Vector2 maxPosition = new Vector2(float.MinValue, float.MinValue);
// Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted
foreach (var h in SelectedHitObjects.OfType<OsuHitObject>())
{
if (h is Spinner)
{
// Spinners don't support position adjustments
continue;
}
// Stacking is not considered
minPosition = Vector2.ComponentMin(minPosition, Vector2.ComponentMin(h.EndPosition + moveEvent.InstantDelta, h.Position + moveEvent.InstantDelta));
maxPosition = Vector2.ComponentMax(maxPosition, Vector2.ComponentMax(h.EndPosition + moveEvent.InstantDelta, h.Position + moveEvent.InstantDelta));
}
if (minPosition.X < 0 || minPosition.Y < 0 || maxPosition.X > DrawWidth || maxPosition.Y > DrawHeight)
return false;
foreach (var h in SelectedHitObjects.OfType<OsuHitObject>())
{
if (h is Spinner)
@ -22,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit
h.Position += moveEvent.InstantDelta;
}
base.HandleMovement(moveEvent);
return true;
}
}
}

View File

@ -1,21 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects;
using System.Collections.Generic;
namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections
{
/// <summary>
/// Connects hit objects visually, for example with follow points.
/// </summary>
public abstract class ConnectionRenderer<T> : LifetimeManagementContainer
where T : HitObject
{
/// <summary>
/// Hit objects to create connections for
/// </summary>
public abstract IEnumerable<T> HitObjects { get; set; }
}
}

View File

@ -12,6 +12,9 @@ using osu.Game.Skinning;
namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections
{
/// <summary>
/// A single follow point positioned between two adjacent <see cref="DrawableOsuHitObject"/>s.
/// </summary>
public class FollowPoint : Container
{
private const float width = 8;

View File

@ -0,0 +1,140 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections
{
/// <summary>
/// Visualises the <see cref="FollowPoint"/>s between two <see cref="DrawableOsuHitObject"/>s.
/// </summary>
public class FollowPointConnection : CompositeDrawable
{
// Todo: These shouldn't be constants
private const int spacing = 32;
private const double preempt = 800;
/// <summary>
/// The start time of <see cref="Start"/>.
/// </summary>
public readonly Bindable<double> StartTime = new Bindable<double>();
/// <summary>
/// The <see cref="DrawableOsuHitObject"/> which <see cref="FollowPoint"/>s will exit from.
/// </summary>
[NotNull]
public readonly DrawableOsuHitObject Start;
/// <summary>
/// Creates a new <see cref="FollowPointConnection"/>.
/// </summary>
/// <param name="start">The <see cref="DrawableOsuHitObject"/> which <see cref="FollowPoint"/>s will exit from.</param>
public FollowPointConnection([NotNull] DrawableOsuHitObject start)
{
Start = start;
RelativeSizeAxes = Axes.Both;
StartTime.BindTo(Start.HitObject.StartTimeBindable);
}
protected override void LoadComplete()
{
base.LoadComplete();
bindEvents(Start);
}
private DrawableOsuHitObject end;
/// <summary>
/// The <see cref="DrawableOsuHitObject"/> which <see cref="FollowPoint"/>s will enter.
/// </summary>
[CanBeNull]
public DrawableOsuHitObject End
{
get => end;
set
{
end = value;
if (end != null)
bindEvents(end);
if (IsLoaded)
scheduleRefresh();
else
refresh();
}
}
private void bindEvents(DrawableOsuHitObject drawableObject)
{
drawableObject.HitObject.PositionBindable.BindValueChanged(_ => scheduleRefresh());
drawableObject.HitObject.DefaultsApplied += scheduleRefresh;
}
private void scheduleRefresh() => Scheduler.AddOnce(refresh);
private void refresh()
{
ClearInternal();
if (End == null)
return;
OsuHitObject osuStart = Start.HitObject;
OsuHitObject osuEnd = End.HitObject;
if (osuEnd.NewCombo)
return;
if (osuStart is Spinner || osuEnd is Spinner)
return;
Vector2 startPosition = osuStart.EndPosition;
Vector2 endPosition = osuEnd.Position;
double startTime = (osuStart as IHasEndTime)?.EndTime ?? osuStart.StartTime;
double endTime = osuEnd.StartTime;
Vector2 distanceVector = endPosition - startPosition;
int distance = (int)distanceVector.Length;
float rotation = (float)(Math.Atan2(distanceVector.Y, distanceVector.X) * (180 / Math.PI));
double duration = endTime - startTime;
for (int d = (int)(spacing * 1.5); d < distance - spacing; d += spacing)
{
float fraction = (float)d / distance;
Vector2 pointStartPosition = startPosition + (fraction - 0.1f) * distanceVector;
Vector2 pointEndPosition = startPosition + fraction * distanceVector;
double fadeOutTime = startTime + fraction * duration;
double fadeInTime = fadeOutTime - preempt;
FollowPoint fp;
AddInternal(fp = new FollowPoint
{
Position = pointStartPosition,
Rotation = rotation,
Alpha = 0,
Scale = new Vector2(1.5f * osuEnd.Scale),
});
using (fp.BeginAbsoluteSequence(fadeInTime))
{
fp.FadeIn(osuEnd.TimeFadeIn);
fp.ScaleTo(osuEnd.Scale, osuEnd.TimeFadeIn, Easing.Out);
fp.MoveTo(pointEndPosition, osuEnd.TimeFadeIn, Easing.Out);
fp.Delay(fadeOutTime - fadeInTime).FadeOut(osuEnd.TimeFadeIn);
}
fp.Expire(true);
}
}
}
}

View File

@ -1,121 +1,110 @@
// 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 osuTK;
using System.Linq;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Objects.Types;
using osu.Framework.Graphics.Containers;
namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections
{
public class FollowPointRenderer : ConnectionRenderer<OsuHitObject>
/// <summary>
/// Visualises connections between <see cref="DrawableOsuHitObject"/>s.
/// </summary>
public class FollowPointRenderer : CompositeDrawable
{
private int pointDistance = 32;
/// <summary>
/// Determines how much space there is between points.
/// All the <see cref="FollowPointConnection"/>s contained by this <see cref="FollowPointRenderer"/>.
/// </summary>
public int PointDistance
{
get => pointDistance;
set
{
if (pointDistance == value) return;
internal IReadOnlyList<FollowPointConnection> Connections => connections;
pointDistance = value;
update();
}
}
private int preEmpt = 800;
/// <summary>
/// Follow points to the next hitobject start appearing for this many milliseconds before an hitobject's end time.
/// </summary>
public int PreEmpt
{
get => preEmpt;
set
{
if (preEmpt == value) return;
preEmpt = value;
update();
}
}
private IEnumerable<OsuHitObject> hitObjects;
public override IEnumerable<OsuHitObject> HitObjects
{
get => hitObjects;
set
{
hitObjects = value;
update();
}
}
private readonly List<FollowPointConnection> connections = new List<FollowPointConnection>();
public override bool RemoveCompletedTransforms => false;
private void update()
/// <summary>
/// Adds the <see cref="FollowPoint"/>s around a <see cref="DrawableOsuHitObject"/>.
/// This includes <see cref="FollowPoint"/>s leading into <paramref name="hitObject"/>, and <see cref="FollowPoint"/>s exiting <paramref name="hitObject"/>.
/// </summary>
/// <param name="hitObject">The <see cref="DrawableOsuHitObject"/> to add <see cref="FollowPoint"/>s for.</param>
public void AddFollowPoints(DrawableOsuHitObject hitObject)
=> addConnection(new FollowPointConnection(hitObject).With(g => g.StartTime.BindValueChanged(_ => onStartTimeChanged(g))));
/// <summary>
/// Removes the <see cref="FollowPoint"/>s around a <see cref="DrawableOsuHitObject"/>.
/// This includes <see cref="FollowPoint"/>s leading into <paramref name="hitObject"/>, and <see cref="FollowPoint"/>s exiting <paramref name="hitObject"/>.
/// </summary>
/// <param name="hitObject">The <see cref="DrawableOsuHitObject"/> to remove <see cref="FollowPoint"/>s for.</param>
public void RemoveFollowPoints(DrawableOsuHitObject hitObject) => removeGroup(connections.Single(g => g.Start == hitObject));
/// <summary>
/// Adds a <see cref="FollowPointConnection"/> to this <see cref="FollowPointRenderer"/>.
/// </summary>
/// <param name="connection">The <see cref="FollowPointConnection"/> to add.</param>
/// <returns>The index of <paramref name="connection"/> in <see cref="connections"/>.</returns>
private void addConnection(FollowPointConnection connection)
{
ClearInternal();
AddInternal(connection);
if (hitObjects == null)
return;
// Groups are sorted by their start time when added such that the index can be used to post-process other surrounding connections
int index = connections.AddInPlace(connection, Comparer<FollowPointConnection>.Create((g1, g2) => g1.StartTime.Value.CompareTo(g2.StartTime.Value)));
OsuHitObject prevHitObject = null;
foreach (var currHitObject in hitObjects)
if (index < connections.Count - 1)
{
if (prevHitObject != null && !currHitObject.NewCombo && !(prevHitObject is Spinner) && !(currHitObject is Spinner))
{
Vector2 startPosition = prevHitObject.EndPosition;
Vector2 endPosition = currHitObject.Position;
double startTime = (prevHitObject as IHasEndTime)?.EndTime ?? prevHitObject.StartTime;
double endTime = currHitObject.StartTime;
// Update the connection's end point to the next connection's start point
// h1 -> -> -> h2
// connection nextGroup
Vector2 distanceVector = endPosition - startPosition;
int distance = (int)distanceVector.Length;
float rotation = (float)(Math.Atan2(distanceVector.Y, distanceVector.X) * (180 / Math.PI));
double duration = endTime - startTime;
for (int d = (int)(PointDistance * 1.5); d < distance - PointDistance; d += PointDistance)
{
float fraction = (float)d / distance;
Vector2 pointStartPosition = startPosition + (fraction - 0.1f) * distanceVector;
Vector2 pointEndPosition = startPosition + fraction * distanceVector;
double fadeOutTime = startTime + fraction * duration;
double fadeInTime = fadeOutTime - PreEmpt;
FollowPoint fp;
AddInternal(fp = new FollowPoint
{
Position = pointStartPosition,
Rotation = rotation,
Alpha = 0,
Scale = new Vector2(1.5f * currHitObject.Scale),
});
using (fp.BeginAbsoluteSequence(fadeInTime))
{
fp.FadeIn(currHitObject.TimeFadeIn);
fp.ScaleTo(currHitObject.Scale, currHitObject.TimeFadeIn, Easing.Out);
fp.MoveTo(pointEndPosition, currHitObject.TimeFadeIn, Easing.Out);
fp.Delay(fadeOutTime - fadeInTime).FadeOut(currHitObject.TimeFadeIn);
}
fp.Expire(true);
}
}
prevHitObject = currHitObject;
FollowPointConnection nextConnection = connections[index + 1];
connection.End = nextConnection.Start;
}
else
{
// The end point may be non-null during re-ordering
connection.End = null;
}
if (index > 0)
{
// Update the previous connection's end point to the current connection's start point
// h1 -> -> -> h2
// prevGroup connection
FollowPointConnection previousConnection = connections[index - 1];
previousConnection.End = connection.Start;
}
}
/// <summary>
/// Removes a <see cref="FollowPointConnection"/> from this <see cref="FollowPointRenderer"/>.
/// </summary>
/// <param name="connection">The <see cref="FollowPointConnection"/> to remove.</param>
/// <returns>Whether <paramref name="connection"/> was removed.</returns>
private void removeGroup(FollowPointConnection connection)
{
RemoveInternal(connection);
int index = connections.IndexOf(connection);
if (index > 0)
{
// Update the previous connection's end point to the next connection's start point
// h1 -> -> -> h2 -> -> -> h3
// prevGroup connection nextGroup
// The current connection's end point is used since there may not be a next connection
FollowPointConnection previousConnection = connections[index - 1];
previousConnection.End = connection.End;
}
connections.Remove(connection);
}
private void onStartTimeChanged(FollowPointConnection connection)
{
// Naive but can be improved if performance becomes an issue
removeGroup(connection);
addConnection(connection);
}
}
}

View File

@ -7,7 +7,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Game.Graphics.Sprites;
using osuTK.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Skinning;
@ -30,17 +29,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces
Children = new Drawable[]
{
new CircularContainer
new Container
{
Masking = true,
Origin = Anchor.Centre,
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Radius = 60,
Colour = Color4.White.Opacity(0.5f),
},
Child = new Box()
},
number = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText
{

View File

@ -37,6 +37,8 @@ namespace osu.Game.Rulesets.Osu.Objects
{
PathBindable.Value = value;
endPositionCache.Invalidate();
updateNestedPositions();
}
}
@ -48,14 +50,9 @@ namespace osu.Game.Rulesets.Osu.Objects
set
{
base.Position = value;
endPositionCache.Invalidate();
if (HeadCircle != null)
HeadCircle.Position = value;
if (TailCircle != null)
TailCircle.Position = EndPosition;
updateNestedPositions();
}
}
@ -197,6 +194,15 @@ namespace osu.Game.Rulesets.Osu.Objects
}
}
private void updateNestedPositions()
{
if (HeadCircle != null)
HeadCircle.Position = Position;
if (TailCircle != null)
TailCircle.Position = EndPosition;
}
private List<HitSampleInfo> getNodeSamples(int nodeIndex) =>
nodeIndex < NodeSamples.Count ? NodeSamples[nodeIndex] : Samples;

View File

@ -9,7 +9,6 @@ using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables.Connections;
using osu.Game.Rulesets.UI;
using System.Linq;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.UI.Cursor;
using osu.Game.Skinning;
@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.UI
{
private readonly ApproachCircleProxyContainer approachCircles;
private readonly JudgementContainer<DrawableOsuJudgement> judgementLayer;
private readonly ConnectionRenderer<OsuHitObject> connectionLayer;
private readonly FollowPointRenderer followPoints;
public static readonly Vector2 BASE_SIZE = new Vector2(512, 384);
@ -30,7 +29,7 @@ namespace osu.Game.Rulesets.Osu.UI
{
InternalChildren = new Drawable[]
{
connectionLayer = new FollowPointRenderer
followPoints = new FollowPointRenderer
{
RelativeSizeAxes = Axes.Both,
Depth = 2,
@ -64,11 +63,18 @@ namespace osu.Game.Rulesets.Osu.UI
};
base.Add(h);
followPoints.AddFollowPoints((DrawableOsuHitObject)h);
}
public override void PostProcess()
public override bool Remove(DrawableHitObject h)
{
connectionLayer.HitObjects = HitObjectContainer.Objects.Select(d => d.HitObject).OfType<OsuHitObject>();
bool result = base.Remove(h);
if (result)
followPoints.RemoveFollowPoints((DrawableOsuHitObject)h);
return result;
}
private void onNewResult(DrawableHitObject judgedObject, JudgementResult result)

View File

@ -38,9 +38,10 @@ namespace osu.Game.Rulesets.Osu.UI
});
}
public override void Show()
protected override void PopIn()
{
base.Show();
base.PopIn();
GameplayCursor.ActiveCursor.Hide();
cursorScaleContainer.MoveTo(GameplayCursor.ActiveCursor.Position);
clickToResumeCursor.Appear();
@ -55,13 +56,13 @@ namespace osu.Game.Rulesets.Osu.UI
}
}
public override void Hide()
protected override void PopOut()
{
base.PopOut();
localCursorContainer?.Expire();
localCursorContainer = null;
GameplayCursor.ActiveCursor.Show();
base.Hide();
GameplayCursor?.ActiveCursor?.Show();
}
protected override bool OnHover(HoverEvent e) => true;

View File

@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
AddStep("Reset height", () => changePlayfieldSize(6));
var controlPointInfo = new ControlPointInfo();
controlPointInfo.TimingPoints.Add(new TimingControlPoint());
controlPointInfo.Add(0, new TimingControlPoint());
WorkingBeatmap beatmap = CreateWorkingBeatmap(new Beatmap
{
@ -142,7 +142,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Good : HitResult.Great;
var cpi = new ControlPointInfo();
cpi.EffectPoints.Add(new EffectControlPoint { KiaiMode = kiai });
cpi.Add(0, new EffectControlPoint { KiaiMode = kiai });
Hit hit = new Hit();
hit.ApplyDefaults(cpi, new BeatmapDifficulty());
@ -157,7 +157,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Good : HitResult.Great;
var cpi = new ControlPointInfo();
cpi.EffectPoints.Add(new EffectControlPoint { KiaiMode = kiai });
cpi.Add(0, new EffectControlPoint { KiaiMode = kiai });
Hit hit = new Hit();
hit.ApplyDefaults(cpi, new BeatmapDifficulty());

View File

@ -19,12 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Audio
{
this.controlPoints = controlPoints;
IEnumerable<SampleControlPoint> samplePoints;
if (controlPoints.SamplePoints.Count == 0)
// Get the default sample point
samplePoints = new[] { controlPoints.SamplePointAt(double.MinValue) };
else
samplePoints = controlPoints.SamplePoints;
IEnumerable<SampleControlPoint> samplePoints = controlPoints.SamplePoints.Count == 0 ? new[] { controlPoints.SamplePointAt(double.MinValue) } : controlPoints.SamplePoints;
foreach (var s in samplePoints)
{

View File

@ -167,9 +167,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
var controlPoints = beatmap.ControlPointInfo;
Assert.AreEqual(4, controlPoints.TimingPoints.Count);
Assert.AreEqual(42, controlPoints.DifficultyPoints.Count);
Assert.AreEqual(42, controlPoints.SamplePoints.Count);
Assert.AreEqual(42, controlPoints.EffectPoints.Count);
Assert.AreEqual(5, controlPoints.DifficultyPoints.Count);
Assert.AreEqual(34, controlPoints.SamplePoints.Count);
Assert.AreEqual(8, controlPoints.EffectPoints.Count);
var timingPoint = controlPoints.TimingPointAt(0);
Assert.AreEqual(956, timingPoint.Time);
@ -191,7 +191,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.AreEqual(1.0, difficultyPoint.SpeedMultiplier);
difficultyPoint = controlPoints.DifficultyPointAt(48428);
Assert.AreEqual(48428, difficultyPoint.Time);
Assert.AreEqual(0, difficultyPoint.Time);
Assert.AreEqual(1.0, difficultyPoint.SpeedMultiplier);
difficultyPoint = controlPoints.DifficultyPointAt(116999);
@ -224,7 +224,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.IsFalse(effectPoint.OmitFirstBarLine);
effectPoint = controlPoints.EffectPointAt(119637);
Assert.AreEqual(119637, effectPoint.Time);
Assert.AreEqual(95901, effectPoint.Time);
Assert.IsFalse(effectPoint.KiaiMode);
Assert.IsFalse(effectPoint.OmitFirstBarLine);
}
@ -262,6 +262,21 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
[Test]
public void TestTimingPointResetsSpeedMultiplier()
{
var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };
using (var resStream = TestResources.OpenResource("timingpoint-speedmultiplier-reset.osu"))
using (var stream = new LineBufferedReader(resStream))
{
var controlPoints = decoder.Decode(stream).ControlPointInfo;
Assert.That(controlPoints.DifficultyPointAt(0).SpeedMultiplier, Is.EqualTo(0.5).Within(0.1));
Assert.That(controlPoints.DifficultyPointAt(2000).SpeedMultiplier, Is.EqualTo(1).Within(0.1));
}
}
[Test]
public void TestDecodeBeatmapColours()
{
@ -362,6 +377,23 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
[Test]
public void TestDecodeControlPointDifficultyChange()
{
var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };
using (var resStream = TestResources.OpenResource("controlpoint-difficulty-multiplier.osu"))
using (var stream = new LineBufferedReader(resStream))
{
var controlPointInfo = decoder.Decode(stream).ControlPointInfo;
Assert.That(controlPointInfo.DifficultyPointAt(5).SpeedMultiplier, Is.EqualTo(1));
Assert.That(controlPointInfo.DifficultyPointAt(1000).SpeedMultiplier, Is.EqualTo(10));
Assert.That(controlPointInfo.DifficultyPointAt(2000).SpeedMultiplier, Is.EqualTo(1.8518518518518519d));
Assert.That(controlPointInfo.DifficultyPointAt(3000).SpeedMultiplier, Is.EqualTo(0.5));
}
}
[Test]
public void TestDecodeControlPointCustomSampleBank()
{

View File

@ -273,6 +273,96 @@ namespace osu.Game.Tests.Chat
Assert.AreEqual(21, result.Links[0].Length);
}
[Test]
public void TestMarkdownFormatLinkWithInlineTitle()
{
Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [this link format](https://osu.ppy.sh \"osu!\") before..." });
Assert.AreEqual("I haven't seen this link format before...", result.DisplayContent);
Assert.AreEqual(1, result.Links.Count);
Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url);
Assert.AreEqual(15, result.Links[0].Index);
Assert.AreEqual(16, result.Links[0].Length);
}
[Test]
public void TestMarkdownFormatLinkWithInlineTitleAndEscapedQuotes()
{
Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [this link format](https://osu.ppy.sh \"inner quote \\\" just to confuse \") before..." });
Assert.AreEqual("I haven't seen this link format before...", result.DisplayContent);
Assert.AreEqual(1, result.Links.Count);
Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url);
Assert.AreEqual(15, result.Links[0].Index);
Assert.AreEqual(16, result.Links[0].Length);
}
[Test]
public void TestMarkdownFormatLinkWithUrlInTextAndInlineTitle()
{
Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [https://osu.ppy.sh](https://osu.ppy.sh \"https://osu.ppy.sh\") before..." });
Assert.AreEqual("I haven't seen https://osu.ppy.sh before...", result.DisplayContent);
Assert.AreEqual(1, result.Links.Count);
Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url);
Assert.AreEqual(15, result.Links[0].Index);
Assert.AreEqual(18, result.Links[0].Length);
}
[Test]
public void TestMarkdownFormatLinkWithUrlAndTextInTitle()
{
Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [oh no, text here! https://osu.ppy.sh](https://osu.ppy.sh) before..." });
Assert.AreEqual("I haven't seen oh no, text here! https://osu.ppy.sh before...", result.DisplayContent);
Assert.AreEqual(1, result.Links.Count);
Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url);
Assert.AreEqual(15, result.Links[0].Index);
Assert.AreEqual(36, result.Links[0].Length);
}
[Test]
public void TestMarkdownFormatLinkWithMisleadingUrlInText()
{
Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [https://google.com](https://osu.ppy.sh) before..." });
Assert.AreEqual("I haven't seen https://google.com before...", result.DisplayContent);
Assert.AreEqual(1, result.Links.Count);
Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url);
Assert.AreEqual(15, result.Links[0].Index);
Assert.AreEqual(18, result.Links[0].Length);
}
[Test]
public void TestMarkdownFormatLinkThatContractsIntoLargerLink()
{
Message result = MessageFormatter.FormatMessage(new Message { Content = "super broken https://[osu.ppy](https://reddit.com).sh/" });
Assert.AreEqual("super broken https://osu.ppy.sh/", result.DisplayContent);
Assert.AreEqual(1, result.Links.Count);
Assert.AreEqual("https://reddit.com", result.Links[0].Url);
Assert.AreEqual(21, result.Links[0].Index);
Assert.AreEqual(7, result.Links[0].Length);
}
[Test]
public void TestMarkdownFormatLinkDirectlyNextToRawLink()
{
// the raw link has a port at the end of it, so that the raw link regex terminates at the port and doesn't consume display text from the formatted one
Message result = MessageFormatter.FormatMessage(new Message { Content = "https://localhost:8080[https://osu.ppy.sh](https://osu.ppy.sh) should be two links" });
Assert.AreEqual("https://localhost:8080https://osu.ppy.sh should be two links", result.DisplayContent);
Assert.AreEqual(2, result.Links.Count);
Assert.AreEqual("https://localhost:8080", result.Links[0].Url);
Assert.AreEqual(0, result.Links[0].Index);
Assert.AreEqual(22, result.Links[0].Length);
Assert.AreEqual("https://osu.ppy.sh", result.Links[1].Url);
Assert.AreEqual(22, result.Links[1].Index);
Assert.AreEqual(18, result.Links[1].Length);
}
[Test]
public void TestChannelLink()
{

View File

@ -0,0 +1,194 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Editor
{
[HeadlessTest]
public class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene
{
private TestHitObjectComposer composer;
[SetUp]
public void Setup() => Schedule(() =>
{
Child = composer = new TestHitObjectComposer();
BeatDivisor.Value = 1;
composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1;
composer.EditorBeatmap.ControlPointInfo.Clear();
composer.EditorBeatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 1 });
composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 });
});
[TestCase(1)]
[TestCase(2)]
public void TestSliderMultiplier(float multiplier)
{
AddStep($"set multiplier = {multiplier}", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = multiplier);
assertSnapDistance(100 * multiplier);
}
[TestCase(1)]
[TestCase(2)]
public void TestSpeedMultiplier(float multiplier)
{
AddStep($"set multiplier = {multiplier}", () =>
{
composer.EditorBeatmap.ControlPointInfo.Clear();
composer.EditorBeatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = multiplier });
});
assertSnapDistance(100 * multiplier);
}
[TestCase(1)]
[TestCase(2)]
public void TestBeatDivisor(int divisor)
{
AddStep($"set divisor = {divisor}", () => BeatDivisor.Value = divisor);
assertSnapDistance(100f / divisor);
}
[Test]
public void TestConvertDurationToDistance()
{
assertDurationToDistance(500, 50);
assertDurationToDistance(1000, 100);
AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2);
assertDurationToDistance(500, 100);
assertDurationToDistance(1000, 200);
AddStep("set beat length = 500", () =>
{
composer.EditorBeatmap.ControlPointInfo.Clear();
composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 });
});
assertDurationToDistance(500, 200);
assertDurationToDistance(1000, 400);
}
[Test]
public void TestConvertDistanceToDuration()
{
assertDistanceToDuration(50, 500);
assertDistanceToDuration(100, 1000);
AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2);
assertDistanceToDuration(100, 500);
assertDistanceToDuration(200, 1000);
AddStep("set beat length = 500", () =>
{
composer.EditorBeatmap.ControlPointInfo.Clear();
composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 });
});
assertDistanceToDuration(200, 500);
assertDistanceToDuration(400, 1000);
}
[Test]
public void TestGetSnappedDurationFromDistance()
{
assertSnappedDuration(50, 0);
assertSnappedDuration(100, 1000);
assertSnappedDuration(150, 1000);
assertSnappedDuration(200, 2000);
assertSnappedDuration(250, 2000);
AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2);
assertSnappedDuration(50, 0);
assertSnappedDuration(100, 0);
assertSnappedDuration(150, 0);
assertSnappedDuration(200, 1000);
assertSnappedDuration(250, 1000);
AddStep("set beat length = 500", () =>
{
composer.EditorBeatmap.ControlPointInfo.Clear();
composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 });
});
assertSnappedDuration(50, 0);
assertSnappedDuration(100, 0);
assertSnappedDuration(150, 0);
assertSnappedDuration(200, 500);
assertSnappedDuration(250, 500);
assertSnappedDuration(400, 1000);
}
[Test]
public void GetSnappedDistanceFromDistance()
{
assertSnappedDistance(50, 0);
assertSnappedDistance(100, 100);
assertSnappedDistance(150, 100);
assertSnappedDistance(200, 200);
assertSnappedDistance(250, 200);
AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2);
assertSnappedDistance(50, 0);
assertSnappedDistance(100, 0);
assertSnappedDistance(150, 0);
assertSnappedDistance(200, 200);
assertSnappedDistance(250, 200);
AddStep("set beat length = 500", () =>
{
composer.EditorBeatmap.ControlPointInfo.Clear();
composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 });
});
assertSnappedDistance(50, 0);
assertSnappedDistance(100, 0);
assertSnappedDistance(150, 0);
assertSnappedDistance(200, 200);
assertSnappedDistance(250, 200);
assertSnappedDistance(400, 400);
}
private void assertSnapDistance(float expectedDistance)
=> AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(0) == expectedDistance);
private void assertDurationToDistance(double duration, float expectedDistance)
=> AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DurationToDistance(0, duration) == expectedDistance);
private void assertDistanceToDuration(float distance, double expectedDuration)
=> AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(0, distance) == expectedDuration);
private void assertSnappedDuration(float distance, double expectedDuration)
=> AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.GetSnappedDurationFromDistance(0, distance) == expectedDuration);
private void assertSnappedDistance(float distance, float expectedDistance)
=> AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.GetSnappedDistanceFromDistance(0, distance) == expectedDistance);
private class TestHitObjectComposer : OsuHitObjectComposer
{
public new EditorBeatmap<OsuHitObject> EditorBeatmap => base.EditorBeatmap;
public TestHitObjectComposer()
: base(new OsuRuleset())
{
}
}
}
}

View File

@ -0,0 +1,227 @@
// 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.Game.Beatmaps.ControlPoints;
namespace osu.Game.Tests.NonVisual
{
[TestFixture]
public class ControlPointInfoTest
{
[Test]
public void TestAdd()
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint());
cpi.Add(1000, new TimingControlPoint { BeatLength = 500 });
Assert.That(cpi.Groups.Count, Is.EqualTo(2));
Assert.That(cpi.TimingPoints.Count, Is.EqualTo(2));
Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2));
}
[Test]
public void TestAddRedundantTiming()
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint()); // is *not* redundant, special exception for first timing point.
cpi.Add(1000, new TimingControlPoint()); // is redundant
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
Assert.That(cpi.TimingPoints.Count, Is.EqualTo(1));
Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1));
}
[Test]
public void TestAddRedundantDifficulty()
{
var cpi = new ControlPointInfo();
cpi.Add(0, new DifficultyControlPoint()); // is redundant
cpi.Add(1000, new DifficultyControlPoint()); // is redundant
Assert.That(cpi.Groups.Count, Is.EqualTo(0));
Assert.That(cpi.TimingPoints.Count, Is.EqualTo(0));
Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(0));
cpi.Add(1000, new DifficultyControlPoint { SpeedMultiplier = 2 }); // is not redundant
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(1));
Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1));
}
[Test]
public void TestAddRedundantSample()
{
var cpi = new ControlPointInfo();
cpi.Add(0, new SampleControlPoint()); // is redundant
cpi.Add(1000, new SampleControlPoint()); // is redundant
Assert.That(cpi.Groups.Count, Is.EqualTo(0));
Assert.That(cpi.TimingPoints.Count, Is.EqualTo(0));
Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(0));
cpi.Add(1000, new SampleControlPoint { SampleVolume = 50 }); // is not redundant
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
Assert.That(cpi.SamplePoints.Count, Is.EqualTo(1));
Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1));
}
[Test]
public void TestAddRedundantEffect()
{
var cpi = new ControlPointInfo();
cpi.Add(0, new EffectControlPoint()); // is redundant
cpi.Add(1000, new EffectControlPoint()); // is redundant
Assert.That(cpi.Groups.Count, Is.EqualTo(0));
Assert.That(cpi.TimingPoints.Count, Is.EqualTo(0));
Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(0));
cpi.Add(1000, new EffectControlPoint { KiaiMode = true }); // is not redundant
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
Assert.That(cpi.EffectPoints.Count, Is.EqualTo(1));
Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1));
}
[Test]
public void TestAddGroup()
{
var cpi = new ControlPointInfo();
var group = cpi.GroupAt(1000, true);
var group2 = cpi.GroupAt(1000, true);
Assert.That(group, Is.EqualTo(group2));
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
}
[Test]
public void TestGroupAtLookupOnly()
{
var cpi = new ControlPointInfo();
var group = cpi.GroupAt(5000, true);
Assert.That(group, Is.Not.Null);
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
Assert.That(cpi.GroupAt(1000), Is.Null);
Assert.That(cpi.GroupAt(5000), Is.Not.Null);
}
[Test]
public void TestAddRemoveGroup()
{
var cpi = new ControlPointInfo();
var group = cpi.GroupAt(1000, true);
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
cpi.RemoveGroup(group);
Assert.That(cpi.Groups.Count, Is.EqualTo(0));
}
[Test]
public void TestAddControlPointToGroup()
{
var cpi = new ControlPointInfo();
var group = cpi.GroupAt(1000, true);
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
// usually redundant, but adding to group forces it to be added
group.Add(new DifficultyControlPoint());
Assert.That(group.ControlPoints.Count, Is.EqualTo(1));
Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(1));
}
[Test]
public void TestAddDuplicateControlPointToGroup()
{
var cpi = new ControlPointInfo();
var group = cpi.GroupAt(1000, true);
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
group.Add(new DifficultyControlPoint());
group.Add(new DifficultyControlPoint { SpeedMultiplier = 2 });
Assert.That(group.ControlPoints.Count, Is.EqualTo(1));
Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(1));
Assert.That(cpi.DifficultyPoints.First().SpeedMultiplier, Is.EqualTo(2));
}
[Test]
public void TestRemoveControlPointFromGroup()
{
var cpi = new ControlPointInfo();
var group = cpi.GroupAt(1000, true);
Assert.That(cpi.Groups.Count, Is.EqualTo(1));
var difficultyPoint = new DifficultyControlPoint();
group.Add(difficultyPoint);
group.Remove(difficultyPoint);
Assert.That(group.ControlPoints.Count, Is.EqualTo(0));
Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(0));
Assert.That(cpi.AllControlPoints.Count, Is.EqualTo(0));
}
[Test]
public void TestOrdering()
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint());
cpi.Add(1000, new TimingControlPoint { BeatLength = 500 });
cpi.Add(10000, new TimingControlPoint { BeatLength = 200 });
cpi.Add(5000, new TimingControlPoint { BeatLength = 100 });
cpi.Add(3000, new DifficultyControlPoint { SpeedMultiplier = 2 });
cpi.GroupAt(7000, true).Add(new DifficultyControlPoint { SpeedMultiplier = 4 });
cpi.GroupAt(1000).Add(new SampleControlPoint { SampleVolume = 0 });
cpi.GroupAt(8000, true).Add(new EffectControlPoint { KiaiMode = true });
Assert.That(cpi.AllControlPoints.Count, Is.EqualTo(8));
Assert.That(cpi.Groups, Is.Ordered.Ascending.By(nameof(ControlPointGroup.Time)));
Assert.That(cpi.AllControlPoints, Is.Ordered.Ascending.By(nameof(ControlPoint.Time)));
Assert.That(cpi.TimingPoints, Is.Ordered.Ascending.By(nameof(ControlPoint.Time)));
}
[Test]
public void TestClear()
{
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint());
cpi.Add(1000, new TimingControlPoint { BeatLength = 500 });
cpi.Add(10000, new TimingControlPoint { BeatLength = 200 });
cpi.Add(5000, new TimingControlPoint { BeatLength = 100 });
cpi.Add(3000, new DifficultyControlPoint { SpeedMultiplier = 2 });
cpi.GroupAt(7000, true).Add(new DifficultyControlPoint { SpeedMultiplier = 4 });
cpi.GroupAt(1000).Add(new SampleControlPoint { SampleVolume = 0 });
cpi.GroupAt(8000, true).Add(new EffectControlPoint { KiaiMode = true });
cpi.Clear();
Assert.That(cpi.Groups.Count, Is.EqualTo(0));
Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(0));
Assert.That(cpi.AllControlPoints.Count, Is.EqualTo(0));
}
}
}

View File

@ -0,0 +1,8 @@
osu file format v7
[TimingPoints]
0,100,4,2,0,100,1,0
12,500,4,2,0,100,1,0
1000,-10,4,2,0,100,0,0
2000,-54,4,2,0,100,0,0
3000,-200,4,2,0,100,0,0

View File

@ -0,0 +1,5 @@
osu file format v14
[TimingPoints]
0,-200,4,1,0,100,0,0
2000,100,1,1,0,100,1,0

View File

@ -34,6 +34,7 @@ namespace osu.Game.Tests.Visual.Components
PreviewTrack track = null;
AddStep("get track", () => track = getOwnedTrack());
AddUntilStep("wait loaded", () => track.IsLoaded);
AddStep("start", () => track.Start());
AddAssert("started", () => track.IsRunning);
AddStep("stop", () => track.Stop());
@ -52,6 +53,8 @@ namespace osu.Game.Tests.Visual.Components
track2 = getOwnedTrack();
});
AddUntilStep("wait loaded", () => track1.IsLoaded && track2.IsLoaded);
AddStep("start track 1", () => track1.Start());
AddStep("start track 2", () => track2.Start());
AddAssert("track 1 stopped", () => !track1.IsRunning);
@ -64,6 +67,7 @@ namespace osu.Game.Tests.Visual.Components
PreviewTrack track = null;
AddStep("get track", () => track = getOwnedTrack());
AddUntilStep("wait loaded", () => track.IsLoaded);
AddStep("start", () => track.Start());
AddStep("stop by owner", () => trackManager.StopAnyPlaying(this));
AddAssert("stopped", () => !track.IsRunning);
@ -76,6 +80,7 @@ namespace osu.Game.Tests.Visual.Components
PreviewTrack track = null;
AddStep("get track", () => Add(owner = new TestTrackOwner(track = getTrack())));
AddUntilStep("wait loaded", () => track.IsLoaded);
AddStep("start", () => track.Start());
AddStep("attempt stop", () => trackManager.StopAnyPlaying(this));
AddAssert("not stopped", () => track.IsRunning);
@ -89,16 +94,24 @@ namespace osu.Game.Tests.Visual.Components
{
var track = getTrack();
Add(track);
LoadComponentAsync(track, Add);
return track;
}
private class TestTrackOwner : CompositeDrawable, IPreviewTrackOwner
{
private readonly PreviewTrack track;
public TestTrackOwner(PreviewTrack track)
{
AddInternal(track);
this.track = track;
}
[BackgroundDependencyLoader]
private void load()
{
LoadComponentAsync(track, AddInternal);
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)

View File

@ -1,14 +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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.MathUtils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
@ -27,25 +25,27 @@ namespace osu.Game.Tests.Visual.Editor
[Cached(typeof(IEditorBeatmap))]
private readonly EditorBeatmap<OsuHitObject> editorBeatmap;
private TestDistanceSnapGrid grid;
[Cached(typeof(IDistanceSnapProvider))]
private readonly SnapProvider snapProvider = new SnapProvider();
public TestSceneDistanceSnapGrid()
{
editorBeatmap = new EditorBeatmap<OsuHitObject>(new OsuBeatmap());
editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beat_length });
createGrid();
editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length });
}
[SetUp]
public void Setup() => Schedule(() =>
{
Clear();
editorBeatmap.ControlPointInfo.TimingPoints.Clear();
editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beat_length });
BeatDivisor.Value = 1;
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.SlateGray
},
new TestDistanceSnapGrid(new HitObject(), grid_position)
};
});
[TestCase(1)]
@ -56,65 +56,15 @@ namespace osu.Game.Tests.Visual.Editor
[TestCase(8)]
[TestCase(12)]
[TestCase(16)]
public void TestInitialBeatDivisor(int divisor)
public void TestBeatDivisor(int divisor)
{
AddStep($"set beat divisor = {divisor}", () => BeatDivisor.Value = divisor);
createGrid();
float expectedDistance = (float)beat_length / divisor;
AddAssert($"spacing is {expectedDistance}", () => Precision.AlmostEquals(grid.DistanceSpacing, expectedDistance));
}
[Test]
public void TestChangeBeatDivisor()
public void TestLimitedDistance()
{
createGrid();
AddStep("set beat divisor = 2", () => BeatDivisor.Value = 2);
const float expected_distance = (float)beat_length / 2;
AddAssert($"spacing is {expected_distance}", () => Precision.AlmostEquals(grid.DistanceSpacing, expected_distance));
}
[TestCase(100)]
[TestCase(200)]
public void TestBeatLength(double beatLength)
{
AddStep($"set beat length = {beatLength}", () =>
{
editorBeatmap.ControlPointInfo.TimingPoints.Clear();
editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beatLength });
});
createGrid();
AddAssert($"spacing is {beatLength}", () => Precision.AlmostEquals(grid.DistanceSpacing, beatLength));
}
[TestCase(1)]
[TestCase(2)]
public void TestGridVelocity(float velocity)
{
createGrid(g => g.Velocity = velocity);
float expectedDistance = (float)beat_length * velocity;
AddAssert($"spacing is {expectedDistance}", () => Precision.AlmostEquals(grid.DistanceSpacing, expectedDistance));
}
[Test]
public void TestGetSnappedTime()
{
createGrid();
Vector2 snapPosition = Vector2.Zero;
AddStep("get first tick position", () => snapPosition = grid_position + new Vector2((float)beat_length, 0));
AddAssert("snap time is 1 beat away", () => Precision.AlmostEquals(beat_length, grid.GetSnapTime(snapPosition), 0.01));
createGrid(g => g.Velocity = 2, "with velocity = 2");
AddAssert("snap time is now 0.5 beats away", () => Precision.AlmostEquals(beat_length / 2, grid.GetSnapTime(snapPosition), 0.01));
}
private void createGrid(Action<TestDistanceSnapGrid> func = null, string description = null)
{
AddStep($"create grid {description ?? string.Empty}", () =>
AddStep("create limited grid", () =>
{
Children = new Drawable[]
{
@ -123,21 +73,17 @@ namespace osu.Game.Tests.Visual.Editor
RelativeSizeAxes = Axes.Both,
Colour = Color4.SlateGray
},
grid = new TestDistanceSnapGrid(new HitObject(), grid_position)
new TestDistanceSnapGrid(new HitObject(), grid_position, new HitObject { StartTime = 100 })
};
func?.Invoke(grid);
});
}
private class TestDistanceSnapGrid : DistanceSnapGrid
{
public new float Velocity = 1;
public new float DistanceSpacing => base.DistanceSpacing;
public TestDistanceSnapGrid(HitObject hitObject, Vector2 centrePosition)
: base(hitObject, centrePosition)
public TestDistanceSnapGrid(HitObject hitObject, Vector2 centrePosition, HitObject nextHitObject = null)
: base(hitObject, nextHitObject, centrePosition)
{
}
@ -152,7 +98,7 @@ namespace osu.Game.Tests.Visual.Editor
int beatIndex = 0;
for (float s = centrePosition.X + DistanceSpacing; s <= DrawWidth; s += DistanceSpacing, beatIndex++)
for (float s = centrePosition.X + DistanceSpacing; s <= DrawWidth && beatIndex < MaxIntervals; s += DistanceSpacing, beatIndex++)
{
AddInternal(new Circle
{
@ -165,7 +111,7 @@ namespace osu.Game.Tests.Visual.Editor
beatIndex = 0;
for (float s = centrePosition.X - DistanceSpacing; s >= 0; s -= DistanceSpacing, beatIndex++)
for (float s = centrePosition.X - DistanceSpacing; s >= 0 && beatIndex < MaxIntervals; s -= DistanceSpacing, beatIndex++)
{
AddInternal(new Circle
{
@ -178,7 +124,7 @@ namespace osu.Game.Tests.Visual.Editor
beatIndex = 0;
for (float s = centrePosition.Y + DistanceSpacing; s <= DrawHeight; s += DistanceSpacing, beatIndex++)
for (float s = centrePosition.Y + DistanceSpacing; s <= DrawHeight && beatIndex < MaxIntervals; s += DistanceSpacing, beatIndex++)
{
AddInternal(new Circle
{
@ -191,7 +137,7 @@ namespace osu.Game.Tests.Visual.Editor
beatIndex = 0;
for (float s = centrePosition.Y - DistanceSpacing; s >= 0; s -= DistanceSpacing, beatIndex++)
for (float s = centrePosition.Y - DistanceSpacing; s >= 0 && beatIndex < MaxIntervals; s -= DistanceSpacing, beatIndex++)
{
AddInternal(new Circle
{
@ -203,11 +149,23 @@ namespace osu.Game.Tests.Visual.Editor
}
}
protected override float GetVelocity(double time, ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty)
=> Velocity;
public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition)
=> (Vector2.Zero, 0);
}
public override Vector2 GetSnapPosition(Vector2 screenSpacePosition)
=> Vector2.Zero;
private class SnapProvider : IDistanceSnapProvider
{
public (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time) => (position, time);
public float GetBeatSnapDistanceAt(double referenceTime) => 10;
public float DurationToDistance(double referenceTime, double duration) => (float)duration;
public double DistanceToDuration(double referenceTime, float distance) => distance;
public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0;
public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => 0;
}
}
}

View File

@ -10,9 +10,9 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Edit.Compose.Components.Timeline;
using osuTK;
using osuTK.Graphics;
@ -101,7 +101,7 @@ namespace osu.Game.Tests.Visual.Editor
}
}
private class StartStopButton : Button
private class StartStopButton : OsuButton
{
private IAdjustableClock adjustableClock;
private bool started;

View File

@ -28,18 +28,7 @@ namespace osu.Game.Tests.Visual.Editor
{
var testBeatmap = new Beatmap
{
ControlPointInfo = new ControlPointInfo
{
TimingPoints =
{
new TimingControlPoint { Time = 0, BeatLength = 200 },
new TimingControlPoint { Time = 100, BeatLength = 400 },
new TimingControlPoint { Time = 175, BeatLength = 800 },
new TimingControlPoint { Time = 350, BeatLength = 200 },
new TimingControlPoint { Time = 450, BeatLength = 100 },
new TimingControlPoint { Time = 500, BeatLength = 307.69230769230802 }
}
},
ControlPointInfo = new ControlPointInfo(),
HitObjects =
{
new HitCircle { StartTime = 0 },
@ -47,6 +36,13 @@ namespace osu.Game.Tests.Visual.Editor
}
};
testBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 200 });
testBeatmap.ControlPointInfo.Add(100, new TimingControlPoint { BeatLength = 400 });
testBeatmap.ControlPointInfo.Add(175, new TimingControlPoint { BeatLength = 800 });
testBeatmap.ControlPointInfo.Add(350, new TimingControlPoint { BeatLength = 200 });
testBeatmap.ControlPointInfo.Add(450, new TimingControlPoint { BeatLength = 100 });
testBeatmap.ControlPointInfo.Add(500, new TimingControlPoint { BeatLength = 307.69230769230802 });
Beatmap.Value = CreateWorkingBeatmap(testBeatmap);
Child = new TimingPointVisualiser(testBeatmap, 5000) { Clock = Clock };

View File

@ -22,7 +22,7 @@ using osuTK;
namespace osu.Game.Tests.Visual.Editor
{
[TestFixture]
public class TestSceneHitObjectComposer : OsuTestScene
public class TestSceneHitObjectComposer : EditorClockTestScene
{
public override IReadOnlyList<Type> RequiredTypes => new[]
{

View File

@ -0,0 +1,35 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit.Timing;
namespace osu.Game.Tests.Visual.Editor
{
[TestFixture]
public class TestSceneTimingScreen : EditorClockTestScene
{
public override IReadOnlyList<Type> RequiredTypes => new[]
{
typeof(ControlPointTable),
typeof(ControlPointSettings),
typeof(Section<>),
typeof(TimingSection),
typeof(EffectSection),
typeof(SampleSection),
typeof(DifficultySection),
typeof(RowAttribute)
};
[BackgroundDependencyLoader]
private void load()
{
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
Child = new TimingScreen();
}
}
}

View File

@ -47,7 +47,8 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestRelativeBeatLengthScaleSingleTimingPoint()
{
var beatmap = createBeatmap(new TimingControlPoint { BeatLength = time_range / 2 });
var beatmap = createBeatmap();
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range / 2 });
createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true);
@ -61,10 +62,10 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestRelativeBeatLengthScaleTimingPointBeyondEndDoesNotBecomeDominant()
{
var beatmap = createBeatmap(
new TimingControlPoint { BeatLength = time_range / 2 },
new TimingControlPoint { Time = 12000, BeatLength = time_range },
new TimingControlPoint { Time = 100000, BeatLength = time_range });
var beatmap = createBeatmap();
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range / 2 });
beatmap.ControlPointInfo.Add(12000, new TimingControlPoint { BeatLength = time_range });
beatmap.ControlPointInfo.Add(100000, new TimingControlPoint { BeatLength = time_range });
createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true);
@ -75,9 +76,9 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestRelativeBeatLengthScaleFromSecondTimingPoint()
{
var beatmap = createBeatmap(
new TimingControlPoint { BeatLength = time_range },
new TimingControlPoint { Time = 3 * time_range, BeatLength = time_range / 2 });
var beatmap = createBeatmap();
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range });
beatmap.ControlPointInfo.Add(3 * time_range, new TimingControlPoint { BeatLength = time_range / 2 });
createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true);
@ -97,9 +98,9 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestNonRelativeScale()
{
var beatmap = createBeatmap(
new TimingControlPoint { BeatLength = time_range },
new TimingControlPoint { Time = 3 * time_range, BeatLength = time_range / 2 });
var beatmap = createBeatmap();
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range });
beatmap.ControlPointInfo.Add(3 * time_range, new TimingControlPoint { BeatLength = time_range / 2 });
createTest(beatmap);
@ -119,7 +120,8 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestSliderMultiplierDoesNotAffectRelativeBeatLength()
{
var beatmap = createBeatmap(new TimingControlPoint { BeatLength = time_range });
var beatmap = createBeatmap();
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range });
beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2;
createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true);
@ -132,7 +134,8 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestSliderMultiplierAffectsNonRelativeBeatLength()
{
var beatmap = createBeatmap(new TimingControlPoint { BeatLength = time_range });
var beatmap = createBeatmap();
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range });
beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2;
createTest(beatmap);
@ -154,14 +157,11 @@ namespace osu.Game.Tests.Visual.Gameplay
/// Creates an <see cref="IBeatmap"/>, containing 10 hitobjects and user-provided timing points.
/// The hitobjects are spaced <see cref="time_range"/> milliseconds apart.
/// </summary>
/// <param name="timingControlPoints">The timing points to add to the beatmap.</param>
/// <returns>The <see cref="IBeatmap"/>.</returns>
private IBeatmap createBeatmap(params TimingControlPoint[] timingControlPoints)
private IBeatmap createBeatmap()
{
var beatmap = new Beatmap<HitObject> { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } };
beatmap.ControlPointInfo.TimingPoints.AddRange(timingControlPoints);
for (int i = 0; i < 10; i++)
beatmap.HitObjects.Add(new HitObject { StartTime = i * time_range });

View File

@ -69,6 +69,24 @@ namespace osu.Game.Tests.Visual.Gameplay
confirmClockRunning(true);
}
[Test]
public void TestPauseWithResumeOverlay()
{
AddStep("move cursor to center", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre));
AddUntilStep("wait for hitobjects", () => Player.ScoreProcessor.Health.Value < 1);
pauseAndConfirm();
resume();
confirmClockRunning(false);
confirmPauseOverlayShown(false);
pauseAndConfirm();
AddUntilStep("resume overlay is not active", () => Player.DrawableRuleset.ResumeOverlay.State.Value == Visibility.Hidden);
confirmPaused();
}
[Test]
public void TestResumeWithResumeOverlaySkipped()
{
@ -219,6 +237,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("player not exited", () => Player.IsCurrentScreen());
AddStep("exit", () => Player.Exit());
confirmExited();
confirmNoTrackAdjustments();
}
private void confirmPaused()
@ -240,6 +259,11 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("player exited", () => !Player.IsCurrentScreen());
}
private void confirmNoTrackAdjustments()
{
AddAssert("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value == 1);
}
private void restart() => AddStep("restart", () => Player.Restart());
private void pause() => AddStep("pause", () => Player.Pause());
private void resume() => AddStep("resume", () => Player.Resume());

View File

@ -7,11 +7,10 @@ using osu.Game.Online;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets.Osu;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Users;
using osuTK;
using System;
using System.Collections.Generic;
using osu.Game.Screens.Ranking.Pages;
namespace osu.Game.Tests.Visual.Gameplay
{
@ -42,7 +41,6 @@ namespace osu.Game.Tests.Visual.Gameplay
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(80, 40),
};
});
}

View File

@ -3,6 +3,7 @@
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Play.PlayerSettings;
@ -20,6 +21,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
State = { Value = Visibility.Visible }
});
Add(container = new ExampleContainer());

View File

@ -3,11 +3,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens;
using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Ranking.Pages;
@ -22,11 +27,13 @@ namespace osu.Game.Tests.Visual.Gameplay
public override IReadOnlyList<Type> RequiredTypes => new[]
{
typeof(ScoreInfo),
typeof(Results),
typeof(ResultsPage),
typeof(ScoreResultsPage),
typeof(LocalLeaderboardPage)
typeof(RetryButton),
typeof(ReplayDownloadButton),
typeof(LocalLeaderboardPage),
typeof(TestPlayer)
};
[BackgroundDependencyLoader]
@ -42,26 +49,82 @@ namespace osu.Game.Tests.Visual.Gameplay
var beatmapInfo = beatmaps.QueryBeatmap(b => b.RulesetID == 0);
if (beatmapInfo != null)
Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo);
}
LoadScreen(new SoloResults(new ScoreInfo
private TestSoloResults createResultsScreen() => new TestSoloResults(new ScoreInfo
{
TotalScore = 2845370,
Accuracy = 0.98,
MaxCombo = 123,
Rank = ScoreRank.A,
Date = DateTimeOffset.Now,
Statistics = new Dictionary<HitResult, int>
{
TotalScore = 2845370,
Accuracy = 0.98,
MaxCombo = 123,
Rank = ScoreRank.A,
Date = DateTimeOffset.Now,
Statistics = new Dictionary<HitResult, int>
{ HitResult.Great, 50 },
{ HitResult.Good, 20 },
{ HitResult.Meh, 50 },
{ HitResult.Miss, 1 }
},
User = new User
{
Username = "peppy",
}
});
[Test]
public void ResultsWithoutPlayer()
{
TestSoloResults screen = null;
AddStep("load results", () => Child = new OsuScreenStack(screen = createResultsScreen())
{
RelativeSizeAxes = Axes.Both
});
AddUntilStep("wait for loaded", () => screen.IsLoaded);
AddAssert("retry overlay not present", () => screen.RetryOverlay == null);
}
[Test]
public void ResultsWithPlayer()
{
TestSoloResults screen = null;
AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen()));
AddUntilStep("wait for loaded", () => screen.IsLoaded);
AddAssert("retry overlay present", () => screen.RetryOverlay != null);
}
private class TestResultsContainer : Container
{
[Cached(typeof(Player))]
private readonly Player player = new TestPlayer();
public TestResultsContainer(IScreen screen)
{
RelativeSizeAxes = Axes.Both;
InternalChild = new OsuScreenStack(screen)
{
{ HitResult.Great, 50 },
{ HitResult.Good, 20 },
{ HitResult.Meh, 50 },
{ HitResult.Miss, 1 }
},
User = new User
{
Username = "peppy",
}
}));
RelativeSizeAxes = Axes.Both,
};
}
}
private class TestSoloResults : SoloResults
{
public HotkeyRetryOverlay RetryOverlay;
public TestSoloResults(ScoreInfo score)
: base(score)
{
}
protected override void LoadComplete()
{
base.LoadComplete();
RetryOverlay = InternalChildren.OfType<HotkeyRetryOverlay>().SingleOrDefault();
}
}
}
}

View File

@ -33,23 +33,15 @@ namespace osu.Game.Tests.Visual.Menus
[Test]
public void TestInstantLoad()
{
bool logoVisible = false;
// visual only, very impossible to test this using asserts.
AddStep("begin loading", () =>
AddStep("load immediately", () =>
{
loader = new TestLoader();
loader.AllowLoad.Set();
LoadScreen(loader);
});
AddUntilStep("loaded", () =>
{
logoVisible = loader.Logo?.Alpha > 0;
return loader.Logo != null && loader.ScreenLoaded;
});
AddAssert("logo was not visible", () => !logoVisible);
}
[Test]
@ -58,7 +50,7 @@ namespace osu.Game.Tests.Visual.Menus
AddStep("begin loading", () => LoadScreen(loader = new TestLoader()));
AddUntilStep("wait for logo visible", () => loader.Logo?.Alpha > 0);
AddStep("finish loading", () => loader.AllowLoad.Set());
AddAssert("loaded", () => loader.Logo != null && loader.ScreenLoaded);
AddUntilStep("loaded", () => loader.Logo != null && loader.ScreenLoaded);
AddUntilStep("logo gone", () => loader.Logo?.Alpha == 0);
}

View File

@ -0,0 +1,102 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Beatmaps;
using osu.Game.Overlays.BeatmapSet;
using osu.Game.Rulesets;
using System;
using System.Collections.Generic;
using System.Linq;
namespace osu.Game.Tests.Visual.Online
{
public class TestSceneBeatmapRulesetSelector : OsuTestScene
{
public override IReadOnlyList<Type> RequiredTypes => new[]
{
typeof(BeatmapRulesetSelector),
typeof(BeatmapRulesetTabItem),
};
private readonly TestRulesetSelector selector;
public TestSceneBeatmapRulesetSelector()
{
Add(selector = new TestRulesetSelector());
}
[Resolved]
private RulesetStore rulesets { get; set; }
[Test]
public void TestMultipleRulesetsBeatmapSet()
{
var enabledRulesets = rulesets.AvailableRulesets.Skip(1).Take(2);
AddStep("load multiple rulesets beatmapset", () =>
{
selector.BeatmapSet = new BeatmapSetInfo
{
Beatmaps = enabledRulesets.Select(r => new BeatmapInfo { Ruleset = r }).ToList()
};
});
var tabItems = selector.TabContainer.TabItems;
AddAssert("other rulesets disabled", () => tabItems.Except(tabItems.Where(t => enabledRulesets.Any(r => r.Equals(t.Value)))).All(t => !t.Enabled.Value));
AddAssert("left-most ruleset selected", () => tabItems.First(t => t.Enabled.Value).Active.Value);
}
[Test]
public void TestSingleRulesetBeatmapSet()
{
var enabledRuleset = rulesets.AvailableRulesets.Last();
AddStep("load single ruleset beatmapset", () =>
{
selector.BeatmapSet = new BeatmapSetInfo
{
Beatmaps = new List<BeatmapInfo>
{
new BeatmapInfo
{
Ruleset = enabledRuleset
}
}
};
});
AddAssert("single ruleset selected", () => selector.SelectedTab.Value.Equals(enabledRuleset));
}
[Test]
public void TestEmptyBeatmapSet()
{
AddStep("load empty beatmapset", () => selector.BeatmapSet = new BeatmapSetInfo
{
Beatmaps = new List<BeatmapInfo>()
});
AddAssert("no ruleset selected", () => selector.SelectedTab == null);
AddAssert("all rulesets disabled", () => selector.TabContainer.TabItems.All(t => !t.Enabled.Value));
}
[Test]
public void TestNullBeatmapSet()
{
AddStep("load null beatmapset", () => selector.BeatmapSet = null);
AddAssert("no ruleset selected", () => selector.SelectedTab == null);
AddAssert("all rulesets disabled", () => selector.TabContainer.TabItems.All(t => !t.Enabled.Value));
}
private class TestRulesetSelector : BeatmapRulesetSelector
{
public new TabItem<RulesetInfo> SelectedTab => base.SelectedTab;
public new TabFillFlowContainer TabContainer => base.TabContainer;
}
}
}

View File

@ -40,24 +40,19 @@ namespace osu.Game.Tests.Visual.Online
typeof(PreviewButton),
typeof(SuccessRate),
typeof(BeatmapAvailability),
typeof(BeatmapRulesetSelector),
typeof(BeatmapRulesetTabItem),
};
protected override bool UseOnlineAPI => true;
private RulesetInfo taikoRuleset;
private RulesetInfo maniaRuleset;
public TestSceneBeatmapSetOverlay()
{
Add(overlay = new TestBeatmapSetOverlay());
}
[BackgroundDependencyLoader]
private void load(RulesetStore rulesets)
{
taikoRuleset = rulesets.GetRuleset(1);
maniaRuleset = rulesets.GetRuleset(3);
}
[Resolved]
private RulesetStore rulesets { get; set; }
[Test]
public void TestLoading()
@ -111,7 +106,7 @@ namespace osu.Game.Tests.Visual.Online
StarDifficulty = 9.99,
Version = @"TEST",
Length = 456000,
Ruleset = maniaRuleset,
Ruleset = rulesets.GetRuleset(3),
BaseDifficulty = new BeatmapDifficulty
{
CircleSize = 1,
@ -189,7 +184,7 @@ namespace osu.Game.Tests.Visual.Online
StarDifficulty = 5.67,
Version = @"ANOTHER TEST",
Length = 123000,
Ruleset = taikoRuleset,
Ruleset = rulesets.GetRuleset(1),
BaseDifficulty = new BeatmapDifficulty
{
CircleSize = 9,
@ -217,6 +212,54 @@ namespace osu.Game.Tests.Visual.Online
downloadAssert(false);
}
[Test]
public void TestMultipleRulesets()
{
AddStep("show multiple rulesets beatmap", () =>
{
var beatmaps = new List<BeatmapInfo>();
foreach (var ruleset in rulesets.AvailableRulesets.Skip(1))
{
beatmaps.Add(new BeatmapInfo
{
Version = ruleset.Name,
Ruleset = ruleset,
BaseDifficulty = new BeatmapDifficulty(),
OnlineInfo = new BeatmapOnlineInfo(),
Metrics = new BeatmapMetrics
{
Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(),
Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(),
},
});
}
overlay.ShowBeatmapSet(new BeatmapSetInfo
{
Metadata = new BeatmapMetadata
{
Title = @"multiple rulesets beatmap",
Artist = @"none",
Author = new User
{
Username = "BanchoBot",
Id = 3,
}
},
OnlineInfo = new BeatmapSetOnlineInfo
{
Covers = new BeatmapSetOnlineCovers(),
},
Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() },
Beatmaps = beatmaps
});
});
AddAssert("shown beatmaps of current ruleset", () => overlay.Header.Picker.Difficulties.All(b => b.Beatmap.Ruleset.Equals(overlay.Header.RulesetSelector.Current.Value)));
AddAssert("left-most beatmap selected", () => overlay.Header.Picker.Difficulties.First().State == BeatmapPicker.DifficultySelectorState.Selected);
}
[Test]
public void TestHide()
{
@ -281,12 +324,12 @@ namespace osu.Game.Tests.Visual.Online
private void downloadAssert(bool shown)
{
AddAssert($"is download button {(shown ? "shown" : "hidden")}", () => overlay.DownloadButtonsVisible == shown);
AddAssert($"is download button {(shown ? "shown" : "hidden")}", () => overlay.Header.DownloadButtonsVisible == shown);
}
private class TestBeatmapSetOverlay : BeatmapSetOverlay
{
public bool DownloadButtonsVisible => Header.DownloadButtonsVisible;
public new Header Header => base.Header;
}
}
}

View File

@ -22,7 +22,8 @@ namespace osu.Game.Tests.Visual.Online
typeof(HeaderButton),
typeof(SortTabControl),
typeof(ShowChildrenButton),
typeof(DeletedChildrenPlaceholder)
typeof(DeletedChildrenPlaceholder),
typeof(VotePill)
};
protected override bool UseOnlineAPI => true;

View File

@ -7,6 +7,10 @@ using osu.Game.Online.Chat;
using osu.Game.Users;
using osuTK;
using System;
using System.Linq;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays.Chat;
namespace osu.Game.Tests.Visual.Online
{
@ -42,14 +46,14 @@ namespace osu.Game.Tests.Visual.Online
[Cached]
private ChannelManager channelManager = new ChannelManager();
private readonly StandAloneChatDisplay chatDisplay;
private readonly StandAloneChatDisplay chatDisplay2;
private readonly TestStandAloneChatDisplay chatDisplay;
private readonly TestStandAloneChatDisplay chatDisplay2;
public TestSceneStandAloneChatDisplay()
{
Add(channelManager);
Add(chatDisplay = new StandAloneChatDisplay
Add(chatDisplay = new TestStandAloneChatDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
@ -57,7 +61,7 @@ namespace osu.Game.Tests.Visual.Online
Size = new Vector2(400, 80)
});
Add(chatDisplay2 = new StandAloneChatDisplay(true)
Add(chatDisplay2 = new TestStandAloneChatDisplay(true)
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
@ -119,6 +123,49 @@ namespace osu.Game.Tests.Visual.Online
Content = "Message from the future!",
Timestamp = DateTimeOffset.Now
}));
AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom);
const int messages_per_call = 10;
AddRepeatStep("add many messages", () =>
{
for (int i = 0; i < messages_per_call; i++)
testChannel.AddNewMessages(new Message(sequence++)
{
Sender = longUsernameUser,
Content = "Many messages! " + Guid.NewGuid(),
Timestamp = DateTimeOffset.Now
});
}, Channel.MAX_HISTORY / messages_per_call + 5);
AddAssert("Ensure no adjacent day separators", () =>
{
var indices = chatDisplay.FillFlow.OfType<DrawableChannel.DaySeparator>().Select(ds => chatDisplay.FillFlow.IndexOf(ds));
foreach (var i in indices)
if (i < chatDisplay.FillFlow.Count && chatDisplay.FillFlow[i + 1] is DrawableChannel.DaySeparator)
return false;
return true;
});
AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom);
}
private class TestStandAloneChatDisplay : StandAloneChatDisplay
{
public TestStandAloneChatDisplay(bool textbox = false)
: base(textbox)
{
}
protected DrawableChannel DrawableChannel => InternalChildren.OfType<DrawableChannel>().First();
protected OsuScrollContainer ScrollContainer => (OsuScrollContainer)((Container)DrawableChannel.Child).Child;
public FillFlowContainer FillFlow => (FillFlowContainer)ScrollContainer.Child;
public bool ScrolledToBottom => ScrollContainer.IsScrolledToEnd(1);
}
}
}

View File

@ -245,6 +245,28 @@ namespace osu.Game.Tests.Visual.SongSelect
AddAssert($"Check #{set_count} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Title.EndsWith($"#{set_count}!"));
}
[Test]
public void TestSortingStability()
{
var sets = new List<BeatmapSetInfo>();
for (int i = 0; i < 20; i++)
{
var set = createTestBeatmapSet(i);
set.Metadata.Artist = "same artist";
set.Metadata.Title = "same title";
sets.Add(set);
}
loadBeatmaps(sets);
AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false));
AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.ID == index).All(b => b));
AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false));
AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.ID == index).All(b => b));
}
[Test]
public void TestSortingWithFiltered()
{

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
@ -10,7 +11,6 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Lists;
using osu.Framework.Timing;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
@ -153,7 +153,7 @@ namespace osu.Game.Tests.Visual.UserInterface
};
}
private SortedList<TimingControlPoint> timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints;
private List<TimingControlPoint> timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ToList();
private TimingControlPoint getNextTimingPoint(TimingControlPoint current)
{

View File

@ -11,7 +11,7 @@ using osuTK.Graphics;
namespace osu.Game.Tests.Visual.UserInterface
{
public class TestSceneLabelledComponent : OsuTestScene
public class TestSceneLabelledDrawable : OsuTestScene
{
[TestCase(false)]
[TestCase(true)]
@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.UserInterface
{
AddStep("create component", () =>
{
LabelledComponent<Drawable> component;
LabelledDrawable<Drawable> component;
Child = new Container
{
@ -33,7 +33,7 @@ namespace osu.Game.Tests.Visual.UserInterface
Origin = Anchor.Centre,
Width = 500,
AutoSizeAxes = Axes.Y,
Child = component = padded ? (LabelledComponent<Drawable>)new PaddedLabelledComponent() : new NonPaddedLabelledComponent(),
Child = component = padded ? (LabelledDrawable<Drawable>)new PaddedLabelledDrawable() : new NonPaddedLabelledDrawable(),
};
component.Label = "a sample component";
@ -41,9 +41,9 @@ namespace osu.Game.Tests.Visual.UserInterface
});
}
private class PaddedLabelledComponent : LabelledComponent<Drawable>
private class PaddedLabelledDrawable : LabelledDrawable<Drawable>
{
public PaddedLabelledComponent()
public PaddedLabelledDrawable()
: base(true)
{
}
@ -57,9 +57,9 @@ namespace osu.Game.Tests.Visual.UserInterface
};
}
private class NonPaddedLabelledComponent : LabelledComponent<Drawable>
private class NonPaddedLabelledDrawable : LabelledDrawable<Drawable>
{
public NonPaddedLabelledComponent()
public NonPaddedLabelledDrawable()
: base(false)
{
}

View File

@ -7,7 +7,6 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
namespace osu.Game.Tests.Visual.UserInterface
@ -28,7 +27,7 @@ namespace osu.Game.Tests.Visual.UserInterface
{
AddStep("create component", () =>
{
LabelledComponent<OsuTextBox> component;
LabelledTextBox component;
Child = new Container
{

View File

@ -89,7 +89,7 @@ namespace osu.Game.Tournament.Screens
};
}
private class ActionableInfo : LabelledComponent<Drawable>
private class ActionableInfo : LabelledDrawable<Drawable>
{
private OsuButton button;

View File

@ -215,7 +215,7 @@ namespace osu.Game.Tournament
foreach (var r in ladder.Rounds)
foreach (var b in r.Beatmaps)
if (b.BeatmapInfo == null)
if (b.BeatmapInfo == null && b.ID > 0)
{
var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = b.ID });
req.Perform(API);

View File

@ -9,15 +9,18 @@ using osu.Framework.Threading;
namespace osu.Game.Audio
{
[LongRunningLoad]
public abstract class PreviewTrack : Component
{
/// <summary>
/// Invoked when this <see cref="PreviewTrack"/> has stopped playing.
/// Not invoked in a thread-safe context.
/// </summary>
public event Action Stopped;
/// <summary>
/// Invoked when this <see cref="PreviewTrack"/> has started playing.
/// Not invoked in a thread-safe context.
/// </summary>
public event Action Started;
@ -29,7 +32,7 @@ namespace osu.Game.Audio
{
track = GetTrack();
if (track != null)
track.Completed += () => Schedule(Stop);
track.Completed += Stop;
}
/// <summary>
@ -93,6 +96,7 @@ namespace osu.Game.Audio
hasStarted = false;
track.Stop();
Stopped?.Invoke();
}

View File

@ -46,18 +46,18 @@ namespace osu.Game.Audio
{
var track = CreatePreviewTrack(beatmapSetInfo, trackStore);
track.Started += () =>
track.Started += () => Schedule(() =>
{
current?.Stop();
current = track;
audio.Tracks.AddAdjustment(AdjustableProperty.Volume, muteBindable);
};
});
track.Stopped += () =>
track.Stopped += () => Schedule(() =>
{
current = null;
audio.Tracks.RemoveAdjustment(AdjustableProperty.Volume, muteBindable);
};
});
return track;
}

View File

@ -392,8 +392,15 @@ namespace osu.Game.Beatmaps
req.Failure += e => { LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})"); };
// intentionally blocking to limit web request concurrency
req.Perform(api);
try
{
// intentionally blocking to limit web request concurrency
req.Perform(api);
}
catch (Exception e)
{
LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})");
}
}
}
}

View File

@ -1,25 +1,30 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
namespace osu.Game.Beatmaps.ControlPoints
{
public class ControlPoint : IComparable<ControlPoint>, IEquatable<ControlPoint>
public abstract class ControlPoint : IComparable<ControlPoint>, IEquatable<ControlPoint>
{
/// <summary>
/// The time at which the control point takes effect.
/// </summary>
public double Time;
public double Time => controlPointGroup?.Time ?? 0;
/// <summary>
/// Whether this timing point was generated internally, as opposed to parsed from the underlying beatmap.
/// </summary>
internal bool AutoGenerated;
private ControlPointGroup controlPointGroup;
public void AttachGroup(ControlPointGroup pointGroup) => this.controlPointGroup = pointGroup;
public int CompareTo(ControlPoint other) => Time.CompareTo(other.Time);
public bool Equals(ControlPoint other)
=> Time.Equals(other?.Time);
/// <summary>
/// Whether this control point is equivalent to another, ignoring time.
/// </summary>
/// <param name="other">Another control point to compare with.</param>
/// <returns>Whether equivalent.</returns>
public abstract bool EquivalentTo(ControlPoint other);
public bool Equals(ControlPoint other) => Time.Equals(other?.Time) && EquivalentTo(other);
}
}

View File

@ -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 System.Linq;
using osu.Framework.Bindables;
namespace osu.Game.Beatmaps.ControlPoints
{
public class ControlPointGroup : IComparable<ControlPointGroup>
{
public event Action<ControlPoint> ItemAdded;
public event Action<ControlPoint> ItemRemoved;
/// <summary>
/// The time at which the control point takes effect.
/// </summary>
public double Time { get; }
public IBindableList<ControlPoint> ControlPoints => controlPoints;
private readonly BindableList<ControlPoint> controlPoints = new BindableList<ControlPoint>();
public ControlPointGroup(double time)
{
Time = time;
}
public int CompareTo(ControlPointGroup other) => Time.CompareTo(other.Time);
public void Add(ControlPoint point)
{
var existing = controlPoints.FirstOrDefault(p => p.GetType() == point.GetType());
if (existing != null)
Remove(existing);
point.AttachGroup(this);
controlPoints.Add(point);
ItemAdded?.Invoke(point);
}
public void Remove(ControlPoint point)
{
controlPoints.Remove(point);
ItemRemoved?.Invoke(point);
}
}
}

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Lists;
namespace osu.Game.Beatmaps.ControlPoints
@ -12,57 +13,78 @@ namespace osu.Game.Beatmaps.ControlPoints
[Serializable]
public class ControlPointInfo
{
/// <summary>
/// All control points grouped by time.
/// </summary>
[JsonProperty]
public IBindableList<ControlPointGroup> Groups => groups;
private readonly BindableList<ControlPointGroup> groups = new BindableList<ControlPointGroup>();
/// <summary>
/// All timing points.
/// </summary>
[JsonProperty]
public SortedList<TimingControlPoint> TimingPoints { get; private set; } = new SortedList<TimingControlPoint>(Comparer<TimingControlPoint>.Default);
public IReadOnlyList<TimingControlPoint> TimingPoints => timingPoints;
private readonly SortedList<TimingControlPoint> timingPoints = new SortedList<TimingControlPoint>(Comparer<TimingControlPoint>.Default);
/// <summary>
/// All difficulty points.
/// </summary>
[JsonProperty]
public SortedList<DifficultyControlPoint> DifficultyPoints { get; private set; } = new SortedList<DifficultyControlPoint>(Comparer<DifficultyControlPoint>.Default);
public IReadOnlyList<DifficultyControlPoint> DifficultyPoints => difficultyPoints;
private readonly SortedList<DifficultyControlPoint> difficultyPoints = new SortedList<DifficultyControlPoint>(Comparer<DifficultyControlPoint>.Default);
/// <summary>
/// All sound points.
/// </summary>
[JsonProperty]
public SortedList<SampleControlPoint> SamplePoints { get; private set; } = new SortedList<SampleControlPoint>(Comparer<SampleControlPoint>.Default);
public IReadOnlyList<SampleControlPoint> SamplePoints => samplePoints;
private readonly SortedList<SampleControlPoint> samplePoints = new SortedList<SampleControlPoint>(Comparer<SampleControlPoint>.Default);
/// <summary>
/// All effect points.
/// </summary>
[JsonProperty]
public SortedList<EffectControlPoint> EffectPoints { get; private set; } = new SortedList<EffectControlPoint>(Comparer<EffectControlPoint>.Default);
public IReadOnlyList<EffectControlPoint> EffectPoints => effectPoints;
private readonly SortedList<EffectControlPoint> effectPoints = new SortedList<EffectControlPoint>(Comparer<EffectControlPoint>.Default);
/// <summary>
/// All control points, of all types.
/// </summary>
public IEnumerable<ControlPoint> AllControlPoints => Groups.SelectMany(g => g.ControlPoints).ToArray();
/// <summary>
/// Finds the difficulty control point that is active at <paramref name="time"/>.
/// </summary>
/// <param name="time">The time to find the difficulty control point at.</param>
/// <returns>The difficulty control point.</returns>
public DifficultyControlPoint DifficultyPointAt(double time) => binarySearch(DifficultyPoints, time);
public DifficultyControlPoint DifficultyPointAt(double time) => binarySearchWithFallback(DifficultyPoints, time);
/// <summary>
/// Finds the effect control point that is active at <paramref name="time"/>.
/// </summary>
/// <param name="time">The time to find the effect control point at.</param>
/// <returns>The effect control point.</returns>
public EffectControlPoint EffectPointAt(double time) => binarySearch(EffectPoints, time);
public EffectControlPoint EffectPointAt(double time) => binarySearchWithFallback(EffectPoints, time);
/// <summary>
/// Finds the sound control point that is active at <paramref name="time"/>.
/// </summary>
/// <param name="time">The time to find the sound control point at.</param>
/// <returns>The sound control point.</returns>
public SampleControlPoint SamplePointAt(double time) => binarySearch(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : null);
public SampleControlPoint SamplePointAt(double time) => binarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : null);
/// <summary>
/// Finds the timing control point that is active at <paramref name="time"/>.
/// </summary>
/// <param name="time">The time to find the timing control point at.</param>
/// <returns>The timing control point.</returns>
public TimingControlPoint TimingPointAt(double time) => binarySearch(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : null);
public TimingControlPoint TimingPointAt(double time) => binarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : null);
/// <summary>
/// Finds the maximum BPM represented by any timing control point.
@ -85,24 +107,93 @@ namespace osu.Game.Beatmaps.ControlPoints
public double BPMMode =>
60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? new TimingControlPoint()).BeatLength;
/// <summary>
/// Remove all <see cref="ControlPointGroup"/>s and return to a pristine state.
/// </summary>
public void Clear()
{
groups.Clear();
timingPoints.Clear();
difficultyPoints.Clear();
samplePoints.Clear();
effectPoints.Clear();
}
/// <summary>
/// Add a new <see cref="ControlPoint"/>. Note that the provided control point may not be added if the correct state is already present at the provided time.
/// </summary>
/// <param name="time">The time at which the control point should be added.</param>
/// <param name="controlPoint">The control point to add.</param>
/// <returns>Whether the control point was added.</returns>
public bool Add(double time, ControlPoint controlPoint)
{
if (checkAlreadyExisting(time, controlPoint))
return false;
GroupAt(time, true).Add(controlPoint);
return true;
}
public ControlPointGroup GroupAt(double time, bool addIfNotExisting = false)
{
var newGroup = new ControlPointGroup(time);
int i = groups.BinarySearch(newGroup);
if (i >= 0)
return groups[i];
if (addIfNotExisting)
{
newGroup.ItemAdded += groupItemAdded;
newGroup.ItemRemoved += groupItemRemoved;
groups.Insert(~i, newGroup);
return newGroup;
}
return null;
}
public void RemoveGroup(ControlPointGroup group)
{
group.ItemAdded -= groupItemAdded;
group.ItemRemoved -= groupItemRemoved;
groups.Remove(group);
}
/// <summary>
/// Binary searches one of the control point lists to find the active control point at <paramref name="time"/>.
/// Includes logic for returning a specific point when no matching point is found.
/// </summary>
/// <param name="list">The list to search.</param>
/// <param name="time">The time to find the control point at.</param>
/// <param name="prePoint">The control point to use when <paramref name="time"/> is before any control points. If null, a new control point will be constructed.</param>
/// <returns>The active control point at <paramref name="time"/>.</returns>
private T binarySearch<T>(SortedList<T> list, double time, T prePoint = null)
/// <returns>The active control point at <paramref name="time"/>, or a fallback <see cref="ControlPoint"/> if none found.</returns>
private T binarySearchWithFallback<T>(IReadOnlyList<T> list, double time, T prePoint = null)
where T : ControlPoint, new()
{
return binarySearch(list, time) ?? prePoint ?? new T();
}
/// <summary>
/// Binary searches one of the control point lists to find the active control point at <paramref name="time"/>.
/// </summary>
/// <param name="list">The list to search.</param>
/// <param name="time">The time to find the control point at.</param>
/// <returns>The active control point at <paramref name="time"/>.</returns>
private T binarySearch<T>(IReadOnlyList<T> list, double time)
where T : ControlPoint
{
if (list == null)
throw new ArgumentNullException(nameof(list));
if (list.Count == 0)
return new T();
return null;
if (time < list[0].Time)
return prePoint ?? new T();
return null;
if (time >= list[list.Count - 1].Time)
return list[list.Count - 1];
@ -125,5 +216,82 @@ namespace osu.Game.Beatmaps.ControlPoints
// l will be the first control point with Time > time, but we want the one before it
return list[l - 1];
}
/// <summary>
/// Check whether <see cref="newPoint"/> should be added.
/// </summary>
/// <param name="time">The time to find the timing control point at.</param>
/// <param name="newPoint">A point to be added.</param>
/// <returns>Whether the new point should be added.</returns>
private bool checkAlreadyExisting(double time, ControlPoint newPoint)
{
ControlPoint existing = null;
switch (newPoint)
{
case TimingControlPoint _:
// Timing points are a special case and need to be added regardless of fallback availability.
existing = binarySearch(TimingPoints, time);
break;
case EffectControlPoint _:
existing = EffectPointAt(time);
break;
case SampleControlPoint _:
existing = SamplePointAt(time);
break;
case DifficultyControlPoint _:
existing = DifficultyPointAt(time);
break;
}
return existing?.EquivalentTo(newPoint) == true;
}
private void groupItemAdded(ControlPoint controlPoint)
{
switch (controlPoint)
{
case TimingControlPoint typed:
timingPoints.Add(typed);
break;
case EffectControlPoint typed:
effectPoints.Add(typed);
break;
case SampleControlPoint typed:
samplePoints.Add(typed);
break;
case DifficultyControlPoint typed:
difficultyPoints.Add(typed);
break;
}
}
private void groupItemRemoved(ControlPoint controlPoint)
{
switch (controlPoint)
{
case TimingControlPoint typed:
timingPoints.Remove(typed);
break;
case EffectControlPoint typed:
effectPoints.Remove(typed);
break;
case SampleControlPoint typed:
samplePoints.Remove(typed);
break;
case DifficultyControlPoint typed:
difficultyPoints.Remove(typed);
break;
}
}
}
}

View File

@ -1,26 +1,33 @@
// 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 osuTK;
using osu.Framework.Bindables;
namespace osu.Game.Beatmaps.ControlPoints
{
public class DifficultyControlPoint : ControlPoint, IEquatable<DifficultyControlPoint>
public class DifficultyControlPoint : ControlPoint
{
/// <summary>
/// The speed multiplier at this control point.
/// </summary>
public readonly BindableDouble SpeedMultiplierBindable = new BindableDouble(1)
{
Precision = 0.1,
Default = 1,
MinValue = 0.1,
MaxValue = 10
};
/// <summary>
/// The speed multiplier at this control point.
/// </summary>
public double SpeedMultiplier
{
get => speedMultiplier;
set => speedMultiplier = MathHelper.Clamp(value, 0.1, 10);
get => SpeedMultiplierBindable.Value;
set => SpeedMultiplierBindable.Value = value;
}
private double speedMultiplier = 1;
public bool Equals(DifficultyControlPoint other)
=> base.Equals(other)
&& SpeedMultiplier.Equals(other?.SpeedMultiplier);
public override bool EquivalentTo(ControlPoint other) =>
other is DifficultyControlPoint otherTyped && otherTyped.SpeedMultiplier.Equals(SpeedMultiplier);
}
}

View File

@ -1,24 +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;
using osu.Framework.Bindables;
namespace osu.Game.Beatmaps.ControlPoints
{
public class EffectControlPoint : ControlPoint, IEquatable<EffectControlPoint>
public class EffectControlPoint : ControlPoint
{
/// <summary>
/// Whether this control point enables Kiai mode.
/// Whether the first bar line of this control point is ignored.
/// </summary>
public bool KiaiMode;
public readonly BindableBool OmitFirstBarLineBindable = new BindableBool();
/// <summary>
/// Whether the first bar line of this control point is ignored.
/// </summary>
public bool OmitFirstBarLine;
public bool OmitFirstBarLine
{
get => OmitFirstBarLineBindable.Value;
set => OmitFirstBarLineBindable.Value = value;
}
public bool Equals(EffectControlPoint other)
=> base.Equals(other)
&& KiaiMode == other?.KiaiMode && OmitFirstBarLine == other.OmitFirstBarLine;
/// <summary>
/// Whether this control point enables Kiai mode.
/// </summary>
public readonly BindableBool KiaiModeBindable = new BindableBool();
/// <summary>
/// Whether this control point enables Kiai mode.
/// </summary>
public bool KiaiMode
{
get => KiaiModeBindable.Value;
set => KiaiModeBindable.Value = value;
}
public override bool EquivalentTo(ControlPoint other) =>
other is EffectControlPoint otherTyped &&
KiaiMode == otherTyped.KiaiMode && OmitFirstBarLine == otherTyped.OmitFirstBarLine;
}
}

View File

@ -1,24 +1,47 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
using osu.Game.Audio;
namespace osu.Game.Beatmaps.ControlPoints
{
public class SampleControlPoint : ControlPoint, IEquatable<SampleControlPoint>
public class SampleControlPoint : ControlPoint
{
public const string DEFAULT_BANK = "normal";
/// <summary>
/// The default sample bank at this control point.
/// </summary>
public string SampleBank = DEFAULT_BANK;
public readonly Bindable<string> SampleBankBindable = new Bindable<string>(DEFAULT_BANK) { Default = DEFAULT_BANK };
/// <summary>
/// The speed multiplier at this control point.
/// </summary>
public string SampleBank
{
get => SampleBankBindable.Value;
set => SampleBankBindable.Value = value;
}
/// <summary>
/// The default sample bank at this control point.
/// </summary>
public readonly BindableInt SampleVolumeBindable = new BindableInt(100)
{
MinValue = 0,
MaxValue = 100,
Default = 100
};
/// <summary>
/// The default sample volume at this control point.
/// </summary>
public int SampleVolume = 100;
public int SampleVolume
{
get => SampleVolumeBindable.Value;
set => SampleVolumeBindable.Value = value;
}
/// <summary>
/// Create a SampleInfo based on the sample settings in this control point.
@ -45,8 +68,8 @@ namespace osu.Game.Beatmaps.ControlPoints
return newSampleInfo;
}
public bool Equals(SampleControlPoint other)
=> base.Equals(other)
&& string.Equals(SampleBank, other?.SampleBank) && SampleVolume == other?.SampleVolume;
public override bool EquivalentTo(ControlPoint other) =>
other is SampleControlPoint otherTyped &&
string.Equals(SampleBank, otherTyped.SampleBank) && SampleVolume == otherTyped.SampleVolume;
}
}

View File

@ -1,34 +1,55 @@
// 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 osuTK;
using osu.Framework.Bindables;
using osu.Game.Beatmaps.Timing;
namespace osu.Game.Beatmaps.ControlPoints
{
public class TimingControlPoint : ControlPoint, IEquatable<TimingControlPoint>
public class TimingControlPoint : ControlPoint
{
/// <summary>
/// The time signature at this control point.
/// </summary>
public TimeSignatures TimeSignature = TimeSignatures.SimpleQuadruple;
public readonly Bindable<TimeSignatures> TimeSignatureBindable = new Bindable<TimeSignatures>(TimeSignatures.SimpleQuadruple) { Default = TimeSignatures.SimpleQuadruple };
/// <summary>
/// The time signature at this control point.
/// </summary>
public TimeSignatures TimeSignature
{
get => TimeSignatureBindable.Value;
set => TimeSignatureBindable.Value = value;
}
public const double DEFAULT_BEAT_LENGTH = 1000;
/// <summary>
/// The beat length at this control point.
/// </summary>
public virtual double BeatLength
public readonly BindableDouble BeatLengthBindable = new BindableDouble(DEFAULT_BEAT_LENGTH)
{
get => beatLength;
set => beatLength = MathHelper.Clamp(value, 6, 60000);
Default = DEFAULT_BEAT_LENGTH,
MinValue = 6,
MaxValue = 60000
};
/// <summary>
/// The beat length at this control point.
/// </summary>
public double BeatLength
{
get => BeatLengthBindable.Value;
set => BeatLengthBindable.Value = value;
}
private double beatLength = DEFAULT_BEAT_LENGTH;
/// <summary>
/// The BPM at this control point.
/// </summary>
public double BPM => 60000 / BeatLength;
public bool Equals(TimingControlPoint other)
=> base.Equals(other)
&& TimeSignature == other?.TimeSignature && beatLength.Equals(other.beatLength);
public override bool EquivalentTo(ControlPoint other) =>
other is TimingControlPoint otherTyped
&& TimeSignature == otherTyped.TimeSignature && BeatLength.Equals(otherTyped.BeatLength);
}
}

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics.Textures;
namespace osu.Game.Beatmaps.Drawables
{
[LongRunningLoad]
public class BeatmapSetCover : Sprite
{
private readonly BeatmapSetInfo set;

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using osu.Framework.IO.File;
@ -50,6 +51,8 @@ namespace osu.Game.Beatmaps.Formats
base.ParseStreamInto(stream, beatmap);
flushPendingPoints();
// Objects may be out of order *only* if a user has manually edited an .osu file.
// Unfortunately there are ranked maps in this state (example: https://osu.ppy.sh/s/594828).
// OrderBy is used to guarantee that the parsing order of hitobjects with equal start times is maintained (stably-sorted)
@ -369,104 +372,64 @@ namespace osu.Game.Beatmaps.Formats
if (timingChange)
{
var controlPoint = CreateTimingControlPoint();
controlPoint.Time = time;
controlPoint.BeatLength = beatLength;
controlPoint.TimeSignature = timeSignature;
handleTimingControlPoint(controlPoint);
addControlPoint(time, controlPoint, true);
}
handleDifficultyControlPoint(new DifficultyControlPoint
addControlPoint(time, new LegacyDifficultyControlPoint
{
Time = time,
SpeedMultiplier = speedMultiplier,
AutoGenerated = timingChange
});
}, timingChange);
handleEffectControlPoint(new EffectControlPoint
addControlPoint(time, new EffectControlPoint
{
Time = time,
KiaiMode = kiaiMode,
OmitFirstBarLine = omitFirstBarSignature,
AutoGenerated = timingChange
});
}, timingChange);
handleSampleControlPoint(new LegacySampleControlPoint
addControlPoint(time, new LegacySampleControlPoint
{
Time = time,
SampleBank = stringSampleSet,
SampleVolume = sampleVolume,
CustomSampleBank = customSampleBank,
AutoGenerated = timingChange
});
}, timingChange);
// To handle the scenario where a non-timing line shares the same time value as a subsequent timing line but
// appears earlier in the file, we buffer non-timing control points and rewrite them *after* control points from the timing line
// with the same time value (allowing them to overwrite as necessary).
//
// The expected outcome is that we prefer the non-timing line's adjustments over the timing line's adjustments when time is equal.
if (timingChange)
flushPendingPoints();
}
private void handleTimingControlPoint(TimingControlPoint newPoint)
private readonly List<ControlPoint> pendingControlPoints = new List<ControlPoint>();
private double pendingControlPointsTime;
private void addControlPoint(double time, ControlPoint point, bool timingChange)
{
var existing = beatmap.ControlPointInfo.TimingPointAt(newPoint.Time);
if (time != pendingControlPointsTime)
flushPendingPoints();
if (existing.Time == newPoint.Time)
if (timingChange)
{
// autogenerated points should not replace non-autogenerated.
// this allows for incorrectly ordered timing points to still be correctly handled.
if (newPoint.AutoGenerated && !existing.AutoGenerated)
return;
beatmap.ControlPointInfo.TimingPoints.Remove(existing);
beatmap.ControlPointInfo.Add(time, point);
return;
}
beatmap.ControlPointInfo.TimingPoints.Add(newPoint);
pendingControlPoints.Add(point);
pendingControlPointsTime = time;
}
private void handleDifficultyControlPoint(DifficultyControlPoint newPoint)
private void flushPendingPoints()
{
var existing = beatmap.ControlPointInfo.DifficultyPointAt(newPoint.Time);
foreach (var p in pendingControlPoints)
beatmap.ControlPointInfo.Add(pendingControlPointsTime, p);
if (existing.Time == newPoint.Time)
{
// autogenerated points should not replace non-autogenerated.
// this allows for incorrectly ordered timing points to still be correctly handled.
if (newPoint.AutoGenerated && !existing.AutoGenerated)
return;
beatmap.ControlPointInfo.DifficultyPoints.Remove(existing);
}
beatmap.ControlPointInfo.DifficultyPoints.Add(newPoint);
}
private void handleEffectControlPoint(EffectControlPoint newPoint)
{
var existing = beatmap.ControlPointInfo.EffectPointAt(newPoint.Time);
if (existing.Time == newPoint.Time)
{
// autogenerated points should not replace non-autogenerated.
// this allows for incorrectly ordered timing points to still be correctly handled.
if (newPoint.AutoGenerated && !existing.AutoGenerated)
return;
beatmap.ControlPointInfo.EffectPoints.Remove(existing);
}
beatmap.ControlPointInfo.EffectPoints.Add(newPoint);
}
private void handleSampleControlPoint(SampleControlPoint newPoint)
{
var existing = beatmap.ControlPointInfo.SamplePointAt(newPoint.Time);
if (existing.Time == newPoint.Time)
{
// autogenerated points should not replace non-autogenerated.
// this allows for incorrectly ordered timing points to still be correctly handled.
if (newPoint.AutoGenerated && !existing.AutoGenerated)
return;
beatmap.ControlPointInfo.SamplePoints.Remove(existing);
}
beatmap.ControlPointInfo.SamplePoints.Add(newPoint);
pendingControlPoints.Clear();
}
private void handleHitObject(string line)

View File

@ -189,7 +189,15 @@ namespace osu.Game.Beatmaps.Formats
Foreground = 3
}
internal class LegacySampleControlPoint : SampleControlPoint, IEquatable<LegacySampleControlPoint>
internal class LegacyDifficultyControlPoint : DifficultyControlPoint
{
public LegacyDifficultyControlPoint()
{
SpeedMultiplierBindable.Precision = double.Epsilon;
}
}
internal class LegacySampleControlPoint : SampleControlPoint
{
public int CustomSampleBank;
@ -203,9 +211,9 @@ namespace osu.Game.Beatmaps.Formats
return baseInfo;
}
public bool Equals(LegacySampleControlPoint other)
=> base.Equals(other)
&& CustomSampleBank == other?.CustomSampleBank;
public override bool EquivalentTo(ControlPoint other) =>
base.EquivalentTo(other) && other is LegacySampleControlPoint otherTyped &&
CustomSampleBank == otherTyped.CustomSampleBank;
}
}
}

View File

@ -28,11 +28,15 @@ namespace osu.Game.Beatmaps.Formats
}
protected override TimingControlPoint CreateTimingControlPoint()
=> new LegacyDifficultyCalculatorControlPoint();
=> new LegacyDifficultyCalculatorTimingControlPoint();
private class LegacyDifficultyCalculatorControlPoint : TimingControlPoint
private class LegacyDifficultyCalculatorTimingControlPoint : TimingControlPoint
{
public override double BeatLength { get; set; } = DEFAULT_BEAT_LENGTH;
public LegacyDifficultyCalculatorTimingControlPoint()
{
BeatLengthBindable.MinValue = double.MinValue;
BeatLengthBindable.MaxValue = double.MaxValue;
}
}
}
}

View File

@ -108,7 +108,7 @@ namespace osu.Game.Database
return Import(notification, paths);
}
protected async Task Import(ProgressNotification notification, params string[] paths)
protected async Task<IEnumerable<TModel>> Import(ProgressNotification notification, params string[] paths)
{
notification.Progress = 0;
notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising...";
@ -168,6 +168,8 @@ namespace osu.Game.Database
notification.State = ProgressNotificationState.Completed;
}
return imported;
}
/// <summary>

View File

@ -76,21 +76,17 @@ namespace osu.Game.Database
Task.Factory.StartNew(async () =>
{
// This gets scheduled back to the update thread, but we want the import to run in the background.
await Import(notification, filename);
var imported = await Import(notification, filename);
// for now a failed import will be marked as a failed download for simplicity.
if (!imported.Any())
DownloadFailed?.Invoke(request);
currentDownloads.Remove(request);
}, TaskCreationOptions.LongRunning);
};
request.Failure += error =>
{
DownloadFailed?.Invoke(request);
if (error is OperationCanceledException) return;
notification.State = ProgressNotificationState.Cancelled;
Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!");
currentDownloads.Remove(request);
};
request.Failure += triggerFailure;
notification.CancelRequested += () =>
{
@ -103,11 +99,31 @@ namespace osu.Game.Database
currentDownloads.Add(request);
PostNotification?.Invoke(notification);
Task.Factory.StartNew(() => request.Perform(api), TaskCreationOptions.LongRunning);
Task.Factory.StartNew(() =>
{
try
{
request.Perform(api);
}
catch (Exception error)
{
triggerFailure(error);
}
}, TaskCreationOptions.LongRunning);
DownloadBegan?.Invoke(request);
return true;
void triggerFailure(Exception error)
{
DownloadFailed?.Invoke(request);
if (error is OperationCanceledException) return;
notification.State = ProgressNotificationState.Cancelled;
Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!");
currentDownloads.Remove(request);
}
}
public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, modelStore.ConsumableItems.Where(m => !m.DeletePending));

View File

@ -166,19 +166,6 @@ namespace osu.Game.Database
// no-op. called by tooling.
}
private class OsuDbLoggerProvider : ILoggerProvider
{
#region Disposal
public void Dispose()
{
}
#endregion
public ILogger CreateLogger(string categoryName) => new OsuDbLogger();
}
private class OsuDbLogger : ILogger
{
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)

View File

@ -104,14 +104,10 @@ namespace osu.Game.Graphics.Containers
defaultTiming = new TimingControlPoint
{
BeatLength = default_beat_length,
AutoGenerated = true,
Time = 0
};
defaultEffect = new EffectControlPoint
{
Time = 0,
AutoGenerated = true,
KiaiMode = false,
OmitFirstBarLine = false
};

View File

@ -8,9 +8,6 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics.Sprites;
using System.Collections.Generic;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Users;
namespace osu.Game.Graphics.Containers
@ -23,21 +20,12 @@ namespace osu.Game.Graphics.Containers
}
private OsuGame game;
private ChannelManager channelManager;
private Action showNotImplementedError;
[BackgroundDependencyLoader(true)]
private void load(OsuGame game, NotificationOverlay notifications, ChannelManager channelManager)
private void load(OsuGame game)
{
// will be null in tests
this.game = game;
this.channelManager = channelManager;
showNotImplementedError = () => notifications?.Post(new SimpleNotification
{
Text = @"This link type is not yet supported!",
Icon = FontAwesome.Solid.LifeRing,
});
}
public void AddLinks(string text, List<Link> links)
@ -56,85 +44,47 @@ namespace osu.Game.Graphics.Containers
foreach (var link in links)
{
AddText(text.Substring(previousLinkEnd, link.Index - previousLinkEnd));
AddLink(text.Substring(link.Index, link.Length), link.Url, link.Action, link.Argument);
AddLink(text.Substring(link.Index, link.Length), link.Action, link.Argument ?? link.Url);
previousLinkEnd = link.Index + link.Length;
}
AddText(text.Substring(previousLinkEnd));
}
public IEnumerable<Drawable> AddLink(string text, string url, LinkAction linkType = LinkAction.External, string linkArgument = null, string tooltipText = null, Action<SpriteText> creationParameters = null)
=> createLink(AddText(text, creationParameters), text, url, linkType, linkArgument, tooltipText);
public void AddLink(string text, string url, Action<SpriteText> creationParameters = null) =>
createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.External, url), url);
public IEnumerable<Drawable> AddLink(string text, Action action, string tooltipText = null, Action<SpriteText> creationParameters = null)
=> createLink(AddText(text, creationParameters), text, tooltipText: tooltipText, action: action);
public void AddLink(string text, Action action, string tooltipText = null, Action<SpriteText> creationParameters = null)
=> createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.Custom, null), tooltipText, action);
public IEnumerable<Drawable> AddLink(IEnumerable<SpriteText> text, string url, LinkAction linkType = LinkAction.External, string linkArgument = null, string tooltipText = null)
public void AddLink(string text, LinkAction action, string argument, string tooltipText = null, Action<SpriteText> creationParameters = null)
=> createLink(AddText(text, creationParameters), new LinkDetails(action, argument), null);
public void AddLink(IEnumerable<SpriteText> text, LinkAction action = LinkAction.External, string linkArgument = null, string tooltipText = null)
{
foreach (var t in text)
AddArbitraryDrawable(t);
return createLink(text, null, url, linkType, linkArgument, tooltipText);
createLink(text, new LinkDetails(action, linkArgument), tooltipText);
}
public IEnumerable<Drawable> AddUserLink(User user, Action<SpriteText> creationParameters = null)
=> createLink(AddText(user.Username, creationParameters), user.Username, null, LinkAction.OpenUserProfile, user.Id.ToString(), "View profile");
public void AddUserLink(User user, Action<SpriteText> creationParameters = null)
=> createLink(AddText(user.Username, creationParameters), new LinkDetails(LinkAction.OpenUserProfile, user.Id.ToString()), "View Profile");
private IEnumerable<Drawable> createLink(IEnumerable<Drawable> drawables, string text, string url = null, LinkAction linkType = LinkAction.External, string linkArgument = null, string tooltipText = null, Action action = null)
private void createLink(IEnumerable<Drawable> drawables, LinkDetails link, string tooltipText, Action action = null)
{
AddInternal(new DrawableLinkCompiler(drawables.OfType<SpriteText>().ToList())
{
RelativeSizeAxes = Axes.Both,
TooltipText = tooltipText ?? (url != text ? url : string.Empty),
Action = action ?? (() =>
TooltipText = tooltipText,
Action = () =>
{
switch (linkType)
{
case LinkAction.OpenBeatmap:
// TODO: proper query params handling
if (linkArgument != null && int.TryParse(linkArgument.Contains('?') ? linkArgument.Split('?')[0] : linkArgument, out int beatmapId))
game?.ShowBeatmap(beatmapId);
break;
case LinkAction.OpenBeatmapSet:
if (int.TryParse(linkArgument, out int setId))
game?.ShowBeatmapSet(setId);
break;
case LinkAction.OpenChannel:
try
{
channelManager?.OpenChannel(linkArgument);
}
catch (ChannelNotFoundException)
{
Logger.Log($"The requested channel \"{linkArgument}\" does not exist");
}
break;
case LinkAction.OpenEditorTimestamp:
case LinkAction.JoinMultiplayerMatch:
case LinkAction.Spectate:
showNotImplementedError?.Invoke();
break;
case LinkAction.External:
game?.OpenUrlExternally(url);
break;
case LinkAction.OpenUserProfile:
if (long.TryParse(linkArgument, out long userId))
game?.ShowUser(userId);
break;
default:
throw new NotImplementedException($"This {nameof(LinkAction)} ({linkType.ToString()}) is missing an associated action.");
}
}),
if (action != null)
action();
else
game.HandleLink(link);
},
});
return drawables;
}
// We want the compilers to always be visible no matter where they are, so RelativeSizeAxes is used.

View File

@ -0,0 +1,85 @@
// 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.Events;
using osu.Game.Graphics.Containers;
using osuTK;
namespace osu.Game.Graphics.UserInterface
{
public abstract class LoadingButton : OsuHoverContainer
{
private bool isLoading;
public bool IsLoading
{
get => isLoading;
set
{
isLoading = value;
Enabled.Value = !isLoading;
if (value)
{
loading.Show();
OnLoadStarted();
}
else
{
loading.Hide();
OnLoadFinished();
}
}
}
public Vector2 LoadingAnimationSize
{
get => loading.Size;
set => loading.Size = value;
}
private readonly LoadingAnimation loading;
protected LoadingButton()
{
AddRange(new[]
{
CreateContent(),
loading = new LoadingAnimation
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(12)
}
});
}
protected override bool OnClick(ClickEvent e)
{
if (!Enabled.Value)
return false;
try
{
return base.OnClick(e);
}
finally
{
// run afterwards as this will disable this button.
IsLoading = true;
}
}
protected virtual void OnLoadStarted()
{
}
protected virtual void OnLoadFinished()
{
}
protected abstract Drawable CreateContent();
}
}

View File

@ -1,10 +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.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
@ -19,53 +21,104 @@ namespace osu.Game.Graphics.UserInterface
/// </summary>
public class OsuButton : Button
{
private Box hover;
public string Text
{
get => SpriteText?.Text;
set
{
if (SpriteText != null)
SpriteText.Text = value;
}
}
private Color4? backgroundColour;
public Color4 BackgroundColour
{
set
{
backgroundColour = value;
Background.FadeColour(value);
}
}
protected override Container<Drawable> Content { get; }
protected Box Hover;
protected Box Background;
protected SpriteText SpriteText;
public OsuButton()
{
Height = 40;
Content.Masking = true;
Content.CornerRadius = 5;
AddInternal(Content = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Masking = true,
CornerRadius = 5,
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
Background = new Box
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
},
Hover = new Box
{
Alpha = 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Colour = Color4.White.Opacity(.1f),
Blending = BlendingParameters.Additive,
Depth = float.MinValue
},
SpriteText = CreateText(),
new HoverClickSounds(HoverSampleSet.Loud),
}
});
Enabled.BindValueChanged(enabledChanged, true);
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
BackgroundColour = colours.BlueDark;
AddRange(new Drawable[]
{
hover = new Box
{
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive,
Colour = Color4.White.Opacity(0.1f),
Alpha = 0,
Depth = -1
},
new HoverClickSounds(HoverSampleSet.Loud),
});
if (backgroundColour == null)
BackgroundColour = colours.BlueDark;
Enabled.ValueChanged += enabledChanged;
Enabled.TriggerChange();
}
private void enabledChanged(ValueChangedEvent<bool> e)
protected override bool OnClick(ClickEvent e)
{
this.FadeColour(e.NewValue ? Color4.White : Color4.Gray, 200, Easing.OutQuint);
if (Enabled.Value)
{
Debug.Assert(backgroundColour != null);
Background.FlashColour(backgroundColour.Value, 200);
}
return base.OnClick(e);
}
protected override bool OnHover(HoverEvent e)
{
hover.FadeIn(200);
if (Enabled.Value)
Hover.FadeIn(200, Easing.OutQuint);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
hover.FadeOut(200);
base.OnHoverLost(e);
Hover.FadeOut(300);
}
protected override bool OnMouseDown(MouseDownEvent e)
@ -80,12 +133,17 @@ namespace osu.Game.Graphics.UserInterface
return base.OnMouseUp(e);
}
protected override SpriteText CreateText() => new OsuSpriteText
protected virtual SpriteText CreateText() => new OsuSpriteText
{
Depth = -1,
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Font = OsuFont.GetFont(weight: FontWeight.Bold)
};
private void enabledChanged(ValueChangedEvent<bool> e)
{
this.FadeColour(e.NewValue ? Color4.White : Color4.Gray, 200, Easing.OutQuint);
}
}
}

View File

@ -5,8 +5,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
@ -14,9 +12,9 @@ using System.Collections.Generic;
namespace osu.Game.Graphics.UserInterface
{
public class ShowMoreButton : OsuHoverContainer
public class ShowMoreButton : LoadingButton
{
private const float fade_duration = 200;
private const int duration = 200;
private Color4 chevronIconColour;
@ -32,100 +30,55 @@ namespace osu.Game.Graphics.UserInterface
set => text.Text = value;
}
private bool isLoading;
public bool IsLoading
{
get => isLoading;
set
{
isLoading = value;
Enabled.Value = !isLoading;
if (value)
{
loading.Show();
content.FadeOut(fade_duration, Easing.OutQuint);
}
else
{
loading.Hide();
content.FadeIn(fade_duration, Easing.OutQuint);
}
}
}
private readonly Box background;
private readonly LoadingAnimation loading;
private readonly FillFlowContainer content;
private readonly ChevronIcon leftChevron;
private readonly ChevronIcon rightChevron;
private readonly SpriteText text;
protected override IEnumerable<Drawable> EffectTargets => new[] { background };
private ChevronIcon leftChevron;
private ChevronIcon rightChevron;
private SpriteText text;
private Box background;
private FillFlowContainer textContainer;
public ShowMoreButton()
{
AutoSizeAxes = Axes.Both;
}
protected override Drawable CreateContent() => new CircularContainer
{
Masking = true,
Size = new Vector2(140, 30),
Children = new Drawable[]
{
new CircularContainer
background = new Box
{
Masking = true,
Size = new Vector2(140, 30),
RelativeSizeAxes = Axes.Both,
},
textContainer = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(7),
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
},
content = new FillFlowContainer
leftChevron = new ChevronIcon(),
text = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(7),
Children = new Drawable[]
{
leftChevron = new ChevronIcon(),
text = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
Text = "show more".ToUpper(),
},
rightChevron = new ChevronIcon(),
}
},
loading = new LoadingAnimation
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(12)
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
Text = "show more".ToUpper(),
},
rightChevron = new ChevronIcon(),
}
}
};
}
protected override bool OnClick(ClickEvent e)
{
if (!Enabled.Value)
return false;
try
{
return base.OnClick(e);
}
finally
{
// run afterwards as this will disable this button.
IsLoading = true;
}
}
};
protected override void OnLoadStarted() => textContainer.FadeOut(duration, Easing.OutQuint);
protected override void OnLoadFinished() => textContainer.FadeIn(duration, Easing.OutQuint);
private class ChevronIcon : SpriteIcon
{

View File

@ -1,132 +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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.Containers;
using osuTK;
using osu.Framework.Graphics.UserInterface;
namespace osu.Game.Graphics.UserInterfaceV2
{
public abstract class LabelledComponent<T> : CompositeDrawable
where T : Drawable
public abstract class LabelledComponent<T, U> : LabelledDrawable<T>, IHasCurrentValue<U>
where T : Drawable, IHasCurrentValue<U>
{
protected const float CONTENT_PADDING_VERTICAL = 10;
protected const float CONTENT_PADDING_HORIZONTAL = 15;
protected const float CORNER_RADIUS = 15;
/// <summary>
/// The component that is being displayed.
/// </summary>
protected readonly T Component;
private readonly OsuTextFlowContainer labelText;
private readonly OsuTextFlowContainer descriptionText;
/// <summary>
/// Creates a new <see cref="LabelledComponent{T}"/>.
/// </summary>
/// <param name="padded">Whether the component should be padded or should be expanded to the bounds of this <see cref="LabelledComponent{T}"/>.</param>
protected LabelledComponent(bool padded)
: base(padded)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
CornerRadius = CORNER_RADIUS;
Masking = true;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = OsuColour.FromHex("1c2125"),
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Padding = padded
? new MarginPadding { Horizontal = CONTENT_PADDING_HORIZONTAL, Vertical = CONTENT_PADDING_VERTICAL }
: new MarginPadding { Left = CONTENT_PADDING_HORIZONTAL },
Spacing = new Vector2(0, 12),
Children = new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Content = new[]
{
new Drawable[]
{
labelText = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Bold))
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = 20 }
},
new Container
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Child = Component = CreateComponent().With(d =>
{
d.Anchor = Anchor.CentreRight;
d.Origin = Anchor.CentreRight;
})
}
},
},
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }
},
descriptionText = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold, italics: true))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Bottom = padded ? 0 : CONTENT_PADDING_VERTICAL },
Alpha = 0,
}
}
}
};
}
[BackgroundDependencyLoader]
private void load(OsuColour osuColour)
public Bindable<U> Current
{
descriptionText.Colour = osuColour.Yellow;
get => Component.Current;
set => Component.Current = value;
}
public string Label
{
set => labelText.Text = value;
}
public string Description
{
set
{
descriptionText.Text = value;
if (!string.IsNullOrEmpty(value))
descriptionText.Show();
else
descriptionText.Hide();
}
}
/// <summary>
/// Creates the component that should be displayed.
/// </summary>
/// <returns>The component.</returns>
protected abstract T CreateComponent();
}
}

View File

@ -0,0 +1,132 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.Containers;
using osuTK;
namespace osu.Game.Graphics.UserInterfaceV2
{
public abstract class LabelledDrawable<T> : CompositeDrawable
where T : Drawable
{
protected const float CONTENT_PADDING_VERTICAL = 10;
protected const float CONTENT_PADDING_HORIZONTAL = 15;
protected const float CORNER_RADIUS = 15;
/// <summary>
/// The component that is being displayed.
/// </summary>
protected readonly T Component;
private readonly OsuTextFlowContainer labelText;
private readonly OsuTextFlowContainer descriptionText;
/// <summary>
/// Creates a new <see cref="LabelledComponent{T, U}"/>.
/// </summary>
/// <param name="padded">Whether the component should be padded or should be expanded to the bounds of this <see cref="LabelledComponent{T, U}"/>.</param>
protected LabelledDrawable(bool padded)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
CornerRadius = CORNER_RADIUS;
Masking = true;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = OsuColour.FromHex("1c2125"),
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Padding = padded
? new MarginPadding { Horizontal = CONTENT_PADDING_HORIZONTAL, Vertical = CONTENT_PADDING_VERTICAL }
: new MarginPadding { Left = CONTENT_PADDING_HORIZONTAL },
Spacing = new Vector2(0, 12),
Children = new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Content = new[]
{
new Drawable[]
{
labelText = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Bold))
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = 20 }
},
new Container
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Child = Component = CreateComponent().With(d =>
{
d.Anchor = Anchor.CentreRight;
d.Origin = Anchor.CentreRight;
})
}
},
},
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }
},
descriptionText = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold, italics: true))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Bottom = padded ? 0 : CONTENT_PADDING_VERTICAL },
Alpha = 0,
}
}
}
};
}
[BackgroundDependencyLoader]
private void load(OsuColour osuColour)
{
descriptionText.Colour = osuColour.Yellow;
}
public string Label
{
set => labelText.Text = value;
}
public string Description
{
set
{
descriptionText.Text = value;
if (!string.IsNullOrEmpty(value))
descriptionText.Show();
else
descriptionText.Hide();
}
}
/// <summary>
/// Creates the component that should be displayed.
/// </summary>
/// <returns>The component.</returns>
protected abstract T CreateComponent();
}
}

View File

@ -3,7 +3,7 @@
namespace osu.Game.Graphics.UserInterfaceV2
{
public class LabelledSwitchButton : LabelledComponent<SwitchButton>
public class LabelledSwitchButton : LabelledComponent<SwitchButton, bool>
{
public LabelledSwitchButton()
: base(true)

View File

@ -8,7 +8,7 @@ using osu.Game.Graphics.UserInterface;
namespace osu.Game.Graphics.UserInterfaceV2
{
public class LabelledTextBox : LabelledComponent<OsuTextBox>
public class LabelledTextBox : LabelledComponent<OsuTextBox, string>
{
public event TextBox.OnCommitHandler OnCommit;

View File

@ -1,30 +1,16 @@
// 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.IO;
using System.Linq;
using osu.Framework.IO.Stores;
using SharpCompress.Archives.Zip;
using SharpCompress.Common;
namespace osu.Game.IO.Archives
{
public sealed class ZipArchiveReader : ArchiveReader
{
/// <summary>
/// List of substrings that indicate a file should be ignored during the import process
/// (usually due to representing no useful data and being autogenerated by the OS).
/// </summary>
private static readonly string[] filename_ignore_list =
{
// Mac-specific
"__MACOSX",
".DS_Store",
// Windows-specific
"Thumbs.db"
};
private readonly Stream archiveStream;
private readonly ZipArchive archive;
@ -58,9 +44,7 @@ namespace osu.Game.IO.Archives
archiveStream.Dispose();
}
private static bool canBeIgnored(IEntry entry) => filename_ignore_list.Any(ignoredName => entry.Key.IndexOf(ignoredName, StringComparison.OrdinalIgnoreCase) >= 0);
public override IEnumerable<string> Filenames => archive.Entries.Where(e => !canBeIgnored(e)).Select(e => e.Key).ToArray();
public override IEnumerable<string> Filenames => archive.Entries.Select(e => e.Key).ExcludeSystemFileNames();
public override Stream GetUnderlyingStream() => archiveStream;
}

View File

@ -0,0 +1,36 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.IO.Network;
using osu.Game.Online.API.Requests.Responses;
using System.Net.Http;
namespace osu.Game.Online.API.Requests
{
public class CommentVoteRequest : APIRequest<CommentBundle>
{
private readonly long id;
private readonly CommentVoteAction action;
public CommentVoteRequest(long id, CommentVoteAction action)
{
this.id = id;
this.action = action;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = action == CommentVoteAction.Vote ? HttpMethod.Post : HttpMethod.Delete;
return req;
}
protected override string Target => $@"comments/{id}/vote";
}
public enum CommentVoteAction
{
Vote,
UnVote
}
}

View File

@ -72,6 +72,8 @@ namespace osu.Game.Online.API.Requests.Responses
public bool HasMessage => !string.IsNullOrEmpty(MessageHtml);
public bool IsVoted { get; set; }
public string GetMessage => HasMessage ? WebUtility.HtmlDecode(Regex.Replace(MessageHtml, @"<(.|\n)*?>", string.Empty)) : string.Empty;
public int DeletedChildrenCount => ChildComments.Count(c => c.IsDeleted);

View File

@ -47,6 +47,22 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty(@"included_comments")]
public List<Comment> IncludedComments { get; set; }
[JsonProperty(@"user_votes")]
private List<long> userVotes
{
set
{
value.ForEach(v =>
{
Comments.ForEach(c =>
{
if (v == c.Id)
c.IsVoted = true;
});
});
}
}
private List<User> users;
[JsonProperty(@"users")]

View File

@ -14,7 +14,7 @@ namespace osu.Game.Online.Chat
{
public class Channel
{
public readonly int MaxHistory = 300;
public const int MAX_HISTORY = 300;
/// <summary>
/// Contains every joined user except the current logged in user. Currently only returned for PM channels.
@ -80,8 +80,6 @@ namespace osu.Game.Online.Chat
/// </summary>
public Bindable<bool> Joined = new Bindable<bool>();
public const int MAX_HISTORY = 300;
[JsonConstructor]
public Channel()
{
@ -162,8 +160,8 @@ namespace osu.Game.Online.Chat
{
// never purge local echos
int messageCount = Messages.Count - pendingMessages.Count;
if (messageCount > MaxHistory)
Messages.RemoveRange(0, messageCount - MaxHistory);
if (messageCount > MAX_HISTORY)
Messages.RemoveRange(0, messageCount - MAX_HISTORY);
}
}
}

View File

@ -20,7 +20,7 @@ namespace osu.Game.Online.Chat
private static readonly Regex new_link_regex = new Regex(@"\[(?<url>[a-z]+://[^ ]+) (?<text>(((?<=\\)[\[\]])|[^\[\]])*(((?<open>\[)(((?<=\\)[\[\]])|[^\[\]])*)+((?<close-open>\])(((?<=\\)[\[\]])|[^\[\]])*)+)*(?(open)(?!)))\]");
// [test](https://osu.ppy.sh/b/1234) -> test (https://osu.ppy.sh/b/1234) aka correct markdown format
private static readonly Regex markdown_link_regex = new Regex(@"\[(?<text>(((?<=\\)[\[\]])|[^\[\]])*(((?<open>\[)(((?<=\\)[\[\]])|[^\[\]])*)+((?<close-open>\])(((?<=\\)[\[\]])|[^\[\]])*)+)*(?(open)(?!)))\]\((?<url>[a-z]+://[^ ]+)\)");
private static readonly Regex markdown_link_regex = new Regex(@"\[(?<text>(((?<=\\)[\[\]])|[^\[\]])*(((?<open>\[)(((?<=\\)[\[\]])|[^\[\]])*)+((?<close-open>\])(((?<=\\)[\[\]])|[^\[\]])*)+)*(?(open)(?!)))\]\((?<url>[a-z]+://[^ ]+)(\s+(?<title>""([^""]|(?<=\\)"")*""))?\)");
// advanced, RFC-compatible regular expression that matches any possible URL, *but* allows certain invalid characters that are widely used
// This is in the format (<required>, [optional]):
@ -81,7 +81,7 @@ namespace osu.Game.Online.Chat
//since we just changed the line display text, offset any already processed links.
result.Links.ForEach(l => l.Index -= l.Index > index ? m.Length - displayText.Length : 0);
var details = getLinkDetails(linkText);
var details = GetLinkDetails(linkText);
result.Links.Add(new Link(linkText, index, displayText.Length, linkActionOverride ?? details.Action, details.Argument));
//adjust the offset for processing the current matches group.
@ -95,15 +95,21 @@ namespace osu.Game.Online.Chat
foreach (Match m in regex.Matches(result.Text, startIndex))
{
var index = m.Index;
var link = m.Groups["link"].Value;
var indexLength = link.Length;
var linkText = m.Groups["link"].Value;
var indexLength = linkText.Length;
var details = getLinkDetails(link);
result.Links.Add(new Link(link, index, indexLength, details.Action, details.Argument));
var details = GetLinkDetails(linkText);
var link = new Link(linkText, index, indexLength, details.Action, details.Argument);
// sometimes an already-processed formatted link can reduce to a simple URL, too
// (example: [mean example - https://osu.ppy.sh](https://osu.ppy.sh))
// therefore we need to check if any of the pre-existing links contains the raw one we found
if (result.Links.All(existingLink => !existingLink.Overlaps(link)))
result.Links.Add(link);
}
}
private static LinkDetails getLinkDetails(string url)
public static LinkDetails GetLinkDetails(string url)
{
var args = url.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
args[0] = args[0].TrimEnd(':');
@ -249,17 +255,17 @@ namespace osu.Game.Online.Chat
OriginalText = Text = text;
}
}
}
public class LinkDetails
public class LinkDetails
{
public LinkAction Action;
public string Argument;
public LinkDetails(LinkAction action, string argument)
{
public LinkAction Action;
public string Argument;
public LinkDetails(LinkAction action, string argument)
{
Action = action;
Argument = argument;
}
Action = action;
Argument = argument;
}
}
@ -273,6 +279,7 @@ namespace osu.Game.Online.Chat
JoinMultiplayerMatch,
Spectate,
OpenUserProfile,
Custom
}
public class Link : IComparable<Link>
@ -292,6 +299,8 @@ namespace osu.Game.Online.Chat
Argument = argument;
}
public bool Overlaps(Link otherLink) => Index < otherLink.Index + otherLink.Length && otherLink.Index < Index + Length;
public int CompareTo(Link otherLink) => Index > otherLink.Index ? 1 : -1;
}
}

View File

@ -21,6 +21,7 @@ using osu.Game.Users.Drawables;
using osuTK;
using osuTK.Graphics;
using Humanizer;
using osu.Game.Online.API;
namespace osu.Game.Online.Leaderboards
{
@ -37,6 +38,7 @@ namespace osu.Game.Online.Leaderboards
private readonly ScoreInfo score;
private readonly int rank;
private readonly bool allowHighlight;
private Box background;
private Container content;
@ -49,17 +51,18 @@ namespace osu.Game.Online.Leaderboards
private List<ScoreComponentLabel> statisticsLabels;
public LeaderboardScore(ScoreInfo score, int rank)
public LeaderboardScore(ScoreInfo score, int rank, bool allowHighlight = true)
{
this.score = score;
this.rank = rank;
this.allowHighlight = allowHighlight;
RelativeSizeAxes = Axes.X;
Height = HEIGHT;
}
[BackgroundDependencyLoader]
private void load()
private void load(IAPIProvider api, OsuColour colour)
{
var user = score.User;
@ -100,7 +103,7 @@ namespace osu.Game.Online.Leaderboards
background = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Black,
Colour = user.Id == api.LocalUser.Value.Id && allowHighlight ? colour.Green : Color4.Black,
Alpha = background_alpha,
},
},

View File

@ -215,31 +215,102 @@ namespace osu.Game
private ExternalLinkOpener externalLinkOpener;
public void OpenUrlExternally(string url)
/// <summary>
/// Handle an arbitrary URL. Displays via in-game overlays where possible.
/// This can be called from a non-thread-safe non-game-loaded state.
/// </summary>
/// <param name="url">The URL to load.</param>
public void HandleLink(string url) => HandleLink(MessageFormatter.GetLinkDetails(url));
/// <summary>
/// Handle a specific <see cref="LinkDetails"/>.
/// This can be called from a non-thread-safe non-game-loaded state.
/// </summary>
/// <param name="link">The link to load.</param>
public void HandleLink(LinkDetails link) => Schedule(() =>
{
switch (link.Action)
{
case LinkAction.OpenBeatmap:
// TODO: proper query params handling
if (link.Argument != null && int.TryParse(link.Argument.Contains('?') ? link.Argument.Split('?')[0] : link.Argument, out int beatmapId))
ShowBeatmap(beatmapId);
break;
case LinkAction.OpenBeatmapSet:
if (int.TryParse(link.Argument, out int setId))
ShowBeatmapSet(setId);
break;
case LinkAction.OpenChannel:
ShowChannel(link.Argument);
break;
case LinkAction.OpenEditorTimestamp:
case LinkAction.JoinMultiplayerMatch:
case LinkAction.Spectate:
waitForReady(() => notifications, _ => notifications?.Post(new SimpleNotification
{
Text = @"This link type is not yet supported!",
Icon = FontAwesome.Solid.LifeRing,
}));
break;
case LinkAction.External:
OpenUrlExternally(link.Argument);
break;
case LinkAction.OpenUserProfile:
if (long.TryParse(link.Argument, out long userId))
ShowUser(userId);
break;
default:
throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action.");
}
});
public void OpenUrlExternally(string url) => waitForReady(() => externalLinkOpener, _ =>
{
if (url.StartsWith("/"))
url = $"{API.Endpoint}{url}";
externalLinkOpener.OpenUrlExternally(url);
}
});
/// <summary>
/// Open a specific channel in chat.
/// </summary>
/// <param name="channel">The channel to display.</param>
public void ShowChannel(string channel) => waitForReady(() => channelManager, _ =>
{
try
{
channelManager.OpenChannel(channel);
}
catch (ChannelNotFoundException)
{
Logger.Log($"The requested channel \"{channel}\" does not exist");
}
});
/// <summary>
/// Show a beatmap set as an overlay.
/// </summary>
/// <param name="setId">The set to display.</param>
public void ShowBeatmapSet(int setId) => beatmapSetOverlay.FetchAndShowBeatmapSet(setId);
public void ShowBeatmapSet(int setId) => waitForReady(() => beatmapSetOverlay, _ => beatmapSetOverlay.FetchAndShowBeatmapSet(setId));
/// <summary>
/// Show a user's profile as an overlay.
/// </summary>
/// <param name="userId">The user to display.</param>
public void ShowUser(long userId) => userProfile.ShowUser(userId);
public void ShowUser(long userId) => waitForReady(() => userProfile, _ => userProfile.ShowUser(userId));
/// <summary>
/// Show a beatmap's set as an overlay, displaying the given beatmap.
/// </summary>
/// <param name="beatmapId">The beatmap to show.</param>
public void ShowBeatmap(int beatmapId) => beatmapSetOverlay.FetchAndShowBeatmap(beatmapId);
public void ShowBeatmap(int beatmapId) => waitForReady(() => beatmapSetOverlay, _ => beatmapSetOverlay.FetchAndShowBeatmap(beatmapId));
/// <summary>
/// Present a beatmap at song select immediately.
@ -397,6 +468,23 @@ namespace osu.Game
performFromMainMenuTask = Schedule(() => performFromMainMenu(action, taskName));
}
/// <summary>
/// Wait for the game (and target component) to become loaded and then run an action.
/// </summary>
/// <param name="retrieveInstance">A function to retrieve a (potentially not-yet-constructed) target instance.</param>
/// <param name="action">The action to perform on the instance when load is confirmed.</param>
/// <typeparam name="T">The type of the target instance.</typeparam>
private void waitForReady<T>(Func<T> retrieveInstance, Action<T> action)
where T : Drawable
{
var instance = retrieveInstance();
if (ScreenStack == null || ScreenStack.CurrentScreen is StartupScreen || instance?.IsLoaded != true)
Schedule(() => waitForReady(retrieveInstance, action));
else
action(instance);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

View File

@ -17,6 +17,7 @@ using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets;
using osuTK;
using osuTK.Graphics;
@ -27,10 +28,11 @@ namespace osu.Game.Overlays.BeatmapSet
private const float tile_icon_padding = 7;
private const float tile_spacing = 2;
private readonly DifficultiesContainer difficulties;
private readonly OsuSpriteText version, starRating;
private readonly Statistic plays, favourites;
public readonly DifficultiesContainer Difficulties;
public readonly Bindable<BeatmapInfo> Beatmap = new Bindable<BeatmapInfo>();
private BeatmapSetInfo beatmapSet;
@ -43,38 +45,10 @@ namespace osu.Game.Overlays.BeatmapSet
if (value == beatmapSet) return;
beatmapSet = value;
updateDisplay();
}
}
private void updateDisplay()
{
difficulties.Clear();
if (BeatmapSet != null)
{
difficulties.ChildrenEnumerable = BeatmapSet.Beatmaps.OrderBy(beatmap => beatmap.StarDifficulty).Select(b => new DifficultySelectorButton(b)
{
State = DifficultySelectorState.NotSelected,
OnHovered = beatmap =>
{
showBeatmap(beatmap);
starRating.Text = beatmap.StarDifficulty.ToString("Star Difficulty 0.##");
starRating.FadeIn(100);
},
OnClicked = beatmap => { Beatmap.Value = beatmap; },
});
}
starRating.FadeOut(100);
Beatmap.Value = BeatmapSet?.Beatmaps.FirstOrDefault();
plays.Value = BeatmapSet?.OnlineInfo.PlayCount ?? 0;
favourites.Value = BeatmapSet?.OnlineInfo.FavouriteCount ?? 0;
updateDifficultyButtons();
}
public BeatmapPicker()
{
RelativeSizeAxes = Axes.X;
@ -89,7 +63,7 @@ namespace osu.Game.Overlays.BeatmapSet
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
difficulties = new DifficultiesContainer
Difficulties = new DifficultiesContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
@ -147,6 +121,9 @@ namespace osu.Game.Overlays.BeatmapSet
};
}
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
@ -158,10 +135,39 @@ namespace osu.Game.Overlays.BeatmapSet
{
base.LoadComplete();
ruleset.ValueChanged += r => updateDisplay();
// done here so everything can bind in intialization and get the first trigger
Beatmap.TriggerChange();
}
private void updateDisplay()
{
Difficulties.Clear();
if (BeatmapSet != null)
{
Difficulties.ChildrenEnumerable = BeatmapSet.Beatmaps.Where(b => b.Ruleset.Equals(ruleset.Value)).OrderBy(b => b.StarDifficulty).Select(b => new DifficultySelectorButton(b)
{
State = DifficultySelectorState.NotSelected,
OnHovered = beatmap =>
{
showBeatmap(beatmap);
starRating.Text = beatmap.StarDifficulty.ToString("Star Difficulty 0.##");
starRating.FadeIn(100);
},
OnClicked = beatmap => { Beatmap.Value = beatmap; },
});
}
starRating.FadeOut(100);
Beatmap.Value = Difficulties.FirstOrDefault()?.Beatmap;
plays.Value = BeatmapSet?.OnlineInfo.PlayCount ?? 0;
favourites.Value = BeatmapSet?.OnlineInfo.FavouriteCount ?? 0;
updateDifficultyButtons();
}
private void showBeatmap(BeatmapInfo beatmap)
{
version.Text = beatmap?.Version;
@ -169,10 +175,10 @@ namespace osu.Game.Overlays.BeatmapSet
private void updateDifficultyButtons()
{
difficulties.Children.ToList().ForEach(diff => diff.State = diff.Beatmap == Beatmap.Value ? DifficultySelectorState.Selected : DifficultySelectorState.NotSelected);
Difficulties.Children.ToList().ForEach(diff => diff.State = diff.Beatmap == Beatmap.Value ? DifficultySelectorState.Selected : DifficultySelectorState.NotSelected);
}
private class DifficultiesContainer : FillFlowContainer<DifficultySelectorButton>
public class DifficultiesContainer : FillFlowContainer<DifficultySelectorButton>
{
public Action OnLostHover;
@ -183,7 +189,7 @@ namespace osu.Game.Overlays.BeatmapSet
}
}
private class DifficultySelectorButton : OsuClickableContainer, IStateful<DifficultySelectorState>
public class DifficultySelectorButton : OsuClickableContainer, IStateful<DifficultySelectorState>
{
private const float transition_duration = 100;
private const float size = 52;
@ -320,7 +326,7 @@ namespace osu.Game.Overlays.BeatmapSet
}
}
private enum DifficultySelectorState
public enum DifficultySelectorState
{
Selected,
NotSelected,

View File

@ -0,0 +1,48 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osuTK;
using System.Linq;
namespace osu.Game.Overlays.BeatmapSet
{
public class BeatmapRulesetSelector : RulesetSelector
{
private readonly Bindable<BeatmapSetInfo> beatmapSet = new Bindable<BeatmapSetInfo>();
public BeatmapSetInfo BeatmapSet
{
get => beatmapSet.Value;
set
{
// propagate value to tab items first to enable only available rulesets.
beatmapSet.Value = value;
SelectTab(TabContainer.TabItems.FirstOrDefault(t => t.Enabled.Value));
}
}
public BeatmapRulesetSelector()
{
AutoSizeAxes = Axes.Both;
}
protected override TabItem<RulesetInfo> CreateTabItem(RulesetInfo value) => new BeatmapRulesetTabItem(value)
{
BeatmapSet = { BindTarget = beatmapSet }
};
protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
};
}
}

View File

@ -0,0 +1,145 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osuTK;
using osuTK.Graphics;
using System.Linq;
namespace osu.Game.Overlays.BeatmapSet
{
public class BeatmapRulesetTabItem : TabItem<RulesetInfo>
{
private readonly OsuSpriteText name, count;
private readonly Box bar;
public readonly Bindable<BeatmapSetInfo> BeatmapSet = new Bindable<BeatmapSetInfo>();
public override bool PropagatePositionalInputSubTree => Enabled.Value && !Active.Value && base.PropagatePositionalInputSubTree;
public BeatmapRulesetTabItem(RulesetInfo value)
: base(value)
{
AutoSizeAxes = Axes.Both;
FillFlowContainer nameContainer;
Children = new Drawable[]
{
nameContainer = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Margin = new MarginPadding { Bottom = 7.5f },
Spacing = new Vector2(2.5f),
Children = new Drawable[]
{
name = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = value.Name,
Font = OsuFont.Default.With(size: 18),
},
new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 4f,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Black.Opacity(0.5f),
},
count = new OsuSpriteText
{
Alpha = 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Margin = new MarginPadding { Horizontal = 5f },
Font = OsuFont.Default.With(weight: FontWeight.SemiBold),
}
}
}
}
},
bar = new Box
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.X,
},
new HoverClickSounds(),
};
BeatmapSet.BindValueChanged(setInfo =>
{
var beatmapsCount = setInfo.NewValue?.Beatmaps.Count(b => b.Ruleset.Equals(Value)) ?? 0;
count.Text = beatmapsCount.ToString();
count.Alpha = beatmapsCount > 0 ? 1f : 0f;
Enabled.Value = beatmapsCount > 0;
}, true);
Enabled.BindValueChanged(v => nameContainer.Alpha = v.NewValue ? 1f : 0.5f, true);
}
[Resolved]
private OsuColour colour { get; set; }
protected override void LoadComplete()
{
base.LoadComplete();
count.Colour = colour.Gray9;
bar.Colour = colour.Blue;
updateState();
}
private void updateState()
{
var isHoveredOrActive = IsHovered || Active.Value;
bar.ResizeHeightTo(isHoveredOrActive ? 4 : 0, 200, Easing.OutQuint);
name.Colour = isHoveredOrActive ? colour.GrayE : colour.GrayC;
name.Font = name.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Regular);
}
#region Hovering and activation logic
protected override void OnActivated() => updateState();
protected override void OnDeactivated() => updateState();
protected override bool OnHover(HoverEvent e)
{
updateState();
return false;
}
protected override void OnHoverLost(HoverLostEvent e) => updateState();
#endregion
}
}

View File

@ -3,6 +3,7 @@
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
@ -16,6 +17,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Online;
using osu.Game.Overlays.BeatmapSet.Buttons;
using osu.Game.Overlays.Direct;
using osu.Game.Rulesets;
using osuTK;
using osuTK.Graphics;
@ -39,6 +41,7 @@ namespace osu.Game.Overlays.BeatmapSet
public bool DownloadButtonsVisible => downloadButtonsContainer.Any();
public readonly BeatmapRulesetSelector RulesetSelector;
public readonly BeatmapPicker Picker;
private readonly FavouriteButton favouriteButton;
@ -47,6 +50,9 @@ namespace osu.Game.Overlays.BeatmapSet
private readonly LoadingAnimation loading;
[Cached(typeof(IBindable<RulesetInfo>))]
private readonly Bindable<RulesetInfo> ruleset = new Bindable<RulesetInfo>();
public Header()
{
ExternalLinkButton externalLink;
@ -69,12 +75,18 @@ namespace osu.Game.Overlays.BeatmapSet
{
RelativeSizeAxes = Axes.X,
Height = tabs_height,
Children = new[]
Children = new Drawable[]
{
tabsBg = new Box
{
RelativeSizeAxes = Axes.Both,
},
RulesetSelector = new BeatmapRulesetSelector
{
Current = ruleset,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
}
},
},
new Container
@ -223,7 +235,7 @@ namespace osu.Game.Overlays.BeatmapSet
BeatmapSet.BindValueChanged(setInfo =>
{
Picker.BeatmapSet = author.BeatmapSet = beatmapAvailability.BeatmapSet = Details.BeatmapSet = setInfo.NewValue;
Picker.BeatmapSet = RulesetSelector.BeatmapSet = author.BeatmapSet = beatmapAvailability.BeatmapSet = Details.BeatmapSet = setInfo.NewValue;
cover.BeatmapSet = setInfo.NewValue;
if (setInfo.NewValue == null)

View File

@ -110,7 +110,7 @@ namespace osu.Game.Overlays.Changelog
t.Font = fontLarge;
t.Colour = entryColour;
});
title.AddLink($"{entry.Repository.Replace("ppy/", "")}#{entry.GithubPullRequestId}", entry.GithubUrl, Online.Chat.LinkAction.External,
title.AddLink($"{entry.Repository.Replace("ppy/", "")}#{entry.GithubPullRequestId}", entry.GithubUrl,
creationParameters: t =>
{
t.Font = fontLarge;
@ -140,7 +140,7 @@ namespace osu.Game.Overlays.Changelog
t.Colour = entryColour;
});
else if (entry.GithubUser.GithubUrl != null)
title.AddLink(entry.GithubUser.DisplayName, entry.GithubUser.GithubUrl, Online.Chat.LinkAction.External, null, null, t =>
title.AddLink(entry.GithubUser.DisplayName, entry.GithubUser.GithubUrl, t =>
{
t.Font = fontMedium;
t.Colour = entryColour;

View File

@ -44,7 +44,17 @@ namespace osu.Game.Overlays.Changelog
req.Failure += _ => complete = true;
// This is done on a separate thread to support cancellation below
Task.Run(() => req.Perform(api));
Task.Run(() =>
{
try
{
req.Perform(api);
}
catch
{
complete = true;
}
});
while (!complete)
{

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