1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-13 14:12:56 +08:00

Merge branch 'master' into skin-editor-closest-anchor

This commit is contained in:
Dean Herbert 2021-06-22 16:41:51 +09:00
commit 1fff9a93b9
76 changed files with 2147 additions and 368 deletions

3
.gitignore vendored
View File

@ -336,3 +336,6 @@ inspectcode
/BenchmarkDotNet.Artifacts
*.GeneratedMSBuildEditorConfig.editorconfig
# Fody (pulled in by Realm) - schema file
FodyWeavers.xsd

3
FodyWeavers.xml Normal file
View File

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Realm />
</Weavers>

View File

@ -43,7 +43,7 @@ If your platform is not listed above, there is still a chance you can manually b
osu! is designed to have extensible modular gameplay modes, called "rulesets". Building one of these allows a developer to harness the power of osu! for their own game style. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates).
You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/issues/5852).
You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096).
## Developing osu!

View File

@ -51,7 +51,11 @@
<Reference Include="Java.Interop" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.614.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.616.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
<PackageReference Include="Realm" Version="10.2.0" />
</ItemGroup>
</Project>

View File

@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -34,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.UI
// only check the X position; handle all vertical space.
base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y));
public CatchPlayfield(BeatmapDifficulty difficulty, Func<CatchHitObject, DrawableHitObject<CatchHitObject>> createDrawableRepresentation)
public CatchPlayfield(BeatmapDifficulty difficulty)
{
var droppedObjectContainer = new Container<CaughtObject>
{

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Catch.UI
protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield);
protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty, CreateDrawableRepresentation);
protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty);
public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchPlayfieldAdjustmentContainer();

View File

@ -0,0 +1,260 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using Moq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Checks;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
{
[TestFixture]
public class CheckLowDiffOverlapsTest
{
private CheckLowDiffOverlaps check;
[SetUp]
public void Setup()
{
check = new CheckLowDiffOverlaps();
}
[Test]
public void TestNoOverlapFarApart()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(200, 0) }
}
});
}
[Test]
public void TestNoOverlapClose()
{
assertShouldProbablyOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 167, Position = new Vector2(200, 0) }
}
});
}
[Test]
public void TestNoOverlapTooClose()
{
assertShouldOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 100, Position = new Vector2(200, 0) }
}
});
}
[Test]
public void TestNoOverlapTooCloseExpert()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 100, Position = new Vector2(200, 0) }
}
}, DifficultyRating.Expert);
}
[Test]
public void TestOverlapClose()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 167, Position = new Vector2(20, 0) }
}
});
}
[Test]
public void TestOverlapFarApart()
{
assertShouldNotOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(20, 0) }
}
});
}
[Test]
public void TestAlmostOverlapFarApart()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
// Default circle diameter is 128 px, but part of that is the fade/border of the circle.
// We want this to only be a problem when it actually looks like an overlap.
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(125, 0) }
}
});
}
[Test]
public void TestAlmostNotOverlapFarApart()
{
assertShouldNotOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(110, 0) }
}
});
}
[Test]
public void TestOverlapFarApartExpert()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(20, 0) }
}
}, DifficultyRating.Expert);
}
[Test]
public void TestOverlapTooFarApart()
{
// Far apart enough to where the objects are not visible at the same time, and so overlapping is fine.
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 2000, Position = new Vector2(20, 0) }
}
});
}
[Test]
public void TestSliderTailOverlapFarApart()
{
assertShouldNotOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) }
}
});
}
[Test]
public void TestSliderTailOverlapClose()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) }
}
});
}
[Test]
public void TestSliderTailNoOverlapFarApart()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) }
}
});
}
[Test]
public void TestSliderTailNoOverlapClose()
{
// If these were circles they would need to overlap, but overlapping with slider tails is not required.
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) }
}
});
}
private Mock<Slider> getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition)
{
var mockSlider = new Mock<Slider>();
mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
mockSlider.SetupGet(s => s.Position).Returns(startPosition);
mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition);
mockSlider.As<IHasDuration>().Setup(d => d.EndTime).Returns(endTime);
return mockSlider;
}
private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
Assert.That(check.Run(context), Is.Empty);
}
private void assertShouldProbablyOverlap(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldProbablyOverlap));
}
private void assertShouldOverlap(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldOverlap));
}
private void assertShouldNotOverlap(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldNotOverlap));
}
}
}

View File

@ -0,0 +1,324 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using Moq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Checks;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
{
[TestFixture]
public class CheckTimeDistanceEqualityTest
{
private CheckTimeDistanceEquality check;
[SetUp]
public void Setup()
{
check = new CheckTimeDistanceEquality();
}
[Test]
public void TestCirclesEquidistant()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(100, 0) },
new HitCircle { StartTime = 1500, Position = new Vector2(150, 0) }
}
});
}
[Test]
public void TestCirclesOneSlightlyOff()
{
assertWarning(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(80, 0) }, // Distance a quite low compared to previous.
new HitCircle { StartTime = 1500, Position = new Vector2(130, 0) }
}
});
}
[Test]
public void TestCirclesOneOff()
{
assertProblem(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing.
new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
}
});
}
[Test]
public void TestCirclesTwoOff()
{
assertProblem(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing.
new HitCircle { StartTime = 1500, Position = new Vector2(250, 0) } // Also twice the regular spacing.
}
}, count: 2);
}
[Test]
public void TestCirclesStacked()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(50, 0) }, // Stacked, is fine.
new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
}
});
}
[Test]
public void TestCirclesStacking()
{
assertWarning(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(50, 0), StackHeight = 1 },
new HitCircle { StartTime = 1500, Position = new Vector2(50, 0), StackHeight = 2 },
new HitCircle { StartTime = 2000, Position = new Vector2(50, 0), StackHeight = 3 },
new HitCircle { StartTime = 2500, Position = new Vector2(50, 0), StackHeight = 4 }, // Ends up far from (50; 0), causing irregular spacing.
new HitCircle { StartTime = 3000, Position = new Vector2(100, 0) }
}
});
}
[Test]
public void TestCirclesHalfStack()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(55, 0) }, // Basically stacked, so is fine.
new HitCircle { StartTime = 1500, Position = new Vector2(105, 0) }
}
});
}
[Test]
public void TestCirclesPartialOverlap()
{
assertProblem(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(65, 0) }, // Really low distance compared to previous.
new HitCircle { StartTime = 1500, Position = new Vector2(115, 0) }
}
});
}
[Test]
public void TestCirclesSlightlyDifferent()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
// Does not need to be perfect, as long as the distance is approximately correct it's sight-readable.
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(52, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(97, 0) },
new HitCircle { StartTime = 1500, Position = new Vector2(165, 0) }
}
});
}
[Test]
public void TestCirclesSlowlyChanging()
{
const float multiplier = 1.2f;
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) },
// This gap would be a warning if it weren't for the previous pushing the average spacing up.
new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) }
}
});
}
[Test]
public void TestCirclesQuicklyChanging()
{
const float multiplier = 1.6f;
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) }, // Warning
new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) } // Problem
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(2));
Assert.That(issues.First().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning);
Assert.That(issues.Last().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem);
}
[Test]
public void TestCirclesTooFarApart()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 4000, Position = new Vector2(200, 0) }, // 2 seconds apart from previous, so can start from wherever.
new HitCircle { StartTime = 4500, Position = new Vector2(250, 0) }
}
});
}
[Test]
public void TestCirclesOneOffExpert()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Jumps are allowed in higher difficulties.
new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
}
}, DifficultyRating.Expert);
}
[Test]
public void TestSpinner()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new Spinner { StartTime = 500, EndTime = 1000 }, // Distance to and from the spinner should be ignored. If it isn't this should give a problem.
new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) },
new HitCircle { StartTime = 2000, Position = new Vector2(150, 0) }
}
});
}
[Test]
public void TestSliders()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object,
getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(200, 0), endPosition: new Vector2(250, 0)).Object,
new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) }
}
});
}
[Test]
public void TestSlidersOneOff()
{
assertProblem(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object,
getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(250, 0), endPosition: new Vector2(300, 0)).Object, // Twice the spacing.
new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) }
}
});
}
private Mock<Slider> getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition)
{
var mockSlider = new Mock<Slider>();
mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
mockSlider.SetupGet(s => s.Position).Returns(startPosition);
mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition);
mockSlider.As<IHasDuration>().Setup(d => d.EndTime).Returns(endTime);
return mockSlider;
}
private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
Assert.That(check.Run(context), Is.Empty);
}
private void assertWarning(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning));
}
private void assertProblem(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem));
}
}
}

View File

@ -0,0 +1,114 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Transforms;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Tests.Editor
{
[TestFixture]
public class TestSceneOsuEditorHitAnimations : TestSceneOsuEditor
{
[Resolved]
private OsuConfigManager config { get; set; }
[Test]
public void TestHitCircleAnimationDisable()
{
HitCircle hitCircle = null;
DrawableHitCircle drawableHitCircle = null;
AddStep("retrieve first hit circle", () => hitCircle = getHitCircle(0));
toggleAnimations(true);
seekSmoothlyTo(() => hitCircle.StartTime + 10);
AddStep("retrieve drawable", () => drawableHitCircle = (DrawableHitCircle)getDrawableObjectFor(hitCircle));
assertFutureTransforms(() => drawableHitCircle.CirclePiece, true);
AddStep("retrieve second hit circle", () => hitCircle = getHitCircle(1));
toggleAnimations(false);
seekSmoothlyTo(() => hitCircle.StartTime + 10);
AddStep("retrieve drawable", () => drawableHitCircle = (DrawableHitCircle)getDrawableObjectFor(hitCircle));
assertFutureTransforms(() => drawableHitCircle.CirclePiece, false);
AddAssert("hit circle has longer fade-out applied", () =>
{
var alphaTransform = drawableHitCircle.Transforms.Last(t => t.TargetMember == nameof(Alpha));
return alphaTransform.EndTime - alphaTransform.StartTime == DrawableOsuEditorRuleset.EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION;
});
}
[Test]
public void TestSliderAnimationDisable()
{
Slider slider = null;
DrawableSlider drawableSlider = null;
DrawableSliderRepeat sliderRepeat = null;
AddStep("retrieve first slider with repeats", () => slider = getSliderWithRepeats(0));
toggleAnimations(true);
seekSmoothlyTo(() => slider.StartTime + slider.SpanDuration + 10);
retrieveDrawables();
assertFutureTransforms(() => sliderRepeat, true);
AddStep("retrieve second slider with repeats", () => slider = getSliderWithRepeats(1));
toggleAnimations(false);
seekSmoothlyTo(() => slider.StartTime + slider.SpanDuration + 10);
retrieveDrawables();
assertFutureTransforms(() => sliderRepeat.Arrow, false);
seekSmoothlyTo(() => slider.GetEndTime());
AddAssert("slider has longer fade-out applied", () =>
{
var alphaTransform = drawableSlider.Transforms.Last(t => t.TargetMember == nameof(Alpha));
return alphaTransform.EndTime - alphaTransform.StartTime == DrawableOsuEditorRuleset.EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION;
});
void retrieveDrawables() =>
AddStep("retrieve drawables", () =>
{
drawableSlider = (DrawableSlider)getDrawableObjectFor(slider);
sliderRepeat = (DrawableSliderRepeat)getDrawableObjectFor(slider.NestedHitObjects.OfType<SliderRepeat>().First());
});
}
private HitCircle getHitCircle(int index)
=> EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(index);
private Slider getSliderWithRepeats(int index)
=> EditorBeatmap.HitObjects.OfType<Slider>().Where(s => s.RepeatCount >= 1).ElementAt(index);
private DrawableHitObject getDrawableObjectFor(HitObject hitObject)
=> this.ChildrenOfType<DrawableHitObject>().Single(ho => ho.HitObject == hitObject);
private IEnumerable<Transform> getTransformsRecursively(Drawable drawable)
=> drawable.ChildrenOfType<Drawable>().SelectMany(d => d.Transforms);
private void toggleAnimations(bool enabled)
=> AddStep($"toggle animations {(enabled ? "on" : "off")}", () => config.SetValue(OsuSetting.EditorHitAnimations, enabled));
private void seekSmoothlyTo(Func<double> targetTime)
{
AddStep("seek smoothly", () => EditorClock.SeekSmoothlyTo(targetTime.Invoke()));
AddUntilStep("wait for seek", () => Precision.AlmostEquals(targetTime.Invoke(), EditorClock.CurrentTime));
}
private void assertFutureTransforms(Func<Drawable> getDrawable, bool hasFutureTransforms)
=> AddAssert($"object {(hasFutureTransforms ? "has" : "has no")} future transforms",
() => getTransformsRecursively(getDrawable()).Any(t => t.EndTime >= EditorClock.CurrentTime) == hasFutureTransforms);
}
}

