1
0
mirror of https://github.com/ppy/osu.git synced 2025-03-11 15:27:20 +08:00

Merge branch 'master' into fix-multiplayer-unobserved

This commit is contained in:
Dean Herbert 2022-04-06 11:33:10 +09:00 committed by GitHub
commit c540810943
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 1816 additions and 602 deletions

View File

@ -9,7 +9,7 @@ body:
Important to note that your issue may have already been reported before. Please check: Important to note that your issue may have already been reported before. Please check:
- Pinned issues, at the top of https://github.com/ppy/osu/issues. - Pinned issues, at the top of https://github.com/ppy/osu/issues.
- Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0). - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0).
- And most importantly, search for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful. - And most importantly, search for your issue both in the [issue listing](https://github.com/ppy/osu/issues) and the [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a). If you find that it already exists, respond with a reaction or add any further information that may be helpful.
- type: dropdown - type: dropdown
attributes: attributes:
@ -48,20 +48,28 @@ body:
Attaching log files is required for every reported bug. See instructions below on how to find them. Attaching log files is required for every reported bug. See instructions below on how to find them.
**Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead.
### Desktop platforms
If the game has not yet been closed since you found the bug: If the game has not yet been closed since you found the bug:
1. Head on to game settings and click on "Open osu! folder" 1. Head on to game settings and click on "Open osu! folder"
2. Then open the `logs` folder located there 2. Then open the `logs` folder located there
**Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead. The default places to find the logs on desktop platforms are as follows:
The default places to find the logs are as follows:
- `%AppData%/osu/logs` *on Windows* - `%AppData%/osu/logs` *on Windows*
- `~/.local/share/osu/logs` *on Linux & macOS* - `~/.local/share/osu/logs` *on Linux & macOS*
- `Android/data/sh.ppy.osulazer/files/logs` *on Android*
- *On iOS*, they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
If you have selected a custom location for the game files, you can find the `logs` folder there. If you have selected a custom location for the game files, you can find the `logs` folder there.
### Mobile platforms
The places to find the logs on mobile platforms are as follows:
- *On Android*, navigate to `Android/data/sh.ppy.osulazer/files/logs` using a file browser app.
- *On iOS*, connect your device to a PC and copy the `logs` directory from the app's document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
---
After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below. After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below.
- type: textarea - type: textarea

View File

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

View File

@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Catch.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Catch"; protected override string ResourceAssembly => "osu.Game.Rulesets.Catch";
[TestCase(4.0505463516206195d, "diffcalc-test")] [TestCase(4.0505463516206195d, 127, "diffcalc-test")]
public void Test(double expected, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expected, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(5.1696411260785498d, "diffcalc-test")] [TestCase(5.1696411260785498d, 127, "diffcalc-test")]
public void TestClockRateAdjusted(double expected, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expected, name, new CatchModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new CatchModDoubleTime());
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new CatchDifficultyCalculator(new CatchRuleset().RulesetInfo, beatmap); protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new CatchDifficultyCalculator(new CatchRuleset().RulesetInfo, beatmap);

View File

@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Mania.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; protected override string ResourceAssembly => "osu.Game.Rulesets.Mania";
[TestCase(2.3449735700206298d, "diffcalc-test")] [TestCase(2.3449735700206298d, 151, "diffcalc-test")]
public void Test(double expected, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expected, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(2.7879104989252959d, "diffcalc-test")] [TestCase(2.7879104989252959d, 151, "diffcalc-test")]
public void TestClockRateAdjusted(double expected, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expected, name, new ManiaModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new ManiaModDoubleTime());
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new ManiaDifficultyCalculator(new ManiaRuleset().RulesetInfo, beatmap); protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new ManiaDifficultyCalculator(new ManiaRuleset().RulesetInfo, beatmap);

View File

@ -6,18 +6,18 @@ using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Tests.Mods namespace osu.Game.Rulesets.Osu.Tests.Mods
{ {
public class TestSceneOsuModAimAssist : OsuModTestScene public class TestSceneOsuModMagnetised : OsuModTestScene
{ {
[TestCase(0.1f)] [TestCase(0.1f)]
[TestCase(0.5f)] [TestCase(0.5f)]
[TestCase(1)] [TestCase(1)]
public void TestAimAssist(float strength) public void TestMagnetised(float strength)
{ {
CreateModTest(new ModTestData CreateModTest(new ModTestData
{ {
Mod = new OsuModAimAssist Mod = new OsuModMagnetised
{ {
AssistStrength = { Value = strength }, AttractionStrength = { Value = strength },
}, },
PassCondition = () => true, PassCondition = () => true,
Autoplay = false, Autoplay = false,

View File

@ -15,15 +15,20 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
[TestCase(6.6972307565739273d, "diffcalc-test")] [TestCase(6.6972307565739273d, 206, "diffcalc-test")]
[TestCase(1.4484754139145539d, "zero-length-sliders")] [TestCase(1.4484754139145539d, 45, "zero-length-sliders")]
public void Test(double expected, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expected, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(8.9382559208689809d, "diffcalc-test")] [TestCase(8.9382559208689809d, 206, "diffcalc-test")]
[TestCase(1.7548875851757628d, "zero-length-sliders")] [TestCase(1.7548875851757628d, 45, "zero-length-sliders")]
public void TestClockRateAdjusted(double expected, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expected, name, new OsuModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.6972307218715166d, 239, "diffcalc-test")]
[TestCase(1.4484754139145537d, 54, "zero-length-sliders")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap); protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap);

View File

@ -61,10 +61,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double drainRate = beatmap.Difficulty.DrainRate; double drainRate = beatmap.Difficulty.DrainRate;
int maxCombo = beatmap.GetMaxCombo();
int maxCombo = beatmap.HitObjects.Count;
// Add the ticks + tail of the slider. 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above)
maxCombo += beatmap.HitObjects.OfType<Slider>().Sum(s => s.NestedHitObjects.Count - 1);
int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle); int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int sliderCount = beatmap.HitObjects.Count(h => h is Slider);

View File

@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Automation; public override ModType Type => ModType.Automation;
public override string Description => @"Automatic cursor movement - just follow the rhythm."; public override string Description => @"Automatic cursor movement - just follow the rhythm.";
public override double ScoreMultiplier => 1; public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModAimAssist) }; public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised) };
public bool PerformFail() => false; public bool PerformFail() => false;

View File

@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{ {
public class OsuModAutoplay : ModAutoplay public class OsuModAutoplay : ModAutoplay
{ {
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAimAssist), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray(); public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods) public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });

View File

@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{ {
public class OsuModCinema : ModCinema<OsuHitObject> public class OsuModCinema : ModCinema<OsuHitObject>
{ {
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAimAssist), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray(); public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods) public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });

View File

@ -16,20 +16,20 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Mods namespace osu.Game.Rulesets.Osu.Mods
{ {
internal class OsuModAimAssist : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject> internal class OsuModMagnetised : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>
{ {
public override string Name => "Aim Assist"; public override string Name => "Magnetised";
public override string Acronym => "AA"; public override string Acronym => "MG";
public override IconUsage? Icon => FontAwesome.Solid.MousePointer; public override IconUsage? Icon => FontAwesome.Solid.Magnet;
public override ModType Type => ModType.Fun; public override ModType Type => ModType.Fun;
public override string Description => "No need to chase the circle the circle chases you!"; public override string Description => "No need to chase the circles your cursor is a magnet!";
public override double ScoreMultiplier => 1; public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) }; public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) };
private IFrameStableClock gameplayClock; private IFrameStableClock gameplayClock;
[SettingSource("Assist strength", "How much this mod will assist you.", 0)] [SettingSource("Attraction strength", "How strong the pull is.", 0)]
public BindableFloat AssistStrength { get; } = new BindableFloat(0.5f) public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f)
{ {
Precision = 0.05f, Precision = 0.05f,
MinValue = 0.05f, MinValue = 0.05f,
@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.Mods
private void easeTo(DrawableHitObject hitObject, Vector2 destination) private void easeTo(DrawableHitObject hitObject, Vector2 destination)
{ {
double dampLength = Interpolation.Lerp(3000, 40, AssistStrength.Value); double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value);
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime); float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime);
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime); float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime);

View File

@ -4,19 +4,14 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Osu.Utils; using osu.Game.Rulesets.Osu.Utils;
using osuTK;
namespace osu.Game.Rulesets.Osu.Mods namespace osu.Game.Rulesets.Osu.Mods
{ {
@ -28,12 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override string Description => "It never gets boring!"; public override string Description => "It never gets boring!";
private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast;
private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
/// <summary>
/// Number of previous hitobjects to be shifted together when another object is being moved.
/// </summary>
private const int preceding_hitobjects_to_shift = 10;
private Random? rng; private Random? rng;
@ -42,330 +31,33 @@ namespace osu.Game.Rulesets.Osu.Mods
if (!(beatmap is OsuBeatmap osuBeatmap)) if (!(beatmap is OsuBeatmap osuBeatmap))
return; return;
var hitObjects = osuBeatmap.HitObjects;
Seed.Value ??= RNG.Next(); Seed.Value ??= RNG.Next();
rng = new Random((int)Seed.Value); rng = new Random((int)Seed.Value);
var randomObjects = randomiseObjects(hitObjects); var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects);
applyRandomisation(hitObjects, randomObjects);
}
/// <summary>
/// Randomise the position of each hit object and return a list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.
/// </summary>
/// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to have their positions randomised.</param>
/// <returns>A list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.</returns>
private List<RandomObjectInfo> randomiseObjects(IEnumerable<OsuHitObject> hitObjects)
{
Debug.Assert(rng != null, $"{nameof(ApplyToBeatmap)} was not called before randomising objects");
var randomObjects = new List<RandomObjectInfo>();
RandomObjectInfo? previous = null;
float rateOfChangeMultiplier = 0; float rateOfChangeMultiplier = 0;
foreach (OsuHitObject hitObject in hitObjects) foreach (var positionInfo in positionInfos)
{ {
var current = new RandomObjectInfo(hitObject);
randomObjects.Add(current);
// rateOfChangeMultiplier only changes every 5 iterations in a combo // rateOfChangeMultiplier only changes every 5 iterations in a combo
// to prevent shaky-line-shaped streams // to prevent shaky-line-shaped streams
if (hitObject.IndexInCurrentCombo % 5 == 0) if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0)
rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1; rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
if (previous == null) if (positionInfo == positionInfos.First())
{ {
current.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2); positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2);
current.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
} }
else else
{ {
current.DistanceFromPrevious = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal); positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f));
// The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object)
// is proportional to the distance between the last and the current hit object
// to allow jumps and prevent too sharp turns during streams.
// Allow maximum jump angle when jump distance is more than half of playfield diagonal length
current.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, current.DistanceFromPrevious / (playfield_diagonal * 0.5f));
} }
previous = current;
} }
return randomObjects; osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos);
}
/// <summary>
/// Reposition the hit objects according to the information in <paramref name="randomObjects"/>.
/// </summary>
/// <param name="hitObjects">The hit objects to be repositioned.</param>
/// <param name="randomObjects">A list of <see cref="RandomObjectInfo"/> describing how each hit object should be placed.</param>
private void applyRandomisation(IReadOnlyList<OsuHitObject> hitObjects, IReadOnlyList<RandomObjectInfo> randomObjects)
{
RandomObjectInfo? previous = null;
for (int i = 0; i < hitObjects.Count; i++)
{
var hitObject = hitObjects[i];
var current = randomObjects[i];
if (hitObject is Spinner)
{
previous = null;
continue;
}
computeRandomisedPosition(current, previous, i > 1 ? randomObjects[i - 2] : null);
// Move hit objects back into the playfield if they are outside of it
Vector2 shift = Vector2.Zero;
switch (hitObject)
{
case HitCircle circle:
shift = clampHitCircleToPlayfield(circle, current);
break;
case Slider slider:
shift = clampSliderToPlayfield(slider, current);
break;
}
if (shift != Vector2.Zero)
{
var toBeShifted = new List<OsuHitObject>();
for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
{
// only shift hit circles
if (!(hitObjects[j] is HitCircle)) break;
toBeShifted.Add(hitObjects[j]);
}
if (toBeShifted.Count > 0)
applyDecreasingShift(toBeShifted, shift);
}
previous = current;
}
}
/// <summary>
/// Compute the randomised position of a hit object while attempting to keep it inside the playfield.
/// </summary>
/// <param name="current">The <see cref="RandomObjectInfo"/> representing the hit object to have the randomised position computed for.</param>
/// <param name="previous">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the current one.</param>
/// <param name="beforePrevious">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
private void computeRandomisedPosition(RandomObjectInfo current, RandomObjectInfo? previous, RandomObjectInfo? beforePrevious)
{
float previousAbsoluteAngle = 0f;
if (previous != null)
{
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
}
float absoluteAngle = previousAbsoluteAngle + current.RelativeAngle;
var posRelativeToPrev = new Vector2(
current.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
);
Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre;
posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
current.PositionRandomised = lastEndPosition + posRelativeToPrev;
}
/// <summary>
/// Move the randomised position of a hit circle so that it fits inside the playfield.
/// </summary>
/// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
private Vector2 clampHitCircleToPlayfield(HitCircle circle, RandomObjectInfo objectInfo)
{
var previousPosition = objectInfo.PositionRandomised;
objectInfo.EndPositionRandomised = objectInfo.PositionRandomised = clampToPlayfieldWithPadding(
objectInfo.PositionRandomised,
(float)circle.Radius
);
circle.Position = objectInfo.PositionRandomised;
return objectInfo.PositionRandomised - previousPosition;
}
/// <summary>
/// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
/// </summary>
/// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo)
{
var possibleMovementBounds = calculatePossibleMovementBounds(slider);
var previousPosition = objectInfo.PositionRandomised;
// Clamp slider position to the placement area
// If the slider is larger than the playfield, force it to stay at the original position
float newX = possibleMovementBounds.Width < 0
? objectInfo.PositionOriginal.X
: Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
float newY = possibleMovementBounds.Height < 0
? objectInfo.PositionOriginal.Y
: Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY);
objectInfo.EndPositionRandomised = slider.EndPosition;
shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal);
return objectInfo.PositionRandomised - previousPosition;
}
/// <summary>
/// Decreasingly shift a list of <see cref="OsuHitObject"/>s by a specified amount.
/// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount.
/// </summary>
/// <param name="hitObjects">The list of hit objects to be shifted.</param>
/// <param name="shift">The amount to be shifted.</param>
private void applyDecreasingShift(IList<OsuHitObject> hitObjects, Vector2 shift)
{
for (int i = 0; i < hitObjects.Count; i++)
{
var hitObject = hitObjects[i];
// The first object is shifted by a vector slightly smaller than shift
// The last object is shifted by a vector slightly larger than zero
Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1));
hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius);
}
}
/// <summary>
/// Calculates a <see cref="RectangleF"/> which contains all of the possible movements of the slider (in relative X/Y coordinates)
/// such that the entire slider is inside the playfield.
/// </summary>
/// <remarks>
/// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
/// </remarks>
private RectangleF calculatePossibleMovementBounds(Slider slider)
{
var pathPositions = new List<Vector2>();
slider.Path.GetPathToProgress(pathPositions, 0, 1);
float minX = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
float minY = float.PositiveInfinity;
float maxY = float.NegativeInfinity;
// Compute the bounding box of the slider.
foreach (var pos in pathPositions)
{
minX = MathF.Min(minX, pos.X);
maxX = MathF.Max(maxX, pos.X);
minY = MathF.Min(minY, pos.Y);
maxY = MathF.Max(maxY, pos.Y);
}
// Take the circle radius into account.
float radius = (float)slider.Radius;
minX -= radius;
minY -= radius;
maxX += radius;
maxY += radius;
// Given the bounding box of the slider (via min/max X/Y),
// the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right),
// and the amount that it can move to the right is WIDTH - maxX.
// Same calculation applies for the Y axis.
float left = -minX;
float right = OsuPlayfield.BASE_SIZE.X - maxX;
float top = -minY;
float bottom = OsuPlayfield.BASE_SIZE.Y - maxY;
return new RectangleF(left, top, right - left, bottom - top);
}
/// <summary>
/// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
/// </summary>
/// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
/// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
private void shiftNestedObjects(Slider slider, Vector2 shift)
{
foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
{
if (!(hitObject is OsuHitObject osuHitObject))
continue;
osuHitObject.Position += shift;
}
}
/// <summary>
/// Clamp a position to playfield, keeping a specified distance from the edges.
/// </summary>
/// <param name="position">The position to be clamped.</param>
/// <param name="padding">The minimum distance allowed from playfield edges.</param>
/// <returns>The clamped position.</returns>
private Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
{
return new Vector2(
Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding),
Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding)
);
}
private class RandomObjectInfo
{
/// <summary>
/// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
/// </summary>
/// <remarks>
/// <see cref="RelativeAngle"/> of the first hit object in a beatmap represents the absolute angle from playfield center to the object.
/// </remarks>
/// <example>
/// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
/// the previous object to reach this one.
/// </example>
public float RelativeAngle { get; set; }
/// <summary>
/// The jump distance from the previous hit object to this one.
/// </summary>
/// <remarks>
/// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
/// </remarks>
public float DistanceFromPrevious { get; set; }
public Vector2 PositionOriginal { get; }
public Vector2 PositionRandomised { get; set; }
public Vector2 EndPositionOriginal { get; }
public Vector2 EndPositionRandomised { get; set; }
public OsuHitObject HitObject { get; }
public RandomObjectInfo(OsuHitObject hitObject)
{
PositionRandomised = PositionOriginal = hitObject.Position;
EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition;
HitObject = hitObject;
}
} }
} }
} }

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToPlayer public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToPlayer
{ {
public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things.";
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModAimAssist) }).ToArray(); public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised) }).ToArray();
/// <summary> /// <summary>
/// How early before a hitobject's start time to trigger a hit. /// How early before a hitobject's start time to trigger a hit.

