diff --git a/osu.Android.props b/osu.Android.props index b179b8b837..a7376aa5a7 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + \n\n
\nWelcome to the osu! wiki, a project containing a wide range of osu! related information.\n
\n\n
\n
\n\n# Getting started\n\n[Welcome](/wiki/Welcome) • [Installation](/wiki/Installation) • [Registration](/wiki/Registration) • [Help Centre](/wiki/Help_Centre) • [FAQ](/wiki/FAQ)\n\n
\n
\n\n# Game client\n\n[Interface](/wiki/Interface) • [Options](/wiki/Options) • [Visual settings](/wiki/Visual_Settings) • [Shortcut key reference](/wiki/Shortcut_key_reference) • [Configuration file](/wiki/osu!_Program_Files/User_Configuration_File) • [Program files](/wiki/osu!_Program_Files)\n\n[File formats](/wiki/osu!_File_Formats): [.osz](/wiki/osu!_File_Formats/Osz_(file_format)) • [.osk](/wiki/osu!_File_Formats/Osk_(file_format)) • [.osr](/wiki/osu!_File_Formats/Osr_(file_format)) • [.osu](/wiki/osu!_File_Formats/Osu_(file_format)) • [.osb](/wiki/osu!_File_Formats/Osb_(file_format)) • [.db](/wiki/osu!_File_Formats/Db_(file_format))\n\n
\n
\n\n# Gameplay\n\n[Game modes](/wiki/Game_mode): [osu!](/wiki/Game_mode/osu!) • [osu!taiko](/wiki/Game_mode/osu!taiko) • [osu!catch](/wiki/Game_mode/osu!catch) • [osu!mania](/wiki/Game_mode/osu!mania)\n\n[Beatmap](/wiki/Beatmap) • [Hit object](/wiki/Hit_object) • [Mods](/wiki/Game_modifier) • [Score](/wiki/Score) • [Replay](/wiki/Replay) • [Multi](/wiki/Multi)\n\n
\n
\n\n# [Beatmap editor](/wiki/Beatmap_Editor)\n\nSections: [Compose](/wiki/Beatmap_Editor/Compose) • [Design](/wiki/Beatmap_Editor/Design) • [Timing](/wiki/Beatmap_Editor/Timing) • [Song setup](/wiki/Beatmap_Editor/Song_Setup)\n\nComponents: [AiMod](/wiki/Beatmap_Editor/AiMod) • [Beat snap divisor](/wiki/Beatmap_Editor/Beat_Snap_Divisor) • [Distance snap](/wiki/Beatmap_Editor/Distance_Snap) • [Menu](/wiki/Beatmap_Editor/Menu) • [SB load](/wiki/Beatmap_Editor/SB_Load) • [Timelines](/wiki/Beatmap_Editor/Timelines)\n\n[Beatmapping](/wiki/Beatmapping) • [Difficulty](/wiki/Beatmap/Difficulty) • [Mapping techniques](/wiki/Mapping_Techniques) • [Storyboarding](/wiki/Storyboarding)\n\n
\n
\n\n# Beatmap submission and ranking\n\n[Submission](/wiki/Submission) • [Modding](/wiki/Modding) • [Ranking procedure](/wiki/Beatmap_ranking_procedure) • [Mappers' Guild](/wiki/Mappers_Guild) • [Project Loved](/wiki/Project_Loved)\n\n[Ranking criteria](/wiki/Ranking_Criteria): [osu!](/wiki/Ranking_Criteria/osu!) • [osu!taiko](/wiki/Ranking_Criteria/osu!taiko) • [osu!catch](/wiki/Ranking_Criteria/osu!catch) • [osu!mania](/wiki/Ranking_Criteria/osu!mania)\n\n
\n
\n\n# Community\n\n[Tournaments](/wiki/Tournaments) • [Skinning](/wiki/Skinning) • [Projects](/wiki/Projects) • [Guides](/wiki/Guides) • [osu!dev Discord server](/wiki/osu!dev_Discord_server) • [How you can help](/wiki/How_You_Can_Help!) • [Glossary](/wiki/Glossary)\n\n
\n
\n\n# People\n\n[The Team](/wiki/People/The_Team): [Developers](/wiki/People/The_Team/Developers) • [Global Moderation Team](/wiki/People/The_Team/Global_Moderation_Team) • [Support Team](/wiki/People/The_Team/Support_Team) • [Nomination Assessment Team](/wiki/People/The_Team/Nomination_Assessment_Team) • [Beatmap Nominators](/wiki/People/The_Team/Beatmap_Nominators) • [osu! Alumni](/wiki/People/The_Team/osu!_Alumni) • [Project Loved Team](/wiki/People/The_Team/Project_Loved_Team)\n\nOrganisations: [osu! UCI](/wiki/Organisations/osu!_UCI)\n\n[Community Contributors](/wiki/People/Community_Contributors) • [Users with unique titles](/wiki/People/Users_with_unique_titles)\n\n
\n
\n\n# For developers\n\n[API](/wiki/osu!api) • [Bot account](/wiki/Bot_account) • [Brand identity guidelines](/wiki/Brand_identity_guidelines)\n\n
\n
\n\n# About the wiki\n\n[Sitemap](/wiki/Sitemap) • [Contribution guide](/wiki/osu!_wiki_Contribution_Guide) • [Article styling criteria](/wiki/Article_Styling_Criteria) • [News styling criteria](/wiki/News_Styling_Criteria)\n\n
\n
\n"; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index d4b6bc2b91..8909305602 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -69,8 +69,8 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/"); - AddStep("set '/wiki/Main_Page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_Page)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_Page"); + AddStep("set '/wiki/Main_page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_page)"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_page"); AddStep("set '../FAQ''", () => markdownContainer.Text = "[FAQ](../FAQ)"); AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/FAQ"); @@ -275,7 +275,7 @@ Phasellus eu nunc nec ligula semper fringilla. Aliquam magna neque, placerat sed AddStep("set content", () => { markdownContainer.Text = @" -This is a paragraph containing `inline code` synatax. +This is a paragraph containing `inline code` syntax. Oh wow I do love the `WikiMarkdownContainer`, it is very cool! This is a line before the fenced code block: diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index 79c7e3a22e..e70d35f74a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -107,12 +107,12 @@ namespace osu.Game.Tests.Visual.Online }; }); - // From https://osu.ppy.sh/api/v2/wiki/en/Main_Page + // From https://osu.ppy.sh/api/v2/wiki/en/Main_page private APIWikiPage responseMainPage => new APIWikiPage { - Title = "Main Page", - Layout = "main_page", - Path = "Main_Page", + Title = "Main page", + Layout = WikiOverlay.INDEX_PATH.ToLowerInvariant(), // custom classes are always lower snake. + Path = WikiOverlay.INDEX_PATH, Locale = "en", Subtitle = null, Markdown = diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs index 4e1bf1390a..a95bb2c9e3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs @@ -9,6 +9,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Select; +using osu.Game.Utils; namespace osu.Game.Tests.Visual.UserInterface { @@ -74,7 +75,7 @@ namespace osu.Game.Tests.Visual.UserInterface private bool assertModsMultiplier(IEnumerable mods) { double multiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); - string expectedValue = multiplier.Equals(1.0) ? string.Empty : $"{multiplier:N2}x"; + string expectedValue = multiplier == 1 ? string.Empty : ModUtils.FormatScoreMultiplier(multiplier).ToString(); return expectedValue == footerButtonMods.MultiplierText.Current.Value; } diff --git a/osu.Game/BackgroundDataStoreProcessor.cs b/osu.Game/BackgroundDataStoreProcessor.cs index a748a7422a..fc7db13d41 100644 --- a/osu.Game/BackgroundDataStoreProcessor.cs +++ b/osu.Game/BackgroundDataStoreProcessor.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -28,6 +29,8 @@ namespace osu.Game /// public partial class BackgroundDataStoreProcessor : Component { + protected Task ProcessingTask { get; private set; } = null!; + [Resolved] private RulesetStore rulesetStore { get; set; } = null!; @@ -61,7 +64,7 @@ namespace osu.Game { base.LoadComplete(); - Task.Factory.StartNew(() => + ProcessingTask = Task.Factory.StartNew(() => { Logger.Log("Beginning background data store processing.."); @@ -314,10 +317,17 @@ namespace osu.Game { Logger.Log("Querying for scores that need total score conversion..."); - HashSet scoreIds = realmAccess.Run(r => new HashSet(r.All() - .Where(s => !s.BackgroundReprocessingFailed && s.BeatmapInfo != null - && s.TotalScoreVersion < LegacyScoreEncoder.LATEST_VERSION) - .AsEnumerable().Select(s => s.ID))); + HashSet scoreIds = realmAccess.Run(r => new HashSet( + r.All() + .Where(s => !s.BackgroundReprocessingFailed + && s.BeatmapInfo != null + && s.IsLegacyScore + && s.TotalScoreVersion < LegacyScoreEncoder.LATEST_VERSION) + .AsEnumerable() + // must be done after materialisation, as realm doesn't want to support + // nested property predicates + .Where(s => s.Ruleset.IsLegacyRuleset()) + .Select(s => s.ID))); Logger.Log($"Found {scoreIds.Count} scores which require total score conversion."); diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index d4162b76d6..23686db1f8 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -20,6 +20,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Skinning; +using osu.Game.Users; namespace osu.Game.Configuration { @@ -193,6 +194,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.LastProcessedMetadataId, -1); SetDefault(OsuSetting.ComboColourNormalisationAmount, 0.2f, 0f, 1f, 0.01f); + SetDefault(OsuSetting.UserOnlineStatus, null); } protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) @@ -420,5 +422,6 @@ namespace osu.Game.Configuration EditorShowSpeedChanges, TouchDisableGameplayTaps, ModSelectTextSearchStartsActive, + UserOnlineStatus, } } diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index 1da64d5be8..9683baec69 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.IO; -using System.Linq; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Logging; @@ -98,15 +98,11 @@ namespace osu.Game.Database // can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal. realm.Write(r => { - // TODO: consider using a realm native query to avoid iterating all files (https://github.com/realm/realm-dotnet/issues/2659#issuecomment-927823707) - var files = r.All().ToList(); - - foreach (var file in files) + foreach (var file in r.All().Filter(@$"{nameof(RealmFile.Usages)}.@count = 0")) { totalFiles++; - if (file.BacklinksCount > 0) - continue; + Debug.Assert(file.BacklinksCount == 0); try { diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 66c816e796..8c73806cb5 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -311,13 +311,22 @@ namespace osu.Game.Database long maximumLegacyBonusScore = attributes.BonusScore; double legacyAccScore = maximumLegacyAccuracyScore * score.Accuracy; - // We can not separate the ComboScore from the BonusScore, so we keep the bonus in the ratio. - // Note that `maximumLegacyComboScore + maximumLegacyBonusScore` can actually be 0 - // when playing a beatmap with no bonus objects, with mods that have a 0.0x multiplier on stable (relax/autopilot). - // In such cases, just assume 0. - double comboProportion = maximumLegacyComboScore + maximumLegacyBonusScore > 0 - ? ((double)score.LegacyTotalScore - legacyAccScore) / (maximumLegacyComboScore + maximumLegacyBonusScore) - : 0; + + double comboProportion; + + if (maximumLegacyComboScore + maximumLegacyBonusScore > 0) + { + // We can not separate the ComboScore from the BonusScore, so we keep the bonus in the ratio. + comboProportion = Math.Max((double)score.LegacyTotalScore - legacyAccScore, 0) / (maximumLegacyComboScore + maximumLegacyBonusScore); + } + else + { + // Two possible causes: + // the beatmap has no bonus objects *AND* + // either the active mods have a zero mod multiplier, in which case assume 0, + // or the *beatmap* has a zero `difficultyPeppyStars` (or just no combo-giving objects), in which case assume 1. + comboProportion = legacyModMultiplier == 0 ? 0 : 1; + } // We assume the bonus proportion only makes up the rest of the score that exceeds maximumLegacyBaseScore. long maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore; @@ -437,16 +446,42 @@ namespace osu.Game.Database break; case 2: + // compare logic in `CatchScoreProcessor`. + + // this could technically be slightly incorrect in the case of stable scores. + // because large droplet misses are counted as full misses in stable scores, + // `score.MaximumStatistics.GetValueOrDefault(Great)` will be equal to the count of fruits *and* large droplets + // rather than just fruits (which was the intent). + // this is not fixable without introducing an extra legacy score attribute dedicated for catch, + // and this is a ballpark conversion process anyway, so attempt to trudge on. + int fruitTinyScaleDivisor = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + score.MaximumStatistics.GetValueOrDefault(HitResult.Great); + double fruitTinyScale = fruitTinyScaleDivisor == 0 + ? 0 + : (double)score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) / fruitTinyScaleDivisor; + + const int max_tiny_droplets_portion = 400000; + + double comboPortion = 1000000 - max_tiny_droplets_portion + max_tiny_droplets_portion * (1 - fruitTinyScale); + double dropletsPortion = max_tiny_droplets_portion * fruitTinyScale; + double dropletsHit = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) == 0 + ? 0 + : (double)score.Statistics.GetValueOrDefault(HitResult.SmallTickHit) / score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit); + convertedTotalScore = (long)Math.Round(( - 600000 * comboProportion - + 400000 * score.Accuracy + comboPortion * estimateComboProportionForCatch(attributes.MaxCombo, score.MaxCombo, score.Statistics.GetValueOrDefault(HitResult.Miss)) + + dropletsPortion * dropletsHit + bonusProportion) * modMultiplier); break; case 3: + // in the mania case accuracy actually changes between score V1 and score V2 / standardised + // (PERFECT weighting changes from 300 to 305), + // so for better accuracy recompute accuracy locally based on hit statistics and use that instead, + double scoreV2Accuracy = ComputeAccuracy(score); + convertedTotalScore = (long)Math.Round(( 850000 * comboProportion - + 150000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy) + + 150000 * Math.Pow(scoreV2Accuracy, 2 + 2 * scoreV2Accuracy) + bonusProportion) * modMultiplier); break; @@ -461,6 +496,94 @@ namespace osu.Game.Database return convertedTotalScore; } + /// + /// + /// For catch, the general method of calculating the combo proportion used for other rulesets is generally useless. + /// This is because in stable score V1, catch has quadratic score progression, + /// while in stable score V2, score progression is logarithmic up to 200 combo and then linear. + /// + /// + /// This means that applying the naive rescale method to scores with lots of short combos (think 10x 100-long combos on a 1000-object map) + /// by linearly rescaling the combo portion as given by score V1 leads to horribly underestimating it. + /// Therefore this method attempts to counteract this by calculating the best case estimate for the combo proportion that takes all of the above into account. + /// + /// + /// The general idea is that aside from the which the player is known to have hit, + /// the remaining misses are evenly distributed across the rest of the objects that give combo. + /// This is therefore a worst-case estimate. + /// + /// + private static double estimateComboProportionForCatch(int beatmapMaxCombo, int scoreMaxCombo, int scoreMissCount) + { + if (beatmapMaxCombo == 0) + return 1; + + if (scoreMaxCombo == 0) + return 0; + + if (beatmapMaxCombo == scoreMaxCombo) + return 1; + + double estimatedBestCaseTotal = estimateBestCaseComboTotal(beatmapMaxCombo); + + int remainingCombo = beatmapMaxCombo - (scoreMaxCombo + scoreMissCount); + double totalDroppedScore = 0; + + int assumedLengthOfRemainingCombos = (int)Math.Floor((double)remainingCombo / scoreMissCount); + + if (assumedLengthOfRemainingCombos > 0) + { + int assumedCombosCount = (int)Math.Floor((double)remainingCombo / assumedLengthOfRemainingCombos); + totalDroppedScore += assumedCombosCount * estimateDroppedComboScoreAfterMiss(assumedLengthOfRemainingCombos); + + remainingCombo -= assumedCombosCount * assumedLengthOfRemainingCombos; + + if (remainingCombo > 0) + totalDroppedScore += estimateDroppedComboScoreAfterMiss(remainingCombo); + } + else + { + // there are so many misses that attempting to evenly divide remaining combo results in 0 length per combo, + // i.e. all remaining judgements are combo breaks. + // in that case, presume every single remaining object is a miss and did not give any combo score. + totalDroppedScore = estimatedBestCaseTotal - estimateBestCaseComboTotal(scoreMaxCombo); + } + + return estimatedBestCaseTotal == 0 + ? 1 + : 1 - Math.Clamp(totalDroppedScore / estimatedBestCaseTotal, 0, 1); + + double estimateBestCaseComboTotal(int maxCombo) + { + if (maxCombo == 0) + return 1; + + double estimatedTotal = 0.5 * Math.Min(maxCombo, 2); + + if (maxCombo <= 2) + return estimatedTotal; + + // int_2^x log_4(t) dt + estimatedTotal += (Math.Min(maxCombo, 200) * (Math.Log(Math.Min(maxCombo, 200)) - 1) + 2 - Math.Log(4)) / Math.Log(4); + + if (maxCombo <= 200) + return estimatedTotal; + + estimatedTotal += (maxCombo - 200) * Math.Log(200) / Math.Log(4); + return estimatedTotal; + } + + double estimateDroppedComboScoreAfterMiss(int lengthOfComboAfterMiss) + { + if (lengthOfComboAfterMiss >= 200) + lengthOfComboAfterMiss = 200; + + // int_0^x (log_4(200) - log_4(t)) dt + // note that this is an pessimistic estimate, i.e. it may subtract too much if the miss happened before reaching 200 combo + return lengthOfComboAfterMiss * (1 + Math.Log(200) - Math.Log(lengthOfComboAfterMiss)) / Math.Log(4); + } + } + public static double ComputeAccuracy(ScoreInfo scoreInfo) { Ruleset ruleset = scoreInfo.Ruleset.CreateInstance(); diff --git a/osu.Game/Extensions/TimeDisplayExtensions.cs b/osu.Game/Extensions/TimeDisplayExtensions.cs index 98633958ee..1b224cfeb7 100644 --- a/osu.Game/Extensions/TimeDisplayExtensions.cs +++ b/osu.Game/Extensions/TimeDisplayExtensions.cs @@ -59,7 +59,8 @@ namespace osu.Game.Extensions /// A short relative string representing the input time. public static string ToShortRelativeTime(this DateTimeOffset time, TimeSpan lowerCutoff) { - if (time == default) + // covers all `DateTimeOffset` instances with the date portion of 0001-01-01. + if (time.Date == default) return "-"; var now = DateTime.Now; diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 0d11d2d4ef..1b5877b966 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -95,6 +95,7 @@ namespace osu.Game.Graphics case HitResult.SmallTickHit: case HitResult.LargeTickHit: + case HitResult.SliderTailHit: case HitResult.Great: return Blue; diff --git a/osu.Game/Input/ConfineMouseTracker.cs b/osu.Game/Input/ConfineMouseTracker.cs index de8660dbce..926f68df45 100644 --- a/osu.Game/Input/ConfineMouseTracker.cs +++ b/osu.Game/Input/ConfineMouseTracker.cs @@ -22,6 +22,7 @@ namespace osu.Game.Input { private Bindable frameworkConfineMode; private Bindable frameworkWindowMode; + private Bindable frameworkMinimiseOnFocusLossInFullscreen; private Bindable osuConfineMode; private IBindable localUserPlaying; @@ -31,7 +32,9 @@ namespace osu.Game.Input { frameworkConfineMode = frameworkConfigManager.GetBindable(FrameworkSetting.ConfineMouseMode); frameworkWindowMode = frameworkConfigManager.GetBindable(FrameworkSetting.WindowMode); + frameworkMinimiseOnFocusLossInFullscreen = frameworkConfigManager.GetBindable(FrameworkSetting.MinimiseOnFocusLossInFullscreen); frameworkWindowMode.BindValueChanged(_ => updateConfineMode()); + frameworkMinimiseOnFocusLossInFullscreen.BindValueChanged(_ => updateConfineMode()); osuConfineMode = osuConfigManager.GetBindable(OsuSetting.ConfineMouseMode); localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy(); @@ -46,7 +49,8 @@ namespace osu.Game.Input if (frameworkConfineMode.Disabled) return; - if (frameworkWindowMode.Value == WindowMode.Fullscreen) + // override confine mode only when clicking outside the window minimises it. + if (frameworkWindowMode.Value == WindowMode.Fullscreen && frameworkMinimiseOnFocusLossInFullscreen.Value) { frameworkConfineMode.Value = ConfineMouseMode.Fullscreen; return; diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs index 422704514f..1d14b0a596 100644 --- a/osu.Game/Localisation/GraphicsSettingsStrings.cs +++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs @@ -152,9 +152,13 @@ namespace osu.Game.Localisation /// /// "In order to change the renderer, the game will close. Please open it again." /// - public static LocalisableString ChangeRendererConfirmation => - new TranslatableString(getKey(@"change_renderer_configuration"), @"In order to change the renderer, the game will close. Please open it again."); + public static LocalisableString ChangeRendererConfirmation => new TranslatableString(getKey(@"change_renderer_configuration"), @"In order to change the renderer, the game will close. Please open it again."); - private static string getKey(string key) => $"{prefix}:{key}"; + /// + /// "Minimise osu! when switching to another app" + /// + public static LocalisableString MinimiseOnFocusLoss => new TranslatableString(getKey(@"minimise_on_focus_loss"), @"Minimise osu! when switching to another app"); + + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs index 2faa3f0ca6..4d1642fb5f 100644 --- a/osu.Game/Models/RealmFile.cs +++ b/osu.Game/Models/RealmFile.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Game.IO; using Realms; @@ -11,5 +12,8 @@ namespace osu.Game.Models { [PrimaryKey] public string Hash { get; set; } = string.Empty; + + [Backlink(nameof(RealmNamedFileUsage.File))] + public IQueryable Usages { get; } = null!; } } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 21107d61fc..17bf8bcc37 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -53,6 +53,7 @@ namespace osu.Game.Online.API public IBindable LocalUser => localUser; public IBindableList Friends => friends; public IBindable Activity => activity; + public IBindable Statistics => statistics; public Language Language => game.CurrentLanguage.Value; @@ -62,6 +63,11 @@ namespace osu.Game.Online.API private Bindable activity { get; } = new Bindable(); + private Bindable configStatus { get; } = new Bindable(); + private Bindable localUserStatus { get; } = new Bindable(); + + private Bindable statistics { get; } = new Bindable(); + protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); @@ -85,12 +91,20 @@ namespace osu.Game.Online.API authentication.TokenString = config.Get(OsuSetting.Token); authentication.Token.ValueChanged += onTokenChanged; + config.BindWith(OsuSetting.UserOnlineStatus, configStatus); + localUser.BindValueChanged(u => { u.OldValue?.Activity.UnbindFrom(activity); u.NewValue.Activity.BindTo(activity); + + if (u.OldValue != null) + localUserStatus.UnbindFrom(u.OldValue.Status); + localUserStatus.BindTo(u.NewValue.Status); }, true); + localUserStatus.BindValueChanged(val => configStatus.Value = val.NewValue); + var thread = new Thread(run) { Name = "APIAccess", @@ -200,6 +214,7 @@ namespace osu.Game.Online.API setLocalUser(new APIUser { Username = ProvidedUsername, + Status = { Value = configStatus.Value ?? UserStatus.Online } }); } @@ -246,8 +261,7 @@ namespace osu.Game.Online.API }; userReq.Success += user => { - // todo: save/pull from settings - user.Status.Value = UserStatus.Online; + user.Status.Value = configStatus.Value ?? UserStatus.Online; setLocalUser(user); @@ -506,9 +520,21 @@ namespace osu.Game.Online.API flushQueue(); } + public void UpdateStatistics(UserStatistics newStatistics) + { + statistics.Value = newStatistics; + + if (IsLoggedIn) + localUser.Value.Statistics = newStatistics; + } + private static APIUser createGuestUser() => new GuestUser(); - private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false); + private void setLocalUser(APIUser user) => Scheduler.Add(() => + { + localUser.Value = user; + statistics.Value = user.Statistics; + }, false); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index d585124db6..4b4f8061e0 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -28,6 +28,8 @@ namespace osu.Game.Online.API public Bindable Activity { get; } = new Bindable(); + public Bindable Statistics { get; } = new Bindable(); + public Language Language => Language.en; public string AccessToken => "token"; @@ -115,6 +117,12 @@ namespace osu.Game.Online.API Id = DUMMY_USER_ID, }; + Statistics.Value = new UserStatistics + { + GlobalRank = 1, + CountryRank = 1 + }; + state.Value = APIState.Online; } @@ -126,6 +134,14 @@ namespace osu.Game.Online.API LocalUser.Value = new GuestUser(); } + public void UpdateStatistics(UserStatistics newStatistics) + { + Statistics.Value = newStatistics; + + if (IsLoggedIn) + LocalUser.Value.Statistics = newStatistics; + } + public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; public NotificationsClientConnector GetNotificationsConnector() => new PollingNotificationsClientConnector(this); @@ -141,6 +157,7 @@ namespace osu.Game.Online.API IBindable IAPIProvider.LocalUser => LocalUser; IBindableList IAPIProvider.Friends => Friends; IBindable IAPIProvider.Activity => Activity; + IBindable IAPIProvider.Statistics => Statistics; /// /// During the next simulated login, the process will fail immediately. diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index a1d7006c8c..b58d4a363a 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -28,6 +28,11 @@ namespace osu.Game.Online.API /// IBindable Activity { get; } + /// + /// The current user's online statistics. + /// + IBindable Statistics { get; } + /// /// The language supplied by this provider to API requests. /// @@ -111,6 +116,11 @@ namespace osu.Game.Online.API /// void Logout(); + /// + /// Sets Statistics bindable. + /// + void UpdateStatistics(UserStatistics newStatistics); + /// /// Constructs a new . May be null if not supported. /// diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index e95bc128c8..23989caae2 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -247,7 +247,7 @@ namespace osu.Game.Online.Chat string command = parameters[0]; string content = parameters.Length == 2 ? parameters[1] : string.Empty; - switch (command) + switch (command.ToLowerInvariant()) { case "np": AddInternal(new NowPlayingCommand(target)); diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 93aa0b95a7..67f2590ad8 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -152,6 +152,15 @@ namespace osu.Game.Online.Leaderboards /// public void RefetchScores() => Scheduler.AddOnce(refetchScores); + /// + /// Clear all scores from the display. + /// + public void ClearScores() + { + cancelPendingWork(); + SetScores(null); + } + /// /// Call when a retrieval or display failure happened to show a relevant message to the user. /// @@ -220,9 +229,7 @@ namespace osu.Game.Online.Leaderboards { Debug.Assert(ThreadSafety.IsUpdateThread); - cancelPendingWork(); - - SetScores(null); + ClearScores(); setState(LeaderboardState.Retrieving); currentFetchCancellationSource = new CancellationTokenSource(); diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index 6d00ce7551..c42c3378b7 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -23,7 +23,6 @@ namespace osu.Game.Online.Metadata public override IBindable IsWatchingUserPresence => isWatchingUserPresence; private readonly BindableBool isWatchingUserPresence = new BindableBool(); - // ReSharper disable once InconsistentlySynchronizedField public override IBindableDictionary UserStates => userStates; private readonly BindableDictionary userStates = new BindableDictionary(); @@ -192,7 +191,7 @@ namespace osu.Game.Online.Metadata { Schedule(() => { - if (presence != null) + if (presence?.Status != null) userStates[userId] = presence.Value; else userStates.Remove(userId); diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs index 46449fea73..55b27fb364 100644 --- a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -127,6 +127,8 @@ namespace osu.Game.Online.Solo { string rulesetName = callback.Score.Ruleset.ShortName; + api.UpdateStatistics(updatedStatistics); + if (latestStatistics == null) return; diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 4048b35e78..4ac37a63e2 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -32,7 +32,7 @@ namespace osu.Game.Overlays.Dialog private readonly Vector2 ringMinifiedSize = new Vector2(20f); private readonly Box flashLayer; - private Sample flashSample = null!; + private Sample? flashSample; private readonly Container content; private readonly Container ring; @@ -267,7 +267,7 @@ namespace osu.Game.Overlays.Dialog flashLayer.FadeInFromZero(80, Easing.OutQuint) .Then() .FadeOutFromOne(1500, Easing.OutQuint); - flashSample.Play(); + flashSample?.Play(); } protected override bool OnKeyDown(KeyDownEvent e) diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index 57a9f4035c..513088ba0c 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -139,6 +139,8 @@ namespace osu.Game.Overlays.Login }, }; + panel.Status.BindValueChanged(_ => updateDropdownCurrent(), true); + dropdown.Current.BindValueChanged(action => { switch (action.NewValue) @@ -170,6 +172,24 @@ namespace osu.Game.Overlays.Login ScheduleAfterChildren(() => GetContainingInputManager()?.ChangeFocus(form)); }); + private void updateDropdownCurrent() + { + switch (panel.Status.Value) + { + case UserStatus.Online: + dropdown.Current.Value = UserAction.Online; + break; + + case UserStatus.DoNotDisturb: + dropdown.Current.Value = UserAction.DoNotDisturb; + break; + + case UserStatus.Offline: + dropdown.Current.Value = UserAction.AppearOffline; + break; + } + } + public override bool AcceptsFocus => true; protected override bool OnClick(ClickEvent e) => true; diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index baa7e594c1..f9875c5547 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -447,7 +447,7 @@ namespace osu.Game.Overlays.Mods private void filterMods() { foreach (var modState in AllAvailableMods) - modState.ValidForSelection.Value = modState.Mod.HasImplementation && IsValidMod.Invoke(modState.Mod); + modState.ValidForSelection.Value = modState.Mod.Type != ModType.System && modState.Mod.HasImplementation && IsValidMod.Invoke(modState.Mod); } private void updateMultiplier() diff --git a/osu.Game/Overlays/Mods/ScoreMultiplierDisplay.cs b/osu.Game/Overlays/Mods/ScoreMultiplierDisplay.cs index c758632392..b599b53082 100644 --- a/osu.Game/Overlays/Mods/ScoreMultiplierDisplay.cs +++ b/osu.Game/Overlays/Mods/ScoreMultiplierDisplay.cs @@ -4,7 +4,6 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,6 +14,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; using osuTK; namespace osu.Game.Overlays.Mods @@ -147,7 +147,7 @@ namespace osu.Game.Overlays.Mods { protected override double RollingDuration => 500; - protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"0.00x"); + protected override LocalisableString FormatCount(double count) => ModUtils.FormatScoreMultiplier(count); protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText { diff --git a/osu.Game/Overlays/Mods/SelectAllModsButton.cs b/osu.Game/Overlays/Mods/SelectAllModsButton.cs index b6b3051a0d..1da762d164 100644 --- a/osu.Game/Overlays/Mods/SelectAllModsButton.cs +++ b/osu.Game/Overlays/Mods/SelectAllModsButton.cs @@ -41,8 +41,8 @@ namespace osu.Game.Overlays.Mods private void updateEnabledState() { Enabled.Value = availableMods.Value - .Where(pair => pair.Key != ModType.System) .SelectMany(pair => pair.Value) + .Where(modState => modState.ValidForSelection.Value) .Any(modState => !modState.Active.Value && modState.Visible); } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index a3290bc81c..71afec88d4 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -51,6 +51,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private SettingsDropdown resolutionDropdown = null!; private SettingsDropdown displayDropdown = null!; private SettingsDropdown windowModeDropdown = null!; + private SettingsCheckbox minimiseOnFocusLossCheckbox = null!; private SettingsCheckbox safeAreaConsiderationsCheckbox = null!; private Bindable scalingPositionX = null!; @@ -106,6 +107,12 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics ItemSource = resolutions, Current = sizeFullscreen }, + minimiseOnFocusLossCheckbox = new SettingsCheckbox + { + LabelText = GraphicsSettingsStrings.MinimiseOnFocusLoss, + Current = config.GetBindable(FrameworkSetting.MinimiseOnFocusLossInFullscreen), + Keywords = new[] { "alt-tab", "minimize", "focus", "hide" }, + }, safeAreaConsiderationsCheckbox = new SettingsCheckbox { LabelText = "Shrink game to avoid cameras and notches", @@ -255,6 +262,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { resolutionDropdown.CanBeShown.Value = resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen; displayDropdown.CanBeShown.Value = displayDropdown.Items.Count() > 1; + minimiseOnFocusLossCheckbox.CanBeShown.Value = RuntimeInfo.IsDesktop && windowModeDropdown.Current.Value == WindowMode.Fullscreen; safeAreaConsiderationsCheckbox.CanBeShown.Value = host.Window?.SafeAreaPadding.Value.Total != Vector2.Zero; } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs index 53d0f50605..adf05a71b9 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs @@ -140,7 +140,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input /// A generated from the full input state. /// The key which triggered this update, and should be used as the binding. public void UpdateKeyCombination(KeyCombination fullState, InputKey triggerKey) => - UpdateKeyCombination(new KeyCombination(fullState.Keys.Where(KeyCombination.IsModifierKey).Append(triggerKey))); + // TODO: Distinct() can be removed after https://github.com/ppy/osu-framework/pull/6130 is merged. + UpdateKeyCombination(new KeyCombination(fullState.Keys.Where(KeyCombination.IsModifierKey).Append(triggerKey).Distinct().ToArray())); public void UpdateKeyCombination(KeyCombination newCombination) { diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 6bf06f4f98..7805ed5834 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -28,6 +28,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private Bindable localSensitivity; private Bindable windowMode; + private Bindable minimiseOnFocusLoss; private SettingsEnumDropdown confineMouseModeSetting; private Bindable relativeMode; @@ -47,6 +48,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input relativeMode = mouseHandler.UseRelativeMode.GetBoundCopy(); windowMode = config.GetBindable(FrameworkSetting.WindowMode); + minimiseOnFocusLoss = config.GetBindable(FrameworkSetting.MinimiseOnFocusLossInFullscreen); Children = new Drawable[] { @@ -98,21 +100,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input localSensitivity.BindValueChanged(val => handlerSensitivity.Value = val.NewValue); - windowMode.BindValueChanged(mode => - { - bool isFullscreen = mode.NewValue == WindowMode.Fullscreen; - - if (isFullscreen) - { - confineMouseModeSetting.Current.Disabled = true; - confineMouseModeSetting.TooltipText = MouseSettingsStrings.NotApplicableFullscreen; - } - else - { - confineMouseModeSetting.Current.Disabled = false; - confineMouseModeSetting.TooltipText = string.Empty; - } - }, true); + windowMode.BindValueChanged(_ => updateConfineMouseModeSettingVisibility()); + minimiseOnFocusLoss.BindValueChanged(_ => updateConfineMouseModeSettingVisibility(), true); highPrecisionMouse.Current.BindValueChanged(highPrecision => { @@ -126,6 +115,25 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, true); } + /// + /// Updates disabled state and tooltip of to match when is overriding the confine mode. + /// + private void updateConfineMouseModeSettingVisibility() + { + bool confineModeOverriden = windowMode.Value == WindowMode.Fullscreen && minimiseOnFocusLoss.Value; + + if (confineModeOverriden) + { + confineMouseModeSetting.Current.Disabled = true; + confineMouseModeSetting.TooltipText = MouseSettingsStrings.NotApplicableFullscreen; + } + else + { + confineMouseModeSetting.Current.Disabled = false; + confineMouseModeSetting.TooltipText = string.Empty; + } + } + public partial class SensitivitySetting : SettingsSlider { public SensitivitySetting() diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index c0948c1eab..de13bd96d4 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -151,9 +151,12 @@ namespace osu.Game.Overlays base.Update(); if (!headerTextVisibilityCache.IsValid) + { // These toolbox grouped may be contracted to only show icons. // For now, let's hide the header to avoid text truncation weirdness in such cases. headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint); + headerTextVisibilityCache.Validate(); + } } protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 03a1cfc005..1da2e1b744 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -26,6 +26,8 @@ namespace osu.Game.Overlays.Toolbar { public abstract partial class ToolbarButton : OsuClickableContainer, IKeyBindingHandler { + public const float PADDING = 3; + protected GlobalAction? Hotkey { get; set; } public void SetIcon(Drawable icon) @@ -63,6 +65,7 @@ namespace osu.Game.Overlays.Toolbar protected virtual Anchor TooltipAnchor => Anchor.TopLeft; + protected readonly Container ButtonContent; protected ConstrainedIconContainer IconContainer; protected SpriteText DrawableText; protected Box HoverBackground; @@ -80,59 +83,66 @@ namespace osu.Game.Overlays.Toolbar protected ToolbarButton() { - Width = Toolbar.HEIGHT; + AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; - Padding = new MarginPadding(3); - Children = new Drawable[] { - BackgroundContent = new Container + ButtonContent = new Container { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 6, - CornerExponent = 3f, - Children = new Drawable[] - { - HoverBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(80).Opacity(180), - Blending = BlendingParameters.Additive, - Alpha = 0, - }, - flashBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Colour = Color4.White.Opacity(100), - Blending = BlendingParameters.Additive, - }, - } - }, - Flow = new FillFlowContainer - { - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Padding = new MarginPadding { Left = Toolbar.HEIGHT / 2, Right = Toolbar.HEIGHT / 2 }, + Width = Toolbar.HEIGHT, RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, + Padding = new MarginPadding(PADDING), Children = new Drawable[] { - IconContainer = new ConstrainedIconContainer + BackgroundContent = new Container { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(20), - Alpha = 0, + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 6, + CornerExponent = 3f, + Children = new Drawable[] + { + HoverBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(80).Opacity(180), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + flashBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.White.Opacity(100), + Blending = BlendingParameters.Additive, + }, + } }, - DrawableText = new OsuSpriteText + Flow = new FillFlowContainer { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Padding = new MarginPadding { Left = Toolbar.HEIGHT / 2, Right = Toolbar.HEIGHT / 2 }, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + IconContainer = new ConstrainedIconContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(20), + Alpha = 0, + }, + DrawableText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + }, }, }, }, diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs index 67688155ae..e1d658b811 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs @@ -42,52 +42,59 @@ namespace osu.Game.Overlays.Toolbar clockDisplayMode = config.GetBindable(OsuSetting.ToolbarClockDisplayMode); prefer24HourTime = config.GetBindable(OsuSetting.Prefer24HourTime); - Padding = new MarginPadding(3); - Children = new Drawable[] { new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 6, - CornerExponent = 3f, - Children = new Drawable[] - { - hoverBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(80).Opacity(180), - Blending = BlendingParameters.Additive, - Alpha = 0, - }, - flashBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Colour = Color4.White.Opacity(100), - Blending = BlendingParameters.Additive, - }, - } - }, - new FillFlowContainer { RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Padding = new MarginPadding(10), + Padding = new MarginPadding(ToolbarButton.PADDING), Children = new Drawable[] { - analog = new AnalogClockDisplay + new Container { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 6, + CornerExponent = 3f, + Children = new Drawable[] + { + hoverBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(80).Opacity(180), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + flashBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.White.Opacity(100), + Blending = BlendingParameters.Additive, + }, + } }, - digital = new DigitalClockDisplay + new FillFlowContainer { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + analog = new AnalogClockDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + digital = new DigitalClockDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + } } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs index 70675c1b92..499ca804c9 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Toolbar { public ToolbarHomeButton() { - Width *= 1.4f; + ButtonContent.Width *= 1.4f; Hotkey = GlobalAction.Home; } diff --git a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs index 69597c6b46..5da0056787 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs @@ -28,7 +28,7 @@ namespace osu.Game.Overlays.Toolbar public ToolbarMusicButton() { Hotkey = GlobalAction.ToggleNowPlaying; - AutoSizeAxes = Axes.X; + ButtonContent.AutoSizeAxes = Axes.X; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs index 5500f1c879..c224f0f9c9 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs @@ -48,7 +48,7 @@ namespace osu.Game.Overlays.Toolbar public RulesetButton() { - Padding = new MarginPadding(3) + ButtonContent.Padding = new MarginPadding(PADDING) { Bottom = 5 }; diff --git a/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs index 78df060252..899f58c9c0 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs @@ -10,7 +10,7 @@ namespace osu.Game.Overlays.Toolbar { public ToolbarSettingsButton() { - Width *= 1.4f; + ButtonContent.Width *= 1.4f; Hotkey = GlobalAction.ToggleSettings; } diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index 19b98628e6..230974cd59 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.Toolbar public ToolbarUserButton() { - AutoSizeAxes = Axes.X; + ButtonContent.AutoSizeAxes = Axes.X; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index 24eddeb0c2..d64d6b934a 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -17,8 +17,6 @@ namespace osu.Game.Overlays.Wiki { public partial class WikiHeader : BreadcrumbControlOverlayHeader { - private const string index_path = "Main_Page"; - public static LocalisableString IndexPageString => LayoutStrings.HeaderHelpIndex; public readonly Bindable WikiPageData = new Bindable(); @@ -45,7 +43,7 @@ namespace osu.Game.Overlays.Wiki TabControl.AddItem(IndexPageString); - if (e.NewValue.Path == index_path) + if (e.NewValue.Path == WikiOverlay.INDEX_PATH) { Current.Value = IndexPageString; return; diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index c816eca776..ffbc168fb7 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -19,11 +19,11 @@ namespace osu.Game.Overlays { public partial class WikiOverlay : OnlineOverlay { - private const string index_path = @"main_page"; + public const string INDEX_PATH = @"Main_page"; public string CurrentPath => path.Value; - private readonly Bindable path = new Bindable(index_path); + private readonly Bindable path = new Bindable(INDEX_PATH); private readonly Bindable wikiData = new Bindable(); @@ -43,7 +43,7 @@ namespace osu.Game.Overlays { } - public void ShowPage(string pagePath = index_path) + public void ShowPage(string pagePath = INDEX_PATH) { path.Value = pagePath.Trim('/'); Show(); @@ -137,7 +137,7 @@ namespace osu.Game.Overlays wikiData.Value = response; path.Value = response.Path; - if (response.Layout == index_path) + if (response.Layout.Equals(INDEX_PATH, StringComparison.OrdinalIgnoreCase)) { LoadDisplay(new WikiMainPage { @@ -161,7 +161,7 @@ namespace osu.Game.Overlays path.Value = "error"; LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/", - $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page](Main_Page).")); + $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page]({INDEX_PATH}).")); } private void showParentPage() diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index dbb37e0af6..7c88a8a588 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.ModCinema; public override LocalisableString Description => "Watch the video without visual distractions."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModAutoplay), typeof(ModNoFail) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) }).ToArray(); public void ApplyToHUD(HUDOverlay overlay) { diff --git a/osu.Game/Rulesets/Mods/ModFailCondition.cs b/osu.Game/Rulesets/Mods/ModFailCondition.cs index 471c3bfe8d..0b229766c1 100644 --- a/osu.Game/Rulesets/Mods/ModFailCondition.cs +++ b/osu.Game/Rulesets/Mods/ModFailCondition.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public abstract class ModFailCondition : Mod, IApplicableToHealthProcessor, IApplicableFailOverride { - public override Type[] IncompatibleMods => new[] { typeof(ModNoFail) }; + public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModCinema) }; [SettingSource("Restart on fail", "Automatically restarts when failed.")] public BindableBool Restart { get; } = new BindableBool(); diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 95406cc9e6..dc2ad6f47e 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -56,9 +56,6 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { Combo.BindTo(scoreProcessor.Combo); - - // Default value of ScoreProcessor's Rank in Flashlight Mod should be SS+ - scoreProcessor.Rank.Value = ScoreRank.XH; } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 5a8226115f..8b25768575 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -17,8 +17,6 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { - // Default value of ScoreProcessor's Rank in Hidden Mod should be SS+ - scoreProcessor.Rank.Value = ScoreRank.XH; } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 1daaa24d57..bce28361cb 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -599,7 +599,9 @@ namespace osu.Game.Rulesets.Objects.Drawables float balanceAdjustAmount = positionalHitsoundsLevel.Value * 2; double returnedValue = balanceAdjustAmount * (position - 0.5f); - return returnedValue; + // Rounded to reduce the overhead of audio adjustments (which are currently bindable heavy). + // Balance is very hard to perceive in small increments anyways. + return Math.Round(returnedValue, 2); } /// diff --git a/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs b/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs index 53cf835248..2a5a11161b 100644 --- a/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs +++ b/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs @@ -57,5 +57,40 @@ namespace osu.Game.Rulesets.Objects.Legacy return (float)(1.0f - 0.7f * IBeatmapDifficultyInfo.DifficultyRange(circleSize)) / 2 * (applyFudge ? broken_gamefield_rounding_allowance : 1); } + + public static int CalculateDifficultyPeppyStars(BeatmapDifficulty difficulty, int objectCount, int drainLength) + { + /* + * WARNING: DO NOT TOUCH IF YOU DO NOT KNOW WHAT YOU ARE DOING + * + * It so happens that in stable, due to .NET Framework internals, float math would be performed + * using x87 registers and opcodes. + * .NET (Core) however uses SSE instructions on 32- and 64-bit words. + * x87 registers are _80 bits_ wide. Which is notably wider than _both_ float and double. + * Therefore, on a significant number of beatmaps, the rounding would not produce correct values. + * + * Thus, to crudely - but, seemingly *mostly* accurately, after checking across all ranked maps - emulate this, + * use `decimal`, which is slow, but has bigger precision than `double`. + * At the time of writing, there is _one_ ranked exception to this - namely https://osu.ppy.sh/beatmapsets/1156087#osu/2625853 - + * but it is considered an "acceptable casualty", since in that case scores aren't inflated by _that_ much compared to others. + */ + + decimal objectToDrainRatio = drainLength != 0 + ? Math.Clamp((decimal)objectCount / drainLength * 8, 0, 16) + : 16; + + /* + * Notably, THE `double` CASTS BELOW ARE IMPORTANT AND MUST REMAIN. + * Their goal is to trick the compiler / runtime into NOT promoting from single-precision float, as doing so would prompt it + * to attempt to "silently" fix the single-precision values when converting to decimal, + * which is NOT what the x87 FPU does. + */ + + decimal drainRate = (decimal)(double)difficulty.DrainRate; + decimal overallDifficulty = (decimal)(double)difficulty.OverallDifficulty; + decimal circleSize = (decimal)(double)difficulty.CircleSize; + + return (int)Math.Round((drainRate + overallDifficulty + circleSize + objectToDrainRatio) / 38 * 5); + } } } diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index f307344347..7e58df3cfa 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Scoring /// [Description(@"")] [EnumMember(Value = "none")] - [Order(14)] + [Order(15)] None, /// @@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Scoring /// Indicates small tick miss. /// [EnumMember(Value = "small_tick_miss")] - [Order(11)] + [Order(12)] SmallTickMiss, /// @@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Scoring /// [EnumMember(Value = "large_tick_miss")] [Description("-")] - [Order(10)] + [Order(11)] LargeTickMiss, /// @@ -103,7 +103,7 @@ namespace osu.Game.Rulesets.Scoring /// [Description("S Bonus")] [EnumMember(Value = "small_bonus")] - [Order(9)] + [Order(10)] SmallBonus, /// @@ -111,7 +111,7 @@ namespace osu.Game.Rulesets.Scoring /// [Description("L Bonus")] [EnumMember(Value = "large_bonus")] - [Order(8)] + [Order(9)] LargeBonus, /// @@ -119,14 +119,14 @@ namespace osu.Game.Rulesets.Scoring /// [EnumMember(Value = "ignore_miss")] [Description("-")] - [Order(13)] + [Order(14)] IgnoreMiss, /// /// Indicates a hit that should be ignored for scoring purposes. /// [EnumMember(Value = "ignore_hit")] - [Order(12)] + [Order(13)] IgnoreHit, /// @@ -136,14 +136,14 @@ namespace osu.Game.Rulesets.Scoring /// May be paired with . /// [EnumMember(Value = "combo_break")] - [Order(15)] + [Order(16)] ComboBreak, /// /// A special judgement similar to that's used to increase the valuation of the final tick of a slider. /// [EnumMember(Value = "slider_tail_hit")] - [Order(16)] + [Order(8)] SliderTailHit, /// diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 837bb4080e..14fa928224 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -86,7 +86,9 @@ namespace osu.Game.Rulesets.Scoring /// /// The current rank. /// - public readonly Bindable Rank = new Bindable(ScoreRank.X); + public IBindable Rank => rank; + + private readonly Bindable rank = new Bindable(ScoreRank.X); /// /// The highest combo achieved by this score. @@ -167,14 +169,14 @@ namespace osu.Game.Rulesets.Scoring if (!beatmapApplied) throw new InvalidOperationException($"Cannot access maximum statistics before calling {nameof(ApplyBeatmap)}."); - return new Dictionary(maximumResultCounts); + return new Dictionary(MaximumResultCounts); } } private bool beatmapApplied; - private readonly Dictionary scoreResultCounts = new Dictionary(); - private readonly Dictionary maximumResultCounts = new Dictionary(); + protected readonly Dictionary ScoreResultCounts = new Dictionary(); + protected readonly Dictionary MaximumResultCounts = new Dictionary(); private readonly List hitEvents = new List(); private HitObject? lastHitObject; @@ -186,9 +188,13 @@ namespace osu.Game.Rulesets.Scoring Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => { - Rank.Value = RankFromAccuracy(accuracy.NewValue); + // Once failed, we shouldn't update the rank anymore. + if (rank.Value == ScoreRank.F) + return; + + rank.Value = RankFromAccuracy(accuracy.NewValue); foreach (var mod in Mods.Value.OfType()) - Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); + rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); }; Mods.ValueChanged += mods => @@ -216,7 +222,7 @@ namespace osu.Game.Rulesets.Scoring if (result.FailedAtJudgement) return; - scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; + ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) + 1; if (result.Type.IncreasesCombo()) Combo.Value++; @@ -272,7 +278,7 @@ namespace osu.Game.Rulesets.Scoring if (result.FailedAtJudgement) return; - scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; + ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) - 1; if (result.Judgement.MaxResult.AffectsAccuracy()) { @@ -394,13 +400,13 @@ namespace osu.Game.Rulesets.Scoring maximumComboPortion = currentComboPortion; maximumAccuracyJudgementCount = currentAccuracyJudgementCount; - maximumResultCounts.Clear(); - maximumResultCounts.AddRange(scoreResultCounts); + MaximumResultCounts.Clear(); + MaximumResultCounts.AddRange(ScoreResultCounts); MaximumTotalScore = TotalScore.Value; } - scoreResultCounts.Clear(); + ScoreResultCounts.Clear(); currentBaseScore = 0; currentMaximumBaseScore = 0; @@ -411,8 +417,7 @@ namespace osu.Game.Rulesets.Scoring TotalScore.Value = 0; Accuracy.Value = 1; Combo.Value = 0; - Rank.Disabled = false; - Rank.Value = ScoreRank.X; + rank.Value = ScoreRank.X; HighestCombo.Value = 0; } @@ -430,10 +435,10 @@ namespace osu.Game.Rulesets.Scoring score.MaximumStatistics.Clear(); foreach (var result in HitResultExtensions.ALL_TYPES) - score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result); + score.Statistics[result] = ScoreResultCounts.GetValueOrDefault(result); foreach (var result in HitResultExtensions.ALL_TYPES) - score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); + score.MaximumStatistics[result] = MaximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. score.TotalScore = TotalScore.Value; @@ -448,7 +453,7 @@ namespace osu.Game.Rulesets.Scoring return; score.Passed = false; - Rank.Value = ScoreRank.F; + rank.Value = ScoreRank.F; PopulateScore(score); } @@ -464,8 +469,8 @@ namespace osu.Game.Rulesets.Scoring HighestCombo.Value = frame.Header.MaxCombo; TotalScore.Value = frame.Header.TotalScore; - scoreResultCounts.Clear(); - scoreResultCounts.AddRange(frame.Header.Statistics); + ScoreResultCounts.Clear(); + ScoreResultCounts.AddRange(frame.Header.Statistics); SetScoreProcessorStatistics(frame.Header.ScoreProcessorStatistics); diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index cf0a7bd54f..389b20b5c8 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -13,6 +13,7 @@ using osu.Game.Extensions; using osu.Game.IO.Legacy; using osu.Game.IO.Serialization; using osu.Game.Replays.Legacy; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; using SharpCompress.Compressors.LZMA; @@ -36,9 +37,15 @@ namespace osu.Game.Scoring.Legacy /// 30000007: Adjust osu!mania combo and accuracy portions and judgement scoring values. Reconvert all scores. /// 30000008: Add accuracy conversion. Reconvert all scores. /// 30000009: Fix edge cases in conversion for scores which have 0.0x mod multiplier on stable. Reconvert all scores. + /// 30000010: Fix mania score V1 conversion using score V1 accuracy rather than V2 accuracy. Reconvert all scores. + /// 30000011: Re-do catch scoring to mirror stable Score V2 as closely as feasible. Reconvert all scores. + /// + /// 30000012: Fix incorrect total score conversion on selected beatmaps after implementing the more correct + /// method. Reconvert all scores. + /// /// /// - public const int LATEST_VERSION = 30000009; + public const int LATEST_VERSION = 30000012; /// /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs index 024749a701..5270162189 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -62,6 +62,9 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override bool OnDragStart(DragStartEvent e) { + if (e.Button != MouseButton.Left) + return false; + if (rotationHandler == null) return false; rotationHandler.Begin(); diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index de7732dd5e..ac7dffc241 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -95,6 +95,8 @@ namespace osu.Game.Screens.Menu Colour = Color4.Black }; + public override bool? AllowGlobalTrackControl => false; + protected IntroScreen([CanBeNull] Func createNextScreen = null) { this.createNextScreen = createNextScreen; diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 516b090a16..a75edd1cff 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; +using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -25,6 +26,7 @@ using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Online.API; using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; @@ -49,6 +51,8 @@ namespace osu.Game.Screens.Menu public override bool AllowExternalScreenChange => true; + public override bool? AllowGlobalTrackControl => true; + private Screen songSelect; private MenuSideFlashes sideFlashes; @@ -390,7 +394,12 @@ namespace osu.Game.Screens.Menu if (requiresConfirmation) { if (dialogOverlay.CurrentDialog is ConfirmExitDialog exitDialog) - exitDialog.PerformOkAction(); + { + if (exitDialog.Buttons.OfType().FirstOrDefault() != null) + exitDialog.PerformOkAction(); + else + exitDialog.Flash(); + } else { dialogOverlay.Push(new ConfirmExitDialog(() => diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 7c12e6eab5..56256bb15c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -349,6 +349,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer addItemButton.Alpha = localUserCanAddItem ? 1 : 0; Scheduler.AddOnce(UpdateMods); + + Activity.Value = new UserActivity.InLobby(Room); } private bool localUserCanAddItem => client.IsHost || Room.QueueMode.Value != QueueMode.HostOnly; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index e6d9dd4cd0..d9043df1d5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -26,9 +26,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { protected override bool PauseOnFocusLost => false; - // Disallow fails in multiplayer for now. - protected override bool CheckModsAllowFailure() => false; - protected override UserActivity InitialActivity => new UserActivity.InMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); [Resolved] @@ -55,6 +52,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, + AllowFailAnimation = false, AllowSkipping = room.AutoSkip.Value, AutomaticallySkipIntro = room.AutoSkip.Value, AlwaysShowLeaderboard = true, diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 3ca82ec00b..e18612c955 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -4,12 +4,14 @@ #nullable disable using System.Collections.Generic; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osu.Game.Screens.Play.Break; namespace osu.Game.Screens.Play @@ -113,7 +115,7 @@ namespace osu.Game.Screens.Play if (scoreProcessor != null) { info.AccuracyDisplay.Current.BindTo(scoreProcessor.Accuracy); - info.GradeDisplay.Current.BindTo(scoreProcessor.Rank); + ((IBindable)info.GradeDisplay.Current).BindTo(scoreProcessor.Rank); } } diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index c2162d4df2..cc399a0fbe 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -46,7 +46,7 @@ namespace osu.Game.Screens.Play public bool HasPassed { get; set; } /// - /// Whether the user failed during gameplay. + /// Whether the user failed during gameplay. This is only set when the gameplay session has completed due to the fail. /// public bool HasFailed { get; set; } diff --git a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs index 521ad63426..171aa3f44b 100644 --- a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs @@ -83,12 +83,14 @@ namespace osu.Game.Screens.Play.HUD }, fractionPart = new ArgonCounterTextComponent(Anchor.TopLeft) { + RequiredDisplayDigits = { Value = 2 }, WireframeOpacity = { BindTarget = WireframeOpacity }, Scale = new Vector2(0.5f), }, percentText = new ArgonCounterTextComponent(Anchor.TopLeft) { Text = @"%", + RequiredDisplayDigits = { Value = 1 }, WireframeOpacity = { BindTarget = WireframeOpacity } }, } diff --git a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs index 5ea7fd0b82..1d6ca3c893 100644 --- a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs @@ -57,6 +57,31 @@ namespace osu.Game.Screens.Play.HUD }); } + public override int DisplayedCount + { + get => base.DisplayedCount; + set + { + base.DisplayedCount = value; + updateWireframe(); + } + } + + private void updateWireframe() + { + text.RequiredDisplayDigits.Value = getDigitsRequiredForDisplayCount(); + } + + private int getDigitsRequiredForDisplayCount() + { + // one for the single presumed starting digit, one for the "x" at the end. + int digitsRequired = 2; + long c = DisplayedCount; + while ((c /= 10) > 0) + digitsRequired++; + return digitsRequired; + } + protected override LocalisableString FormatCount(int count) => $@"{count}x"; protected override IHasText CreateText() => text = new ArgonCounterTextComponent(Anchor.TopLeft, MatchesStrings.MatchScoreStatsCombo.ToUpper()) diff --git a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs index 2a3f4365cb..a11f2f01cd 100644 --- a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs +++ b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; +using System.Collections.Generic; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -33,14 +33,7 @@ namespace osu.Game.Screens.Play.HUD public LocalisableString Text { get => textPart.Text; - set - { - int remainingCount = RequiredDisplayDigits.Value - value.ToString().Count(char.IsDigit); - string remainingText = remainingCount > 0 ? new string('#', remainingCount) : string.Empty; - - wireframesPart.Text = remainingText + value; - textPart.Text = value; - } + set => textPart.Text = value; } public ArgonCounterTextComponent(Anchor anchor, LocalisableString? label = null) @@ -81,6 +74,8 @@ namespace osu.Game.Screens.Play.HUD } } }; + + RequiredDisplayDigits.BindValueChanged(digits => wireframesPart.Text = new string('#', digits.NewValue)); } private string textLookup(char c) @@ -137,33 +132,49 @@ namespace osu.Game.Screens.Play.HUD [BackgroundDependencyLoader] private void load(TextureStore textures) { + const string font_name = @"argon-counter"; + Spacing = new Vector2(-2f, 0f); - Font = new FontUsage(@"argon-counter", 1); - glyphStore = new GlyphStore(textures, getLookup); + Font = new FontUsage(font_name, 1); + glyphStore = new GlyphStore(font_name, textures, getLookup); } protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); private class GlyphStore : ITexturedGlyphLookupStore { + private readonly string fontName; private readonly TextureStore textures; private readonly Func getLookup; - public GlyphStore(TextureStore textures, Func getLookup) + private readonly Dictionary cache = new Dictionary(); + + public GlyphStore(string fontName, TextureStore textures, Func getLookup) { + this.fontName = fontName; this.textures = textures; this.getLookup = getLookup; } public ITexturedCharacterGlyph? Get(string? fontName, char character) { + // We only service one font. + if (fontName != this.fontName) + return null; + + if (cache.TryGetValue(character, out var cached)) + return cached; + string lookup = getLookup(character); var texture = textures.Get($"Gameplay/Fonts/{fontName}-{lookup}"); - if (texture == null) - return null; + TexturedCharacterGlyph? glyph = null; - return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 0.125f); + if (texture != null) + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 0.125f); + + cache[character] = glyph; + return glyph; } public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index 8acc43c091..236bd3366d 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -3,9 +3,9 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -58,39 +58,12 @@ namespace osu.Game.Screens.Play.HUD private bool displayingMiss => resetMissBarDelegate != null; - private readonly List missBarVertices = new List(); - private readonly List healthBarVertices = new List(); + private readonly List vertices = new List(); private double glowBarValue; - public double GlowBarValue - { - get => glowBarValue; - set - { - if (glowBarValue == value) - return; - - glowBarValue = value; - Scheduler.AddOnce(updatePathVertices); - } - } - private double healthBarValue; - public double HealthBarValue - { - get => healthBarValue; - set - { - if (healthBarValue == value) - return; - - healthBarValue = value; - Scheduler.AddOnce(updatePathVertices); - } - } - public const float MAIN_PATH_RADIUS = 10f; private const float curve_start_offset = 70; @@ -100,6 +73,8 @@ namespace osu.Game.Screens.Play.HUD private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + private readonly Cached pathVerticesCache = new Cached(); + public ArgonHealthDisplay() { AddLayout(drawSizeLayout); @@ -158,7 +133,6 @@ namespace osu.Game.Screens.Play.HUD base.LoadComplete(); HealthProcessor.NewJudgement += onNewJudgement; - Current.BindValueChanged(onCurrentChanged, true); // we're about to set `RelativeSizeAxes` depending on the value of `UseRelativeSize`. // setting `RelativeSizeAxes` internally transforms absolute sizing to relative and back to keep the size the same, @@ -173,31 +147,6 @@ namespace osu.Game.Screens.Play.HUD private void onNewJudgement(JudgementResult result) => pendingMissAnimation |= !result.IsHit; - private void onCurrentChanged(ValueChangedEvent valueChangedEvent) - // schedule display updates one frame later to ensure we know the judgement result causing this change (if there is one). - => Scheduler.AddOnce(updateDisplay); - - private void updateDisplay() - { - double newHealth = Current.Value; - - if (newHealth >= GlowBarValue) - finishMissDisplay(); - - double time = newHealth > GlowBarValue ? 500 : 250; - - // TODO: this should probably use interpolation in update. - this.TransformTo(nameof(HealthBarValue), newHealth, time, Easing.OutQuint); - - if (pendingMissAnimation && newHealth < GlowBarValue) - triggerMissDisplay(); - - pendingMissAnimation = false; - - if (!displayingMiss) - this.TransformTo(nameof(GlowBarValue), newHealth, time, Easing.OutQuint); - } - protected override void Update() { base.Update(); @@ -208,30 +157,44 @@ namespace osu.Game.Screens.Play.HUD drawSizeLayout.Validate(); } + healthBarValue = Interpolation.DampContinuously(healthBarValue, Current.Value, 50, Time.Elapsed); + if (!displayingMiss) + glowBarValue = Interpolation.DampContinuously(glowBarValue, Current.Value, 50, Time.Elapsed); + mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); - glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, GlowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, glowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + + updatePathVertices(); + } + + protected override void HealthChanged(bool increase) + { + if (Current.Value >= glowBarValue) + finishMissDisplay(); + + if (pendingMissAnimation) + { + triggerMissDisplay(); + pendingMissAnimation = false; + } + + base.HealthChanged(increase); } protected override void FinishInitialAnimation(double value) { base.FinishInitialAnimation(value); - this.TransformTo(nameof(HealthBarValue), value, 500, Easing.OutQuint); - this.TransformTo(nameof(GlowBarValue), value, 250, Easing.OutQuint); + this.TransformTo(nameof(healthBarValue), value, 500, Easing.OutQuint); + this.TransformTo(nameof(glowBarValue), value, 250, Easing.OutQuint); } protected override void Flash() { base.Flash(); - mainBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour.Opacity(0.8f)) - .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.OutQuint); - if (!displayingMiss) { - glowBar.TransformTo(nameof(BarPath.BarColour), Colour4.White, 30, Easing.OutQuint) - .Then() - .TransformTo(nameof(BarPath.BarColour), main_bar_colour, 1000, Easing.OutQuint); - + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.GlowColour), Colour4.White, 30, Easing.OutQuint) .Then() .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.OutQuint); @@ -245,13 +208,15 @@ namespace osu.Game.Screens.Play.HUD this.Delay(500).Schedule(() => { - this.TransformTo(nameof(GlowBarValue), Current.Value, 300, Easing.OutQuint); + this.TransformTo(nameof(glowBarValue), Current.Value, 300, Easing.OutQuint); finishMissDisplay(); }, out resetMissBarDelegate); + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.BarColour), new Colour4(255, 147, 147, 255), 100, Easing.OutQuint).Then() .TransformTo(nameof(BarPath.BarColour), new Colour4(255, 93, 93, 255), 800, Easing.OutQuint); + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255).Lighten(0.2f)) .TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255), 800, Easing.OutQuint); } @@ -263,6 +228,7 @@ namespace osu.Game.Screens.Play.HUD if (Current.Value > 0) { + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.BarColour), main_bar_colour, 300, Easing.In); glowBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.In); } @@ -302,7 +268,6 @@ namespace osu.Game.Screens.Play.HUD if (DrawWidth - padding < rescale_cutoff) rescalePathProportionally(); - List vertices = new List(); barPath.GetPathToProgress(vertices, 0.0, 1.0); background.Vertices = vertices; @@ -332,20 +297,25 @@ namespace osu.Game.Screens.Play.HUD private void updatePathVertices() { - barPath.GetPathToProgress(healthBarVertices, 0.0, healthBarValue); - barPath.GetPathToProgress(missBarVertices, healthBarValue, Math.Max(glowBarValue, healthBarValue)); + barPath.GetPathToProgress(vertices, 0.0, healthBarValue); + if (vertices.Count == 0) vertices.Add(Vector2.Zero); + Vector2 initialVertex = vertices[0]; + for (int i = 0; i < vertices.Count; i++) + vertices[i] -= initialVertex; - if (healthBarVertices.Count == 0) - healthBarVertices.Add(Vector2.Zero); + mainBar.Vertices = vertices; + mainBar.Position = initialVertex; - if (missBarVertices.Count == 0) - missBarVertices.Add(Vector2.Zero); + barPath.GetPathToProgress(vertices, healthBarValue, Math.Max(glowBarValue, healthBarValue)); + if (vertices.Count == 0) vertices.Add(Vector2.Zero); + initialVertex = vertices[0]; + for (int i = 0; i < vertices.Count; i++) + vertices[i] -= initialVertex; - glowBar.Vertices = missBarVertices.Select(v => v - missBarVertices[0]).ToList(); - glowBar.Position = missBarVertices[0]; + glowBar.Vertices = vertices; + glowBar.Position = initialVertex; - mainBar.Vertices = healthBarVertices.Select(v => v - healthBarVertices[0]).ToList(); - mainBar.Position = healthBarVertices[0]; + pathVerticesCache.Validate(); } protected override void Dispose(bool isDisposing) @@ -358,14 +328,17 @@ namespace osu.Game.Screens.Play.HUD private partial class BackgroundPath : SmoothPath { + private static readonly Color4 colour_white = Color4.White.Opacity(0.8f); + private static readonly Color4 colour_black = Color4.Black.Opacity(0.2f); + protected override Color4 ColourAt(float position) { if (position <= 0.16f) - return Color4.White.Opacity(0.8f); + return colour_white; return Interpolation.ValueAt(position, - Color4.White.Opacity(0.8f), - Color4.Black.Opacity(0.2f), + colour_white, + colour_black, -0.5f, 1f, Easing.OutQuint); } } @@ -404,12 +377,14 @@ namespace osu.Game.Screens.Play.HUD public float GlowPortion { get; init; } + private static readonly Colour4 transparent_black = Colour4.Black.Opacity(0.0f); + protected override Color4 ColourAt(float position) { if (position >= GlowPortion) return BarColour; - return Interpolation.ValueAt(position, Colour4.Black.Opacity(0.0f), GlowColour, 0.0, GlowPortion, Easing.InQuint); + return Interpolation.ValueAt(position, transparent_black, GlowColour, 0.0, GlowPortion, Easing.InQuint); } } } diff --git a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs index 005f7e36a7..f7ca218767 100644 --- a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -15,6 +16,8 @@ namespace osu.Game.Screens.Play.HUD { public partial class ArgonScoreCounter : GameplayScoreCounter, ISerialisableDrawable { + private ArgonScoreTextComponent scoreText = null!; + protected override double RollingDuration => 500; protected override Easing RollingEasing => Easing.OutQuint; @@ -33,13 +36,42 @@ namespace osu.Game.Screens.Play.HUD protected override LocalisableString FormatCount(long count) => count.ToLocalisableString(); - protected override IHasText CreateText() => new ArgonScoreTextComponent(Anchor.TopRight, BeatmapsetsStrings.ShowScoreboardHeadersScore.ToUpper()) + protected override IHasText CreateText() => scoreText = new ArgonScoreTextComponent(Anchor.TopRight, BeatmapsetsStrings.ShowScoreboardHeadersScore.ToUpper()) { - RequiredDisplayDigits = { BindTarget = RequiredDisplayDigits }, WireframeOpacity = { BindTarget = WireframeOpacity }, ShowLabel = { BindTarget = ShowLabel }, }; + public ArgonScoreCounter() + { + RequiredDisplayDigits.BindValueChanged(_ => updateWireframe()); + } + + public override long DisplayedCount + { + get => base.DisplayedCount; + set + { + base.DisplayedCount = value; + updateWireframe(); + } + } + + private void updateWireframe() + { + scoreText.RequiredDisplayDigits.Value = + Math.Max(RequiredDisplayDigits.Value, getDigitsRequiredForDisplayCount()); + } + + private int getDigitsRequiredForDisplayCount() + { + int digitsRequired = 1; + long c = DisplayedCount; + while ((c /= 10) > 0) + digitsRequired++; + return digitsRequired; + } + private partial class ArgonScoreTextComponent : ArgonCounterTextComponent { public ArgonScoreTextComponent(Anchor anchor, LocalisableString? label = null) diff --git a/osu.Game/Screens/Play/HUD/ComboCounter.cs b/osu.Game/Screens/Play/HUD/ComboCounter.cs index 17531281aa..93802e11c2 100644 --- a/osu.Game/Screens/Play/HUD/ComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ComboCounter.cs @@ -11,11 +11,6 @@ namespace osu.Game.Screens.Play.HUD { public bool UsesFixedAnchor { get; set; } - protected ComboCounter() - { - Current.Value = DisplayedCount = 0; - } - protected override double GetProportionalDuration(int currentValue, int newValue) { return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; diff --git a/osu.Game/Screens/Play/HUD/FailingLayer.cs b/osu.Game/Screens/Play/HUD/FailingLayer.cs index 3954e23cbe..2bac7660b3 100644 --- a/osu.Game/Screens/Play/HUD/FailingLayer.cs +++ b/osu.Game/Screens/Play/HUD/FailingLayer.cs @@ -100,11 +100,11 @@ namespace osu.Game.Screens.Play.HUD protected override void Update() { + base.Update(); + double target = Math.Clamp(max_alpha * (1 - Current.Value / low_health_threshold), 0, max_alpha); boxes.Alpha = (float)Interpolation.Lerp(boxes.Alpha, target, Clock.ElapsedFrameTime * 0.01f); - - base.Update(); } } } diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index 7747036826..3ef3dcb417 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -8,6 +8,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; @@ -30,11 +31,13 @@ namespace osu.Game.Screens.Play.HUD public Bindable Current { get; } = new BindableDouble { MinValue = 0, - MaxValue = 1 + MaxValue = 1, }; private BindableNumber health = null!; + protected bool InitialAnimationPlaying => initialIncrease != null; + private ScheduledDelegate? initialIncrease; /// @@ -56,13 +59,6 @@ namespace osu.Game.Screens.Play.HUD // Don't bind directly so we can animate the startup procedure. health = HealthProcessor.Health.GetBoundCopy(); - health.BindValueChanged(h => - { - if (initialIncrease != null) - FinishInitialAnimation(h.OldValue); - - Current.Value = h.NewValue; - }); if (hudOverlay != null) showHealthBar.BindTo(hudOverlay.ShowHealthBar); @@ -70,12 +66,42 @@ namespace osu.Game.Screens.Play.HUD // this probably shouldn't be operating on `this.` showHealthBar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); + initialHealthValue = health.Value; + if (PlayInitialIncreaseAnimation) startInitialAnimation(); else Current.Value = health.Value; } + private double lastValue; + private double initialHealthValue; + + protected override void Update() + { + base.Update(); + + if (!InitialAnimationPlaying || health.Value != initialHealthValue) + { + Current.Value = health.Value; + + if (initialIncrease != null) + FinishInitialAnimation(Current.Value); + } + + // Health changes every frame in draining situations. + // Manually handle value changes to avoid bindable event flow overhead. + if (!Precision.AlmostEquals(lastValue, Current.Value, 0.001f)) + { + HealthChanged(Current.Value > lastValue); + lastValue = Current.Value; + } + } + + protected virtual void HealthChanged(bool increase) + { + } + private void startInitialAnimation() { if (Current.Value >= health.Value) diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index a475f4823f..0d60ec4713 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -40,6 +40,12 @@ namespace osu.Game.Screens.Play Precision = 0.1, }; + /// + /// Whether the audio playback rate should be validated. + /// Mostly disabled for tests. + /// + internal bool ShouldValidatePlaybackRate { get; init; } + /// /// Whether the audio playback is within acceptable ranges. /// Will become false if audio playback is not going as expected. @@ -223,6 +229,9 @@ namespace osu.Game.Screens.Play private void checkPlaybackValidity() { + if (!ShouldValidatePlaybackRate) + return; + if (GameplayClock.IsRunning) { elapsedGameplayClockTime += GameplayClock.ElapsedFrameTime; diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index c960ac357f..b87306b9a2 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -735,7 +735,7 @@ namespace osu.Game.Screens.Play } // Only show the completion screen if the player hasn't failed - if (HealthProcessor.HasFailed) + if (GameplayState.HasFailed) return; GameplayState.HasPassed = true; @@ -801,8 +801,6 @@ namespace osu.Game.Screens.Play // This player instance may already be in the process of exiting. return; - Debug.Assert(ScoreProcessor.Rank.Value != ScoreRank.F); - this.Push(CreateResults(prepareScoreForDisplayTask.GetResultSafely())); }, Time.Current + delay, 50); @@ -924,37 +922,44 @@ namespace osu.Game.Screens.Play if (!CheckModsAllowFailure()) return false; - Debug.Assert(!GameplayState.HasFailed); - Debug.Assert(!GameplayState.HasPassed); - Debug.Assert(!GameplayState.HasQuit); + if (Configuration.AllowFailAnimation) + { + Debug.Assert(!GameplayState.HasFailed); + Debug.Assert(!GameplayState.HasPassed); + Debug.Assert(!GameplayState.HasQuit); - GameplayState.HasFailed = true; + GameplayState.HasFailed = true; - updateGameplayState(); + updateGameplayState(); - // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer) - // could process an extra frame after the GameplayClock is stopped. - // In such cases we want the fail state to precede a user triggered pause. - if (PauseOverlay.State.Value == Visibility.Visible) - PauseOverlay.Hide(); + // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer) + // could process an extra frame after the GameplayClock is stopped. + // In such cases we want the fail state to precede a user triggered pause. + if (PauseOverlay.State.Value == Visibility.Visible) + PauseOverlay.Hide(); - failAnimationContainer.Start(); + failAnimationContainer.Start(); - // Failures can be triggered either by a judgement, or by a mod. - // - // For the case of a judgement, due to ordering considerations, ScoreProcessor will not have received - // the final judgement which triggered the failure yet (see DrawableRuleset.NewResult handling above). - // - // A schedule here ensures that any lingering judgements from the current frame are applied before we - // finalise the score as "failed". - Schedule(() => + // Failures can be triggered either by a judgement, or by a mod. + // + // For the case of a judgement, due to ordering considerations, ScoreProcessor will not have received + // the final judgement which triggered the failure yet (see DrawableRuleset.NewResult handling above). + // + // A schedule here ensures that any lingering judgements from the current frame are applied before we + // finalise the score as "failed". + Schedule(() => + { + ScoreProcessor.FailScore(Score.ScoreInfo); + OnFail(); + + if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) + Restart(true); + }); + } + else { ScoreProcessor.FailScore(Score.ScoreInfo); - OnFail(); - - if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) - Restart(true); - }); + } return true; } diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index 122e25f406..466a691118 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -15,6 +15,12 @@ namespace osu.Game.Screens.Play /// public bool ShowResults { get; set; } = true; + /// + /// Whether the fail animation / screen should be triggered on failing. + /// If false, the score will still be marked as failed but gameplay will continue. + /// + public bool AllowFailAnimation { get; set; } = true; + /// /// Whether the player should be allowed to trigger a restart. /// diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 83adf1f960..04abb6162f 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -41,6 +41,7 @@ namespace osu.Game.Screens.Play [Resolved] private SessionStatics statics { get; set; } + private readonly object scoreSubmissionLock = new object(); private TaskCompletionSource scoreSubmissionSource; protected SubmittingPlayer(PlayerConfiguration configuration = null) @@ -60,6 +61,11 @@ namespace osu.Game.Screens.Play AddInternal(new PlayerTouchInputDetector()); } + protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart) + { + ShouldValidatePlaybackRate = true, + }; + protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); @@ -228,16 +234,19 @@ namespace osu.Game.Screens.Play return Task.CompletedTask; } - if (scoreSubmissionSource != null) - return scoreSubmissionSource.Task; + lock (scoreSubmissionLock) + { + if (scoreSubmissionSource != null) + return scoreSubmissionSource.Task; + + scoreSubmissionSource = new TaskCompletionSource(); + } // if the user never hit anything, this score should not be counted in any way. if (!score.ScoreInfo.Statistics.Any(s => s.Key.IsHit() && s.Value > 0)) return Task.CompletedTask; Logger.Log($"Beginning score submission (token:{token.Value})..."); - - scoreSubmissionSource = new TaskCompletionSource(); var request = CreateSubmissionRequest(score, token.Value); request.Success += s => diff --git a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs index 73b9897096..762be61853 100644 --- a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs @@ -37,7 +37,6 @@ namespace osu.Game.Screens.Ranking.Statistics RelativeSizeAxes = Axes.X, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Width = 0.5f, StatisticsUpdate = { BindTarget = StatisticsUpdate } })).ToArray(); } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 89911c9a69..4408634787 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -96,18 +96,19 @@ namespace osu.Game.Screens.Select /// /// Extend the range to retain already loaded pooled drawables. /// - private const float distance_offscreen_before_unload = 1024; + private const float distance_offscreen_before_unload = 2048; /// /// Extend the range to update positions / retrieve pooled drawables outside of visible range. /// - private const float distance_offscreen_to_preload = 512; // todo: adjust this appropriately once we can make set panel contents load while off-screen. + private const float distance_offscreen_to_preload = 768; /// /// Whether carousel items have completed asynchronously loaded. /// public bool BeatmapSetsLoaded { get; private set; } + [Cached] protected readonly CarouselScrollContainer Scroll; private readonly NoResultsPlaceholder noResultsPlaceholder; @@ -1251,7 +1252,7 @@ namespace osu.Game.Screens.Select } } - protected partial class CarouselScrollContainer : UserTrackingScrollContainer + public partial class CarouselScrollContainer : UserTrackingScrollContainer { private bool rightMouseScrollBlocked; diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index f16e92a82a..369db37e63 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -5,11 +5,14 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -46,6 +49,8 @@ namespace osu.Game.Screens.Select.Carousel private MenuItem[]? mainMenuItems; + private double timeSinceUnpool; + [Resolved] private BeatmapManager manager { get; set; } = null!; @@ -54,6 +59,7 @@ namespace osu.Game.Screens.Select.Carousel base.FreeAfterUse(); Item = null; + timeSinceUnpool = 0; ClearTransforms(); } @@ -92,13 +98,21 @@ namespace osu.Game.Screens.Select.Carousel // algorithm for this is taken from ScrollContainer. // while it doesn't necessarily need to match 1:1, as we are emulating scroll in some cases this feels most correct. Y = (float)Interpolation.Lerp(targetY, Y, Math.Exp(-0.01 * Time.Elapsed)); + + loadContentIfRequired(); } + private CancellationTokenSource? loadCancellation; + protected override void UpdateItem() { + loadCancellation?.Cancel(); + loadCancellation = null; + base.UpdateItem(); Content.Clear(); + Header.Clear(); beatmapContainer = null; beatmapsLoadTask = null; @@ -107,32 +121,8 @@ namespace osu.Game.Screens.Select.Carousel return; beatmapSet = ((CarouselBeatmapSet)Item).BeatmapSet; - - DelayedLoadWrapper background; - DelayedLoadWrapper mainFlow; - - Header.Children = new Drawable[] - { - // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). - background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) - { - RelativeSizeAxes = Axes.Both, - }, 200) - { - RelativeSizeAxes = Axes.Both - }, - mainFlow = new DelayedLoadWrapper(() => new SetPanelContent((CarouselBeatmapSet)Item), 50) - { - RelativeSizeAxes = Axes.Both - }, - }; - - background.DelayedLoadComplete += fadeContentIn; - mainFlow.DelayedLoadComplete += fadeContentIn; } - private void fadeContentIn(Drawable d) => d.FadeInFromZero(150); - protected override void Deselected() { base.Deselected(); @@ -190,6 +180,56 @@ namespace osu.Game.Screens.Select.Carousel } } + [Resolved] + private BeatmapCarousel.CarouselScrollContainer scrollContainer { get; set; } = null!; + + private void loadContentIfRequired() + { + Quad containingSsdq = scrollContainer.ScreenSpaceDrawQuad; + + // Using DelayedLoadWrappers would only allow us to load content when on screen, but we want to preload while off-screen + // to provide a better user experience. + + // This is tracking time that this drawable is updating since the last pool. + // This is intended to provide a debounce so very fast scrolls (from one end to the other of the carousel) + // don't cause huge overheads. + // + // We increase the delay based on distance from centre, so the beatmaps the user is currently looking at load first. + float timeUpdatingBeforeLoad = 50 + Math.Abs(containingSsdq.Centre.Y - ScreenSpaceDrawQuad.Centre.Y) / containingSsdq.Height * 100; + + Debug.Assert(Item != null); + + // A load is already in progress if the cancellation token is non-null. + if (loadCancellation != null) + return; + + timeSinceUnpool += Time.Elapsed; + + // We only trigger a load after this set has been in an updating state for a set amount of time. + if (timeSinceUnpool <= timeUpdatingBeforeLoad) + return; + + loadCancellation = new CancellationTokenSource(); + + LoadComponentsAsync(new CompositeDrawable[] + { + // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). + new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) + { + RelativeSizeAxes = Axes.Both, + }, + new SetPanelContent((CarouselBeatmapSet)Item) + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + } + }, drawables => + { + Header.AddRange(drawables); + drawables.ForEach(d => d.FadeInFromZero(150)); + }, loadCancellation.Token); + } + private void updateBeatmapYPositions() { if (beatmapContainer == null) diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index 9a84f9a0aa..69782c25bb 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -19,6 +19,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; using osu.Game.Input.Bindings; +using osu.Game.Utils; namespace osu.Game.Screens.Select { @@ -87,12 +88,11 @@ namespace osu.Game.Screens.Select private void updateMultiplierText() => Schedule(() => { double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; + MultiplierText.Text = multiplier == 1 ? string.Empty : ModUtils.FormatScoreMultiplier(multiplier); - MultiplierText.Text = multiplier.Equals(1.0) ? string.Empty : $"{multiplier:N2}x"; - - if (multiplier > 1.0) + if (multiplier > 1) MultiplierText.FadeColour(highMultiplierColour, 200); - else if (multiplier < 1.0) + else if (multiplier < 1) MultiplierText.FadeColour(lowMultiplierColour, 200); else MultiplierText.FadeColour(Color4.White, 200); diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 2d5c44e5a5..bf1724995a 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -660,7 +660,8 @@ namespace osu.Game.Screens.Select logo.Action = () => { - FinaliseSelection(); + if (this.IsCurrentScreen()) + FinaliseSelection(); return false; }; } diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 845fc77394..9c06cbbfb5 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -79,7 +79,14 @@ namespace osu.Game.Skinning marker.Position = fill.Position + new Vector2(fill.DrawWidth, isNewStyle ? fill.DrawHeight / 2 : 0); } - protected override void Flash() => marker.Flash(); + protected override void HealthChanged(bool increase) + { + if (increase) + marker.Bulge(); + base.HealthChanged(increase); + } + + protected override void Flash() => marker.Flash(Current.Value >= epic_cutoff); private static Texture getTexture(ISkin skin, string name) => skin?.GetTexture($"scorebar-{name}"); @@ -113,19 +120,16 @@ namespace osu.Game.Skinning Origin = Anchor.Centre, }; - protected override void LoadComplete() + protected override void Update() { - base.LoadComplete(); + base.Update(); - Current.BindValueChanged(hp => - { - if (hp.NewValue < 0.2f) - Main.Texture = superDangerTexture; - else if (hp.NewValue < epic_cutoff) - Main.Texture = dangerTexture; - else - Main.Texture = normalTexture; - }); + if (Current.Value < 0.2f) + Main.Texture = superDangerTexture; + else if (Current.Value < epic_cutoff) + Main.Texture = dangerTexture; + else + Main.Texture = normalTexture; } } @@ -226,37 +230,30 @@ namespace osu.Game.Skinning public abstract Sprite CreateSprite(); - protected override void LoadComplete() + public override void Flash(bool isEpic) { - base.LoadComplete(); - - Current.BindValueChanged(val => - { - if (val.NewValue > val.OldValue) - bulgeMain(); - }); - } - - public override void Flash() - { - bulgeMain(); - - bool isEpic = Current.Value >= epic_cutoff; - + Bulge(); explode.Blending = isEpic ? BlendingParameters.Additive : BlendingParameters.Inherit; explode.ScaleTo(1).Then().ScaleTo(isEpic ? 2 : 1.6f, 120); explode.FadeOutFromOne(120); } - private void bulgeMain() => + public override void Bulge() + { + base.Bulge(); Main.ScaleTo(1.4f).Then().ScaleTo(1, 200, Easing.Out); + } } public partial class LegacyHealthPiece : CompositeDrawable { public Bindable Current { get; } = new Bindable(); - public virtual void Flash() + public virtual void Bulge() + { + } + + public virtual void Flash(bool isEpic) { } } diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index b472afb74f..ff6e7fc38e 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -155,7 +155,15 @@ namespace osu.Game.Skinning if (i >= output.Length) break; - output[i] = float.Parse(values[i], CultureInfo.InvariantCulture) * (applyScaleFactor ? LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR : 1); + if (!float.TryParse(values[i], NumberStyles.Float, CultureInfo.InvariantCulture, out float parsedValue)) + // some skins may provide incorrect entries in array values. to match stable behaviour, read such entries as zero. + // see: https://github.com/ppy/osu/issues/26464, stable code: https://github.com/peppy/osu-stable-reference/blob/3ea48705eb67172c430371dcfc8a16a002ed0d3d/osu!/Graphics/Skinning/Components/Section.cs#L134-L137 + parsedValue = 0; + + if (applyScaleFactor) + parsedValue *= LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + + output[i] = parsedValue; } } } diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 8aefa50252..581e7534e4 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -44,10 +45,11 @@ namespace osu.Game.Skinning [BackgroundDependencyLoader] private void load(ISkinSource skin) { - base.Font = new FontUsage(skin.GetFontPrefix(font), 1, fixedWidth: FixedWidth); + string fontPrefix = skin.GetFontPrefix(font); + base.Font = new FontUsage(fontPrefix, 1, fixedWidth: FixedWidth); Spacing = new Vector2(-skin.GetFontOverlap(font), 0); - glyphStore = new LegacyGlyphStore(skin, MaxSizePerGlyph); + glyphStore = new LegacyGlyphStore(fontPrefix, skin, MaxSizePerGlyph); } protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); @@ -57,25 +59,42 @@ namespace osu.Game.Skinning private readonly ISkin skin; private readonly Vector2? maxSize; - public LegacyGlyphStore(ISkin skin, Vector2? maxSize) + private readonly string fontName; + + private readonly Dictionary cache = new Dictionary(); + + public LegacyGlyphStore(string fontName, ISkin skin, Vector2? maxSize) { + this.fontName = fontName; this.skin = skin; this.maxSize = maxSize; } public ITexturedCharacterGlyph? Get(string? fontName, char character) { + // We only service one font. + if (fontName != this.fontName) + return null; + + if (cache.TryGetValue(character, out var cached)) + return cached; + string lookup = getLookupName(character); var texture = skin.GetTexture($"{fontName}-{lookup}"); - if (texture == null) - return null; + TexturedCharacterGlyph? glyph = null; - if (maxSize != null) - texture = texture.WithMaximumSize(maxSize.Value); + if (texture != null) + { + if (maxSize != null) + texture = texture.WithMaximumSize(maxSize.Value); - return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); + } + + cache[character] = glyph; + return glyph; } private static string getLookupName(char character) diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index f866a4f8ec..f153f4f8d3 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -194,9 +194,33 @@ namespace osu.Game.Skinning /// /// Whether any samples are currently playing. /// - public bool IsPlaying => samplesContainer.Any(s => s.Playing); + public bool IsPlaying + { + get + { + foreach (PoolableSkinnableSample s in samplesContainer) + { + if (s.Playing) + return true; + } - public bool IsPlayed => samplesContainer.Any(s => s.Played); + return false; + } + } + + public bool IsPlayed + { + get + { + foreach (PoolableSkinnableSample s in samplesContainer) + { + if (s.Played) + return true; + } + + return false; + } + } public IBindable AggregateVolume => samplesContainer.AggregateVolume; diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index bb4e06654a..1f491be7e3 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -132,8 +132,8 @@ namespace osu.Game.Tests.Beatmaps public AudioManager AudioManager => Audio; public IResourceStore Files => userSkinResourceStore; public new IResourceStore Resources => base.Resources; - public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null; - RealmAccess IStorageResourceProvider.RealmAccess => null; + public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null!; + RealmAccess IStorageResourceProvider.RealmAccess => null!; #endregion diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 94be4a375d..947305439e 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -58,6 +58,12 @@ namespace osu.Game.Tests.Visual [SetUpSteps] public virtual void SetUpSteps() + { + CreateNewGame(); + ConfirmAtMainMenu(); + } + + protected void CreateNewGame() { AddStep("Create new game instance", () => { @@ -71,8 +77,6 @@ namespace osu.Game.Tests.Visual AddUntilStep("Wait for load", () => Game.IsLoaded); AddUntilStep("Wait for intro", () => Game.ScreenStack.CurrentScreen is IntroScreen); - - ConfirmAtMainMenu(); } [TearDownSteps] diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index 1bd60fcdde..252579a186 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -226,5 +228,21 @@ namespace osu.Game.Utils return proposedWereValid; } + + /// + /// Given a value of a score multiplier, returns a string version with special handling for a value near 1.00x. + /// + /// The value of the score multiplier. + /// A formatted score multiplier with a trailing "x" symbol + public static LocalisableString FormatScoreMultiplier(double scoreMultiplier) + { + // Round multiplier values away from 1.00x to two significant digits. + if (scoreMultiplier > 1) + scoreMultiplier = Math.Ceiling(Math.Round(scoreMultiplier * 100, 12)) / 100; + else + scoreMultiplier = Math.Floor(Math.Round(scoreMultiplier * 100, 12)) / 100; + + return scoreMultiplier.ToLocalisableString("0.00x"); + } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index c7e0cc3808..07a638f04a 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index 7e03ab50e2..98e8b136e5 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -23,6 +23,6 @@ iossimulator-x64 - +