View File

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

View File

@ -0,0 +1,109 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit.Checks
{
public class CheckLowDiffOverlaps : ICheck
{
// For the lowest difficulties, the osu! Ranking Criteria encourages overlapping ~180 BPM 1/2, but discourages ~180 BPM 1/1.
private const double should_overlap_threshold = 150; // 200 BPM 1/2
private const double should_probably_overlap_threshold = 175; // 170 BPM 1/2
private const double should_not_overlap_threshold = 250; // 120 BPM 1/2 = 240 BPM 1/1
/// <summary>
/// Objects need to overlap this much before being treated as an overlap, else it may just be the borders slightly touching.
/// </summary>
private const double overlap_leniency = 5;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Missing or unexpected overlaps");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateShouldOverlap(this),
new IssueTemplateShouldProbablyOverlap(this),
new IssueTemplateShouldNotOverlap(this)
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
// TODO: This should also apply to *lowest difficulty* Normals - they are skipped for now.
if (context.InterpretedDifficulty > DifficultyRating.Easy)
yield break;
var hitObjects = context.Beatmap.HitObjects;
for (int i = 0; i < hitObjects.Count - 1; ++i)
{
if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner)
continue;
if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner)
continue;
var deltaTime = nextHitObject.StartTime - hitObject.GetEndTime();
if (deltaTime >= hitObject.TimeFadeIn + hitObject.TimePreempt)
// The objects are not visible at the same time (without mods), hence skipping.
continue;
var distanceSq = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthSquared;
var diameter = (hitObject.Radius - overlap_leniency) * 2;
var diameterSq = diameter * diameter;
bool areOverlapping = distanceSq < diameterSq;
// Slider ends do not need to be overlapped because of slider leniency.
if (!areOverlapping && !(hitObject is Slider))
{
if (deltaTime < should_overlap_threshold)
yield return new IssueTemplateShouldOverlap(this).Create(deltaTime, hitObject, nextHitObject);
else if (deltaTime < should_probably_overlap_threshold)
yield return new IssueTemplateShouldProbablyOverlap(this).Create(deltaTime, hitObject, nextHitObject);
}
if (areOverlapping && deltaTime > should_not_overlap_threshold)
yield return new IssueTemplateShouldNotOverlap(this).Create(deltaTime, hitObject, nextHitObject);
}
}
public abstract class IssueTemplateOverlap : IssueTemplate
{
protected IssueTemplateOverlap(ICheck check, IssueType issueType, string unformattedMessage)
: base(check, issueType, unformattedMessage)
{
}
public Issue Create(double deltaTime, params HitObject[] hitObjects) => new Issue(hitObjects, this, deltaTime);
}
public class IssueTemplateShouldOverlap : IssueTemplateOverlap
{
public IssueTemplateShouldOverlap(ICheck check)
: base(check, IssueType.Problem, "These are {0} ms apart and so should be overlapping.")
{
}
}
public class IssueTemplateShouldProbablyOverlap : IssueTemplateOverlap
{
public IssueTemplateShouldProbablyOverlap(ICheck check)
: base(check, IssueType.Warning, "These are {0} ms apart and so should probably be overlapping.")
{
}
}
public class IssueTemplateShouldNotOverlap : IssueTemplateOverlap
{
public IssueTemplateShouldNotOverlap(ICheck check)
: base(check, IssueType.Problem, "These are {0} ms apart and so should NOT be overlapping.")
{
}
}
}
}

View File

@ -0,0 +1,179 @@
// 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;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit.Checks
{
public class CheckTimeDistanceEquality : ICheck
{
/// <summary>
/// Two objects this many ms apart or more are skipped. (200 BPM 2/1)
/// </summary>
private const double pattern_lifetime = 600;
/// <summary>
/// Two objects this distance apart or less are skipped.
/// </summary>
private const double stack_leniency = 12;
/// <summary>
/// How long an observation is relevant for comparison. (120 BPM 8/1)
/// </summary>
private const double observation_lifetime = 4000;
/// <summary>
/// How different two delta times can be to still be compared. (240 BPM 1/16)
/// </summary>
private const double similar_time_leniency = 16;
/// <summary>
/// How many pixels are subtracted from the difference between current and expected distance.
/// </summary>
private const double distance_leniency_absolute_warning = 10;
/// <summary>
/// How much of the current distance that the difference can make out.
/// </summary>
private const double distance_leniency_percent_warning = 0.15;
private const double distance_leniency_absolute_problem = 20;
private const double distance_leniency_percent_problem = 0.3;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Object too close or far away from previous");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateIrregularSpacingProblem(this),
new IssueTemplateIrregularSpacingWarning(this)
};
/// <summary>
/// Represents an observation of the time and distance between two objects.
/// </summary>
private readonly struct ObservedTimeDistance
{
public readonly double ObservationTime;
public readonly double DeltaTime;
public readonly double Distance;
public ObservedTimeDistance(double observationTime, double deltaTime, double distance)
{
ObservationTime = observationTime;
DeltaTime = deltaTime;
Distance = distance;
}
}
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
if (context.InterpretedDifficulty > DifficultyRating.Normal)
yield break;
var prevObservedTimeDistances = new List<ObservedTimeDistance>();
var hitObjects = context.Beatmap.HitObjects;
for (int i = 0; i < hitObjects.Count - 1; ++i)
{
if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner)
continue;
if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner)
continue;
var deltaTime = nextHitObject.StartTime - hitObject.GetEndTime();
// Ignore objects that are far enough apart in time to not be considered the same pattern.
if (deltaTime > pattern_lifetime)
continue;
// Relying on FastInvSqrt is probably good enough here. We'll be taking the difference between distances later, hence square not being sufficient.
var distance = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthFast;
// Ignore stacks and half-stacks, as these are close enough to where they can't be confused for being time-distanced.
if (distance < stack_leniency)
continue;
var observedTimeDistance = new ObservedTimeDistance(nextHitObject.StartTime, deltaTime, distance);
var expectedDistance = getExpectedDistance(prevObservedTimeDistances, observedTimeDistance);
if (expectedDistance == 0)
{
// There was nothing relevant to compare to.
prevObservedTimeDistances.Add(observedTimeDistance);
continue;
}
if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_problem) / distance > distance_leniency_percent_problem)
yield return new IssueTemplateIrregularSpacingProblem(this).Create(expectedDistance, distance, hitObject, nextHitObject);
else if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_warning) / distance > distance_leniency_percent_warning)
yield return new IssueTemplateIrregularSpacingWarning(this).Create(expectedDistance, distance, hitObject, nextHitObject);
else
{
// We use `else` here to prevent issues from cascading; an object spaced too far could cause regular spacing to be considered "too short" otherwise.
prevObservedTimeDistances.Add(observedTimeDistance);
}
}
}
private double getExpectedDistance(IEnumerable<ObservedTimeDistance> prevObservedTimeDistances, ObservedTimeDistance observedTimeDistance)
{
var observations = prevObservedTimeDistances.Count();
int count = 0;
double sum = 0;
// Looping this in reverse allows us to break before going through all elements, as we're only interested in the most recent ones.
for (int i = observations - 1; i >= 0; --i)
{
var prevObservedTimeDistance = prevObservedTimeDistances.ElementAt(i);
// Only consider observations within the last few seconds - this allows the map to build spacing up/down over time, but prevents it from being too sudden.
if (observedTimeDistance.ObservationTime - prevObservedTimeDistance.ObservationTime > observation_lifetime)
break;
// Only consider observations which have a similar time difference - this leniency allows handling of multi-BPM maps which speed up/down slowly.
if (Math.Abs(observedTimeDistance.DeltaTime - prevObservedTimeDistance.DeltaTime) > similar_time_leniency)
break;
count += 1;
sum += prevObservedTimeDistance.Distance / Math.Max(prevObservedTimeDistance.DeltaTime, 1);
}
return sum / Math.Max(count, 1) * observedTimeDistance.DeltaTime;
}
public abstract class IssueTemplateIrregularSpacing : IssueTemplate
{
protected IssueTemplateIrregularSpacing(ICheck check, IssueType issueType)
: base(check, issueType, "Expected {0:0} px spacing like previous objects, currently {1:0}.")
{
}
public Issue Create(double expected, double actual, params HitObject[] hitObjects) => new Issue(hitObjects, this, expected, actual);
}
public class IssueTemplateIrregularSpacingProblem : IssueTemplateIrregularSpacing
{
public IssueTemplateIrregularSpacingProblem(ICheck check)
: base(check, IssueType.Problem)
{
}
}
public class IssueTemplateIrregularSpacingWarning : IssueTemplateIrregularSpacing
{
public IssueTemplateIrregularSpacingWarning(ICheck check)
: base(check, IssueType.Warning)
{
}
}
}
}

View File

