Merge branch 'master' into grids

This commit is contained in:
Bartłomiej Dach 2024-10-16 09:55:27 +02:00
commit dcf78a6e2c
No known key found for this signature in database
25 changed files with 553 additions and 107 deletions

View File

@ -273,22 +273,23 @@ jobs:
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
path: ${{ steps.query.outputs.DATA_PKG }}
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
wget -q -O "${{ steps.query.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query.outputs.DATA_PKG }}"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_PKG }}"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
@ -304,22 +305,23 @@ jobs:
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
path: ${{ steps.query.outputs.DATA_PKG }}
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
wget -q -O "${{ steps.query.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query.outputs.DATA_PKG }}"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_PKG }}"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"

View File

@ -1,5 +1,6 @@
{
"recommendations": [
"ms-dotnettools.csharp"
"editorconfig.editorconfig",
"ms-dotnettools.csdevkit"
]
}

View File

@ -53,7 +53,7 @@ Please make sure you have the following prerequisites:
- A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed.
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) plugin installed.
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed.
### Downloading the source code

View File

@ -240,39 +240,74 @@ public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null
points = originalConvexHull!;
foreach (var point in points)
{
scale = clampToBound(scale, point, Vector2.Zero);
scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE);
}
scale = clampToBounds(scale, point, Vector2.Zero, OsuPlayfield.BASE_SIZE);
return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
return scale;
float minPositiveComponent(Vector2 v) => MathF.Min(v.X < 0 ? float.PositiveInfinity : v.X, v.Y < 0 ? float.PositiveInfinity : v.Y);
Vector2 clampToBound(Vector2 s, Vector2 p, Vector2 bound)
// Clamps the scale vector s such that the point p scaled by s is within the rectangle defined by lowerBounds and upperBounds
Vector2 clampToBounds(Vector2 s, Vector2 p, Vector2 lowerBounds, Vector2 upperBounds)
{
p -= actualOrigin;
bound -= actualOrigin;
lowerBounds -= actualOrigin;
upperBounds -= actualOrigin;
// a.X is the rotated X component of p with respect to the X bounds
// a.Y is the rotated X component of p with respect to the Y bounds
// b.X is the rotated Y component of p with respect to the X bounds
// b.Y is the rotated Y component of p with respect to the Y bounds
var a = new Vector2(cos * cos * p.X - sin * cos * p.Y, -sin * cos * p.X + sin * sin * p.Y);
var b = new Vector2(sin * sin * p.X + sin * cos * p.Y, sin * cos * p.X + cos * cos * p.Y);
float sLowerBound, sUpperBound;
switch (adjustAxis)
{
case Axes.X:
s.X = MathF.Min(scale.X, minPositiveComponent(Vector2.Divide(bound - b, a)));
(sLowerBound, sUpperBound) = computeBounds(lowerBounds - b, upperBounds - b, a);
s.X = MathHelper.Clamp(s.X, sLowerBound, sUpperBound);
break;
case Axes.Y:
s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b)));
(sLowerBound, sUpperBound) = computeBounds(lowerBounds - a, upperBounds - a, b);
s.Y = MathHelper.Clamp(s.Y, sLowerBound, sUpperBound);
break;
case Axes.Both:
s = Vector2.ComponentMin(s, s * minPositiveComponent(Vector2.Divide(bound, a * s.X + b * s.Y)));
// Here we compute the bounds for the magnitude multiplier of the scale vector
// Therefore the ratio s.X / s.Y will be maintained
(sLowerBound, sUpperBound) = computeBounds(lowerBounds, upperBounds, a * s.X + b * s.Y);
s.X = s.X < 0
? MathHelper.Clamp(s.X, s.X * sUpperBound, s.X * sLowerBound)
: MathHelper.Clamp(s.X, s.X * sLowerBound, s.X * sUpperBound);
s.Y = s.Y < 0
? MathHelper.Clamp(s.Y, s.Y * sUpperBound, s.Y * sLowerBound)
: MathHelper.Clamp(s.Y, s.Y * sLowerBound, s.Y * sUpperBound);
break;
}
return s;
}
// Computes the bounds for the magnitude of the scaled point p with respect to the bounds lowerBounds and upperBounds
(float, float) computeBounds(Vector2 lowerBounds, Vector2 upperBounds, Vector2 p)
{
var sLowerBounds = Vector2.Divide(lowerBounds, p);
var sUpperBounds = Vector2.Divide(upperBounds, p);
// If the point is negative, then the bounds are flipped
if (p.X < 0)
(sLowerBounds.X, sUpperBounds.X) = (sUpperBounds.X, sLowerBounds.X);
if (p.Y < 0)
(sLowerBounds.Y, sUpperBounds.Y) = (sUpperBounds.Y, sLowerBounds.Y);
// If the point is at zero, then any scale will have no effect on the point so the bounds are infinite
// The float division would already give us infinity for the bounds, but the sign is not consistent so we have to manually set it
if (Precision.AlmostEquals(p.X, 0))
(sLowerBounds.X, sUpperBounds.X) = (float.NegativeInfinity, float.PositiveInfinity);
if (Precision.AlmostEquals(p.Y, 0))
(sLowerBounds.Y, sUpperBounds.Y) = (float.NegativeInfinity, float.PositiveInfinity);
return (MathF.Max(sLowerBounds.X, sLowerBounds.Y), MathF.Min(sUpperBounds.X, sUpperBounds.Y));
}
}
private void moveSelectionInBounds()