View File

@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Fun; public override ModType Type => ModType.Fun;
public override string Description => "Everything rotates. EVERYTHING."; public override string Description => "Everything rotates. EVERYTHING.";
public override double ScoreMultiplier => 1; public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModAimAssist) }; public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised) };
private float theta; private float theta;

View File

@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Fun; public override ModType Type => ModType.Fun;
public override string Description => "They just won't stay still..."; public override string Description => "They just won't stay still...";
public override double ScoreMultiplier => 1; public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModAimAssist) }; public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised) };
private const int wiggle_duration = 90; // (ms) Higher = fewer wiggles private const int wiggle_duration = 90; // (ms) Higher = fewer wiggles
private const int wiggle_strength = 10; // Higher = stronger wiggles private const int wiggle_strength = 10; // Higher = stronger wiggles

View File

@ -195,7 +195,7 @@ namespace osu.Game.Rulesets.Osu
new OsuModApproachDifferent(), new OsuModApproachDifferent(),
new OsuModMuted(), new OsuModMuted(),
new OsuModNoScope(), new OsuModNoScope(),
new OsuModAimAssist(), new OsuModMagnetised(),
new ModAdaptiveSpeed() new ModAdaptiveSpeed()
}; };

View File

@ -11,7 +11,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Utils namespace osu.Game.Rulesets.Osu.Utils
{ {
public static class OsuHitObjectGenerationUtils public static partial class OsuHitObjectGenerationUtils
{ {
// The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle. // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle.
// The closer the hit objects draw to the border, the sharper the turn // The closer the hit objects draw to the border, the sharper the turn

View File

@ -0,0 +1,340 @@
// 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.Graphics.Primitives;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osuTK;
#nullable enable
namespace osu.Game.Rulesets.Osu.Utils
{
public static partial class OsuHitObjectGenerationUtils
{
/// <summary>
/// Number of previous hitobjects to be shifted together when an object is being moved.
/// </summary>
private const int preceding_hitobjects_to_shift = 10;
private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
/// <summary>
/// Generate a list of <see cref="ObjectPositionInfo"/>s containing information for how the given list of
/// <see cref="OsuHitObject"/>s are positioned.
/// </summary>
/// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to process.</param>
/// <returns>A list of <see cref="ObjectPositionInfo"/>s describing how each hit object is positioned relative to the previous one.</returns>
public static List<ObjectPositionInfo> GeneratePositionInfos(IEnumerable<OsuHitObject> hitObjects)
{
var positionInfos = new List<ObjectPositionInfo>();
Vector2 previousPosition = playfield_centre;
float previousAngle = 0;
foreach (OsuHitObject hitObject in hitObjects)
{
Vector2 relativePosition = hitObject.Position - previousPosition;
float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
float relativeAngle = absoluteAngle - previousAngle;
positionInfos.Add(new ObjectPositionInfo(hitObject)
{
RelativeAngle = relativeAngle,
DistanceFromPrevious = relativePosition.Length
});
previousPosition = hitObject.EndPosition;
previousAngle = absoluteAngle;
}
return positionInfos;
}
/// <summary>
/// Reposition the hit objects according to the information in <paramref name="objectPositionInfos"/>.
/// </summary>
/// <param name="objectPositionInfos">Position information for each hit object.</param>
/// <returns>The repositioned hit objects.</returns>
public static List<OsuHitObject> RepositionHitObjects(IEnumerable<ObjectPositionInfo> objectPositionInfos)
{
List<WorkingObject> workingObjects = objectPositionInfos.Select(o => new WorkingObject(o)).ToList();
WorkingObject? previous = null;
for (int i = 0; i < workingObjects.Count; i++)
{
var current = workingObjects[i];
var hitObject = current.HitObject;
if (hitObject is Spinner)
{
previous = null;
continue;
}
computeModifiedPosition(current, previous, i > 1 ? workingObjects[i - 2] : null);
// Move hit objects back into the playfield if they are outside of it
Vector2 shift = Vector2.Zero;
switch (hitObject)
{
case HitCircle _:
shift = clampHitCircleToPlayfield(current);
break;
case Slider _:
shift = clampSliderToPlayfield(current);
break;
}
if (shift != Vector2.Zero)
{
var toBeShifted = new List<OsuHitObject>();
for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
{
// only shift hit circles
if (!(workingObjects[j].HitObject is HitCircle)) break;
toBeShifted.Add(workingObjects[j].HitObject);
}
if (toBeShifted.Count > 0)
applyDecreasingShift(toBeShifted, shift);
}
previous = current;
}
return workingObjects.Select(p => p.HitObject).ToList();
}
/// <summary>
/// Compute the modified position of a hit object while attempting to keep it inside the playfield.
/// </summary>
/// <param name="current">The <see cref="WorkingObject"/> representing the hit object to have the modified position computed for.</param>
/// <param name="previous">The <see cref="WorkingObject"/> representing the hit object immediately preceding the current one.</param>
/// <param name="beforePrevious">The <see cref="WorkingObject"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
private static void computeModifiedPosition(WorkingObject current, WorkingObject? previous, WorkingObject? beforePrevious)
{
float previousAbsoluteAngle = 0f;
if (previous != null)
{
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
}
float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle;
var posRelativeToPrev = new Vector2(
current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
);
Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre;
posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
current.PositionModified = lastEndPosition + posRelativeToPrev;
}
/// <summary>
/// Move the modified position of a <see cref="HitCircle"/> so that it fits inside the playfield.
/// </summary>
/// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
private static Vector2 clampHitCircleToPlayfield(WorkingObject workingObject)
{
var previousPosition = workingObject.PositionModified;
workingObject.EndPositionModified = workingObject.PositionModified = clampToPlayfieldWithPadding(
workingObject.PositionModified,
(float)workingObject.HitObject.Radius
);
workingObject.HitObject.Position = workingObject.PositionModified;
return workingObject.PositionModified - previousPosition;
}
/// <summary>
/// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
/// </summary>
/// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
private static Vector2 clampSliderToPlayfield(WorkingObject workingObject)
{
var slider = (Slider)workingObject.HitObject;
var possibleMovementBounds = calculatePossibleMovementBounds(slider);
var previousPosition = workingObject.PositionModified;
// Clamp slider position to the placement area
// If the slider is larger than the playfield, force it to stay at the original position
float newX = possibleMovementBounds.Width < 0
? workingObject.PositionOriginal.X
: Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
float newY = possibleMovementBounds.Height < 0
? workingObject.PositionOriginal.Y
: Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
slider.Position = workingObject.PositionModified = new Vector2(newX, newY);
workingObject.EndPositionModified = slider.EndPosition;
shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal);
return workingObject.PositionModified - previousPosition;
}
/// <summary>
/// Decreasingly shift a list of <see cref="OsuHitObject"/>s by a specified amount.
/// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount.
/// </summary>
/// <param name="hitObjects">The list of hit objects to be shifted.</param>
/// <param name="shift">The amount to be shifted.</param>
private static void applyDecreasingShift(IList<OsuHitObject> hitObjects, Vector2 shift)
{
for (int i = 0; i < hitObjects.Count; i++)
{
var hitObject = hitObjects[i];
// The first object is shifted by a vector slightly smaller than shift
// The last object is shifted by a vector slightly larger than zero
Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1));
hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius);
}
}
/// <summary>
/// Calculates a <see cref="RectangleF"/> which contains all of the possible movements of the slider (in relative X/Y coordinates)
/// such that the entire slider is inside the playfield.
/// </summary>
/// <remarks>
/// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
/// </remarks>
private static RectangleF calculatePossibleMovementBounds(Slider slider)
{
var pathPositions = new List<Vector2>();
slider.Path.GetPathToProgress(pathPositions, 0, 1);
float minX = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
float minY = float.PositiveInfinity;
float maxY = float.NegativeInfinity;
// Compute the bounding box of the slider.
foreach (var pos in pathPositions)
{
minX = MathF.Min(minX, pos.X);
maxX = MathF.Max(maxX, pos.X);
minY = MathF.Min(minY, pos.Y);
maxY = MathF.Max(maxY, pos.Y);
}
// Take the circle radius into account.
float radius = (float)slider.Radius;
minX -= radius;
minY -= radius;
maxX += radius;
maxY += radius;
// Given the bounding box of the slider (via min/max X/Y),
// the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right),
// and the amount that it can move to the right is WIDTH - maxX.
// Same calculation applies for the Y axis.
float left = -minX;
float right = OsuPlayfield.BASE_SIZE.X - maxX;
float top = -minY;
float bottom = OsuPlayfield.BASE_SIZE.Y - maxY;
return new RectangleF(left, top, right - left, bottom - top);
}
/// <summary>
/// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
/// </summary>
/// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
/// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
private static void shiftNestedObjects(Slider slider, Vector2 shift)
{
foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
{
if (!(hitObject is OsuHitObject osuHitObject))
continue;
osuHitObject.Position += shift;
}
}
/// <summary>
/// Clamp a position to playfield, keeping a specified distance from the edges.
/// </summary>
/// <param name="position">The position to be clamped.</param>
/// <param name="padding">The minimum distance allowed from playfield edges.</param>
/// <returns>The clamped position.</returns>
private static Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
{
return new Vector2(
Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding),
Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding)
);
}
public class ObjectPositionInfo
{
/// <summary>
/// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
/// </summary>
/// <remarks>
/// <see cref="RelativeAngle"/> of the first hit object in a beatmap represents the absolute angle from playfield center to the object.
/// </remarks>
/// <example>
/// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
/// the previous object to reach this one.
/// </example>
public float RelativeAngle { get; set; }
/// <summary>
/// The jump distance from the previous hit object to this one.
/// </summary>
/// <remarks>
/// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
/// </remarks>
public float DistanceFromPrevious { get; set; }
/// <summary>
/// The hit object associated with this <see cref="ObjectPositionInfo"/>.
/// </summary>
public OsuHitObject HitObject { get; }
public ObjectPositionInfo(OsuHitObject hitObject)
{
HitObject = hitObject;
}
}
private class WorkingObject
{
public Vector2 PositionOriginal { get; }
public Vector2 PositionModified { get; set; }
public Vector2 EndPositionModified { get; set; }
public ObjectPositionInfo PositionInfo { get; }
public OsuHitObject HitObject => PositionInfo.HitObject;
public WorkingObject(ObjectPositionInfo positionInfo)
{
PositionInfo = positionInfo;
PositionModified = PositionOriginal = HitObject.Position;
EndPositionModified = HitObject.EndPosition;
}
}
}
}