@ -20,6 +20,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public class DrawableOsuEditorRuleset : DrawableOsuRuleset
{
/// <summary>
/// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay.
/// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points.
/// </summary>
public const double EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION = 700;
public DrawableOsuEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
: base(ruleset, beatmap, mods)
{
@ -46,12 +52,6 @@ namespace osu.Game.Rulesets.Osu.Edit
d.ApplyCustomUpdateState += updateState;
}
/// <summary>
/// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay.
/// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points.
/// </summary>
private const double editor_hit_object_fade_out_extension = 700;
private void updateState(DrawableHitObject hitObject, ArmedState state)
{
if (state == ArmedState.Idle || hitAnimations.Value)
@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Edit
if (hitObject is DrawableHitCircle circle)
{
circle.ApproachCircle
.FadeOutFromOne(editor_hit_object_fade_out_extension * 4)
.FadeOutFromOne(EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION * 4)
.Expire();
circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint);
@ -69,14 +69,20 @@ namespace osu.Game.Rulesets.Osu.Edit
if (hitObject is IHasMainCirclePiece mainPieceContainer)
{
// clear any explode animation logic.
mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.HitStateUpdateTime, true);
mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.HitStateUpdateTime, true);
// this is scheduled after children to ensure that the clear happens after invocations of ApplyCustomUpdateState on the circle piece's nested skinnables.
ScheduleAfterChildren(() =>
{
if (hitObject.HitObject == null) return;
mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.StateUpdateTime, true);
mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.StateUpdateTime, true);
});
}
if (hitObject is DrawableSliderRepeat repeat)
{
repeat.Arrow.ApplyTransformsAt(hitObject.HitStateUpdateTime, true);
repeat.Arrow.ClearTransformsAfter(hitObject.HitStateUpdateTime, true);
repeat.Arrow.ApplyTransformsAt(hitObject.StateUpdateTime, true);
repeat.Arrow.ClearTransformsAfter(hitObject.StateUpdateTime, true);
}
// adjust the visuals of top-level object types to make them stay on screen for longer than usual.
@ -93,7 +99,7 @@ namespace osu.Game.Rulesets.Osu.Edit
hitObject.RemoveTransform(existing);
using (hitObject.BeginAbsoluteSequence(hitObject.HitStateUpdateTime))
hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire();
hitObject.FadeOut(EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION).Expire();
break;
}
}

View File

@ -13,7 +13,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
private readonly List<ICheck> checks = new List<ICheck>
{
new CheckOffscreenObjects()
// Compose
new CheckOffscreenObjects(),
// Spread
new CheckTimeDistanceEquality(),
new CheckLowDiffOverlaps()
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)

View File

@ -0,0 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Rulesets.Osu.Mods
{
/// <summary>
/// Any mod which affects the animation or visibility of approach circles. Should be used for incompatibility purposes.
/// </summary>
public interface IMutateApproachCircles
{
}
}

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 osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
@ -11,7 +12,7 @@ using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModApproachDifferent : Mod, IApplicableToDrawableHitObject
public class OsuModApproachDifferent : Mod, IApplicableToDrawableHitObject, IMutateApproachCircles
{
public override string Name => "Approach Different";
public override string Acronym => "AD";
@ -19,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Mods
public override double ScoreMultiplier => 1;
public override IconUsage? Icon { get; } = FontAwesome.Regular.Circle;
public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
[SettingSource("Initial size", "Change the initial size of the approach circle, relative to hit circles.", 0)]
public BindableFloat Scale { get; } = new BindableFloat(4)
{

View File

@ -11,15 +11,16 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Skinning;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModHidden : ModHidden
public class OsuModHidden : ModHidden, IMutateApproachCircles
{
public override string Description => @"Play with no approach circles and fading circles/sliders.";
public override double ScoreMultiplier => 1.06;
public override Type[] IncompatibleMods => new[] { typeof(OsuModTraceable), typeof(OsuModSpinIn) };
public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
private const double fade_in_duration_multiplier = 0.4;
private const double fade_out_duration_multiplier = 0.3;
@ -110,6 +111,9 @@ namespace osu.Game.Rulesets.Osu.Mods
// hide elements we don't care about.
// todo: hide background
spinner.Body.OnSkinChanged += () => hideSpinnerApproachCircle(spinner);
hideSpinnerApproachCircle(spinner);
using (spinner.BeginAbsoluteSequence(fadeStartTime))
spinner.FadeOut(fadeDuration);
@ -160,5 +164,15 @@ namespace osu.Game.Rulesets.Osu.Mods
}
}
}
private static void hideSpinnerApproachCircle(DrawableSpinner spinner)
{
var approachCircle = (spinner.Body.Drawable as IHasApproachCircle)?.ApproachCircle;
if (approachCircle == null)
return;
using (spinner.BeginAbsoluteSequence(spinner.HitObject.StartTime - spinner.HitObject.TimePreempt))
approachCircle.Hide();
}
}
}

View File

@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Mods
/// <summary>
/// Adjusts the size of hit objects during their fade in animation.
/// </summary>
public abstract class OsuModObjectScaleTween : ModWithVisibilityAdjustment
public abstract class OsuModObjectScaleTween : ModWithVisibilityAdjustment, IMutateApproachCircles
{
public override ModType Type => ModType.Fun;
@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods
protected virtual float EndScale => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModSpinIn), typeof(OsuModTraceable) };
public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
{

View File

@ -12,7 +12,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModSpinIn : ModWithVisibilityAdjustment
public class OsuModSpinIn : ModWithVisibilityAdjustment, IMutateApproachCircles
{
public override string Name => "Spin In";
public override string Acronym => "SI";
@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override double ScoreMultiplier => 1;
// todo: this mod should be able to be compatible with hidden with a bit of further implementation.
public override Type[] IncompatibleMods => new[] { typeof(OsuModObjectScaleTween), typeof(OsuModHidden), typeof(OsuModTraceable) };
public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
private const int rotate_offset = 360;
private const float rotate_starting_width = 2;

View File

@ -11,7 +11,7 @@ using osu.Game.Rulesets.Osu.Skinning.Default;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModTraceable : ModWithVisibilityAdjustment
public class OsuModTraceable : ModWithVisibilityAdjustment, IMutateApproachCircles
{
public override string Name => "Traceable";
public override string Acronym => "TC";
@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override string Description => "Put your faith in the approach circles...";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModHidden), typeof(OsuModSpinIn), typeof(OsuModObjectScaleTween) };
public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
{

View File

@ -12,6 +12,7 @@ using osu.Framework.Input.Bindings;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Osu.Skinning;
using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Rulesets.Scoring;
using osu.Game.Skinning;
@ -19,7 +20,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece
public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece, IHasApproachCircle
{
public OsuAction? HitAction => HitArea.HitAction;
protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle;
@ -28,6 +29,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public HitReceptor HitArea { get; private set; }
public SkinnableDrawable CirclePiece { get; private set; }
Drawable IHasApproachCircle.ApproachCircle => ApproachCircle;
private Container scaleContainer;
private InputManager inputManager;

View File

@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public new OsuSpinnerJudgementResult Result => (OsuSpinnerJudgementResult)base.Result;
public SkinnableDrawable Body { get; private set; }
public SpinnerRotationTracker RotationTracker { get; private set; }
private SpinnerSpmCalculator spmCalculator;
@ -86,7 +88,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
RelativeSizeAxes = Axes.Y,
Children = new Drawable[]
{
new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()),
Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()),
RotationTracker = new SpinnerRotationTracker(this)
}
},

View File

@ -10,7 +10,6 @@ namespace osu.Game.Rulesets.Osu
Cursor,
CursorTrail,
SliderScorePoint,
ApproachCircle,
ReverseArrow,
HitCircleText,
SliderHeadHitCircle,

View File

@ -0,0 +1,18 @@
// 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;
namespace osu.Game.Rulesets.Osu.Skinning
{
/// <summary>
/// A common interface between implementations which provide an approach circle.
/// </summary>
public interface IHasApproachCircle
{
/// <summary>
/// The approach circle drawable.
/// </summary>
Drawable ApproachCircle { get; }
}
}

View File

@ -55,28 +55,40 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-bottom")
Texture = source.GetTexture("spinner-bottom"),
},
discTop = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-top")
Texture = source.GetTexture("spinner-top"),
},
fixedMiddle = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-middle")
Texture = source.GetTexture("spinner-middle"),
},
spinningMiddle = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-middle2")
}
Texture = source.GetTexture("spinner-middle2"),
},
}
});
if (!(source.FindProvider(s => s.GetTexture("spinner-top") != null) is DefaultLegacySkin))
{
AddInternal(ApproachCircle = new Sprite
{
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-approachcircle"),
Scale = new Vector2(SPRITE_SCALE * 1.86f),
Y = SPINNER_Y_CENTRE,
});
}
}
protected override void UpdateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state)

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
spinnerBlink = source.GetConfig<OsuSkinConfiguration, bool>(OsuSkinConfiguration.SpinnerNoBlink)?.Value != true;
AddRangeInternal(new Drawable[]
AddRangeInternal(new[]
{
new Sprite
{
@ -68,6 +68,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Origin = Anchor.TopLeft,
Scale = new Vector2(SPRITE_SCALE)
}
},
ApproachCircle = new Sprite
{
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-approachcircle"),
Scale = new Vector2(SPRITE_SCALE * 1.86f),
Y = SPINNER_Y_CENTRE,
}
});
}

View File

@ -15,8 +15,10 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
public abstract class LegacySpinner : CompositeDrawable
public abstract class LegacySpinner : CompositeDrawable, IHasApproachCircle
{
public const float SPRITE_SCALE = 0.625f;
/// <remarks>
/// All constants are in osu!stable's gamefield space, which is shifted 16px downwards.
/// This offset is negated in both osu!stable and osu!lazer to bring all constants into window-space.
@ -26,12 +28,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
protected const float SPINNER_Y_CENTRE = SPINNER_TOP_OFFSET + 219f;
protected const float SPRITE_SCALE = 0.625f;
private const float spm_hide_offset = 50f;
protected DrawableSpinner DrawableSpinner { get; private set; }
public Drawable ApproachCircle { get; protected set; }
private Sprite spin;
private Sprite clear;
@ -175,6 +177,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
spmCounter.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out);
}
using (BeginAbsoluteSequence(d.HitObject.StartTime))
ApproachCircle?.ScaleTo(SPRITE_SCALE * 0.1f, d.HitObject.Duration);
double spinFadeOutLength = Math.Min(400, d.HitObject.Duration);
using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - spinFadeOutLength, true))

View File

@ -28,6 +28,8 @@ namespace osu.Game.Tests.Chat
[TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123")]
[TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123/whatever")]
[TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/abc", "https://dev.ppy.sh/beatmapsets/abc")]
[TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/discussions", "https://dev.ppy.sh/beatmapsets/discussions")]
[TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/discussions/123", "https://dev.ppy.sh/beatmapsets/discussions/123")]
public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, string link)
{
MessageFormatter.WebsiteRootUrl = "dev.ppy.sh";

View File

@ -0,0 +1,101 @@
// 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 NUnit.Framework;
using osu.Framework.Input.Bindings;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Tests.Database
{
[TestFixture]
public class TestRealmKeyBindingStore
{
private NativeStorage storage;
private RealmKeyBindingStore keyBindingStore;
private RealmContextFactory realmContextFactory;
[SetUp]
public void SetUp()
{
var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
storage = new NativeStorage(directory.FullName);
realmContextFactory = new RealmContextFactory(storage);
keyBindingStore = new RealmKeyBindingStore(realmContextFactory);
}
[Test]
public void TestDefaultsPopulationAndQuery()
{
Assert.That(query().Count, Is.EqualTo(0));
KeyBindingContainer testContainer = new TestKeyBindingContainer();
keyBindingStore.Register(testContainer);
Assert.That(query().Count, Is.EqualTo(3));
Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Back).Count, Is.EqualTo(1));
Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Select).Count, Is.EqualTo(2));
}
private IQueryable<RealmKeyBinding> query() => realmContextFactory.Context.All<RealmKeyBinding>();
[Test]
public void TestUpdateViaQueriedReference()
{
KeyBindingContainer testContainer = new TestKeyBindingContainer();
keyBindingStore.Register(testContainer);
var backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back);
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape }));
var tsr = ThreadSafeReference.Create(backBinding);
using (var usage = realmContextFactory.GetForWrite())
{
var binding = usage.Realm.ResolveReference(tsr);
binding.KeyCombination = new KeyCombination(InputKey.BackSpace);
usage.Commit();
}
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
// check still correct after re-query.
backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back);
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
}
[TearDown]
public void TearDown()
{
realmContextFactory.Dispose();
storage.DeleteDirectory(string.Empty);
}
public class TestKeyBindingContainer : KeyBindingContainer
{
public override IEnumerable<IKeyBinding> DefaultKeyBindings =>
new[]
{
new KeyBinding(InputKey.Escape, GlobalAction.Back),
new KeyBinding(InputKey.Enter, GlobalAction.Select),
new KeyBinding(InputKey.Space, GlobalAction.Select),
};
}
}
}

