mirror of
https://github.com/ppy/osu.git
synced 2024-12-14 20:13:21 +08:00
Merge branch 'master' into scoreprocessor-rework
This commit is contained in:
commit
94ff6a338f
@ -258,6 +258,7 @@ namespace osu.Game.Rulesets.Mania
|
||||
{
|
||||
new MultiMod(new ModWindUp(), new ModWindDown()),
|
||||
new ManiaModMuted(),
|
||||
new ModAdaptiveSpeed()
|
||||
};
|
||||
|
||||
default:
|
||||
|
@ -1,8 +1,11 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using osu.Framework.Graphics.Primitives;
|
||||
using osu.Framework.Utils;
|
||||
@ -25,13 +28,14 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
public override string Description => "It never gets boring!";
|
||||
|
||||
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;
|
||||
|
||||
public void ApplyToBeatmap(IBeatmap beatmap)
|
||||
{
|
||||
@ -44,28 +48,79 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
|
||||
rng = new Random((int)Seed.Value);
|
||||
|
||||
RandomObjectInfo previous = null;
|
||||
var randomObjects = randomiseObjects(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;
|
||||
|
||||
for (int i = 0; i < hitObjects.Count; i++)
|
||||
foreach (OsuHitObject hitObject in hitObjects)
|
||||
{
|
||||
var hitObject = hitObjects[i];
|
||||
|
||||
var current = new RandomObjectInfo(hitObject);
|
||||
randomObjects.Add(current);
|
||||
|
||||
// rateOfChangeMultiplier only changes every 5 iterations in a combo
|
||||
// to prevent shaky-line-shaped streams
|
||||
if (hitObject.IndexInCurrentCombo % 5 == 0)
|
||||
rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
|
||||
|
||||
if (previous == null)
|
||||
{
|
||||
current.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2);
|
||||
current.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
|
||||
}
|
||||
else
|
||||
{
|
||||
current.DistanceFromPrevious = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
applyRandomisation(rateOfChangeMultiplier, previous, current);
|
||||
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;
|
||||
@ -102,44 +157,34 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the final position of the hit object
|
||||
/// Compute the randomised position of a hit object while attempting to keep it inside the playfield.
|
||||
/// </summary>
|
||||
/// <returns>Final position of the hit object</returns>
|
||||
private void applyRandomisation(float rateOfChangeMultiplier, RandomObjectInfo previous, RandomObjectInfo current)
|
||||
/// <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)
|
||||
{
|
||||
if (previous == null)
|
||||
float previousAbsoluteAngle = 0f;
|
||||
|
||||
if (previous != null)
|
||||
{
|
||||
var playfieldSize = OsuPlayfield.BASE_SIZE;
|
||||
|
||||
current.AngleRad = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
|
||||
current.PositionRandomised = new Vector2((float)rng.NextDouble() * playfieldSize.X, (float)rng.NextDouble() * playfieldSize.Y);
|
||||
|
||||
return;
|
||||
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
|
||||
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
|
||||
previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
|
||||
}
|
||||
|
||||
float distanceToPrev = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal);
|
||||
|
||||
// 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
|
||||
double randomAngleRad = rateOfChangeMultiplier * 2 * Math.PI * Math.Min(1f, distanceToPrev / (playfield_diagonal * 0.5f));
|
||||
|
||||
current.AngleRad = (float)randomAngleRad + previous.AngleRad;
|
||||
if (current.AngleRad < 0)
|
||||
current.AngleRad += 2 * (float)Math.PI;
|
||||
float absoluteAngle = previousAbsoluteAngle + current.RelativeAngle;
|
||||
|
||||
var posRelativeToPrev = new Vector2(
|
||||
distanceToPrev * (float)Math.Cos(current.AngleRad),
|
||||
distanceToPrev * (float)Math.Sin(current.AngleRad)
|
||||
current.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
|
||||
current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
|
||||
);
|
||||
|
||||
posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(previous.EndPositionRandomised, posRelativeToPrev);
|
||||
Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre;
|
||||
|
||||
current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X);
|
||||
posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
|
||||
|
||||
current.PositionRandomised = previous.EndPositionRandomised + posRelativeToPrev;
|
||||
current.PositionRandomised = lastEndPosition + posRelativeToPrev;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -287,7 +332,25 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
|
||||
private class RandomObjectInfo
|
||||
{
|
||||
public float AngleRad { get; set; }
|
||||
/// <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; }
|
||||
@ -295,11 +358,13 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
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;
|
||||
AngleRad = 0;
|
||||
HitObject = hitObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -195,6 +195,7 @@ namespace osu.Game.Rulesets.Osu
|
||||
new OsuModMuted(),
|
||||
new OsuModNoScope(),
|
||||
new OsuModAimAssist(),
|
||||
new ModAdaptiveSpeed()
|
||||
};
|
||||
|
||||
case ModType.System:
|
||||
|
@ -151,6 +151,7 @@ namespace osu.Game.Rulesets.Taiko
|
||||
{
|
||||
new MultiMod(new ModWindUp(), new ModWindDown()),
|
||||
new TaikoModMuted(),
|
||||
new ModAdaptiveSpeed()
|
||||
};
|
||||
|
||||
default:
|
||||
|
@ -43,8 +43,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
|
||||
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default));
|
||||
Dependencies.Cache(Realm);
|
||||
|
||||
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
|
||||
}
|
||||
|
||||
[SetUp]
|
||||
@ -52,8 +50,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
AvailabilityTracker.SelectedItem.BindTo(selectedItem);
|
||||
|
||||
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
|
||||
importedSet = beatmaps.GetAllUsableBeatmapSets().First();
|
||||
Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First());
|
||||
|
||||
selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo)
|
||||
{
|
||||
RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID
|
||||
@ -92,16 +92,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
OsuButton readyButton = null;
|
||||
|
||||
AddAssert("ensure ready button enabled", () =>
|
||||
AddUntilStep("ensure ready button enabled", () =>
|
||||
{
|
||||
readyButton = button.ChildrenOfType<OsuButton>().Single();
|
||||
return readyButton.Enabled.Value;
|
||||
});
|
||||
|
||||
AddStep("delete beatmap", () => beatmaps.Delete(importedSet));
|
||||
AddAssert("ready button disabled", () => !readyButton.Enabled.Value);
|
||||
AddUntilStep("ready button disabled", () => !readyButton.Enabled.Value);
|
||||
AddStep("undelete beatmap", () => beatmaps.Undelete(importedSet));
|
||||
AddAssert("ready button enabled back", () => readyButton.Enabled.Value);
|
||||
AddUntilStep("ready button enabled back", () => readyButton.Enabled.Value);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
AddUntilStep("ensure manager loaded", () => beatmaps != null);
|
||||
ensureSoleilyRemoved();
|
||||
createButtonWithBeatmap(createSoleily());
|
||||
AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
|
||||
AddUntilStep("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
|
||||
AddStep("import soleily", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()));
|
||||
|
||||
AddUntilStep("wait for beatmap import", () => beatmaps.GetAllUsableBeatmapSets().Any(b => b.OnlineID == 241526));
|
||||
@ -50,7 +50,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
createButtonWithBeatmap(createSoleily());
|
||||
AddUntilStep("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable);
|
||||
ensureSoleilyRemoved();
|
||||
AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
|
||||
AddUntilStep("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
|
||||
}
|
||||
|
||||
private void ensureSoleilyRemoved()
|
||||
|
@ -33,16 +33,25 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
|
||||
private TestResultsScreen resultsScreen;
|
||||
|
||||
private int currentScoreId;
|
||||
private int lowestScoreId; // Score ID of the lowest score in the list.
|
||||
private int highestScoreId; // Score ID of the highest score in the list.
|
||||
|
||||
private bool requestComplete;
|
||||
private int totalCount;
|
||||
private ScoreInfo userScore;
|
||||
|
||||
[SetUp]
|
||||
public void Setup() => Schedule(() =>
|
||||
{
|
||||
currentScoreId = 1;
|
||||
lowestScoreId = 1;
|
||||
highestScoreId = 1;
|
||||
requestComplete = false;
|
||||
totalCount = 0;
|
||||
|
||||
userScore = TestResources.CreateTestScoreInfo();
|
||||
userScore.TotalScore = 0;
|
||||
userScore.Statistics = new Dictionary<HitResult, int>();
|
||||
|
||||
bindHandler();
|
||||
|
||||
// beatmap is required to be an actual beatmap so the scores can get their scores correctly calculated for standardised scoring.
|
||||
@ -53,15 +62,7 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
[Test]
|
||||
public void TestShowWithUserScore()
|
||||
{
|
||||
ScoreInfo userScore = null;
|
||||
|
||||
AddStep("bind user score info handler", () =>
|
||||
{
|
||||
userScore = TestResources.CreateTestScoreInfo();
|
||||
userScore.OnlineID = currentScoreId++;
|
||||
|
||||
bindHandler(userScore: userScore);
|
||||
});
|
||||
AddStep("bind user score info handler", () => bindHandler(userScore: userScore));
|
||||
|
||||
createResults(() => userScore);
|
||||
|
||||
@ -81,15 +82,7 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
[Test]
|
||||
public void TestShowUserScoreWithDelay()
|
||||
{
|
||||
ScoreInfo userScore = null;
|
||||
|
||||
AddStep("bind user score info handler", () =>
|
||||
{
|
||||
userScore = TestResources.CreateTestScoreInfo();
|
||||
userScore.OnlineID = currentScoreId++;
|
||||
|
||||
bindHandler(true, userScore);
|
||||
});
|
||||
AddStep("bind user score info handler", () => bindHandler(true, userScore));
|
||||
|
||||
createResults(() => userScore);
|
||||
|
||||
@ -124,7 +117,7 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible);
|
||||
waitForDisplay();
|
||||
|
||||
AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType<ScorePanel>().Count() == beforePanelCount + scores_per_result);
|
||||
AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType<ScorePanel>().Count() >= beforePanelCount + scores_per_result);
|
||||
AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden);
|
||||
}
|
||||
}
|
||||
@ -132,15 +125,7 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
[Test]
|
||||
public void TestFetchWhenScrolledToTheLeft()
|
||||
{
|
||||
ScoreInfo userScore = null;
|
||||
|
||||
AddStep("bind user score info handler", () =>
|
||||
{
|
||||
userScore = TestResources.CreateTestScoreInfo();
|
||||
userScore.OnlineID = currentScoreId++;
|
||||
|
||||
bindHandler(userScore: userScore);
|
||||
});
|
||||
AddStep("bind user score info handler", () => bindHandler(userScore: userScore));
|
||||
|
||||
createResults(() => userScore);
|
||||
|
||||
@ -156,7 +141,7 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
AddAssert("left loading spinner shown", () => resultsScreen.LeftSpinner.State.Value == Visibility.Visible);
|
||||
waitForDisplay();
|
||||
|
||||
AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType<ScorePanel>().Count() == beforePanelCount + scores_per_result);
|
||||
AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType<ScorePanel>().Count() >= beforePanelCount + scores_per_result);
|
||||
AddAssert("left loading spinner hidden", () => resultsScreen.LeftSpinner.State.Value == Visibility.Hidden);
|
||||
}
|
||||
}
|
||||
@ -245,16 +230,13 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
{
|
||||
var multiplayerUserScore = new MultiplayerScore
|
||||
{
|
||||
ID = (int)(userScore.OnlineID > 0 ? userScore.OnlineID : currentScoreId++),
|
||||
ID = highestScoreId,
|
||||
Accuracy = userScore.Accuracy,
|
||||
EndedAt = userScore.Date,
|
||||
Passed = userScore.Passed,
|
||||
Rank = userScore.Rank,
|
||||
Position = real_user_position,
|
||||
MaxCombo = userScore.MaxCombo,
|
||||
TotalScore = userScore.TotalScore,
|
||||
User = userScore.User,
|
||||
Statistics = userScore.Statistics,
|
||||
ScoresAround = new MultiplayerScoresAround
|
||||
{
|
||||
Higher = new MultiplayerScores(),
|
||||
@ -268,38 +250,32 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
{
|
||||
multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore
|
||||
{
|
||||
ID = currentScoreId++,
|
||||
ID = --highestScoreId,
|
||||
Accuracy = userScore.Accuracy,
|
||||
EndedAt = userScore.Date,
|
||||
Passed = true,
|
||||
Rank = userScore.Rank,
|
||||
MaxCombo = userScore.MaxCombo,
|
||||
TotalScore = userScore.TotalScore - i,
|
||||
User = new APIUser
|
||||
{
|
||||
Id = 2,
|
||||
Username = $"peppy{i}",
|
||||
CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
|
||||
},
|
||||
Statistics = userScore.Statistics
|
||||
});
|
||||
|
||||
multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore
|
||||
{
|
||||
ID = currentScoreId++,
|
||||
ID = ++lowestScoreId,
|
||||
Accuracy = userScore.Accuracy,
|
||||
EndedAt = userScore.Date,
|
||||
Passed = true,
|
||||
Rank = userScore.Rank,
|
||||
MaxCombo = userScore.MaxCombo,
|
||||
TotalScore = userScore.TotalScore + i,
|
||||
User = new APIUser
|
||||
{
|
||||
Id = 2,
|
||||
Username = $"peppy{i}",
|
||||
CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
|
||||
},
|
||||
Statistics = userScore.Statistics
|
||||
});
|
||||
|
||||
totalCount += 2;
|
||||
@ -315,33 +291,23 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
{
|
||||
var result = new IndexedMultiplayerScores();
|
||||
|
||||
long startTotalScore = req.Cursor?.Properties["total_score"].ToObject<long>() ?? 1000000;
|
||||
string sort = req.IndexParams?.Properties["sort"].ToObject<string>() ?? "score_desc";
|
||||
|
||||
for (int i = 1; i <= scores_per_result; i++)
|
||||
{
|
||||
result.Scores.Add(new MultiplayerScore
|
||||
{
|
||||
ID = currentScoreId++,
|
||||
ID = sort == "score_asc" ? --highestScoreId : ++lowestScoreId,
|
||||
Accuracy = 1,
|
||||
EndedAt = DateTimeOffset.Now,
|
||||
Passed = true,
|
||||
Rank = ScoreRank.X,
|
||||
MaxCombo = 1000,
|
||||
TotalScore = startTotalScore + (sort == "score_asc" ? i : -i),
|
||||
User = new APIUser
|
||||
{
|
||||
Id = 2,
|
||||
Username = $"peppy{i}",
|
||||
CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
|
||||
},
|
||||
Statistics = new Dictionary<HitResult, int>
|
||||
{
|
||||
{ HitResult.Miss, 1 },
|
||||
{ HitResult.Meh, 50 },
|
||||
{ HitResult.Good, 100 },
|
||||
{ HitResult.Great, 300 }
|
||||
}
|
||||
});
|
||||
|
||||
totalCount++;
|
||||
@ -367,7 +333,7 @@ namespace osu.Game.Tests.Visual.Playlists
|
||||
{
|
||||
Properties = new Dictionary<string, JToken>
|
||||
{
|
||||
{ "sort", JToken.FromObject(scores.Scores[^1].TotalScore > scores.Scores[^2].TotalScore ? "score_asc" : "score_desc") }
|
||||
{ "sort", JToken.FromObject(scores.Scores[^1].ID > scores.Scores[^2].ID ? "score_asc" : "score_desc") }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -2,12 +2,14 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Models;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Scoring;
|
||||
using osu.Game.Screens.Ranking;
|
||||
using osu.Game.Tests.Resources;
|
||||
@ -208,13 +210,19 @@ namespace osu.Game.Tests.Visual.Ranking
|
||||
public void TestKeyboardNavigation()
|
||||
{
|
||||
var lowestScore = TestResources.CreateTestScoreInfo();
|
||||
lowestScore.MaxCombo = 100;
|
||||
lowestScore.OnlineID = 3;
|
||||
lowestScore.TotalScore = 0;
|
||||
lowestScore.Statistics = new Dictionary<HitResult, int>();
|
||||
|
||||
var middleScore = TestResources.CreateTestScoreInfo();
|
||||
middleScore.MaxCombo = 200;
|
||||
middleScore.OnlineID = 2;
|
||||
middleScore.TotalScore = 0;
|
||||
middleScore.Statistics = new Dictionary<HitResult, int>();
|
||||
|
||||
var highestScore = TestResources.CreateTestScoreInfo();
|
||||
highestScore.MaxCombo = 300;
|
||||
highestScore.OnlineID = 1;
|
||||
highestScore.TotalScore = 0;
|
||||
highestScore.Statistics = new Dictionary<HitResult, int>();
|
||||
|
||||
createListStep(() => new ScorePanelList());
|
||||
|
||||
|
@ -284,14 +284,13 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
public void TestDummy()
|
||||
{
|
||||
createSongSelect();
|
||||
AddAssert("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap);
|
||||
AddUntilStep("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap);
|
||||
|
||||
AddUntilStep("dummy shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap == defaultBeatmap);
|
||||
|
||||
addManyTestMaps();
|
||||
AddWaitStep("wait for select", 3);
|
||||
|
||||
AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap);
|
||||
AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@ -299,9 +298,8 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
{
|
||||
createSongSelect();
|
||||
addManyTestMaps();
|
||||
AddWaitStep("wait for add", 3);
|
||||
|
||||
AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap);
|
||||
AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap);
|
||||
|
||||
AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist));
|
||||
AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title));
|
||||
@ -571,6 +569,8 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
|
||||
createSongSelect();
|
||||
|
||||
AddUntilStep("wait for selection", () => !Beatmap.IsDefault);
|
||||
|
||||
AddStep("press ctrl+enter", () =>
|
||||
{
|
||||
InputManager.PressKey(Key.ControlLeft);
|
||||
@ -605,6 +605,8 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
addRulesetImportStep(0);
|
||||
createSongSelect();
|
||||
|
||||
AddUntilStep("wait for selection", () => !Beatmap.IsDefault);
|
||||
|
||||
DrawableCarouselBeatmapSet set = null;
|
||||
AddStep("Find the DrawableCarouselBeatmapSet", () =>
|
||||
{
|
||||
@ -844,6 +846,8 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
|
||||
createSongSelect();
|
||||
|
||||
AddUntilStep("wait for selection", () => !Beatmap.IsDefault);
|
||||
|
||||
AddStep("present score", () =>
|
||||
{
|
||||
// this beatmap change should be overridden by the present.
|
||||
|
@ -124,7 +124,7 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
});
|
||||
|
||||
AddUntilStep("Became present", () => topLocalRank.IsPresent);
|
||||
AddAssert("Correct rank", () => topLocalRank.Rank == ScoreRank.B);
|
||||
AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.B);
|
||||
|
||||
AddStep("Add higher score for current user", () =>
|
||||
{
|
||||
@ -137,7 +137,7 @@ namespace osu.Game.Tests.Visual.SongSelect
|
||||
scoreManager.Import(testScoreInfo2);
|
||||
});
|
||||
|
||||
AddAssert("Correct rank", () => topLocalRank.Rank == ScoreRank.S);
|
||||
AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.S);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,40 @@
|
||||
// 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.Overlays;
|
||||
using osu.Game.Overlays.Mods;
|
||||
|
||||
namespace osu.Game.Tests.Visual.UserInterface
|
||||
{
|
||||
[TestFixture]
|
||||
public class TestSceneDifficultyMultiplierDisplay : OsuTestScene
|
||||
{
|
||||
[Cached]
|
||||
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
|
||||
|
||||
[Test]
|
||||
public void TestDifficultyMultiplierDisplay()
|
||||
{
|
||||
DifficultyMultiplierDisplay multiplierDisplay = null;
|
||||
|
||||
AddStep("create content", () => Child = multiplierDisplay = new DifficultyMultiplierDisplay
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre
|
||||
});
|
||||
|
||||
AddStep("set multiplier below 1", () => multiplierDisplay.Current.Value = 0.5);
|
||||
AddStep("set multiplier to 1", () => multiplierDisplay.Current.Value = 1);
|
||||
AddStep("set multiplier above 1", () => multiplierDisplay.Current.Value = 1.5);
|
||||
|
||||
AddSliderStep("set multiplier", 0, 2, 1d, multiplier =>
|
||||
{
|
||||
if (multiplierDisplay != null)
|
||||
multiplierDisplay.Current.Value = multiplier;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -105,6 +105,8 @@ namespace osu.Game.Database
|
||||
|
||||
public Realm Realm => ensureUpdateRealm();
|
||||
|
||||
private const string realm_extension = @".realm";
|
||||
|
||||
private Realm ensureUpdateRealm()
|
||||
{
|
||||
if (isSendingNotificationResetEvents)
|
||||
@ -149,11 +151,18 @@ namespace osu.Game.Database
|
||||
|
||||
Filename = filename;
|
||||
|
||||
const string realm_extension = @".realm";
|
||||
|
||||
if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal))
|
||||
Filename += realm_extension;
|
||||
|
||||
string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}";
|
||||
|
||||
// Attempt to recover a newer database version if available.
|
||||
if (storage.Exists(newerVersionFilename))
|
||||
{
|
||||
Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database);
|
||||
attemptRecoverFromFile(newerVersionFilename);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// This method triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date.
|
||||
@ -161,15 +170,78 @@ namespace osu.Game.Database
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made.");
|
||||
// See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022
|
||||
// This is the best way we can detect a schema version downgrade.
|
||||
if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal))
|
||||
{
|
||||
Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data.");
|
||||
|
||||
CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}");
|
||||
storage.Delete(Filename);
|
||||
// If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about.
|
||||
if (!storage.Exists(newerVersionFilename))
|
||||
CreateBackup(newerVersionFilename);
|
||||
|
||||
storage.Delete(Filename);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made.");
|
||||
CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}");
|
||||
storage.Delete(Filename);
|
||||
}
|
||||
|
||||
cleanupPendingDeletions();
|
||||
}
|
||||
}
|
||||
|
||||
private void attemptRecoverFromFile(string recoveryFilename)
|
||||
{
|
||||
Logger.Log($@"Performing recovery from {recoveryFilename}", LoggingTarget.Database);
|
||||
|
||||
// First check the user hasn't started to use the database that is in place..
|
||||
try
|
||||
{
|
||||
using (var realm = Realm.GetInstance(getConfiguration()))
|
||||
{
|
||||
if (realm.All<ScoreInfo>().Any())
|
||||
{
|
||||
Logger.Log(@"Recovery aborted as the existing database has scores set already.", LoggingTarget.Database);
|
||||
Logger.Log(@"To perform recovery, delete client.realm while osu! is not running.", LoggingTarget.Database);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Even if reading the in place database fails, still attempt to recover.
|
||||
}
|
||||
|
||||
// Then check that the database we are about to attempt recovery can actually be recovered on this version..
|
||||
try
|
||||
{
|
||||
using (Realm.GetInstance(getConfiguration(recoveryFilename)))
|
||||
{
|
||||
// Don't need to do anything, just check that opening the realm works correctly.
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.Log(@"Recovery aborted as the newer version could not be loaded by this osu! version.", LoggingTarget.Database);
|
||||
return;
|
||||
}
|
||||
|
||||
// For extra safety, also store the temporarily-used database which we are about to replace.
|
||||
CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_newer_version_before_recovery{realm_extension}");
|
||||
|
||||
storage.Delete(Filename);
|
||||
|
||||
using (var inputStream = storage.GetStream(recoveryFilename))
|
||||
using (var outputStream = storage.GetStream(Filename, FileAccess.Write, FileMode.Create))
|
||||
inputStream.CopyTo(outputStream);
|
||||
|
||||
storage.Delete(recoveryFilename);
|
||||
Logger.Log(@"Recovery complete!", LoggingTarget.Database);
|
||||
}
|
||||
|
||||
private void cleanupPendingDeletions()
|
||||
{
|
||||
using (var realm = getRealmInstance())
|
||||
@ -476,7 +548,7 @@ namespace osu.Game.Database
|
||||
}
|
||||
}
|
||||
|
||||
private RealmConfiguration getConfiguration()
|
||||
private RealmConfiguration getConfiguration(string? filename = null)
|
||||
{
|
||||
// This is currently the only usage of temporary files at the osu! side.
|
||||
// If we use the temporary folder in more situations in the future, this should be moved to a higher level (helper method or OsuGameBase).
|
||||
@ -484,7 +556,7 @@ namespace osu.Game.Database
|
||||
if (!Directory.Exists(tempPathLocation))
|
||||
Directory.CreateDirectory(tempPathLocation);
|
||||
|
||||
return new RealmConfiguration(storage.GetFullPath(Filename, true))
|
||||
return new RealmConfiguration(storage.GetFullPath(filename ?? Filename, true))
|
||||
{
|
||||
SchemaVersion = schema_version,
|
||||
MigrationCallback = onMigration,
|
||||
|
@ -10,12 +10,11 @@ using osu.Game.IO.Serialization.Converters;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osu.Game.Online.Multiplayer;
|
||||
using osu.Game.Online.Rooms.RoomStatuses;
|
||||
using osu.Game.Utils;
|
||||
|
||||
namespace osu.Game.Online.Rooms
|
||||
{
|
||||
[JsonObject(MemberSerialization.OptIn)]
|
||||
public class Room : IDeepCloneable<Room>
|
||||
public class Room
|
||||
{
|
||||
[Cached]
|
||||
[JsonProperty("id")]
|
||||
@ -153,22 +152,6 @@ namespace osu.Game.Online.Rooms
|
||||
Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a copy of this room without online information.
|
||||
/// Should be used to create a local copy of a room for submitting in the future.
|
||||
/// </summary>
|
||||
public Room DeepClone()
|
||||
{
|
||||
var copy = new Room();
|
||||
|
||||
copy.CopyFrom(this);
|
||||
|
||||
// ID must be unset as we use this as a marker for whether this is a client-side (not-yet-created) room or not.
|
||||
copy.RoomID.Value = null;
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
public void CopyFrom(Room other)
|
||||
{
|
||||
RoomID.Value = other.RoomID.Value;
|
||||
|
@ -79,7 +79,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
|
||||
var beatmapInfo = new BeatmapInfo
|
||||
{
|
||||
MaxCombo = apiBeatmap.MaxCombo,
|
||||
Status = apiBeatmap.Status
|
||||
Status = apiBeatmap.Status,
|
||||
MD5Hash = apiBeatmap.MD5Hash
|
||||
};
|
||||
|
||||
scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets, beatmapInfo)).ToArray(), loadCancellationSource.Token)
|
||||
|
185
osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs
Normal file
185
osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs
Normal file
@ -0,0 +1,185 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.LocalisationExtensions;
|
||||
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.Framework.Localisation;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Overlays.Mods
|
||||
{
|
||||
public class DifficultyMultiplierDisplay : CompositeDrawable, IHasCurrentValue<double>
|
||||
{
|
||||
public Bindable<double> Current
|
||||
{
|
||||
get => current.Current;
|
||||
set => current.Current = value;
|
||||
}
|
||||
|
||||
private readonly BindableNumberWithCurrent<double> current = new BindableNumberWithCurrent<double>(1)
|
||||
{
|
||||
Precision = 0.01
|
||||
};
|
||||
|
||||
private readonly Box underlayBackground;
|
||||
private readonly Box contentBackground;
|
||||
private readonly FillFlowContainer multiplierFlow;
|
||||
private readonly MultiplierCounter multiplierCounter;
|
||||
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private OverlayColourProvider colourProvider { get; set; }
|
||||
|
||||
private const float height = 42;
|
||||
private const float multiplier_value_area_width = 56;
|
||||
private const float transition_duration = 200;
|
||||
|
||||
public DifficultyMultiplierDisplay()
|
||||
{
|
||||
Height = height;
|
||||
AutoSizeAxes = Axes.X;
|
||||
|
||||
InternalChild = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
AutoSizeAxes = Axes.X,
|
||||
Masking = true,
|
||||
CornerRadius = ModPanel.CORNER_RADIUS,
|
||||
Shear = new Vector2(ModPanel.SHEAR_X, 0),
|
||||
Children = new Drawable[]
|
||||
{
|
||||
underlayBackground = new Box
|
||||
{
|
||||
Anchor = Anchor.CentreRight,
|
||||
Origin = Anchor.CentreRight,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
Width = multiplier_value_area_width + ModPanel.CORNER_RADIUS
|
||||
},
|
||||
new GridContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
AutoSizeAxes = Axes.X,
|
||||
ColumnDimensions = new[]
|
||||
{
|
||||
new Dimension(GridSizeMode.AutoSize),
|
||||
new Dimension(GridSizeMode.Absolute, multiplier_value_area_width)
|
||||
},
|
||||
Content = new[]
|
||||
{
|
||||
new Drawable[]
|
||||
{
|
||||
new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
AutoSizeAxes = Axes.X,
|
||||
Masking = true,
|
||||
CornerRadius = ModPanel.CORNER_RADIUS,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
contentBackground = new Box
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Margin = new MarginPadding { Horizontal = 18 },
|
||||
Shear = new Vector2(-ModPanel.SHEAR_X, 0),
|
||||
Text = "Difficulty Multiplier",
|
||||
Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
},
|
||||
multiplierFlow = new FillFlowContainer
|
||||
{
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Shear = new Vector2(-ModPanel.SHEAR_X, 0),
|
||||
Direction = FillDirection.Horizontal,
|
||||
Spacing = new Vector2(2, 0),
|
||||
Children = new Drawable[]
|
||||
{
|
||||
multiplierCounter = new MultiplierCounter
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Current = { BindTarget = Current }
|
||||
},
|
||||
new SpriteIcon
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Icon = FontAwesome.Solid.Times,
|
||||
Size = new Vector2(7),
|
||||
Margin = new MarginPadding { Top = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
contentBackground.Colour = colourProvider.Background4;
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
current.BindValueChanged(_ => updateState(), true);
|
||||
FinishTransforms(true);
|
||||
// required to prevent the counter initially rolling up from 0 to 1
|
||||
// due to `Current.Value` having a nonstandard default value of 1.
|
||||
multiplierCounter.SetCountWithoutRolling(Current.Value);
|
||||
}
|
||||
|
||||
private void updateState()
|
||||
{
|
||||
if (Current.IsDefault)
|
||||
{
|
||||
underlayBackground.FadeColour(colourProvider.Background3, transition_duration, Easing.OutQuint);
|
||||
multiplierFlow.FadeColour(Colour4.White, transition_duration, Easing.OutQuint);
|
||||
}
|
||||
else
|
||||
{
|
||||
var backgroundColour = Current.Value < 1
|
||||
? colours.ForModType(ModType.DifficultyReduction)
|
||||
: colours.ForModType(ModType.DifficultyIncrease);
|
||||
|
||||
underlayBackground.FadeColour(backgroundColour, transition_duration, Easing.OutQuint);
|
||||
multiplierFlow.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint);
|
||||
}
|
||||
}
|
||||
|
||||
private class MultiplierCounter : RollingCounter<double>
|
||||
{
|
||||
protected override double RollingDuration => 500;
|
||||
|
||||
protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"N2");
|
||||
|
||||
protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText
|
||||
{
|
||||
Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -134,6 +134,9 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
|
||||
private void updateSelectedSkinFromConfig()
|
||||
{
|
||||
if (!skinDropdown.Items.Any())
|
||||
return;
|
||||
|
||||
Live<SkinInfo> skin = null;
|
||||
|
||||
if (Guid.TryParse(configBindable.Value, out var configId))
|
||||
|
269
osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs
Normal file
269
osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs
Normal file
@ -0,0 +1,269 @@
|
||||
// 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.Audio;
|
||||
using osu.Framework.Audio.Track;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics.Audio;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.UI;
|
||||
|
||||
namespace osu.Game.Rulesets.Mods
|
||||
{
|
||||
public class ModAdaptiveSpeed : Mod, IApplicableToRate, IApplicableToDrawableHitObject, IApplicableToBeatmap, IUpdatableByPlayfield
|
||||
{
|
||||
public override string Name => "Adaptive Speed";
|
||||
|
||||
public override string Acronym => "AS";
|
||||
|
||||
public override string Description => "Let track speed adapt to you.";
|
||||
|
||||
public override ModType Type => ModType.Fun;
|
||||
|
||||
public override double ScoreMultiplier => 1;
|
||||
|
||||
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp) };
|
||||
|
||||
[SettingSource("Initial rate", "The starting speed of the track")]
|
||||
public BindableNumber<double> InitialRate { get; } = new BindableDouble
|
||||
{
|
||||
MinValue = 0.5,
|
||||
MaxValue = 2,
|
||||
Default = 1,
|
||||
Value = 1,
|
||||
Precision = 0.01
|
||||
};
|
||||
|
||||
[SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
|
||||
public BindableBool AdjustPitch { get; } = new BindableBool
|
||||
{
|
||||
Default = true,
|
||||
Value = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The instantaneous rate of the track.
|
||||
/// Every frame this mod will attempt to smoothly adjust this to meet <see cref="targetRate"/>.
|
||||
/// </summary>
|
||||
public BindableNumber<double> SpeedChange { get; } = new BindableDouble
|
||||
{
|
||||
MinValue = min_allowable_rate,
|
||||
MaxValue = max_allowable_rate,
|
||||
Default = 1,
|
||||
Value = 1
|
||||
};
|
||||
|
||||
// The two constants below denote the maximum allowable range of rates that `SpeedChange` can take.
|
||||
// The range is purposefully wider than the range of values that `InitialRate` allows
|
||||
// in order to give some leeway for change even when extreme initial rates are chosen.
|
||||
private const double min_allowable_rate = 0.4d;
|
||||
private const double max_allowable_rate = 2.5d;
|
||||
|
||||
// The two constants below denote the maximum allowable change in rate caused by a single hit
|
||||
// This prevents sudden jolts caused by a badly-timed hit.
|
||||
private const double min_allowable_rate_change = 0.9d;
|
||||
private const double max_allowable_rate_change = 1.11d;
|
||||
|
||||
// Apply a fixed rate change when missing, allowing the player to catch up when the rate is too fast.
|
||||
private const double rate_change_on_miss = 0.95d;
|
||||
|
||||
private ITrack track;
|
||||
private double targetRate = 1d;
|
||||
|
||||
/// <summary>
|
||||
/// The number of most recent track rates (approximated from how early/late each object was hit relative to the previous object)
|
||||
/// which should be averaged to calculate <see cref="targetRate"/>.
|
||||
/// </summary>
|
||||
private const int recent_rate_count = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the most recent <see cref="recent_rate_count"/> approximated track rates
|
||||
/// which are averaged to calculate the value of <see cref="targetRate"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This list is used as a double-ended queue with fixed capacity
|
||||
/// (items can be enqueued/dequeued at either end of the list).
|
||||
/// When time is elapsing forward, items are dequeued from the start and enqueued onto the end of the list.
|
||||
/// When time is being rewound, items are dequeued from the end and enqueued onto the start of the list.
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <para>
|
||||
/// The track rate approximation is calculated as follows:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Consider a hitobject which ends at 1000ms, and assume that its preceding hitobject ends at 500ms.
|
||||
/// This gives a time difference of 1000 - 500 = 500ms.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Now assume that the user hit this object at 980ms rather than 1000ms.
|
||||
/// When compared to the preceding hitobject, this gives 980 - 500 = 480ms.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// With the above assumptions, the player is rushing / hitting early, which means that the track should speed up to match.
|
||||
/// Therefore, the approximated target rate for this object would be equal to 500 / 480 * <see cref="InitialRate"/>.
|
||||
/// </para>
|
||||
/// </example>
|
||||
private readonly List<double> recentRates = Enumerable.Repeat(1d, recent_rate_count).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// For each given <see cref="HitObject"/> in the map, this dictionary maps the object onto the latest end time of any other object
|
||||
/// that precedes the end time of the given object.
|
||||
/// This can be loosely interpreted as the end time of the preceding hit object in rulesets that do not have overlapping hit objects.
|
||||
/// </summary>
|
||||
private readonly Dictionary<HitObject, double> precedingEndTimes = new Dictionary<HitObject, double>();
|
||||
|
||||
/// <summary>
|
||||
/// For each given <see cref="HitObject"/> in the map, this dictionary maps the object onto the track rate dequeued from
|
||||
/// <see cref="recentRates"/> (i.e. the oldest value in the queue) when the object is hit. If the hit is then reverted,
|
||||
/// the mapped value can be re-introduced to <see cref="recentRates"/> to properly rewind the queue.
|
||||
/// </summary>
|
||||
private readonly Dictionary<HitObject, double> ratesForRewinding = new Dictionary<HitObject, double>();
|
||||
|
||||
public ModAdaptiveSpeed()
|
||||
{
|
||||
InitialRate.BindValueChanged(val =>
|
||||
{
|
||||
SpeedChange.Value = val.NewValue;
|
||||
targetRate = val.NewValue;
|
||||
});
|
||||
AdjustPitch.BindValueChanged(adjustPitchChanged);
|
||||
}
|
||||
|
||||
public void ApplyToTrack(ITrack track)
|
||||
{
|
||||
this.track = track;
|
||||
|
||||
InitialRate.TriggerChange();
|
||||
AdjustPitch.TriggerChange();
|
||||
recentRates.Clear();
|
||||
recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count));
|
||||
}
|
||||
|
||||
public void ApplyToSample(DrawableSample sample)
|
||||
{
|
||||
sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange);
|
||||
}
|
||||
|
||||
public void Update(Playfield playfield)
|
||||
{
|
||||
SpeedChange.Value = Interpolation.DampContinuously(SpeedChange.Value, targetRate, 50, playfield.Clock.ElapsedFrameTime);
|
||||
}
|
||||
|
||||
public double ApplyToRate(double time, double rate = 1) => rate * InitialRate.Value;
|
||||
|
||||
public void ApplyToDrawableHitObject(DrawableHitObject drawable)
|
||||
{
|
||||
drawable.OnNewResult += (o, result) =>
|
||||
{
|
||||
if (ratesForRewinding.ContainsKey(result.HitObject)) return;
|
||||
if (!shouldProcessResult(result)) return;
|
||||
|
||||
ratesForRewinding.Add(result.HitObject, recentRates[0]);
|
||||
recentRates.RemoveAt(0);
|
||||
|
||||
recentRates.Add(Math.Clamp(getRelativeRateChange(result) * SpeedChange.Value, min_allowable_rate, max_allowable_rate));
|
||||
|
||||
updateTargetRate();
|
||||
};
|
||||
drawable.OnRevertResult += (o, result) =>
|
||||
{
|
||||
if (!ratesForRewinding.ContainsKey(result.HitObject)) return;
|
||||
if (!shouldProcessResult(result)) return;
|
||||
|
||||
recentRates.Insert(0, ratesForRewinding[result.HitObject]);
|
||||
ratesForRewinding.Remove(result.HitObject);
|
||||
|
||||
recentRates.RemoveAt(recentRates.Count - 1);
|
||||
|
||||
updateTargetRate();
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyToBeatmap(IBeatmap beatmap)
|
||||
{
|
||||
var hitObjects = getAllApplicableHitObjects(beatmap.HitObjects).ToList();
|
||||
var endTimes = hitObjects.Select(x => x.GetEndTime()).OrderBy(x => x).Distinct().ToList();
|
||||
|
||||
foreach (HitObject hitObject in hitObjects)
|
||||
{
|
||||
int index = endTimes.BinarySearch(hitObject.GetEndTime());
|
||||
if (index < 0) index = ~index; // BinarySearch returns the next larger element in bitwise complement if there's no exact match
|
||||
index -= 1;
|
||||
|
||||
if (index >= 0)
|
||||
precedingEndTimes.Add(hitObject, endTimes[index]);
|
||||
}
|
||||
}
|
||||
|
||||
private void adjustPitchChanged(ValueChangedEvent<bool> adjustPitchSetting)
|
||||
{
|
||||
track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange);
|
||||
|
||||
track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange);
|
||||
}
|
||||
|
||||
private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue)
|
||||
=> adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo;
|
||||
|
||||
private IEnumerable<HitObject> getAllApplicableHitObjects(IEnumerable<HitObject> hitObjects)
|
||||
{
|
||||
foreach (var hitObject in hitObjects)
|
||||
{
|
||||
if (!(hitObject.HitWindows is HitWindows.EmptyHitWindows))
|
||||
yield return hitObject;
|
||||
|
||||
foreach (HitObject nested in getAllApplicableHitObjects(hitObject.NestedHitObjects))
|
||||
yield return nested;
|
||||
}
|
||||
}
|
||||
|
||||
private bool shouldProcessResult(JudgementResult result)
|
||||
{
|
||||
if (!result.Type.AffectsAccuracy()) return false;
|
||||
if (!precedingEndTimes.ContainsKey(result.HitObject)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private double getRelativeRateChange(JudgementResult result)
|
||||
{
|
||||
if (!result.IsHit)
|
||||
return rate_change_on_miss;
|
||||
|
||||
double prevEndTime = precedingEndTimes[result.HitObject];
|
||||
return Math.Clamp(
|
||||
(result.HitObject.GetEndTime() - prevEndTime) / (result.TimeAbsolute - prevEndTime),
|
||||
min_allowable_rate_change,
|
||||
max_allowable_rate_change
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update <see cref="targetRate"/> based on the values in <see cref="recentRates"/>.
|
||||
/// </summary>
|
||||
private void updateTargetRate()
|
||||
{
|
||||
// Compare values in recentRates to see how consistent the player's speed is
|
||||
// If the player hits half of the notes too fast and the other half too slow: Abs(consistency) = 0
|
||||
// If the player hits all their notes too fast or too slow: Abs(consistency) = recent_rate_count - 1
|
||||
int consistency = 0;
|
||||
|
||||
for (int i = 1; i < recentRates.Count; i++)
|
||||
{
|
||||
consistency += Math.Sign(recentRates[i] - recentRates[i - 1]);
|
||||
}
|
||||
|
||||
// Scale the rate adjustment based on consistency
|
||||
targetRate = Interpolation.Lerp(targetRate, recentRates.Average(), Math.Abs(consistency) / (recent_rate_count - 1d));
|
||||
}
|
||||
}
|
||||
}
|
@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mods
|
||||
|
||||
public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value;
|
||||
|
||||
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) };
|
||||
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed) };
|
||||
|
||||
public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x";
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mods
|
||||
[SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
|
||||
public abstract BindableBool AdjustPitch { get; }
|
||||
|
||||
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust) };
|
||||
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) };
|
||||
|
||||
public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x";
|
||||
|
||||
|
@ -131,7 +131,7 @@ namespace osu.Game.Scoring
|
||||
public async Task<long> GetTotalScoreAsync([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: This is required for playlist aggregate scores. They should likely not be getting here in the first place.
|
||||
if (string.IsNullOrEmpty(score.BeatmapInfo.Hash))
|
||||
if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash))
|
||||
return score.TotalScore;
|
||||
|
||||
int beatmapMaxCombo;
|
||||
|
@ -128,7 +128,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
|
||||
{
|
||||
new OsuMenuItem("Create copy", MenuItemType.Standard, () =>
|
||||
{
|
||||
lounge?.Open(Room.DeepClone());
|
||||
lounge?.OpenCopy(Room);
|
||||
})
|
||||
};
|
||||
|
||||
|
@ -20,6 +20,7 @@ using osu.Framework.Threading;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Input;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.Rooms;
|
||||
using osu.Game.Overlays;
|
||||
using osu.Game.Rulesets;
|
||||
@ -63,6 +64,9 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
|
||||
[Resolved]
|
||||
private IBindable<RulesetInfo> ruleset { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
[CanBeNull]
|
||||
private IDisposable joiningRoomOperation { get; set; }
|
||||
|
||||
@ -310,6 +314,42 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
|
||||
});
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Copies a room and opens it as a fresh (not-yet-created) one.
|
||||
/// </summary>
|
||||
/// <param name="room">The room to copy.</param>
|
||||
public void OpenCopy(Room room)
|
||||
{
|
||||
Debug.Assert(room.RoomID.Value != null);
|
||||
|
||||
if (joiningRoomOperation != null)
|
||||
return;
|
||||
|
||||
joiningRoomOperation = ongoingOperationTracker?.BeginOperation();
|
||||
|
||||
var req = new GetRoomRequest(room.RoomID.Value.Value);
|
||||
|
||||
req.Success += r =>
|
||||
{
|
||||
// ID must be unset as we use this as a marker for whether this is a client-side (not-yet-created) room or not.
|
||||
r.RoomID.Value = null;
|
||||
|
||||
Open(r);
|
||||
|
||||
joiningRoomOperation?.Dispose();
|
||||
joiningRoomOperation = null;
|
||||
};
|
||||
|
||||
req.Failure += exception =>
|
||||
{
|
||||
Logger.Error(exception, "Couldn't create a copy of this room.");
|
||||
joiningRoomOperation?.Dispose();
|
||||
joiningRoomOperation = null;
|
||||
};
|
||||
|
||||
api.Queue(req);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push a room as a new subscreen.
|
||||
/// </summary>
|
||||
|
@ -17,6 +17,9 @@ namespace osu.Game.Screens.Select.Carousel
|
||||
|
||||
public override bool IsPresent => base.IsPresent || Item?.Visible == true;
|
||||
|
||||
public override bool HandlePositionalInput => Item?.Visible == true;
|
||||
public override bool PropagatePositionalInputSubTree => Item?.Visible == true;
|
||||
|
||||
public readonly CarouselHeader Header;
|
||||
|
||||
/// <summary>
|
||||
|
@ -64,7 +64,7 @@ namespace osu.Game.Users.Drawables
|
||||
|
||||
private void openProfile()
|
||||
{
|
||||
if (user?.Id > 1)
|
||||
if (user?.Id > 1 || !string.IsNullOrEmpty(user?.Username))
|
||||
game?.ShowUser(user);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user