View File

@ -14,15 +14,15 @@ namespace osu.Game.Rulesets.Taiko.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
[TestCase(2.2420075288523802d, "diffcalc-test")] [TestCase(2.2420075288523802d, 200, "diffcalc-test")]
[TestCase(2.2420075288523802d, "diffcalc-test-strong")] [TestCase(2.2420075288523802d, 200, "diffcalc-test-strong")]
public void Test(double expected, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expected, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(3.134084469440479d, "diffcalc-test")] [TestCase(3.134084469440479d, 200, "diffcalc-test")]
[TestCase(3.134084469440479d, "diffcalc-test-strong")] [TestCase(3.134084469440479d, 200, "diffcalc-test-strong")]
public void TestClockRateAdjusted(double expected, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expected, name, new TaikoModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new TaikoDifficultyCalculator(new TaikoRuleset().RulesetInfo, beatmap); protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new TaikoDifficultyCalculator(new TaikoRuleset().RulesetInfo, beatmap);

View File

@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
/// <summary> /// <summary>
/// Default size of a drawable taiko hit object. /// Default size of a drawable taiko hit object.
/// </summary> /// </summary>
public const float DEFAULT_SIZE = 0.45f; public const float DEFAULT_SIZE = 0.475f;
public override Judgement CreateJudgement() => new TaikoJudgement(); public override Judgement CreateJudgement() => new TaikoJudgement();

View File

@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
/// <summary> /// <summary>
/// Scale multiplier for a strong drawable taiko hit object. /// Scale multiplier for a strong drawable taiko hit object.
/// </summary> /// </summary>
public const float STRONG_SCALE = 1.4f; public const float STRONG_SCALE = 1 / 0.65f;
/// <summary> /// <summary>
/// Default size of a strong drawable taiko hit object. /// Default size of a strong drawable taiko hit object.

View File

@ -11,6 +11,7 @@ using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Rulesets.Taiko.Objects;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Rulesets.Taiko.Skinning.Default namespace osu.Game.Rulesets.Taiko.Skinning.Default
@ -24,8 +25,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default
/// </summary> /// </summary>
public abstract class CirclePiece : BeatSyncedContainer, IHasAccentColour public abstract class CirclePiece : BeatSyncedContainer, IHasAccentColour
{ {
public const float SYMBOL_SIZE = 0.45f; public const float SYMBOL_SIZE = TaikoHitObject.DEFAULT_SIZE;
public const float SYMBOL_BORDER = 8; public const float SYMBOL_BORDER = 8;
private const double pre_beat_transition_time = 80; private const double pre_beat_transition_time = 80;
private Color4 accentColour; private Color4 accentColour;

View File

@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
new Sprite new Sprite
{ {
Texture = skin.GetTexture("approachcircle"), Texture = skin.GetTexture("approachcircle"),
Scale = new Vector2(0.73f), Scale = new Vector2(0.83f),
Alpha = 0.47f, // eyeballed to match stable Alpha = 0.47f, // eyeballed to match stable
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
new Sprite new Sprite
{ {
Texture = skin.GetTexture("taikobigcircle"), Texture = skin.GetTexture("taikobigcircle"),
Scale = new Vector2(0.7f), Scale = new Vector2(0.8f),
Alpha = 0.22f, // eyeballed to match stable Alpha = 0.22f, // eyeballed to match stable
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,

View File

@ -2,10 +2,13 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Timing; using osu.Framework.Timing;
using osu.Game.Configuration;
using osu.Game.Overlays.Toolbar; using osu.Game.Overlays.Toolbar;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -15,7 +18,10 @@ namespace osu.Game.Tests.Visual.Menus
[TestFixture] [TestFixture]
public class TestSceneToolbarClock : OsuManualInputManagerTestScene public class TestSceneToolbarClock : OsuManualInputManagerTestScene
{ {
private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
private readonly Container mainContainer; private readonly Container mainContainer;
private readonly ToolbarClock toolbarClock;
public TestSceneToolbarClock() public TestSceneToolbarClock()
{ {
@ -49,7 +55,7 @@ namespace osu.Game.Tests.Visual.Menus
RelativeSizeAxes = Axes.Y, RelativeSizeAxes = Axes.Y,
Width = 2, Width = 2,
}, },
new ToolbarClock(), toolbarClock = new ToolbarClock(),
new Box new Box
{ {
Colour = Color4.DarkRed, Colour = Color4.DarkRed,
@ -65,6 +71,12 @@ namespace osu.Game.Tests.Visual.Menus
AddSliderStep("scale", 0.5, 4, 1, scale => mainContainer.Scale = new Vector2((float)scale)); AddSliderStep("scale", 0.5, 4, 1, scale => mainContainer.Scale = new Vector2((float)scale));
} }
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
}
[Test] [Test]
public void TestRealGameTime() public void TestRealGameTime()
{ {
@ -76,5 +88,20 @@ namespace osu.Game.Tests.Visual.Menus
{ {
AddStep("Set game time long", () => mainContainer.Clock = new FramedOffsetClock(Clock, false) { Offset = 3600.0 * 24 * 1000 * 98 }); AddStep("Set game time long", () => mainContainer.Clock = new FramedOffsetClock(Clock, false) { Offset = 3600.0 * 24 * 1000 * 98 });
} }
[Test]
public void TestDisplayModeChange()
{
AddStep("Set clock display mode", () => clockDisplayMode.Value = ToolbarClockDisplayMode.Full);
AddStep("Trigger click", () => toolbarClock.TriggerClick());
AddAssert("State is digital with runtime", () => clockDisplayMode.Value == ToolbarClockDisplayMode.DigitalWithRuntime);
AddStep("Trigger click", () => toolbarClock.TriggerClick());
AddAssert("State is digital", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Digital);
AddStep("Trigger click", () => toolbarClock.TriggerClick());
AddAssert("State is analog", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Analog);
AddStep("Trigger click", () => toolbarClock.TriggerClick());
AddAssert("State is full", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Full);
}
} }
} }

View File

@ -13,6 +13,7 @@ using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Rulesets; using osu.Game.Rulesets;
@ -183,14 +184,41 @@ namespace osu.Game.Tests.Visual.Multiplayer
assertItemInHistoryListStep(2, 0); assertItemInHistoryListStep(2, 0);
} }
[Test]
public void TestInsertedItemDoesNotRefreshAllOthers()
{
AddStep("change to round robin queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayersRoundRobin }).WaitSafely());
// Add a few items for the local user.
addItemStep();
addItemStep();
addItemStep();
addItemStep();
addItemStep();
DrawableRoomPlaylistItem[] drawableItems = null;
AddStep("get drawable items", () => drawableItems = this.ChildrenOfType<DrawableRoomPlaylistItem>().ToArray());
// Add 1 item for another user.
AddStep("join second user", () => MultiplayerClient.AddUser(new APIUser { Id = 10 }));
addItemStep(userId: 10);
// New item inserted towards the top of the list.
assertItemInQueueListStep(7, 1);
AddAssert("all previous playlist items remained", () => drawableItems.All(this.ChildrenOfType<DrawableRoomPlaylistItem>().Contains));
}
/// <summary> /// <summary>
/// Adds a step to create a new playlist item. /// Adds a step to create a new playlist item.
/// </summary> /// </summary>
private void addItemStep(bool expired = false) => AddStep("add item", () => MultiplayerClient.AddPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) private void addItemStep(bool expired = false, int? userId = null) => AddStep("add item", () =>
{ {
Expired = expired, MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)
PlayedAt = DateTimeOffset.Now {
}))); Expired = expired,
PlayedAt = DateTimeOffset.Now
})).WaitSafely();
});
/// <summary> /// <summary>
/// Asserts the position of a given playlist item in the queue list. /// Asserts the position of a given playlist item in the queue list.

View File

@ -5,9 +5,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API; using osu.Game.Online.API;
@ -29,6 +31,14 @@ namespace osu.Game.Tests.Visual.Online
private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType<BeatmapListingSearchControl>().Single(); private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType<BeatmapListingSearchControl>().Single();
private OsuConfigManager localConfig;
[BackgroundDependencyLoader]
private void load()
{
Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage));
}
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public void SetUpSteps()
{ {
@ -61,6 +71,8 @@ namespace osu.Game.Tests.Visual.Online
Id = API.LocalUser.Value.Id + 1, Id = API.LocalUser.Value.Id + 1,
}; };
}); });
AddStep("reset size", () => localConfig.SetValue(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal));
} }
[Test] [Test]
@ -121,23 +133,23 @@ namespace osu.Game.Tests.Visual.Online
} }
[Test] [Test]
public void TestCardSizeSwitching() public void TestCardSizeSwitching([Values] bool viaConfig)
{ {
AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); AddAssert("is visible", () => overlay.State.Value == Visibility.Visible);
AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray()));
assertAllCardsOfType<BeatmapCardNormal>(100); assertAllCardsOfType<BeatmapCardNormal>(100);
setCardSize(BeatmapCardSize.Extra); setCardSize(BeatmapCardSize.Extra, viaConfig);
assertAllCardsOfType<BeatmapCardExtra>(100); assertAllCardsOfType<BeatmapCardExtra>(100);
setCardSize(BeatmapCardSize.Normal); setCardSize(BeatmapCardSize.Normal, viaConfig);
assertAllCardsOfType<BeatmapCardNormal>(100); assertAllCardsOfType<BeatmapCardNormal>(100);
AddStep("fetch for 0 beatmaps", () => fetchFor()); AddStep("fetch for 0 beatmaps", () => fetchFor());
AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true); AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
setCardSize(BeatmapCardSize.Extra); setCardSize(BeatmapCardSize.Extra, viaConfig);
AddAssert("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true); AddAssert("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
} }
@ -361,7 +373,13 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("\"no maps found\" placeholder not shown", () => !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any(d => d.IsPresent)); AddUntilStep("\"no maps found\" placeholder not shown", () => !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any(d => d.IsPresent));
} }
private void setCardSize(BeatmapCardSize cardSize) => AddStep($"set card size to {cardSize}", () => overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize); private void setCardSize(BeatmapCardSize cardSize, bool viaConfig) => AddStep($"set card size to {cardSize}", () =>
{
if (viaConfig)
localConfig.SetValue(OsuSetting.BeatmapListingCardSize, cardSize);
else
overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize;
});
private void assertAllCardsOfType<T>(int expectedCount) private void assertAllCardsOfType<T>(int expectedCount)
where T : BeatmapCard => where T : BeatmapCard =>
@ -370,5 +388,11 @@ namespace osu.Game.Tests.Visual.Online
int loadedCorrectCount = this.ChildrenOfType<BeatmapCard>().Count(card => card.IsLoaded && card.GetType() == typeof(T)); int loadedCorrectCount = this.ChildrenOfType<BeatmapCard>().Count(card => card.IsLoaded && card.GetType() == typeof(T));
return loadedCorrectCount > 0 && loadedCorrectCount == expectedCount; return loadedCorrectCount > 0 && loadedCorrectCount == expectedCount;
}); });
protected override void Dispose(bool isDisposing)
{
localConfig?.Dispose();
base.Dispose(isDisposing);
}
} }
} }

View File

@ -71,7 +71,9 @@ namespace osu.Game.Tests.Visual.Online
{ {
Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(), Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(),
Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(), Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(),
} },
PassCount = RNG.Next(0, 999),
PlayCount = RNG.Next(1000, 1999),
}; };
} }

View File

@ -0,0 +1,122 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Overlays;
using osu.Game.Overlays.Chat;
namespace osu.Game.Tests.Visual.Online
{
[TestFixture]
public class TestSceneChatTextBox : OsuTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink);
[Cached]
private readonly Bindable<Channel> currentChannel = new Bindable<Channel>();
private OsuSpriteText commitText;
private OsuSpriteText searchText;
private ChatTextBar bar;
[SetUp]
public void SetUp()
{
Schedule(() =>
{
Child = new GridContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
RowDimensions = new[]
{
new Dimension(GridSizeMode.Absolute, 30),
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(),
},
Content = new[]
{
new Drawable[]
{
commitText = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Font = OsuFont.Default.With(size: 20),
},
searchText = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Font = OsuFont.Default.With(size: 20),
},
},
},
},
},
new Drawable[]
{
bar = new ChatTextBar
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Width = 0.99f,
},
},
},
};
bar.OnChatMessageCommitted += text =>
{
commitText.Text = $"{nameof(bar.OnChatMessageCommitted)}: {text}";
commitText.FadeOutFromOne(1000, Easing.InQuint);
};
bar.OnSearchTermsChanged += text =>
{
searchText.Text = $"{nameof(bar.OnSearchTermsChanged)}: {text}";
};
});
}
[Test]
public void TestVisual()
{
AddStep("Public Channel", () => currentChannel.Value = createPublicChannel("#osu"));
AddStep("Public Channel Long Name", () => currentChannel.Value = createPublicChannel("#public-channel-long-name"));
AddStep("Private Channel", () => currentChannel.Value = createPrivateChannel("peppy", 2));
AddStep("Private Long Name", () => currentChannel.Value = createPrivateChannel("test user long name", 3));
AddStep("Chat Mode Channel", () => bar.ShowSearch.Value = false);
AddStep("Chat Mode Search", () => bar.ShowSearch.Value = true);
}
private static Channel createPublicChannel(string name)
=> new Channel { Name = name, Type = ChannelType.Public, Id = 1234 };
private static Channel createPrivateChannel(string username, int id)
=> new Channel(new APIUser { Id = id, Username = username });
}
}