View File

@ -44,11 +44,9 @@ namespace osu.Game.Tests.Gameplay
{
TestDrawableHitObject dho = null;
TestLifetimeEntry entry = null;
AddStep("Create DHO", () =>
AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject
{
dho = new TestDrawableHitObject(null);
dho.Apply(entry = new TestLifetimeEntry(new HitObject()));
Child = dho;
Entry = entry = new TestLifetimeEntry(new HitObject())
});
AddStep("KeepAlive = true", () =>
@ -81,12 +79,10 @@ namespace osu.Game.Tests.Gameplay
AddAssert("Lifetime is updated", () => entry.LifetimeStart == -TestLifetimeEntry.INITIAL_LIFETIME_OFFSET);
TestDrawableHitObject dho = null;
AddStep("Create DHO", () =>
AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject
{
dho = new TestDrawableHitObject(null);
dho.Apply(entry);
Child = dho;
dho.SetLifetimeStartOnApply = true;
Entry = entry,
SetLifetimeStartOnApply = true
});
AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()));
AddAssert("Lifetime is correct", () => dho.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY && entry.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY);
@ -97,11 +93,9 @@ namespace osu.Game.Tests.Gameplay
{
TestDrawableHitObject dho = null;
TestLifetimeEntry entry = null;
AddStep("Create DHO", () =>
AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject
{
dho = new TestDrawableHitObject(null);
dho.Apply(entry = new TestLifetimeEntry(new HitObject()));
Child = dho;
Entry = entry = new TestLifetimeEntry(new HitObject())
});
AddStep("Set entry lifetime", () =>
@ -135,7 +129,7 @@ namespace osu.Game.Tests.Gameplay
public bool SetLifetimeStartOnApply;
public TestDrawableHitObject(HitObject hitObject)
public TestDrawableHitObject(HitObject hitObject = null)
: base(hitObject)
{
}

View File

@ -21,6 +21,14 @@ namespace osu.Game.Tests.Mods
Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object }));
}
[Test]
public void TestModIsCompatibleByItselfWithIncompatibleInterface()
{
var mod = new Mock<CustomMod1>();
mod.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) });
Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object }));
}
[Test]
public void TestIncompatibleThroughTopLevel()
{
@ -34,6 +42,20 @@ namespace osu.Game.Tests.Mods
Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False);
}
[Test]
public void TestIncompatibleThroughInterface()
{
var mod1 = new Mock<CustomMod1>();
var mod2 = new Mock<CustomMod2>();
mod1.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) });
mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) });
// Test both orderings.
Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False);
Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False);
}
[Test]
public void TestMultiModIncompatibleWithTopLevel()
{
@ -149,11 +171,15 @@ namespace osu.Game.Tests.Mods
Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
}
public abstract class CustomMod1 : Mod
public abstract class CustomMod1 : Mod, IModCompatibilitySpecification
{
}
public abstract class CustomMod2 : Mod
public abstract class CustomMod2 : Mod, IModCompatibilitySpecification
{
}
public interface IModCompatibilitySpecification
{
}
}

View File

@ -142,7 +142,10 @@ namespace osu.Game.Tests.NonVisual
foreach (var file in osuStorage.IgnoreFiles)
{
Assert.That(File.Exists(Path.Combine(originalDirectory, file)));
// avoid touching realm files which may be a pipe and break everything.
// this is also done locally inside OsuStorage via the IgnoreFiles list.
if (file.EndsWith(".ini", StringComparison.Ordinal))
Assert.That(File.Exists(Path.Combine(originalDirectory, file)));
Assert.That(storage.Exists(file), Is.False);
}

View File

@ -74,7 +74,6 @@ namespace osu.Game.Tests.Visual
typeof(FileStore),
typeof(ScoreManager),
typeof(BeatmapManager),
typeof(KeyBindingStore),
typeof(SettingsStore),
typeof(RulesetConfigCache),
typeof(OsuColour),

View File

@ -0,0 +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 Newtonsoft.Json;
using Realms;
namespace osu.Game.Database
{
public interface IHasGuidPrimaryKey
{
[JsonIgnore]
[PrimaryKey]
Guid ID { get; set; }
}
}

View File

@ -0,0 +1,27 @@
// 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 Realms;
namespace osu.Game.Database
{
public interface IRealmFactory
{
/// <summary>
/// The main realm context, bound to the update thread.
/// </summary>
Realm Context { get; }
/// <summary>
/// Get a fresh context for read usage.
/// </summary>
RealmContextFactory.RealmUsage GetForRead();
/// <summary>
/// Request a context for write usage.
/// This method may block if a write is already active on a different thread.
/// </summary>
/// <returns>A usage containing a usable context.</returns>
RealmContextFactory.RealmWriteUsage GetForWrite();
}
}

View File

@ -24,13 +24,15 @@ namespace osu.Game.Database
public DbSet<BeatmapDifficulty> BeatmapDifficulty { get; set; }
public DbSet<BeatmapMetadata> BeatmapMetadata { get; set; }
public DbSet<BeatmapSetInfo> BeatmapSetInfo { get; set; }
public DbSet<DatabasedKeyBinding> DatabasedKeyBinding { get; set; }
public DbSet<DatabasedSetting> DatabasedSetting { get; set; }
public DbSet<FileInfo> FileInfo { get; set; }
public DbSet<RulesetInfo> RulesetInfo { get; set; }
public DbSet<SkinInfo> SkinInfo { get; set; }
public DbSet<ScoreInfo> ScoreInfo { get; set; }
// migrated to realm
public DbSet<DatabasedKeyBinding> DatabasedKeyBinding { get; set; }
private readonly string connectionString;
private static readonly Lazy<OsuDbLoggerFactory> logger = new Lazy<OsuDbLoggerFactory>(() => new OsuDbLoggerFactory());

View File

@ -0,0 +1,208 @@
// 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.Threading;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Database
{
public class RealmContextFactory : Component, IRealmFactory
{
private readonly Storage storage;
private const string database_name = @"client";
private const int schema_version = 6;
/// <summary>
/// Lock object which is held for the duration of a write operation (via <see cref="GetForWrite"/>).
/// </summary>
private readonly object writeLock = new object();
private static readonly GlobalStatistic<int> reads = GlobalStatistics.Get<int>("Realm", "Get (Read)");
private static readonly GlobalStatistic<int> writes = GlobalStatistics.Get<int>("Realm", "Get (Write)");
private static readonly GlobalStatistic<int> refreshes = GlobalStatistics.Get<int>("Realm", "Dirty Refreshes");
private static readonly GlobalStatistic<int> contexts_created = GlobalStatistics.Get<int>("Realm", "Contexts (Created)");
private static readonly GlobalStatistic<int> pending_writes = GlobalStatistics.Get<int>("Realm", "Pending writes");
private static readonly GlobalStatistic<int> active_usages = GlobalStatistics.Get<int>("Realm", "Active usages");
private readonly ManualResetEventSlim blockingResetEvent = new ManualResetEventSlim(true);
private Realm context;
public Realm Context
{
get
{
if (IsDisposed)
throw new InvalidOperationException($"Attempted to access {nameof(Context)} on a disposed context factory");
if (context == null)
{
context = createContext();
Logger.Log($"Opened realm \"{context.Config.DatabasePath}\" at version {context.Config.SchemaVersion}");
}
// creating a context will ensure our schema is up-to-date and migrated.
return context;
}
}
public RealmContextFactory(Storage storage)
{
this.storage = storage;
}
public RealmUsage GetForRead()
{
reads.Value++;
return new RealmUsage(this);
}
public RealmWriteUsage GetForWrite()
{
writes.Value++;
pending_writes.Value++;
Monitor.Enter(writeLock);
return new RealmWriteUsage(this);
}
protected override void Update()
{
base.Update();
if (context?.Refresh() == true)
refreshes.Value++;
}
private Realm createContext()
{
blockingResetEvent.Wait();
contexts_created.Value++;
return Realm.GetInstance(new RealmConfiguration(storage.GetFullPath($"{database_name}.realm", true))
{
SchemaVersion = schema_version,
MigrationCallback = onMigration,
});
}
private void onMigration(Migration migration, ulong lastSchemaVersion)
{
switch (lastSchemaVersion)
{
case 5:
// let's keep things simple. changing the type of the primary key is a bit involved.
migration.NewRealm.RemoveAll<RealmKeyBinding>();
break;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
BlockAllOperations();
}
public IDisposable BlockAllOperations()
{
blockingResetEvent.Reset();
flushContexts();
return new InvokeOnDisposal<RealmContextFactory>(this, r => endBlockingSection());
}
private void endBlockingSection()
{
blockingResetEvent.Set();
}
private void flushContexts()
{
var previousContext = context;
context = null;
// wait for all threaded usages to finish
while (active_usages.Value > 0)
Thread.Sleep(50);
previousContext?.Dispose();
}
/// <summary>
/// A usage of realm from an arbitrary thread.
/// </summary>
public class RealmUsage : IDisposable
{
public readonly Realm Realm;
protected readonly RealmContextFactory Factory;
internal RealmUsage(RealmContextFactory factory)
{
active_usages.Value++;
Factory = factory;
Realm = factory.createContext();
}
/// <summary>
/// Disposes this instance, calling the initially captured action.
/// </summary>
public virtual void Dispose()
{
Realm?.Dispose();
active_usages.Value--;
}
}
/// <summary>
/// A transaction used for making changes to realm data.
/// </summary>
public class RealmWriteUsage : RealmUsage
{
private readonly Transaction transaction;
internal RealmWriteUsage(RealmContextFactory factory)
: base(factory)
{
transaction = Realm.BeginWrite();
}
/// <summary>
/// Commit all changes made in this transaction.
/// </summary>
public void Commit() => transaction.Commit();
/// <summary>
/// Revert all changes made in this transaction.
/// </summary>
public void Rollback() => transaction.Rollback();
/// <summary>
/// Disposes this instance, calling the initially captured action.
/// </summary>
public override void Dispose()
{
// rollback if not explicitly committed.
transaction?.Dispose();
base.Dispose();
Monitor.Exit(Factory.writeLock);
pending_writes.Value--;
}
}
}
}

View File

@ -0,0 +1,51 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using AutoMapper;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Database
{
public static class RealmExtensions
{
private static readonly IMapper mapper = new MapperConfiguration(c =>
{
c.ShouldMapField = fi => false;
c.ShouldMapProperty = pi => pi.SetMethod != null && pi.SetMethod.IsPublic;
c.CreateMap<RealmKeyBinding, RealmKeyBinding>();
}).CreateMapper();
/// <summary>
/// Create a detached copy of the each item in the collection.
/// </summary>
/// <param name="items">A list of managed <see cref="RealmObject"/>s to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A list containing non-managed copies of provided items.</returns>
public static List<T> Detach<T>(this IEnumerable<T> items) where T : RealmObject
{
var list = new List<T>();
foreach (var obj in items)
list.Add(obj.Detach());
return list;
}
/// <summary>
/// Create a detached copy of the item.
/// </summary>
/// <param name="item">The managed <see cref="RealmObject"/> to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A non-managed copy of provided item. Will return the provided item if already detached.</returns>
public static T Detach<T>(this T item) where T : RealmObject
{
if (!item.IsManaged)
return item;
return mapper.Map<T>(item);
}
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osuTK.Graphics;
using System.Collections.Generic;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Graphics.Containers
{
@ -20,7 +21,8 @@ namespace osu.Game.Graphics.Containers
protected virtual IEnumerable<Drawable> EffectTargets => new[] { Content };
public OsuHoverContainer()
public OsuHoverContainer(HoverSampleSet sampleSet = HoverSampleSet.Default)
: base(sampleSet)
{
Enabled.ValueChanged += e =>
{

View File

@ -13,13 +13,13 @@ namespace osu.Game.Graphics.UserInterface
[Description("button")]
Button,
[Description("softer")]
Soft,
[Description("toolbar")]
Toolbar,
[Description("songselect")]
SongSelect
[Description("tabselect")]
TabSelect,
[Description("scrolltotop")]
ScrollToTop
}
}

View File

@ -4,6 +4,8 @@
using System.Linq;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -57,6 +59,9 @@ namespace osu.Game.Graphics.UserInterface
{
public override bool HandleNonPositionalInput => State == MenuState.Open;
private Sample sampleOpen;
private Sample sampleClose;
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
public OsuDropdownMenu()
{
@ -69,9 +74,30 @@ namespace osu.Game.Graphics.UserInterface
ItemsContainer.Padding = new MarginPadding(5);
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sampleOpen = audio.Samples.Get(@"UI/dropdown-open");
sampleClose = audio.Samples.Get(@"UI/dropdown-close");
}
// todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed.
private bool wasOpened;
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
protected override void AnimateOpen() => this.FadeIn(300, Easing.OutQuint);
protected override void AnimateClose() => this.FadeOut(300, Easing.OutQuint);
protected override void AnimateOpen()
{
wasOpened = true;
this.FadeIn(300, Easing.OutQuint);
sampleOpen?.Play();
}
protected override void AnimateClose()
{
this.FadeOut(300, Easing.OutQuint);
if (wasOpened)
sampleClose?.Play();
}
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
protected override void UpdateSize(Vector2 newSize)
@ -155,7 +181,7 @@ namespace osu.Game.Graphics.UserInterface
nonAccentSelectedColour = Color4.Black.Opacity(0.5f);
updateColours();
AddInternal(new HoverClickSounds(HoverSampleSet.Soft));
AddInternal(new HoverSounds());
}
protected override void UpdateForegroundColour()
@ -262,7 +288,7 @@ namespace osu.Game.Graphics.UserInterface
},
};
AddInternal(new HoverClickSounds());
AddInternal(new HoverSounds());
}
[BackgroundDependencyLoader]

View File

@ -172,7 +172,7 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
new HoverClickSounds()
new HoverClickSounds(HoverSampleSet.TabSelect)
};
}

