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:
commit
1fff9a93b9
3
.gitignore
vendored
3
.gitignore
vendored
@ -336,3 +336,6 @@ inspectcode
|
||||
/BenchmarkDotNet.Artifacts
|
||||
|
||||
*.GeneratedMSBuildEditorConfig.editorconfig
|
||||
|
||||
# Fody (pulled in by Realm) - schema file
|
||||
FodyWeavers.xsd
|
||||
|
3
FodyWeavers.xml
Normal file
3
FodyWeavers.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<Realm />
|
||||
</Weavers>
|
@ -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!
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
{
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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" />
|
||||
|
109
osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs
Normal file
109
osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs
Normal 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.")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
179
osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs
Normal file
179
osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
12
osu.Game.Rulesets.Osu/Mods/IMutateApproachCircles.cs
Normal file
12
osu.Game.Rulesets.Osu/Mods/IMutateApproachCircles.cs
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
@ -10,7 +10,6 @@ namespace osu.Game.Rulesets.Osu
|
||||
Cursor,
|
||||
CursorTrail,
|
||||
SliderScorePoint,
|
||||
ApproachCircle,
|
||||
ReverseArrow,
|
||||
HitCircleText,
|
||||
SliderHeadHitCircle,
|
||||
|
18
osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs
Normal file
18
osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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";
|
||||
|
101
osu.Game.Tests/Database/TestRealmKeyBindingStore.cs
Normal file
101
osu.Game.Tests/Database/TestRealmKeyBindingStore.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -74,7 +74,6 @@ namespace osu.Game.Tests.Visual
|
||||
typeof(FileStore),
|
||||
typeof(ScoreManager),
|
||||
typeof(BeatmapManager),
|
||||
typeof(KeyBindingStore),
|
||||
typeof(SettingsStore),
|
||||
typeof(RulesetConfigCache),
|
||||
typeof(OsuColour),
|
||||
|
16
osu.Game/Database/IHasGuidPrimaryKey.cs
Normal file
16
osu.Game/Database/IHasGuidPrimaryKey.cs
Normal 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; }
|
||||
}
|
||||
}
|
27
osu.Game/Database/IRealmFactory.cs
Normal file
27
osu.Game/Database/IRealmFactory.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -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());
|
||||
|
208
osu.Game/Database/RealmContextFactory.cs
Normal file
208
osu.Game/Database/RealmContextFactory.cs
Normal 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--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
51
osu.Game/Database/RealmExtensions.cs
Normal file
51
osu.Game/Database/RealmExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 =>
|
||||
{
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -172,7 +172,7 @@ namespace osu.Game.Graphics.UserInterface
|
||||
Origin = Anchor.BottomLeft,
|
||||
Anchor = Anchor.BottomLeft,
|
||||
},
|
||||
new HoverClickSounds()
|
||||
new HoverClickSounds(HoverSampleSet.TabSelect)
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
39
osu.Game/Input/Bindings/RealmKeyBinding.cs
Normal file
39
osu.Game/Input/Bindings/RealmKeyBinding.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
117
osu.Game/Input/RealmKeyBindingStore.cs
Normal file
117
osu.Game/Input/RealmKeyBindingStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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());
|
||||
|
@ -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);
|
||||
|
@ -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>>();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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))
|
||||
|
@ -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;
|
||||
|
@ -148,6 +148,8 @@ namespace osu.Game.Overlays
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
AddInternal(new HoverClickSounds());
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
|
@ -99,7 +99,7 @@ namespace osu.Game.Overlays
|
||||
ExpandedSize = 5f,
|
||||
CollapsedSize = 0
|
||||
},
|
||||
new HoverClickSounds()
|
||||
new HoverClickSounds(HoverSampleSet.TabSelect)
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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; }
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user