View File

@ -136,8 +136,26 @@ protected override void LoadComplete()
});
scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue });
xCheckBox.Current.BindValueChanged(x => setAxis(x.NewValue, yCheckBox.Current.Value));
yCheckBox.Current.BindValueChanged(y => setAxis(xCheckBox.Current.Value, y.NewValue));
xCheckBox.Current.BindValueChanged(_ =>
{
if (!xCheckBox.Current.Value && !yCheckBox.Current.Value)
{
yCheckBox.Current.Value = true;
return;
}
updateAxes();
});
yCheckBox.Current.BindValueChanged(_ =>
{
if (!xCheckBox.Current.Value && !yCheckBox.Current.Value)
{
xCheckBox.Current.Value = true;
return;
}
updateAxes();
});
selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value);
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;
@ -152,6 +170,12 @@ protected override void LoadComplete()
});
}
private void updateAxes()
{
scaleInfo.Value = scaleInfo.Value with { XAxis = xCheckBox.Current.Value, YAxis = yCheckBox.Current.Value };
updateMinMaxScale();
}
private void updateAxisCheckBoxesEnabled()
{
if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre)
@ -175,12 +199,14 @@ private void toggleAxisAvailable(Bindable<bool> axisBindable, bool available)
axisBindable.Disabled = !available;
}
private void updateMaxScale()
private void updateMinMaxScale()
{
if (!scaleHandler.OriginalSurroundingQuad.HasValue)
return;
const float min_scale = 0.5f;
const float max_scale = 10;
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
if (!scaleInfo.Value.XAxis)
@ -189,12 +215,21 @@ private void updateMaxScale()
scale.Y = max_scale;
scaleInputBindable.MaxValue = MathF.Max(1, MathF.Min(scale.X, scale.Y));
scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(min_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
if (!scaleInfo.Value.XAxis)
scale.X = min_scale;
if (!scaleInfo.Value.YAxis)
scale.Y = min_scale;
scaleInputBindable.MinValue = MathF.Min(1, MathF.Max(scale.X, scale.Y));
}
private void setOrigin(ScaleOrigin origin)
{
scaleInfo.Value = scaleInfo.Value with { Origin = origin };
updateMaxScale();
updateMinMaxScale();
updateAxisCheckBoxesEnabled();
}
@ -219,21 +254,26 @@ private void setOrigin(ScaleOrigin origin)
}
}
private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y;
private Axes getAdjustAxis(PreciseScaleInfo scale)
{
var result = Axes.None;
if (scale.XAxis)
result |= Axes.X;
if (scale.YAxis)
result |= Axes.Y;
return result;
}
private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0;
private void setAxis(bool x, bool y)
{
scaleInfo.Value = scaleInfo.Value with { XAxis = x, YAxis = y };
updateMaxScale();
}
protected override void PopIn()
{
base.PopIn();
scaleHandler.Begin();
updateMaxScale();
updateMinMaxScale();
}
protected override void PopOut()

View File