View File

@ -4,6 +4,8 @@
using osuTK;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -43,6 +45,8 @@ namespace osu.Game.Graphics.UserInterface
}
private const float transition_length = 500;
private Sample sampleChecked;
private Sample sampleUnchecked;
public OsuTabControlCheckbox()
{
@ -77,8 +81,7 @@ namespace osu.Game.Graphics.UserInterface
Colour = Color4.White,
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
new HoverClickSounds()
}
};
Current.ValueChanged += selected =>
@ -91,10 +94,13 @@ namespace osu.Game.Graphics.UserInterface
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
private void load(OsuColour colours, AudioManager audio)
{
if (accentColour == null)
AccentColour = colours.Blue;
sampleChecked = audio.Samples.Get(@"UI/check-on");
sampleUnchecked = audio.Samples.Get(@"UI/check-off");
}
protected override bool OnHover(HoverEvent e)
@ -111,6 +117,16 @@ namespace osu.Game.Graphics.UserInterface
base.OnHoverLost(e);
}
protected override void OnUserChange(bool value)
{
base.OnUserChange(value);
if (value)
sampleChecked?.Play();
else
sampleUnchecked?.Play();
}
private void updateFade()
{
box.FadeTo(Current.Value || IsHovered ? 1 : 0, transition_length, Easing.OutQuint);

View File

@ -76,7 +76,7 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
new HoverClickSounds()
new HoverClickSounds(HoverSampleSet.TabSelect)
};
Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Torus, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true);

View File

@ -33,12 +33,18 @@ namespace osu.Game.IO
private readonly StorageConfigManager storageConfig;
private readonly Storage defaultStorage;
public override string[] IgnoreDirectories => new[] { "cache" };
public override string[] IgnoreDirectories => new[]
{
"cache",
"client.realm.management"
};
public override string[] IgnoreFiles => new[]
{
"framework.ini",
"storage.ini"
"storage.ini",
"client.realm.note",
"client.realm.lock",
};
public OsuStorage(GameHost host, Storage defaultStorage)

View File

@ -8,7 +8,7 @@ using osu.Game.Database;
namespace osu.Game.Input.Bindings
{
[Table("KeyBinding")]
public class DatabasedKeyBinding : KeyBinding, IHasPrimaryKey
public class DatabasedKeyBinding : IKeyBinding, IHasPrimaryKey
{
public int ID { get; set; }
@ -17,17 +17,23 @@ namespace osu.Game.Input.Bindings
public int? Variant { get; set; }
[Column("Keys")]
public string KeysString
{
get => KeyCombination.ToString();
private set => KeyCombination = value;
}
public string KeysString { get; set; }
[Column("Action")]
public int IntAction
public int IntAction { get; set; }
[NotMapped]
public KeyCombination KeyCombination
{
get => (int)Action;
set => Action = value;
get => KeysString;
set => KeysString = value.ToString();
}
[NotMapped]
public object Action
{
get => IntAction;
set => IntAction = (int)value;
}
}
}

View File

@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Input.Bindings;
using osu.Game.Database;
using osu.Game.Rulesets;
using System.Linq;
using Realms;
namespace osu.Game.Input.Bindings
{
@ -21,7 +23,11 @@ namespace osu.Game.Input.Bindings
private readonly int? variant;
private KeyBindingStore store;
private IDisposable realmSubscription;
private IQueryable<RealmKeyBinding> realmKeyBindings;
[Resolved]
private RealmContextFactory realmFactory { get; set; }
public override IEnumerable<IKeyBinding> DefaultKeyBindings => ruleset.CreateInstance().GetDefaultKeyBindings(variant ?? 0);
@ -42,24 +48,34 @@ namespace osu.Game.Input.Bindings
throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided.");
}
[BackgroundDependencyLoader]
private void load(KeyBindingStore keyBindings)
{
store = keyBindings;
}
protected override void LoadComplete()
{
if (ruleset == null || ruleset.ID.HasValue)
{
var rulesetId = ruleset?.ID;
realmKeyBindings = realmFactory.Context.All<RealmKeyBinding>()
.Where(b => b.RulesetID == rulesetId && b.Variant == variant);
realmSubscription = realmKeyBindings
.SubscribeForNotifications((sender, changes, error) =>
{
// first subscription ignored as we are handling this in LoadComplete.
if (changes == null)
return;
ReloadMappings();
});
}
base.LoadComplete();
store.KeyBindingChanged += ReloadMappings;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (store != null)
store.KeyBindingChanged -= ReloadMappings;
realmSubscription?.Dispose();
}
protected override void ReloadMappings()
@ -67,17 +83,17 @@ namespace osu.Game.Input.Bindings
var defaults = DefaultKeyBindings.ToList();
if (ruleset != null && !ruleset.ID.HasValue)
// if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings.
// fallback to defaults instead.
// some tests instantiate a ruleset which is not present in the database.
// in these cases we still want key bindings to work, but matching to database instances would result in none being present,
// so let's populate the defaults directly.
KeyBindings = defaults;
else
{
KeyBindings = store.Query(ruleset?.ID, variant)
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction))
// this ordering is important to ensure that we read entries from the database in the order
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order.
.ToList();
KeyBindings = realmKeyBindings.Detach()
// this ordering is important to ensure that we read entries from the database in the order
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order.
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList();
}
}
}

View File

@ -0,0 +1,39 @@
// 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.Input.Bindings;
using osu.Game.Database;
using Realms;
namespace osu.Game.Input.Bindings
{
[MapTo(nameof(KeyBinding))]
public class RealmKeyBinding : RealmObject, IHasGuidPrimaryKey, IKeyBinding
{
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
public int? RulesetID { get; set; }
public int? Variant { get; set; }
public KeyCombination KeyCombination
{
get => KeyCombinationString;
set => KeyCombinationString = value.ToString();
}
public object Action
{
get => ActionInt;
set => ActionInt = (int)value;
}
[MapTo(nameof(Action))]
public int ActionInt { get; set; }
[MapTo(nameof(KeyCombination))]
public string KeyCombinationString { get; set; }
}
}

View File

