1
0
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:
Dean Herbert 2022-03-09 23:04:18 +09:00 committed by GitHub
commit 94ff6a338f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 780 additions and 138 deletions

View File

@ -258,6 +258,7 @@ namespace osu.Game.Rulesets.Mania
{
new MultiMod(new ModWindUp(), new ModWindDown()),
new ManiaModMuted(),
new ModAdaptiveSpeed()
};
default:

View File

@ -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;
}
}
}

View File

@ -195,6 +195,7 @@ namespace osu.Game.Rulesets.Osu
new OsuModMuted(),
new OsuModNoScope(),
new OsuModAimAssist(),
new ModAdaptiveSpeed()
};
case ModType.System:

View File

@ -151,6 +151,7 @@ namespace osu.Game.Rulesets.Taiko
{
new MultiMod(new ModWindUp(), new ModWindDown()),
new TaikoModMuted(),
new ModAdaptiveSpeed()
};
default:

View File

@ -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]

View File

@ -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()

View File

@ -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") }
}
};
}

View File

@ -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());

View File

@ -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.

View File

@ -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);
}
}
}

View File

@ -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;
});
}
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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)

View 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)
};
}
}
}

View File

@ -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))

View 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));
}
}
}

View File

@ -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";
}

View File

@ -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";

View File

@ -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;

View File

@ -128,7 +128,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
{
new OsuMenuItem("Create copy", MenuItemType.Standard, () =>
{
lounge?.Open(Room.DeepClone());
lounge?.OpenCopy(Room);
})
};

View File

@ -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>

View File

@ -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>

View File

@ -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);
}