@ -6,10 +6,12 @@
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Screens;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.Metadata;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.Metadata;
@ -81,6 +83,38 @@ public void TestNotifications()
AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
AddUntilStep("wait for screen", () => screen.IsCurrentScreen());
AddStep("daily challenge ended", () => metadataClient.DailyChallengeInfo.Value = null);
AddAssert("notification posted", () => notificationOverlay.AllNotifications.OfType<SimpleNotification>().Any(n => n.Text == DailyChallengeStrings.ChallengeEndedNotification));
}
[Test]
public void TestConclusionNotificationDoesNotFireOnDisconnect()
{
var room = new Room
{
RoomID = { Value = 1234 },
Name = { Value = "Daily Challenge: June 4, 2024" },
Playlist =
{
new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First())
{
RequiredMods = [new APIMod(new OsuModTraceable())],
AllowedMods = [new APIMod(new OsuModDoubleTime())]
}
},
EndDate = { Value = DateTimeOffset.Now.AddHours(12) },
Category = { Value = RoomCategory.DailyChallenge }
};
AddStep("add room", () => API.Perform(new CreateRoomRequest(room)));
AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 });
Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!;
AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
AddUntilStep("wait for screen", () => screen.IsCurrentScreen());
AddStep("disconnect from metadata server", () => metadataClient.Disconnect());
AddUntilStep("wait for disconnection", () => metadataClient.DailyChallengeInfo.Value, () => Is.Null);
AddAssert("no notification posted", () => notificationOverlay.AllNotifications, () => Is.Empty);
AddStep("reconnect to metadata server", () => metadataClient.Reconnect());
}
}
}

View File

@ -79,5 +79,114 @@ public void TestMusicNavigationActions()
trackChangeQueue.Peek().changeDirection == TrackChangeDirection.Next);
AddAssert("track actually changed", () => !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo));
}
[Test]
public void TestShuffleBackwards()
{
Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!;
AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true);
// ensure we have at least two beatmaps available to identify the direction the music controller navigated to.
AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5);
AddStep("ensure nonzero track duration", () => Game.Realm.Write(r =>
{
// this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`),
// but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`.
// do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore.
foreach (var beatmap in r.All<BeatmapInfo>().Where(b => b.Length == 0))
beatmap.Length = 60_000;
}));
AddStep("bind to track change", () =>
{
trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>();
Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection));
});
AddStep("seek track to 6 second", () => Game.MusicController.SeekTo(6000));
AddUntilStep("wait for current time to update", () => Game.MusicController.CurrentTrack.CurrentTime > 5000);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddAssert("no track change", () => trackChangeQueue.Count == 0);
AddUntilStep("track restarted", () => Game.MusicController.CurrentTrack.CurrentTime < 5000);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddUntilStep("track changed", () => trackChangeQueue.Count == 1);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddUntilStep("track changed", () => trackChangeQueue.Count == 2);
AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
AddUntilStep("track changed", () =>
trackChangeQueue.Count == 3 && !trackChangeQueue.ElementAt(1).working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo));
}
[Test]
public void TestShuffleForwards()
{
Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!;
AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true);
// ensure we have at least two beatmaps available to identify the direction the music controller navigated to.
AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5);
AddStep("ensure nonzero track duration", () => Game.Realm.Write(r =>
{
// this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`),
// but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`.
// do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore.
foreach (var beatmap in r.All<BeatmapInfo>().Where(b => b.Length == 0))
beatmap.Length = 60_000;
}));
AddStep("bind to track change", () =>
{
trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>();
Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection));
});
AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
AddUntilStep("track changed", () => trackChangeQueue.Count == 1);
AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
AddUntilStep("track changed", () => trackChangeQueue.Count == 2);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddUntilStep("track changed", () =>
trackChangeQueue.Count == 3 && !trackChangeQueue.ElementAt(1).working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo));
}
[Test]
public void TestShuffleBackAndForth()
{
Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!;
AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true);
// ensure we have at least two beatmaps available to identify the direction the music controller navigated to.
AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5);
AddStep("ensure nonzero track duration", () => Game.Realm.Write(r =>
{
// this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`),
// but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`.
// do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore.
foreach (var beatmap in r.All<BeatmapInfo>().Where(b => b.Length == 0))
beatmap.Length = 60_000;
}));
AddStep("bind to track change", () =>
{
trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>();
Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection));
});
AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
AddUntilStep("track changed", () => trackChangeQueue.Count == 1);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddUntilStep("track changed", () =>
trackChangeQueue.Count == 2 && !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo));
}
}
}

View File