@ -1,133 +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 System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Input.Bindings;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
namespace osu.Game.Input
{
public class KeyBindingStore : DatabaseBackedStore
{
public event Action KeyBindingChanged;
/// <summary>
/// Keys which should not be allowed for gameplay input purposes.
/// </summary>
private static readonly IEnumerable<InputKey> banned_keys = new[]
{
InputKey.MouseWheelDown,
InputKey.MouseWheelLeft,
InputKey.MouseWheelUp,
InputKey.MouseWheelRight
};
public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null)
: base(contextFactory, storage)
{
using (ContextFactory.GetForWrite())
{
foreach (var info in rulesets.AvailableRulesets)
{
var ruleset = info.CreateInstance();
foreach (var variant in ruleset.AvailableVariants)
insertDefaults(ruleset.GetDefaultKeyBindings(variant), info.ID, variant);
}
}
}
public void Register(KeyBindingContainer manager) => insertDefaults(manager.DefaultKeyBindings);
/// <summary>
/// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action.
/// </summary>
/// <param name="globalAction">The action to lookup.</param>
/// <returns>A set of display strings for all the user's key configuration for the action.</returns>
public IEnumerable<string> GetReadableKeyCombinationsFor(GlobalAction globalAction)
{
foreach (var action in Query().Where(b => (GlobalAction)b.Action == globalAction))
{
string str = action.KeyCombination.ReadableString();
// even if found, the readable string may be empty for an unbound action.
if (str.Length > 0)
yield return str;
}
}
private void insertDefaults(IEnumerable<IKeyBinding> defaults, int? rulesetId = null, int? variant = null)
{
using (var usage = ContextFactory.GetForWrite())
{
// compare counts in database vs defaults
foreach (var group in defaults.GroupBy(k => k.Action))
{
int count = Query(rulesetId, variant).Count(k => (int)k.Action == (int)group.Key);
int aimCount = group.Count();
if (aimCount <= count)
continue;
foreach (var insertable in group.Skip(count).Take(aimCount - count))
{
// insert any defaults which are missing.
usage.Context.DatabasedKeyBinding.Add(new DatabasedKeyBinding
{
KeyCombination = insertable.KeyCombination,
Action = insertable.Action,
RulesetID = rulesetId,
Variant = variant
});
// required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686)
usage.Context.SaveChanges();
}
}
}
}
/// <summary>
/// Retrieve <see cref="DatabasedKeyBinding"/>s for a specified ruleset/variant content.
/// </summary>
/// <param name="rulesetId">The ruleset's internal ID.</param>
/// <param name="variant">An optional variant.</param>
public List<DatabasedKeyBinding> Query(int? rulesetId = null, int? variant = null) =>
ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList();
public void Update(KeyBinding keyBinding)
{
using (ContextFactory.GetForWrite())
{
var dbKeyBinding = (DatabasedKeyBinding)keyBinding;
Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination));
Refresh(ref dbKeyBinding);
if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination))
return;
dbKeyBinding.KeyCombination = keyBinding.KeyCombination;
}
KeyBindingChanged?.Invoke();
}
public static bool CheckValidForGameplay(KeyCombination combination)
{
foreach (var key in banned_keys)
{
if (combination.Keys.Contains(key))
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,117 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Input.Bindings;
using osu.Game.Database;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
#nullable enable
namespace osu.Game.Input
{
public class RealmKeyBindingStore
{
private readonly RealmContextFactory realmFactory;
public RealmKeyBindingStore(RealmContextFactory realmFactory)
{
this.realmFactory = realmFactory;
}
/// <summary>
/// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action.
/// </summary>
/// <param name="globalAction">The action to lookup.</param>
/// <returns>A set of display strings for all the user's key configuration for the action.</returns>
public IReadOnlyList<string> GetReadableKeyCombinationsFor(GlobalAction globalAction)
{
List<string> combinations = new List<string>();
using (var context = realmFactory.GetForRead())
{
foreach (var action in context.Realm.All<RealmKeyBinding>().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction))
{
string str = action.KeyCombination.ReadableString();
// even if found, the readable string may be empty for an unbound action.
if (str.Length > 0)
combinations.Add(str);
}
}
return combinations;
}
/// <summary>
/// Register a new type of <see cref="KeyBindingContainer{T}"/>, adding default bindings from <see cref="KeyBindingContainer.DefaultKeyBindings"/>.
/// </summary>
/// <param name="container">The container to populate defaults from.</param>
public void Register(KeyBindingContainer container) => insertDefaults(container.DefaultKeyBindings);
/// <summary>
/// Register a ruleset, adding default bindings for each of its variants.
/// </summary>
/// <param name="ruleset">The ruleset to populate defaults from.</param>
public void Register(RulesetInfo ruleset)
{
var instance = ruleset.CreateInstance();
foreach (var variant in instance.AvailableVariants)
insertDefaults(instance.GetDefaultKeyBindings(variant), ruleset.ID, variant);
}
private void insertDefaults(IEnumerable<IKeyBinding> defaults, int? rulesetId = null, int? variant = null)
{
using (var usage = realmFactory.GetForWrite())
{
// compare counts in database vs defaults
foreach (var defaultsForAction in defaults.GroupBy(k => k.Action))
{
int existingCount = usage.Realm.All<RealmKeyBinding>().Count(k => k.RulesetID == rulesetId && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key);
if (defaultsForAction.Count() <= existingCount)
continue;
foreach (var k in defaultsForAction.Skip(existingCount))
{
// insert any defaults which are missing.
usage.Realm.Add(new RealmKeyBinding
{
KeyCombinationString = k.KeyCombination.ToString(),
ActionInt = (int)k.Action,
RulesetID = rulesetId,
Variant = variant
});
}
}
usage.Commit();
}
}
/// <summary>
/// Keys which should not be allowed for gameplay input purposes.
/// </summary>
private static readonly IEnumerable<InputKey> banned_keys = new[]
{
InputKey.MouseWheelDown,
InputKey.MouseWheelLeft,
InputKey.MouseWheelUp,
InputKey.MouseWheelRight
};
public static bool CheckValidForGameplay(KeyCombination combination)
{
foreach (var key in banned_keys)
{
if (combination.Keys.Contains(key))
return false;
}
return true;
}
}
}

View File

@ -14,9 +14,8 @@ namespace osu.Game.Localisation
// [Description(@"اَلْعَرَبِيَّةُ")]
// ar,
// TODO: Some accented glyphs are missing. Revisit when adding Inter.
// [Description(@"Беларуская мова")]
// be,
[Description(@"Беларуская мова")]
be,
[Description(@"Български")]
bg,
@ -30,9 +29,8 @@ namespace osu.Game.Localisation
[Description(@"Deutsch")]
de,
// TODO: Some accented glyphs are missing. Revisit when adding Inter.
// [Description(@"Ελληνικά")]
// el,
[Description(@"Ελληνικά")]
el,
[Description(@"español")]
es,
@ -88,15 +86,16 @@ namespace osu.Game.Localisation
[Description(@"ไทย")]
th,
[Description(@"Tagalog")]
tl,
// Tagalog has no associated localisations yet, and is not supported on Xamarin platforms or Windows versions <10.
// Can be revisited if localisations ever arrive.
//[Description(@"Tagalog")]
//tl,
[Description(@"Türkçe")]
tr,
// TODO: Some accented glyphs are missing. Revisit when adding Inter.
// [Description(@"Українська мова")]
// uk,
[Description(@"Українська мова")]
uk,
[Description(@"Tiếng Việt")]
vi,

View File

@ -154,6 +154,10 @@ namespace osu.Game.Online.Chat
case "beatmapsets":
case "d":
{
if (mainArg == "discussions")
// handle discussion links externally for now
return new LinkDetails(LinkAction.External, url);
if (args.Length > 4 && int.TryParse(args[4], out var id))
// https://osu.ppy.sh/beatmapsets/1154158#osu/2768184
return new LinkDetails(LinkAction.OpenBeatmap, id.ToString());

View File

@ -585,7 +585,15 @@ namespace osu.Game
foreach (var language in Enum.GetValues(typeof(Language)).OfType<Language>())
{
var cultureCode = language.ToCultureCode();
Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode));
try
{
Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode));
}
catch (Exception ex)
{
Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\"");
}
}
// The next time this is updated is in UpdateAfterChildren, which occurs too late and results
@ -608,9 +616,9 @@ namespace osu.Game
LocalConfig.LookupKeyBindings = l =>
{
var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l).ToArray();
var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l);
if (combinations.Length == 0)
if (combinations.Count == 0)
return "none";
return string.Join(" or ", combinations);

View File

@ -95,7 +95,7 @@ namespace osu.Game
protected RulesetStore RulesetStore { get; private set; }
protected KeyBindingStore KeyBindingStore { get; private set; }
protected RealmKeyBindingStore KeyBindingStore { get; private set; }
protected MenuCursorContainer MenuCursorContainer { get; private set; }
@ -144,6 +144,8 @@ namespace osu.Game
private DatabaseContextFactory contextFactory;
private RealmContextFactory realmFactory;
protected override Container<Drawable> Content => content;
private Container content;
@ -179,6 +181,9 @@ namespace osu.Game
dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage));
dependencies.Cache(realmFactory = new RealmContextFactory(Storage));
AddInternal(realmFactory);
dependencies.CacheAs(Storage);
var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures")));
@ -190,20 +195,29 @@ namespace osu.Game
AddFont(Resources, @"Fonts/osuFont");
AddFont(Resources, @"Fonts/Torus-Regular");
AddFont(Resources, @"Fonts/Torus-Light");
AddFont(Resources, @"Fonts/Torus-SemiBold");
AddFont(Resources, @"Fonts/Torus-Bold");
AddFont(Resources, @"Fonts/Torus/Torus-Regular");
AddFont(Resources, @"Fonts/Torus/Torus-Light");
AddFont(Resources, @"Fonts/Torus/Torus-SemiBold");
AddFont(Resources, @"Fonts/Torus/Torus-Bold");
AddFont(Resources, @"Fonts/Noto-Basic");
AddFont(Resources, @"Fonts/Noto-Hangul");
AddFont(Resources, @"Fonts/Noto-CJK-Basic");
AddFont(Resources, @"Fonts/Noto-CJK-Compatibility");
AddFont(Resources, @"Fonts/Noto-Thai");
AddFont(Resources, @"Fonts/Inter/Inter-Regular");
AddFont(Resources, @"Fonts/Inter/Inter-RegularItalic");
AddFont(Resources, @"Fonts/Inter/Inter-Light");
AddFont(Resources, @"Fonts/Inter/Inter-LightItalic");
AddFont(Resources, @"Fonts/Inter/Inter-SemiBold");
AddFont(Resources, @"Fonts/Inter/Inter-SemiBoldItalic");
AddFont(Resources, @"Fonts/Inter/Inter-Bold");
AddFont(Resources, @"Fonts/Inter/Inter-BoldItalic");
AddFont(Resources, @"Fonts/Venera-Light");
AddFont(Resources, @"Fonts/Venera-Bold");
AddFont(Resources, @"Fonts/Venera-Black");
AddFont(Resources, @"Fonts/Noto/Noto-Basic");
AddFont(Resources, @"Fonts/Noto/Noto-Hangul");
AddFont(Resources, @"Fonts/Noto/Noto-CJK-Basic");
AddFont(Resources, @"Fonts/Noto/Noto-CJK-Compatibility");
AddFont(Resources, @"Fonts/Noto/Noto-Thai");
AddFont(Resources, @"Fonts/Venera/Venera-Light");
AddFont(Resources, @"Fonts/Venera/Venera-Bold");
AddFont(Resources, @"Fonts/Venera/Venera-Black");
Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY;
@ -275,7 +289,8 @@ namespace osu.Game
dependencies.Cache(scorePerformanceManager);
AddInternal(scorePerformanceManager);
dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore));
migrateDataToRealm();
dependencies.Cache(settingsStore = new SettingsStore(contextFactory));
dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(settingsStore));
@ -323,7 +338,12 @@ namespace osu.Game
base.Content.Add(CreateScalingContainer().WithChildren(mainContent));
KeyBindingStore = new RealmKeyBindingStore(realmFactory);
KeyBindingStore.Register(globalBindings);
foreach (var r in RulesetStore.AvailableRulesets)
KeyBindingStore.Register(r);
dependencies.Cache(globalBindings);
PreviewTrackManager previewTrackManager;
@ -378,8 +398,11 @@ namespace osu.Game
public void Migrate(string path)
{
contextFactory.FlushConnections();
(Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
using (realmFactory.BlockAllOperations())
{
contextFactory.FlushConnections();
(Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
}
}
protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager();
@ -390,6 +413,34 @@ namespace osu.Game
protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage);
private void migrateDataToRealm()
{
using (var db = contextFactory.GetForWrite())
using (var usage = realmFactory.GetForWrite())
{
var existingBindings = db.Context.DatabasedKeyBinding;
// only migrate data if the realm database is empty.
if (!usage.Realm.All<RealmKeyBinding>().Any())
{
foreach (var dkb in existingBindings)
{
usage.Realm.Add(new RealmKeyBinding
{
KeyCombinationString = dkb.KeyCombination.ToString(),
ActionInt = (int)dkb.Action,
RulesetID = dkb.RulesetID,
Variant = dkb.Variant
});
}
}
db.Context.RemoveRange(existingBindings);
usage.Commit();
}
}
private void onRulesetChanged(ValueChangedEvent<RulesetInfo> r)
{
var dict = new Dictionary<ModType, IReadOnlyList<Mod>>();

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Framework.Bindables;
@ -66,6 +68,8 @@ namespace osu.Game.Overlays.Comments
public readonly BindableBool Checked = new BindableBool();
private readonly SpriteIcon checkboxIcon;
private Sample sampleChecked;
private Sample sampleUnchecked;
public ShowDeletedButton()
{
@ -93,6 +97,13 @@ namespace osu.Game.Overlays.Comments
});
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sampleChecked = audio.Samples.Get(@"UI/check-on");
sampleUnchecked = audio.Samples.Get(@"UI/check-off");
}
protected override void LoadComplete()
{
Checked.BindValueChanged(isChecked => checkboxIcon.Icon = isChecked.NewValue ? FontAwesome.Solid.CheckSquare : FontAwesome.Regular.Square, true);
@ -102,6 +113,12 @@ namespace osu.Game.Overlays.Comments
protected override bool OnClick(ClickEvent e)
{
Checked.Value = !Checked.Value;
if (Checked.Value)
sampleChecked?.Play();
else
sampleUnchecked?.Play();
return true;
}
}