View File

@ -44,9 +44,6 @@ namespace osu.Game.Tests.Visual.UserInterface
private BeatmapInfo beatmapInfo; private BeatmapInfo beatmapInfo;
[Resolved]
private RealmAccess realm { get; set; }
[Cached] [Cached]
private readonly DialogOverlay dialogOverlay; private readonly DialogOverlay dialogOverlay;
@ -92,6 +89,12 @@ namespace osu.Game.Tests.Visual.UserInterface
dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get<RulesetStore>(), () => beatmapManager, LocalStorage, Realm, Scheduler)); dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get<RulesetStore>(), () => beatmapManager, LocalStorage, Realm, Scheduler));
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
return dependencies;
}
[BackgroundDependencyLoader]
private void load() => Schedule(() =>
{
var imported = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely(); var imported = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely();
imported?.PerformRead(s => imported?.PerformRead(s =>
@ -115,26 +118,26 @@ namespace osu.Game.Tests.Visual.UserInterface
importedScores.Add(scoreManager.Import(score).Value); importedScores.Add(scoreManager.Import(score).Value);
} }
}); });
return dependencies;
}
[SetUp]
public void Setup() => Schedule(() =>
{
realm.Run(r =>
{
// Due to soft deletions, we can re-use deleted scores between test runs
scoreManager.Undelete(r.All<ScoreInfo>().Where(s => s.DeletePending).ToList());
});
leaderboard.BeatmapInfo = beatmapInfo;
leaderboard.RefetchScores(); // Required in the case that the beatmap hasn't changed
}); });
[SetUpSteps] [SetUpSteps]
public void SetupSteps() public void SetupSteps()
{ {
AddUntilStep("ensure scores imported", () => importedScores.Count == 50);
AddStep("undelete scores", () =>
{
Realm.Run(r =>
{
// Due to soft deletions, we can re-use deleted scores between test runs
scoreManager.Undelete(r.All<ScoreInfo>().Where(s => s.DeletePending).ToList());
});
});
AddStep("set up leaderboard", () =>
{
leaderboard.BeatmapInfo = beatmapInfo;
leaderboard.RefetchScores(); // Required in the case that the beatmap hasn't changed
});
// Ensure the leaderboard items have finished showing up // Ensure the leaderboard items have finished showing up
AddStep("finish transforms", () => leaderboard.FinishTransforms(true)); AddStep("finish transforms", () => leaderboard.FinishTransforms(true));
AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType<LeaderboardScore>().Any()); AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType<LeaderboardScore>().Any());
@ -169,11 +172,14 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("click delete button", () => AddStep("click delete button", () =>
{ {
InputManager.MoveMouseTo(dialogOverlay.ChildrenOfType<DialogButton>().First()); InputManager.MoveMouseTo(dialogOverlay.ChildrenOfType<DialogButton>().First());
InputManager.Click(MouseButton.Left); InputManager.PressButton(MouseButton.Left);
}); });
AddUntilStep("wait for fetch", () => leaderboard.Scores != null); AddUntilStep("wait for fetch", () => leaderboard.Scores != null);
AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID)); AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID));
// "Clean up"
AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left));
} }
[Test] [Test]

View File

@ -0,0 +1,108 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osuTK.Input;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public class TestSceneShearedToggleButton : OsuManualInputManagerTestScene
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
[Test]
public void TestShearedToggleButton()
{
ShearedToggleButton button = null;
AddStep("create button", () =>
{
Child = button = new ShearedToggleButton(200)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Toggle me",
};
});
AddToggleStep("toggle button", active => button.Active.Value = active);
AddToggleStep("toggle disabled", disabled => button.Active.Disabled = disabled);
}
[Test]
public void TestSizing()
{
ShearedToggleButton toggleButton = null;
AddStep("create fixed width button", () => Child = toggleButton = new ShearedToggleButton(200)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Fixed width"
});
AddStep("change text", () => toggleButton.Text = "New text");
AddStep("create auto-sizing button", () => Child = toggleButton = new ShearedToggleButton
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "This button autosizes to its text!"
});
AddStep("change text", () => toggleButton.Text = "New text");
}
[Test]
public void TestDisabledState()
{
ShearedToggleButton button = null;
AddStep("create button", () =>
{
Child = button = new ShearedToggleButton(200)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Toggle me",
};
});
clickToggle();
assertToggleState(true);
clickToggle();
assertToggleState(false);
setToggleDisabledState(true);
assertToggleState(false);
clickToggle();
assertToggleState(false);
setToggleDisabledState(false);
assertToggleState(false);
clickToggle();
assertToggleState(true);
setToggleDisabledState(true);
assertToggleState(true);
clickToggle();
assertToggleState(true);
void clickToggle() => AddStep("click toggle", () =>
{
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
void assertToggleState(bool active) => AddAssert($"toggle is {(active ? "" : "not ")}active", () => button.Active.Value == active);
void setToggleDisabledState(bool disabled) => AddStep($"{(disabled ? "disable" : "enable")} toggle", () => button.Active.Disabled = disabled);
}
}
}

View File

@ -7,8 +7,10 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Tournament.Components; using osu.Game.Tournament.Components;
using osu.Game.Tournament.Screens; using osu.Game.Tournament.Screens;
using osu.Game.Tournament.Screens.Drawings; using osu.Game.Tournament.Screens.Drawings;
@ -23,6 +25,7 @@ using osu.Game.Tournament.Screens.TeamIntro;
using osu.Game.Tournament.Screens.TeamWin; using osu.Game.Tournament.Screens.TeamWin;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Tournament namespace osu.Game.Tournament
{ {
@ -123,16 +126,16 @@ namespace osu.Game.Tournament
new ScreenButton(typeof(RoundEditorScreen)) { Text = "Rounds Editor", RequestSelection = SetScreen }, new ScreenButton(typeof(RoundEditorScreen)) { Text = "Rounds Editor", RequestSelection = SetScreen },
new ScreenButton(typeof(LadderEditorScreen)) { Text = "Bracket Editor", RequestSelection = SetScreen }, new ScreenButton(typeof(LadderEditorScreen)) { Text = "Bracket Editor", RequestSelection = SetScreen },
new Separator(), new Separator(),
new ScreenButton(typeof(ScheduleScreen)) { Text = "Schedule", RequestSelection = SetScreen }, new ScreenButton(typeof(ScheduleScreen), Key.S) { Text = "Schedule", RequestSelection = SetScreen },
new ScreenButton(typeof(LadderScreen)) { Text = "Bracket", RequestSelection = SetScreen }, new ScreenButton(typeof(LadderScreen), Key.B) { Text = "Bracket", RequestSelection = SetScreen },
new Separator(), new Separator(),
new ScreenButton(typeof(TeamIntroScreen)) { Text = "Team Intro", RequestSelection = SetScreen }, new ScreenButton(typeof(TeamIntroScreen), Key.I) { Text = "Team Intro", RequestSelection = SetScreen },
new ScreenButton(typeof(SeedingScreen)) { Text = "Seeding", RequestSelection = SetScreen }, new ScreenButton(typeof(SeedingScreen), Key.D) { Text = "Seeding", RequestSelection = SetScreen },
new Separator(), new Separator(),
new ScreenButton(typeof(MapPoolScreen)) { Text = "Map Pool", RequestSelection = SetScreen }, new ScreenButton(typeof(MapPoolScreen), Key.M) { Text = "Map Pool", RequestSelection = SetScreen },
new ScreenButton(typeof(GameplayScreen)) { Text = "Gameplay", RequestSelection = SetScreen }, new ScreenButton(typeof(GameplayScreen), Key.G) { Text = "Gameplay", RequestSelection = SetScreen },
new Separator(), new Separator(),
new ScreenButton(typeof(TeamWinScreen)) { Text = "Win", RequestSelection = SetScreen }, new ScreenButton(typeof(TeamWinScreen), Key.W) { Text = "Win", RequestSelection = SetScreen },
new Separator(), new Separator(),
new ScreenButton(typeof(DrawingsScreen)) { Text = "Drawings", RequestSelection = SetScreen }, new ScreenButton(typeof(DrawingsScreen)) { Text = "Drawings", RequestSelection = SetScreen },
new ScreenButton(typeof(ShowcaseScreen)) { Text = "Showcase", RequestSelection = SetScreen }, new ScreenButton(typeof(ShowcaseScreen)) { Text = "Showcase", RequestSelection = SetScreen },
@ -231,13 +234,60 @@ namespace osu.Game.Tournament
{ {
public readonly Type Type; public readonly Type Type;
public ScreenButton(Type type) private readonly Key? shortcutKey;
public ScreenButton(Type type, Key? shortcutKey = null)
{ {
this.shortcutKey = shortcutKey;
Type = type; Type = type;
BackgroundColour = OsuColour.Gray(0.2f); BackgroundColour = OsuColour.Gray(0.2f);
Action = () => RequestSelection?.Invoke(type); Action = () => RequestSelection?.Invoke(type);
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
if (shortcutKey != null)
{
Add(new Container
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(24),
Margin = new MarginPadding(5),
Masking = true,
CornerRadius = 4,
Alpha = 0.5f,
Blending = BlendingParameters.Additive,
Children = new Drawable[]
{
new Box
{
Colour = OsuColour.Gray(0.1f),
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Font = OsuFont.Default.With(size: 24),
Y = -2,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = shortcutKey.ToString(),
}
}
});
}
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Key == shortcutKey)
{
TriggerClick();
return true;
}
return base.OnKeyDown(e);
} }
private bool isSelected; private bool isSelected;

View File

@ -5,6 +5,7 @@ using System.Collections.Generic;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing; using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Beatmaps namespace osu.Game.Beatmaps
{ {
@ -70,4 +71,27 @@ namespace osu.Game.Beatmaps
/// </summary> /// </summary>
new IReadOnlyList<T> HitObjects { get; } new IReadOnlyList<T> HitObjects { get; }
} }
public static class BeatmapExtensions
{
/// <summary>
/// Finds the maximum achievable combo by hitting all <see cref="HitObject"/>s in a beatmap.
/// </summary>
public static int GetMaxCombo(this IBeatmap beatmap)
{
int combo = 0;
foreach (var h in beatmap.HitObjects)
addCombo(h, ref combo);
return combo;
static void addCombo(HitObject hitObject, ref int combo)
{
if (hitObject.CreateJudgement().MaxResult.AffectsCombo())
combo++;
foreach (var nested in hitObject.NestedHitObjects)
addCombo(nested, ref combo);
}
}
}
} }

View File

@ -10,6 +10,7 @@ using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Input; using osu.Game.Input;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using osu.Game.Localisation; using osu.Game.Localisation;
@ -44,6 +45,8 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f);
SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal);
SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full); SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full);
// Online settings // Online settings
@ -297,6 +300,7 @@ namespace osu.Game.Configuration
RandomSelectAlgorithm, RandomSelectAlgorithm,
ShowFpsDisplay, ShowFpsDisplay,
ChatDisplayHeight, ChatDisplayHeight,
BeatmapListingCardSize,
ToolbarClockDisplayMode, ToolbarClockDisplayMode,
Version, Version,
ShowConvertedBeatmaps, ShowConvertedBeatmaps,

View File

@ -88,6 +88,7 @@ namespace osu.Game.Configuration
throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})"); throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})");
var control = (Drawable)Activator.CreateInstance(controlType); var control = (Drawable)Activator.CreateInstance(controlType);
controlType.GetProperty(nameof(SettingsItem<object>.SettingSourceObject))?.SetValue(control, obj);
controlType.GetProperty(nameof(SettingsItem<object>.LabelText))?.SetValue(control, attr.Label); controlType.GetProperty(nameof(SettingsItem<object>.LabelText))?.SetValue(control, attr.Label);
controlType.GetProperty(nameof(SettingsItem<object>.TooltipText))?.SetValue(control, attr.Description); controlType.GetProperty(nameof(SettingsItem<object>.TooltipText))?.SetValue(control, attr.Description);
controlType.GetProperty(nameof(SettingsItem<object>.Current))?.SetValue(control, value); controlType.GetProperty(nameof(SettingsItem<object>.Current))?.SetValue(control, value);

View File