@ -8,11 +8,13 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
@ -90,6 +92,48 @@ public void TestNoUserBest()
AddAssert("no best score displayed", () => scoresContainer.ChildrenOfType<DrawableTopScore>().Count() == 1);
}
[Test]
public void TestHitResultsWithSameNameAreGrouped()
{
AddStep("Load scores without user best", () =>
{
var allScores = createScores();
allScores.UserScore = null;
scoresContainer.Scores = allScores;
});
AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType<ScoreTableRowBackground>().Any());
AddAssert("only one column for slider end", () =>
{
ScoreTable scoreTable = scoresContainer.ChildrenOfType<ScoreTable>().First();
return scoreTable.Columns.Count(c => c.Header.Equals("slider end")) == 1;
});
AddAssert("all rows show non-zero slider ends", () =>
{
ScoreTable scoreTable = scoresContainer.ChildrenOfType<ScoreTable>().First();
int sliderEndColumnIndex = Array.FindIndex(scoreTable.Columns, c => c != null && c.Header.Equals("slider end"));
bool sliderEndFilledInEachRow = true;
for (int i = 0; i < scoreTable.Content?.GetLength(0); i++)
{
switch (scoreTable.Content[i, sliderEndColumnIndex])
{
case OsuSpriteText text:
if (text.Text.Equals(0.0d.ToLocalisableString(@"N0")))
sliderEndFilledInEachRow = false;
break;
default:
sliderEndFilledInEachRow = false;
break;
}
}
return sliderEndFilledInEachRow;
});
}
[Test]
public void TestUserBest()
{
@ -287,13 +331,17 @@ private APIScoresCollection createScores()
const int initial_great_count = 2000;
const int initial_tick_count = 100;
const int initial_slider_end_count = 500;
int greatCount = initial_great_count;
int tickCount = initial_tick_count;
int sliderEndCount = initial_slider_end_count;
foreach (var s in scores.Scores)
foreach (var (score, index) in scores.Scores.Select((s, i) => (s, i)))
{
s.Statistics = new Dictionary<HitResult, int>
HitResult sliderEndResult = index % 2 == 0 ? HitResult.SliderTailHit : HitResult.SmallTickHit;
score.Statistics = new Dictionary<HitResult, int>
{
{ HitResult.Great, greatCount },
{ HitResult.LargeTickHit, tickCount },
@ -301,10 +349,19 @@ private APIScoresCollection createScores()
{ HitResult.Meh, RNG.Next(100) },
{ HitResult.Miss, initial_great_count - greatCount },
{ HitResult.LargeTickMiss, initial_tick_count - tickCount },
{ sliderEndResult, sliderEndCount },
};
// Some hit results, including SliderTailHit and SmallTickHit, are only displayed
// when the maximum number is known
score.MaximumStatistics = new Dictionary<HitResult, int>
{
{ sliderEndResult, initial_slider_end_count },
};
greatCount -= 100;
tickCount -= RNG.Next(1, 5);
sliderEndCount -= 20;
}
return scores;

View File

@ -285,7 +285,8 @@ public List<BeatmapSetInfo> GetAllUsableBeatmapSets()
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public BeatmapInfo? QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => Realm.Run(r => r.All<BeatmapInfo>().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach());
public BeatmapInfo? QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => Realm.Run(r =>
r.All<BeatmapInfo>().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach());
/// <summary>
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
@ -313,6 +314,23 @@ public void DeleteAllVideos()
});
}
public void ResetAllOffsets()
{
const string reset_complete_message = "All offsets have been reset!";
Realm.Write(r =>
{
var items = r.All<BeatmapInfo>();
foreach (var beatmap in items)
{
if (beatmap.UserSettings.Offset != 0)
beatmap.UserSettings.Offset = 0;
}
PostNotification?.Invoke(new ProgressCompletionNotification { Text = reset_complete_message });
});
}
public void Delete(Expression<Func<BeatmapSetInfo, bool>>? filter = null, bool silent = false)
{
Realm.Run(r =>

View File

@ -93,8 +93,9 @@ public class RealmAccess : IDisposable
/// 40 2023-12-21 Add ScoreInfo.Version to keep track of which build scores were set on.
/// 41 2024-04-17 Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances.
/// 42 2024-08-07 Update mania key bindings to reflect changes to ManiaAction
/// 43 2024-10-14 Reset keybind for toggling FPS display to avoid conflict with "convert to stream" in the editor, if not already changed by user.
/// </summary>
private const int schema_version = 42;
private const int schema_version = 43;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -1192,6 +1193,21 @@ void remapKeyBinding(int oldAction, int newAction)
}
break;
case 43:
{
// Clear default bindings for "Toggle FPS Display",
// as it conflicts with "Convert to Stream" in the editor.
// Only apply change if set to the conflicting bind
// i.e. has been manually rebound by the user.
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
var toggleFpsBind = keyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleFPSDisplay);
if (toggleFpsBind != null && toggleFpsBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.F }))
migration.NewRealm.Remove(toggleFpsBind);
break;
}
}
Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");