View File

@ -6,7 +6,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Comments
{
@ -39,7 +38,6 @@ namespace osu.Game.Overlays.Comments
Origin = Anchor.Centre,
Margin = new MarginPadding { Horizontal = 10 }
},
new HoverClickSounds(),
});
}

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.Framework.Allocation;
@ -13,6 +14,7 @@ using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@ -27,7 +29,7 @@ namespace osu.Game.Overlays.KeyBinding
public class KeyBindingRow : Container, IFilterable
{
private readonly object action;
private readonly IEnumerable<Framework.Input.Bindings.KeyBinding> bindings;
private readonly IEnumerable<RealmKeyBinding> bindings;
private const float transition_time = 150;
@ -62,7 +64,7 @@ namespace osu.Game.Overlays.KeyBinding
public IEnumerable<string> FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend(text.Text.ToString());
public KeyBindingRow(object action, IEnumerable<Framework.Input.Bindings.KeyBinding> bindings)
public KeyBindingRow(object action, List<RealmKeyBinding> bindings)
{
this.action = action;
this.bindings = bindings;
@ -72,7 +74,7 @@ namespace osu.Game.Overlays.KeyBinding
}
[Resolved]
private KeyBindingStore store { get; set; }
private RealmContextFactory realmFactory { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
@ -153,7 +155,8 @@ namespace osu.Game.Overlays.KeyBinding
{
var button = buttons[i++];
button.UpdateKeyCombination(d);
store.Update(button.KeyBinding);
updateStoreFromButton(button);
}
isDefault.Value = true;
@ -314,7 +317,7 @@ namespace osu.Game.Overlays.KeyBinding
{
if (bindTarget != null)
{
store.Update(bindTarget.KeyBinding);
updateStoreFromButton(bindTarget);
updateIsDefaultValue();
@ -361,6 +364,17 @@ namespace osu.Game.Overlays.KeyBinding
if (bindTarget != null) bindTarget.IsBinding = true;
}
private void updateStoreFromButton(KeyButton button)
{
using (var usage = realmFactory.GetForWrite())
{
var binding = usage.Realm.Find<RealmKeyBinding>(((IHasGuidPrimaryKey)button.KeyBinding).ID);
binding.KeyCombinationString = button.KeyBinding.KeyCombinationString;
usage.Commit();
}
}
private void updateIsDefaultValue()
{
isDefault.Value = bindings.Select(b => b.KeyCombination).SequenceEqual(Defaults);
@ -386,7 +400,7 @@ namespace osu.Game.Overlays.KeyBinding
public class KeyButton : Container
{
public readonly Framework.Input.Bindings.KeyBinding KeyBinding;
public readonly RealmKeyBinding KeyBinding;
private readonly Box box;
public readonly OsuSpriteText Text;
@ -408,8 +422,11 @@ namespace osu.Game.Overlays.KeyBinding
}
}
public KeyButton(Framework.Input.Bindings.KeyBinding keyBinding)
public KeyButton(RealmKeyBinding keyBinding)
{
if (keyBinding.IsManaged)
throw new ArgumentException("Key binding should not be attached as we make temporary changes", nameof(keyBinding));
KeyBinding = keyBinding;
Margin = new MarginPadding(padding);
@ -478,7 +495,7 @@ namespace osu.Game.Overlays.KeyBinding
public void UpdateKeyCombination(KeyCombination newCombination)
{
if ((KeyBinding as DatabasedKeyBinding)?.RulesetID != null && !KeyBindingStore.CheckValidForGameplay(newCombination))
if (KeyBinding.RulesetID != null && !RealmKeyBindingStore.CheckValidForGameplay(newCombination))
return;
KeyBinding.KeyCombination = newCombination;

View File

@ -6,8 +6,9 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets;
using osuTK;
@ -31,16 +32,21 @@ namespace osu.Game.Overlays.KeyBinding
}
[BackgroundDependencyLoader]
private void load(KeyBindingStore store)
private void load(RealmContextFactory realmFactory)
{
var bindings = store.Query(Ruleset?.ID, variant);
var rulesetId = Ruleset?.ID;
List<RealmKeyBinding> bindings;
using (var usage = realmFactory.GetForRead())
bindings = usage.Realm.All<RealmKeyBinding>().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach();
foreach (var defaultGroup in Defaults.GroupBy(d => d.Action))
{
int intKey = (int)defaultGroup.Key;
// one row per valid action.
Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => ((int)b.Action).Equals(intKey)))
Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => b.ActionInt.Equals(intKey)).ToList())
{
AllowMainMouseButtons = Ruleset != null,
Defaults = defaultGroup.Select(d => d.KeyCombination)

View File

@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Mods
base.OnModSelected(mod);
foreach (var section in ModSectionsContainer.Children)
section.DeselectTypes(mod.IncompatibleMods, true);
section.DeselectTypes(mod.IncompatibleMods, true, mod);
}
}
}

View File

@ -302,7 +302,7 @@ namespace osu.Game.Overlays.Mods
Anchor = Anchor.TopCentre,
Font = OsuFont.GetFont(size: 18)
},
new HoverClickSounds(buttons: new[] { MouseButton.Left, MouseButton.Right })
new HoverSounds()
};
Mod = mod;

View File

@ -159,12 +159,16 @@ namespace osu.Game.Overlays.Mods
/// </summary>
/// <param name="modTypes">The types of <see cref="Mod"/>s which should be deselected.</param>
/// <param name="immediate">Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow.</param>
public void DeselectTypes(IEnumerable<Type> modTypes, bool immediate = false)
/// <param name="newSelection">If this deselection is triggered by a user selection, this should contain the newly selected type. This type will never be deselected, even if it matches one provided in <paramref name="modTypes"/>.</param>
public void DeselectTypes(IEnumerable<Type> modTypes, bool immediate = false, Mod newSelection = null)
{
foreach (var button in Buttons)
{
if (button.SelectedMod == null) continue;
if (button.SelectedMod == newSelection)
continue;
foreach (var type in modTypes)
{
if (type.IsInstanceOfType(button.SelectedMod))

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osuTK;
using osuTK.Graphics;
@ -84,6 +85,7 @@ namespace osu.Game.Overlays
private readonly Box background;
public ScrollToTopButton()
: base(HoverSampleSet.ScrollToTop)
{
Size = new Vector2(50);
Alpha = 0;

View File

@ -148,6 +148,8 @@ namespace osu.Game.Overlays
}
}
});
AddInternal(new HoverClickSounds());
}
protected override void LoadComplete()

View File

@ -99,7 +99,7 @@ namespace osu.Game.Overlays
ExpandedSize = 5f,
CollapsedSize = 0
},
new HoverClickSounds()
new HoverClickSounds(HoverSampleSet.TabSelect)
};
}

View File

@ -1,8 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
@ -13,13 +13,13 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osuTK;
using osuTK.Graphics;
@ -76,7 +76,7 @@ namespace osu.Game.Overlays.Toolbar
protected FillFlowContainer Flow;
[Resolved]
private KeyBindingStore keyBindings { get; set; }
private RealmContextFactory realmFactory { get; set; }
protected ToolbarButton()
: base(HoverSampleSet.Toolbar)
@ -159,27 +159,28 @@ namespace osu.Game.Overlays.Toolbar
};
}
private readonly Cached tooltipKeyBinding = new Cached();
private RealmKeyBinding realmKeyBinding;
[BackgroundDependencyLoader]
private void load()
protected override void LoadComplete()
{
keyBindings.KeyBindingChanged += () => tooltipKeyBinding.Invalidate();
base.LoadComplete();
if (Hotkey == null) return;
realmKeyBinding = realmFactory.Context.All<RealmKeyBinding>().FirstOrDefault(rkb => rkb.RulesetID == null && rkb.ActionInt == (int)Hotkey.Value);
if (realmKeyBinding != null)
{
realmKeyBinding.PropertyChanged += (sender, args) =>
{
if (args.PropertyName == nameof(realmKeyBinding.KeyCombinationString))
updateKeyBindingTooltip();
};
}
updateKeyBindingTooltip();
}
private void updateKeyBindingTooltip()
{
if (tooltipKeyBinding.IsValid)
return;
var binding = keyBindings.Query().Find(b => (GlobalAction)b.Action == Hotkey);
var keyBindingString = binding?.KeyCombination.ReadableString();
keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) ? $" ({keyBindingString})" : string.Empty;
tooltipKeyBinding.Validate();
}
protected override bool OnMouseDown(MouseDownEvent e) => true;
protected override bool OnClick(ClickEvent e)
@ -218,6 +219,17 @@ namespace osu.Game.Overlays.Toolbar
public void OnReleased(GlobalAction action)
{
}
private void updateKeyBindingTooltip()
{
if (realmKeyBinding != null)
{
var keyBindingString = realmKeyBinding.KeyCombination.ReadableString();
if (!string.IsNullOrEmpty(keyBindingString))
keyBindingTooltip.Text = $" ({keyBindingString})";
}
}
}
public class OpaqueBackground : Container

View File

@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Runtime;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -11,7 +10,6 @@ namespace osu.Game.Performance
public class HighPerformanceSession : Component
{
private readonly IBindable<bool> localUserPlaying = new Bindable<bool>();
private GCLatencyMode originalGCMode;
[BackgroundDependencyLoader]
private void load(OsuGame game)
@ -34,14 +32,10 @@ namespace osu.Game.Performance
protected virtual void EnableHighPerformanceSession()
{
originalGCMode = GCSettings.LatencyMode;
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
}
protected virtual void DisableHighPerformanceSession()
{
if (GCSettings.LatencyMode == GCLatencyMode.LowLatency)
GCSettings.LatencyMode = originalGCMode;
}
}
}

View File

