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:
@@ -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
@@ -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)")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 "(/usr/libexec/PlistBuddy -c 'Print :$(OsuVersionKey)' '$(PlistFilePath)' >/dev/null 2>&1 \
|
||||
&& /usr/libexec/PlistBuddy -c 'Set :$(OsuVersionKey) $(Version)' '$(PlistFilePath)') \
|
||||
|| /usr/libexec/PlistBuddy -c 'Add :$(OsuVersionKey) string $(Version)' '$(PlistFilePath)'""/>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user