1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-17 13:23:00 +08:00

Merge branch 'master' into import-all-button

This commit is contained in:
Czer0x
2025-07-24 17:56:47 +02:00
committed by GitHub
Unverified
57 changed files with 1313 additions and 188 deletions
+2 -2
View File
@@ -131,7 +131,7 @@ jobs:
build-only-ios:
name: Build only (iOS)
runs-on: macos-latest
runs-on: macos-15
timeout-minutes: 60
steps:
- name: Checkout
@@ -143,7 +143,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET Workloads
run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json
run: dotnet workload install ios
- name: Build
run: dotnet build -c Debug osu.iOS.slnf
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.718.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.715.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
@@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Catch.Edit
new CheckBananaShowerGap(),
new CheckConcurrentObjects(),
// Spread
new CheckCatchLowestDiffDrainTime(),
// Settings
new CheckCatchAbnormalDifficultySettings(),
};
@@ -0,0 +1,21 @@
// 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 osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Catch.Edit.Checks
{
public class CheckCatchLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21catch#general
yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Platter");
yield return (DifficultyRating.Insane, new TimeSpan(0, 3, 15).TotalMilliseconds, "Rain");
yield return (DifficultyRating.Expert, new TimeSpan(0, 4, 0).TotalMilliseconds, "Overdose");
}
}
}
@@ -0,0 +1,21 @@
// 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 osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Mania.Edit.Checks
{
public class CheckManiaLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21mania#rules
yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Hard");
yield return (DifficultyRating.Insane, new TimeSpan(0, 2, 45).TotalMilliseconds, "Insane");
yield return (DifficultyRating.Expert, new TimeSpan(0, 3, 30).TotalMilliseconds, "Expert");
}
}
}
@@ -16,6 +16,9 @@ namespace osu.Game.Rulesets.Mania.Edit
// Compose
new CheckManiaConcurrentObjects(),
// Spread
new CheckManiaLowestDiffDrainTime(),
// Settings
new CheckKeyCount(),
new CheckManiaAbnormalDifficultySettings(),
@@ -0,0 +1,21 @@
// 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 osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Osu.Edit.Checks
{
public class CheckOsuLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21#general
yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard");
yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane");
yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert");
}
}
}
@@ -23,6 +23,7 @@ namespace osu.Game.Rulesets.Osu.Edit
new CheckTimeDistanceEquality(),
new CheckLowDiffOverlaps(),
new CheckTooShortSliders(),
new CheckOsuLowestDiffDrainTime(),
// Settings
new CheckOsuAbnormalDifficultySettings(),
@@ -0,0 +1,21 @@
// 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 osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Taiko.Edit.Checks
{
public class CheckTaikoLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21taiko#general
yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Muzukashii");
yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Oni");
yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Inner Oni");
}
}
}
@@ -17,6 +17,9 @@ namespace osu.Game.Rulesets.Taiko.Edit
// Compose
new CheckConcurrentObjects(),
// Spread
new CheckTaikoLowestDiffDrainTime(),
// Settings
new CheckTaikoAbnormalDifficultySettings(),
};
@@ -13,6 +13,7 @@ using osu.Framework.Extensions;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Extensions;
using osu.Game.IO.Legacy;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Replays;
@@ -321,6 +322,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
CountryCode = CountryCode.PL
};
scoreInfo.ClientVersion = "2023.1221.0";
scoreInfo.Pauses.AddRange([111111, 222222, 333333]);
var beatmap = new TestBeatmap(ruleset);
var score = new Score
@@ -345,6 +347,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.That(decodedAfterEncode.ScoreInfo.Mods, Is.EqualTo(scoreInfo.Mods));
Assert.That(decodedAfterEncode.ScoreInfo.ClientVersion, Is.EqualTo("2023.1221.0"));
Assert.That(decodedAfterEncode.ScoreInfo.RealmUser.OnlineID, Is.EqualTo(3035836));
Assert.That(decodedAfterEncode.ScoreInfo.Pauses, Is.EquivalentTo(new[] { 111111, 222222, 333333 }));
});
}
@@ -0,0 +1,264 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Extensions;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Editing.Checks
{
[TestFixture]
public class CheckLowestDiffDrainTimeTest
{
private TestCheckLowestDiffDrainTime check = null!;
[SetUp]
public void Setup()
{
check = new TestCheckLowestDiffDrainTime();
}
[Test]
public void TestSingleDifficultyMeetsRequirement()
{
var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 3.5, "Hard"); // 4 minutes
assertOk(beatmap);
}
[Test]
public void TestSingleDifficultyTooShort()
{
var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 3.5, "Hard"); // 2 minutes - too short for Hard
assertTooShort(beatmap);
}
[Test]
public void TestHardDifficultyAtThreshold()
{
var beatmap = createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"); // Exactly 3:30
assertOk(beatmap);
}
[Test]
public void TestHardDifficultyJustUnderThreshold()
{
var beatmap = createBeatmapWithDrainTime((3 * 60 + 29) * 1000, 3.5, "Hard"); // 3:29 - just under threshold
assertTooShort(beatmap);
}
[Test]
public void TestInsaneDifficultyAtThreshold()
{
var beatmap = createBeatmapWithDrainTime((4 * 60 + 15) * 1000, 4.5, "Insane"); // Exactly 4:15
assertOk(beatmap);
}
[Test]
public void TestInsaneDifficultyTooShort()
{
var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"); // 4:00 - too short for Insane
assertTooShort(beatmap);
}
[Test]
public void TestExpertDifficultyAtThreshold()
{
var beatmap = createBeatmapWithDrainTime(5 * 60 * 1000, 5.5, "Expert"); // Exactly 5:00
assertOk(beatmap);
}
[Test]
public void TestExpertDifficultyTooShort()
{
var beatmap = createBeatmapWithDrainTime((4 * 60 + 30) * 1000, 5.5, "Expert"); // 4:30 - too short for Expert
assertTooShort(beatmap);
}
[Test]
public void TestEasyDifficultyMeetsRequirement()
{
var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 1.5, "Easy"); // 2 minutes - should be ok for Easy
assertOk(beatmap);
}
[Test]
public void TestNormalDifficultyMeetsRequirement()
{
var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 2.5, "Normal"); // 2 minutes - should be ok for Normal
assertOk(beatmap);
}
[Test]
public void TestMultipleDifficultiesMeetsRequirement()
{
var difficulties = new List<IBeatmap>
{
createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"), // Hard - lowest difficulty, 3:30
createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 4.5, "Insane"),
createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 5.5, "Expert")
};
// All should be ok because lowest difficulty is Hard and drain time meets Hard requirement
assertOkWithMultipleDifficulties(difficulties[0], difficulties);
assertOkWithMultipleDifficulties(difficulties[1], difficulties);
assertOkWithMultipleDifficulties(difficulties[2], difficulties);
}
[Test]
public void TestMultipleDifficultiesTooShort()
{
var difficulties = new List<IBeatmap>
{
createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"), // Insane - lowest difficulty, 4:00
createBeatmapWithDrainTime(4 * 60 * 1000, 5.5, "Expert") // Same drain time
};
// Should be too short because lowest difficulty is Insane and requires 4:15
assertTooShortWithMultipleDifficulties(difficulties[0], difficulties);
assertTooShortWithMultipleDifficulties(difficulties[1], difficulties);
}
[Test]
public void TestPlayTimeVsDrainTimeNotHighestDifficulty()
{
var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time
expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break
var difficulties = new List<IBeatmap>
{
expertBeatmap, // Expert - 5:00 play, 4:20 drain
createBeatmapWithPlayTime(5 * 60 * 1000, 6.5, "ExpertPlus") // ExpertPlus - highest difficulty
};
// The Expert difficulty (not highest) should use play time (5:00) and pass the Expert requirement
assertOkWithMultipleDifficulties(difficulties[0], difficulties);
}
[Test]
public void TestPlayTimeVsDrainTimeHighestDifficulty()
{
var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time
expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break
// As the highest difficulty with breaks > 30s, it should use drain time and fail
assertTooShort(expertBeatmap);
}
private IBeatmap createBeatmapWithDrainTime(double drainTimeMs, double starRating = 3.5, string difficultyName = "Default")
{
var beatmap = new Beatmap<HitObject>
{
BeatmapInfo = new BeatmapInfo
{
StarRating = starRating,
DifficultyName = difficultyName,
Ruleset = new OsuRuleset().RulesetInfo
},
HitObjects = new List<HitObject>
{
new HitObject { StartTime = 0 },
new HitObject { StartTime = drainTimeMs } // Last object at drain time
}
};
return beatmap;
}
private IBeatmap createBeatmapWithPlayTime(double playTimeMs, double starRating = 3.5, string difficultyName = "Default")
{
var beatmap = new Beatmap<HitObject>
{
BeatmapInfo = new BeatmapInfo
{
StarRating = starRating,
DifficultyName = difficultyName,
Ruleset = new OsuRuleset().RulesetInfo
},
HitObjects = new List<HitObject>
{
new HitObject { StartTime = 0 },
new HitObject { StartTime = playTimeMs } // Last object at play time
}
};
return beatmap;
}
private void assertOk(IBeatmap beatmap)
{
var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating);
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
Assert.That(check.Run(context), Is.Empty);
}
private void assertTooShort(IBeatmap beatmap)
{
var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating);
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort);
}
private void assertOkWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable<IBeatmap> allDifficulties)
{
var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties);
Assert.That(check.Run(context), Is.Empty);
}
private void assertTooShortWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable<IBeatmap> allDifficulties)
{
var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort);
}
private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable<IBeatmap> allDifficulties)
{
var beatmapSet = new BeatmapSetInfo();
var beatmapInfos = allDifficulties.Select(d => d.BeatmapInfo).ToList();
// Set up the beatmapset with all difficulties
beatmapSet.Beatmaps.AddRange(beatmapInfos);
currentBeatmap.BeatmapInfo.BeatmapSet = beatmapSet;
// Create a resolver that returns the appropriate working beatmap for each difficulty
var difficultyDict = allDifficulties.ToDictionary(d => d.BeatmapInfo, d => new TestWorkingBeatmap(d));
// Use the current beatmap's star rating to determine its difficulty rating
var currentDifficultyRating = StarDifficulty.GetDifficultyRating(currentBeatmap.BeatmapInfo.StarRating);
return new BeatmapVerifierContext(
currentBeatmap,
new TestWorkingBeatmap(currentBeatmap),
currentDifficultyRating,
beatmapInfo => difficultyDict.TryGetValue(beatmapInfo, out var workingBeatmap) ? workingBeatmap.Beatmap : null
);
}
private class TestCheckLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// Same thresholds as `CheckOsuLowestDiffDrainTime` for testing
yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard");
yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane");
yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert");
}
}
}
}
@@ -40,6 +40,11 @@ namespace osu.Game.Tests.NonVisual.Filtering
Author = { Username = "The Author" },
Source = "unit tests",
Tags = "look for tags too",
UserTags =
{
"song representation/simple",
"style/clean",
}
},
DifficultyName = "version as well",
Length = 2500,
@@ -292,6 +297,33 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[TestCase("simple", false)]
[TestCase("\"style/clean\"", false)]
[TestCase("\"style/clean\"!", false)]
[TestCase("iNiS-style", true)]
[TestCase("\"reading/visually dense\"!", true)]
public void TestCriteriaMatchingUserTags(string query, bool filtered)
{
var beatmap = getExampleBeatmap();
var criteria = new FilterCriteria { UserTag = { SearchTerm = query } };
var carouselItem = new CarouselBeatmap(beatmap);
carouselItem.Filter(criteria);
Assert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test]
public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive()
{
var beatmap = getExampleBeatmap();
var criteria = new FilterCriteria { UserTag = { SearchTerm = "simple" } };
var carouselItem = new CarouselBeatmap(beatmap);
carouselItem.BeatmapInfo.Metadata.UserTags.Clear();
carouselItem.Filter(criteria);
Assert.True(carouselItem.Filtered.Value);
}
[Test]
public void TestCustomRulesetCriteria([Values(null, true, false)] bool? matchCustomCriteria)
{
@@ -273,6 +273,12 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
}
[Test]
public void TestNegativeZero()
{
AddAssert("assert", () => BeatmapOffsetControl.GetOffsetExplanatoryText(-0.0001).ToString(), () => Is.EqualTo("0 ms"));
}
private void recreateControl()
{
AddStep("Create control", () =>
@@ -69,6 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay
pauseViaBackAction();
pauseViaBackAction();
confirmPausedWithNoOverlay();
AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Count.EqualTo(1));
}
[Test]
@@ -77,6 +78,7 @@ namespace osu.Game.Tests.Visual.Gameplay
pauseViaPauseGameplayAction();
pauseViaPauseGameplayAction();
confirmPausedWithNoOverlay();
AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Count.EqualTo(1));
}
[Test]
@@ -697,44 +697,6 @@ namespace osu.Game.Tests.Visual.Navigation
AddUntilStep("wait for score panel removal", () => scorePanel.Parent == null);
}
[TestCase(true)]
[TestCase(false)]
public void TestSongContinuesAfterExitPlayer(bool withUserPause)
{
Player player = null;
IWorkingBeatmap beatmap() => Game.Beatmap.Value;
Screens.SelectV2.SongSelect songSelect = null;
PushAndConfirm(() => songSelect = new SoloSongSelect());
AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented);
AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
if (withUserPause)
AddStep("pause", () => Game.Dependencies.Get<MusicController>().Stop(true));
AddStep("press enter", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for player", () =>
{
DismissAnyNotifications();
return (player = Game.ScreenStack.CurrentScreen as Player) != null;
});
AddUntilStep("wait for fail", () => player.GameplayState.HasFailed);
AddUntilStep("wait for track stop", () => !Game.MusicController.IsPlaying);
AddAssert("Ensure time before preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime);
pushEscape();
AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying);
AddAssert("Ensure time wasn't reset to preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime);
}
[Test]
public void TestMenuMakesMusic()
{
@@ -92,6 +92,30 @@ namespace osu.Game.Tests.Visual.Navigation
waitForScreen<SoloSongSelect>();
}
[Test]
public void TestPresentBeatmapFromMainMenuUsesPreviewPoint()
{
BeatmapSetInfo beatmapInfo = null!;
AddStep("import beatmap", () =>
{
var task = BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true);
task.WaitSafely();
beatmapInfo = task.GetResultSafely();
});
AddStep("present beatmap", () => Game.PresentBeatmap(beatmapInfo));
AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying);
AddAssert("ensure time is reset to preview point",
() =>
{
double timeFromPreviewPoint = Math.Abs(Game.MusicController.CurrentTrack.CurrentTime - beatmapInfo.Metadata.PreviewTime);
return timeFromPreviewPoint < 5000;
});
}
[TestCase(true)]
[TestCase(false)]
public void TestSongContinuesAfterExitPlayer(bool withUserPause)
@@ -108,15 +108,17 @@ namespace osu.Game.Tests.Visual.SongSelectV2
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddHours(-5), beatmapSets, out var todayBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-1), beatmapSets, out var yesterdayBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-4), beatmapSets, out var lastWeekBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-21), beatmapSets, out var oneMonthBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddMonths(-2).AddDays(-3), beatmapSets, out var threeMonthBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-21), beatmapSets, out var lastMonthBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddMonths(-1).AddDays(-21), beatmapSets, out var oneMonthAgoBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddMonths(-2).AddDays(-3), beatmapSets, out var twoMonthsAgoBeatmap);
var results = await runGrouping(GroupMode.DateAdded, beatmapSets);
assertGroup(results, 0, "Today", new[] { todayBeatmap }, ref total);
assertGroup(results, 1, "Yesterday", new[] { yesterdayBeatmap }, ref total);
assertGroup(results, 2, "Last week", new[] { lastWeekBeatmap }, ref total);
assertGroup(results, 3, "1 month ago", new[] { oneMonthBeatmap }, ref total);
assertGroup(results, 4, "3 months ago", new[] { threeMonthBeatmap }, ref total);
assertGroup(results, 3, "Last month", new[] { lastMonthBeatmap }, ref total);
assertGroup(results, 4, "1 month ago", new[] { oneMonthAgoBeatmap }, ref total);
assertGroup(results, 5, "2 months ago", new[] { twoMonthsAgoBeatmap }, ref total);
assertTotal(results, total);
}
@@ -129,17 +131,19 @@ namespace osu.Game.Tests.Visual.SongSelectV2
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddHours(-5)), beatmapSets, out var todayBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-1)), beatmapSets, out var yesterdayBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-4)), beatmapSets, out var lastWeekBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-21)), beatmapSets, out var oneMonthBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddMonths(-2).AddDays(-3)), beatmapSets, out var threeMonthBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-21)), beatmapSets, out var lastMonthBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddMonths(-1).AddDays(-21)), beatmapSets, out var oneMonthAgoBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddMonths(-2).AddDays(-3)), beatmapSets, out var twoMonthsBeatmap);
addBeatmapSet(applyLastPlayed(null), beatmapSets, out var neverBeatmap);
var results = await runGrouping(GroupMode.LastPlayed, beatmapSets);
assertGroup(results, 0, "Today", new[] { todayBeatmap }, ref total);
assertGroup(results, 1, "Yesterday", new[] { yesterdayBeatmap }, ref total);
assertGroup(results, 2, "Last week", new[] { lastWeekBeatmap }, ref total);
assertGroup(results, 3, "1 month ago", new[] { oneMonthBeatmap }, ref total);
assertGroup(results, 4, "3 months ago", new[] { threeMonthBeatmap }, ref total);
assertGroup(results, 5, "Never", new[] { neverBeatmap }, ref total);
assertGroup(results, 3, "Last month", new[] { lastMonthBeatmap }, ref total);
assertGroup(results, 4, "1 month ago", new[] { oneMonthAgoBeatmap }, ref total);
assertGroup(results, 5, "2 months ago", new[] { twoMonthsBeatmap }, ref total);
assertGroup(results, 6, "Never", new[] { neverBeatmap }, ref total);
assertTotal(results, total);
}
@@ -4,6 +4,7 @@
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
@@ -25,9 +26,19 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
base.LoadComplete();
Child = wedge = new BeatmapMetadataWedge
var lookupSource = new RealmPopulatingOnlineLookupSource();
Child = new DependencyProvidingContainer
{
State = { Value = Visibility.Visible },
RelativeSizeAxes = Axes.Both,
CachedDependencies = [(typeof(RealmPopulatingOnlineLookupSource), lookupSource)],
Children =
[
lookupSource,
wedge = new BeatmapMetadataWedge
{
State = { Value = Visibility.Visible },
}
]
};
}
@@ -5,6 +5,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
@@ -50,24 +52,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
base.LoadComplete();
((DummyAPIAccess)API).HandleRequest = request =>
{
switch (request)
{
case GetBeatmapSetRequest set:
if (set.ID == currentOnlineSet?.OnlineID)
{
set.TriggerSuccess(currentOnlineSet);
return true;
}
return false;
default:
return false;
}
};
AddRange(new Drawable[]
{
new Container
@@ -151,6 +135,27 @@ namespace osu.Game.Tests.Visual.SongSelectV2
[Test]
public void TestOnlineAvailability()
{
AddStep("set up request handler", () =>
{
((DummyAPIAccess)API).HandleRequest = request =>
{
switch (request)
{
case GetBeatmapSetRequest set:
if (set.ID == currentOnlineSet?.OnlineID)
{
set.TriggerSuccess(currentOnlineSet);
return true;
}
return false;
default:
return false;
}
};
});
AddStep("online beatmapset", () =>
{
var (working, onlineSet) = createTestBeatmap();
@@ -159,7 +164,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
Beatmap.Value = working;
});
AddAssert("play count = 10000", () => this.ChildrenOfType<BeatmapTitleWedge.Statistic>().ElementAt(0).Text.ToString() == "10,000");
AddAssert("favourites count = 2345", () => this.ChildrenOfType<BeatmapTitleWedge.Statistic>().ElementAt(1).Text.ToString() == "2,345");
AddAssert("favourites count = 2345", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().Text.ToString() == "2,345");
AddStep("online beatmapset with local diff", () =>
{
var (working, onlineSet) = createTestBeatmap();
@@ -170,7 +175,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
Beatmap.Value = working;
});
AddAssert("play count = -", () => this.ChildrenOfType<BeatmapTitleWedge.Statistic>().ElementAt(0).Text.ToString() == "-");
AddAssert("favourites count = 2345", () => this.ChildrenOfType<BeatmapTitleWedge.Statistic>().ElementAt(1).Text.ToString() == "2,345");
AddAssert("favourites count = 2345", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().Text.ToString() == "2,345");
AddStep("local beatmapset", () =>
{
var (working, _) = createTestBeatmap();
@@ -179,7 +184,75 @@ namespace osu.Game.Tests.Visual.SongSelectV2
Beatmap.Value = working;
});
AddAssert("play count = -", () => this.ChildrenOfType<BeatmapTitleWedge.Statistic>().ElementAt(0).Text.ToString() == "-");
AddAssert("favourites count = -", () => this.ChildrenOfType<BeatmapTitleWedge.Statistic>().ElementAt(1).Text.ToString() == "-");
AddAssert("favourites count = -", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().Text.ToString() == "-");
}
[Test]
public void TestFavouriting()
{
var resetEvent = new ManualResetEventSlim(false);
AddStep("set up request handler", () =>
{
((DummyAPIAccess)API).HandleRequest = request =>
{
switch (request)
{
case GetBeatmapSetRequest set:
if (set.ID == currentOnlineSet?.OnlineID)
{
set.TriggerSuccess(currentOnlineSet);
return true;
}
return false;
case PostBeatmapFavouriteRequest favourite:
Task.Run(() =>
{
resetEvent.Wait(10000);
favourite.TriggerSuccess();
});
return true;
default:
return false;
}
};
});
AddStep("online beatmapset", () =>
{
var (working, onlineSet) = createTestBeatmap();
currentOnlineSet = onlineSet;
Beatmap.Value = working;
});
AddUntilStep("play count = 10000", () => this.ChildrenOfType<BeatmapTitleWedge.Statistic>().ElementAt(0).Text.ToString() == "10,000");
AddUntilStep("favourites count = 2345", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().Text.ToString() == "2,345");
AddStep("click favourite button", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().TriggerClick());
AddStep("allow request to complete", () => resetEvent.Set());
AddAssert("favourites count = 2346", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().Text.ToString() == "2,346");
AddStep("reset event", () => resetEvent.Reset());
AddStep("click favourite button", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().TriggerClick());
AddStep("allow request to complete", () => resetEvent.Set());
AddAssert("favourites count = 2345", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().Text.ToString() == "2,345");
AddStep("reset event", () => resetEvent.Reset());
AddStep("click favourite button", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().TriggerClick());
AddStep("change to another beatmap", () =>
{
var (working, onlineSet) = createTestBeatmap();
onlineSet.FavouriteCount = 9999;
working.BeatmapSetInfo.OnlineID = onlineSet.OnlineID = 99999;
currentOnlineSet = onlineSet;
Beatmap.Value = working;
});
AddStep("allow request to complete", () => resetEvent.Set());
AddAssert("favourites count = 9999", () => this.ChildrenOfType<BeatmapTitleWedge.FavouriteButton>().Single().Text.ToString() == "9,999");
}
[TestCase(120, 125, null, "120-125 (mostly 120)")]
+12 -3
View File
@@ -2,9 +2,11 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using JetBrains.Annotations;
using Newtonsoft.Json;
using osu.Game.Models;
using osu.Game.Screens.SelectV2;
using osu.Game.Users;
using osu.Game.Utils;
using Realms;
@@ -15,10 +17,10 @@ namespace osu.Game.Beatmaps
/// A realm model containing metadata for a beatmap.
/// </summary>
/// <remarks>
/// This is currently stored against each beatmap difficulty, even when it is duplicated.
/// An instance of this object is stored against each beatmap difficulty.
/// It is also provided via <see cref="BeatmapSetInfo"/> for convenience and historical purposes.
/// A future effort could see this converted to an <see cref="EmbeddedObject"/> or potentially de-duped
/// and shared across multiple difficulties in the same set, if required.
/// Note that accessing the metadata via <see cref="BeatmapSetInfo"/> may result in indeterminate results
/// as metadata can meaningfully differ per beatmap in a set.
///
/// Note that difficulty name is not stored in this metadata but in <see cref="BeatmapInfo"/>.
/// </remarks>
@@ -43,6 +45,13 @@ namespace osu.Game.Beatmaps
[JsonProperty(@"tags")]
public string Tags { get; set; } = string.Empty;
/// <summary>
/// The list of user-voted tags applicable to this beatmap.
/// This information is populated from online sources (<see cref="RealmPopulatingOnlineLookupSource"/>)
/// and can meaningfully differ between beatmaps of a single set.
/// </summary>
public IList<string> UserTags { get; } = null!;
/// <summary>
/// The time in milliseconds to begin playing the track for preview purposes.
/// If -1, the track should begin playing at 40% of its length.
@@ -50,8 +50,7 @@ namespace osu.Game.Beatmaps.Drawables
protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func<Drawable> createContentFunc, double timeBeforeLoad)
=> new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, timeBeforeUnload)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
};
protected override Drawable CreateDrawable(IBeatmapSetOnlineInfo model)
+3 -1
View File
@@ -99,8 +99,10 @@ namespace osu.Game.Database
/// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions.
/// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues).
/// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID.
/// 50 2025-07-11 Add UserTags to BeatmapMetadata.
/// 51 2025-07-22 Add ScoreInfo.Pauses.
/// </summary>
private const int schema_version = 49;
private const int schema_version = 51;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@@ -87,6 +87,9 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty("legacy_score_id")]
public ulong? LegacyScoreId { get; set; }
[JsonProperty("pauses")]
public int[] Pauses { get; set; } = [];
#region osu-web API additions (not stored to database).
[JsonProperty("id")]
@@ -260,6 +263,7 @@ namespace osu.Game.Online.API.Requests.Responses
Mods = score.APIMods,
Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(),
MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(),
Pauses = score.Pauses.ToArray(),
};
}
}
@@ -4,6 +4,7 @@
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -262,27 +263,31 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks
};
}
var ppTooltipText = LocalisableString.Interpolate($@"{Score.PP:N1}pp");
return new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new[]
{
new OsuSpriteText
new SpriteTextWithTooltip
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = font,
Text = $"{Score.PP:0}",
Colour = colourProvider.Highlight1
Text = Score.PP.ToLocalisableString(@"N0"),
TooltipText = ppTooltipText,
Colour = colourProvider.Highlight1,
},
new OsuSpriteText
new SpriteTextWithTooltip
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = font.With(size: 12),
Text = "pp",
Colour = colourProvider.Light3
Text = @"pp",
TooltipText = ppTooltipText,
Colour = colourProvider.Light3,
}
}
};
@@ -4,6 +4,7 @@
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
@@ -44,7 +45,9 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks
Child = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true),
Text = Score.PP.HasValue ? $"{Score.PP * weight:0}pp" : string.Empty,
Text = Score.PP.HasValue
? LocalisableString.Interpolate($"{Score.PP * weight:N0}pp")
: string.Empty,
},
}
}
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
namespace osu.Game.Rulesets.Edit
@@ -26,11 +28,44 @@ namespace osu.Game.Rulesets.Edit
/// </summary>
public DifficultyRating InterpretedDifficulty;
public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus)
/// <summary>
/// All beatmap difficulties in the same beatmapset, including the current beatmap.
/// </summary>
public readonly IReadOnlyList<IBeatmap> BeatmapsetDifficulties;
// TODO: Refactor this to have a simple constructor that only stores data and move the beatmap resolution logic to a static factory method.
public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus, Func<BeatmapInfo, IBeatmap?>? beatmapResolver = null)
{
Beatmap = beatmap;
WorkingBeatmap = workingBeatmap;
InterpretedDifficulty = difficultyRating;
var beatmapSet = beatmap.BeatmapInfo.BeatmapSet;
if (beatmapSet?.Beatmaps == null)
{
BeatmapsetDifficulties = new[] { beatmap };
return;
}
var difficulties = new List<IBeatmap>();
foreach (var beatmapInfo in beatmapSet.Beatmaps)
{
// Use the current beatmap if it matches this BeatmapInfo
if (beatmapInfo.Equals(beatmap.BeatmapInfo))
{
difficulties.Add(beatmap);
continue;
}
// Try to resolve other difficulties using the provided resolver
var resolvedBeatmap = beatmapResolver?.Invoke(beatmapInfo);
if (resolvedBeatmap != null)
difficulties.Add(resolvedBeatmap);
}
BeatmapsetDifficulties = difficulties;
}
}
}
@@ -0,0 +1,88 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Edit.Checks
{
public abstract class CheckLowestDiffDrainTime : ICheck
{
/// <summary>
/// Defines the minimum drain time thresholds for different difficulty ratings.
/// </summary>
protected abstract IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds();
private const double break_time_leniency = 30 * 1000;
public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Spread, "Lowest difficulty too difficult for the given drain/play time(s)");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateTooShort(this)
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
IReadOnlyList<IBeatmap> difficulties = context.BeatmapsetDifficulties
.Where(d => d.BeatmapInfo.Ruleset.Equals(context.Beatmap.BeatmapInfo.Ruleset))
.ToList();
if (difficulties.Count == 0)
yield break;
var lowestDifficulty = difficulties.OrderBy(b => b.BeatmapInfo.StarRating).First();
// Get difficulty rating for the lowest difficulty
DifficultyRating lowestDifficultyRating = lowestDifficulty == context.Beatmap
? context.InterpretedDifficulty
: StarDifficulty.GetDifficultyRating(lowestDifficulty.BeatmapInfo.StarRating);
double drainTime = context.Beatmap.CalculateDrainLength();
double playTime = context.Beatmap.CalculatePlayableLength();
bool isHighestDifficulty = difficulties.OrderByDescending(b => b.BeatmapInfo.StarRating).First() == context.Beatmap;
// Use play time unless it's the highest difficulty and has significant breaks
bool canUsePlayTime = !isHighestDifficulty || context.Beatmap.TotalBreakTime < break_time_leniency;
double effectiveTime = canUsePlayTime ? playTime : drainTime;
double thresholdReduction = canUsePlayTime ? 0 : break_time_leniency;
// Check against thresholds based on the lowest difficulty's rating in the beatmapset
// Find the most appropriate threshold (highest rating that applies)
var applicableThreshold = GetThresholds()
.Where(t => lowestDifficultyRating >= t.rating)
.OrderByDescending(t => t.rating)
.FirstOrDefault();
if (applicableThreshold != default && effectiveTime < applicableThreshold.thresholdMs - thresholdReduction)
{
yield return new IssueTemplateTooShort(this).Create(
applicableThreshold.name,
canUsePlayTime ? "play" : "drain",
applicableThreshold.thresholdMs - thresholdReduction,
effectiveTime
);
}
}
public class IssueTemplateTooShort : IssueTemplate
{
public IssueTemplateTooShort(ICheck check)
: base(check, IssueType.Problem, "With the lowest difficulty being \"{0}\", the {1} time of this difficulty must be at least {2}, currently {3}.")
{
}
public Issue Create(string lowestDiffLevel, string timeType, double requiredTime, double currentTime)
=> new Issue(this,
lowestDiffLevel,
timeType,
TimeSpan.FromMilliseconds(requiredTime).ToString(@"m\:ss"),
TimeSpan.FromMilliseconds(currentTime).ToString(@"m\:ss"));
}
}
}
@@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Edit.Checks
public class IssueTemplateIncorrectFormat : IssueTemplate
{
public IssueTemplateIncorrectFormat(ICheck check)
: base(check, IssueType.Problem, "\"{0}\" is using a incorrect format. Use mp3 or ogg for the song's audio.")
: base(check, IssueType.Problem, "\"{0}\" is using an incorrect format. Use mp3 or ogg for the song's audio.")
{
}
@@ -49,6 +49,9 @@ namespace osu.Game.Scoring.Legacy
[JsonProperty("total_score_without_mods")]
public long? TotalScoreWithoutMods { get; set; }
[JsonProperty("pauses")]
public int[] Pauses { get; set; } = [];
public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo
{
OnlineID = score.OnlineID,
@@ -59,6 +62,7 @@ namespace osu.Game.Scoring.Legacy
Rank = score.Rank,
UserID = score.User.OnlineID,
TotalScoreWithoutMods = score.TotalScoreWithoutMods > 0 ? score.TotalScoreWithoutMods : null,
Pauses = score.Pauses.ToArray(),
};
}
}
@@ -13,6 +13,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO.Legacy;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Replays;
@@ -142,6 +143,8 @@ namespace osu.Game.Scoring.Legacy
score.ScoreInfo.TotalScoreWithoutMods = totalScoreWithoutMods;
else
PopulateTotalScoreWithoutMods(score.ScoreInfo);
score.ScoreInfo.Pauses.AddRange(readScore.Pauses);
});
}
}
+2
View File
@@ -155,6 +155,8 @@ namespace osu.Game.Scoring
[MapTo("MaximumStatistics")]
public string MaximumStatisticsJson { get; set; } = string.Empty;
public IList<int> Pauses { get; } = null!;
public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null)
{
Ruleset = ruleset ?? new RulesetInfo();
@@ -19,7 +19,6 @@ namespace osu.Game.Screens.Backgrounds
{
public partial class EditorBackgroundScreen : BackgroundScreen
{
private readonly WorkingBeatmap beatmap;
private readonly Container dimContainer;
private CancellationTokenSource? cancellationTokenSource;
@@ -31,10 +30,14 @@ namespace osu.Game.Screens.Backgrounds
private IFrameBasedClock? clockSource;
public EditorBackgroundScreen(WorkingBeatmap beatmap)
{
this.beatmap = beatmap;
// We retrieve IBindable<WorkingBeatmap> from our dependency cache instead of passing WorkingBeatmap directly into EditorBackgroundScreen.
// Otherwise, DummyWorkingBeatmap will be erroneously passed in whenever creating a new beatmap (since the Schedule() in the Editor that populates
// a new WorkingBeatmap with correct values generally runs after EditorBackgroundScreen is created), which causes any background changes to not be displayed.
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
public EditorBackgroundScreen()
{
InternalChild = dimContainer = new Container
{
RelativeSizeAxes = Axes.Both,
@@ -54,14 +57,14 @@ namespace osu.Game.Screens.Backgrounds
private IEnumerable<Drawable> createContent() =>
[
new BeatmapBackground(beatmap) { RelativeSizeAxes = Axes.Both, },
new BeatmapBackground(beatmap.Value) { RelativeSizeAxes = Axes.Both, },
// this kooky container nesting is here because the storyboard needs a custom clock
// but also needs it on an isolated-enough level that doesn't break screen stack expiry logic (which happens if the clock was put on `this`),
// or doesn't make it literally impossible to fade the storyboard in/out in real time (which happens if the fade transforms were to be applied directly to the storyboard).
new Container
{
RelativeSizeAxes = Axes.Both,
Child = new DrawableStoryboard(beatmap.Storyboard)
Child = new DrawableStoryboard(beatmap.Value.Storyboard)
{
Clock = clockSource ?? Clock,
}
@@ -82,7 +85,7 @@ namespace osu.Game.Screens.Backgrounds
storyboardContainer.FadeTo(showStoryboard.Value ? 1 : 0, duration, Easing.OutQuint);
// yes, this causes overdraw, but is also a (crude) fix for bad-looking transitions on screen entry
// caused by the previous background on the background stack poking out from under this one and then instantly fading out
background.FadeColour(beatmap.Storyboard.ReplacesBackground && showStoryboard.Value ? Colour4.Black : Colour4.White, duration, Easing.OutQuint);
background.FadeColour(beatmap.Value.Storyboard.ReplacesBackground && showStoryboard.Value ? Colour4.Black : Colour4.White, duration, Easing.OutQuint);
}
public void ChangeClockSource(IFrameBasedClock frameBasedClock)
@@ -103,7 +106,7 @@ namespace osu.Game.Screens.Backgrounds
background = dimContainer.OfType<BeatmapBackground>().Single();
storyboardContainer = dimContainer.OfType<Container>().Single();
updateState(0);
}, (cancellationTokenSource ??= new CancellationTokenSource()).Token);
}, (cancellationTokenSource = new CancellationTokenSource()).Token);
}
public override bool Equals(BackgroundScreen? other)
+1 -1
View File
@@ -480,7 +480,7 @@ namespace osu.Game.Screens.Edit
[Resolved]
private MusicController musicController { get; set; }
protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(Beatmap.Value);
protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen();
protected override void LoadComplete()
{
@@ -149,8 +149,9 @@ namespace osu.Game.Screens.Edit.Submission
progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true);
progressSampleChannel = progressSample?.GetChannel();
if (progressSampleChannel != null)
progressSampleChannel.ManualFree = true;
// TODO: add back once framework revert is reverted.
// if (progressSampleChannel != null)
// progressSampleChannel.ManualFree = true;
}
public void SetNotStarted() => status.Value = StageStatusType.NotStarted;
+10 -1
View File
@@ -33,6 +33,9 @@ namespace osu.Game.Screens.Edit.Verify
[Resolved]
private VerifyScreen verify { get; set; }
[Resolved]
private BeatmapManager beatmapManager { get; set; }
private IBeatmapVerifier rulesetVerifier;
private BeatmapVerifier generalVerifier;
private BeatmapVerifierContext context;
@@ -43,7 +46,13 @@ namespace osu.Game.Screens.Edit.Verify
generalVerifier = new BeatmapVerifier();
rulesetVerifier = beatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapVerifier();
context = new BeatmapVerifierContext(beatmap, workingBeatmap.Value, verify.InterpretedDifficulty.Value);
context = new BeatmapVerifierContext(
beatmap,
workingBeatmap.Value,
verify.InterpretedDifficulty.Value,
beatmapInfo => beatmapManager.GetWorkingBeatmap(beatmapInfo).GetPlayableBeatmap(beatmapInfo.Ruleset)
);
verify.InterpretedDifficulty.BindValueChanged(difficulty => context.InterpretedDifficulty = difficulty.NewValue);
RelativeSizeAxes = Axes.Both;
+1 -1
View File
@@ -1046,7 +1046,7 @@ namespace osu.Game.Screens.Play
// already resuming
&& !IsResuming;
public bool Pause()
public virtual bool Pause()
{
if (!pausingSupportedByCurrentState) return false;
@@ -17,6 +17,7 @@ using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
@@ -321,9 +322,11 @@ namespace osu.Game.Screens.Play.PlayerSettings
public static LocalisableString GetOffsetExplanatoryText(double offset)
{
return offset == 0
? LocalisableString.Interpolate($@"{offset:0.0} ms")
: LocalisableString.Interpolate($@"{offset:0.0} ms {getEarlyLateText(offset)}");
string formatOffset = offset.ToStandardFormattedString(1);
return formatOffset == "0"
? LocalisableString.Interpolate($@"{formatOffset} ms")
: LocalisableString.Interpolate($@"{formatOffset} ms {getEarlyLateText(offset)}");
LocalisableString getEarlyLateText(double value)
{
+12
View File
@@ -234,6 +234,18 @@ namespace osu.Game.Screens.Play
spectatorClient.BeginPlaying(token, GameplayState, Score);
}
public override bool Pause()
{
bool wasPaused = GameplayClockContainer.IsPaused.Value;
bool paused = base.Pause();
if (!wasPaused && paused)
Score.ScoreInfo.Pauses.Add((int)Math.Round(GameplayClockContainer.CurrentTime));
return paused;
}
protected override void OnFail()
{
base.OnFail();
@@ -83,6 +83,15 @@ namespace osu.Game.Screens.Select.Carousel
criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode);
match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName);
match &= !criteria.Source.HasFilter || criteria.Source.Matches(BeatmapInfo.Metadata.Source);
if (criteria.UserTag.HasFilter)
{
bool anyTagMatched = false;
foreach (string tag in BeatmapInfo.Metadata.UserTags)
anyTagMatched |= criteria.UserTag.Matches(tag);
match &= anyTagMatched;
}
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
if (!match) return false;
@@ -39,6 +39,7 @@ namespace osu.Game.Screens.Select
public OptionalTextFilter Title;
public OptionalTextFilter DifficultyName;
public OptionalTextFilter Source;
public OptionalTextFilter UserTag;
public OptionalRange<double> UserStarDifficulty = new OptionalRange<double>
{
@@ -116,6 +116,9 @@ namespace osu.Game.Screens.Select
case "source":
return TryUpdateCriteriaText(ref criteria.Source, op, value);
case "tag":
return TryUpdateCriteriaText(ref criteria.UserTag, op, value);
default:
return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false;
}
@@ -13,6 +13,9 @@ namespace osu.Game.Screens.Select.Leaderboards
/// <summary>
/// List of all scores to display on the leaderboard.
/// </summary>
/// <remarks>
/// Implementors should ensure that this list is only mutated from the update thread.
/// </remarks>
IBindableList<GameplayLeaderboardScore> Scores { get; }
}
@@ -34,24 +34,22 @@ namespace osu.Game.Screens.Select.Leaderboards
[BackgroundDependencyLoader]
private void load(IAPIProvider api, GameplayState? gameplayState)
{
var scoresToShow = new List<GameplayLeaderboardScore>();
var scoresRequest = new IndexPlaylistScoresRequest(room.RoomID!.Value, playlistItem.ID);
scoresRequest.Success += response =>
{
var newScores = new List<GameplayLeaderboardScore>();
isPartial = response.Scores.Count < response.TotalScores;
for (int i = 0; i < response.Scores.Count; i++)
{
var score = response.Scores[i];
score.Position = i + 1;
newScores.Add(new GameplayLeaderboardScore(score, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest));
scoresToShow.Add(new GameplayLeaderboardScore(score, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest));
}
if (response.UserScore != null && response.Scores.All(s => s.ID != response.UserScore.ID))
newScores.Add(new GameplayLeaderboardScore(response.UserScore, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest));
scores.AddRange(newScores);
scoresToShow.Add(new GameplayLeaderboardScore(response.UserScore, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest));
};
api.Perform(scoresRequest);
@@ -59,9 +57,12 @@ namespace osu.Game.Screens.Select.Leaderboards
{
var localScore = new GameplayLeaderboardScore(gameplayState, tracked: true, GameplayLeaderboardScore.ComboDisplayMode.Highest);
localScore.TotalScore.BindValueChanged(_ => sorting.Invalidate());
scores.Add(localScore);
scoresToShow.Add(localScore);
}
// touching the public bindable must happen on the update thread for general thread safety,
// since we may have external subscribers bound already
Schedule(() => scores.AddRange(scoresToShow));
Scheduler.AddDelayed(sort, 1000, true);
}
@@ -261,12 +261,15 @@ namespace osu.Game.Screens.SelectV2
return new GroupDefinition(2, "Last week");
if (elapsed.TotalDays < 30)
return new GroupDefinition(3, "1 month ago");
return new GroupDefinition(3, "Last month");
for (int i = 60; i <= 150; i += 30)
if (elapsed.TotalDays < 60)
return new GroupDefinition(4, "1 month ago");
for (int i = 90; i <= 150; i += 30)
{
if (elapsed.TotalDays < i)
return new GroupDefinition(i, $"{i / 30} months ago");
return new GroupDefinition(i, $"{i / 30 - 1} months ago");
}
return new GroupDefinition(151, "Over 5 months ago");
@@ -105,6 +105,15 @@ namespace osu.Game.Screens.SelectV2
criteria.Title.Matches(beatmap.Metadata.TitleUnicode);
match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(beatmap.DifficultyName);
match &= !criteria.Source.HasFilter || criteria.Source.Matches(beatmap.Metadata.Source);
if (criteria.UserTag.HasFilter)
{
bool anyTagMatched = false;
foreach (string tag in beatmap.Metadata.UserTags)
anyTagMatched |= criteria.UserTag.Matches(tag);
match &= anyTagMatched;
}
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(beatmap.StarRating);
if (!match) return false;
@@ -2,18 +2,21 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Resources.Localisation.Web;
@@ -51,6 +54,12 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private RealmPopulatingOnlineLookupSource onlineLookupSource { get; set; } = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
private IBindable<APIState> apiState = null!;
[Resolved]
@@ -314,34 +323,34 @@ namespace osu.Game.Screens.SelectV2
}
private APIBeatmapSet? currentOnlineBeatmapSet;
private GetBeatmapSetRequest? currentRequest;
private CancellationTokenSource? cancellationTokenSource;
private Task<APIBeatmapSet?>? currentFetchTask;
private void refetchBeatmapSet()
{
var beatmapSetInfo = beatmap.Value.BeatmapSetInfo;
currentRequest?.Cancel();
currentRequest = null;
cancellationTokenSource?.Cancel();
currentOnlineBeatmapSet = null;
if (beatmapSetInfo.OnlineID >= 1)
{
// todo: consider introducing a BeatmapSetLookupCache for caching benefits.
currentRequest = new GetBeatmapSetRequest(beatmapSetInfo.OnlineID);
currentRequest.Failure += _ => updateOnlineDisplay();
currentRequest.Success += s =>
cancellationTokenSource = new CancellationTokenSource();
currentFetchTask = onlineLookupSource.GetBeatmapSetAsync(beatmapSetInfo.OnlineID);
currentFetchTask.ContinueWith(t =>
{
currentOnlineBeatmapSet = s;
updateOnlineDisplay();
};
api.Queue(currentRequest);
if (t.IsCompletedSuccessfully)
currentOnlineBeatmapSet = t.GetResultSafely();
if (t.Exception != null)
Logger.Log($"Error when fetching online beatmap set: {t.Exception}", LoggingTarget.Network);
Scheduler.AddOnce(updateOnlineDisplay);
});
}
}
private void updateOnlineDisplay()
{
if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting)
if (currentFetchTask?.IsCompleted == false)
{
genre.Data = null;
language.Data = null;
@@ -379,28 +388,21 @@ namespace osu.Game.Screens.SelectV2
private void updateUserTags()
{
var beatmapInfo = beatmap.Value.BeatmapInfo;
var onlineBeatmapSet = currentOnlineBeatmapSet;
var onlineBeatmap = onlineBeatmapSet?.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID);
string[] tags = realm.Run(r =>
{
// need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags
var refetchedBeatmap = r.Find<BeatmapInfo>(beatmap.Value.BeatmapInfo.ID);
return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? [];
});
if (onlineBeatmap?.TopTags == null || onlineBeatmap.TopTags.Length == 0 || onlineBeatmapSet?.RelatedTags == null)
if (tags.Length == 0)
{
userTags.FadeOut(transition_duration, Easing.OutQuint);
return;
}
var tagsById = onlineBeatmapSet.RelatedTags.ToDictionary(t => t.Id);
string[] userTagsArray = onlineBeatmap.TopTags
.Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId)))
.Where(t => t.relatedTag != null)
// see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria
.OrderByDescending(t => t.topTag.VoteCount)
.ThenBy(t => t.relatedTag!.Name)
.Select(t => t.relatedTag!.Name)
.ToArray();
userTags.FadeIn(transition_duration, Easing.OutQuint);
userTags.Tags = (userTagsArray, t => songSelect?.Search(t));
userTags.Tags = (tags, t => songSelect?.Search($@"tag=""{t}""!"));
}
}
}
@@ -56,12 +56,12 @@ namespace osu.Game.Screens.SelectV2
}
}
public (string[] tags, Action<string> linkAction)? Tags
public (string[] tags, Action<string> searchAction)? Tags
{
set
{
if (value != null)
setTags(value.Value.tags, value.Value.linkAction);
setTags(value.Value.tags, value.Value.searchAction);
else
setLoading();
}
@@ -161,12 +161,12 @@ namespace osu.Game.Screens.SelectV2
contentDate.Date = date;
}
private void setTags(string[] tags, Action<string> link)
private void setTags(string[] tags, Action<string> searchAction)
{
clear();
contentTags.Tags = tags;
contentTags.Action = link;
contentTags.PerformSearch = searchAction;
}
private void setLoading()
@@ -44,7 +44,7 @@ namespace osu.Game.Screens.SelectV2
}
}
public Action<string>? Action;
public Action<string>? PerformSearch { get; set; }
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
@@ -103,7 +103,7 @@ namespace osu.Game.Screens.SelectV2
ChildrenEnumerable = tags.Select(t => new OsuHoverContainer
{
AutoSizeAxes = Axes.Both,
Action = () => Action?.Invoke(t),
Action = () => PerformSearch?.Invoke(t),
IdleColour = colourProvider.Light2,
AlwaysPresent = true,
Alpha = 0f,
@@ -117,6 +117,7 @@ namespace osu.Game.Screens.SelectV2
Add(overflowButton = new TagsOverflowButton(tags)
{
Alpha = 0f,
PerformSearch = PerformSearch,
});
drawSizeLayout.Invalidate();
@@ -132,11 +133,10 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[Resolved]
private ISongSelect? songSelect { get; set; }
public float LineBaseHeight => text.LineBaseHeight;
public Action<string>? PerformSearch { get; set; }
public TagsOverflowButton(string[] tags)
{
this.tags = tags;
@@ -188,18 +188,18 @@ namespace osu.Game.Screens.SelectV2
return true;
}
public Popover GetPopover() => new TagsOverflowPopover(tags, songSelect);
public Popover GetPopover() => new TagsOverflowPopover(tags, PerformSearch);
}
public partial class TagsOverflowPopover : OsuPopover
{
private readonly string[] tags;
private readonly ISongSelect? songSelect;
private readonly Action<string>? performSearch;
public TagsOverflowPopover(string[] tags, ISongSelect? songSelect)
public TagsOverflowPopover(string[] tags, Action<string>? performSearchAction)
{
this.tags = tags;
this.songSelect = songSelect;
performSearch = performSearchAction;
}
[BackgroundDependencyLoader]
@@ -215,7 +215,7 @@ namespace osu.Game.Screens.SelectV2
foreach (string tag in tags)
{
textFlow.AddLink(tag, () => songSelect?.Search(tag));
textFlow.AddLink(tag, () => performSearch?.Invoke(tag));
textFlow.AddText(" ");
}
}
+5 -13
View File
@@ -8,7 +8,6 @@ using System.Threading;
using System.Threading.Tasks;
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.Localisation;
@@ -59,7 +58,7 @@ namespace osu.Game.Screens.SelectV2
internal string DisplayedArtist => artistLabel.Text.ToString();
private StatisticPlayCount playCount = null!;
private Statistic favouritesStatistic = null!;
private FavouriteButton favouriteButton = null!;
private Statistic lengthStatistic = null!;
private Statistic bpmStatistic = null!;
@@ -157,7 +156,7 @@ namespace osu.Game.Screens.SelectV2
{
Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN },
},
favouritesStatistic = new Statistic(OsuIcon.Heart, background: true, minSize: 25f)
favouriteButton = new FavouriteButton
{
TooltipText = BeatmapsStrings.StatusFavourites,
},
@@ -316,20 +315,13 @@ namespace osu.Game.Screens.SelectV2
if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting)
{
playCount.Value = null;
favouritesStatistic.Text = null;
}
else if (currentOnlineBeatmapSet == null)
{
playCount.Value = new StatisticPlayCount.Data(-1, -1);
favouritesStatistic.Text = "-";
favouriteButton.SetLoading();
}
else
{
var onlineBeatmapSet = currentOnlineBeatmapSet;
var onlineBeatmap = currentOnlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID);
var onlineBeatmap = currentOnlineBeatmapSet?.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID);
playCount.Value = new StatisticPlayCount.Data(onlineBeatmap?.PlayCount ?? -1, onlineBeatmap?.UserPlayCount ?? -1);
favouritesStatistic.Text = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0");
favouriteButton.SetBeatmapSet(currentOnlineBeatmapSet);
}
}
}
@@ -0,0 +1,349 @@
// 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.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
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.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.SelectV2
{
public partial class BeatmapTitleWedge
{
public partial class FavouriteButton : OsuClickableContainer
{
private readonly BindableBool isFavourite = new BindableBool();
private Box background = null!;
private OsuSpriteText valueText = null!;
private LoadingSpinner loadingSpinner = null!;
private Box hoverLayer = null!;
private HeartIcon icon = null!;
private APIBeatmapSet? onlineBeatmapSet;
private PostBeatmapFavouriteRequest? favouriteRequest;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
internal LocalisableString Text => valueText.Text;
public FavouriteButton()
{
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load()
{
Masking = true;
CornerRadius = 5;
Shear = OsuGame.SHEAR;
AddRange(new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Black.Opacity(0.2f),
},
new FillFlowContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Margin = new MarginPadding { Left = 10, Right = 10, Vertical = 5f },
Spacing = new Vector2(4f, 0f),
Shear = -OsuGame.SHEAR,
Children = new Drawable[]
{
icon = new HeartIcon
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(OsuFont.Style.Heading2.Size),
},
new Container
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.X,
Height = 20,
Children = new Drawable[]
{
loadingSpinner = new LoadingSpinner
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(12f),
State = { Value = Visibility.Visible },
},
new GridContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize, minSize: 25),
},
Content = new[]
{
new[]
{
valueText = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Style.Heading2,
Colour = colourProvider.Content2,
Margin = new MarginPadding { Bottom = 2f },
AlwaysPresent = true,
},
}
}
},
},
},
},
},
hoverLayer = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Colour = Colour4.White.Opacity(0.1f),
Blending = BlendingParameters.Additive,
},
});
Action = toggleFavourite;
}
protected override bool OnHover(HoverEvent e)
{
hoverLayer.FadeIn(500, Easing.OutQuint);
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
hoverLayer.FadeOut(500, Easing.OutQuint);
}
// Note: `setLoading()` and `setBeatmapSet()` are called externally via their public counterparts by song select when the beatmap changes,
// as well as internally in order to display the progress and result of the (un)favourite operation when the button is clicked.
// In case of external calls, we want to cancel pending favourite requests, primarily to avoid a situation when a late success callback from an (un)favourite
// could show the favourite count from a prior beatmap.
public void SetLoading()
{
if (favouriteRequest?.CompletionState == APIRequestCompletionState.Waiting)
favouriteRequest.Cancel();
setLoading();
}
private void setLoading()
{
loadingSpinner.State.Value = Visibility.Visible;
valueText.FadeOut(120, Easing.OutQuint);
onlineBeatmapSet = null;
updateFavouriteState();
}
public void SetBeatmapSet(APIBeatmapSet? beatmapSet)
{
if (favouriteRequest?.CompletionState == APIRequestCompletionState.Waiting)
favouriteRequest.Cancel();
setBeatmapSet(beatmapSet);
}
private void setBeatmapSet(APIBeatmapSet? beatmapSet, bool withHeartAnimation = false)
{
loadingSpinner.State.Value = Visibility.Hidden;
valueText.FadeIn(120, Easing.OutQuint);
onlineBeatmapSet = beatmapSet;
updateFavouriteState(withHeartAnimation);
}
private void updateFavouriteState(bool withAnimation = false)
{
Enabled.Value = onlineBeatmapSet != null;
if (loadingSpinner.State.Value == Visibility.Hidden)
valueText.Text = onlineBeatmapSet?.FavouriteCount.ToLocalisableString(@"N0") ?? "-";
isFavourite.Value = onlineBeatmapSet?.HasFavourited == true;
background.FadeColour(isFavourite.Value ? colours.Pink4.Darken(1f).Opacity(0.5f) : Color4.Black.Opacity(0.2f), 500, Easing.OutQuint);
valueText.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint);
icon.SetActive(isFavourite.Value, withAnimation);
}
private void toggleFavourite()
{
Debug.Assert(onlineBeatmapSet != null);
// having this copy locally is important to capture this particular beatmap set instance rather than the field in the request success callback,
// because if it was captured via the field / `this`, it could change value due to an external `setLoading()` or `setBeatmapSet()` call.
// there's also the part where we want to call `setLoading()` here to show the spinner, but that also sets `onlineBeatmapSet` to null.
var beatmapSet = onlineBeatmapSet;
favouriteRequest = new PostBeatmapFavouriteRequest(beatmapSet.OnlineID, isFavourite.Value ? BeatmapFavouriteAction.UnFavourite : BeatmapFavouriteAction.Favourite);
favouriteRequest.Success += () =>
{
bool hasFavourited = favouriteRequest.Action == BeatmapFavouriteAction.Favourite;
beatmapSet.HasFavourited = hasFavourited;
beatmapSet.FavouriteCount += hasFavourited ? 1 : -1;
setBeatmapSet(beatmapSet, withHeartAnimation: hasFavourited);
};
api.Queue(favouriteRequest);
setLoading();
}
}
private partial class HeartIcon : CompositeDrawable
{
private readonly SpriteIcon icon;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public HeartIcon()
{
InternalChildren = new Drawable[]
{
icon = new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Icon = FontAwesome.Regular.Heart,
RelativeSizeAxes = Axes.Both,
},
};
}
private const double pop_out_duration = 100;
private const double pop_in_duration = 500;
private bool active;
public void SetActive(bool active, bool withAnimation = false)
{
if (this.active == active)
return;
this.active = active;
FinishTransforms(true);
if (active)
{
transitionIcon(FontAwesome.Solid.Heart, colours.Pink1, emphasised: withAnimation);
if (withAnimation)
playFavouriteAnimation();
}
else
{
transitionIcon(FontAwesome.Regular.Heart, colourProvider.Content2);
}
}
private void transitionIcon(IconUsage newIcon, Color4 colour, bool emphasised = false)
{
icon.ScaleTo(emphasised ? 0.5f : 0.8f, pop_out_duration, Easing.OutQuad)
.Then()
.FadeColour(colour)
.Schedule(() => icon.Icon = newIcon)
.ScaleTo(1, pop_in_duration, Easing.OutElasticHalf);
}
private void playFavouriteAnimation()
{
var circle = new FastCircle
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(0.5f),
Blending = BlendingParameters.Additive,
Alpha = 0,
Depth = float.MinValue,
};
AddInternal(circle);
circle.Delay(pop_out_duration)
.FadeTo(0.35f)
.FadeOut(1400, Easing.OutCubic)
.ScaleTo(10f, 750, Easing.OutQuint)
.Expire();
const int num_particles = 8;
static float randomFloat(float min, float max) => min + Random.Shared.NextSingle() * (max - min);
for (int i = 0; i < num_particles; i++)
{
double duration = randomFloat(600, 1000);
float angle = (i + randomFloat(0, 0.75f)) / num_particles * MathF.PI * 2;
var direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle));
float distance = randomFloat(DrawWidth / 2, DrawWidth);
var particle = new FastCircle
{
Position = direction * DrawWidth / 4,
Size = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Blending = BlendingParameters.Additive,
Alpha = 0,
Depth = 2,
Colour = colours.Pink,
};
AddInternal(particle);
particle
.Delay(pop_out_duration)
.FadeTo(0.5f)
.MoveTo(direction * distance, 1300, Easing.OutQuint)
.FadeOut(duration, Easing.Out)
.ScaleTo(0.5f, duration)
.Expire();
}
}
}
}
}
@@ -0,0 +1,81 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using Realms;
namespace osu.Game.Screens.SelectV2
{
/// <summary>
/// This component is designed to perform lookups of online data
/// and store portions of it for later local use to the realm database.
/// </summary>
/// <example>
/// This component is designed to locally persist potentially-volatile online information such as:
/// <list type="bullet">
/// <item>user tags assigned to difficulties of a beatmap,</item>
/// <item>guest mappers assigned to difficulties of a beatmap,</item>
/// <item>the local user's best score on a given beatmap.</item>
/// </list>
/// </example>
public partial class RealmPopulatingOnlineLookupSource : Component
{
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
public Task<APIBeatmapSet?> GetBeatmapSetAsync(int id, CancellationToken token = default)
{
var request = new GetBeatmapSetRequest(id);
var tcs = new TaskCompletionSource<APIBeatmapSet?>();
request.Success += onlineBeatmapSet =>
{
if (token.IsCancellationRequested)
{
tcs.SetCanceled(token);
return;
}
var tagsById = (onlineBeatmapSet.RelatedTags ?? []).ToDictionary(t => t.Id);
var onlineBeatmaps = onlineBeatmapSet.Beatmaps.ToDictionary(b => b.OnlineID);
realm.Write(r =>
{
foreach (var dbBeatmap in r.All<BeatmapInfo>().Filter($@"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.OnlineID)} == $0", id))
{
if (onlineBeatmaps.TryGetValue(dbBeatmap.OnlineID, out var onlineBeatmap))
{
string[] userTagsArray = onlineBeatmap.TopTags?
.Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId)))
.Where(t => t.relatedTag != null)
// see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria
.OrderByDescending(t => t.topTag.VoteCount)
.ThenBy(t => t.relatedTag!.Name)
.Select(t => t.relatedTag!.Name)
.ToArray() ?? [];
dbBeatmap.Metadata.UserTags.Clear();
dbBeatmap.Metadata.UserTags.AddRange(userTagsArray);
}
}
});
tcs.SetResult(onlineBeatmapSet);
};
request.Failure += tcs.SetException;
api.Queue(request);
return tcs.Task;
}
}
}
+11 -4
View File
@@ -133,6 +133,9 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private IDialogOverlay? dialogOverlay { get; set; }
[Cached]
private RealmPopulatingOnlineLookupSource onlineLookupSource = new RealmPopulatingOnlineLookupSource();
private Bindable<bool> configBackgroundBlur = null!;
[BackgroundDependencyLoader]
@@ -143,6 +146,7 @@ namespace osu.Game.Screens.SelectV2
AddRangeInternal(new Drawable[]
{
new GlobalScrollAdjustsVolume(),
onlineLookupSource,
mainContent = new Container
{
Anchor = Anchor.Centre,
@@ -342,7 +346,7 @@ namespace osu.Game.Screens.SelectV2
ensureGlobalBeatmapValid();
ensurePlayingSelected(true);
ensurePlayingSelected();
updateBackgroundDim();
updateWedgeVisibility();
});
@@ -375,7 +379,7 @@ namespace osu.Game.Screens.SelectV2
/// Ensures some music is playing for the current track.
/// Will resume playback from a manual user pause if the track has changed.
/// </summary>
private void ensurePlayingSelected(bool restart)
private void ensurePlayingSelected()
{
if (!ControlGlobalMusic)
return;
@@ -387,7 +391,10 @@ namespace osu.Game.Screens.SelectV2
if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack))
{
Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}");
music.Play(restart);
// Only restart playback if a new track.
// This is important so that when exiting gameplay, the track is not restarted back to the preview point.
music.Play(isNewTrack);
}
lastTrack.SetTarget(track);
@@ -630,7 +637,7 @@ namespace osu.Game.Screens.SelectV2
ensureGlobalBeatmapValid();
ensurePlayingSelected(false);
ensurePlayingSelected();
updateBackgroundDim();
}
+1 -1
View File
@@ -35,7 +35,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="20.1.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.718.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.715.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2025.708.0" />
<PackageReference Include="Sentry" Version="5.1.1" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
+1 -1
View File
@@ -17,6 +17,6 @@
<MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.718.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.715.0" />
</ItemGroup>
</Project>
+3 -9
View File
@@ -20,15 +20,9 @@ namespace osu.iOS
{
private readonly AppDelegate appDelegate;
public override Version AssemblyVersion
{
get
{
// Example: 2025.613.0-tachyon
string bundleVersion = NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString();
return new Version(bundleVersion.Split('-')[0]);
}
}
public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString());
public override string Version => NSBundle.MainBundle.InfoDictionary["OsuVersion"].ToString();
public override bool HideUnlicensedContent => true;
+16 -2
View File
@@ -4,8 +4,12 @@
<SupportedOSPlatformVersion>13.4</SupportedOSPlatformVersion>
<OutputType>Exe</OutputType>
<Version>0.1.0</Version>
<ApplicationVersion Condition=" '$(ApplicationVersion)' == '' ">$(Version)</ApplicationVersion>
<ApplicationDisplayVersion Condition=" '$(ApplicationDisplayVersion)' == '' ">$(Version)</ApplicationDisplayVersion>
<!-- Incoming version string will be e.g. 2025.723.0-tachyon -->
<VersionNoSuffix>$([System.String]::Copy('$(Version)').Split('-')[0])</VersionNoSuffix>
<ApplicationVersion Condition=" '$(ApplicationVersion)' == '' ">$(VersionNoSuffix)</ApplicationVersion>
<ApplicationDisplayVersion Condition=" '$(ApplicationDisplayVersion)' == '' ">$(VersionNoSuffix)</ApplicationDisplayVersion>
</PropertyGroup>
<Import Project="..\osu.iOS.props" />
<ItemGroup>
@@ -18,4 +22,14 @@
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Essentials" Version="8.0.3" />
</ItemGroup>
<!-- https://github.com/dotnet/macios/blob/eabcdee2ac43a0cc8324396a1bf75f8797d71810/msbuild/Xamarin.Shared/Xamarin.Shared.targets#L1328 -->
<Target Name="AddOsuVersionToBundle" AfterTargets="_CreateAppBundle">
<PropertyGroup>
<PlistFilePath>$(AppBundleDir)/Info.plist</PlistFilePath>
<OsuVersionKey>OsuVersion</OsuVersionKey>
</PropertyGroup>
<Exec Command="bash -c &quot;(/usr/libexec/PlistBuddy -c 'Print :$(OsuVersionKey)' '$(PlistFilePath)' &gt;/dev/null 2&gt;&amp;1 \
&amp;&amp; /usr/libexec/PlistBuddy -c 'Set :$(OsuVersionKey) $(Version)' '$(PlistFilePath)') \
|| /usr/libexec/PlistBuddy -c 'Add :$(OsuVersionKey) string $(Version)' '$(PlistFilePath)'&quot;"/>
</Target>
</Project>