@ -156,10 +156,11 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// If <c>null</c>, a hitobject is expected to be later applied via <see cref="PoolableDrawableWithLifetime{TEntry}.Apply"/> (or automatically via pooling).
/// </param>
protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null)
: base(initialHitObject != null ? new SyntheticHitObjectEntry(initialHitObject) : null)
{
if (Entry != null)
ensureEntryHasResult();
if (initialHitObject == null) return;
Entry = new SyntheticHitObjectEntry(initialHitObject);
ensureEntryHasResult();
}
[BackgroundDependencyLoader]

View File

@ -5,6 +5,7 @@
using System;
using System.Diagnostics;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Pooling;
@ -16,14 +17,32 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// <typeparam name="TEntry">The <see cref="LifetimeEntry"/> type storing state and controlling this drawable.</typeparam>
public abstract class PoolableDrawableWithLifetime<TEntry> : PoolableDrawable where TEntry : LifetimeEntry
{
private TEntry? entry;
/// <summary>
/// The entry holding essential state of this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
/// </summary>
public TEntry? Entry { get; private set; }
/// <remarks>
/// If a non-null value is set before loading is started, the entry is applied when the loading is completed.
/// It is not valid to set an entry while this <see cref="PoolableDrawableWithLifetime{TEntry}"/> is loading.
/// </remarks>
public TEntry? Entry
{
get => entry;
set
{
if (LoadState == LoadState.NotLoaded)
entry = value;
else if (value != null)
Apply(value);
else if (HasEntryApplied)
free();
}
}
/// <summary>
/// Whether <see cref="Entry"/> is applied to this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
/// When an initial entry is specified in the constructor, <see cref="Entry"/> is set but not applied until loading is completed.
/// When an <see cref="Entry"/> is set during initialization, it is not applied until loading is completed.
/// </summary>
protected bool HasEntryApplied { get; private set; }
@ -65,9 +84,9 @@ namespace osu.Game.Rulesets.Objects.Pooling
{
base.LoadAsyncComplete();
// Apply the initial entry given in the constructor.
// Apply the initial entry.
if (Entry != null && !HasEntryApplied)
Apply(Entry);
apply(Entry);
}
/// <summary>
@ -76,16 +95,10 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// </summary>
public void Apply(TEntry entry)
{
if (HasEntryApplied)
free();
if (LoadState == LoadState.Loading)
throw new InvalidOperationException($"Cannot apply a new {nameof(TEntry)} while currently loading.");
Entry = entry;
entry.LifetimeChanged += setLifetimeFromEntry;
setLifetimeFromEntry(entry);
OnApply(entry);
HasEntryApplied = true;
apply(entry);
}
protected sealed override void FreeAfterUse()
@ -111,6 +124,20 @@ namespace osu.Game.Rulesets.Objects.Pooling
{
}
private void apply(TEntry entry)
{
if (HasEntryApplied)
free();
this.entry = entry;
entry.LifetimeChanged += setLifetimeFromEntry;
setLifetimeFromEntry(entry);
OnApply(entry);
HasEntryApplied = true;
}
private void free()
{
Debug.Assert(Entry != null && HasEntryApplied);
@ -118,7 +145,7 @@ namespace osu.Game.Rulesets.Objects.Pooling
OnFree(Entry);
Entry.LifetimeChanged -= setLifetimeFromEntry;
Entry = null;
entry = null;
base.LifetimeStart = double.MinValue;
base.LifetimeEnd = double.MaxValue;

View File

@ -96,13 +96,25 @@ namespace osu.Game.Rulesets
context.SaveChanges();
// add any other modes
var existingRulesets = context.RulesetInfo.ToList();
// add any other rulesets which have assemblies present but are not yet in the database.
foreach (var r in instances.Where(r => !(r is ILegacyRuleset)))
{
if (existingRulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null)
context.RulesetInfo.Add(r.RulesetInfo);
{
var existingSameShortName = existingRulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName);
if (existingSameShortName != null)
{
// even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName.
// this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one.
// in such cases, update the instantiation info of the existing entry to point to the new one.
existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo;
}
else
context.RulesetInfo.Add(r.RulesetInfo);
}
}
context.SaveChanges();

View File

@ -177,7 +177,7 @@ namespace osu.Game.Rulesets.UI
{
base.ReloadMappings();
KeyBindings = KeyBindings.Where(b => KeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
}
}
}

View File

@ -295,12 +295,12 @@ namespace osu.Game.Screens.Play
DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded =>
{
if (storyboardEnded.NewValue && resultsDisplayDelegate == null)
updateCompletionState();
if (storyboardEnded.NewValue)
progressToResults(true);
};
// Bind the judgement processors to ourselves
ScoreProcessor.HasCompleted.BindValueChanged(_ => updateCompletionState());
ScoreProcessor.HasCompleted.BindValueChanged(scoreCompletionChanged);
HealthProcessor.Failed += onFail;
foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>())
@ -374,7 +374,7 @@ namespace osu.Game.Screens.Play
},
skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0)
{
RequestSkip = () => updateCompletionState(true),
RequestSkip = () => progressToResults(false),
Alpha = 0
},
FailOverlay = new FailOverlay
@ -643,9 +643,8 @@ namespace osu.Game.Screens.Play
/// <summary>
/// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime.
/// </summary>
/// <param name="skipStoryboardOutro">If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it.</param>
/// <exception cref="InvalidOperationException">Thrown if this method is called more than once without changing state.</exception>
private void updateCompletionState(bool skipStoryboardOutro = false)
private void scoreCompletionChanged(ValueChangedEvent<bool> completed)
{
// If this player instance is in the middle of an exit, don't attempt any kind of state update.
if (!this.IsCurrentScreen())
@ -656,7 +655,7 @@ namespace osu.Game.Screens.Play
// Currently, even if this scenario is hit, prepareScoreForDisplay has already been queued (and potentially run).
// In scenarios where rewinding is possible (replay, spectating) this is a non-issue as no submission/import work is done,
// but it still doesn't feel right that this exists here.
if (!ScoreProcessor.HasCompleted.Value)
if (!completed.NewValue)
{
resultsDisplayDelegate?.Cancel();
resultsDisplayDelegate = null;
@ -666,9 +665,6 @@ namespace osu.Game.Screens.Play
return;
}
if (resultsDisplayDelegate != null)
throw new InvalidOperationException(@$"{nameof(updateCompletionState)} should never be fired more than once.");
// Only show the completion screen if the player hasn't failed
if (HealthProcessor.HasFailed)
return;
@ -683,27 +679,25 @@ namespace osu.Game.Screens.Play
if (!Configuration.ShowResults)
return;
// Asynchronously run score preparation operations (database import, online submission etc.).
prepareScoreForDisplayTask ??= Task.Run(prepareScoreForResults);
if (skipStoryboardOutro)
{
scheduleCompletion();
return;
}
bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value;
if (storyboardHasOutro)
{
// if the current beatmap has a storyboard, the progression to results will be handled by the storyboard ending
// or the user pressing the skip outro button.
skipOutroOverlay.Show();
return;
}
using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY))
scheduleCompletion();
progressToResults(true);
}
/// <summary>
/// Asynchronously run score preparation operations (database import, online submission etc.).
/// </summary>
/// <returns>The final score.</returns>
private async Task<ScoreInfo> prepareScoreForResults()
{
try
@ -727,18 +721,44 @@ namespace osu.Game.Screens.Play
return Score.ScoreInfo;
}
private void scheduleCompletion() => resultsDisplayDelegate = Schedule(() =>
/// <summary>
/// Queue the results screen for display.
/// </summary>
/// <remarks>
/// A final display will only occur once all work is completed in <see cref="PrepareScoreForResultsAsync"/>. This means that even after calling this method, the results screen will never be shown until <see cref="JudgementProcessor.HasCompleted">ScoreProcessor.HasCompleted</see> becomes <see langword="true"/>.
///
/// Calling this method multiple times will have no effect.
/// </remarks>
/// <param name="withDelay">Whether a minimum delay (<see cref="RESULTS_DISPLAY_DELAY"/>) should be added before the screen is displayed.</param>
private void progressToResults(bool withDelay)
{
if (!prepareScoreForDisplayTask.IsCompleted)
{
scheduleCompletion();
if (resultsDisplayDelegate != null)
// Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be
// accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued
// may take x00 more milliseconds than expected in the very rare edge case).
//
// If required we can handle this more correctly by rescheduling here.
return;
}
// screen may be in the exiting transition phase.
if (this.IsCurrentScreen())
double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0;
resultsDisplayDelegate = new ScheduledDelegate(() =>
{
if (prepareScoreForDisplayTask?.IsCompleted != true)
// If the asynchronous preparation has not completed, keep repeating this delegate.
return;
resultsDisplayDelegate?.Cancel();
if (!this.IsCurrentScreen())
// This player instance may already be in the process of exiting.
return;
this.Push(CreateResults(prepareScoreForDisplayTask.Result));
});
}, Time.Current + delay, 50);
Scheduler.Add(resultsDisplayDelegate);
}
protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value;
@ -936,14 +956,6 @@ namespace osu.Game.Screens.Play
{
screenSuspension?.Expire();
// if the results screen is prepared to be displayed, forcefully show it on an exit request.
// usually if a user has completed a play session they do want to see results. and if they don't they can hit the same key a second time.
if (resultsDisplayDelegate != null && !resultsDisplayDelegate.Cancelled && !resultsDisplayDelegate.Completed)
{
resultsDisplayDelegate.RunTask();
return true;
}
// EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous.
// To resolve test failures, forcefully end playing synchronously when this screen exits.
// Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method.

View File

@ -66,7 +66,7 @@ namespace osu.Game.Screens.Select
private readonly Box light;
public FooterButton()
: base(HoverSampleSet.SongSelect)
: base(HoverSampleSet.Button)
{
AutoSizeAxes = Axes.Both;
Shear = SHEAR;

View File

@ -25,7 +25,7 @@ namespace osu.Game.Storyboards.Drawables
/// </summary>
public IBindable<bool> HasStoryboardEnded => hasStoryboardEnded;
private readonly BindableBool hasStoryboardEnded = new BindableBool();
private readonly BindableBool hasStoryboardEnded = new BindableBool(true);
protected override Container<DrawableStoryboardLayer> Content { get; }

View File

@ -60,6 +60,9 @@ namespace osu.Game.Utils
{
foreach (var invalid in combination.Where(m => type.IsInstanceOfType(m)))
{
if (invalid == mod)
continue;
invalidMods ??= new List<Mod>();
invalidMods.Add(invalid);
}

View File

@ -18,6 +18,7 @@
</None>
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="DiffPlex" Version="1.7.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.33" />
<PackageReference Include="Humanizer" Version="2.10.1" />
@ -34,8 +35,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.2.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.616.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.614.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" />
<PackageReference Include="Sentry" Version="3.4.0" />
<PackageReference Include="SharpCompress" Version="0.28.2" />
<PackageReference Include="NUnit" Version="3.13.2" />

View File

@ -71,7 +71,7 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.616.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.614.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
<PropertyGroup>
@ -99,5 +99,6 @@
<PackageReference Include="SharpRaven" Version="2.4.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="ppy.osu.Framework.NativeLibs" Version="2021.115.0" ExcludeAssets="all" />
<PackageReference Include="Realm" Version="10.2.0" />
</ItemGroup>
</Project>