@ -0,0 +1,177 @@
// 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.
#nullable enable
using System;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Graphics.UserInterface
{
public class ShearedToggleButton : OsuClickableContainer
{
public BindableBool Active { get; } = new BindableBool();
public LocalisableString Text
{
get => text.Text;
set => text.Text = value;
}
private readonly Box background;
private readonly OsuSpriteText text;
private Sample? sampleOff;
private Sample? sampleOn;
private const float shear = 0.2f;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
/// <summary>
/// Creates a new <see cref="ShearedToggleButton"/>
/// </summary>
/// <param name="width">
/// The width of the button.
/// <list type="bullet">
/// <item>If a non-<see langword="null"/> value is provided, this button will have a fixed width equal to the provided value.</item>
/// <item>If a <see langword="null"/> value is provided (or the argument is omitted entirely), the button will autosize in width to fit the text.</item>
/// </list>
/// </param>
public ShearedToggleButton(float? width = null)
{
Height = 50;
Padding = new MarginPadding { Horizontal = shear * 50 };
Content.CornerRadius = 7;
Content.Shear = new Vector2(shear, 0);
Content.Masking = true;
Content.BorderThickness = 2;
Content.Anchor = Content.Origin = Anchor.Centre;
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both
},
text = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.TorusAlternate.With(size: 17),
Shear = new Vector2(-shear, 0)
}
};
if (width != null)
{
Width = width.Value;
}
else
{
AutoSizeAxes = Axes.X;
text.Margin = new MarginPadding { Horizontal = 15 };
}
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sampleOn = audio.Samples.Get(@"UI/check-on");
sampleOff = audio.Samples.Get(@"UI/check-off");
}
protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet);
protected override void LoadComplete()
{
base.LoadComplete();
Active.BindValueChanged(_ =>
{
updateState();
playSample();
});
Active.BindDisabledChanged(disabled =>
{
updateState();
Action = disabled ? (Action?)null : Active.Toggle;
}, true);
FinishTransforms(true);
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
base.OnHoverLost(e);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
Content.ScaleTo(0.8f, 2000, Easing.OutQuint);
return base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseUpEvent e)
{
Content.ScaleTo(1, 1000, Easing.OutElastic);
base.OnMouseUp(e);
}
private void updateState()
{
var darkerColour = Active.Value ? colourProvider.Highlight1 : colourProvider.Background3;
var lighterColour = Active.Value ? colourProvider.Colour0 : colourProvider.Background1;
if (Active.Disabled)
{
darkerColour = darkerColour.Darken(0.3f);
lighterColour = lighterColour.Darken(0.3f);
}
else if (IsHovered)
{
darkerColour = darkerColour.Lighten(0.3f);
lighterColour = lighterColour.Lighten(0.3f);
}
background.FadeColour(darkerColour, 150, Easing.OutQuint);
Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(darkerColour, lighterColour), 150, Easing.OutQuint);
var textColour = Active.Value ? colourProvider.Background6 : colourProvider.Content1;
if (Active.Disabled)
textColour = textColour.Opacity(0.6f);
text.FadeColour(textColour, 150, Easing.OutQuint);
}
private void playSample()
{
if (Active.Value)
sampleOn?.Play();
else
sampleOff?.Play();
}
}
}

View File

@ -11,6 +11,7 @@ using osu.Framework.Bindables;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Utils;
namespace osu.Game.Online.Rooms namespace osu.Game.Online.Rooms
{ {
@ -84,6 +85,19 @@ namespace osu.Game.Online.Rooms
Beatmap = beatmap; Beatmap = beatmap;
} }
public PlaylistItem(MultiplayerPlaylistItem item)
: this(new APIBeatmap { OnlineID = item.BeatmapID })
{
ID = item.ID;
OwnerID = item.OwnerID;
RulesetID = item.RulesetID;
Expired = item.Expired;
PlaylistOrder = item.PlaylistOrder;
PlayedAt = item.PlayedAt;
RequiredMods = item.RequiredMods.ToArray();
AllowedMods = item.AllowedMods.ToArray();
}
public void MarkInvalid() => valid.Value = false; public void MarkInvalid() => valid.Value = false;
#region Newtonsoft.Json implicit ShouldSerialize() methods #region Newtonsoft.Json implicit ShouldSerialize() methods
@ -101,13 +115,13 @@ namespace osu.Game.Online.Rooms
#endregion #endregion
public PlaylistItem With(IBeatmapInfo beatmap) => new PlaylistItem(beatmap) public PlaylistItem With(Optional<IBeatmapInfo> beatmap = default, Optional<ushort?> playlistOrder = default) => new PlaylistItem(beatmap.GetOr(Beatmap))
{ {
ID = ID, ID = ID,
OwnerID = OwnerID, OwnerID = OwnerID,
RulesetID = RulesetID, RulesetID = RulesetID,
Expired = Expired, Expired = Expired,
PlaylistOrder = PlaylistOrder, PlaylistOrder = playlistOrder.GetOr(PlaylistOrder),
PlayedAt = PlayedAt, PlayedAt = PlayedAt,
AllowedMods = AllowedMods, AllowedMods = AllowedMods,
RequiredMods = RequiredMods, RequiredMods = RequiredMods,
@ -119,6 +133,7 @@ namespace osu.Game.Online.Rooms
&& Beatmap.OnlineID == other.Beatmap.OnlineID && Beatmap.OnlineID == other.Beatmap.OnlineID
&& RulesetID == other.RulesetID && RulesetID == other.RulesetID
&& Expired == other.Expired && Expired == other.Expired
&& PlaylistOrder == other.PlaylistOrder
&& AllowedMods.SequenceEqual(other.AllowedMods) && AllowedMods.SequenceEqual(other.AllowedMods)
&& RequiredMods.SequenceEqual(other.RequiredMods); && RequiredMods.SequenceEqual(other.RequiredMods);
} }

View File

@ -1061,6 +1061,12 @@ namespace osu.Game
return true; return true;
case GlobalAction.RandomSkin: case GlobalAction.RandomSkin:
// Don't allow random skin selection while in the skin editor.
// This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path.
// If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow.
if (skinEditor.State.Value == Visibility.Visible)
return false;
SkinManager.SelectRandomSkin(); SkinManager.SelectRandomSkin();
return true; return true;
} }

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Configuration;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
@ -53,7 +54,9 @@ namespace osu.Game.Overlays.BeatmapListing
/// <summary> /// <summary>
/// The currently selected <see cref="BeatmapCardSize"/>. /// The currently selected <see cref="BeatmapCardSize"/>.
/// </summary> /// </summary>
public IBindable<BeatmapCardSize> CardSize { get; } = new Bindable<BeatmapCardSize>(); public IBindable<BeatmapCardSize> CardSize => cardSize;
private readonly Bindable<BeatmapCardSize> cardSize = new Bindable<BeatmapCardSize>();
private readonly BeatmapListingSearchControl searchControl; private readonly BeatmapListingSearchControl searchControl;
private readonly BeatmapListingSortTabControl sortControl; private readonly BeatmapListingSortTabControl sortControl;
@ -128,6 +131,9 @@ namespace osu.Game.Overlays.BeatmapListing
}; };
} }
[Resolved]
private OsuConfigManager config { get; set; }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, IAPIProvider api) private void load(OverlayColourProvider colourProvider, IAPIProvider api)
{ {
@ -141,6 +147,8 @@ namespace osu.Game.Overlays.BeatmapListing
{ {
base.LoadComplete(); base.LoadComplete();
config.BindWith(OsuSetting.BeatmapListingCardSize, cardSize);
var sortCriteria = sortControl.Current; var sortCriteria = sortControl.Current;
var sortDirection = sortControl.SortDirection; var sortDirection = sortControl.SortDirection;

View File

@ -5,6 +5,8 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -19,7 +21,7 @@ namespace osu.Game.Overlays.BeatmapSet
protected readonly FailRetryGraph Graph; protected readonly FailRetryGraph Graph;
private readonly FillFlowContainer header; private readonly FillFlowContainer header;
private readonly OsuSpriteText successPercent; private readonly SuccessRatePercentage successPercent;
private readonly Bar successRate; private readonly Bar successRate;
private readonly Container percentContainer; private readonly Container percentContainer;
@ -45,6 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet
float rate = playCount != 0 ? (float)passCount / playCount : 0; float rate = playCount != 0 ? (float)passCount / playCount : 0;
successPercent.Text = rate.ToLocalisableString(@"0.#%"); successPercent.Text = rate.ToLocalisableString(@"0.#%");
successPercent.TooltipText = $"{passCount} / {playCount}";
successRate.Length = rate; successRate.Length = rate;
percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic); percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic);
@ -80,7 +83,7 @@ namespace osu.Game.Overlays.BeatmapSet
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
Width = 0f, Width = 0f,
Child = successPercent = new OsuSpriteText Child = successPercent = new SuccessRatePercentage
{ {
Anchor = Anchor.TopRight, Anchor = Anchor.TopRight,
Origin = Anchor.TopCentre, Origin = Anchor.TopCentre,
@ -121,5 +124,10 @@ namespace osu.Game.Overlays.BeatmapSet
Graph.Padding = new MarginPadding { Top = header.DrawHeight }; Graph.Padding = new MarginPadding { Top = header.DrawHeight };
} }
private class SuccessRatePercentage : OsuSpriteText, IHasTooltip
{
public LocalisableString TooltipText { get; set; }
}
} }
} }

View File

@ -0,0 +1,163 @@
// 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.
#nullable enable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.Containers;
using osu.Game.Online.Chat;
using osuTK;
namespace osu.Game.Overlays.Chat
{
public class ChatTextBar : Container
{
public readonly BindableBool ShowSearch = new BindableBool();
public event Action<string>? OnChatMessageCommitted;
public event Action<string>? OnSearchTermsChanged;
[Resolved]
private Bindable<Channel> currentChannel { get; set; } = null!;
private OsuTextFlowContainer chattingTextContainer = null!;
private Container searchIconContainer = null!;
private ChatTextBox chatTextBox = null!;
private const float chatting_text_width = 180;
private const float search_icon_width = 40;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
RelativeSizeAxes = Axes.X;
Height = 60;
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5,
},
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(),
},
Content = new[]
{
new Drawable[]
{
chattingTextContainer = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 20))
{
Masking = true,
Width = chatting_text_width,
Padding = new MarginPadding { Left = 10 },
RelativeSizeAxes = Axes.Y,
TextAnchor = Anchor.CentreRight,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Colour = colourProvider.Background1,
},
searchIconContainer = new Container
{
RelativeSizeAxes = Axes.Y,
Width = search_icon_width,
Child = new SpriteIcon
{
Icon = FontAwesome.Solid.Search,
Origin = Anchor.CentreRight,
Anchor = Anchor.CentreRight,
Size = new Vector2(20),
Margin = new MarginPadding { Right = 2 },
},
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = 5 },
Child = chatTextBox = new ChatTextBox
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.X,
ShowSearch = { BindTarget = ShowSearch },
HoldFocus = true,
ReleaseFocusOnCommit = false,
},
},
},
},
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
chatTextBox.Current.ValueChanged += chatTextBoxChange;
chatTextBox.OnCommit += chatTextBoxCommit;
ShowSearch.BindValueChanged(change =>
{
bool showSearch = change.NewValue;
chattingTextContainer.FadeTo(showSearch ? 0 : 1);
searchIconContainer.FadeTo(showSearch ? 1 : 0);
// Clear search terms if any exist when switching back to chat mode
if (!showSearch)
OnSearchTermsChanged?.Invoke(string.Empty);
}, true);
currentChannel.BindValueChanged(change =>
{
Channel newChannel = change.NewValue;
switch (newChannel?.Type)
{
case ChannelType.Public:
chattingTextContainer.Text = $"chatting in {newChannel.Name}";
break;
case ChannelType.PM:
chattingTextContainer.Text = $"chatting with {newChannel.Name}";
break;
default:
chattingTextContainer.Text = string.Empty;
break;
}
}, true);
}
private void chatTextBoxChange(ValueChangedEvent<string> change)
{
if (ShowSearch.Value)
OnSearchTermsChanged?.Invoke(change.NewValue);
}
private void chatTextBoxCommit(TextBox sender, bool newText)
{
if (ShowSearch.Value)
return;
OnChatMessageCommitted?.Invoke(sender.Text);
sender.Text = string.Empty;
}
}
}

View File

@ -0,0 +1,38 @@
// 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.
#nullable enable
using osu.Framework.Bindables;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Chat
{
public class ChatTextBox : FocusedTextBox
{
public readonly BindableBool ShowSearch = new BindableBool();
public override bool HandleLeftRightArrows => !ShowSearch.Value;
protected override void LoadComplete()
{
base.LoadComplete();
ShowSearch.BindValueChanged(change =>
{
bool showSearch = change.NewValue;
PlaceholderText = showSearch ? "type here to search" : "type here";
Text = string.Empty;
}, true);
}
protected override void Commit()
{
if (ShowSearch.Value)
return;
base.Commit();
}
}
}

View File