View File

@ -5,6 +5,7 @@
using System;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.UserInterface
{
@ -20,7 +21,7 @@ public abstract class TernaryStateMenuItem : StatefulMenuItem<TernaryState>
/// <param name="nextStateFunction">A function to inform what the next state should be when this item is clicked.</param>
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
protected TernaryStateMenuItem(string text, Func<TernaryState, TernaryState> nextStateFunction, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
protected TernaryStateMenuItem(LocalisableString text, Func<TernaryState, TernaryState> nextStateFunction, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: base(text, nextStateFunction, type, action)
{
}

View File

@ -4,6 +4,7 @@
#nullable disable
using System;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.UserInterface
{
@ -18,7 +19,7 @@ public class TernaryStateRadioMenuItem : TernaryStateMenuItem
/// <param name="text">The text to display.</param>
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
public TernaryStateRadioMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
public TernaryStateRadioMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: base(text, getNextState, type, action)
{
}

View File

@ -101,7 +101,7 @@ public static IEnumerable<GlobalAction> GetGlobalActionsFor(GlobalActionCategory
new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay),
new KeyBinding(InputKey.None, GlobalAction.ToggleFPSDisplay),
new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor),

View File

@ -19,6 +19,11 @@ public static class DeleteConfirmationContentStrings
/// </summary>
public static LocalisableString BeatmapVideos => new TranslatableString(getKey(@"beatmap_videos"), @"Are you sure you want to delete all beatmaps videos? This cannot be undone!");
/// <summary>
/// "Are you sure you want to reset all local beatmap offsets? This cannot be undone!"
/// </summary>
public static LocalisableString Offsets => new TranslatableString(getKey(@"offsets"), @"Are you sure you want to reset all local beatmap offsets? This cannot be undone!");
/// <summary>
/// "Are you sure you want to delete all skins? This cannot be undone!"
/// </summary>

View File

@ -59,6 +59,11 @@ public static class MaintenanceSettingsStrings
/// </summary>
public static LocalisableString DeleteAllBeatmapVideos => new TranslatableString(getKey(@"delete_all_beatmap_videos"), @"Delete ALL beatmap videos");
/// <summary>
/// "Reset ALL beatmap offsets"
/// </summary>
public static LocalisableString ResetAllOffsets => new TranslatableString(getKey(@"reset_all_offsets"), @"Reset ALL beatmap offsets");
/// <summary>
/// "Delete ALL scores"
/// </summary>

View File

@ -49,6 +49,51 @@ public static class SkinEditorStrings
/// </summary>
public static LocalisableString RevertToDefaultDescription => new TranslatableString(getKey(@"revert_to_default_description"), @"All layout elements for layers in the current screen will be reset to defaults.");
/// <summary>
/// "Closest"
/// </summary>
public static LocalisableString Closest => new TranslatableString(getKey(@"closest"), @"Closest");
/// <summary>
/// "Anchor"
/// </summary>
public static LocalisableString Anchor => new TranslatableString(getKey(@"anchor"), @"Anchor");
/// <summary>
/// "Origin"
/// </summary>
public static LocalisableString Origin => new TranslatableString(getKey(@"origin"), @"Origin");
/// <summary>
/// "Reset position"
/// </summary>
public static LocalisableString ResetPosition => new TranslatableString(getKey(@"reset_position"), @"Reset position");
/// <summary>
/// "Reset rotation"
/// </summary>
public static LocalisableString ResetRotation => new TranslatableString(getKey(@"reset_rotation"), @"Reset rotation");
/// <summary>
/// "Reset scale"
/// </summary>
public static LocalisableString ResetScale => new TranslatableString(getKey(@"reset_scale"), @"Reset scale");
/// <summary>
/// "Bring to front"
/// </summary>
public static LocalisableString BringToFront => new TranslatableString(getKey(@"bring_to_front"), @"Bring to front");
/// <summary>
/// "Send to back"
/// </summary>
public static LocalisableString SendToBack => new TranslatableString(getKey(@"send_to_back"), @"Send to back");
/// <summary>
/// "Current working layer"
/// </summary>
public static LocalisableString CurrentWorkingLayer => new TranslatableString(getKey(@"current_working_layer"), @"Current working layer");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -9,7 +9,6 @@
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
@ -58,9 +57,11 @@ public ScoreTable()
}
/// <summary>
/// The statistics that appear in the table, in order of appearance.
/// The names of the statistics that appear in the table. If multiple HitResults have the same
/// DisplayName (for example, "slider end" is the name for both <see cref="HitResult.SliderTailHit"/> and <see cref="HitResult.SmallTickHit"/>
/// in osu!) the name will only be listed once.
/// </summary>
private readonly List<(HitResult result, LocalisableString displayName)> statisticResultTypes = new List<(HitResult, LocalisableString)>();
private readonly List<LocalisableString> statisticResultNames = new List<LocalisableString>();
private bool showPerformancePoints;
@ -72,7 +73,7 @@ public void DisplayScores(IReadOnlyList<ScoreInfo> scores, bool showPerformanceC
return;
showPerformancePoints = showPerformanceColumn;
statisticResultTypes.Clear();
statisticResultNames.Clear();
for (int i = 0; i < scores.Count; i++)
backgroundFlow.Add(new ScoreTableRowBackground(i, scores[i], row_height));
@ -105,20 +106,18 @@ private TableColumn[] createHeaders(IReadOnlyList<ScoreInfo> scores)
var ruleset = scores.First().Ruleset.CreateInstance();
foreach (var result in EnumExtensions.GetValuesInOrder<HitResult>())
foreach (var resultGroup in ruleset.GetHitResults().GroupBy(r => r.displayName))
{
if (!allScoreStatistics.Contains(result))
if (!resultGroup.Any(r => allScoreStatistics.Contains(r.result)))
continue;
// for the time being ignore bonus result types.
// this is not being sent from the API and will be empty in all cases.
if (result.IsBonus())
if (resultGroup.All(r => r.result.IsBonus()))
continue;
var displayName = ruleset.GetDisplayNameForHitResult(result);
columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60)));
statisticResultTypes.Add((result, displayName));
columns.Add(new TableColumn(resultGroup.Key, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60)));
statisticResultNames.Add(resultGroup.Key);
}
if (showPerformancePoints)
@ -167,14 +166,25 @@ private Drawable[] createContent(int index, ScoreInfo score)
#pragma warning restore 618
};
var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.Result);
var availableStatistics = score.GetStatisticsForDisplay().ToLookup(tuple => tuple.DisplayName);
foreach (var result in statisticResultTypes)
foreach (var columnName in statisticResultNames)
{
if (!availableStatistics.TryGetValue(result.result, out var stat))
stat = new HitResultDisplayStatistic(result.result, 0, null, result.displayName);
int count = 0;
int? maxCount = null;
content.Add(new StatisticText(stat.Count, stat.MaxCount, @"N0") { Colour = stat.Count == 0 ? Color4.Gray : Color4.White });
if (availableStatistics.Contains(columnName))
{
maxCount = 0;
foreach (var s in availableStatistics[columnName])
{
count += s.Count;
maxCount += s.MaxCount;
}
}
content.Add(new StatisticText(count, maxCount, @"N0") { Colour = count == 0 ? Color4.Gray : Color4.White });
}
if (showPerformancePoints)