@ -17,7 +17,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
HeaderText = @"Confirm deletion of"; HeaderText = @"Confirm deletion of";
Buttons = new PopupDialogButton[] Buttons = new PopupDialogButton[]
{ {
new PopupDialogOkButton new PopupDialogDangerousButton
{ {
Text = @"Yes. Go for it.", Text = @"Yes. Go for it.",
Action = deleteAction Action = deleteAction

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
@ -24,6 +25,11 @@ namespace osu.Game.Overlays.Settings
protected Drawable Control { get; } protected Drawable Control { get; }
/// <summary>
/// The source component if this <see cref="SettingsItem{T}"/> was created via <see cref="SettingSourceAttribute"/>.
/// </summary>
public object SettingSourceObject { get; internal set; }
private IHasCurrentValue<T> controlWithCurrent => Control as IHasCurrentValue<T>; private IHasCurrentValue<T> controlWithCurrent => Control as IHasCurrentValue<T>;
protected override Container<Drawable> Content => FlowContent; protected override Container<Drawable> Content => FlowContent;

View File

@ -6,7 +6,6 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Overlays.Toolbar namespace osu.Game.Overlays.Toolbar
{ {
@ -42,7 +41,7 @@ namespace osu.Game.Overlays.Toolbar
{ {
Y = 14, Y = 14,
Colour = colours.PinkLight, Colour = colours.PinkLight,
Scale = new Vector2(0.6f) Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold),
} }
}; };

View File

@ -190,7 +190,7 @@ namespace osu.Game.Overlays.Toolbar
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e) public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{ {
if (e.Action == Hotkey) if (e.Action == Hotkey && !e.Repeat)
{ {
TriggerClick(); TriggerClick();
return true; return true;

View File

@ -3,27 +3,35 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osuTK; using osuTK;
using osuTK.Graphics;
namespace osu.Game.Overlays.Toolbar namespace osu.Game.Overlays.Toolbar
{ {
public class ToolbarClock : CompositeDrawable public class ToolbarClock : OsuClickableContainer
{ {
private Bindable<ToolbarClockDisplayMode> clockDisplayMode; private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
private Box hoverBackground;
private Box flashBackground;
private DigitalClockDisplay digital; private DigitalClockDisplay digital;
private AnalogClockDisplay analog; private AnalogClockDisplay analog;
public ToolbarClock() public ToolbarClock()
: base(HoverSampleSet.Toolbar)
{ {
RelativeSizeAxes = Axes.Y; RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X; AutoSizeAxes = Axes.X;
Padding = new MarginPadding(10);
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -31,23 +39,41 @@ namespace osu.Game.Overlays.Toolbar
{ {
clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode); clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
InternalChild = new FillFlowContainer Children = new Drawable[]
{ {
RelativeSizeAxes = Axes.Y, hoverBackground = new Box
AutoSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Children = new Drawable[]
{ {
analog = new AnalogClockDisplay RelativeSizeAxes = Axes.Both,
Colour = OsuColour.Gray(80).Opacity(180),
Blending = BlendingParameters.Additive,
Alpha = 0,
},
flashBackground = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Colour = Color4.White.Opacity(100),
Blending = BlendingParameters.Additive,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Padding = new MarginPadding(10),
Children = new Drawable[]
{ {
Anchor = Anchor.CentreLeft, analog = new AnalogClockDisplay
Origin = Anchor.CentreLeft, {
}, Anchor = Anchor.CentreLeft,
digital = new DigitalClockDisplay Origin = Anchor.CentreLeft,
{ },
Anchor = Anchor.CentreLeft, digital = new DigitalClockDisplay
Origin = Anchor.CentreLeft, {
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
} }
} }
}; };
@ -72,8 +98,25 @@ namespace osu.Game.Overlays.Toolbar
protected override bool OnClick(ClickEvent e) protected override bool OnClick(ClickEvent e)
{ {
flashBackground.FadeOutFromOne(800, Easing.OutQuint);
cycleDisplayMode(); cycleDisplayMode();
return true;
return base.OnClick(e);
}
protected override bool OnHover(HoverEvent e)
{
hoverBackground.FadeIn(200);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
hoverBackground.FadeOut(200);
base.OnHoverLost(e);
} }
private void cycleDisplayMode() private void cycleDisplayMode()

View File

@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Difficulty namespace osu.Game.Rulesets.Difficulty
{ {
@ -119,15 +120,23 @@ namespace osu.Game.Rulesets.Difficulty
/// <summary> /// <summary>
/// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap. /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
/// </summary> /// </summary>
/// <remarks>
/// This can only be used to compute difficulties for legacy mod combinations.
/// </remarks>
/// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns> /// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default) public IEnumerable<DifficultyAttributes> CalculateAllLegacyCombinations(CancellationToken cancellationToken = default)
{ {
var rulesetInstance = ruleset.CreateInstance();
foreach (var combination in CreateDifficultyAdjustmentModCombinations()) foreach (var combination in CreateDifficultyAdjustmentModCombinations())
{ {
if (combination is MultiMod multi) Mod classicMod = rulesetInstance.CreateAllMods().SingleOrDefault(m => m is ModClassic);
yield return Calculate(multi.Mods, cancellationToken);
else var finalCombination = ModUtils.FlattenMod(combination);
yield return Calculate(combination.Yield(), cancellationToken); if (classicMod != null)
finalCombination = finalCombination.Append(classicMod);
yield return Calculate(finalCombination.ToArray(), cancellationToken);
} }
} }

View File

@ -1,19 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays.Settings;
namespace osu.Game.Screens.Edit.Timing namespace osu.Game.Screens.Edit.Timing
{ {
internal class TimingSection : Section<TimingControlPoint> internal class TimingSection : Section<TimingControlPoint>
{ {
private SettingsSlider<double> bpmSlider;
private LabelledTimeSignature timeSignature; private LabelledTimeSignature timeSignature;
private BPMTextBox bpmTextEntry; private BPMTextBox bpmTextEntry;
@ -23,7 +20,6 @@ namespace osu.Game.Screens.Edit.Timing
Flow.AddRange(new Drawable[] Flow.AddRange(new Drawable[]
{ {
bpmTextEntry = new BPMTextBox(), bpmTextEntry = new BPMTextBox(),
bpmSlider = new BPMSlider(),
timeSignature = new LabelledTimeSignature timeSignature = new LabelledTimeSignature
{ {
Label = "Time Signature" Label = "Time Signature"
@ -35,11 +31,8 @@ namespace osu.Game.Screens.Edit.Timing
{ {
if (point.NewValue != null) if (point.NewValue != null)
{ {
bpmSlider.Current = point.NewValue.BeatLengthBindable;
bpmSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable;
// no need to hook change handler here as it's the same bindable as above bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
timeSignature.Current = point.NewValue.TimeSignatureBindable; timeSignature.Current = point.NewValue.TimeSignatureBindable;
timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
@ -102,51 +95,6 @@ namespace osu.Game.Screens.Edit.Timing
} }
} }
private class BPMSlider : SettingsSlider<double>
{
private const double sane_minimum = 60;
private const double sane_maximum = 240;
private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable;
private readonly BindableDouble bpmBindable = new BindableDouble(60000 / TimingControlPoint.DEFAULT_BEAT_LENGTH)
{
MinValue = sane_minimum,
MaxValue = sane_maximum,
};
public BPMSlider()
{
beatLengthBindable.BindValueChanged(beatLength => updateCurrent(beatLengthToBpm(beatLength.NewValue)), true);
bpmBindable.BindValueChanged(bpm => beatLengthBindable.Value = beatLengthToBpm(bpm.NewValue));
base.Current = bpmBindable;
TransferValueOnCommit = true;
}
public override Bindable<double> Current
{
get => base.Current;
set
{
// incoming will be beat length, not bpm
beatLengthBindable.UnbindBindings();
beatLengthBindable.BindTo(value);
}
}
private void updateCurrent(double newValue)
{
// we use a more sane range for the slider display unless overridden by the user.
// if a value comes in outside our range, we should expand temporarily.
bpmBindable.MinValue = Math.Min(newValue, sane_minimum);
bpmBindable.MaxValue = Math.Max(newValue, sane_maximum);
bpmBindable.Value = newValue;
}
}
private static double beatLengthToBpm(double beatLength) => 60000 / beatLength; private static double beatLengthToBpm(double beatLength) => 60000 / beatLength;
} }
} }

View File

@ -5,6 +5,8 @@ using System;
using System.Linq; using System.Linq;
using JetBrains.Annotations; using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Graphics; using osu.Game.Graphics;
@ -27,6 +29,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
[CanBeNull] [CanBeNull]
private MultiplayerRoom room => multiplayerClient.Room; private MultiplayerRoom room => multiplayerClient.Room;
private Sample countdownTickSample;
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
countdownTickSample = audio.Samples.Get(@"Multiplayer/countdown-tick");
// disabled for now pending further work on sound effect
// countdownTickFinalSample = audio.Samples.Get(@"Multiplayer/countdown-tick-final");
}
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
@ -36,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
} }
private MultiplayerCountdown countdown; private MultiplayerCountdown countdown;
private DateTimeOffset countdownChangeTime; private double countdownChangeTime;
private ScheduledDelegate countdownUpdateDelegate; private ScheduledDelegate countdownUpdateDelegate;
private void onRoomUpdated() => Scheduler.AddOnce(() => private void onRoomUpdated() => Scheduler.AddOnce(() =>
@ -44,20 +56,55 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
if (countdown != room?.Countdown) if (countdown != room?.Countdown)
{ {
countdown = room?.Countdown; countdown = room?.Countdown;
countdownChangeTime = DateTimeOffset.Now; countdownChangeTime = Time.Current;
} }
scheduleNextCountdownUpdate();
updateButtonText();
updateButtonColour();
});
private void scheduleNextCountdownUpdate()
{
countdownUpdateDelegate?.Cancel();
if (countdown != null) if (countdown != null)
countdownUpdateDelegate ??= Scheduler.AddDelayed(updateButtonText, 100, true); {
// The remaining time on a countdown may be at a fractional portion between two seconds.
// We want to align certain audio/visual cues to the point at which integer seconds change.
// To do so, we schedule to the next whole second. Note that scheduler invocation isn't
// guaranteed to be accurate, so this may still occur slightly late, but even in such a case
// the next invocation will be roughly correct.
double timeToNextSecond = countdownTimeRemaining.TotalMilliseconds % 1000;
countdownUpdateDelegate = Scheduler.AddDelayed(onCountdownTick, timeToNextSecond);
}
else else
{ {
countdownUpdateDelegate?.Cancel(); countdownUpdateDelegate?.Cancel();
countdownUpdateDelegate = null; countdownUpdateDelegate = null;
} }
updateButtonText(); void onCountdownTick()
updateButtonColour(); {
}); updateButtonText();
int secondsRemaining = countdownTimeRemaining.Seconds;
playTickSound(secondsRemaining);
if (secondsRemaining > 0)
scheduleNextCountdownUpdate();
}
}
private void playTickSound(int secondsRemaining)
{
if (secondsRemaining < 10) countdownTickSample?.Play();
// disabled for now pending further work on sound effect
// if (secondsRemaining <= 3) countdownTickFinalSample?.Play();
}
private void updateButtonText() private void updateButtonText()
{ {
@ -75,15 +122,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
if (countdown != null) if (countdown != null)
{ {
TimeSpan timeElapsed = DateTimeOffset.Now - countdownChangeTime; string countdownText = $"Starting in {countdownTimeRemaining:mm\\:ss}";
TimeSpan countdownRemaining;
if (timeElapsed > countdown.TimeRemaining)
countdownRemaining = TimeSpan.Zero;
else
countdownRemaining = countdown.TimeRemaining - timeElapsed;
string countdownText = $"Starting in {countdownRemaining:mm\\:ss}";
switch (localUser?.State) switch (localUser?.State)
{ {
@ -116,6 +155,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
} }
} }
private TimeSpan countdownTimeRemaining
{
get
{
double timeElapsed = Time.Current - countdownChangeTime;
TimeSpan remaining;
if (timeElapsed > countdown.TimeRemaining.TotalMilliseconds)
remaining = TimeSpan.Zero;
else
remaining = countdown.TimeRemaining - TimeSpan.FromMilliseconds(timeElapsed);
return remaining;
}
}
private void updateButtonColour() private void updateButtonColour()
{ {
if (room == null) if (room == null)

View File

@ -117,8 +117,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
{ {
base.PlaylistItemChanged(item); base.PlaylistItemChanged(item);
removeItemFromLists(item.ID); var newApiItem = Playlist.SingleOrDefault(i => i.ID == item.ID);
addItemToLists(item); var existingApiItemInQueue = queueList.Items.SingleOrDefault(i => i.ID == item.ID);
// Test if the only change between the two playlist items is the order.
if (newApiItem != null && existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem))
{
// Set the new playlist order directly without refreshing the DrawablePlaylistItem.
existingApiItemInQueue.PlaylistOrder = newApiItem.PlaylistOrder;
// The following isn't really required, but is here for safety and explicitness.
// MultiplayerQueueList internally binds to changes in Playlist to invalidate its own layout, which is mutated on every playlist operation.
queueList.Invalidate();
}
else
{
removeItemFromLists(item.ID);
addItemToLists(item);
}
} }
private void addItemToLists(MultiplayerPlaylistItem item) private void addItemToLists(MultiplayerPlaylistItem item)

View File

@ -9,6 +9,7 @@ using Newtonsoft.Json;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Skinning; using osu.Game.Skinning;
@ -84,9 +85,17 @@ namespace osu.Game.Screens.Play.HUD
/// <returns>The new instance.</returns> /// <returns>The new instance.</returns>
public Drawable CreateInstance() public Drawable CreateInstance()
{ {
Drawable d = (Drawable)Activator.CreateInstance(Type); try
d.ApplySkinnableInfo(this); {
return d; Drawable d = (Drawable)Activator.CreateInstance(Type);
d.ApplySkinnableInfo(this);
return d;
}
catch (Exception e)
{
Logger.Error(e, $"Unable to create skin component {Type.Name}");
return Drawable.Empty();
}
} }
} }
} }

View File

@ -26,7 +26,7 @@ namespace osu.Game.Screens.Select
HeaderText = @"Confirm deletion of"; HeaderText = @"Confirm deletion of";
Buttons = new PopupDialogButton[] Buttons = new PopupDialogButton[]
{ {
new PopupDialogOkButton new PopupDialogDangerousButton
{ {
Text = @"Yes. Totally. Delete it.", Text = @"Yes. Totally. Delete it.",
Action = () => manager?.Delete(beatmap), Action = () => manager?.Delete(beatmap),

View File

@ -174,7 +174,7 @@ namespace osu.Game.Screens.Select
public virtual bool OnPressed(KeyBindingPressEvent<GlobalAction> e) public virtual bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{ {
if (e.Action == Hotkey) if (e.Action == Hotkey && !e.Repeat)
{ {
TriggerClick(); TriggerClick();
return true; return true;

View File

@ -38,7 +38,7 @@ namespace osu.Game.Screens.Select
HeaderText = "Confirm deletion of local score"; HeaderText = "Confirm deletion of local score";
Buttons = new PopupDialogButton[] Buttons = new PopupDialogButton[]
{ {
new PopupDialogOkButton new PopupDialogDangerousButton
{ {
Text = "Yes. Please.", Text = "Yes. Please.",
Action = () => scoreManager?.Delete(score) Action = () => scoreManager?.Delete(score)

View File

@ -8,6 +8,7 @@ using osu.Framework.Audio.Sample;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Formats;
@ -46,13 +47,13 @@ namespace osu.Game.Skinning
this.resources = resources; this.resources = resources;
} }
public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT);
public override ISample GetSample(ISampleInfo sampleInfo) public override ISample GetSample(ISampleInfo sampleInfo)
{ {
foreach (string lookup in sampleInfo.LookupNames) foreach (string lookup in sampleInfo.LookupNames)
{ {
var sample = resources.AudioManager.Samples.Get(lookup); var sample = Samples?.Get(lookup) ?? resources.AudioManager.Samples.Get(lookup);
if (sample != null) if (sample != null)
return sample; return sample;
} }
@ -157,6 +158,16 @@ namespace osu.Game.Skinning
break; break;
} }
switch (component.LookupName)
{
// Temporary until default skin has a valid hit lighting.
case @"lighting":
return Drawable.Empty();
}
if (GetTexture(component.LookupName) is Texture t)
return new Sprite { Texture = t };
return null; return null;
} }

View File

@ -3,7 +3,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -11,6 +13,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Cursor;
@ -22,7 +25,7 @@ using osu.Game.Screens.Edit.Components.Menus;
namespace osu.Game.Skinning.Editor namespace osu.Game.Skinning.Editor
{ {
[Cached(typeof(SkinEditor))] [Cached(typeof(SkinEditor))]
public class SkinEditor : VisibilityContainer public class SkinEditor : VisibilityContainer, ICanAcceptFiles
{ {
public const double TRANSITION_DURATION = 500; public const double TRANSITION_DURATION = 500;
@ -36,12 +39,18 @@ namespace osu.Game.Skinning.Editor
private Bindable<Skin> currentSkin; private Bindable<Skin> currentSkin;
[Resolved(canBeNull: true)]
private OsuGame game { get; set; }
[Resolved] [Resolved]
private SkinManager skins { get; set; } private SkinManager skins { get; set; }
[Resolved] [Resolved]
private OsuColour colours { get; set; } private OsuColour colours { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private SkinEditorOverlay skinEditorOverlay { get; set; } private SkinEditorOverlay skinEditorOverlay { get; set; }
@ -171,6 +180,8 @@ namespace osu.Game.Skinning.Editor
Show(); Show();
game?.RegisterImportHandler(this);
// as long as the skin editor is loaded, let's make sure we can modify the current skin. // as long as the skin editor is loaded, let's make sure we can modify the current skin.
currentSkin = skins.CurrentSkin.GetBoundCopy(); currentSkin = skins.CurrentSkin.GetBoundCopy();
@ -229,21 +240,29 @@ namespace osu.Game.Skinning.Editor
} }
private void placeComponent(Type type) private void placeComponent(Type type)
{
if (!(Activator.CreateInstance(type) is ISkinnableDrawable component))
throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}.");
placeComponent(component);
}
private void placeComponent(ISkinnableDrawable component, bool applyDefaults = true)
{ {
var targetContainer = getFirstTarget(); var targetContainer = getFirstTarget();
if (targetContainer == null) if (targetContainer == null)
return; return;
if (!(Activator.CreateInstance(type) is ISkinnableDrawable component))
throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}.");
var drawableComponent = (Drawable)component; var drawableComponent = (Drawable)component;
// give newly added components a sane starting location. if (applyDefaults)
drawableComponent.Origin = Anchor.TopCentre; {
drawableComponent.Anchor = Anchor.TopCentre; // give newly added components a sane starting location.
drawableComponent.Y = targetContainer.DrawSize.Y / 2; drawableComponent.Origin = Anchor.TopCentre;
drawableComponent.Anchor = Anchor.TopCentre;
drawableComponent.Y = targetContainer.DrawSize.Y / 2;
}
targetContainer.Add(component); targetContainer.Add(component);
@ -313,5 +332,54 @@ namespace osu.Game.Skinning.Editor
foreach (var item in items) foreach (var item in items)
availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item); availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item);
} }
#region Drag & drop import handling
public Task Import(params string[] paths)
{
Schedule(() =>
{
var file = new FileInfo(paths.First());
// import to skin
currentSkin.Value.SkinInfo.PerformWrite(skinInfo =>
{
using (var contents = file.OpenRead())
skins.AddFile(skinInfo, contents, file.Name);
});
// Even though we are 100% on an update thread, we need to wait for realm callbacks to fire (to correctly invalidate caches in RealmBackedResourceStore).
// See https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-2483573 for further discussion.
// This is the best we can do for now.
realm.Run(r => r.Refresh());
// place component
var sprite = new SkinnableSprite
{
SpriteName = { Value = file.Name },
Origin = Anchor.Centre,
Position = getFirstTarget().ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position),
};
placeComponent(sprite, false);
SkinSelectionHandler.ApplyClosestAnchor(sprite);
});
return Task.CompletedTask;
}
public Task Import(params ImportTask[] tasks) => throw new NotImplementedException();
public IEnumerable<string> HandledExtensions => new[] { ".jpg", ".jpeg", ".png" };
#endregion
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
game?.UnregisterImportHandler(this);
}
} }
} }

View File

@ -157,13 +157,13 @@ namespace osu.Game.Skinning.Editor
if (item.UsesFixedAnchor) continue; if (item.UsesFixedAnchor) continue;
applyClosestAnchor(drawable); ApplyClosestAnchor(drawable);
} }
return true; return true;
} }
private static void applyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable)); public static void ApplyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable));
protected override void OnSelectionChanged() protected override void OnSelectionChanged()
{ {
@ -252,7 +252,7 @@ namespace osu.Game.Skinning.Editor
if (item.UsesFixedAnchor) continue; if (item.UsesFixedAnchor) continue;
applyClosestAnchor(drawable); ApplyClosestAnchor(drawable);
} }
} }
@ -279,7 +279,7 @@ namespace osu.Game.Skinning.Editor
foreach (var item in SelectedItems) foreach (var item in SelectedItems)
{ {
item.UsesFixedAnchor = false; item.UsesFixedAnchor = false;
applyClosestAnchor((Drawable)item); ApplyClosestAnchor((Drawable)item);
} }
} }

View File

@ -11,6 +11,7 @@ using osu.Framework.IO.Stores;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Formats;
using osu.Game.Database;
using osu.Game.IO; using osu.Game.IO;
using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
@ -37,11 +38,11 @@ namespace osu.Game.Skinning
private static IResourceStore<byte[]> createRealmBackedStore(BeatmapInfo beatmapInfo, IStorageResourceProvider? resources) private static IResourceStore<byte[]> createRealmBackedStore(BeatmapInfo beatmapInfo, IStorageResourceProvider? resources)
{ {
if (resources == null) if (resources == null || beatmapInfo.BeatmapSet == null)
// should only ever be used in tests. // should only ever be used in tests.
return new ResourceStore<byte[]>(); return new ResourceStore<byte[]>();
return new RealmBackedResourceStore(beatmapInfo.BeatmapSet, resources.Files, new[] { @"ogg" }); return new RealmBackedResourceStore<BeatmapSetInfo>(beatmapInfo.BeatmapSet.ToLive(resources.RealmAccess), resources.Files, resources.RealmAccess);
} }
public override Drawable? GetDrawableComponent(ISkinComponent component) public override Drawable? GetDrawableComponent(ISkinComponent component)

View File

@ -23,7 +23,7 @@ namespace osu.Game.Skinning
/// The <see cref="ISkin"/> which is being transformed. /// The <see cref="ISkin"/> which is being transformed.
/// </summary> /// </summary>
[NotNull] [NotNull]
protected ISkin Skin { get; } protected internal ISkin Skin { get; }
protected LegacySkinTransformer([NotNull] ISkin skin) protected LegacySkinTransformer([NotNull] ISkin skin)
{ {

View File

@ -1,51 +1,77 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable enable
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.IO.Stores; using osu.Framework.IO.Stores;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Extensions; using osu.Game.Extensions;
using Realms;
namespace osu.Game.Skinning namespace osu.Game.Skinning
{ {
public class RealmBackedResourceStore : ResourceStore<byte[]> public class RealmBackedResourceStore<T> : ResourceStore<byte[]>
where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey
{ {
private readonly Dictionary<string, string> fileToStoragePathMapping = new Dictionary<string, string>(); private Lazy<Dictionary<string, string>> fileToStoragePathMapping;
public RealmBackedResourceStore(IHasRealmFiles source, IResourceStore<byte[]> underlyingStore, string[] extensions = null) private readonly Live<T> liveSource;
private readonly IDisposable? realmSubscription;
public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess? realm)
: base(underlyingStore) : base(underlyingStore)
{ {
// Must be initialised before the file cache. liveSource = source;
if (extensions != null)
{
foreach (string extension in extensions)
AddExtension(extension);
}
initialiseFileCache(source); invalidateCache();
Debug.Assert(fileToStoragePathMapping != null);
realmSubscription = realm?.RegisterForNotifications(r => r.All<T>().Where(s => s.ID == source.ID), skinChanged);
} }
private void initialiseFileCache(IHasRealmFiles source) protected override void Dispose(bool disposing)
{ {
fileToStoragePathMapping.Clear(); base.Dispose(disposing);
foreach (var f in source.Files) realmSubscription?.Dispose();
fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
} }
private void skinChanged(IRealmCollection<T> sender, ChangeSet changes, Exception error) => invalidateCache();
protected override IEnumerable<string> GetFilenames(string name) protected override IEnumerable<string> GetFilenames(string name)
{ {
foreach (string filename in base.GetFilenames(name)) foreach (string filename in base.GetFilenames(name))
{ {
string path = getPathForFile(filename.ToStandardisedPath()); string? path = getPathForFile(filename.ToStandardisedPath());
if (path != null) if (path != null)
yield return path; yield return path;
} }
} }
private string getPathForFile(string filename) => private string? getPathForFile(string filename)
fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null; {
if (fileToStoragePathMapping.Value.TryGetValue(filename.ToLowerInvariant(), out string path))
return path;
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Keys; return null;
}
private void invalidateCache() => fileToStoragePathMapping = new Lazy<Dictionary<string, string>>(initialiseFileCache);
private Dictionary<string, string> initialiseFileCache() => liveSource.PerformRead(source =>
{
var dictionary = new Dictionary<string, string>();
dictionary.Clear();
foreach (var f in source.Files)
dictionary[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
return dictionary;
});
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Value.Keys;
} }
} }

View File

@ -54,6 +54,8 @@ namespace osu.Game.Skinning
where TLookup : notnull where TLookup : notnull
where TValue : notnull; where TValue : notnull;
private readonly RealmBackedResourceStore<SkinInfo>? realmBackedStorage;
/// <summary> /// <summary>
/// Construct a new skin. /// Construct a new skin.
/// </summary> /// </summary>
@ -67,7 +69,9 @@ namespace osu.Game.Skinning
{ {
SkinInfo = skin.ToLive(resources.RealmAccess); SkinInfo = skin.ToLive(resources.RealmAccess);
storage ??= new RealmBackedResourceStore(skin, resources.Files, new[] { @"ogg" }); storage ??= realmBackedStorage = new RealmBackedResourceStore<SkinInfo>(SkinInfo, resources.Files, resources.RealmAccess);
(storage as ResourceStore<byte[]>)?.AddExtension("ogg");
var samples = resources.AudioManager?.GetSampleStore(storage); var samples = resources.AudioManager?.GetSampleStore(storage);
if (samples != null) if (samples != null)
@ -155,16 +159,7 @@ namespace osu.Game.Skinning
var components = new List<Drawable>(); var components = new List<Drawable>();
foreach (var i in skinnableInfo) foreach (var i in skinnableInfo)
{ components.Add(i.CreateInstance());
try
{
components.Add(i.CreateInstance());
}
catch (Exception e)
{
Logger.Error(e, $"Unable to create skin component {i.Type.Name}");
}
}
return new SkinnableTargetComponentsContainer return new SkinnableTargetComponentsContainer
{ {
@ -200,6 +195,8 @@ namespace osu.Game.Skinning
Textures?.Dispose(); Textures?.Dispose();
Samples?.Dispose(); Samples?.Dispose();
realmBackedStorage?.Dispose();
} }
#endregion #endregion

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Threading; using System.Threading;
@ -23,7 +24,9 @@ using osu.Game.Audio;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.IO; using osu.Game.IO;
using osu.Game.IO.Archives; using osu.Game.IO.Archives;
using osu.Game.Models;
using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Notifications;
using osu.Game.Utils;
namespace osu.Game.Skinning namespace osu.Game.Skinning
{ {
@ -35,7 +38,7 @@ namespace osu.Game.Skinning
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process. /// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
/// </remarks> /// </remarks>
[ExcludeFromDynamicCompile] [ExcludeFromDynamicCompile]
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo> public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>, IModelManager<SkinInfo>, IModelFileManager<SkinInfo, RealmNamedFileUsage>
{ {
private readonly AudioManager audio; private readonly AudioManager audio;
@ -95,7 +98,10 @@ namespace osu.Game.Skinning
} }
}); });
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); CurrentSkinInfo.ValueChanged += skin =>
{
CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
};
CurrentSkin.Value = DefaultSkin; CurrentSkin.Value = DefaultSkin;
CurrentSkin.ValueChanged += skin => CurrentSkin.ValueChanged += skin =>
@ -144,20 +150,26 @@ namespace osu.Game.Skinning
if (!s.Protected) if (!s.Protected)
return; return;
string[] existingSkinNames = realm.Run(r => r.All<SkinInfo>()
.Where(skin => !skin.DeletePending)
.AsEnumerable()
.Select(skin => skin.Name).ToArray());
// if the user is attempting to save one of the default skin implementations, create a copy first. // if the user is attempting to save one of the default skin implementations, create a copy first.
var result = skinModelManager.Import(new SkinInfo var skinInfo = new SkinInfo
{ {
Name = s.Name + @" (modified)",
Creator = s.Creator, Creator = s.Creator,
InstantiationInfo = s.InstantiationInfo, InstantiationInfo = s.InstantiationInfo,
}); Name = NamingUtils.GetNextBestName(existingSkinNames, $"{s.Name} (modified)")
};
var result = skinModelManager.Import(skinInfo);
if (result != null) if (result != null)
{ {
// save once to ensure the required json content is populated. // save once to ensure the required json content is populated.
// currently this only happens on save. // currently this only happens on save.
result.PerformRead(skin => Save(skin.CreateInstance(this))); result.PerformRead(skin => Save(skin.CreateInstance(this)));
CurrentSkinInfo.Value = result; CurrentSkinInfo.Value = result;
} }
}); });
@ -306,5 +318,45 @@ namespace osu.Game.Skinning
} }
#endregion #endregion
public bool Delete(SkinInfo item)
{
return skinModelManager.Delete(item);
}
public void Delete(List<SkinInfo> items, bool silent = false)
{
skinModelManager.Delete(items, silent);
}
public void Undelete(List<SkinInfo> items, bool silent = false)
{
skinModelManager.Undelete(items, silent);
}
public void Undelete(SkinInfo item)
{
skinModelManager.Undelete(item);
}
public bool IsAvailableLocally(SkinInfo model)
{
return skinModelManager.IsAvailableLocally(model);
}
public void ReplaceFile(SkinInfo model, RealmNamedFileUsage file, Stream contents)
{
skinModelManager.ReplaceFile(model, file, contents);
}
public void DeleteFile(SkinInfo model, RealmNamedFileUsage file)
{
skinModelManager.DeleteFile(model, file);
}
public void AddFile(SkinInfo model, Stream contents, string filename)
{
skinModelManager.AddFile(model, contents, filename);
}
} }
} }

View File

@ -31,7 +31,7 @@ namespace osu.Game.Skinning
set => base.AutoSizeAxes = value; set => base.AutoSizeAxes = value;
} }
private readonly ISkinComponent component; protected readonly ISkinComponent Component;
private readonly ConfineMode confineMode; private readonly ConfineMode confineMode;
@ -49,7 +49,7 @@ namespace osu.Game.Skinning
protected SkinnableDrawable(ISkinComponent component, ConfineMode confineMode = ConfineMode.NoScaling) protected SkinnableDrawable(ISkinComponent component, ConfineMode confineMode = ConfineMode.NoScaling)
{ {
this.component = component; Component = component;
this.confineMode = confineMode; this.confineMode = confineMode;
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
@ -75,13 +75,13 @@ namespace osu.Game.Skinning
protected override void SkinChanged(ISkinSource skin) protected override void SkinChanged(ISkinSource skin)
{ {
Drawable = skin.GetDrawableComponent(component); Drawable = skin.GetDrawableComponent(Component);
isDefault = false; isDefault = false;
if (Drawable == null) if (Drawable == null)
{ {
Drawable = CreateDefault(component); Drawable = CreateDefault(Component);
isDefault = true; isDefault = true;
} }

View File

@ -1,26 +1,56 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Game.Configuration;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays.Settings;
using osuTK;
namespace osu.Game.Skinning namespace osu.Game.Skinning
{ {
/// <summary> /// <summary>
/// A skinnable element which uses a stable sprite and can therefore share implementation logic. /// A skinnable element which uses a single texture backing.
/// </summary> /// </summary>
public class SkinnableSprite : SkinnableDrawable public class SkinnableSprite : SkinnableDrawable, ISkinnableDrawable
{ {
protected override bool ApplySizeRestrictionsToDefault => true; protected override bool ApplySizeRestrictionsToDefault => true;
[Resolved] [Resolved]
private TextureStore textures { get; set; } private TextureStore textures { get; set; }
[SettingSource("Sprite name", "The filename of the sprite", SettingControlType = typeof(SpriteSelectorControl))]
public Bindable<string> SpriteName { get; } = new Bindable<string>(string.Empty);
[Resolved]
private ISkinSource source { get; set; }
public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling) public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling)
: base(new SpriteComponent(textureName), confineMode) : base(new SpriteComponent(textureName), confineMode)
{ {
SpriteName.Value = textureName;
}
public SkinnableSprite()
: base(new SpriteComponent(string.Empty), ConfineMode.NoScaling)
{
RelativeSizeAxes = Axes.None;
AutoSizeAxes = Axes.Both;
SpriteName.BindValueChanged(name =>
{
((SpriteComponent)Component).LookupName = name.NewValue ?? string.Empty;
if (IsLoaded)
SkinChanged(CurrentSkin);
});
} }
protected override Drawable CreateDefault(ISkinComponent component) protected override Drawable CreateDefault(ISkinComponent component)
@ -28,19 +58,85 @@ namespace osu.Game.Skinning
var texture = textures.Get(component.LookupName); var texture = textures.Get(component.LookupName);
if (texture == null) if (texture == null)
return null; return new SpriteNotFound(component.LookupName);
return new Sprite { Texture = texture }; return new Sprite { Texture = texture };
} }
public bool UsesFixedAnchor { get; set; }
private class SpriteComponent : ISkinComponent private class SpriteComponent : ISkinComponent
{ {
public string LookupName { get; set; }
public SpriteComponent(string textureName) public SpriteComponent(string textureName)
{ {
LookupName = textureName; LookupName = textureName;
} }
}
public string LookupName { get; } public class SpriteSelectorControl : SettingsDropdown<string>
{
protected override void LoadComplete()
{
base.LoadComplete();
// Round-about way of getting the user's skin to find available resources.
// In the future we'll probably want to allow access to resources from the fallbacks, or potentially other skins
// but that requires further thought.
var highestPrioritySkin = getHighestPriorityUserSkin(((SkinnableSprite)SettingSourceObject).source.AllSources) as Skin;
string[] availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files
.Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal)
|| f.Filename.EndsWith(".jpg", StringComparison.Ordinal))
.Select(f => f.Filename).Distinct()).ToArray();
if (availableFiles?.Length > 0)
Items = availableFiles;
static ISkin getHighestPriorityUserSkin(IEnumerable<ISkin> skins)
{
foreach (var skin in skins)
{
if (skin is LegacySkinTransformer transformer && isUserSkin(transformer.Skin))
return transformer.Skin;
if (isUserSkin(skin))
return skin;
}
return null;
}
// Temporarily used to exclude undesirable ISkin implementations
static bool isUserSkin(ISkin skin)
=> skin.GetType() == typeof(DefaultSkin)
|| skin.GetType() == typeof(DefaultLegacySkin)
|| skin.GetType() == typeof(LegacySkin);
}
}
public class SpriteNotFound : CompositeDrawable
{
public SpriteNotFound(string lookup)
{
AutoSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
new SpriteIcon
{
Size = new Vector2(50),
Icon = FontAwesome.Solid.QuestionCircle
},
new OsuSpriteText
{
Position = new Vector2(25, 50),
Text = $"missing: {lookup}",
Origin = Anchor.TopCentre,
}
};
}
} }
} }
} }