View File

@ -74,6 +74,7 @@ public partial class MusicController : CompositeDrawable
private readonly Bindable<RandomSelectAlgorithm> randomSelectAlgorithm = new Bindable<RandomSelectAlgorithm>();
private readonly List<Live<BeatmapSetInfo>> previousRandomSets = new List<Live<BeatmapSetInfo>>();
private int randomHistoryDirection;
private int lastRandomTrackDirection;
[BackgroundDependencyLoader]
private void load(AudioManager audio, OsuConfigManager configManager)
@ -370,14 +371,24 @@ private bool next(bool allowProtectedTracks)
}
private Live<BeatmapSetInfo>? getNextRandom(int direction, bool allowProtectedTracks)
{
try
{
Live<BeatmapSetInfo> result;
var possibleSets = getBeatmapSets().Where(s => !s.Value.Protected || allowProtectedTracks).ToArray();
var possibleSets = getBeatmapSets().Where(s => !s.Value.Protected || allowProtectedTracks).ToList();
if (possibleSets.Length == 0)
if (possibleSets.Count == 0)
return null;
// if there is only one possible set left, play it, even if it is the same as the current track.
// looping is preferable over playing nothing.
if (possibleSets.Count == 1)
return possibleSets.Single();
// now that we actually know there is a choice, do not allow the current track to be played again.
possibleSets.RemoveAll(s => s.Value.Equals(current?.BeatmapSetInfo));
// condition below checks if the signs of `randomHistoryDirection` and `direction` are opposite and not zero.
// if that is the case, it means that the user had previously chosen next track `randomHistoryDirection` times and wants to go back,
// or that the user had previously chosen previous track `randomHistoryDirection` times and wants to go forward.
@ -385,31 +396,42 @@ private bool next(bool allowProtectedTracks)
if (randomHistoryDirection * direction < 0)
{
Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count);
result = previousRandomSets[^1];
// if the user has been shuffling backwards and now going forwards (or vice versa),
// the topmost item from history needs to be discarded because it's the *current* track.
if (direction * lastRandomTrackDirection < 0)
{
previousRandomSets.RemoveAt(previousRandomSets.Count - 1);
randomHistoryDirection += direction;
}
if (previousRandomSets.Count > 0)
{
result = previousRandomSets[^1];
previousRandomSets.RemoveAt(previousRandomSets.Count - 1);
return result;
}
}
// if the early-return above didn't cover it, it means that we have no history to fall back on
// and need to actually choose something random.
switch (randomSelectAlgorithm.Value)
{
case RandomSelectAlgorithm.Random:
result = possibleSets[RNG.Next(possibleSets.Length)];
result = possibleSets[RNG.Next(possibleSets.Count)];
break;
case RandomSelectAlgorithm.RandomPermutation:
var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToArray();
var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToList();
if (notYetPlayedSets.Length == 0)
if (notYetPlayedSets.Count == 0)
{
notYetPlayedSets = possibleSets;
previousRandomSets.Clear();
randomHistoryDirection = 0;
}
result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Length)];
result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Count)];
break;
default:
@ -417,9 +439,14 @@ private bool next(bool allowProtectedTracks)
}
previousRandomSets.Add(result);
randomHistoryDirection += direction;
return result;
}
finally
{
randomHistoryDirection += direction;
lastRandomTrackDirection = direction;
}
}
private void restartTrack()
{

View File

@ -16,6 +16,7 @@ public partial class BeatmapSettings : SettingsSubsection
private SettingsButton deleteBeatmapsButton = null!;
private SettingsButton deleteBeatmapVideosButton = null!;
private SettingsButton resetOffsetsButton = null!;
private SettingsButton restoreButton = null!;
private SettingsButton undeleteButton = null!;
@ -47,6 +48,20 @@ private void load(BeatmapManager beatmaps, IDialogOverlay? dialogOverlay)
}, DeleteConfirmationContentStrings.BeatmapVideos));
}
});
Add(resetOffsetsButton = new DangerousSettingsButton
{
Text = MaintenanceSettingsStrings.ResetAllOffsets,
Action = () =>
{
dialogOverlay?.Push(new MassDeleteConfirmationDialog(() =>
{
resetOffsetsButton.Enabled.Value = false;
Task.Run(beatmaps.ResetAllOffsets).ContinueWith(_ => Schedule(() => resetOffsetsButton.Enabled.Value = true));
}, DeleteConfirmationContentStrings.Offsets));
}
});
AddRange(new Drawable[]
{
restoreButton = new SettingsButton

View File

@ -361,7 +361,7 @@ private void targetChanged(ValueChangedEvent<GlobalSkinnableContainerLookup?> ta
componentsSidebar.Children = new[]
{
new EditorSidebarSection("Current working layer")
new EditorSidebarSection(SkinEditorStrings.CurrentWorkingLayer)
{
Children = new Drawable[]
{

View File

@ -13,6 +13,7 @@
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Localisation;
using osu.Game.Skinning;
using osu.Game.Utils;
using osuTK;
@ -101,19 +102,19 @@ protected override void DeleteItems(IEnumerable<ISerialisableDrawable> items) =>
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<ISerialisableDrawable>> selection)
{
var closestItem = new TernaryStateRadioMenuItem("Closest", MenuItemType.Standard, _ => applyClosestAnchors())
var closestItem = new TernaryStateRadioMenuItem(SkinEditorStrings.Closest, MenuItemType.Standard, _ => applyClosestAnchors())
{
State = { Value = GetStateFromSelection(selection, c => !c.Item.UsesFixedAnchor) }
};
yield return new OsuMenuItem("Anchor")
yield return new OsuMenuItem(SkinEditorStrings.Anchor)
{
Items = createAnchorItems((d, a) => d.UsesFixedAnchor && ((Drawable)d).Anchor == a, applyFixedAnchors)
.Prepend(closestItem)
.ToArray()
};
yield return originMenu = new OsuMenuItem("Origin");
yield return originMenu = new OsuMenuItem(SkinEditorStrings.Origin);
closestItem.State.BindValueChanged(s =>
{
@ -125,19 +126,19 @@ protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumer
yield return new OsuMenuItemSpacer();
yield return new OsuMenuItem("Reset position", MenuItemType.Standard, () =>
yield return new OsuMenuItem(SkinEditorStrings.ResetPosition, MenuItemType.Standard, () =>
{
foreach (var blueprint in SelectedBlueprints)
((Drawable)blueprint.Item).Position = Vector2.Zero;
});
yield return new OsuMenuItem("Reset rotation", MenuItemType.Standard, () =>
yield return new OsuMenuItem(SkinEditorStrings.ResetRotation, MenuItemType.Standard, () =>
{
foreach (var blueprint in SelectedBlueprints)
((Drawable)blueprint.Item).Rotation = 0;
});
yield return new OsuMenuItem("Reset scale", MenuItemType.Standard, () =>
yield return new OsuMenuItem(SkinEditorStrings.ResetScale, MenuItemType.Standard, () =>
{
foreach (var blueprint in SelectedBlueprints)
{
@ -153,9 +154,9 @@ protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumer
yield return new OsuMenuItemSpacer();
yield return new OsuMenuItem("Bring to front", MenuItemType.Standard, () => skinEditor.BringSelectionToFront());
yield return new OsuMenuItem(SkinEditorStrings.BringToFront, MenuItemType.Standard, () => skinEditor.BringSelectionToFront());
yield return new OsuMenuItem("Send to back", MenuItemType.Standard, () => skinEditor.SendSelectionToBack());
yield return new OsuMenuItem(SkinEditorStrings.SendToBack, MenuItemType.Standard, () => skinEditor.SendSelectionToBack());
yield return new OsuMenuItemSpacer();

View File

@ -410,7 +410,7 @@ private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule((
private void dailyChallengeChanged(ValueChangedEvent<DailyChallengeInfo?> change)
{
if (change.OldValue?.RoomID == room.RoomID.Value && change.NewValue == null)
if (change.OldValue?.RoomID == room.RoomID.Value && change.NewValue == null && metadataClient.IsConnected.Value)
{
notificationOverlay?.Post(new SimpleNotification { Text = DailyChallengeStrings.ChallengeEndedNotification });
}

View File

@ -455,7 +455,19 @@ private void contentIn(double delayBeforeSideDisplays = 0)
MetadataInfo.Loading = true;
content.FadeInFromZero(500, Easing.OutQuint);
content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer);
if (quickRestart)
{
prepareNewPlayer();
content.ScaleTo(1, 650, Easing.OutQuint);
}
else
{
content
.ScaleTo(1, 650, Easing.OutQuint)
.Then()
.Schedule(prepareNewPlayer);
}
using (BeginDelayedSequence(delayBeforeSideDisplays))
{

View File

@ -14,7 +14,7 @@ public partial class BeatmapDeleteDialog : DeletionDialog
public BeatmapDeleteDialog(BeatmapSetInfo beatmapSet)
{
this.beatmapSet = beatmapSet;
BodyText = $@"{beatmapSet.Metadata.Artist} - {beatmapSet.Metadata.Title}";
BodyText = beatmapSet.Metadata.GetDisplayTitleRomanisable(false);
}
[BackgroundDependencyLoader]

View File

@ -13,7 +13,8 @@ namespace osu.Game.Tests.Visual.Metadata
{
public partial class TestMetadataClient : MetadataClient
{
public override IBindable<bool> IsConnected => new BindableBool(true);
public override IBindable<bool> IsConnected => isConnected;
private readonly BindableBool isConnected = new BindableBool(true);
public override IBindable<bool> IsWatchingUserPresence => isWatchingUserPresence;
private readonly BindableBool isWatchingUserPresence = new BindableBool();
@ -98,5 +99,16 @@ public override Task<MultiplayerPlaylistItemStats[]> BeginWatchingMultiplayerRoo
}
public override Task EndWatchingMultiplayerRoom(long id) => Task.CompletedTask;
public void Disconnect()
{
isConnected.Value = false;
dailyChallengeInfo.Value = null;
}
public void Reconnect()
{
isConnected.Value = true;
}
}
}