View File

@ -22,10 +22,13 @@ namespace osu.Game.Tests.Beatmaps
protected abstract string ResourceAssembly { get; } protected abstract string ResourceAssembly { get; }
protected void Test(double expected, string name, params Mod[] mods) protected void Test(double expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods)
{ {
var attributes = CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods);
// Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences.
Assert.That(CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods).StarRating, Is.EqualTo(expected).Within(0.00001)); Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001));
Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo));
} }
private IWorkingBeatmap getBeatmap(string name) private IWorkingBeatmap getBeatmap(string name)

View File

@ -31,7 +31,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
public override IBindable<bool> IsConnected => isConnected; public override IBindable<bool> IsConnected => isConnected;
private readonly Bindable<bool> isConnected = new Bindable<bool>(true); private readonly Bindable<bool> isConnected = new Bindable<bool>(true);
/// <summary>
/// The local client's <see cref="Room"/>. This is not always equivalent to the server-side room.
/// </summary>
public new Room? APIRoom => base.APIRoom; public new Room? APIRoom => base.APIRoom;
public Action<MultiplayerRoom>? RoomSetupAction; public Action<MultiplayerRoom>? RoomSetupAction;
public bool RoomJoined { get; private set; } public bool RoomJoined { get; private set; }
@ -46,6 +50,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
/// </summary> /// </summary>
private readonly List<MultiplayerPlaylistItem> serverSidePlaylist = new List<MultiplayerPlaylistItem>(); private readonly List<MultiplayerPlaylistItem> serverSidePlaylist = new List<MultiplayerPlaylistItem>();
/// <summary>
/// Guaranteed up-to-date API room.
/// </summary>
private Room? serverSideAPIRoom;
private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex]; private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex];
private int currentIndex; private int currentIndex;
private long lastPlaylistItemId; private long lastPlaylistItemId;
@ -192,13 +201,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
protected override async Task<MultiplayerRoom> JoinRoom(long roomId, string? password = null) protected override async Task<MultiplayerRoom> JoinRoom(long roomId, string? password = null)
{ {
var apiRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId); serverSideAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId);
if (password != apiRoom.Password.Value) if (password != serverSideAPIRoom.Password.Value)
throw new InvalidOperationException("Invalid password."); throw new InvalidOperationException("Invalid password.");
serverSidePlaylist.Clear(); serverSidePlaylist.Clear();
serverSidePlaylist.AddRange(apiRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item))); serverSidePlaylist.AddRange(serverSideAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)));
lastPlaylistItemId = serverSidePlaylist.Max(item => item.ID); lastPlaylistItemId = serverSidePlaylist.Max(item => item.ID);
var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id)
@ -210,11 +219,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
Settings = Settings =
{ {
Name = apiRoom.Name.Value, Name = serverSideAPIRoom.Name.Value,
MatchType = apiRoom.Type.Value, MatchType = serverSideAPIRoom.Type.Value,
Password = password, Password = password,
QueueMode = apiRoom.QueueMode.Value, QueueMode = serverSideAPIRoom.QueueMode.Value,
AutoStartDuration = apiRoom.AutoStartDuration.Value AutoStartDuration = serverSideAPIRoom.AutoStartDuration.Value
}, },
Playlist = serverSidePlaylist.ToList(), Playlist = serverSidePlaylist.ToList(),
Users = { localUser }, Users = { localUser },
@ -449,8 +458,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item) public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item)
{ {
Debug.Assert(Room != null); Debug.Assert(Room != null);
Debug.Assert(APIRoom != null);
Debug.Assert(currentItem != null); Debug.Assert(currentItem != null);
Debug.Assert(serverSideAPIRoom != null);
item.OwnerID = userId; item.OwnerID = userId;
@ -469,6 +478,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
item.PlaylistOrder = existingItem.PlaylistOrder; item.PlaylistOrder = existingItem.PlaylistOrder;
serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item; serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item;
serverSideAPIRoom.Playlist[serverSideAPIRoom.Playlist.IndexOf(serverSideAPIRoom.Playlist.Single(i => i.ID == item.ID))] = new PlaylistItem(item);
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
} }
@ -479,6 +489,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
Debug.Assert(Room != null); Debug.Assert(Room != null);
Debug.Assert(APIRoom != null); Debug.Assert(APIRoom != null);
Debug.Assert(serverSideAPIRoom != null);
var item = serverSidePlaylist.Find(i => i.ID == playlistItemId); var item = serverSidePlaylist.Find(i => i.ID == playlistItemId);
@ -495,6 +506,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
throw new InvalidOperationException("Attempted to remove an item which has already been played."); throw new InvalidOperationException("Attempted to remove an item which has already been played.");
serverSidePlaylist.Remove(item); serverSidePlaylist.Remove(item);
serverSideAPIRoom.Playlist.RemoveAll(i => i.ID == item.ID);
await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false); await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false);
await updateCurrentItem(Room).ConfigureAwait(false); await updateCurrentItem(Room).ConfigureAwait(false);
@ -576,10 +588,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
private async Task addItem(MultiplayerPlaylistItem item) private async Task addItem(MultiplayerPlaylistItem item)
{ {
Debug.Assert(Room != null); Debug.Assert(Room != null);
Debug.Assert(serverSideAPIRoom != null);
item.ID = ++lastPlaylistItemId; item.ID = ++lastPlaylistItemId;
serverSidePlaylist.Add(item); serverSidePlaylist.Add(item);
serverSideAPIRoom.Playlist.Add(new PlaylistItem(item));
await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false); await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false);
await updatePlaylistOrder(Room).ConfigureAwait(false); await updatePlaylistOrder(Room).ConfigureAwait(false);
@ -603,6 +617,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
private async Task updatePlaylistOrder(MultiplayerRoom room) private async Task updatePlaylistOrder(MultiplayerRoom room)
{ {
Debug.Assert(serverSideAPIRoom != null);
List<MultiplayerPlaylistItem> orderedActiveItems; List<MultiplayerPlaylistItem> orderedActiveItems;
switch (room.Settings.QueueMode) switch (room.Settings.QueueMode)
@ -648,6 +664,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
} }
// Also ensure that the API room's playlist is correct.
foreach (var item in serverSideAPIRoom.Playlist)
item.PlaylistOrder = serverSidePlaylist.Single(i => i.ID == item.ID).PlaylistOrder;
} }
} }
} }

View File

@ -36,8 +36,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Realm" Version="10.10.0" /> <PackageReference Include="Realm" Version="10.10.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.325.0" /> <PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
<PackageReference Include="Sentry" Version="3.14.1" /> <PackageReference Include="Sentry" Version="3.14.1" />
<PackageReference Include="SharpCompress" Version="0.30.1" /> <PackageReference Include="SharpCompress" Version="0.30.1" />
<PackageReference Include="NUnit" Version="3.13.2" /> <PackageReference Include="NUnit" Version="3.13.2" />

View File

@ -61,8 +61,8 @@
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.325.0" /> <PackageReference Include="ppy.osu.Framework.iOS" Version="2022.404.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
</ItemGroup> </ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) --> <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
<PropertyGroup> <PropertyGroup>
@ -84,7 +84,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2022.325.0" /> <PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
<PackageReference Include="SharpCompress" Version="0.30.1" /> <PackageReference Include="SharpCompress" Version="0.30.1" />
<PackageReference Include="NUnit" Version="3.13.2" /> <PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />