diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 985fc09df3..4177c402aa 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -27,7 +27,7 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2021.1210.0", + "version": "2022.320.0", "commands": [ "localisation" ] diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml new file mode 100644 index 0000000000..5b19c3732c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -0,0 +1,72 @@ +name: Bug report +description: Report a very clearly broken issue. +body: + - type: markdown + attributes: + value: | + # osu! bug report + + Important to note that your issue may have already been reported before. Please check: + - Pinned issues, at the top of https://github.com/ppy/osu/issues. + - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0). + - And most importantly, search for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful. + + - type: dropdown + attributes: + label: Type + options: + - Crash to desktop + - Game behaviour + - Performance + - Cosmetic + - Other + validations: + required: true + - type: textarea + attributes: + label: Bug description + description: How did you find the bug? Any additional details that might help? + validations: + required: true + - type: textarea + attributes: + label: Screenshots or videos + description: Add screenshots or videos that show the bug here. + placeholder: Drag and drop the screenshots/videos into this box. + validations: + required: false + - type: input + attributes: + label: Version + description: The version you encountered this bug on. This is shown at the bottom of the main menu and also at the end of the settings screen. + validations: + required: true + - type: markdown + attributes: + value: | + ## Logs + + Attaching log files is required for every reported bug. See instructions below on how to find them. + + If the game has not yet been closed since you found the bug: + 1. Head on to game settings and click on "Open osu! folder" + 2. Then open the `logs` folder located there + + **Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead. + + The default places to find the logs are as follows: + - `%AppData%/osu/logs` *on Windows* + - `~/.local/share/osu/logs` *on Linux & macOS* + - `Android/data/sh.ppy.osulazer/files/logs` *on Android* + - *On iOS*, they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) + + If you have selected a custom location for the game files, you can find the `logs` folder there. + + After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below. + + - type: textarea + attributes: + label: Logs + placeholder: Drag and drop the log files into this box. + validations: + required: true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..5b7a98f4ba --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "ms-dotnettools.csharp" + ] +} diff --git a/README.md b/README.md index f64240f67a..dba0b2670d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ If you are looking to install or test osu! without setting up a development envi **Latest build:** -| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.15+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) +| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 10.15+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) | ------------- | ------------- | ------------- | ------------- | ------------- | - The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets. diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Scoring/PippidonScoreProcessor.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Scoring/PippidonScoreProcessor.cs deleted file mode 100644 index 1c4fe698c2..0000000000 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Scoring/PippidonScoreProcessor.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Pippidon.Scoring -{ - public class PippidonScoreProcessor : ScoreProcessor - { - } -} diff --git a/osu.Android.props b/osu.Android.props index 5b26b8f36e..6a3b113fa2 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,11 +51,11 @@ - - + + - + diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index 32ead231c7..a06484214b 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -24,7 +24,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs index 064a84cb98..b720ab1e97 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Tests [SetUp] public void SetUp() => Schedule(() => { - scoreProcessor = new ScoreProcessor(); + scoreProcessor = new ScoreProcessor(new CatchRuleset()); SetContents(_ => new CatchComboDisplay { diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 70d11c42e5..80b9436b2c 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -19,7 +19,6 @@ using osu.Game.Rulesets.Catch.Difficulty; using osu.Game.Rulesets.Catch.Scoring; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; using System; using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Catch.Edit; @@ -182,7 +181,7 @@ namespace osu.Game.Rulesets.Catch public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new CatchLegacySkinTransformer(skin); - public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new CatchPerformanceCalculator(this, attributes, score); + public override PerformanceCalculator CreatePerformanceCalculator() => new CatchPerformanceCalculator(); public int LegacyID => 2; diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 8cdbe500f0..b30b85be2d 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -13,33 +13,29 @@ namespace osu.Game.Rulesets.Catch.Difficulty { public class CatchPerformanceCalculator : PerformanceCalculator { - protected new CatchDifficultyAttributes Attributes => (CatchDifficultyAttributes)base.Attributes; - - private Mod[] mods; - private int fruitsHit; private int ticksHit; private int tinyTicksHit; private int tinyTicksMissed; private int misses; - public CatchPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) - : base(ruleset, attributes, score) + public CatchPerformanceCalculator() + : base(new CatchRuleset()) { } - public override PerformanceAttributes Calculate() + protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes) { - mods = Score.Mods; + var catchAttributes = (CatchDifficultyAttributes)attributes; - fruitsHit = Score.Statistics.GetValueOrDefault(HitResult.Great); - ticksHit = Score.Statistics.GetValueOrDefault(HitResult.LargeTickHit); - tinyTicksHit = Score.Statistics.GetValueOrDefault(HitResult.SmallTickHit); - tinyTicksMissed = Score.Statistics.GetValueOrDefault(HitResult.SmallTickMiss); - misses = Score.Statistics.GetValueOrDefault(HitResult.Miss); + fruitsHit = score.Statistics.GetValueOrDefault(HitResult.Great); + ticksHit = score.Statistics.GetValueOrDefault(HitResult.LargeTickHit); + tinyTicksHit = score.Statistics.GetValueOrDefault(HitResult.SmallTickHit); + tinyTicksMissed = score.Statistics.GetValueOrDefault(HitResult.SmallTickMiss); + misses = score.Statistics.GetValueOrDefault(HitResult.Miss); // We are heavily relying on aim in catch the beat - double value = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; + double value = Math.Pow(5.0 * Math.Max(1.0, catchAttributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; // Longer maps are worth more. "Longer" means how many hits there are which can contribute to combo int numTotalHits = totalComboHits(); @@ -52,10 +48,10 @@ namespace osu.Game.Rulesets.Catch.Difficulty value *= Math.Pow(0.97, misses); // Combo scaling - if (Attributes.MaxCombo > 0) - value *= Math.Min(Math.Pow(Score.MaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); + if (catchAttributes.MaxCombo > 0) + value *= Math.Min(Math.Pow(score.MaxCombo, 0.8) / Math.Pow(catchAttributes.MaxCombo, 0.8), 1.0); - double approachRate = Attributes.ApproachRate; + double approachRate = catchAttributes.ApproachRate; double approachRateFactor = 1.0; if (approachRate > 9.0) approachRateFactor += 0.1 * (approachRate - 9.0); // 10% for each AR above 9 @@ -66,7 +62,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty value *= approachRateFactor; - if (mods.Any(m => m is ModHidden)) + if (score.Mods.Any(m => m is ModHidden)) { // Hiddens gives almost nothing on max approach rate, and more the lower it is if (approachRate <= 10.0) @@ -75,12 +71,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty value *= 1.01 + 0.04 * (11.0 - Math.Min(11.0, approachRate)); // 5% at AR 10, 1% at AR 11 } - if (mods.Any(m => m is ModFlashlight)) + if (score.Mods.Any(m => m is ModFlashlight)) value *= 1.35 * lengthBonus; value *= Math.Pow(accuracy(), 5.5); - if (mods.Any(m => m is ModNoFail)) + if (score.Mods.Any(m => m is ModNoFail)) value *= 0.90; return new CatchPerformanceAttributes diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs index 2cc05826b4..51b1ccaaba 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs @@ -7,5 +7,11 @@ namespace osu.Game.Rulesets.Catch.Scoring { public class CatchScoreProcessor : ScoreProcessor { + public CatchScoreProcessor() + : base(new CatchRuleset()) + { + } + + protected override double ClassicScoreMultiplier => 28; } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index 722cb55036..b347cc9ae2 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -13,10 +13,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty { public class ManiaPerformanceCalculator : PerformanceCalculator { - protected new ManiaDifficultyAttributes Attributes => (ManiaDifficultyAttributes)base.Attributes; - - private Mod[] mods; - // Score after being scaled by non-difficulty-increasing mods private double scaledScore; @@ -27,39 +23,40 @@ namespace osu.Game.Rulesets.Mania.Difficulty private int countMeh; private int countMiss; - public ManiaPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) - : base(ruleset, attributes, score) + public ManiaPerformanceCalculator() + : base(new ManiaRuleset()) { } - public override PerformanceAttributes Calculate() + protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes) { - mods = Score.Mods; - scaledScore = Score.TotalScore; - countPerfect = Score.Statistics.GetValueOrDefault(HitResult.Perfect); - countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); - countGood = Score.Statistics.GetValueOrDefault(HitResult.Good); - countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); - countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); - countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); + var maniaAttributes = (ManiaDifficultyAttributes)attributes; - if (Attributes.ScoreMultiplier > 0) + scaledScore = score.TotalScore; + countPerfect = score.Statistics.GetValueOrDefault(HitResult.Perfect); + countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); + countGood = score.Statistics.GetValueOrDefault(HitResult.Good); + countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + + if (maniaAttributes.ScoreMultiplier > 0) { // Scale score up, so it's comparable to other keymods - scaledScore *= 1.0 / Attributes.ScoreMultiplier; + scaledScore *= 1.0 / maniaAttributes.ScoreMultiplier; } // Arbitrary initial value for scaling pp in order to standardize distributions across game modes. // The specific number has no intrinsic meaning and can be adjusted as needed. double multiplier = 0.8; - if (mods.Any(m => m is ModNoFail)) + if (score.Mods.Any(m => m is ModNoFail)) multiplier *= 0.9; - if (mods.Any(m => m is ModEasy)) + if (score.Mods.Any(m => m is ModEasy)) multiplier *= 0.5; - double difficultyValue = computeDifficultyValue(); - double accValue = computeAccuracyValue(difficultyValue); + double difficultyValue = computeDifficultyValue(maniaAttributes); + double accValue = computeAccuracyValue(difficultyValue, maniaAttributes); double totalValue = Math.Pow( Math.Pow(difficultyValue, 1.1) + @@ -75,9 +72,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty }; } - private double computeDifficultyValue() + private double computeDifficultyValue(ManiaDifficultyAttributes attributes) { - double difficultyValue = Math.Pow(5 * Math.Max(1, Attributes.StarRating / 0.2) - 4.0, 2.2) / 135.0; + double difficultyValue = Math.Pow(5 * Math.Max(1, attributes.StarRating / 0.2) - 4.0, 2.2) / 135.0; difficultyValue *= 1.0 + 0.1 * Math.Min(1.0, totalHits / 1500.0); @@ -97,14 +94,14 @@ namespace osu.Game.Rulesets.Mania.Difficulty return difficultyValue; } - private double computeAccuracyValue(double difficultyValue) + private double computeAccuracyValue(double difficultyValue, ManiaDifficultyAttributes attributes) { - if (Attributes.GreatHitWindow <= 0) + if (attributes.GreatHitWindow <= 0) return 0; // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution - double accuracyValue = Math.Max(0.0, 0.2 - (Attributes.GreatHitWindow - 34) * 0.006667) + double accuracyValue = Math.Max(0.0, 0.2 - (attributes.GreatHitWindow - 34) * 0.006667) * difficultyValue * Math.Pow(Math.Max(0.0, scaledScore - 960000) / 40000, 1.1); diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index f139a88f50..bd6a67bf67 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Mania public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this); - public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new ManiaPerformanceCalculator(this, attributes, score); + public override PerformanceCalculator CreatePerformanceCalculator() => new ManiaPerformanceCalculator(); public const string SHORT_NAME = "mania"; diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index 48b377c794..02d62a090b 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -7,8 +7,15 @@ namespace osu.Game.Rulesets.Mania.Scoring { internal class ManiaScoreProcessor : ScoreProcessor { + public ManiaScoreProcessor() + : base(new ManiaRuleset()) + { + } + protected override double DefaultAccuracyPortion => 0.99; protected override double DefaultComboPortion => 0.01; + + protected override double ClassicScoreMultiplier => 16; } } diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index bd4c3d3345..4ce29ab5c7 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 604ab73454..a93a1641a1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -14,10 +13,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public new OsuDifficultyAttributes Attributes => (OsuDifficultyAttributes)base.Attributes; - - private Mod[] mods; - private double accuracy; private int scoreMaxCombo; private int countGreat; @@ -27,31 +22,32 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double effectiveMissCount; - public OsuPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) - : base(ruleset, attributes, score) + public OsuPerformanceCalculator() + : base(new OsuRuleset()) { } - public override PerformanceAttributes Calculate() + protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes) { - mods = Score.Mods; - accuracy = Score.Accuracy; - scoreMaxCombo = Score.MaxCombo; - countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); - countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); - countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); - countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); - effectiveMissCount = calculateEffectiveMissCount(); + var osuAttributes = (OsuDifficultyAttributes)attributes; + + accuracy = score.Accuracy; + scoreMaxCombo = score.MaxCombo; + countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); + countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + effectiveMissCount = calculateEffectiveMissCount(osuAttributes); double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. - if (mods.Any(m => m is OsuModNoFail)) + if (score.Mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); - if (mods.Any(m => m is OsuModSpunOut) && totalHits > 0) - multiplier *= 1.0 - Math.Pow((double)Attributes.SpinnerCount / totalHits, 0.85); + if (score.Mods.Any(m => m is OsuModSpunOut) && totalHits > 0) + multiplier *= 1.0 - Math.Pow((double)osuAttributes.SpinnerCount / totalHits, 0.85); - if (mods.Any(h => h is OsuModRelax)) + if (score.Mods.Any(h => h is OsuModRelax)) { // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. effectiveMissCount = Math.Min(effectiveMissCount + countOk + countMeh, totalHits); @@ -59,10 +55,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty multiplier *= 0.6; } - double aimValue = computeAimValue(); - double speedValue = computeSpeedValue(); - double accuracyValue = computeAccuracyValue(); - double flashlightValue = computeFlashlightValue(); + double aimValue = computeAimValue(score, osuAttributes); + double speedValue = computeSpeedValue(score, osuAttributes); + double accuracyValue = computeAccuracyValue(score, osuAttributes); + double flashlightValue = computeFlashlightValue(score, osuAttributes); double totalValue = Math.Pow( Math.Pow(aimValue, 1.1) + @@ -82,11 +78,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty }; } - private double computeAimValue() + private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - double rawAim = Attributes.AimDifficulty; + double rawAim = attributes.AimDifficulty; - if (mods.Any(m => m is OsuModTouchDevice)) + if (score.Mods.Any(m => m is OsuModTouchDevice)) rawAim = Math.Pow(rawAim, 0.8); double aimValue = Math.Pow(5.0 * Math.Max(1.0, rawAim / 0.0675) - 4.0, 3.0) / 100000.0; @@ -99,44 +95,44 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) aimValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), effectiveMissCount); - aimValue *= getComboScalingFactor(); + aimValue *= getComboScalingFactor(attributes); double approachRateFactor = 0.0; - if (Attributes.ApproachRate > 10.33) - approachRateFactor = 0.3 * (Attributes.ApproachRate - 10.33); - else if (Attributes.ApproachRate < 8.0) - approachRateFactor = 0.1 * (8.0 - Attributes.ApproachRate); + if (attributes.ApproachRate > 10.33) + approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33); + else if (attributes.ApproachRate < 8.0) + approachRateFactor = 0.1 * (8.0 - attributes.ApproachRate); aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. - if (mods.Any(m => m is OsuModBlinds)) - aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * Attributes.DrainRate * Attributes.DrainRate); - else if (mods.Any(h => h is OsuModHidden)) + if (score.Mods.Any(m => m is OsuModBlinds)) + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); + else if (score.Mods.Any(h => h is OsuModHidden)) { // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); + aimValue *= 1.0 + 0.04 * (12.0 - attributes.ApproachRate); } // We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator. - double estimateDifficultSliders = Attributes.SliderCount * 0.15; + double estimateDifficultSliders = attributes.SliderCount * 0.15; - if (Attributes.SliderCount > 0) + if (attributes.SliderCount > 0) { - double estimateSliderEndsDropped = Math.Clamp(Math.Min(countOk + countMeh + countMiss, Attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders); - double sliderNerfFactor = (1 - Attributes.SliderFactor) * Math.Pow(1 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + Attributes.SliderFactor; + double estimateSliderEndsDropped = Math.Clamp(Math.Min(countOk + countMeh + countMiss, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders); + double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + attributes.SliderFactor; aimValue *= sliderNerfFactor; } aimValue *= accuracy; // It is important to consider accuracy difficulty when scaling with accuracy. - aimValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500; + aimValue *= 0.98 + Math.Pow(attributes.OverallDifficulty, 2) / 2500; return aimValue; } - private double computeSpeedValue() + private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - double speedValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.SpeedDifficulty / 0.0675) - 4.0, 3.0) / 100000.0; + double speedValue = Math.Pow(5.0 * Math.Max(1.0, attributes.SpeedDifficulty / 0.0675) - 4.0, 3.0) / 100000.0; double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); @@ -146,27 +142,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) speedValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); - speedValue *= getComboScalingFactor(); + speedValue *= getComboScalingFactor(attributes); double approachRateFactor = 0.0; - if (Attributes.ApproachRate > 10.33) - approachRateFactor = 0.3 * (Attributes.ApproachRate - 10.33); + if (attributes.ApproachRate > 10.33) + approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33); speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. - if (mods.Any(m => m is OsuModBlinds)) + if (score.Mods.Any(m => m is OsuModBlinds)) { // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. speedValue *= 1.12; } - else if (mods.Any(m => m is OsuModHidden)) + else if (score.Mods.Any(m => m is OsuModHidden)) { // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - speedValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); + speedValue *= 1.0 + 0.04 * (12.0 - attributes.ApproachRate); } // Scale the speed value with accuracy and OD. - speedValue *= (0.95 + Math.Pow(Attributes.OverallDifficulty, 2) / 750) * Math.Pow(accuracy, (14.5 - Math.Max(Attributes.OverallDifficulty, 8)) / 2); + speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow(accuracy, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2); // Scale the speed value with # of 50s to punish doubletapping. speedValue *= Math.Pow(0.98, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); @@ -174,14 +170,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty return speedValue; } - private double computeAccuracyValue() + private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - if (mods.Any(h => h is OsuModRelax)) + if (score.Mods.Any(h => h is OsuModRelax)) return 0.0; // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. double betterAccuracyPercentage; - int amountHitObjectsWithAccuracy = Attributes.HitCircleCount; + int amountHitObjectsWithAccuracy = attributes.HitCircleCount; if (amountHitObjectsWithAccuracy > 0) betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6); @@ -194,43 +190,43 @@ namespace osu.Game.Rulesets.Osu.Difficulty // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. - double accuracyValue = Math.Pow(1.52163, Attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; + double accuracyValue = Math.Pow(1.52163, attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. - if (mods.Any(m => m is OsuModBlinds)) + if (score.Mods.Any(m => m is OsuModBlinds)) accuracyValue *= 1.14; - else if (mods.Any(m => m is OsuModHidden)) + else if (score.Mods.Any(m => m is OsuModHidden)) accuracyValue *= 1.08; - if (mods.Any(m => m is OsuModFlashlight)) + if (score.Mods.Any(m => m is OsuModFlashlight)) accuracyValue *= 1.02; return accuracyValue; } - private double computeFlashlightValue() + private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - if (!mods.Any(h => h is OsuModFlashlight)) + if (!score.Mods.Any(h => h is OsuModFlashlight)) return 0.0; - double rawFlashlight = Attributes.FlashlightDifficulty; + double rawFlashlight = attributes.FlashlightDifficulty; - if (mods.Any(m => m is OsuModTouchDevice)) + if (score.Mods.Any(m => m is OsuModTouchDevice)) rawFlashlight = Math.Pow(rawFlashlight, 0.8); double flashlightValue = Math.Pow(rawFlashlight, 2.0) * 25.0; - if (mods.Any(h => h is OsuModHidden)) + if (score.Mods.Any(h => h is OsuModHidden)) flashlightValue *= 1.3; // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); - flashlightValue *= getComboScalingFactor(); + flashlightValue *= getComboScalingFactor(attributes); // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) + @@ -239,19 +235,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty // Scale the flashlight value with accuracy _slightly_. flashlightValue *= 0.5 + accuracy / 2.0; // It is important to also consider accuracy difficulty when doing that. - flashlightValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500; + flashlightValue *= 0.98 + Math.Pow(attributes.OverallDifficulty, 2) / 2500; return flashlightValue; } - private double calculateEffectiveMissCount() + private double calculateEffectiveMissCount(OsuDifficultyAttributes attributes) { // Guess the number of misses + slider breaks from combo double comboBasedMissCount = 0.0; - if (Attributes.SliderCount > 0) + if (attributes.SliderCount > 0) { - double fullComboThreshold = Attributes.MaxCombo - 0.1 * Attributes.SliderCount; + double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; if (scoreMaxCombo < fullComboThreshold) comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); } @@ -262,7 +258,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty return Math.Max(countMiss, comboBasedMissCount); } - private double getComboScalingFactor() => Attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); + private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalSuccessfulHits => countGreat + countOk + countMeh; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs index ed4b139e00..1abbd67d8f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Fun; public override string Description => "No need to chase the circle – the circle chases you!"; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) }; private IFrameStableClock gameplayClock; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index e04a30d06c..f46573c494 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.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 System.Linq; using osu.Framework.Bindables; using osu.Game.Configuration; @@ -16,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModClassic : ModClassic, IApplicableToHitObject, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset { + public override Type[] IncompatibleMods => new[] { typeof(OsuModStrictTracking) }; + [SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")] public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 1bf63ef6d4..9719de441e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer { public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModAimAssist) }).ToArray(); /// /// How early before a hitobject's start time to trigger a hit. diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs new file mode 100644 index 0000000000..ee325db66a --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -0,0 +1,148 @@ +// 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 System.Linq; +using System.Threading; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModStrictTracking : Mod, IApplicableAfterBeatmapConversion, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset + { + public override string Name => @"Strict Tracking"; + public override string Acronym => @"ST"; + public override IconUsage? Icon => FontAwesome.Solid.PenFancy; + public override ModType Type => ModType.DifficultyIncrease; + public override string Description => @"Follow circles just got serious..."; + public override double ScoreMultiplier => 1.0; + public override Type[] IncompatibleMods => new[] { typeof(ModClassic) }; + + public void ApplyToDrawableHitObject(DrawableHitObject drawable) + { + if (drawable is DrawableSlider slider) + { + slider.Tracking.ValueChanged += e => + { + if (e.NewValue || slider.Judged) return; + + var tail = slider.NestedHitObjects.OfType().First(); + + if (!tail.Judged) + tail.MissForcefully(); + }; + } + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var osuBeatmap = (OsuBeatmap)beatmap; + + if (osuBeatmap.HitObjects.Count == 0) return; + + var hitObjects = osuBeatmap.HitObjects.Select(ho => + { + if (ho is Slider slider) + { + var newSlider = new StrictTrackingSlider(slider); + return newSlider; + } + + return ho; + }).ToList(); + + osuBeatmap.HitObjects = hitObjects; + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableRuleset.Playfield.RegisterPool(10, 100); + } + + private class StrictTrackingSliderTailCircle : SliderTailCircle + { + public StrictTrackingSliderTailCircle(Slider slider) + : base(slider) + { + } + + public override Judgement CreateJudgement() => new OsuJudgement(); + } + + private class StrictTrackingDrawableSliderTail : DrawableSliderTail + { + public override bool DisplayResult => true; + } + + private class StrictTrackingSlider : Slider + { + public StrictTrackingSlider(Slider original) + { + StartTime = original.StartTime; + Samples = original.Samples; + Path = original.Path; + NodeSamples = original.NodeSamples; + RepeatCount = original.RepeatCount; + Position = original.Position; + NewCombo = original.NewCombo; + ComboOffset = original.ComboOffset; + LegacyLastTickOffset = original.LegacyLastTickOffset; + TickDistanceMultiplier = original.TickDistanceMultiplier; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken); + + foreach (var e in sliderEvents) + { + switch (e.Type) + { + case SliderEventType.Head: + AddNested(HeadCircle = new SliderHeadCircle + { + StartTime = e.Time, + Position = Position, + StackHeight = StackHeight, + }); + break; + + case SliderEventType.LegacyLastTick: + AddNested(TailCircle = new StrictTrackingSliderTailCircle(this) + { + RepeatIndex = e.SpanIndex, + StartTime = e.Time, + Position = EndPosition, + StackHeight = StackHeight + }); + break; + + case SliderEventType.Repeat: + AddNested(new SliderRepeat(this) + { + RepeatIndex = e.SpanIndex, + StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration, + Position = Position + Path.PositionAt(e.PathProgress), + StackHeight = StackHeight, + Scale = Scale, + }); + break; + } + } + + UpdateNestedSamples(); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 3acec4498d..1447f131c6 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -2,23 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; -using osuTK; -using osu.Framework.Graphics; -using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Audio; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; -using osuTK.Graphics; using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -126,18 +125,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } Samples.Samples = HitObject.TailSamples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); - - var slidingSamples = new List(); - - var normalSample = HitObject.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); - if (normalSample != null) - slidingSamples.Add(HitObject.SampleControlPoint.ApplyTo(normalSample).With("sliderslide")); - - var whistleSample = HitObject.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); - if (whistleSample != null) - slidingSamples.Add(HitObject.SampleControlPoint.ApplyTo(whistleSample).With("sliderwhistle")); - - slidingSample.Samples = slidingSamples.ToArray(); + slidingSample.Samples = HitObject.CreateSlidingSamples().Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); } public override void StopAllSamples() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index ec1387eb54..64964ed396 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -74,6 +74,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } + protected override void LoadSamples() + { + // Tail models don't actually get samples, as the playback is handled by DrawableSlider. + // This override is only here for visibility in explaining this weird flow. + } + + public override void PlaySamples() + { + // Tail models don't actually get samples, as the playback is handled by DrawableSlider. + // This override is only here for visibility in explaining this weird flow. + } + protected override void UpdateInitialTransforms() { base.UpdateInitialTransforms(); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index c6db02ee02..a904658a4c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -121,15 +121,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.LoadSamples(); - var firstSample = HitObject.Samples.FirstOrDefault(); - - if (firstSample != null) - { - var clone = HitObject.SampleControlPoint.ApplyTo(firstSample).With("spinnerspin"); - - spinningSample.Samples = new ISampleInfo[] { clone }; - spinningSample.Frequency.Value = spinning_sample_initial_frequency; - } + spinningSample.Samples = HitObject.CreateSpinningSamples().Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); + spinningSample.Frequency.Value = spinning_sample_initial_frequency; } private void updateSpinningSample(ValueChangedEvent tracking) diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 5c1c3fd253..a698311bf7 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -29,6 +29,23 @@ namespace osu.Game.Rulesets.Osu.Objects set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. } + public override IList AuxiliarySamples => CreateSlidingSamples().Concat(TailSamples).ToArray(); + + public IList CreateSlidingSamples() + { + var slidingSamples = new List(); + + var normalSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); + if (normalSample != null) + slidingSamples.Add(normalSample.With("sliderslide")); + + var whistleSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); + if (whistleSample != null) + slidingSamples.Add(whistleSample.With("sliderwhistle")); + + return slidingSamples; + } + private readonly Cached endPositionCache = new Cached(); public override Vector2 EndPosition => endPositionCache.IsValid ? endPositionCache.Value : endPositionCache.Value = Position + this.CurvePositionAt(1); @@ -137,7 +154,7 @@ namespace osu.Game.Rulesets.Osu.Objects public Slider() { - SamplesBindable.CollectionChanged += (_, __) => updateNestedSamples(); + SamplesBindable.CollectionChanged += (_, __) => UpdateNestedSamples(); Path.Version.ValueChanged += _ => updateNestedPositions(); } @@ -210,7 +227,7 @@ namespace osu.Game.Rulesets.Osu.Objects } } - updateNestedSamples(); + UpdateNestedSamples(); } private void updateNestedPositions() @@ -224,7 +241,7 @@ namespace osu.Game.Rulesets.Osu.Objects TailCircle.Position = EndPosition; } - private void updateNestedSamples() + protected void UpdateNestedSamples() { var firstSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) ?? Samples.FirstOrDefault(); // TODO: remove this when guaranteed sort is present for samples (https://github.com/ppy/osu/issues/1933) diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index 1eddfb7fef..ddee4d3ebd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -1,7 +1,11 @@ // 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 System.Collections.Generic; +using System.Linq; using System.Threading; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; @@ -73,5 +77,20 @@ namespace osu.Game.Rulesets.Osu.Objects public override Judgement CreateJudgement() => new OsuJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public override IList AuxiliarySamples => CreateSpinningSamples(); + + public HitSampleInfo[] CreateSpinningSamples() + { + var referenceSample = Samples.FirstOrDefault(); + + if (referenceSample == null) + return Array.Empty(); + + return new[] + { + SampleControlPoint.ApplyTo(referenceSample).With("spinnerspin") + }; + } } } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 5b936b1bf1..47a2618ddd 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -159,6 +159,7 @@ namespace osu.Game.Rulesets.Osu new MultiMod(new OsuModDoubleTime(), new OsuModNightcore()), new OsuModHidden(), new MultiMod(new OsuModFlashlight(), new OsuModBlinds()), + new OsuModStrictTracking() }; case ModType.Conversion: @@ -213,7 +214,7 @@ namespace osu.Game.Rulesets.Osu public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(RulesetInfo, beatmap); - public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new OsuPerformanceCalculator(this, attributes, score); + public override PerformanceCalculator CreatePerformanceCalculator() => new OsuPerformanceCalculator(); public override HitObjectComposer CreateHitObjectComposer() => new OsuHitObjectComposer(this); diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs index 44118227d9..ab0c0850dc 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs @@ -11,6 +11,13 @@ namespace osu.Game.Rulesets.Osu.Scoring { public class OsuScoreProcessor : ScoreProcessor { + public OsuScoreProcessor() + : base(new OsuRuleset()) + { + } + + protected override double ClassicScoreMultiplier => 36; + protected override HitEvent CreateHitEvent(JudgementResult result) => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit); diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs b/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs index d49b1713f6..506f679836 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.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.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -37,7 +38,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Child .FadeTo(flash_opacity, EarlyActivationMilliseconds, Easing.OutQuint) .Then() - .FadeOut(timingPoint.BeatLength - fade_length, Easing.OutSine); + .FadeOut(Math.Max(fade_length, timingPoint.BeatLength - fade_length), Easing.OutSine); } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index ff9f6f0e07..900ad6f6d3 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy switch (osuComponent.Component) { case OsuSkinComponents.FollowPoint: - return this.GetAnimation(component.LookupName, true, false, true, startAtCurrentTime: false); + return this.GetAnimation(component.LookupName, true, true, true, startAtCurrentTime: false); case OsuSkinComponents.SliderFollowCircle: var followCircle = this.GetAnimation("sliderfollowcircle", true, true, true); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index bcd55f8fae..a8122551ff 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -14,37 +14,35 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoPerformanceCalculator : PerformanceCalculator { - protected new TaikoDifficultyAttributes Attributes => (TaikoDifficultyAttributes)base.Attributes; - - private Mod[] mods; private int countGreat; private int countOk; private int countMeh; private int countMiss; - public TaikoPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) - : base(ruleset, attributes, score) + public TaikoPerformanceCalculator() + : base(new TaikoRuleset()) { } - public override PerformanceAttributes Calculate() + protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes) { - mods = Score.Mods; - countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); - countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); - countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); - countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); + var taikoAttributes = (TaikoDifficultyAttributes)attributes; + + countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); + countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); double multiplier = 1.1; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things - if (mods.Any(m => m is ModNoFail)) + if (score.Mods.Any(m => m is ModNoFail)) multiplier *= 0.90; - if (mods.Any(m => m is ModHidden)) + if (score.Mods.Any(m => m is ModHidden)) multiplier *= 1.10; - double difficultyValue = computeDifficultyValue(); - double accuracyValue = computeAccuracyValue(); + double difficultyValue = computeDifficultyValue(score, taikoAttributes); + double accuracyValue = computeAccuracyValue(score, taikoAttributes); double totalValue = Math.Pow( Math.Pow(difficultyValue, 1.1) + @@ -59,30 +57,30 @@ namespace osu.Game.Rulesets.Taiko.Difficulty }; } - private double computeDifficultyValue() + private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) { - double difficultyValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0075) - 4.0, 2.0) / 100000.0; + double difficultyValue = Math.Pow(5.0 * Math.Max(1.0, attributes.StarRating / 0.0075) - 4.0, 2.0) / 100000.0; double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); difficultyValue *= lengthBonus; difficultyValue *= Math.Pow(0.985, countMiss); - if (mods.Any(m => m is ModHidden)) + if (score.Mods.Any(m => m is ModHidden)) difficultyValue *= 1.025; - if (mods.Any(m => m is ModFlashlight)) + if (score.Mods.Any(m => m is ModFlashlight)) difficultyValue *= 1.05 * lengthBonus; - return difficultyValue * Score.Accuracy; + return difficultyValue * score.Accuracy; } - private double computeAccuracyValue() + private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) { - if (Attributes.GreatHitWindow <= 0) + if (attributes.GreatHitWindow <= 0) return 0; - double accValue = Math.Pow(150.0 / Attributes.GreatHitWindow, 1.1) * Math.Pow(Score.Accuracy, 15) * 22.0; + double accValue = Math.Pow(150.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 15) * 22.0; // Bonus for many objects - it's harder to keep good accuracy up for longer return accValue * Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs index 6520517039..5a6f57bc36 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs @@ -15,6 +15,7 @@ namespace osu.Game.Rulesets.Taiko.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { drawableTaikoRuleset = (DrawableTaikoRuleset)drawableRuleset; + drawableTaikoRuleset.LockPlayfieldAspect.Value = false; } public void Update(Playfield playfield) diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 1829ea2513..bacc22714e 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -7,8 +7,15 @@ namespace osu.Game.Rulesets.Taiko.Scoring { internal class TaikoScoreProcessor : ScoreProcessor { + public TaikoScoreProcessor() + : base(new TaikoRuleset()) + { + } + protected override double DefaultAccuracyPortion => 0.75; protected override double DefaultComboPortion => 0.25; + + protected override double ClassicScoreMultiplier => 22; } } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index dc90845d92..615fbf093f 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -171,7 +171,7 @@ namespace osu.Game.Rulesets.Taiko public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new TaikoDifficultyCalculator(RulesetInfo, beatmap); - public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new TaikoPerformanceCalculator(this, attributes, score); + public override PerformanceCalculator CreatePerformanceCalculator() => new TaikoPerformanceCalculator(); public int LegacyID => 1; diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 824b95639b..2efc4176f5 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Taiko.UI { public new BindableDouble TimeRange => base.TimeRange; + public readonly BindableBool LockPlayfieldAspect = new BindableBool(true); + protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping; protected override bool UserScrollSpeedAdjustment => false; @@ -70,7 +72,10 @@ namespace osu.Game.Rulesets.Taiko.UI return ControlPoints[result]; } - public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new TaikoPlayfieldAdjustmentContainer(); + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new TaikoPlayfieldAdjustmentContainer + { + LockPlayfieldAspect = { BindTarget = LockPlayfieldAspect } + }; protected override PassThroughInputManager CreateInputManager() => new TaikoInputManager(Ruleset.RulesetInfo); diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index d650cab729..504b10e9bc 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// Default height of a when inside a . /// - public const float DEFAULT_HEIGHT = 212; + public const float DEFAULT_HEIGHT = 200; private Container hitExplosionContainer; private Container kiaiExplosionContainer; diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs index 1041456020..9cf530e903 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs @@ -2,9 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.UI; -using osuTK; namespace osu.Game.Rulesets.Taiko.UI { @@ -13,16 +13,22 @@ namespace osu.Game.Rulesets.Taiko.UI private const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768; private const float default_aspect = 16f / 9f; + public readonly IBindable LockPlayfieldAspect = new BindableBool(true); + protected override void Update() { base.Update(); - float aspectAdjust = Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect; - Size = new Vector2(1, default_relative_height * aspectAdjust); + float height = default_relative_height; + + if (LockPlayfieldAspect.Value) + height *= Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect; + + Height = height; // Position the taiko playfield exactly one playfield from the top of the screen. RelativePositionAxes = Axes.Y; - Y = Size.Y; + Y = height; } } } diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj index b45a3249ff..afafec6b1f 100644 --- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj +++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj @@ -79,7 +79,7 @@ - + 5.0.0 diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj index 97df9b2cd5..05b3cad6da 100644 --- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj +++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj @@ -48,7 +48,7 @@ - + diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 2ba8c51a10..1474f2d277 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -8,6 +8,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; @@ -64,6 +65,62 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [TestCase(3, true)] + [TestCase(6, false)] + [TestCase(LegacyBeatmapDecoder.LATEST_VERSION, false)] + public void TestLegacyBeatmapReplayOffsetsDecode(int beatmapVersion, bool offsetApplied) + { + const double first_frame_time = 48; + const double second_frame_time = 65; + + var decoder = new TestLegacyScoreDecoder(beatmapVersion); + + using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) + { + var score = decoder.Parse(resourceStream); + + Assert.That(score.Replay.Frames[0].Time, Is.EqualTo(first_frame_time + (offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0))); + Assert.That(score.Replay.Frames[1].Time, Is.EqualTo(second_frame_time + (offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0))); + } + } + + [TestCase(3)] + [TestCase(6)] + [TestCase(LegacyBeatmapDecoder.LATEST_VERSION)] + public void TestLegacyBeatmapReplayOffsetsEncodeDecode(int beatmapVersion) + { + const double first_frame_time = 2000; + const double second_frame_time = 3000; + + var ruleset = new OsuRuleset().RulesetInfo; + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + var beatmap = new TestBeatmap(ruleset) + { + BeatmapInfo = + { + BeatmapVersion = beatmapVersion + } + }; + + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(first_frame_time, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(second_frame_time, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) + } + } + }; + + var decodedAfterEncode = encodeThenDecode(beatmapVersion, score, beatmap); + + Assert.That(decodedAfterEncode.Replay.Frames[0].Time, Is.EqualTo(first_frame_time)); + Assert.That(decodedAfterEncode.Replay.Frames[1].Time, Is.EqualTo(second_frame_time)); + } + [Test] public void TestCultureInvariance() { @@ -86,15 +143,7 @@ namespace osu.Game.Tests.Beatmaps.Formats // rather than the classic ASCII U+002D HYPHEN-MINUS. CultureInfo.CurrentCulture = new CultureInfo("se"); - var encodeStream = new MemoryStream(); - - var encoder = new LegacyScoreEncoder(score, beatmap); - encoder.Encode(encodeStream); - - var decodeStream = new MemoryStream(encodeStream.GetBuffer()); - - var decoder = new TestLegacyScoreDecoder(); - var decodedAfterEncode = decoder.Parse(decodeStream); + var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap); Assert.Multiple(() => { @@ -110,6 +159,20 @@ namespace osu.Game.Tests.Beatmaps.Formats }); } + private static Score encodeThenDecode(int beatmapVersion, Score score, TestBeatmap beatmap) + { + var encodeStream = new MemoryStream(); + + var encoder = new LegacyScoreEncoder(score, beatmap); + encoder.Encode(encodeStream); + + var decodeStream = new MemoryStream(encodeStream.GetBuffer()); + + var decoder = new TestLegacyScoreDecoder(beatmapVersion); + var decodedAfterEncode = decoder.Parse(decodeStream); + return decodedAfterEncode; + } + [TearDown] public void TearDown() { @@ -118,6 +181,8 @@ namespace osu.Game.Tests.Beatmaps.Formats private class TestLegacyScoreDecoder : LegacyScoreDecoder { + private readonly int beatmapVersion; + private static readonly Dictionary rulesets = new Ruleset[] { new OsuRuleset(), @@ -126,6 +191,11 @@ namespace osu.Game.Tests.Beatmaps.Formats new ManiaRuleset() }.ToDictionary(ruleset => ((ILegacyRuleset)ruleset).LegacyID); + public TestLegacyScoreDecoder(int beatmapVersion = LegacyBeatmapDecoder.LATEST_VERSION) + { + this.beatmapVersion = beatmapVersion; + } + protected override Ruleset GetRuleset(int rulesetId) => rulesets[rulesetId]; protected override WorkingBeatmap GetBeatmap(string md5Hash) => new TestWorkingBeatmap(new Beatmap @@ -134,7 +204,8 @@ namespace osu.Game.Tests.Beatmaps.Formats { MD5Hash = md5Hash, Ruleset = new OsuRuleset().RulesetInfo, - Difficulty = new BeatmapDifficulty() + Difficulty = new BeatmapDifficulty(), + BeatmapVersion = beatmapVersion, } }); } diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 9d67381b5a..f9c13a8169 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -147,7 +147,10 @@ namespace osu.Game.Tests.Database Live? imported; using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream())) + { imported = await importer.Import(reader); + EnsureLoaded(realm.Realm); + } Assert.AreEqual(1, realm.Realm.All().Count()); @@ -510,6 +513,8 @@ namespace osu.Game.Tests.Database new ImportTask(zipStream, string.Empty) ); + realm.Run(r => r.Refresh()); + checkBeatmapSetCount(realm.Realm, 0); checkBeatmapCount(realm.Realm, 0); @@ -565,6 +570,8 @@ namespace osu.Game.Tests.Database { } + EnsureLoaded(realm.Realm); + checkBeatmapSetCount(realm.Realm, 1); checkBeatmapCount(realm.Realm, 12); @@ -590,6 +597,8 @@ namespace osu.Game.Tests.Database Assert.IsTrue(imported.DeletePending); + var originalAddedDate = imported.DateAdded; + var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. @@ -597,6 +606,7 @@ namespace osu.Game.Tests.Database Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); Assert.IsFalse(imported.DeletePending); Assert.IsFalse(importedSecondTime.DeletePending); + Assert.That(importedSecondTime.DateAdded, Is.GreaterThan(originalAddedDate)); }); } @@ -646,6 +656,8 @@ namespace osu.Game.Tests.Database Assert.IsTrue(imported.DeletePending); + var originalAddedDate = imported.DateAdded; + var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. @@ -653,6 +665,7 @@ namespace osu.Game.Tests.Database Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); Assert.IsFalse(imported.DeletePending); Assert.IsFalse(importedSecondTime.DeletePending); + Assert.That(importedSecondTime.DateAdded, Is.GreaterThan(originalAddedDate)); }); } @@ -720,6 +733,8 @@ namespace osu.Game.Tests.Database var imported = importer.Import(toImport); + realm.Run(r => r.Refresh()); + Assert.NotNull(imported); Debug.Assert(imported != null); @@ -885,6 +900,8 @@ namespace osu.Game.Tests.Database string? temp = TestResources.GetTestBeatmapForImport(); await importer.Import(temp); + EnsureLoaded(realm.Realm); + // Update via the beatmap, not the beatmap info, to ensure correct linking BeatmapSetInfo setToUpdate = realm.Realm.All().First(); diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs index 70ba868de6..9c307341bd 100644 --- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Gameplay { var beatmap = new Beatmap { HitObjects = { new HitObject() } }; - var scoreProcessor = new ScoreProcessor(); + var scoreProcessor = new ScoreProcessor(new OsuRuleset()); scoreProcessor.ApplyBeatmap(beatmap); // Apply a miss judgement @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Gameplay { var beatmap = new Beatmap { HitObjects = { new HitObject() } }; - var scoreProcessor = new ScoreProcessor(); + var scoreProcessor = new ScoreProcessor(new OsuRuleset()); scoreProcessor.ApplyBeatmap(beatmap); // Apply a judgement @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Gameplay { var beatmap = new Beatmap { HitObjects = { new HitCircle() } }; - var scoreProcessor = new ScoreProcessor(); + var scoreProcessor = new ScoreProcessor(new OsuRuleset()); scoreProcessor.ApplyBeatmap(beatmap); scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new TestJudgement(HitResult.Great)) { Type = HitResult.Great }); diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index d5ea3e492c..e98ea98bb2 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -24,6 +24,20 @@ namespace osu.Game.Tests.Online [TestFixture] public class TestAPIModJsonSerialization { + [Test] + public void TestUnknownMod() + { + var apiMod = new APIMod { Acronym = "WNG" }; + + var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + + var converted = deserialized?.ToMod(new TestRuleset()); + + Assert.That(converted, Is.TypeOf(typeof(UnknownMod))); + Assert.That(converted?.Type, Is.EqualTo(ModType.System)); + Assert.That(converted?.Acronym, Is.EqualTo("WNG??")); + } + [Test] public void TestAcronymIsPreserved() { diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 2521d570f2..db988a544d 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -118,7 +118,7 @@ namespace osu.Game.Tests.Online [Test] public void TestBeatmapDownloadingFlow() { - AddAssert("ensure beatmap unavailable", () => !beatmaps.IsAvailableLocally(testBeatmapSet)); + AddUntilStep("ensure beatmap unavailable", () => !beatmaps.IsAvailableLocally(testBeatmapSet)); addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded); AddStep("start downloading", () => beatmapDownloader.Download(testBeatmapSet)); @@ -132,7 +132,7 @@ namespace osu.Game.Tests.Online AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); AddUntilStep("wait for import", () => beatmaps.CurrentImport != null); - AddAssert("ensure beatmap available", () => beatmaps.IsAvailableLocally(testBeatmapSet)); + AddUntilStep("ensure beatmap available", () => beatmaps.IsAvailableLocally(testBeatmapSet)); addAvailabilityCheckStep("state is locally available", BeatmapAvailability.LocallyAvailable); } diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index c6e7988543..7ecd509193 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -6,11 +6,15 @@ using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Rulesets.Scoring @@ -23,7 +27,7 @@ namespace osu.Game.Tests.Rulesets.Scoring [SetUp] public void SetUp() { - scoreProcessor = new ScoreProcessor(); + scoreProcessor = new ScoreProcessor(new TestRuleset()); beatmap = new TestBeatmap(new RulesetInfo()) { HitObjects = new List @@ -300,7 +304,26 @@ namespace osu.Game.Tests.Rulesets.Scoring HitObjects = { new TestHitObject(result) } }); - Assert.That(scoreProcessor.GetImmediateScore(ScoringMode.Standardised, result.AffectsCombo() ? 1 : 0, statistic), Is.EqualTo(expectedScore).Within(0.5d)); + Assert.That(scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, new ScoreInfo + { + Ruleset = new TestRuleset().RulesetInfo, + MaxCombo = result.AffectsCombo() ? 1 : 0, + Statistics = statistic + }), Is.EqualTo(expectedScore).Within(0.5d)); + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => throw new System.NotImplementedException(); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new System.NotImplementedException(); + + public override string Description => string.Empty; + public override string ShortName => string.Empty; } private class TestJudgement : Judgement diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index ecd4035edd..b109234fec 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -13,6 +13,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Database; +using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; @@ -23,6 +24,7 @@ using osu.Game.Screens.Edit.Setup; using osu.Game.Storyboards; using osu.Game.Tests.Resources; using osuTK; +using osuTK.Input; using SharpCompress.Archives; using SharpCompress.Archives.Zip; @@ -63,13 +65,19 @@ namespace osu.Game.Tests.Visual.Editing EditorBeatmap editorBeatmap = null; AddStep("store editor beatmap", () => editorBeatmap = EditorBeatmap); - AddStep("exit without save", () => + + AddStep("exit without save", () => Editor.Exit()); + AddStep("hold to confirm", () => { - Editor.Exit(); - DialogOverlay.CurrentDialog.PerformOkAction(); + var confirmButton = DialogOverlay.CurrentDialog.ChildrenOfType().First(); + + InputManager.MoveMouseTo(confirmButton); + InputManager.PressButton(MouseButton.Left); }); AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen()); + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.ID)?.Value.DeletePending == true); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index d1c1558003..e75c7f25a3 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -17,6 +17,13 @@ namespace osu.Game.Tests.Visual.Editing { public class TestSceneEditorSaving : EditorSavingTestScene { + [Test] + public void TestCantExitWithoutSaving() + { + AddRepeatStep("Exit", () => InputManager.Key(Key.Escape), 10); + AddAssert("Editor is still active screen", () => Game.ScreenStack.CurrentScreen is Editor); + } + [Test] public void TestMetadata() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs index c5f56cae9e..eb1695b3df 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -18,9 +18,11 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Gameplay { @@ -32,11 +34,17 @@ namespace osu.Game.Tests.Visual.Gameplay private SkinManager skinManager { get; set; } [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(); + private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + [Cached] + private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()); + + [Cached] + private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + protected override bool HasCustomSteps => true; [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index 52bedc328d..34e6d1996d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Skinning; @@ -22,6 +25,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private Storyboard storyboard { get; set; } = new Storyboard(); + private IEnumerable sprites => this.ChildrenOfType(); + [Test] public void TestSkinSpriteDisallowedByDefault() { @@ -41,9 +46,54 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); - AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.Centre, Vector2.Zero))); + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); assertSpritesFromSkin(true); + + AddAssert("skinnable sprite has correct size", () => sprites.Any(s => Precision.AlmostEquals(s.ChildrenOfType().Single().Size, new Vector2(128, 128)))); + } + + [Test] + public void TestFlippedSprite() + { + const string lookup_name = "hitcircleoverlay"; + + AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + AddStep("flip sprites", () => sprites.ForEach(s => + { + s.FlipH = true; + s.FlipV = true; + })); + AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight)); + } + + [Test] + public void TestNegativeScale() + { + const string lookup_name = "hitcircleoverlay"; + + AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(-1))); + AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight)); + } + + [Test] + public void TestNegativeScaleWithFlippedSprite() + { + const string lookup_name = "hitcircleoverlay"; + + AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(-1))); + AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight)); + AddStep("flip sprites", () => sprites.ForEach(s => + { + s.FlipH = true; + s.FlipV = true; + })); + AddAssert("origin back", () => sprites.All(s => s.Origin == Anchor.TopLeft)); } private DrawableStoryboardSprite createSprite(string lookupName, Anchor origin, Vector2 initialPosition) @@ -57,7 +107,6 @@ namespace osu.Game.Tests.Visual.Gameplay private void assertSpritesFromSkin(bool fromSkin) => AddAssert($"sprites are {(fromSkin ? "from skin" : "from storyboard")}", - () => this.ChildrenOfType() - .All(sprite => sprite.ChildrenOfType().Any() == fromSkin)); + () => sprites.All(sprite => sprite.ChildrenOfType().Any() == fromSkin)); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs index 6430c29dfa..79d7bb366d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for multiple judgements", () => ((FailPlayer)Player).ScoreProcessor.JudgedHits > 1); AddAssert("total number of results == 1", () => { - var score = new ScoreInfo(); + var score = new ScoreInfo { Ruleset = Ruleset.Value }; ((FailPlayer)Player).ScoreProcessor.PopulateScore(score); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index 4b54cd3510..2d12645811 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -8,11 +8,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Timing; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Skinning; +using osu.Game.Tests.Beatmaps; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay @@ -24,11 +27,17 @@ namespace osu.Game.Tests.Visual.Gameplay private HUDOverlay hudOverlay; [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(); + private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + [Cached] + private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()); + + [Cached] + private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + // best way to check without exposing. private Drawable hideTarget => hudOverlay.KeyCounter; private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index c1260f0231..7febb54010 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -48,7 +48,11 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("create display", () => recreateDisplay(new OsuHitWindows(), 5)); - AddRepeatStep("New random judgement", () => newJudgement(), 40); + AddRepeatStep("New random judgement", () => + { + double offset = RNG.Next(-150, 150); + newJudgement(offset, drawableRuleset.HitWindows.ResultFor(offset)); + }, 400); AddRepeatStep("New max negative", () => newJudgement(-drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20); AddRepeatStep("New max positive", () => newJudgement(drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20); @@ -273,6 +277,11 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestScoreProcessor : ScoreProcessor { + public TestScoreProcessor() + : base(new OsuRuleset()) + { + } + public void Reset() => base.Reset(false); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index b195d2aa74..5a1fc1b1e5 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; @@ -107,7 +108,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("create player", () => { - Beatmap.Value = CreateWorkingBeatmap(beatmap, storyboard); + Beatmap.Value = new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), Audio); LoadScreen(player = new LeadInPlayer()); }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs index 84c7f611af..aefe0db36a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs @@ -45,7 +45,7 @@ namespace osu.Game.Tests.Visual.Gameplay gameplayState = new GameplayState(beatmap, ruleset); gameplayState.LastJudgementResult.BindTo(lastJudgementResult); - scoreProcessor = new ScoreProcessor(); + scoreProcessor = new ScoreProcessor(ruleset); Child = dependencyContainer = new DependencyProvidingContainer { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index a0602e21b9..38d83058c0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -1,12 +1,19 @@ // 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 NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning.Editor; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { @@ -16,6 +23,9 @@ namespace osu.Game.Tests.Visual.Gameplay protected override bool Autoplay => true; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [SetUpSteps] public override void SetUpSteps() { @@ -24,7 +34,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("reload skin editor", () => { skinEditor?.Expire(); - Player.ScaleTo(SkinEditorOverlay.VISIBLE_TARGET_SCALE); + Player.ScaleTo(0.4f); LoadComponentAsync(skinEditor = new SkinEditor(Player), Add); }); } @@ -35,6 +45,36 @@ namespace osu.Game.Tests.Visual.Gameplay AddToggleStep("toggle editor visibility", visible => skinEditor.ToggleVisibility()); } + [Test] + public void TestEditComponent() + { + BarHitErrorMeter hitErrorMeter = null; + + AddStep("select bar hit error blueprint", () => + { + var blueprint = skinEditor.ChildrenOfType().First(b => b.Item is BarHitErrorMeter); + + hitErrorMeter = (BarHitErrorMeter)blueprint.Item; + skinEditor.SelectedComponents.Clear(); + skinEditor.SelectedComponents.Add(blueprint.Item); + }); + + AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault); + + AddStep("hover first slider", () => + { + InputManager.MoveMouseTo( + skinEditor.ChildrenOfType().First() + .ChildrenOfType>().First() + .ChildrenOfType>().First() + ); + }); + + AddStep("adjust slider via keyboard", () => InputManager.Key(Key.Left)); + + AddAssert("value is less than default", () => hitErrorMeter.JudgementLineThickness.Value < hitErrorMeter.JudgementLineThickness.Default); + } + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs index 58c89411c0..5385a9983b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Skinning.Editor; @@ -11,13 +13,17 @@ namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneSkinEditorComponentsList : SkinnableTestScene { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [Test] public void TestToggleEditor() { - AddStep("show available components", () => SetContents(_ => new SkinComponentToolbox(300) + AddStep("show available components", () => SetContents(_ => new SkinComponentToolbox { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Width = 0.6f, })); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index 856747ad19..8150252d45 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -5,11 +5,13 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Timing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Skinning.Editor; +using osu.Game.Tests.Beatmaps; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay @@ -17,11 +19,17 @@ namespace osu.Game.Tests.Visual.Gameplay public class TestSceneSkinEditorMultipleSkins : SkinnableTestScene { [Cached] - private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(); + private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + [Cached] + private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()); + + [Cached] + private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + [SetUpSteps] public void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs index 80eb887894..9c713b4616 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; @@ -14,7 +15,7 @@ namespace osu.Game.Tests.Visual.Gameplay public class TestSceneSkinnableAccuracyCounter : SkinnableHUDComponentTestScene { [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(); + private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); protected override Drawable CreateDefaultImplementation() => new DefaultAccuracyCounter(); protected override Drawable CreateLegacyImplementation() => new LegacyAccuracyCounter(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs index bd1fe050af..f507172931 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; @@ -13,7 +14,7 @@ namespace osu.Game.Tests.Visual.Gameplay public class TestSceneSkinnableComboCounter : SkinnableHUDComponentTestScene { [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(); + private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); protected override Drawable CreateDefaultImplementation() => new DefaultComboCounter(); protected override Drawable CreateLegacyImplementation() => new LegacyComboCounter(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 3074a91dc6..ac5e408d90 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -10,11 +10,13 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Timing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Tests.Beatmaps; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay @@ -24,11 +26,17 @@ namespace osu.Game.Tests.Visual.Gameplay private HUDOverlay hudOverlay; [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(); + private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + [Cached] + private GameplayState gameplayState = new GameplayState(new TestBeatmap(new OsuRuleset().RulesetInfo), new OsuRuleset()); + + [Cached] + private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + private IEnumerable hudOverlays => CreatedDrawables.OfType(); // best way to check without exposing. diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs index 1700886263..a871e37ad4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; @@ -13,7 +14,7 @@ namespace osu.Game.Tests.Visual.Gameplay public class TestSceneSkinnableScoreCounter : SkinnableHUDComponentTestScene { [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(); + private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); protected override Drawable CreateDefaultImplementation() => new DefaultScoreCounter(); protected override Drawable CreateLegacyImplementation() => new LegacyScoreCounter(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index d614815316..8b420cebc8 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -70,6 +70,56 @@ namespace osu.Game.Tests.Visual.Gameplay }); } + [Test] + public void TestSeekToGameplayStartFramesArriveAfterPlayerLoad() + { + const double gameplay_start = 10000; + + loadSpectatingScreen(); + + start(); + + waitForPlayer(); + + sendFrames(startTime: gameplay_start); + + AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start); + } + + /// + /// Tests the same as but with the frames arriving just as is transitioning into existence. + /// + [Test] + public void TestSeekToGameplayStartFramesArriveAsPlayerLoaded() + { + const double gameplay_start = 10000; + + loadSpectatingScreen(); + + start(); + + AddUntilStep("wait for player loader", () => (Stack.CurrentScreen as PlayerLoader)?.IsLoaded == true); + + AddUntilStep("queue send frames on player load", () => + { + var loadingPlayer = (Stack.CurrentScreen as PlayerLoader)?.CurrentPlayer; + + if (loadingPlayer == null) + return false; + + loadingPlayer.OnLoadComplete += _ => + { + spectatorClient.SendFramesFromUser(streamingUser.Id, 10, gameplay_start); + }; + return true; + }); + + waitForPlayer(); + + AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing); + AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start); + } + [Test] public void TestFrameStarvationAndResume() { @@ -319,9 +369,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void checkPaused(bool state) => AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); - private void sendFrames(int count = 10) + private void sendFrames(int count = 10, double startTime = 0) { - AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count)); + AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime)); } private void loadSpectatingScreen() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneUnknownMod.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneUnknownMod.cs new file mode 100644 index 0000000000..c0f1112905 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneUnknownMod.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneUnknownMod : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + /// + /// This test also covers the scenario of exiting Player after an unsuccessful beatmap load. + /// + [Test] + public void TestUnknownModDoesntEnterGameplay() + { + CreateModTest(new ModTestData + { + Beatmap = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo).Beatmap, + Mod = new UnknownMod("WNG"), + PassCondition = () => Player.IsLoaded && !Player.LoadedBeatmapSuccessfully + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs index be20799479..ca8ecd490d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; @@ -102,6 +103,11 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestScoreProcessor : ScoreProcessor { + public TestScoreProcessor() + : base(new OsuRuleset()) + { + } + public void Reset() => base.Reset(false); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs new file mode 100644 index 0000000000..a374488306 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -0,0 +1,338 @@ +// 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 System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.Countdown; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Tests.Resources; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMatchStartControl : MultiplayerTestScene + { + private MatchStartControl control; + private BeatmapSetInfo importedSet; + + private readonly Bindable selectedItem = new Bindable(); + + private BeatmapManager beatmaps; + private RulesetStore rulesets; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(Realm); + } + + [SetUp] + public new void Setup() => Schedule(() => + { + AvailabilityTracker.SelectedItem.BindTo(selectedItem); + + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); + + selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) + { + RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID + }; + + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = control = new MatchStartControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + } + }; + }); + + [Test] + public void TestStartWithCountdown() + { + ClickButtonWhenEnabled(); + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("countdown button not visible", () => !this.ChildrenOfType().Single().IsPresent); + AddStep("finish countdown", () => MultiplayerClient.SkipToEndOfCountdown()); + AddUntilStep("match started", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.WaitingForLoad); + } + + [Test] + public void TestCancelCountdown() + { + ClickButtonWhenEnabled(); + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + ClickButtonWhenEnabled(); + + AddStep("finish countdown", () => MultiplayerClient.SkipToEndOfCountdown()); + AddUntilStep("match not started", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + } + + [Test] + public void TestReadyAndUnReadyDuringCountdown() + { + AddStep("add second user as host", () => + { + MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + MultiplayerClient.TransferHost(2); + }); + + AddStep("start with countdown", () => MultiplayerClient.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(2) }).WaitSafely()); + + ClickButtonWhenEnabled(); + AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + + ClickButtonWhenEnabled(); + AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); + } + + [Test] + public void TestCountdownButtonEnablementAndVisibilityWhileSpectating() + { + AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); + + AddAssert("countdown button is visible", () => this.ChildrenOfType().Single().IsPresent); + AddAssert("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("add second user", () => MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" })); + AddAssert("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("set second user ready", () => MultiplayerClient.ChangeUserState(2, MultiplayerUserState.Ready)); + AddAssert("countdown button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + } + + [Test] + public void TestSpectatingDuringCountdownWithNoReadyUsersCancelsCountdown() + { + ClickButtonWhenEnabled(); + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); + + AddStep("finish countdown", () => MultiplayerClient.SkipToEndOfCountdown()); + AddUntilStep("match not started", () => MultiplayerClient.Room?.State == MultiplayerRoomState.Open); + } + + [Test] + public void TestReadyButtonEnabledWhileSpectatingDuringCountdown() + { + AddStep("add second user", () => MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" })); + AddStep("set second user ready", () => MultiplayerClient.ChangeUserState(2, MultiplayerUserState.Ready)); + + ClickButtonWhenEnabled(); + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); + + AddAssert("ready button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + } + + [Test] + public void TestBecomeHostDuringCountdownAndReady() + { + AddStep("add second user as host", () => + { + MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + MultiplayerClient.TransferHost(2); + }); + + AddStep("start countdown", () => MultiplayerClient.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(1) }).WaitSafely()); + AddUntilStep("countdown started", () => MultiplayerClient.Room?.Countdown != null); + + AddStep("transfer host to local user", () => MultiplayerClient.TransferHost(API.LocalUser.Value.OnlineID)); + AddUntilStep("local user is host", () => MultiplayerClient.Room?.Host?.Equals(MultiplayerClient.LocalUser) == true); + + ClickButtonWhenEnabled(); + AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + AddAssert("countdown still active", () => MultiplayerClient.Room?.Countdown != null); + } + + [Test] + public void TestDeletedBeatmapDisableReady() + { + OsuButton readyButton = null; + + AddUntilStep("ensure ready button enabled", () => + { + readyButton = control.ChildrenOfType().Single(); + return readyButton.Enabled.Value; + }); + + AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + AddUntilStep("ready button disabled", () => !readyButton.Enabled.Value); + AddStep("undelete beatmap", () => beatmaps.Undelete(importedSet)); + AddUntilStep("ready button enabled back", () => readyButton.Enabled.Value); + } + + [Test] + public void TestToggleStateWhenNotHost() + { + AddStep("add second user as host", () => + { + MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + MultiplayerClient.TransferHost(2); + }); + + ClickButtonWhenEnabled(); + AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + + ClickButtonWhenEnabled(); + AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); + } + + [TestCase(true)] + [TestCase(false)] + public void TestToggleStateWhenHost(bool allReady) + { + AddStep("setup", () => + { + MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); + + if (!allReady) + MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + }); + + ClickButtonWhenEnabled(); + AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + + verifyGameplayStartFlow(); + } + + [Test] + public void TestBecomeHostWhileReady() + { + AddStep("add host", () => + { + MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + MultiplayerClient.TransferHost(2); + }); + + ClickButtonWhenEnabled(); + AddStep("make user host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0)); + + verifyGameplayStartFlow(); + } + + [Test] + public void TestLoseHostWhileReady() + { + AddStep("setup", () => + { + MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); + MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + }); + + ClickButtonWhenEnabled(); + AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + + AddStep("transfer host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[1].UserID ?? 0)); + + ClickButtonWhenEnabled(); + AddUntilStep("user is idle (match not started)", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); + AddUntilStep("ready button enabled", () => control.ChildrenOfType().Single().Enabled.Value); + } + + [TestCase(true)] + [TestCase(false)] + public void TestManyUsersChangingState(bool isHost) + { + const int users = 10; + AddStep("setup", () => + { + MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); + for (int i = 0; i < users; i++) + MultiplayerClient.AddUser(new APIUser { Id = i, Username = "Another user" }); + }); + + if (!isHost) + AddStep("transfer host", () => MultiplayerClient.TransferHost(2)); + + ClickButtonWhenEnabled(); + + AddRepeatStep("change user ready state", () => + { + MultiplayerClient.ChangeUserState(RNG.Next(0, users), RNG.NextBool() ? MultiplayerUserState.Ready : MultiplayerUserState.Idle); + }, 20); + + AddRepeatStep("ready all users", () => + { + var nextUnready = MultiplayerClient.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); + if (nextUnready != null) + MultiplayerClient.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready); + }, users); + } + + private void verifyGameplayStartFlow() + { + AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + ClickButtonWhenEnabled(); + AddUntilStep("user waiting for load", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad); + + AddStep("finish gameplay", () => + { + MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.Loaded); + MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.FinishedPlay); + }); + + AddUntilStep("ready button enabled", () => control.ChildrenOfType().Single().Enabled.Value); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 42bb99de24..f57a54d84c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -46,7 +46,10 @@ namespace osu.Game.Tests.Visual.Multiplayer var scoreProcessor = new OsuScoreProcessor(); scoreProcessor.ApplyBeatmap(playable); - LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()) { Expanded = { Value = true } }, Add); + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(Ruleset.Value, scoreProcessor, clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()) + { + Expanded = { Value = true } + }, Add); }); AddUntilStep("wait for load", () => leaderboard.IsLoaded); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index e38da96bd5..d0765fc4b3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -41,6 +41,7 @@ using osu.Game.Screens.Ranking; using osu.Game.Screens.Spectate; using osu.Game.Tests.Resources; using osuTK.Input; +using ReadyButton = osu.Game.Screens.OnlinePlay.Components.ReadyButton; namespace osu.Game.Tests.Visual.Multiplayer { @@ -424,7 +425,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("Beatmap doesn't match current item", () => Beatmap.Value.BeatmapInfo.OnlineID != multiplayerClient.Room?.Playlist.First().BeatmapID); - AddStep("start match externally", () => multiplayerClient.StartMatch()); + AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player); @@ -462,7 +463,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("Ruleset doesn't match current item", () => Ruleset.Value.OnlineID != multiplayerClient.Room?.Playlist.First().RulesetID); - AddStep("start match externally", () => multiplayerClient.StartMatch()); + AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player); @@ -500,7 +501,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("Mods don't match current item", () => !SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); - AddStep("start match externally", () => multiplayerClient.StartMatch()); + AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player); @@ -535,7 +536,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for spectating user state", () => multiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); - AddStep("start match externally", () => multiplayerClient.StartMatch()); + AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddAssert("play not started", () => multiplayerComponents.IsCurrentScreen()); } @@ -568,7 +569,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for spectating user state", () => multiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); - AddStep("start match externally", () => multiplayerClient.StartMatch()); + AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddStep("restore beatmap", () => { @@ -883,7 +884,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("start match by other user", () => { multiplayerClient.ChangeUserState(1234, MultiplayerUserState.Ready); - multiplayerClient.StartMatch(); + multiplayerClient.StartMatch().WaitSafely(); }); AddUntilStep("wait for loading", () => multiplayerClient.Room?.State == MultiplayerRoomState.WaitingForLoad); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index cfac5da4ff..bcd4474876 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Multiplayer scoreProcessor.ApplyBeatmap(playableBeatmap); - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, multiplayerUsers.ToArray()) + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, multiplayerUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs index f751b162d1..7f5aced925 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Multiplayer scoreProcessor.ApplyBeatmap(playableBeatmap); - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, multiplayerUsers.ToArray()) + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, multiplayerUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index 6b5b45b73e..cbd8b472b8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -11,6 +11,8 @@ using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -129,6 +131,25 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("item 1 not in lists", () => !inHistoryList(0) && !inQueueList(0)); } + [Test] + public void TestQueueTabCount() + { + assertQueueTabCount(1); + + addItemStep(); + assertQueueTabCount(2); + + addItemStep(); + assertQueueTabCount(3); + + AddStep("finish current item", () => MultiplayerClient.FinishCurrentItem().WaitSafely()); + assertQueueTabCount(2); + + AddStep("leave room", () => RoomManager.PartRoom()); + AddUntilStep("wait for room part", () => !RoomJoined); + assertQueueTabCount(0); + } + [Ignore("Expired items are initially removed from the room.")] [Test] public void TestJoinRoomWithMixedItemsAddedInCorrectLists() @@ -213,6 +234,17 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + private void assertQueueTabCount(int count) + { + string queueTabText = count > 0 ? $"Queue ({count})" : "Queue"; + AddUntilStep($"Queue tab shows \"{queueTabText}\"", () => + { + return this.ChildrenOfType.OsuTabItem>() + .Single(t => t.Value == MultiplayerPlaylistDisplayMode.Queue) + .ChildrenOfType().Single().Text == queueTabText; + }); + } + private void changeDisplayModeStep(MultiplayerPlaylistDisplayMode mode) => AddStep($"change list to {mode}", () => list.DisplayMode.Value = mode); private bool inQueueList(int playlistItemId) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs deleted file mode 100644 index c86d5e482a..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ /dev/null @@ -1,223 +0,0 @@ -// 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 System.Linq; -using System.Threading.Tasks; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Platform; -using osu.Framework.Testing; -using osu.Framework.Utils; -using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; -using osu.Game.Rulesets; -using osu.Game.Screens.OnlinePlay.Multiplayer.Match; -using osu.Game.Tests.Resources; -using osuTK; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestSceneMultiplayerReadyButton : MultiplayerTestScene - { - private MultiplayerReadyButton button; - private BeatmapSetInfo importedSet; - - private readonly Bindable selectedItem = new Bindable(); - - private BeatmapManager beatmaps; - private RulesetStore rulesets; - - private IDisposable readyClickOperation; - - [BackgroundDependencyLoader] - private void load(GameHost host, AudioManager audio) - { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(Realm); - } - - [SetUp] - public new void Setup() => Schedule(() => - { - AvailabilityTracker.SelectedItem.BindTo(selectedItem); - - beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - importedSet = beatmaps.GetAllUsableBeatmapSets().First(); - Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); - - selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) - { - RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID - }; - - if (button != null) - Remove(button); - - Add(button = new MultiplayerReadyButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), - OnReadyClick = () => - { - readyClickOperation = OngoingOperationTracker.BeginOperation(); - - Task.Run(async () => - { - if (MultiplayerClient.IsHost && MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready) - { - await MultiplayerClient.StartMatch(); - return; - } - - await MultiplayerClient.ToggleReady(); - - readyClickOperation.Dispose(); - }); - } - }); - }); - - [Test] - public void TestDeletedBeatmapDisableReady() - { - OsuButton readyButton = null; - - AddUntilStep("ensure ready button enabled", () => - { - readyButton = button.ChildrenOfType().Single(); - return readyButton.Enabled.Value; - }); - - AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); - AddUntilStep("ready button disabled", () => !readyButton.Enabled.Value); - AddStep("undelete beatmap", () => beatmaps.Undelete(importedSet)); - AddUntilStep("ready button enabled back", () => readyButton.Enabled.Value); - } - - [Test] - public void TestToggleStateWhenNotHost() - { - AddStep("add second user as host", () => - { - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - MultiplayerClient.TransferHost(2); - }); - - ClickButtonWhenEnabled(); - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); - - ClickButtonWhenEnabled(); - AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); - } - - [TestCase(true)] - [TestCase(false)] - public void TestToggleStateWhenHost(bool allReady) - { - AddStep("setup", () => - { - MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); - - if (!allReady) - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - }); - - ClickButtonWhenEnabled(); - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); - - verifyGameplayStartFlow(); - } - - [Test] - public void TestBecomeHostWhileReady() - { - AddStep("add host", () => - { - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - MultiplayerClient.TransferHost(2); - }); - - ClickButtonWhenEnabled(); - AddStep("make user host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0)); - - verifyGameplayStartFlow(); - } - - [Test] - public void TestLoseHostWhileReady() - { - AddStep("setup", () => - { - MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - }); - - ClickButtonWhenEnabled(); - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); - - AddStep("transfer host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[1].UserID ?? 0)); - - ClickButtonWhenEnabled(); - AddUntilStep("user is idle (match not started)", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); - AddAssert("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value); - } - - [TestCase(true)] - [TestCase(false)] - public void TestManyUsersChangingState(bool isHost) - { - const int users = 10; - AddStep("setup", () => - { - MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); - for (int i = 0; i < users; i++) - MultiplayerClient.AddUser(new APIUser { Id = i, Username = "Another user" }); - }); - - if (!isHost) - AddStep("transfer host", () => MultiplayerClient.TransferHost(2)); - - ClickButtonWhenEnabled(); - - AddRepeatStep("change user ready state", () => - { - MultiplayerClient.ChangeUserState(RNG.Next(0, users), RNG.NextBool() ? MultiplayerUserState.Ready : MultiplayerUserState.Idle); - }, 20); - - AddRepeatStep("ready all users", () => - { - var nextUnready = MultiplayerClient.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); - if (nextUnready != null) - MultiplayerClient.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready); - }, users); - } - - private void verifyGameplayStartFlow() - { - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); - ClickButtonWhenEnabled(); - AddUntilStep("user waiting for load", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad); - - AddAssert("ready button disabled", () => !button.ChildrenOfType().Single().Enabled.Value); - AddStep("transitioned to gameplay", () => readyClickOperation.Dispose()); - - AddStep("finish gameplay", () => - { - MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.Loaded); - MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.FinishedPlay); - }); - - AddUntilStep("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs index 956d40a456..13917f4eb0 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -1,9 +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 System.Linq; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -11,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -28,7 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneMultiplayerSpectateButton : MultiplayerTestScene { private MultiplayerSpectateButton spectateButton; - private MultiplayerReadyButton readyButton; + private MatchStartControl startControl; private readonly Bindable selectedItem = new Bindable(); @@ -36,8 +35,6 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapManager beatmaps; private RulesetStore rulesets; - private IDisposable readyClickOperation; - [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { @@ -60,49 +57,26 @@ namespace osu.Game.Tests.Visual.Multiplayer RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, }; - Child = new FillFlowContainer + Child = new PopoverContainer { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer { - spectateButton = new MultiplayerSpectateButton + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), - OnSpectateClick = () => + spectateButton = new MultiplayerSpectateButton { - readyClickOperation = OngoingOperationTracker.BeginOperation(); - - Task.Run(async () => - { - await MultiplayerClient.ToggleSpectate(); - readyClickOperation.Dispose(); - }); - } - }, - readyButton = new MultiplayerReadyButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), - OnReadyClick = () => + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + }, + startControl = new MatchStartControl { - readyClickOperation = OngoingOperationTracker.BeginOperation(); - - Task.Run(async () => - { - if (MultiplayerClient.IsHost && MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready) - { - await MultiplayerClient.StartMatch(); - return; - } - - await MultiplayerClient.ToggleReady(); - - readyClickOperation.Dispose(); - }); + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), } } } @@ -172,6 +146,6 @@ namespace osu.Game.Tests.Visual.Multiplayer => AddUntilStep($"spectate button {(shouldBeEnabled ? "is" : "is not")} enabled", () => spectateButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); private void assertReadyButtonEnablement(bool shouldBeEnabled) - => AddUntilStep($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); + => AddUntilStep($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => startControl.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 8debb95f38..394976eb43 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -14,6 +15,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Settings; using osu.Game.Overlays.Toolbar; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; @@ -21,10 +23,12 @@ using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Screens.Select.Options; +using osu.Game.Skinning.Editor; using osu.Game.Tests.Beatmaps.IO; using osuTK; using osuTK.Input; @@ -66,6 +70,73 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); } + [Test] + public void TestEditComponentDuringGameplay() + { + Screens.Select.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + SkinEditor skinEditor = null; + + AddStep("open skin editor", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.S); + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + AddUntilStep("get skin editor", () => (skinEditor = Game.ChildrenOfType().FirstOrDefault()) != null); + + AddStep("Click gameplay scene button", () => + { + skinEditor.ChildrenOfType().First(b => b.Text == "Gameplay").TriggerClick(); + }); + + AddUntilStep("wait for player", () => + { + // dismiss any notifications that may appear (ie. muted notification). + clickMouseInCentre(); + return Game.ScreenStack.CurrentScreen is Player; + }); + + BarHitErrorMeter hitErrorMeter = null; + + AddUntilStep("select bar hit error blueprint", () => + { + var blueprint = skinEditor.ChildrenOfType().FirstOrDefault(b => b.Item is BarHitErrorMeter); + + if (blueprint == null) + return false; + + hitErrorMeter = (BarHitErrorMeter)blueprint.Item; + skinEditor.SelectedComponents.Clear(); + skinEditor.SelectedComponents.Add(blueprint.Item); + return true; + }); + + AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault); + + AddStep("hover first slider", () => + { + InputManager.MoveMouseTo( + skinEditor.ChildrenOfType().First() + .ChildrenOfType>().First() + .ChildrenOfType>().First() + ); + }); + + AddStep("adjust slider via keyboard", () => InputManager.Key(Key.Left)); + + AddAssert("value is less than default", () => hitErrorMeter.JudgementLineThickness.Value < hitErrorMeter.JudgementLineThickness.Default); + } + [Test] public void TestRetryCountIncrements() { @@ -120,7 +191,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press back button", () => Game.ChildrenOfType().First().Action()); - AddStep("show local scores", () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); + AddStep("show local scores", + () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); @@ -133,7 +205,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("confirm deletion", () => InputManager.Key(Key.Number1)); AddUntilStep("wait for dialog dismissed", () => Game.Dependencies.Get().CurrentDialog == null); - AddAssert("ensure score is pending deletion", () => Game.Realm.Run(r => r.Find(score.ID)?.DeletePending == true)); + AddUntilStep("ensure score is pending deletion", () => Game.Realm.Run(r => r.Find(score.ID)?.DeletePending == true)); AddUntilStep("wait for score panel removal", () => scorePanel.Parent == null); } @@ -152,7 +224,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press back button", () => Game.ChildrenOfType().First().Action()); - AddStep("show local scores", () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); + AddStep("show local scores", + () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); @@ -178,7 +251,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("confirm deletion", () => InputManager.Key(Key.Number1)); AddUntilStep("wait for dialog dismissed", () => Game.Dependencies.Get().CurrentDialog == null); - AddAssert("ensure score is pending deletion", () => Game.Realm.Run(r => r.Find(score.ID)?.DeletePending == true)); + AddUntilStep("ensure score is pending deletion", () => Game.Realm.Run(r => r.Find(score.ID)?.DeletePending == true)); AddUntilStep("wait for score panel removal", () => scorePanel.Parent == null); } @@ -262,6 +335,20 @@ namespace osu.Game.Tests.Visual.Navigation exitViaBackButtonAndConfirm(); } + [Test] + public void TestModsResetOnEnteringMultiplayer() + { + var osuAutomationMod = new OsuModAutoplay(); + + AddStep("Enable autoplay", () => { Game.SelectedMods.Value = new[] { osuAutomationMod }; }); + + PushAndConfirm(() => new Screens.OnlinePlay.Multiplayer.Multiplayer()); + AddUntilStep("Mods are removed", () => Game.SelectedMods.Value.Count == 0); + + AddStep("Return to menu", () => Game.ScreenStack.CurrentScreen.Exit()); + AddUntilStep("Mods are restored", () => Game.SelectedMods.Value.Contains(osuAutomationMod)); + } + [Test] public void TestExitMultiWithEscape() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs new file mode 100644 index 0000000000..af419c8b91 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs @@ -0,0 +1,163 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Chat.ChannelList; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneChannelListItem : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + [Cached] + private readonly Bindable selected = new Bindable(); + + private static readonly List channels = new List + { + createPublicChannel("#public-channel"), + createPublicChannel("#public-channel-long-name"), + createPrivateChannel("test user", 2), + createPrivateChannel("test user long name", 3), + }; + + private readonly Dictionary channelMap = new Dictionary(); + + private FillFlowContainer flow; + private OsuSpriteText selectedText; + private OsuSpriteText leaveText; + + [SetUp] + public void SetUp() + { + Schedule(() => + { + foreach (var item in channelMap.Values) + item.Expire(); + + channelMap.Clear(); + + Child = new FillFlowContainer + { + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10), + Children = new Drawable[] + { + selectedText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + leaveText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Height = 16, + AlwaysPresent = true, + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Y, + Width = 190, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + flow = new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }, + }, + }, + }; + + selected.BindValueChanged(change => + { + selectedText.Text = $"Selected Channel: {change.NewValue?.Name ?? "[null]"}"; + }, true); + + foreach (var channel in channels) + { + var item = new ChannelListItem(channel); + flow.Add(item); + channelMap.Add(channel, item); + item.OnRequestSelect += c => selected.Value = c; + item.OnRequestLeave += leaveChannel; + } + }); + } + + [Test] + public void TestVisual() + { + AddStep("Select second item", () => selected.Value = channels.Skip(1).First()); + + AddStep("Unread Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Unread.Value = true; + }); + + AddStep("Read Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Unread.Value = false; + }); + + AddStep("Add Mention Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Mentions.Value++; + }); + + AddStep("Add 98 Mentions Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Mentions.Value += 98; + }); + + AddStep("Clear Mentions Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Mentions.Value = 0; + }); + } + + private void leaveChannel(Channel channel) + { + leaveText.Text = $"OnRequestLeave: {channel.Name}"; + leaveText.FadeOutFromOne(1000, Easing.InQuint); + } + + private static Channel createPublicChannel(string name) => + new Channel { Name = name, Type = ChannelType.Public, Id = 1234 }; + + private static Channel createPrivateChannel(string username, int id) + => new Channel(new APIUser { Id = id, Username = username }); + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelListing.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelListing.cs new file mode 100644 index 0000000000..e521db1c9d --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelListing.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Chat.Listing; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneChannelListing : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider overlayColours = new OverlayColourProvider(OverlayColourScheme.Pink); + + private SearchTextBox search; + private ChannelListing listing; + + [SetUp] + public void SetUp() + { + Schedule(() => + { + Children = new Drawable[] + { + search = new SearchTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 300, + Margin = new MarginPadding { Top = 100 }, + }, + listing = new ChannelListing + { + Size = new Vector2(800, 400), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + listing.Show(); + search.Current.ValueChanged += term => listing.SearchTerm = term.NewValue; + }); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Add Join/Leave callbacks", () => + { + listing.OnRequestJoin += channel => channel.Joined.Value = true; + listing.OnRequestLeave += channel => channel.Joined.Value = false; + }); + } + + [Test] + public void TestAddRandomChannels() + { + AddStep("Add Random Channels", () => + { + listing.UpdateAvailableChannels(createRandomChannels(20)); + }); + } + + private Channel createRandomChannel() + { + int id = RNG.Next(0, 10000); + return new Channel + { + Name = $"#channel-{id}", + Topic = RNG.Next(4) < 3 ? $"We talk about the number {id} here" : null, + Type = ChannelType.Public, + Id = id, + }; + } + + private List createRandomChannels(int num) + => Enumerable.Range(0, num) + .Select(_ => createRandomChannel()) + .ToList(); + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 14f32df653..80a6698761 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -122,6 +122,8 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestHideOverlay() { + AddStep("Open chat overlay", () => chatOverlay.Show()); + AddAssert("Chat overlay is visible", () => chatOverlay.State.Value == Visibility.Visible); AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible); @@ -134,6 +136,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestChannelSelection() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible); AddStep("Setup get message response", () => onGetMessages = channel => { @@ -169,6 +172,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestSearchInSelector() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddStep("Search for 'no. 2'", () => chatOverlay.ChildrenOfType().First().Text = "no. 2"); AddUntilStep("Only channel 2 visible", () => { @@ -180,6 +184,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestChannelShortcutKeys() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddStep("Join channels", () => channels.ForEach(channel => channelManager.JoinChannel(channel))); AddStep("Close channel selector", () => InputManager.Key(Key.Escape)); AddUntilStep("Wait for close", () => chatOverlay.SelectionOverlayState == Visibility.Hidden); @@ -199,6 +204,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestCloseChannelBehaviour() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddUntilStep("Join until dropdown has channels", () => { if (visibleChannels.Count() < joinedChannels.Count()) @@ -269,6 +275,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestChannelCloseButton() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddStep("Join 2 channels", () => { channelManager.JoinChannel(channel1); @@ -289,6 +296,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestCloseTabShortcut() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddStep("Join 2 channels", () => { channelManager.JoinChannel(channel1); @@ -314,6 +322,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestNewTabShortcut() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddStep("Join 2 channels", () => { channelManager.JoinChannel(channel1); @@ -330,6 +339,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestRestoreTabShortcut() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddStep("Join 3 channels", () => { channelManager.JoinChannel(channel1); @@ -375,6 +385,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestChatCommand() { + AddStep("Open chat overlay", () => chatOverlay.Show()); AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); @@ -398,6 +409,8 @@ namespace osu.Game.Tests.Visual.Online { Channel multiplayerChannel = null; + AddStep("open chat overlay", () => chatOverlay.Show()); + AddStep("join multiplayer channel", () => channelManager.JoinChannel(multiplayerChannel = new Channel(new APIUser()) { Name = "#mp_1", @@ -412,6 +425,122 @@ namespace osu.Game.Tests.Visual.Online AddAssert("channel left", () => !channelManager.JoinedChannels.Contains(multiplayerChannel)); } + [Test] + public void TestHighlightOnCurrentChannel() + { + Message message = null; + + AddStep("Open chat overlay", () => chatOverlay.Show()); + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Send message in channel 1", () => + { + channel1.AddNewMessages(message = new Message + { + ChannelId = channel1.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); + }); + + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel1)); + } + + [Test] + public void TestHighlightOnAnotherChannel() + { + Message message = null; + + AddStep("Open chat overlay", () => chatOverlay.Show()); + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Join channel 2", () => channelManager.JoinChannel(channel2)); + AddStep("Send message in channel 2", () => + { + channel2.AddNewMessages(message = new Message + { + ChannelId = channel2.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); + }); + + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel2)); + AddAssert("Switched to channel 2", () => channelManager.CurrentChannel.Value == channel2); + } + + [Test] + public void TestHighlightOnLeftChannel() + { + Message message = null; + + AddStep("Open chat overlay", () => chatOverlay.Show()); + + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Join channel 2", () => channelManager.JoinChannel(channel2)); + AddStep("Send message in channel 2", () => + { + channel2.AddNewMessages(message = new Message + { + ChannelId = channel2.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); + }); + AddStep("Leave channel 2", () => channelManager.LeaveChannel(channel2)); + + AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel2)); + AddAssert("Switched to channel 2", () => channelManager.CurrentChannel.Value == channel2); + } + + [Test] + public void TestHighlightWhileChatNeverOpen() + { + Message message = null; + + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + + AddStep("Send message in channel 1", () => + { + channel1.AddNewMessages(message = new Message + { + ChannelId = channel1.Id, + Content = "Message to highlight!", + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "Someone", + } + }); + }); + + AddStep("Highlight message and open chat", () => + { + chatOverlay.HighlightMessage(message, channel1); + chatOverlay.Show(); + }); + } + private void pressChannelHotkey(int number) { var channelKey = Key.Number0 + number; @@ -456,8 +585,6 @@ namespace osu.Game.Tests.Visual.Online ChannelManager, ChatOverlay = new TestChatOverlay { RelativeSizeAxes = Axes.Both, }, }; - - ChatOverlay.Show(); } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs index 175d2ea36b..2c253650d5 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.Online private int messageIdCounter; [SetUp] - public void Setup() + public void Setup() => Schedule(() => { if (API is DummyAPIAccess daa) { @@ -50,7 +50,7 @@ namespace osu.Game.Tests.Visual.Online testContainer.ChatOverlay.Show(); }); - } + }); private bool dummyAPIHandleRequest(APIRequest request) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index a21647712d..860ef5d565 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -9,6 +9,8 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Chat; using osuTK.Input; @@ -107,49 +109,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestManyMessages() { - AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = admin, - Content = "I am a wang!" - })); - - AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = redUser, - Content = "I am team red." - })); - - AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = redUser, - Content = "I plan to win!" - })); - - AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = blueUser, - Content = "Not on my watch. Prepare to eat saaaaaaaaaand. Lots and lots of saaaaaaand." - })); - - AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = admin, - Content = "Okay okay, calm down guys. Let's do this!" - })); - - AddStep("message from long username", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = longUsernameUser, - Content = "Hi guys, my new username is lit!" - })); - - AddStep("message with new date", () => testChannel.AddNewMessages(new Message(messageIdSequence++) - { - Sender = longUsernameUser, - Content = "Message from the future!", - Timestamp = DateTimeOffset.Now - })); - + sendRegularMessages(); checkScrolledToBottom(); const int messages_per_call = 10; @@ -182,6 +142,64 @@ namespace osu.Game.Tests.Visual.Online checkScrolledToBottom(); } + [Test] + public void TestMessageHighlighting() + { + Message highlighted = null; + + sendRegularMessages(); + + AddStep("highlight first message", () => + { + highlighted = testChannel.Messages[0]; + testChannel.HighlightedMessage.Value = highlighted; + }); + + AddUntilStep("chat scrolled to first message", () => + { + var line = chatDisplay.ChildrenOfType().Single(c => c.Message == highlighted); + return chatDisplay.ScrollContainer.ScreenSpaceDrawQuad.Contains(line.ScreenSpaceDrawQuad.Centre); + }); + + sendMessage(); + checkNotScrolledToBottom(); + + AddStep("highlight last message", () => + { + highlighted = testChannel.Messages[^1]; + testChannel.HighlightedMessage.Value = highlighted; + }); + + AddUntilStep("chat scrolled to last message", () => + { + var line = chatDisplay.ChildrenOfType().Single(c => c.Message == highlighted); + return chatDisplay.ScrollContainer.ScreenSpaceDrawQuad.Contains(line.ScreenSpaceDrawQuad.Centre); + }); + + sendMessage(); + checkScrolledToBottom(); + + AddRepeatStep("highlight other random messages", () => + { + highlighted = testChannel.Messages[RNG.Next(0, testChannel.Messages.Count - 1)]; + testChannel.HighlightedMessage.Value = highlighted; + }, 10); + } + + [Test] + public void TestMessageHighlightingOnFilledChat() + { + int index = 0; + + fillChat(100); + + AddStep("highlight first message", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = 0]); + AddStep("highlight next message", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = Math.Min(index + 1, testChannel.Messages.Count - 1)]); + AddStep("highlight last message", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = testChannel.Messages.Count - 1]); + AddStep("highlight previous message", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = Math.Max(index - 1, 0)]); + AddRepeatStep("highlight random messages", () => testChannel.HighlightedMessage.Value = testChannel.Messages[index = RNG.Next(0, testChannel.Messages.Count - 1)], 10); + } + /// /// Tests that when a message gets wrapped by the chat display getting contracted while scrolled to bottom, the chat will still keep scrolling down. /// @@ -286,11 +304,11 @@ namespace osu.Game.Tests.Visual.Online checkScrolledToBottom(); } - private void fillChat() + private void fillChat(int count = 10) { AddStep("fill chat", () => { - for (int i = 0; i < 10; i++) + for (int i = 0; i < count; i++) { testChannel.AddNewMessages(new Message(messageIdSequence++) { @@ -321,6 +339,52 @@ namespace osu.Game.Tests.Visual.Online })); } + private void sendRegularMessages() + { + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = admin, + Content = "I am a wang!" + })); + + AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = redUser, + Content = "I am team red." + })); + + AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = redUser, + Content = "I plan to win!" + })); + + AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = blueUser, + Content = "Not on my watch. Prepare to eat saaaaaaaaaand. Lots and lots of saaaaaaand." + })); + + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = admin, + Content = "Okay okay, calm down guys. Let's do this!" + })); + + AddStep("message from long username", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = longUsernameUser, + Content = "Hi guys, my new username is lit!" + })); + + AddStep("message with new date", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Sender = longUsernameUser, + Content = "Message from the future!", + Timestamp = DateTimeOffset.Now + })); + } + private void checkScrolledToBottom() => AddUntilStep("is scrolled to bottom", () => chatDisplay.ScrolledToBottom); diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 67894bab38..ee9a0e263b 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -40,24 +40,33 @@ namespace osu.Game.Tests.Visual.Playlists private int totalCount; private ScoreInfo userScore; - [SetUp] - public void Setup() => Schedule(() => + [SetUpSteps] + public override void SetUpSteps() { - lowestScoreId = 1; - highestScoreId = 1; - requestComplete = false; - totalCount = 0; + base.SetUpSteps(); - userScore = TestResources.CreateTestScoreInfo(); - userScore.TotalScore = 0; - userScore.Statistics = new Dictionary(); + // Previous test instances of the results screen may still exist at this point so wait for + // those screens to be cleaned up by the base SetUpSteps before re-initialising test state. + // The the screen also holds a leased Beatmap bindable so reassigning it must happen after + // the screen as been exited. + AddStep("initialise user scores and beatmap", () => + { + lowestScoreId = 1; + highestScoreId = 1; + requestComplete = false; + totalCount = 0; - bindHandler(); + userScore = TestResources.CreateTestScoreInfo(); + userScore.TotalScore = 0; + userScore.Statistics = new Dictionary(); - // beatmap is required to be an actual beatmap so the scores can get their scores correctly calculated for standardised scoring. - // else the tests that rely on ordering will fall over. - Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - }); + bindHandler(); + + // Beatmap is required to be an actual beatmap so the scores can get their scores correctly + // calculated for standardised scoring, else the tests that rely on ordering will fall over. + Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); + }); + } [Test] public void TestShowWithUserScore() diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index cc380df183..4eed2a25f5 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -390,7 +390,7 @@ namespace osu.Game.Tests.Visual.Ranking private class RulesetWithNoPerformanceCalculator : OsuRuleset { - public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => null; + public override PerformanceCalculator CreatePerformanceCalculator() => null; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneEditorSidebar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneEditorSidebar.cs new file mode 100644 index 0000000000..f2f475e063 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneEditorSidebar.cs @@ -0,0 +1,97 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneEditorSidebar : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + [Test] + public void TestSidebars() + { + AddStep("Add sidebars", () => + { + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new EditorSidebar + { + Children = new[] + { + new EditorSidebarSection("Section 1") + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Spacing = new Vector2(3), + ChildrenEnumerable = Enumerable.Range(0, 10).Select(_ => new Box + { + Colour = Color4.White, + Size = new Vector2(32), + }) + }, + }, + new EditorSidebarSection("Section with a really long section header") + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Spacing = new Vector2(3), + ChildrenEnumerable = Enumerable.Range(0, 400).Select(_ => new Box + { + Colour = Color4.Gray, + Size = new Vector2(32), + }) + }, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + }, + new EditorSidebar + { + Children = new[] + { + new EditorSidebarSection("Section 1"), + new EditorSidebarSection("Section 2"), + }, + }, + } + } + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs index 8e53c7c402..6bd6115e68 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs @@ -40,6 +40,10 @@ namespace osu.Game.Tests.Visual.UserInterface { Text = @"You're a fake!", }, + new PopupDialogDangerousButton + { + Text = @"Careful with this one..", + }, }; } } diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index acf1e8470b..0bcf533653 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -8,7 +8,7 @@ - + WinExe diff --git a/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs b/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs index 93cfa9634e..f0aa857769 100644 --- a/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs +++ b/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tournament.Screens.Setup dropdown.Current.BindValueChanged(v => Button.Enabled.Value = v.NewValue != startupTournament, true); Action = () => game.GracefullyExit(); - folderButton.Action = storage.PresentExternally; + folderButton.Action = () => storage.PresentExternally(); ButtonText = "Close osu!"; } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index c6f69286cd..f90208d0c0 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -9,6 +9,7 @@ using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets; using osu.Game.Scoring; using Realms; @@ -169,7 +170,12 @@ namespace osu.Game.Beatmaps [Ignored] public APIBeatmap? OnlineInfo { get; set; } + /// + /// The maximum achievable combo on this beatmap, populated for online info purposes only. + /// Todo: This should never be used nor exist, but is still relied on in since can't be used yet. For now this is obsoleted until it is removed. + /// [Ignored] + [Obsolete("Use ScoreManager.GetMaximumAchievableComboAsync instead.")] public int? MaxCombo { get; set; } [Ignored] diff --git a/osu.Game/Beatmaps/EFBeatmapInfo.cs b/osu.Game/Beatmaps/EFBeatmapInfo.cs index 8daeaa7030..740adfd1c7 100644 --- a/osu.Game/Beatmaps/EFBeatmapInfo.cs +++ b/osu.Game/Beatmaps/EFBeatmapInfo.cs @@ -53,9 +53,6 @@ namespace osu.Game.Beatmaps [NotMapped] public APIBeatmap OnlineInfo { get; set; } - [NotMapped] - public int? MaxCombo { get; set; } - /// /// The playable length in milliseconds of this beatmap. /// diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index e2a043490f..79d8bd3bb3 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -19,6 +19,11 @@ namespace osu.Game.Beatmaps.Formats { public class LegacyBeatmapDecoder : LegacyDecoder { + /// + /// An offset which needs to be applied to old beatmaps (v4 and lower) to correct timing changes that were applied at a game client level. + /// + public const int EARLY_VERSION_TIMING_OFFSET = 24; + internal static RulesetStore RulesetStore; private Beatmap beatmap; @@ -50,8 +55,7 @@ namespace osu.Game.Beatmaps.Formats RulesetStore = new AssemblyRulesetStore(); } - // BeatmapVersion 4 and lower had an incorrect offset (stable has this set as 24ms off) - offset = FormatVersion < 5 ? 24 : 0; + offset = FormatVersion < 5 ? EARLY_VERSION_TIMING_OFFSET : 0; } protected override Beatmap CreateTemplateObject() diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 5db502804d..4111a67b24 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using JetBrains.Annotations; @@ -170,6 +171,39 @@ namespace osu.Game.Configuration private static readonly ConcurrentDictionary property_info_cache = new ConcurrentDictionary(); + /// + /// Returns the underlying value of the given mod setting object. + /// Can be used for serialization and equality comparison purposes. + /// + /// A bindable. + public static object GetUnderlyingSettingValue(this object setting) + { + switch (setting) + { + case Bindable d: + return d.Value; + + case Bindable i: + return i.Value; + + case Bindable f: + return f.Value; + + case Bindable b: + return b.Value; + + case IBindable u: + // An unknown (e.g. enum) generic type. + var valueMethod = u.GetType().GetProperty(nameof(IBindable.Value)); + Debug.Assert(valueMethod != null); + return valueMethod.GetValue(u); + + default: + // fall back for non-bindable cases. + return setting; + } + } + public static IEnumerable<(SettingSourceAttribute, PropertyInfo)> GetSettingsSourceProperties(this object obj) { var type = obj.GetType(); diff --git a/osu.Game/Database/EFToRealmMigrator.cs b/osu.Game/Database/EFToRealmMigrator.cs index c9deee19fe..cbf5c5ffe9 100644 --- a/osu.Game/Database/EFToRealmMigrator.cs +++ b/osu.Game/Database/EFToRealmMigrator.cs @@ -295,7 +295,6 @@ namespace osu.Game.Database TimelineZoom = beatmap.TimelineZoom, Countdown = beatmap.Countdown, CountdownOffset = beatmap.CountdownOffset, - MaxCombo = beatmap.MaxCombo, Bookmarks = beatmap.Bookmarks, BeatmapSet = realmBeatmapSet, }; diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index f0d4011ab8..8574002436 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; using System.ComponentModel; @@ -17,6 +19,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Statistics; +using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Bindings; @@ -28,8 +31,6 @@ using osu.Game.Stores; using Realms; using Realms.Exceptions; -#nullable enable - namespace osu.Game.Database { /// @@ -46,6 +47,8 @@ namespace osu.Game.Database private readonly IDatabaseContextFactory? efContextFactory; + private readonly SynchronizationContext? updateThreadSyncContext; + /// /// Version history: /// 6 ~2021-10-18 First tracked version. @@ -143,12 +146,15 @@ namespace osu.Game.Database /// /// The game storage which will be used to create the realm backing file. /// The filename to use for the realm backing file. A ".realm" extension will be added automatically if not specified. + /// The game update thread, used to post realm operations into a thread-safe context. /// An EF factory used only for migration purposes. - public RealmAccess(Storage storage, string filename, IDatabaseContextFactory? efContextFactory = null) + public RealmAccess(Storage storage, string filename, GameThread? updateThread = null, IDatabaseContextFactory? efContextFactory = null) { this.storage = storage; this.efContextFactory = efContextFactory; + updateThreadSyncContext = updateThread?.SynchronizationContext ?? SynchronizationContext.Current; + Filename = filename; if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) @@ -379,9 +385,6 @@ namespace osu.Game.Database public IDisposable RegisterForNotifications(Func> query, NotificationCallbackDelegate callback) where T : RealmObjectBase { - if (!ThreadSafety.IsUpdateThread) - throw new InvalidOperationException(@$"{nameof(RegisterForNotifications)} must be called from the update thread."); - lock (realmLock) { Func action = realm => query(realm).QueryAsyncWithNotifications(callback); @@ -459,23 +462,24 @@ namespace osu.Game.Database /// An which should be disposed to unsubscribe any inner subscription. public IDisposable RegisterCustomSubscription(Func action) { - if (!ThreadSafety.IsUpdateThread) - throw new InvalidOperationException(@$"{nameof(RegisterForNotifications)} must be called from the update thread."); - - var syncContext = SynchronizationContext.Current; + if (updateThreadSyncContext == null) + throw new InvalidOperationException("Attempted to register a realm subscription before update thread registration."); total_subscriptions.Value++; - registerSubscription(action); + if (ThreadSafety.IsUpdateThread) + updateThreadSyncContext.Send(_ => registerSubscription(action), null); + else + updateThreadSyncContext.Post(_ => registerSubscription(action), null); // This token is returned to the consumer. // When disposed, it will cause the registration to be permanently ceased (unsubscribed with realm and unregistered by this class). return new InvokeOnDisposal(() => { if (ThreadSafety.IsUpdateThread) - syncContext.Send(_ => unsubscribe(), null); + updateThreadSyncContext.Send(_ => unsubscribe(), null); else - syncContext.Post(_ => unsubscribe(), null); + updateThreadSyncContext.Post(_ => unsubscribe(), null); void unsubscribe() { diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index 005804789e..d1aba2bfe3 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using Humanizer; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osuTK; @@ -59,8 +62,18 @@ namespace osu.Game.Extensions component.Origin = info.Origin; if (component is ISkinnableDrawable skinnable) + { skinnable.UsesFixedAnchor = info.UsesFixedAnchor; + foreach (var (_, property) in component.GetSettingsSourceProperties()) + { + if (!info.Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) + continue; + + skinnable.CopyAdjustedSetting((IBindable)property.GetValue(component), settingValue); + } + } + if (component is Container container) { foreach (var child in info.Children) diff --git a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs index 999dd183aa..b2f08eee0a 100644 --- a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs +++ b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs @@ -28,6 +28,14 @@ namespace osu.Game.Graphics.Containers /// protected virtual bool AllowMultipleFires => false; + /// + /// Specify a custom activation delay, overriding the game-wide user setting. + /// + /// + /// This should be used in special cases where we want to be extra sure the user knows what they are doing. An example is when changes would be lost. + /// + protected virtual double? HoldActivationDelay => null; + public Bindable Progress = new BindableDouble(); private Bindable holdActivationDelay; @@ -35,7 +43,9 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - holdActivationDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); + holdActivationDelay = HoldActivationDelay != null + ? new Bindable(HoldActivationDelay.Value) + : config.GetBindable(OsuSetting.UIHoldActivationDelay); } protected void BeginConfirm() diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index d331b818a1..58d18e1b21 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -6,6 +6,8 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; +using osu.Framework.Layout; +using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Configuration; using osu.Game.Screens; @@ -66,7 +68,7 @@ namespace osu.Game.Graphics.Containers this.targetMode = targetMode; RelativeSizeAxes = Axes.Both; - InternalChild = sizableContainer = new AlwaysInputContainer + InternalChild = sizableContainer = new SizeableAlwaysInputContainer(targetMode == ScalingMode.Everything) { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Both, @@ -209,13 +211,50 @@ namespace osu.Game.Graphics.Containers } } - private class AlwaysInputContainer : Container + private class SizeableAlwaysInputContainer : Container { + [Resolved] + private GameHost host { get; set; } + + [Resolved] + private ISafeArea safeArea { get; set; } + + private readonly bool confineHostCursor; + private readonly LayoutValue cursorRectCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - public AlwaysInputContainer() + /// + /// Container used for sizing/positioning purposes in . Always receives mouse input. + /// + /// Whether to confine the host cursor to the draw area of this container. + /// Cursor confinement will abide by the setting. + public SizeableAlwaysInputContainer(bool confineHostCursor) { RelativeSizeAxes = Axes.Both; + this.confineHostCursor = confineHostCursor; + + if (confineHostCursor) + AddLayout(cursorRectCache); + } + + protected override void Update() + { + base.Update(); + + if (confineHostCursor && !cursorRectCache.IsValid) + { + updateHostCursorConfineRect(); + cursorRectCache.Validate(); + } + } + + private void updateHostCursorConfineRect() + { + if (host.Window == null) return; + + bool coversWholeScreen = Size == Vector2.One && safeArea.SafeAreaPadding.Value.Total == Vector2.Zero; + host.Window.CursorConfineRect = coversWholeScreen ? (RectangleF?)null : ToScreenSpace(DrawRectangle).AABBFloat; } } } diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index 2f9e4dae51..ad69ec4078 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -45,8 +45,9 @@ namespace osu.Game.Graphics.UserInterface } } + protected readonly Container ColourContainer; + private readonly Container backgroundContainer; - private readonly Container colourContainer; private readonly Container glowContainer; private readonly Box leftGlow; private readonly Box centerGlow; @@ -113,7 +114,7 @@ namespace osu.Game.Graphics.UserInterface Masking = true, Children = new Drawable[] { - colourContainer = new Container + ColourContainer = new Container { RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, @@ -182,7 +183,7 @@ namespace osu.Game.Graphics.UserInterface { buttonColour = value; updateGlow(); - colourContainer.Colour = value; + ColourContainer.Colour = value; } } @@ -230,11 +231,11 @@ namespace osu.Game.Graphics.UserInterface Alpha = 0.05f }; - colourContainer.Add(flash); + ColourContainer.Add(flash); flash.FadeOutFromOne(100).Expire(); clickAnimating = true; - colourContainer.ResizeWidthTo(colourContainer.Width * 1.05f, 100, Easing.OutQuint) + ColourContainer.ResizeWidthTo(ColourContainer.Width * 1.05f, 100, Easing.OutQuint) .OnComplete(_ => { clickAnimating = false; @@ -246,14 +247,14 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnMouseDown(MouseDownEvent e) { - colourContainer.ResizeWidthTo(hover_width * 0.98f, click_duration * 4, Easing.OutQuad); + ColourContainer.ResizeWidthTo(hover_width * 0.98f, click_duration * 4, Easing.OutQuad); return base.OnMouseDown(e); } protected override void OnMouseUp(MouseUpEvent e) { if (State == SelectionState.Selected) - colourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In); + ColourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In); base.OnMouseUp(e); } @@ -279,12 +280,12 @@ namespace osu.Game.Graphics.UserInterface if (newState == SelectionState.Selected) { spriteText.TransformSpacingTo(hoverSpacing, hover_duration, Easing.OutElastic); - colourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic); + ColourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic); glowContainer.FadeIn(hover_duration, Easing.OutQuint); } else { - colourContainer.ResizeWidthTo(idle_width, hover_duration, Easing.OutElastic); + ColourContainer.ResizeWidthTo(idle_width, hover_duration, Easing.OutElastic); spriteText.TransformSpacingTo(Vector2.Zero, hover_duration, Easing.OutElastic); glowContainer.FadeOut(hover_duration, Easing.OutQuint); } diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs index 6f0f898de3..a6605de1d2 100644 --- a/osu.Game/IO/WrappedStorage.cs +++ b/osu.Game/IO/WrappedStorage.cs @@ -70,9 +70,9 @@ namespace osu.Game.IO public override Stream GetStream(string path, FileAccess access = FileAccess.Read, FileMode mode = FileMode.OpenOrCreate) => UnderlyingStorage.GetStream(MutatePath(path), access, mode); - public override void OpenFileExternally(string filename) => UnderlyingStorage.OpenFileExternally(MutatePath(filename)); + public override bool OpenFileExternally(string filename) => UnderlyingStorage.OpenFileExternally(MutatePath(filename)); - public override void PresentFileExternally(string filename) => UnderlyingStorage.PresentFileExternally(MutatePath(filename)); + public override bool PresentFileExternally(string filename) => UnderlyingStorage.PresentFileExternally(MutatePath(filename)); public override Storage GetStorageForDirectory(string path) { diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs index 67041cae07..524f7b7108 100644 --- a/osu.Game/Online/API/APIMod.cs +++ b/osu.Game/Online/API/APIMod.cs @@ -8,10 +8,10 @@ using Humanizer; using MessagePack; using Newtonsoft.Json; using osu.Framework.Bindables; +using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Utils; namespace osu.Game.Online.API { @@ -42,7 +42,7 @@ namespace osu.Game.Online.API var bindable = (IBindable)property.GetValue(mod); if (!bindable.IsDefault) - Settings.Add(property.Name.Underscore(), ModUtils.GetSettingUnderlyingValue(bindable)); + Settings.Add(property.Name.Underscore(), bindable.GetUnderlyingSettingValue()); } } @@ -51,7 +51,10 @@ namespace osu.Game.Online.API Mod resultMod = ruleset.CreateModFromAcronym(Acronym); if (resultMod == null) - throw new InvalidOperationException($"There is no mod in the ruleset ({ruleset.ShortName}) matching the acronym {Acronym}."); + { + Logger.Log($"There is no mod in the ruleset ({ruleset.ShortName}) matching the acronym {Acronym}."); + return new UnknownMod(Acronym); + } if (Settings.Count > 0) { @@ -89,13 +92,13 @@ namespace osu.Game.Online.API public bool Equals(KeyValuePair x, KeyValuePair y) { - object xValue = ModUtils.GetSettingUnderlyingValue(x.Value); - object yValue = ModUtils.GetSettingUnderlyingValue(y.Value); + object xValue = x.Value.GetUnderlyingSettingValue(); + object yValue = y.Value.GetUnderlyingSettingValue(); return x.Key == y.Key && EqualityComparer.Default.Equals(xValue, yValue); } - public int GetHashCode(KeyValuePair obj) => HashCode.Combine(obj.Key, ModUtils.GetSettingUnderlyingValue(obj.Value)); + public int GetHashCode(KeyValuePair obj) => HashCode.Combine(obj.Key, obj.Value.GetUnderlyingSettingValue()); } } } diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs index 81ecc74ddb..a7c63c17f9 100644 --- a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs +++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Text; using MessagePack; using MessagePack.Formatters; -using osu.Game.Utils; +using osu.Game.Configuration; namespace osu.Game.Online.API { @@ -23,7 +23,7 @@ namespace osu.Game.Online.API var stringBytes = new ReadOnlySequence(Encoding.UTF8.GetBytes(kvp.Key)); writer.WriteString(in stringBytes); - primitiveFormatter.Serialize(ref writer, ModUtils.GetSettingUnderlyingValue(kvp.Value), options); + primitiveFormatter.Serialize(ref writer, kvp.Value.GetUnderlyingSettingValue(), options); } } diff --git a/osu.Game/Online/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs index 9cbb2f37e4..a37d3084f0 100644 --- a/osu.Game/Online/Chat/Channel.cs +++ b/osu.Game/Online/Chat/Channel.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Lists; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Chat; namespace osu.Game.Online.Chat { @@ -89,6 +90,12 @@ namespace osu.Game.Online.Chat /// public Bindable Joined = new Bindable(); + /// + /// Signals if there is a message to highlight. + /// This is automatically cleared by the associated after highlighting. + /// + public Bindable HighlightedMessage = new Bindable(); + [JsonConstructor] public Channel() { diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index dcd15f9028..ad004e2881 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -59,7 +59,13 @@ namespace osu.Game.Online.Chat return Id.Value.CompareTo(other.Id.Value); } - public virtual bool Equals(Message other) => Id.HasValue && Id == other?.Id; + public virtual bool Equals(Message other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Id.HasValue && Id == other.Id; + } // ReSharper disable once ImpureMethodCallOnReadonlyValueField public override int GetHashCode() => Id.GetHashCode(); diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index 2c99e9f9b9..bcfec3cc0f 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -114,7 +114,7 @@ namespace osu.Game.Online.Chat if (!notifyOnPrivateMessage.Value || channel.Type != ChannelType.PM) return false; - notifications.Post(new PrivateMessageNotification(message.Sender.Username, channel)); + notifications.Post(new PrivateMessageNotification(message, channel)); return true; } @@ -122,7 +122,7 @@ namespace osu.Game.Online.Chat { if (!notifyOnUsername.Value || !CheckContainsUsername(message.Content, localUser.Value.Username)) return; - notifications.Post(new MentionNotification(message.Sender.Username, channel)); + notifications.Post(new MentionNotification(message, channel)); } /// @@ -136,47 +136,49 @@ namespace osu.Game.Online.Chat return Regex.IsMatch(message, $@"(^|\W)({fullName}|{underscoreName})($|\W)", RegexOptions.IgnoreCase); } - public class PrivateMessageNotification : OpenChannelNotification + public class PrivateMessageNotification : HighlightMessageNotification { - public PrivateMessageNotification(string username, Channel channel) - : base(channel) + public PrivateMessageNotification(Message message, Channel channel) + : base(message, channel) { Icon = FontAwesome.Solid.Envelope; - Text = $"You received a private message from '{username}'. Click to read it!"; + Text = $"You received a private message from '{message.Sender.Username}'. Click to read it!"; } } - public class MentionNotification : OpenChannelNotification + public class MentionNotification : HighlightMessageNotification { - public MentionNotification(string username, Channel channel) - : base(channel) + public MentionNotification(Message message, Channel channel) + : base(message, channel) { Icon = FontAwesome.Solid.At; - Text = $"Your name was mentioned in chat by '{username}'. Click to find out why!"; + Text = $"Your name was mentioned in chat by '{message.Sender.Username}'. Click to find out why!"; } } - public abstract class OpenChannelNotification : SimpleNotification + public abstract class HighlightMessageNotification : SimpleNotification { - protected OpenChannelNotification(Channel channel) + protected HighlightMessageNotification(Message message, Channel channel) { + this.message = message; this.channel = channel; } + private readonly Message message; private readonly Channel channel; public override bool IsImportant => false; [BackgroundDependencyLoader] - private void load(OsuColour colours, ChatOverlay chatOverlay, NotificationOverlay notificationOverlay, ChannelManager channelManager) + private void load(OsuColour colours, ChatOverlay chatOverlay, NotificationOverlay notificationOverlay) { IconBackground.Colour = colours.PurpleDark; Activated = delegate { notificationOverlay.Hide(); + chatOverlay.HighlightMessage(message, channel); chatOverlay.Show(); - channelManager.CurrentChannel.Value = channel; return true; }; diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs new file mode 100644 index 0000000000..b067f3b235 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using MessagePack; + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// Indicates a change to the 's countdown. + /// + [MessagePackObject] + public class CountdownChangedEvent : MatchServerEvent + { + /// + /// The new countdown. + /// + [Key(0)] + public MultiplayerCountdown? Countdown { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs b/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs new file mode 100644 index 0000000000..08eab26090 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs @@ -0,0 +1,23 @@ +// 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 MessagePack; + +#nullable enable + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// A request for a countdown to start the match. + /// + [MessagePackObject] + public class StartMatchCountdownRequest : MatchUserRequest + { + /// + /// How long the countdown should last. + /// + [Key(0)] + public TimeSpan Duration { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs new file mode 100644 index 0000000000..20a0e32734 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using MessagePack; + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// Request to stop the current countdown. + /// + [MessagePackObject] + public class StopCountdownRequest : MatchUserRequest + { + } +} diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs index 891fb2cc3b..4ce55e424d 100644 --- a/osu.Game/Online/Multiplayer/MatchServerEvent.cs +++ b/osu.Game/Online/Multiplayer/MatchServerEvent.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using MessagePack; +using osu.Game.Online.Multiplayer.Countdown; namespace osu.Game.Online.Multiplayer { @@ -11,6 +14,8 @@ namespace osu.Game.Online.Multiplayer /// [Serializable] [MessagePackObject] + // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + [Union(0, typeof(CountdownChangedEvent))] public abstract class MatchServerEvent { } diff --git a/osu.Game/Online/Multiplayer/MatchStartCountdown.cs b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs new file mode 100644 index 0000000000..6c1cdd97d3 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A which will start the match after ending. + /// + [MessagePackObject] + public class MatchStartCountdown : MultiplayerCountdown + { + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs index 9c3b07049c..a26a2b3fc2 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs @@ -7,6 +7,7 @@ using MessagePack; namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { + [MessagePackObject] public class ChangeTeamRequest : MatchUserRequest { [Key(0)] diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs index 8c6809e7f3..888b55e428 100644 --- a/osu.Game/Online/Multiplayer/MatchUserRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchUserRequest.cs @@ -3,6 +3,7 @@ using System; using MessagePack; +using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; namespace osu.Game.Online.Multiplayer @@ -12,7 +13,10 @@ namespace osu.Game.Online.Multiplayer /// [Serializable] [MessagePackObject] - [Union(0, typeof(ChangeTeamRequest))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + [Union(0, typeof(ChangeTeamRequest))] + [Union(1, typeof(StartMatchCountdownRequest))] + [Union(2, typeof(StopCountdownRequest))] public abstract class MatchUserRequest { } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index a56cc7f8d6..faa995ed19 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -16,6 +16,7 @@ using osu.Framework.Logging; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Rulesets; @@ -170,6 +171,8 @@ namespace osu.Game.Online.Multiplayer Room = joinedRoom; APIRoom = room; + Debug.Assert(joinedRoom.Playlist.Count > 0); + APIRoom.Playlist.Clear(); APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem)); @@ -534,7 +537,24 @@ namespace osu.Game.Online.Multiplayer public Task MatchEvent(MatchServerEvent e) { - // not used by any match types just yet. + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + switch (e) + { + case CountdownChangedEvent countdownChangedEvent: + Room.Countdown = countdownChangedEvent.Countdown; + break; + } + + RoomUpdated?.Invoke(); + }, false); + return Task.CompletedTask; } @@ -665,6 +685,8 @@ namespace osu.Game.Online.Multiplayer Room.Playlist.Remove(Room.Playlist.Single(existing => existing.ID == playlistItemId)); APIRoom.Playlist.RemoveAll(existing => existing.ID == playlistItemId); + Debug.Assert(Room.Playlist.Count > 0); + ItemRemoved?.Invoke(playlistItemId); RoomUpdated?.Invoke(); }); diff --git a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs new file mode 100644 index 0000000000..81190e64c9 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using MessagePack; +using osu.Game.Online.Multiplayer.Countdown; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// Describes the current countdown in a . + /// + [MessagePackObject] + [Union(0, typeof(MatchStartCountdown))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + public abstract class MultiplayerCountdown + { + /// + /// The amount of time remaining in the countdown. + /// + /// + /// This is only sent once from the server upon initial retrieval of the or via a . + /// + [Key(0)] + public TimeSpan TimeRemaining { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index a60e70dab3..e215498ff9 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -54,6 +54,12 @@ namespace osu.Game.Online.Multiplayer [Key(6)] public IList Playlist { get; set; } = new List(); + /// + /// The currently-running countdown. + /// + [Key(7)] + public MultiplayerCountdown? Countdown { get; set; } + [JsonConstructor] [SerializationConstructor] public MultiplayerRoom(long roomId) diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index a33150fe08..543b176b51 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -10,12 +10,11 @@ using osu.Game.IO.Serialization.Converters; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms.RoomStatuses; -using osu.Game.Utils; namespace osu.Game.Online.Rooms { [JsonObject(MemberSerialization.OptIn)] - public class Room : IDeepCloneable + public class Room { [Cached] [JsonProperty("id")] @@ -153,22 +152,6 @@ namespace osu.Game.Online.Rooms Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); } - /// - /// Create a copy of this room without online information. - /// Should be used to create a local copy of a room for submitting in the future. - /// - public Room DeepClone() - { - var copy = new Room(); - - copy.CopyFrom(this); - - // ID must be unset as we use this as a marker for whether this is a client-side (not-yet-created) room or not. - copy.RoomID.Value = null; - - return copy; - } - public void CopyFrom(Room other) { RoomID.Value = other.RoomID.Value; diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index f69d23d81c..156f916cef 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; namespace osu.Game.Online @@ -18,8 +19,12 @@ namespace osu.Game.Online internal static readonly IReadOnlyList<(Type derivedType, Type baseType)> BASE_TYPE_MAPPING = new[] { (typeof(ChangeTeamRequest), typeof(MatchUserRequest)), + (typeof(StartMatchCountdownRequest), typeof(MatchUserRequest)), + (typeof(StopCountdownRequest), typeof(MatchUserRequest)), + (typeof(CountdownChangedEvent), typeof(MatchServerEvent)), (typeof(TeamVersusRoomState), typeof(MatchRoomState)), (typeof(TeamVersusUserState), typeof(MatchUserState)), + (typeof(MatchStartCountdown), typeof(MultiplayerCountdown)) }; } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index fb81e4fd14..4cd954a646 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1046,6 +1046,10 @@ namespace osu.Game switch (e.Action) { + case GlobalAction.ToggleSkinEditor: + skinEditor.ToggleVisibility(); + return true; + case GlobalAction.ResetInputSettings: Host.ResetInputHandlers(); frameworkConfig.GetBindable(FrameworkSetting.ConfineMouseMode).SetDefault(); @@ -1140,10 +1144,8 @@ namespace osu.Game MenuCursorContainer.CanShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; } - protected virtual void ScreenChanged(IScreen current, IScreen newScreen) + private void screenChanged(IScreen current, IScreen newScreen) { - skinEditor.Reset(); - switch (newScreen) { case IntroScreen intro: @@ -1185,13 +1187,15 @@ namespace osu.Game else BackButton.Hide(); } + + skinEditor.SetTarget((OsuScreen)newScreen); } - private void screenPushed(IScreen lastScreen, IScreen newScreen) => ScreenChanged(lastScreen, newScreen); + private void screenPushed(IScreen lastScreen, IScreen newScreen) => screenChanged(lastScreen, newScreen); private void screenExited(IScreen lastScreen, IScreen newScreen) { - ScreenChanged(lastScreen, newScreen); + screenChanged(lastScreen, newScreen); if (newScreen == null) Exit(); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 8b75de9718..7b9aca4086 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -69,7 +69,7 @@ namespace osu.Game /// private const double global_track_volume_adjust = 0.8; - public bool UseDevelopmentServer { get; } + public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild; public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); @@ -177,7 +177,6 @@ namespace osu.Game public OsuGameBase() { - UseDevelopmentServer = DebugUtils.IsDebugBuild; Name = @"osu!"; } @@ -201,7 +200,7 @@ namespace osu.Game if (Storage.Exists(DatabaseContextFactory.DATABASE_NAME)) dependencies.Cache(EFContextFactory = new DatabaseContextFactory(Storage)); - dependencies.Cache(realm = new RealmAccess(Storage, "client", EFContextFactory)); + dependencies.Cache(realm = new RealmAccess(Storage, "client", Host.UpdateThread, EFContextFactory)); dependencies.CacheAs(RulesetStore = new RealmRulesetStore(realm, Storage)); dependencies.CacheAs(RulesetStore); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 5ef434c427..86e72e9faa 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -173,7 +173,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Text = score.MaxCombo.ToLocalisableString(@"0\x"), Font = OsuFont.GetFont(size: text_size), +#pragma warning disable 618 Colour = score.MaxCombo == score.BeatmapInfo.MaxCombo ? highAccuracyColour : Color4.White +#pragma warning restore 618 } }; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 6f07b20049..7d59c95396 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -78,7 +78,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores // TODO: temporary. should be removed once `OrderByTotalScore` can accept `IScoreInfo`. var beatmapInfo = new BeatmapInfo { +#pragma warning disable 618 MaxCombo = apiBeatmap.MaxCombo, +#pragma warning restore 618 Status = apiBeatmap.Status, MD5Hash = apiBeatmap.MD5Hash }; diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs new file mode 100644 index 0000000000..43574351ed --- /dev/null +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs @@ -0,0 +1,171 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Chat; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Overlays.Chat.ChannelList +{ + public class ChannelListItem : OsuClickableContainer + { + public event Action? OnRequestSelect; + public event Action? OnRequestLeave; + + public readonly BindableInt Mentions = new BindableInt(); + + public readonly BindableBool Unread = new BindableBool(); + + private readonly Channel channel; + + private Box? hoverBox; + private Box? selectBox; + private OsuSpriteText? text; + private ChannelListItemCloseButton? close; + + [Resolved] + private Bindable selectedChannel { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public ChannelListItem(Channel channel) + { + this.channel = channel; + } + + [BackgroundDependencyLoader] + private void load() + { + Height = 30; + RelativeSizeAxes = Axes.X; + + Children = new Drawable[] + { + hoverBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + Alpha = 0f, + }, + selectBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + Alpha = 0f, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 18, Right = 10 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + createIcon(), + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = channel.Name, + Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold), + Colour = colourProvider.Light3, + Margin = new MarginPadding { Bottom = 2 }, + RelativeSizeAxes = Axes.X, + Truncate = true, + }, + new ChannelListItemMentionPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 3 }, + Mentions = { BindTarget = Mentions }, + }, + close = new ChannelListItemCloseButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 3 }, + Action = () => OnRequestLeave?.Invoke(channel), + } + } + }, + }, + }, + }; + + Action = () => OnRequestSelect?.Invoke(channel); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedChannel.BindValueChanged(change => + { + if (change.NewValue == channel) + selectBox?.FadeIn(300, Easing.OutQuint); + else + selectBox?.FadeOut(200, Easing.OutQuint); + }, true); + + Unread.BindValueChanged(change => + { + text!.FadeColour(change.NewValue ? colourProvider.Content1 : colourProvider.Light3, 300, Easing.OutQuint); + }, true); + } + + protected override bool OnHover(HoverEvent e) + { + hoverBox?.FadeIn(300, Easing.OutQuint); + close?.FadeIn(300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverBox?.FadeOut(200, Easing.OutQuint); + close?.FadeOut(200, Easing.OutQuint); + base.OnHoverLost(e); + } + + private Drawable createIcon() + { + if (channel.Type != ChannelType.PM) + return Drawable.Empty(); + + return new UpdateableAvatar(channel.Users.First(), isInteractive: false) + { + Size = new Vector2(20), + Margin = new MarginPadding { Right = 5 }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + CornerRadius = 10, + Masking = true, + }; + } + } +} diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs new file mode 100644 index 0000000000..65b9c4a79b --- /dev/null +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Chat.ChannelList +{ + public class ChannelListItemCloseButton : OsuClickableContainer + { + private SpriteIcon icon = null!; + + private Color4 normalColour; + private Color4 hoveredColour; + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour) + { + normalColour = osuColour.Red2; + hoveredColour = Color4.White; + + Alpha = 0f; + Size = new Vector2(20); + Add(icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.75f), + Icon = FontAwesome.Solid.TimesCircle, + RelativeSizeAxes = Axes.Both, + Colour = normalColour, + }); + } + + // Transforms matching OsuAnimatedButton + protected override bool OnHover(HoverEvent e) + { + icon.FadeColour(hoveredColour, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + icon.FadeColour(normalColour, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + icon.ScaleTo(0.75f, 2000, Easing.OutQuint); + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + icon.ScaleTo(1, 1000, Easing.OutElastic); + base.OnMouseUp(e); + } + } +} diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs new file mode 100644 index 0000000000..5018c8cd64 --- /dev/null +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Chat.ChannelList +{ + public class ChannelListItemMentionPill : CircularContainer + { + public readonly BindableInt Mentions = new BindableInt(); + + private OsuSpriteText countText = null!; + + private Box box = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour, OverlayColourProvider colourProvider) + { + Masking = true; + Size = new Vector2(20, 12); + Alpha = 0f; + + Children = new Drawable[] + { + box = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = osuColour.Orange1, + }, + countText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 11, weight: FontWeight.Bold), + Margin = new MarginPadding { Bottom = 1 }, + Colour = colourProvider.Background5, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Mentions.BindValueChanged(change => + { + int mentionCount = change.NewValue; + + countText.Text = mentionCount > 99 ? "99+" : mentionCount.ToString(); + + if (mentionCount > 0) + { + this.FadeIn(1000, Easing.OutQuint); + box.FlashColour(Color4.White, 500, Easing.OutQuint); + } + else + this.FadeOut(100, Easing.OutQuint); + }, true); + } + } +} diff --git a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs index 58b2b9a075..139c091f03 100644 --- a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs +++ b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs @@ -47,6 +47,12 @@ namespace osu.Game.Overlays.Chat updateTrackState(); } + public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + { + base.ScrollTo(value, animated, distanceDecay); + updateTrackState(); + } + public new void ScrollIntoView(Drawable d, bool animated = true) { base.ScrollIntoView(d, animated); diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 87d1b1a3ad..a1d8cd5d38 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Chat protected virtual float TextSize => 20; - private Color4 customUsernameColour; + private Color4 usernameColour; private OsuSpriteText timestamp; @@ -78,19 +78,22 @@ namespace osu.Game.Overlays.Chat } } - private bool senderHasBackground => !string.IsNullOrEmpty(message.Sender.Colour); + private bool senderHasColour => !string.IsNullOrEmpty(message.Sender.Colour); + + [Resolved] + private OsuColour colours { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - customUsernameColour = colours.ChatBlue; - - bool hasBackground = senderHasBackground; + usernameColour = senderHasColour + ? Color4Extensions.FromHex(message.Sender.Colour) + : username_colours[message.Sender.Id % username_colours.Length]; Drawable effectedUsername = username = new OsuSpriteText { Shadow = false, - Colour = hasBackground ? customUsernameColour : username_colours[message.Sender.Id % username_colours.Length], + Colour = senderHasColour ? colours.ChatBlue : usernameColour, Truncate = true, EllipsisString = "… :", Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Bold, italics: true), @@ -99,7 +102,7 @@ namespace osu.Game.Overlays.Chat MaxWidth = MessagePadding - TimestampPadding }; - if (hasBackground) + if (senderHasColour) { // Background effect effectedUsername = new Container @@ -126,7 +129,7 @@ namespace osu.Game.Overlays.Chat new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(message.Sender.Colour), + Colour = usernameColour, }, new Container { @@ -177,7 +180,7 @@ namespace osu.Game.Overlays.Chat { t.Font = OsuFont.GetFont(italics: true); - if (senderHasBackground) + if (senderHasColour) t.Colour = Color4Extensions.FromHex(message.Sender.Colour); } @@ -200,13 +203,37 @@ namespace osu.Game.Overlays.Chat FinishTransforms(true); } + private Container highlight; + + /// + /// Performs a highlight animation on this . + /// + public void Highlight() + { + if (highlight?.IsAlive != true) + { + AddInternal(highlight = new Container + { + CornerRadius = 2f, + Masking = true, + RelativeSizeAxes = Axes.Both, + Colour = usernameColour.Darken(1f), + Depth = float.MaxValue, + Child = new Box { RelativeSizeAxes = Axes.Both } + }); + } + + highlight.FadeTo(0.5f).FadeOut(1500, Easing.InQuint); + highlight.Expire(); + } + private void updateMessageContent() { this.FadeTo(message is LocalEchoMessage ? 0.4f : 1.0f, 500, Easing.OutQuint); timestamp.FadeTo(message is LocalEchoMessage ? 0 : 1, 500, Easing.OutQuint); timestamp.Text = $@"{message.Timestamp.LocalDateTime:HH:mm:ss}"; - username.Text = $@"{message.Sender.Username}" + (senderHasBackground || message.IsAction ? "" : ":"); + username.Text = $@"{message.Sender.Username}" + (senderHasColour || message.IsAction ? "" : ":"); // remove non-existent channels from the link list message.Links.RemoveAll(link => link.Action == LinkAction.OpenChannel && chatManager?.AvailableChannels.Any(c => c.Name == link.Argument.ToString()) != true); diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 6220beeb82..161fe1d5be 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -48,6 +49,8 @@ namespace osu.Game.Overlays.Chat RelativeSizeAxes = Axes.Both; } + private Bindable highlightedMessage; + [BackgroundDependencyLoader] private void load() { @@ -79,6 +82,34 @@ namespace osu.Game.Overlays.Chat Channel.PendingMessageResolved += pendingMessageResolved; } + protected override void LoadComplete() + { + base.LoadComplete(); + + highlightedMessage = Channel.HighlightedMessage.GetBoundCopy(); + highlightedMessage.BindValueChanged(_ => processMessageHighlighting(), true); + } + + /// + /// Processes any pending message in . + /// + // ScheduleAfterChildren is for ensuring the scroll flow has updated with any new chat lines. + private void processMessageHighlighting() => SchedulerAfterChildren.AddOnce(() => + { + if (highlightedMessage.Value == null) + return; + + var chatLine = chatLines.FirstOrDefault(c => c.Message.Equals(highlightedMessage.Value)); + if (chatLine == null) + return; + + float center = scroll.GetChildPosInContent(chatLine, chatLine.DrawSize / 2) - scroll.DisplayableContent / 2; + scroll.ScrollTo(Math.Clamp(center, 0, scroll.ScrollableExtent)); + chatLine.Highlight(); + + highlightedMessage.Value = null; + }); + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -148,6 +179,8 @@ namespace osu.Game.Overlays.Chat // to avoid making the container think the user has scrolled back up and unwantedly disable auto-scrolling. if (newMessages.Any(m => m is LocalMessage)) scroll.ScrollToEnd(); + + processMessageHighlighting(); }); private void pendingMessageResolved(Message existing, Message updated) => Schedule(() => diff --git a/osu.Game/Overlays/Chat/Listing/ChannelListing.cs b/osu.Game/Overlays/Chat/Listing/ChannelListing.cs new file mode 100644 index 0000000000..732c78de15 --- /dev/null +++ b/osu.Game/Overlays/Chat/Listing/ChannelListing.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; + +namespace osu.Game.Overlays.Chat.Listing +{ + public class ChannelListing : VisibilityContainer + { + public event Action? OnRequestJoin; + public event Action? OnRequestLeave; + + public string SearchTerm + { + get => flow.SearchTerm; + set => flow.SearchTerm = value; + } + + private SearchContainer flow = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarAnchor = Anchor.TopRight, + Child = flow = new SearchContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = 13, + Horizontal = 15, + }, + }, + }, + }; + } + + public void UpdateAvailableChannels(IEnumerable newChannels) + { + flow.ChildrenEnumerable = newChannels.Where(c => c.Type == ChannelType.Public) + .Select(c => new ChannelListingItem(c)); + + foreach (var item in flow.Children) + { + item.OnRequestJoin += channel => OnRequestJoin?.Invoke(channel); + item.OnRequestLeave += channel => OnRequestLeave?.Invoke(channel); + } + } + + protected override void PopIn() => this.FadeIn(); + + protected override void PopOut() => this.FadeOut(); + } +} diff --git a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs new file mode 100644 index 0000000000..526cbcda87 --- /dev/null +++ b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Chat; +using osuTK; + +namespace osu.Game.Overlays.Chat.Listing +{ + public class ChannelListingItem : OsuClickableContainer, IFilterable + { + public event Action? OnRequestJoin; + public event Action? OnRequestLeave; + + public bool FilteringActive { get; set; } + public IEnumerable FilterTerms => new[] { channel.Name, channel.Topic ?? string.Empty }; + public bool MatchingFilter { set => this.FadeTo(value ? 1f : 0f, 100); } + + private readonly Channel channel; + + private Box hoverBox = null!; + private SpriteIcon checkbox = null!; + private OsuSpriteText channelText = null!; + private OsuSpriteText topicText = null!; + private IBindable channelJoined = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private const float text_size = 18; + private const float icon_size = 14; + + private const float vertical_margin = 1.5f; + + public ChannelListingItem(Channel channel) + { + this.channel = channel; + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 5; + RelativeSizeAxes = Axes.X; + Height = 20 + (vertical_margin * 2); + + Children = new Drawable[] + { + hoverBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + Margin = new MarginPadding { Vertical = vertical_margin }, + Alpha = 0f, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 40), + new Dimension(GridSizeMode.Absolute, 200), + new Dimension(GridSizeMode.Absolute, 400), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + checkbox = new SpriteIcon + { + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Check, + Size = new Vector2(icon_size), + }, + channelText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = channel.Name, + Font = OsuFont.Torus.With(size: text_size, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Bottom = 2 }, + }, + topicText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = channel.Topic, + Font = OsuFont.Torus.With(size: text_size), + Margin = new MarginPadding { Bottom = 2 }, + }, + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.User, + Size = new Vector2(icon_size), + Margin = new MarginPadding { Right = 5 }, + Colour = colourProvider.Light3, + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = "0", + Font = OsuFont.Torus.With(size: text_size), + Margin = new MarginPadding { Bottom = 2 }, + Colour = colourProvider.Light3, + }, + }, + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + channelJoined = channel.Joined.GetBoundCopy(); + channelJoined.BindValueChanged(change => + { + const double duration = 500; + + if (change.NewValue) + { + checkbox.FadeIn(duration, Easing.OutQuint); + checkbox.ScaleTo(1f, duration, Easing.OutElastic); + channelText.Colour = Colour4.White; + topicText.Colour = Colour4.White; + } + else + { + checkbox.FadeOut(duration, Easing.OutQuint); + checkbox.ScaleTo(0.8f, duration, Easing.OutQuint); + channelText.Colour = colourProvider.Light3; + topicText.Colour = colourProvider.Content2; + } + }, true); + + Action = () => (channelJoined.Value ? OnRequestLeave : OnRequestJoin)?.Invoke(channel); + } + + protected override bool OnHover(HoverEvent e) + { + hoverBox.Show(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverBox.FadeOut(300, Easing.OutQuint); + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index fde9d28b43..3d39c7ce3a 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osuTK; using osuTK.Graphics; @@ -72,6 +73,10 @@ namespace osu.Game.Overlays private Container channelSelectionContainer; protected ChannelSelectionOverlay ChannelSelectionOverlay; + private readonly IBindableList availableChannels = new BindableList(); + private readonly IBindableList joinedChannels = new BindableList(); + private readonly Bindable currentChannel = new Bindable(); + public override bool Contains(Vector2 screenSpacePos) => chatContainer.ReceivePositionalInputAt(screenSpacePos) || (ChannelSelectionOverlay.State.Value == Visibility.Visible && ChannelSelectionOverlay.ReceivePositionalInputAt(screenSpacePos)); @@ -197,9 +202,13 @@ namespace osu.Game.Overlays }, }; + availableChannels.BindTo(channelManager.AvailableChannels); + joinedChannels.BindTo(channelManager.JoinedChannels); + currentChannel.BindTo(channelManager.CurrentChannel); + textBox.OnCommit += postMessage; - ChannelTabControl.Current.ValueChanged += current => channelManager.CurrentChannel.Value = current.NewValue; + ChannelTabControl.Current.ValueChanged += current => currentChannel.Value = current.NewValue; ChannelTabControl.ChannelSelectorActive.ValueChanged += active => ChannelSelectionOverlay.State.Value = active.NewValue ? Visibility.Visible : Visibility.Hidden; ChannelSelectionOverlay.State.ValueChanged += state => { @@ -237,18 +246,12 @@ namespace osu.Game.Overlays Schedule(() => { // TODO: consider scheduling bindable callbacks to not perform when overlay is not present. - channelManager.JoinedChannels.BindCollectionChanged(joinedChannelsChanged, true); - - channelManager.AvailableChannels.CollectionChanged += availableChannelsChanged; - availableChannelsChanged(null, null); - - currentChannel = channelManager.CurrentChannel.GetBoundCopy(); + joinedChannels.BindCollectionChanged(joinedChannelsChanged, true); + availableChannels.BindCollectionChanged(availableChannelsChanged, true); currentChannel.BindValueChanged(currentChannelChanged, true); }); } - private Bindable currentChannel; - private void currentChannelChanged(ValueChangedEvent e) { if (e.NewValue == null) @@ -303,6 +306,26 @@ namespace osu.Game.Overlays channelManager.MarkChannelAsRead(e.NewValue); } + /// + /// Highlights a certain message in the specified channel. + /// + /// The message to highlight. + /// The channel containing the message. + public void HighlightMessage(Message message, Channel channel) + { + Debug.Assert(channel.Id == message.ChannelId); + + if (currentChannel.Value.Id != channel.Id) + { + if (!channel.Joined.Value) + channel = channelManager.JoinChannel(channel); + + currentChannel.Value = channel; + } + + channel.HighlightedMessage.Value = message; + } + private float startDragChatHeight; private bool isDragging; @@ -386,7 +409,7 @@ namespace osu.Game.Overlays return true; case PlatformAction.DocumentClose: - channelManager.LeaveChannel(channelManager.CurrentChannel.Value); + channelManager.LeaveChannel(currentChannel.Value); return true; } @@ -466,19 +489,7 @@ namespace osu.Game.Overlays private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) { - ChannelSelectionOverlay.UpdateAvailableChannels(channelManager.AvailableChannels); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (channelManager != null) - { - channelManager.CurrentChannel.ValueChanged -= currentChannelChanged; - channelManager.JoinedChannels.CollectionChanged -= joinedChannelsChanged; - channelManager.AvailableChannels.CollectionChanged -= availableChannelsChanged; - } + ChannelSelectionOverlay.UpdateAvailableChannels(availableChannels); } private void postMessage(TextBox textBox, bool newText) diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 0f953f92bb..a70a7f26cc 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -219,7 +219,12 @@ namespace osu.Game.Overlays.Dialog /// /// Programmatically clicks the first . /// - public void PerformOkAction() => Buttons.OfType().First().TriggerClick(); + public void PerformOkAction() => PerformAction(); + + /// + /// Programmatically clicks the first button of the provided type. + /// + public void PerformAction() where T : PopupDialogButton => Buttons.OfType().First().TriggerClick(); protected override bool OnKeyDown(KeyDownEvent e) { diff --git a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs new file mode 100644 index 0000000000..1911a4fa56 --- /dev/null +++ b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Dialog +{ + public class PopupDialogDangerousButton : PopupDialogButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + ButtonColour = colours.Red3; + + ColourContainer.Add(new ConfirmFillBox + { + Action = () => Action(), + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + }); + } + + private class ConfirmFillBox : HoldToConfirmContainer + { + private Box box; + + protected override double? HoldActivationDelay => 500; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Child = box = new Box + { + RelativeSizeAxes = Axes.Both, + }; + + Progress.BindValueChanged(progress => box.Width = (float)progress.NewValue, true); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + BeginConfirm(); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (!e.HasAnyButtonPressed) + AbortConfirm(); + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 158d8811b5..0b4eca6379 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -68,7 +68,7 @@ namespace osu.Game.Overlays.Settings.Sections.General Add(new SettingsButton { Text = GeneralSettingsStrings.OpenOsuFolder, - Action = storage.PresentExternally, + Action = () => storage.PresentExternally(), }); Add(new SettingsButton diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 475c4bff8d..a34776ddf0 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -64,7 +64,7 @@ namespace osu.Game.Overlays.Settings.Sections new SettingsButton { Text = SkinSettingsStrings.SkinLayoutEditor, - Action = () => skinEditor?.Toggle(), + Action = () => skinEditor?.ToggleVisibility(), }, new ExportSkinButton(), }; diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index b4178359a4..b9e5283a44 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -162,7 +162,9 @@ namespace osu.Game.Overlays Expanded.BindValueChanged(v => { - content.ClearTransforms(); + // clearing transforms can break autosizing, see: https://github.com/ppy/osu-framework/issues/5064 + if (v.NewValue != v.OldValue) + content.ClearTransforms(); if (v.NewValue) content.AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs index 3d384f5914..1e5dda253f 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs @@ -63,9 +63,8 @@ namespace osu.Game.Rulesets.Difficulty // calculate total score ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); - scoreProcessor.HighestCombo.Value = perfectPlay.MaxCombo; scoreProcessor.Mods.Value = perfectPlay.Mods; - perfectPlay.TotalScore = (long)scoreProcessor.GetImmediateScore(ScoringMode.Standardised, perfectPlay.MaxCombo, statistics); + perfectPlay.TotalScore = (long)scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, perfectPlay); // compute rank achieved // default to SS, then adjust the rank with mods @@ -85,7 +84,7 @@ namespace osu.Game.Rulesets.Difficulty ).ConfigureAwait(false); // ScorePerformanceCache is not used to avoid caching multiple copies of essentially identical perfect performance attributes - return difficulty == null ? null : ruleset.CreatePerformanceCalculator(difficulty.Value.Attributes, perfectPlay)?.Calculate(); + return difficulty == null ? null : ruleset.CreatePerformanceCalculator()?.Calculate(perfectPlay, difficulty.Value.Attributes); }, cancellationToken); } diff --git a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs index 6358ec18b7..4c55249661 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs @@ -1,41 +1,31 @@ // 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 System.Linq; -using osu.Framework.Audio.Track; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Game.Rulesets.Mods; +using osu.Game.Beatmaps; using osu.Game.Scoring; namespace osu.Game.Rulesets.Difficulty { public abstract class PerformanceCalculator { - protected readonly DifficultyAttributes Attributes; - protected readonly Ruleset Ruleset; - protected readonly ScoreInfo Score; - protected double TimeRate { get; private set; } = 1; - - protected PerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) + protected PerformanceCalculator(Ruleset ruleset) { Ruleset = ruleset; - Score = score; - - Attributes = attributes ?? throw new ArgumentNullException(nameof(attributes)); - - ApplyMods(score.Mods); } - protected virtual void ApplyMods(Mod[] mods) - { - var track = new TrackVirtual(10000); - mods.OfType().ForEach(m => m.ApplyToTrack(track)); - TimeRate = track.Rate; - } + public PerformanceAttributes Calculate(ScoreInfo score, DifficultyAttributes attributes) + => CreatePerformanceAttributes(score, attributes); - public abstract PerformanceAttributes Calculate(); + public PerformanceAttributes Calculate(ScoreInfo score, IWorkingBeatmap beatmap) + => Calculate(score, Ruleset.CreateDifficultyCalculator(beatmap).Calculate(score.Mods)); + + /// + /// Creates to describe a score's performance. + /// + /// The score to create the attributes for. + /// The difficulty attributes for the beatmap relating to the score. + protected abstract PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes); } } diff --git a/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs b/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs index 9998a997b3..98e026c49a 100644 --- a/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs +++ b/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs @@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Edit { protected readonly OsuScrollContainer Scroll; + protected readonly FillFlowContainer FillFlow; + protected override Container Content { get; } public ScrollingToolboxGroup(string title, float scrollAreaHeight) @@ -20,7 +22,7 @@ namespace osu.Game.Rulesets.Edit { RelativeSizeAxes = Axes.X, Height = scrollAreaHeight, - Child = Content = new FillFlowContainer + Child = Content = FillFlow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 7136795461..b2d4be54ce 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -192,7 +192,7 @@ namespace osu.Game.Rulesets.Mods hashCode.Add(GetType()); foreach (var setting in Settings) - hashCode.Add(ModUtils.GetSettingUnderlyingValue(setting)); + hashCode.Add(setting.GetUnderlyingSettingValue()); return hashCode.ToHashCode(); } @@ -208,13 +208,13 @@ namespace osu.Game.Rulesets.Mods public bool Equals(IBindable x, IBindable y) { - object xValue = x == null ? null : ModUtils.GetSettingUnderlyingValue(x); - object yValue = y == null ? null : ModUtils.GetSettingUnderlyingValue(y); + object xValue = x?.GetUnderlyingSettingValue(); + object yValue = y?.GetUnderlyingSettingValue(); return EqualityComparer.Default.Equals(xValue, yValue); } - public int GetHashCode(IBindable obj) => ModUtils.GetSettingUnderlyingValue(obj).GetHashCode(); + public int GetHashCode(IBindable obj) => obj.GetUnderlyingSettingValue().GetHashCode(); } } } diff --git a/osu.Game/Rulesets/Mods/UnknownMod.cs b/osu.Game/Rulesets/Mods/UnknownMod.cs new file mode 100644 index 0000000000..b426386d7a --- /dev/null +++ b/osu.Game/Rulesets/Mods/UnknownMod.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Mods +{ + public class UnknownMod : Mod + { + /// + /// The acronym of the mod which could not be resolved. + /// + public readonly string OriginalAcronym; + + public override string Name => $"Unknown mod ({OriginalAcronym})"; + public override string Acronym => $"{OriginalAcronym}??"; + public override string Description => "This mod could not be resolved by the game."; + public override double ScoreMultiplier => 0; + + public override bool UserPlayable => false; + + public override ModType Type => ModType.System; + + public UnknownMod(string acronym) + { + OriginalAcronym = acronym; + } + + public override Mod DeepClone() => new UnknownMod(OriginalAcronym); + } +} diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index c590cc302f..57b897e5b5 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading; using JetBrains.Annotations; using Newtonsoft.Json; @@ -67,6 +68,12 @@ namespace osu.Game.Rulesets.Objects } } + /// + /// Any samples which may be used by this hit object that are non-standard. + /// This is used only to preload these samples ahead of time. + /// + public virtual IList AuxiliarySamples => ImmutableList.Empty; + public SampleControlPoint SampleControlPoint = SampleControlPoint.DEFAULT; public DifficultyControlPoint DifficultyControlPoint = DifficultyControlPoint.DEFAULT; diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index b091803406..2a7f2b037f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -500,6 +500,9 @@ namespace osu.Game.Rulesets.Objects.Legacy => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered)); public bool Equals(LegacyHitSampleInfo? other) + // The additions to equality checks here are *required* to ensure that pooling works correctly. + // Of note, `IsLayered` may cause the usage of `SampleVirtual` instead of an actual sample (in cases playback is not required). + // Removing it would cause samples which may actually require playback to potentially source for a `SampleVirtual` sample pool. => base.Equals(other) && CustomSampleBank == other.CustomSampleBank && IsLayered == other.IsLayered; public override bool Equals(object? obj) diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 616540b59c..b7c6132bdb 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -201,7 +201,7 @@ namespace osu.Game.Rulesets /// Creates a for this . /// /// The score processor. - public virtual ScoreProcessor CreateScoreProcessor() => new ScoreProcessor(); + public virtual ScoreProcessor CreateScoreProcessor() => new ScoreProcessor(this); /// /// Creates a for this . @@ -228,25 +228,9 @@ namespace osu.Game.Rulesets /// /// Optionally creates a to generate performance data from the provided score. /// - /// Difficulty attributes for the beatmap related to the provided score. - /// The score to be processed. /// A performance calculator instance for the provided score. [CanBeNull] - public virtual PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => null; - - /// - /// Optionally creates a to generate performance data from the provided score. - /// - /// The beatmap to use as a source for generating . - /// The score to be processed. - /// A performance calculator instance for the provided score. - [CanBeNull] - public PerformanceCalculator CreatePerformanceCalculator(IWorkingBeatmap beatmap, ScoreInfo score) - { - var difficultyCalculator = CreateDifficultyCalculator(beatmap); - var difficultyAttributes = difficultyCalculator.Calculate(score.Mods); - return CreatePerformanceCalculator(difficultyAttributes, score); - } + public virtual PerformanceCalculator CreatePerformanceCalculator() => null; public virtual HitObjectComposer CreateHitObjectComposer() => null; diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index 1535fe4d00..a92c30e593 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using osu.Framework.Bindables; using osu.Framework.Utils; @@ -14,12 +16,12 @@ namespace osu.Game.Rulesets.Scoring /// Invoked when the is in a failed state. /// Return true if the fail was permitted. /// - public event Func Failed; + public event Func? Failed; /// /// Additional conditions on top of that cause a failing state. /// - public event Func FailConditions; + public event Func? FailConditions; /// /// The current health. diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 514232db69..9f03c381ee 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -122,7 +122,19 @@ namespace osu.Game.Rulesets.Scoring public static class HitResultExtensions { /// - /// Whether a increases/decreases the combo, and affects the combo portion of the score. + /// Whether a increases the combo. + /// + public static bool IncreasesCombo(this HitResult result) + => AffectsCombo(result) && IsHit(result); + + /// + /// Whether a breaks the combo and resets it back to zero. + /// + public static bool BreaksCombo(this HitResult result) + => AffectsCombo(result) && !IsHit(result); + + /// + /// Whether a increases/breaks the combo, and affects the combo portion of the score. /// public static bool AffectsCombo(this HitResult result) { diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index a643c31daa..94ddc32bb7 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Game.Beatmaps; @@ -17,12 +20,12 @@ namespace osu.Game.Rulesets.Scoring /// /// Invoked when a new judgement has occurred. This occurs after the judgement has been processed by this . /// - public event Action NewJudgement; + public event Action? NewJudgement; /// /// Invoked when a judgement is reverted, usually due to rewinding gameplay. /// - public event Action JudgementReverted; + public event Action? JudgementReverted; /// /// The maximum number of hits that can be judged. @@ -34,7 +37,7 @@ namespace osu.Game.Rulesets.Scoring /// public int JudgedHits { get; private set; } - private JudgementResult lastAppliedResult; + private JudgementResult? lastAppliedResult; private readonly BindableBool hasCompleted = new BindableBool(); @@ -163,7 +166,12 @@ namespace osu.Game.Rulesets.Scoring protected override void Update() { base.Update(); - hasCompleted.Value = JudgedHits == MaxHits && (JudgedHits == 0 || lastAppliedResult.TimeAbsolute < Clock.CurrentTime); + + hasCompleted.Value = + JudgedHits == MaxHits + && (JudgedHits == 0 + // Last applied result is guaranteed to be non-null when JudgedHits > 0. + || lastAppliedResult.AsNonNull().TimeAbsolute < Clock.CurrentTime); } /// diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index d5a5aa4592..1e268bb2eb 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Utils; +using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; @@ -23,7 +26,7 @@ namespace osu.Game.Rulesets.Scoring /// /// Invoked when this was reset from a replay frame. /// - public event Action OnResetFromReplayFrame; + public event Action? OnResetFromReplayFrame; /// /// The current total score. @@ -76,6 +79,12 @@ namespace osu.Game.Rulesets.Scoring /// protected virtual double DefaultComboPortion => 0.7; + /// + /// An arbitrary multiplier to scale scores in the scoring mode. + /// + protected virtual double ClassicScoreMultiplier => 36; + + private readonly Ruleset ruleset; private readonly double accuracyPortion; private readonly double comboPortion; @@ -86,16 +95,32 @@ namespace osu.Game.Rulesets.Scoring /// private double maxBaseScore; + /// + /// The maximum number of basic (non-tick and non-bonus) hitobjects. + /// + private int maxBasicHitObjects; + + /// + /// The maximum of a basic (non-tick and non-bonus) hitobject. + /// Only populated via or . + /// + private HitResult? maxBasicResult; + private double rollingMaxBaseScore; private double baseScore; + private int basicHitObjects; + private bool beatmapApplied; + private readonly Dictionary scoreResultCounts = new Dictionary(); private readonly List hitEvents = new List(); - private HitObject lastHitObject; + private HitObject? lastHitObject; private double scoreMultiplier = 1; - public ScoreProcessor() + public ScoreProcessor(Ruleset ruleset) { + this.ruleset = ruleset; + accuracyPortion = DefaultAccuracyPortion; comboPortion = DefaultComboPortion; @@ -122,7 +147,11 @@ namespace osu.Game.Rulesets.Scoring }; } - private readonly Dictionary scoreResultCounts = new Dictionary(); + public override void ApplyBeatmap(IBeatmap beatmap) + { + base.ApplyBeatmap(beatmap); + beatmapApplied = true; + } protected sealed override void ApplyResultInternal(JudgementResult result) { @@ -137,20 +166,10 @@ namespace osu.Game.Rulesets.Scoring if (!result.Type.IsScorable()) return; - if (result.Type.AffectsCombo()) - { - switch (result.Type) - { - case HitResult.Miss: - case HitResult.LargeTickMiss: - Combo.Value = 0; - break; - - default: - Combo.Value++; - break; - } - } + if (result.Type.IncreasesCombo()) + Combo.Value++; + else if (result.Type.BreaksCombo()) + Combo.Value = 0; double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; @@ -160,6 +179,9 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore += result.Judgement.MaxNumericResult; } + if (result.Type.IsBasic()) + basicHitObjects++; + hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; @@ -195,6 +217,9 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore -= result.Judgement.MaxNumericResult; } + if (result.Type.IsBasic()) + basicHitObjects--; + Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; hitEvents.RemoveAt(hitEvents.Count - 1); @@ -204,29 +229,122 @@ namespace osu.Game.Rulesets.Scoring private void updateScore() { - if (rollingMaxBaseScore != 0) - Accuracy.Value = calculateAccuracyRatio(baseScore, true); + double rollingAccuracyRatio = rollingMaxBaseScore > 0 ? baseScore / rollingMaxBaseScore : 1; + double accuracyRatio = maxBaseScore > 0 ? baseScore / maxBaseScore : 1; + double comboRatio = maxAchievableCombo > 0 ? (double)HighestCombo.Value / maxAchievableCombo : 1; - TotalScore.Value = getScore(Mode.Value); - } - - private double getScore(ScoringMode mode) - { - return GetScore(mode, - calculateAccuracyRatio(baseScore), - calculateComboRatio(HighestCombo.Value), - scoreResultCounts); + Accuracy.Value = rollingAccuracyRatio; + TotalScore.Value = ComputeScore(Mode.Value, accuracyRatio, comboRatio, getBonusScore(scoreResultCounts), maxBasicHitObjects); } /// - /// Computes the total score. + /// Computes the total score of a given finalised . This should be used when a score is known to be complete. /// - /// The to compute the total score in. + /// + /// Does not require to have been called before use. + /// + /// The to represent the score as. + /// The to compute the total score of. + /// The total score in the given . + public double ComputeFinalScore(ScoringMode mode, ScoreInfo scoreInfo) + { + if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) + throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); + + extractFromStatistics(ruleset, + scoreInfo.Statistics, + out double extractedBaseScore, + out double extractedMaxBaseScore, + out int extractedMaxCombo, + out int extractedBasicHitObjects); + + double accuracyRatio = extractedMaxBaseScore > 0 ? extractedBaseScore / extractedMaxBaseScore : 1; + double comboRatio = extractedMaxCombo > 0 ? (double)scoreInfo.MaxCombo / extractedMaxCombo : 1; + + return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), extractedBasicHitObjects); + } + + /// + /// Computes the total score of a partially-completed . This should be used when it is unknown whether a score is complete. + /// + /// + /// Requires to have been called before use. + /// + /// The to represent the score as. + /// The to compute the total score of. + /// The total score in the given . + public double ComputePartialScore(ScoringMode mode, ScoreInfo scoreInfo) + { + if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) + throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); + + if (!beatmapApplied) + throw new InvalidOperationException($"Cannot compute partial score without calling {nameof(ApplyBeatmap)}."); + + extractFromStatistics(ruleset, + scoreInfo.Statistics, + out double extractedBaseScore, + out _, + out _, + out _); + + double accuracyRatio = maxBaseScore > 0 ? extractedBaseScore / maxBaseScore : 1; + double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; + + return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), maxBasicHitObjects); + } + + /// + /// Computes the total score of a given with a given custom max achievable combo. + /// + /// + /// This is useful for processing legacy scores in which the maximum achievable combo can be more accurately determined via external means (e.g. database values or difficulty calculation). + ///

Does not require to have been called before use.

+ ///
+ /// The to represent the score as. + /// The to compute the total score of. + /// The maximum achievable combo for the provided beatmap. + /// The total score in the given . + public double ComputeFinalLegacyScore(ScoringMode mode, ScoreInfo scoreInfo, int maxAchievableCombo) + { + if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) + throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); + + double accuracyRatio = scoreInfo.Accuracy; + double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; + + // For legacy osu!mania scores, a full-GREAT score has 100% accuracy. If combined with a full-combo, the score becomes indistinguishable from a full-PERFECT score. + // To get around this, the accuracy ratio is always recalculated based on the hit statistics rather than trusting the score. + // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. + if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3) + { + extractFromStatistics( + ruleset, + scoreInfo.Statistics, + out double computedBaseScore, + out double computedMaxBaseScore, + out _, + out _); + + if (computedMaxBaseScore > 0) + accuracyRatio = computedBaseScore / computedMaxBaseScore; + } + + int computedBasicHitObjects = scoreInfo.Statistics.Where(kvp => kvp.Key.IsBasic()).Select(kvp => kvp.Value).Sum(); + + return ComputeScore(mode, accuracyRatio, comboRatio, getBonusScore(scoreInfo.Statistics), computedBasicHitObjects); + } + + /// + /// Computes the total score from individual scoring components. + /// + /// The to represent the score as. /// The accuracy percentage achieved by the player. - /// The proportion of the max combo achieved by the player. - /// Any statistics to be factored in. - /// The total score. - public double GetScore(ScoringMode mode, double accuracyRatio, double comboRatio, Dictionary statistics) + /// The portion of the max combo achieved by the player. + /// The total bonus score. + /// The total number of basic (non-tick and non-bonus) hitobjects in the beatmap. + /// The total score computed from the given scoring component ratios. + public double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects) { switch (mode) { @@ -234,62 +352,22 @@ namespace osu.Game.Rulesets.Scoring case ScoringMode.Standardised: double accuracyScore = accuracyPortion * accuracyRatio; double comboScore = comboPortion * comboRatio; - return (max_score * (accuracyScore + comboScore) + getBonusScore(statistics)) * scoreMultiplier; + return (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; case ScoringMode.Classic: - int totalHitObjects = statistics.Where(k => k.Key >= HitResult.Miss && k.Key <= HitResult.Perfect).Sum(k => k.Value); - - // If there are no hitobjects then the beatmap can be composed of only ticks or spinners, so ensure we don't multiply by 0 at all times. - if (totalHitObjects == 0) - totalHitObjects = 1; - // This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring. // The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes. - double scaledStandardised = GetScore(ScoringMode.Standardised, accuracyRatio, comboRatio, statistics) / max_score; - return Math.Pow(scaledStandardised * totalHitObjects, 2) * 36; + double scaledStandardised = ComputeScore(ScoringMode.Standardised, accuracyRatio, comboRatio, bonusScore, totalBasicHitObjects) / max_score; + return Math.Pow(scaledStandardised * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier; } } /// - /// Given a minimal set of inputs, return the computed score for the tracked beatmap / mods combination, at the current point in time. + /// Calculates the total bonus score from score statistics. /// - /// The to compute the total score in. - /// The maximum combo achievable in the beatmap. - /// Statistics to be used for calculating accuracy, bonus score, etc. - /// The computed score for provided inputs. - public double GetImmediateScore(ScoringMode mode, int maxCombo, Dictionary statistics) - { - // calculate base score from statistics pairs - int computedBaseScore = 0; - - foreach (var pair in statistics) - { - if (!pair.Key.AffectsAccuracy()) - continue; - - computedBaseScore += Judgement.ToNumericResult(pair.Key) * pair.Value; - } - - return GetScore(mode, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), statistics); - } - - /// - /// Get the accuracy fraction for the provided base score. - /// - /// The score to be used for accuracy calculation. - /// Whether the rolling base score should be used (ie. for the current point in time based on Apply/Reverted results). - /// The computed accuracy. - private double calculateAccuracyRatio(double baseScore, bool preferRolling = false) - { - if (preferRolling && rollingMaxBaseScore != 0) - return baseScore / rollingMaxBaseScore; - - return maxBaseScore > 0 ? baseScore / maxBaseScore : 1; - } - - private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1; - - private double getBonusScore(Dictionary statistics) + /// The score statistics. + /// The total bonus score. + private double getBonusScore(IReadOnlyDictionary statistics) => statistics.GetValueOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE + statistics.GetValueOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; @@ -311,8 +389,6 @@ namespace osu.Game.Rulesets.Scoring public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result); - public double GetStandardisedScore() => getScore(ScoringMode.Standardised); - /// /// Resets this ScoreProcessor to a default state. /// @@ -329,10 +405,12 @@ namespace osu.Game.Rulesets.Scoring { maxAchievableCombo = HighestCombo.Value; maxBaseScore = baseScore; + maxBasicHitObjects = basicHitObjects; } baseScore = 0; rollingMaxBaseScore = 0; + basicHitObjects = 0; TotalScore.Value = 0; Accuracy.Value = 1; @@ -346,23 +424,19 @@ namespace osu.Game.Rulesets.Scoring ///
public virtual void PopulateScore(ScoreInfo score) { - score.TotalScore = (long)Math.Round(GetStandardisedScore()); score.Combo = Combo.Value; score.MaxCombo = HighestCombo.Value; score.Accuracy = Accuracy.Value; score.Rank = Rank.Value; + score.HitEvents = hitEvents; foreach (var result in HitResultExtensions.ALL_TYPES) score.Statistics[result] = GetStatistic(result); - score.HitEvents = hitEvents; + // Populate total score after everything else. + score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score)); } - /// - /// Maximum for a normal hit (i.e. not tick/bonus) for this ruleset. Only populated via . - /// - private HitResult? maxNormalResult; - public override void ResetFromReplayFrame(Ruleset ruleset, ReplayFrame frame) { base.ResetFromReplayFrame(ruleset, frame); @@ -370,11 +444,26 @@ namespace osu.Game.Rulesets.Scoring if (frame.Header == null) return; - baseScore = 0; - rollingMaxBaseScore = 0; + extractFromStatistics(ruleset, frame.Header.Statistics, out baseScore, out rollingMaxBaseScore, out _, out _); HighestCombo.Value = frame.Header.MaxCombo; - foreach ((HitResult result, int count) in frame.Header.Statistics) + scoreResultCounts.Clear(); + scoreResultCounts.AddRange(frame.Header.Statistics); + + updateScore(); + + OnResetFromReplayFrame?.Invoke(); + } + + private void extractFromStatistics(Ruleset ruleset, IReadOnlyDictionary statistics, out double baseScore, out double maxBaseScore, out int maxCombo, + out int basicHitObjects) + { + baseScore = 0; + maxBaseScore = 0; + maxCombo = 0; + basicHitObjects = 0; + + foreach ((HitResult result, int count) in statistics) { // Bonus scores are counted separately directly from the statistics dictionary later on. if (!result.IsScorable() || result.IsBonus()) @@ -397,20 +486,19 @@ namespace osu.Game.Rulesets.Scoring break; default: - maxResult = maxNormalResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result; + maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result; break; } baseScore += count * Judgement.ToNumericResult(result); - rollingMaxBaseScore += count * Judgement.ToNumericResult(maxResult); + maxBaseScore += count * Judgement.ToNumericResult(maxResult); + + if (result.AffectsCombo()) + maxCombo += count; + + if (result.IsBasic()) + basicHitObjects += count; } - - scoreResultCounts.Clear(); - scoreResultCounts.AddRange(frame.Header.Statistics); - - updateScore(); - - OnResetFromReplayFrame?.Invoke(); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 30e71dde1c..ea5ffb10c6 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -3,22 +3,22 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; -using osu.Framework.Graphics; -using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; using osu.Game.Audio; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; using osuTK; -using System.Diagnostics; namespace osu.Game.Rulesets.UI { @@ -264,10 +264,25 @@ namespace osu.Game.Rulesets.UI var entry = CreateLifetimeEntry(hitObject); lifetimeEntryMap[entry.HitObject] = entry; + preloadSamples(hitObject); + HitObjectContainer.Add(entry); OnHitObjectAdded(entry.HitObject); } + private void preloadSamples(HitObject hitObject) + { + // prepare sample pools ahead of time so we're not initialising at runtime. + foreach (var sample in hitObject.Samples) + prepareSamplePool(hitObject.SampleControlPoint.ApplyTo(sample)); + + foreach (var sample in hitObject.AuxiliarySamples) + prepareSamplePool(hitObject.SampleControlPoint.ApplyTo(sample)); + + foreach (var nestedObject in hitObject.NestedHitObjects) + preloadSamples(nestedObject); + } + /// /// Removes a for a pooled from this . /// @@ -308,7 +323,7 @@ namespace osu.Game.Rulesets.UI /// /// The type. /// The receiver for s. - protected void RegisterPool(int initialSize, int? maximumSize = null) + public void RegisterPool(int initialSize, int? maximumSize = null) where TObject : HitObject where TDrawable : DrawableHitObject, new() => RegisterPool(new DrawablePool(initialSize, maximumSize)); @@ -330,22 +345,7 @@ namespace osu.Game.Rulesets.UI DrawableHitObject IPooledHitObjectProvider.GetPooledDrawableRepresentation(HitObject hitObject, DrawableHitObject parent) { - var lookupType = hitObject.GetType(); - - IDrawablePool pool; - - // Tests may add derived hitobject instances for which pools don't exist. Try to find any applicable pool and dynamically assign the type if the pool exists. - if (!pools.TryGetValue(lookupType, out pool)) - { - foreach (var (t, p) in pools) - { - if (!t.IsInstanceOfType(hitObject)) - continue; - - pools[lookupType] = pool = p; - break; - } - } + var pool = prepareDrawableHitObjectPool(hitObject); return (DrawableHitObject)pool?.Get(d => { @@ -372,14 +372,39 @@ namespace osu.Game.Rulesets.UI }); } + private IDrawablePool prepareDrawableHitObjectPool(HitObject hitObject) + { + var lookupType = hitObject.GetType(); + + IDrawablePool pool; + + // Tests may add derived hitobject instances for which pools don't exist. Try to find any applicable pool and dynamically assign the type if the pool exists. + if (!pools.TryGetValue(lookupType, out pool)) + { + foreach (var (t, p) in pools) + { + if (!t.IsInstanceOfType(hitObject)) + continue; + + pools[lookupType] = pool = p; + break; + } + } + + return pool; + } + private readonly Dictionary> samplePools = new Dictionary>(); - public PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo) - { - if (!samplePools.TryGetValue(sampleInfo, out var existingPool)) - AddInternal(samplePools[sampleInfo] = existingPool = new DrawableSamplePool(sampleInfo, 1)); + public PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo) => prepareSamplePool(sampleInfo).Get(); - return existingPool.Get(); + private DrawablePool prepareSamplePool(ISampleInfo sampleInfo) + { + if (samplePools.TryGetValue(sampleInfo, out var pool)) return pool; + + AddInternal(samplePools[sampleInfo] = pool = new DrawableSamplePool(sampleInfo, 1)); + + return pool; } private class DrawableSamplePool : DrawablePool diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index fefee370b9..754ace82c5 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -23,6 +23,8 @@ namespace osu.Game.Scoring.Legacy private IBeatmap currentBeatmap; private Ruleset currentRuleset; + private float beatmapOffset; + public Score Parse(Stream stream) { var score = new Score @@ -72,6 +74,9 @@ namespace osu.Game.Scoring.Legacy currentBeatmap = workingBeatmap.GetPlayableBeatmap(currentRuleset.RulesetInfo, scoreInfo.Mods); scoreInfo.BeatmapInfo = currentBeatmap.BeatmapInfo; + // As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing. + beatmapOffset = currentBeatmap.BeatmapInfo.BeatmapVersion < 5 ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; + /* score.HpGraphString = */ sr.ReadString(); @@ -229,7 +234,7 @@ namespace osu.Game.Scoring.Legacy private void readLegacyReplay(Replay replay, StreamReader reader) { - float lastTime = 0; + float lastTime = beatmapOffset; ReplayFrame currentFrame = null; string[] frames = reader.ReadToEnd().Split(','); diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index f0ead05280..ae9afbf32e 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.IO; using System.Linq; using System.Text; using osu.Framework.Extensions; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; using osu.Game.IO.Legacy; using osu.Game.Replays.Legacy; @@ -14,8 +17,6 @@ using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; using SharpCompress.Compressors.LZMA; -#nullable enable - namespace osu.Game.Scoring.Legacy { public class LegacyScoreEncoder @@ -111,6 +112,9 @@ namespace osu.Game.Scoring.Legacy { StringBuilder replayData = new StringBuilder(); + // As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing. + double offset = beatmap?.BeatmapInfo.BeatmapVersion < 5 ? -LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; + if (score.Replay != null) { int lastTime = 0; @@ -120,7 +124,7 @@ namespace osu.Game.Scoring.Legacy var legacyFrame = getLegacyFrame(f); // Rounding because stable could only parse integral values - int time = (int)Math.Round(legacyFrame.Time); + int time = (int)Math.Round(legacyFrame.Time + offset); replayData.Append(FormattableString.Invariant($"{time - lastTime}|{legacyFrame.MouseX ?? 0}|{legacyFrame.MouseY ?? 0}|{(int)legacyFrame.ButtonState},")); lastTime = time; } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 4de1d580dc..d7185a1677 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -157,7 +157,7 @@ namespace osu.Game.Scoring public LocalisableString DisplayAccuracy => Accuracy.FormatAccuracy(); /// - /// Whether this represents a legacy (osu!stable) score. + /// Whether this represents a legacy (osu!stable) score. /// [Ignored] public bool IsLegacyScore => Mods.OfType().Any(); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 87345a06fb..83359838aa 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -18,7 +18,6 @@ using osu.Game.Database; using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; -using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring @@ -135,47 +134,9 @@ namespace osu.Game.Scoring if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash)) return score.TotalScore; - int beatmapMaxCombo; - double accuracy = score.Accuracy; - - if (score.IsLegacyScore) - { - if (score.RulesetID == 3) - { - // In osu!stable, a full-GREAT score has 100% accuracy in mania. Along with a full combo, the score becomes indistinguishable from a full-PERFECT score. - // To get around this, recalculate accuracy based on the hit statistics. - // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. - double maxBaseScore = score.Statistics.Select(kvp => kvp.Value).Sum() * Judgement.ToNumericResult(HitResult.Perfect); - double baseScore = score.Statistics.Select(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value).Sum(); - if (maxBaseScore > 0) - accuracy = baseScore / maxBaseScore; - } - - // This score is guaranteed to be an osu!stable score. - // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. - if (score.BeatmapInfo.MaxCombo != null) - beatmapMaxCombo = score.BeatmapInfo.MaxCombo.Value; - else - { - if (difficulties == null) - return score.TotalScore; - - // We can compute the max combo locally after the async beatmap difficulty computation. - var difficulty = await difficulties().GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); - - // Something failed during difficulty calculation. Fall back to provided score. - if (difficulty == null) - return score.TotalScore; - - beatmapMaxCombo = difficulty.Value.MaxCombo; - } - } - else - { - // This is guaranteed to be a non-legacy score. - // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. - beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); - } + int? beatmapMaxCombo = await GetMaximumAchievableComboAsync(score, cancellationToken).ConfigureAwait(false); + if (beatmapMaxCombo == null) + return score.TotalScore; if (beatmapMaxCombo == 0) return 0; @@ -184,7 +145,37 @@ namespace osu.Game.Scoring var scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = score.Mods; - return (long)Math.Round(scoreProcessor.GetScore(mode, accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); + return (long)Math.Round(scoreProcessor.ComputeFinalLegacyScore(mode, score, beatmapMaxCombo.Value)); + } + + /// + /// Retrieves the maximum achievable combo for the provided score. + /// + /// The to compute the maximum achievable combo for. + /// A to cancel the process. + /// The maximum achievable combo. A return value indicates the difficulty cache has failed to retrieve the combo. + public async Task GetMaximumAchievableComboAsync([NotNull] ScoreInfo score, CancellationToken cancellationToken = default) + { + if (score.IsLegacyScore) + { + // This score is guaranteed to be an osu!stable score. + // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. +#pragma warning disable CS0618 + if (score.BeatmapInfo.MaxCombo != null) + return score.BeatmapInfo.MaxCombo.Value; +#pragma warning restore CS0618 + + if (difficulties == null) + return null; + + // We can compute the max combo locally after the async beatmap difficulty computation. + var difficulty = await difficulties().GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); + return difficulty?.MaxCombo; + } + + // This is guaranteed to be a non-legacy score. + // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. + return Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); } /// diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs index a428a66aae..e15d59e648 100644 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -43,9 +43,7 @@ namespace osu.Game.Scoring token.ThrowIfCancellationRequested(); - var calculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(attributes.Value.Attributes, score); - - return calculator?.Calculate(); + return score.Ruleset.CreateInstance().CreatePerformanceCalculator()?.Calculate(score, attributes.Value.Attributes); } public readonly struct PerformanceCacheLookup diff --git a/osu.Game/Screens/Edit/Components/EditorSidebar.cs b/osu.Game/Screens/Edit/Components/EditorSidebar.cs new file mode 100644 index 0000000000..4edcef41b1 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/EditorSidebar.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit.Components +{ + /// + /// A sidebar area that can be attached to the left or right edge of the screen. + /// Houses scrolling sectionised content. + /// + internal class EditorSidebar : Container + { + private readonly Box background; + + protected override Container Content { get; } + + public EditorSidebar() + { + Width = 250; + RelativeSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new OsuScrollContainer + { + Padding = new MarginPadding { Left = 20 }, + ScrollbarOverlapsContent = false, + RelativeSizeAxes = Axes.Both, + Child = Content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background5; + } + } +} diff --git a/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs b/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs new file mode 100644 index 0000000000..3871720562 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Components +{ + public class EditorSidebarSection : Container + { + protected override Container Content { get; } + + public EditorSidebarSection(LocalisableString sectionName) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new SectionHeader(sectionName), + Content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + } + }; + } + + public class SectionHeader : CompositeDrawable + { + private readonly LocalisableString text; + + public SectionHeader(LocalisableString text) + { + this.text = text; + + Margin = new MarginPadding { Vertical = 10, Horizontal = 5 }; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Children = new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold)) + { + Text = text, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + new Circle + { + Colour = colourProvider.Highlight1, + Size = new Vector2(28, 2), + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 130d7a015f..6dc6f20cfe 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -73,6 +73,7 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionHandler = CreateSelectionHandler(); SelectionHandler.DeselectAll = deselectAll; + SelectionHandler.SelectedItems.BindTo(SelectedItems); AddRangeInternal(new[] { diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 5f6e8de557..7f693996a4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -29,11 +29,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // bring in updates from selection changes EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); - SelectedItems.BindTo(EditorBeatmap.SelectedHitObjects); - SelectedItems.CollectionChanged += (sender, args) => - { - Scheduler.AddOnce(UpdateTernaryStates); - }; + SelectedItems.CollectionChanged += (sender, args) => Scheduler.AddOnce(UpdateTernaryStates); } protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index dcb7e3a282..57f7429e06 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -97,7 +97,7 @@ namespace osu.Game.Screens.Edit private bool canSave; - private bool exitConfirmed; + protected bool ExitConfirmed { get; private set; } private string lastSavedHash; @@ -586,7 +586,7 @@ namespace osu.Game.Screens.Edit public override bool OnExiting(IScreen next) { - if (!exitConfirmed) + if (!ExitConfirmed) { // dialog overlay may not be available in visual tests. if (dialogOverlay == null) @@ -595,12 +595,9 @@ namespace osu.Game.Screens.Edit return true; } - // if the dialog is already displayed, confirm exit with no save. - if (dialogOverlay.CurrentDialog is PromptForSaveDialog saveDialog) - { - saveDialog.PerformOkAction(); + // if the dialog is already displayed, block exiting until the user explicitly makes a decision. + if (dialogOverlay.CurrentDialog is PromptForSaveDialog) return true; - } if (isNewBeatmap || HasUnsavedChanges) { @@ -645,7 +642,7 @@ namespace osu.Game.Screens.Edit { Save(); - exitConfirmed = true; + ExitConfirmed = true; this.Exit(); } @@ -668,7 +665,7 @@ namespace osu.Game.Screens.Edit Beatmap.SetDefault(); } - exitConfirmed = true; + ExitConfirmed = true; this.Exit(); } diff --git a/osu.Game/Screens/Edit/PromptForSaveDialog.cs b/osu.Game/Screens/Edit/PromptForSaveDialog.cs index e308a9533d..4f70491ade 100644 --- a/osu.Game/Screens/Edit/PromptForSaveDialog.cs +++ b/osu.Game/Screens/Edit/PromptForSaveDialog.cs @@ -17,12 +17,12 @@ namespace osu.Game.Screens.Edit Buttons = new PopupDialogButton[] { - new PopupDialogCancelButton + new PopupDialogOkButton { Text = @"Save my masterpiece!", Action = saveAndExit }, - new PopupDialogOkButton + new PopupDialogDangerousButton { Text = @"Forget all changes", Action = exit diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index afe75c5ef7..a6b54dd1f2 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -13,14 +13,17 @@ using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.IO.Archives; +using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; +using osu.Game.Skinning; using osuTK; using osuTK.Graphics; using Realms; @@ -55,7 +58,8 @@ namespace osu.Game.Screens.Menu private const int exit_delay = 3000; - private Sample seeya; + private SkinnableSound skinnableSeeya; + private ISample seeya; protected virtual string SeeyaSampleName => "Intro/seeya"; @@ -90,14 +94,18 @@ namespace osu.Game.Screens.Menu private BeatmapManager beatmaps { get; set; } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, Framework.Game game, RealmAccess realm) + private void load(OsuConfigManager config, Framework.Game game, RealmAccess realm, IAPIProvider api) { // prevent user from changing beatmap while the intro is still running. beatmap = Beatmap.BeginLease(false); MenuVoice = config.GetBindable(OsuSetting.MenuVoice); MenuMusic = config.GetBindable(OsuSetting.MenuMusic); - seeya = audio.Samples.Get(SeeyaSampleName); + + if (api.LocalUser.Value.IsSupporter) + AddInternal(skinnableSeeya = new SkinnableSound(new SampleInfo(SeeyaSampleName))); + else + seeya = audio.Samples.Get(SeeyaSampleName); // if the user has requested not to play theme music, we should attempt to find a random beatmap from their collection. if (!MenuMusic.Value) @@ -201,7 +209,15 @@ namespace osu.Game.Screens.Menu // we also handle the exit transition. if (MenuVoice.Value) { - seeya.Play(); + if (skinnableSeeya != null) + { + // resuming a screen (i.e. calling OnResume) happens before the screen itself becomes alive, + // therefore skinnable samples may not be updated yet with the recently selected skin. + // schedule after children to ensure skinnable samples have processed skin changes before playing. + ScheduleAfterChildren(() => skinnableSeeya.Play()); + } + else + seeya.Play(); // if playing the outro voice, we have more time to have fun with the background track. // initially fade to almost silent then ramp out over the remaining time. diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index 639591cfef..27eaa7eb3a 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -13,7 +13,10 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Online.API; using osu.Game.Screens.Backgrounds; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Screens.Menu @@ -23,8 +26,11 @@ namespace osu.Game.Screens.Menu protected override string BeatmapHash => "64e00d7022195959bfa3109d09c2e2276c8f12f486b91fcf6175583e973b48f2"; protected override string BeatmapFile => "welcome.osz"; private const double delay_step_two = 2142; - private Sample welcome; - private Sample pianoReverb; + + private SkinnableSound skinnableWelcome; + private ISample welcome; + + private ISample pianoReverb; protected override string SeeyaSampleName => "Intro/Welcome/seeya"; protected override BackgroundScreen CreateBackground() => background = new BackgroundScreenDefault(false) @@ -40,10 +46,15 @@ namespace osu.Game.Screens.Menu } [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load(AudioManager audio, IAPIProvider api) { if (MenuVoice.Value) - welcome = audio.Samples.Get(@"Intro/Welcome/welcome"); + { + if (api.LocalUser.Value.IsSupporter) + AddInternal(skinnableWelcome = new SkinnableSound(new SampleInfo(@"Intro/Welcome/welcome"))); + else + welcome = audio.Samples.Get(@"Intro/Welcome/welcome"); + } pianoReverb = audio.Samples.Get(@"Intro/Welcome/welcome_piano"); } @@ -65,7 +76,10 @@ namespace osu.Game.Screens.Menu AddInternal(intro); - welcome?.Play(); + if (skinnableWelcome != null) + skinnableWelcome.Play(); + else + welcome?.Play(); var reverbChannel = pianoReverb?.Play(); if (reverbChannel != null) @@ -100,13 +114,13 @@ namespace osu.Game.Screens.Menu private class WelcomeIntroSequence : Container { - private Sprite welcomeText; + private Drawable welcomeText; private Container scaleContainer; public LogoVisualisation LogoVisualisation { get; private set; } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(TextureStore textures, IAPIProvider api) { Origin = Anchor.Centre; Anchor = Anchor.Centre; @@ -135,15 +149,17 @@ namespace osu.Game.Screens.Menu Size = new Vector2(480), Colour = Color4.Black }, - welcomeText = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Texture = textures.Get(@"Intro/Welcome/welcome_text") - }, } }, }; + + if (api.LocalUser.Value.IsSupporter) + scaleContainer.Add(welcomeText = new SkinnableSprite(@"Intro/Welcome/welcome_text")); + else + scaleContainer.Add(welcomeText = new Sprite { Texture = textures.Get(@"Intro/Welcome/welcome_text") }); + + welcomeText.Anchor = Anchor.Centre; + welcomeText.Origin = Anchor.Centre; } protected override void LoadComplete() diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs index 9822ceaaf6..cdaa39d2be 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs @@ -15,12 +15,12 @@ namespace osu.Game.Screens.OnlinePlay.Components { public new readonly BindableBool Enabled = new BindableBool(); - private IBindable availability; + private readonly IBindable availability = new Bindable(); [BackgroundDependencyLoader] private void load(OnlinePlayBeatmapAvailabilityTracker beatmapTracker) { - availability = beatmapTracker.Availability.GetBoundCopy(); + availability.BindTo(beatmapTracker.Availability); availability.BindValueChanged(_ => updateState()); Enabled.BindValueChanged(_ => updateState(), true); diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs index 27743e709f..7baa346c6f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs @@ -128,7 +128,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { new OsuMenuItem("Create copy", MenuItemType.Standard, () => { - lounge?.Open(Room.DeepClone()); + lounge?.OpenCopy(Room); }) }; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index cd1c8a0a64..a2d3b7f4fc 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -20,6 +20,7 @@ using osu.Framework.Threading; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input; +using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Rulesets; @@ -63,6 +64,9 @@ namespace osu.Game.Screens.OnlinePlay.Lounge [Resolved] private IBindable ruleset { get; set; } + [Resolved] + private IAPIProvider api { get; set; } + [CanBeNull] private IDisposable joiningRoomOperation { get; set; } @@ -310,6 +314,46 @@ namespace osu.Game.Screens.OnlinePlay.Lounge }); }); + /// + /// Copies a room and opens it as a fresh (not-yet-created) one. + /// + /// The room to copy. + public void OpenCopy(Room room) + { + Debug.Assert(room.RoomID.Value != null); + + if (joiningRoomOperation != null) + return; + + joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); + + var req = new GetRoomRequest(room.RoomID.Value.Value); + + req.Success += r => + { + // ID must be unset as we use this as a marker for whether this is a client-side (not-yet-created) room or not. + r.RoomID.Value = null; + + // Null out dates because end date is not supported client-side and the settings overlay will populate a duration. + r.EndDate.Value = null; + r.Duration.Value = null; + + Open(r); + + joiningRoomOperation?.Dispose(); + joiningRoomOperation = null; + }; + + req.Failure += exception => + { + Logger.Error(exception, "Couldn't create a copy of this room."); + joiningRoomOperation?.Dispose(); + joiningRoomOperation = null; + }; + + api.Queue(req); + } + /// /// Push a room as a new subscreen. /// diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index e297c90491..a382f65d84 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -12,6 +12,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Game.Audio; @@ -100,122 +101,126 @@ namespace osu.Game.Screens.OnlinePlay.Match { sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); - InternalChildren = new Drawable[] + InternalChild = new PopoverContainer { - beatmapAvailabilityTracker, - new MultiplayerRoomSounds(), - new GridContainer + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + beatmapAvailabilityTracker, + new MultiplayerRoomSounds(), + new GridContainer { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 50) - }, - Content = new[] - { - // Padded main content (drawable room + main content) - new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - new Container + new Dimension(), + new Dimension(GridSizeMode.Absolute, 50) + }, + Content = new[] + { + // Padded main content (drawable room + main content) + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding + new Container { - Horizontal = WaveOverlayContainer.WIDTH_PADDING, - Bottom = 30 - }, - Children = new[] - { - mainContent = new GridContainer + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Bottom = 30 + }, + Children = new[] + { + mainContent = new GridContainer { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 10) - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - new DrawableMatchRoom(Room, allowEdit) - { - OnEdit = () => settingsOverlay.Show(), - SelectedItem = { BindTarget = SelectedItem } - } + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 10) }, - null, - new Drawable[] + Content = new[] { - new Container + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Children = new[] + new DrawableMatchRoom(Room, allowEdit) { - new Container + OnEdit = () => settingsOverlay.Show(), + SelectedItem = { BindTarget = SelectedItem } + } + }, + null, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, - Child = new Box + new Container { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary. + Masking = true, + CornerRadius = 10, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary. + }, }, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(20), - Child = CreateMainContent(), - }, - new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = userModsSelectOverlay = new UserModSelectOverlay + new Container { - SelectedMods = { BindTarget = UserMods }, - IsValidMod = _ => false - } - }, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20), + Child = CreateMainContent(), + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = userModsSelectOverlay = new UserModSelectOverlay + { + SelectedMods = { BindTarget = UserMods }, + IsValidMod = _ => false + } + }, + } } } } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + // Resolves 1px masking errors between the settings overlay and the room panel. + Padding = new MarginPadding(-1), + Child = settingsOverlay = CreateRoomSettingsOverlay(Room) } }, - new Container - { - RelativeSizeAxes = Axes.Both, - // Resolves 1px masking errors between the settings overlay and the room panel. - Padding = new MarginPadding(-1), - Child = settingsOverlay = CreateRoomSettingsOverlay(Room) - } }, }, - }, - // Footer - new Drawable[] - { - new Container + // Footer + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + new Container { - new Box + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"28242d") // Temporary. - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(5), - Child = CreateFooter() - }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d") // Temporary. + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(5), + Child = CreateFooter() + }, + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs new file mode 100644 index 0000000000..af7ed9b9e2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -0,0 +1,224 @@ +// 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 System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.Countdown; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match +{ + public class MatchStartControl : MultiplayerRoomComposite + { + [Resolved] + private OngoingOperationTracker ongoingOperationTracker { get; set; } + + [CanBeNull] + private IDisposable clickOperation; + + private Sample sampleReady; + private Sample sampleReadyAll; + private Sample sampleUnready; + + private readonly BindableBool enabled = new BindableBool(); + private readonly MultiplayerCountdownButton countdownButton; + private int countReady; + private ScheduledDelegate readySampleDelegate; + private IBindable operationInProgress; + + public MatchStartControl() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new MultiplayerReadyButton + { + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Action = onReadyClick, + Enabled = { BindTarget = enabled }, + }, + countdownButton = new MultiplayerCountdownButton + { + RelativeSizeAxes = Axes.Y, + Size = new Vector2(40, 1), + Alpha = 0, + Action = startCountdown, + Enabled = { BindTarget = enabled } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy(); + operationInProgress.BindValueChanged(_ => updateState()); + + sampleReady = audio.Samples.Get(@"Multiplayer/player-ready"); + sampleReadyAll = audio.Samples.Get(@"Multiplayer/player-ready-all"); + sampleUnready = audio.Samples.Get(@"Multiplayer/player-unready"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + CurrentPlaylistItem.BindValueChanged(_ => updateState()); + } + + protected override void OnRoomUpdated() + { + base.OnRoomUpdated(); + updateState(); + } + + protected override void OnRoomLoadRequested() + { + base.OnRoomLoadRequested(); + endOperation(); + } + + private void onReadyClick() + { + if (Room == null) + return; + + Debug.Assert(clickOperation == null); + clickOperation = ongoingOperationTracker.BeginOperation(); + + // Ensure the current user becomes ready before being able to do anything else (start match, stop countdown, unready). + if (!isReady() || !Client.IsHost) + { + toggleReady(); + return; + } + + // Local user is the room host and is in a ready state. + // The only action they can take is to stop a countdown if one's currently running. + if (Room.Countdown != null) + { + stopCountdown(); + return; + } + + // And if a countdown isn't running, start the match. + startMatch(); + + bool isReady() => Client.LocalUser?.State == MultiplayerUserState.Ready || Client.LocalUser?.State == MultiplayerUserState.Spectating; + + void toggleReady() => Client.ToggleReady().ContinueWith(_ => endOperation()); + + void stopCountdown() => Client.SendMatchRequest(new StopCountdownRequest()).ContinueWith(_ => endOperation()); + + void startMatch() => Client.StartMatch().ContinueWith(t => + { + // accessing Exception here silences any potential errors from the antecedent task + if (t.Exception != null) + { + // gameplay was not started due to an exception; unblock button. + endOperation(); + } + + // gameplay is starting, the button will be unblocked on load requested. + }); + } + + private void startCountdown(TimeSpan duration) + { + Debug.Assert(clickOperation == null); + clickOperation = ongoingOperationTracker.BeginOperation(); + + Client.SendMatchRequest(new StartMatchCountdownRequest { Duration = duration }).ContinueWith(_ => endOperation()); + } + + private void endOperation() + { + clickOperation?.Dispose(); + clickOperation = null; + } + + private void updateState() + { + if (Room == null) + { + enabled.Value = false; + return; + } + + var localUser = Client.LocalUser; + + int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready); + int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating); + + if (Room.Countdown != null) + countdownButton.Alpha = 0; + else + { + switch (localUser?.State) + { + default: + countdownButton.Alpha = 0; + break; + + case MultiplayerUserState.Spectating: + case MultiplayerUserState.Ready: + countdownButton.Alpha = Room.Host?.Equals(localUser) == true ? 1 : 0; + break; + } + } + + enabled.Value = + Room.State == MultiplayerRoomState.Open + && CurrentPlaylistItem.Value?.ID == Room.Settings.PlaylistItemId + && !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired + && !operationInProgress.Value; + + // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready. + if (localUser?.State == MultiplayerUserState.Spectating) + enabled.Value &= Room.Host?.Equals(localUser) == true && newCountReady > 0; + + if (newCountReady == countReady) + return; + + readySampleDelegate?.Cancel(); + readySampleDelegate = Schedule(() => + { + if (newCountReady > countReady) + { + if (newCountReady == newCountTotal) + sampleReadyAll?.Play(); + else + sampleReady?.Play(); + } + else if (newCountReady < countReady) + { + sampleUnready?.Play(); + } + + countReady = newCountReady; + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs new file mode 100644 index 0000000000..3bf7e91a55 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs @@ -0,0 +1,83 @@ +// 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 Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match +{ + public class MultiplayerCountdownButton : IconButton, IHasPopover + { + private static readonly TimeSpan[] available_delays = + { + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30), + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(2) + }; + + public new Action Action; + + private readonly Drawable background; + + public MultiplayerCountdownButton() + { + Icon = FontAwesome.Solid.CaretDown; + IconScale = new Vector2(0.6f); + + Add(background = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }); + + base.Action = this.ShowPopover; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = colours.Green; + } + + public Popover GetPopover() + { + var flow = new FillFlowContainer + { + Width = 200, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + }; + + foreach (var duration in available_delays) + { + flow.Add(new OsuButton + { + RelativeSizeAxes = Axes.X, + Text = $"Start match in {duration.Humanize()}", + BackgroundColour = background.Colour, + Action = () => + { + Action(duration); + this.HidePopover(); + } + }); + } + + return new OsuPopover { Child = flow }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs index 036e37ddfd..a07c95bca8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs @@ -1,7 +1,6 @@ // 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.Graphics; using osu.Framework.Graphics.Containers; @@ -12,19 +11,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private const float ready_button_width = 600; private const float spectate_button_width = 200; - public Action OnReadyClick - { - set => readyButton.OnReadyClick = value; - } - - public Action OnSpectateClick - { - set => spectateButton.OnSpectateClick = value; - } - - private readonly MultiplayerReadyButton readyButton; - private readonly MultiplayerSpectateButton spectateButton; - public MultiplayerMatchFooter() { RelativeSizeAxes = Axes.Both; @@ -37,12 +23,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match new Drawable[] { null, - spectateButton = new MultiplayerSpectateButton + new MultiplayerSpectateButton { RelativeSizeAxes = Axes.Both, }, null, - readyButton = new MultiplayerReadyButton + new MatchStartControl { RelativeSizeAxes = Axes.Both, }, diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index 023af85f3b..746e4257f1 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -3,165 +3,176 @@ using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Online.Multiplayer; using osu.Game.Screens.OnlinePlay.Components; -using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerReadyButton : MultiplayerRoomComposite + public class MultiplayerReadyButton : ReadyButton { - public Action OnReadyClick - { - set => button.Action = value; - } + public new Triangles Triangles => base.Triangles; + + [Resolved] + private MultiplayerClient multiplayerClient { get; set; } [Resolved] private OsuColour colours { get; set; } - [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } - - private IBindable operationInProgress; - - private Sample sampleReady; - private Sample sampleReadyAll; - private Sample sampleUnready; - - private readonly ButtonWithTrianglesExposed button; - - private int countReady; - - private ScheduledDelegate readySampleDelegate; - - public MultiplayerReadyButton() - { - InternalChild = button = new ButtonWithTrianglesExposed - { - RelativeSizeAxes = Axes.Both, - Size = Vector2.One, - Enabled = { Value = true }, - }; - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy(); - operationInProgress.BindValueChanged(_ => updateState()); - - sampleReady = audio.Samples.Get(@"Multiplayer/player-ready"); - sampleReadyAll = audio.Samples.Get(@"Multiplayer/player-ready-all"); - sampleUnready = audio.Samples.Get(@"Multiplayer/player-unready"); - } + [CanBeNull] + private MultiplayerRoom room => multiplayerClient.Room; protected override void LoadComplete() { base.LoadComplete(); - CurrentPlaylistItem.BindValueChanged(_ => updateState()); + multiplayerClient.RoomUpdated += onRoomUpdated; + onRoomUpdated(); } - protected override void OnRoomUpdated() - { - base.OnRoomUpdated(); + private MultiplayerCountdown countdown; + private DateTimeOffset countdownReceivedTime; + private ScheduledDelegate countdownUpdateDelegate; - updateState(); + private void onRoomUpdated() => Scheduler.AddOnce(() => + { + if (countdown == null && room?.Countdown != null) + countdownReceivedTime = DateTimeOffset.Now; + + countdown = room?.Countdown; + + if (room?.Countdown != null) + countdownUpdateDelegate ??= Scheduler.AddDelayed(updateButtonText, 1000, true); + else + { + countdownUpdateDelegate?.Cancel(); + countdownUpdateDelegate = null; + } + + updateButtonText(); + updateButtonColour(); + }); + + private void updateButtonText() + { + if (room == null) + { + Text = "Ready"; + return; + } + + var localUser = multiplayerClient.LocalUser; + + int countReady = room.Users.Count(u => u.State == MultiplayerUserState.Ready); + int countTotal = room.Users.Count(u => u.State != MultiplayerUserState.Spectating); + string countText = $"({countReady} / {countTotal} ready)"; + + if (countdown != null) + { + TimeSpan timeElapsed = DateTimeOffset.Now - countdownReceivedTime; + TimeSpan countdownRemaining; + + if (timeElapsed > countdown.TimeRemaining) + countdownRemaining = TimeSpan.Zero; + else + countdownRemaining = countdown.TimeRemaining - timeElapsed; + + string countdownText = $"Starting in {countdownRemaining:mm\\:ss}"; + + switch (localUser?.State) + { + default: + Text = $"Ready ({countdownText.ToLowerInvariant()})"; + break; + + case MultiplayerUserState.Spectating: + case MultiplayerUserState.Ready: + Text = $"{countdownText} {countText}"; + break; + } + } + else + { + switch (localUser?.State) + { + default: + Text = "Ready"; + break; + + case MultiplayerUserState.Spectating: + case MultiplayerUserState.Ready: + Text = room.Host?.Equals(localUser) == true + ? $"Start match {countText}" + : $"Waiting for host... {countText}"; + + break; + } + } } - private void updateState() + private void updateButtonColour() { - var localUser = Client.LocalUser; + if (room == null) + { + setGreen(); + return; + } - int newCountReady = Room?.Users.Count(u => u.State == MultiplayerUserState.Ready) ?? 0; - int newCountTotal = Room?.Users.Count(u => u.State != MultiplayerUserState.Spectating) ?? 0; + var localUser = multiplayerClient.LocalUser; switch (localUser?.State) { default: - button.Text = "Ready"; - updateButtonColour(true); + setGreen(); break; case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: - string countText = $"({newCountReady} / {newCountTotal} ready)"; - - if (Room?.Host?.Equals(localUser) == true) - { - button.Text = $"Start match {countText}"; - updateButtonColour(true); - } + if (room?.Host?.Equals(localUser) == true && room.Countdown == null) + setGreen(); else - { - button.Text = $"Waiting for host... {countText}"; - updateButtonColour(false); - } + setYellow(); break; } - bool enableButton = - Room?.State == MultiplayerRoomState.Open - && CurrentPlaylistItem.Value?.ID == Room.Settings.PlaylistItemId - && !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired - && !operationInProgress.Value; - - // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready. - if (localUser?.State == MultiplayerUserState.Spectating) - enableButton &= Room?.Host?.Equals(localUser) == true && newCountReady > 0; - - button.Enabled.Value = enableButton; - - if (newCountReady == countReady) - return; - - readySampleDelegate?.Cancel(); - readySampleDelegate = Schedule(() => + void setYellow() { - if (newCountReady > countReady) - { - if (newCountReady == newCountTotal) - sampleReadyAll?.Play(); - else - sampleReady?.Play(); - } - else if (newCountReady < countReady) - { - sampleUnready?.Play(); - } - - countReady = newCountReady; - }); - } - - private void updateButtonColour(bool green) - { - if (green) - { - button.BackgroundColour = colours.Green; - button.Triangles.ColourDark = colours.Green; - button.Triangles.ColourLight = colours.GreenLight; + BackgroundColour = colours.YellowDark; + Triangles.ColourDark = colours.YellowDark; + Triangles.ColourLight = colours.Yellow; } - else + + void setGreen() { - button.BackgroundColour = colours.YellowDark; - button.Triangles.ColourDark = colours.YellowDark; - button.Triangles.ColourLight = colours.Yellow; + BackgroundColour = colours.Green; + Triangles.ColourDark = colours.Green; + Triangles.ColourLight = colours.GreenLight; } } - private class ButtonWithTrianglesExposed : ReadyButton + protected override void Dispose(bool isDisposing) { - public new Triangles Triangles => base.Triangles; + base.Dispose(isDisposing); + + if (multiplayerClient != null) + multiplayerClient.RoomUpdated -= onRoomUpdated; + } + + public override LocalisableString TooltipText + { + get + { + if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready) + return "Cancel countdown"; + + return base.TooltipText; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index db99c6a5d5..d939fbf400 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -1,7 +1,6 @@ // 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.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,11 +14,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public class MultiplayerSpectateButton : MultiplayerRoomComposite { - public Action OnSpectateClick - { - set => button.Action = value; - } - [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } @@ -37,9 +31,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.Both, Size = Vector2.One, Enabled = { Value = true }, + Action = onClick }; } + private void onClick() + { + var clickOperation = ongoingOperationTracker.BeginOperation(); + + Client.ToggleSpectate().ContinueWith(t => endOperation()); + + void endOperation() => clickOperation?.Dispose(); + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs index eeafebfec0..879a21e7c1 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -25,6 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist /// public Action RequestEdit; + private MultiplayerPlaylistTabControl playlistTabControl; private MultiplayerQueueList queueList; private MultiplayerHistoryList historyList; private bool firstPopulation = true; @@ -36,7 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist InternalChildren = new Drawable[] { - new OsuTabControl + playlistTabControl = new MultiplayerPlaylistTabControl { RelativeSizeAxes = Axes.X, Height = tab_control_height, @@ -64,6 +64,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist } } }; + + playlistTabControl.QueueItems.BindTarget = queueList.Items; } protected override void LoadComplete() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs new file mode 100644 index 0000000000..583a05839f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + public class MultiplayerPlaylistTabControl : OsuTabControl + { + public readonly IBindableList QueueItems = new BindableList(); + + protected override TabItem CreateTabItem(MultiplayerPlaylistDisplayMode value) + { + if (value == MultiplayerPlaylistDisplayMode.Queue) + return new QueueTabItem { QueueItems = { BindTarget = QueueItems } }; + + return base.CreateTabItem(value); + } + + private class QueueTabItem : OsuTabItem + { + public readonly IBindableList QueueItems = new BindableList(); + + public QueueTabItem() + : base(MultiplayerPlaylistDisplayMode.Queue) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + QueueItems.BindCollectionChanged((_, __) => Text.Text = QueueItems.Count > 0 ? $"Queue ({QueueItems.Count})" : "Queue", true); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index c78dcb7cb6..e53153e017 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -1,11 +1,9 @@ // 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 System.Collections.Generic; using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -46,14 +44,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient client { get; set; } - [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } - private readonly IBindable isConnected = new Bindable(); - [CanBeNull] - private IDisposable readyClickOperation; - private AddItemButton addItemButton; public MultiplayerMatchSubScreen(Room room) @@ -230,11 +222,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit)); } - protected override Drawable CreateFooter() => new MultiplayerMatchFooter - { - OnReadyClick = onReadyClick, - OnSpectateClick = onSpectateClick - }; + protected override Drawable CreateFooter() => new MultiplayerMatchFooter(); protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room); @@ -332,52 +320,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } - private void onReadyClick() - { - Debug.Assert(readyClickOperation == null); - readyClickOperation = ongoingOperationTracker.BeginOperation(); - - if (client.IsHost && (client.LocalUser?.State == MultiplayerUserState.Ready || client.LocalUser?.State == MultiplayerUserState.Spectating)) - { - client.StartMatch() - .ContinueWith(t => - { - // accessing Exception here silences any potential errors from the antecedent task - if (t.Exception != null) - { - // gameplay was not started due to an exception; unblock button. - endOperation(); - } - - // gameplay is starting, the button will be unblocked on load requested. - }); - return; - } - - client.ToggleReady() - .ContinueWith(t => endOperation()); - - void endOperation() - { - readyClickOperation?.Dispose(); - readyClickOperation = null; - } - } - - private void onSpectateClick() - { - Debug.Assert(readyClickOperation == null); - readyClickOperation = ongoingOperationTracker.BeginOperation(); - - client.ToggleSpectate().ContinueWith(t => endOperation()); - - void endOperation() - { - readyClickOperation?.Dispose(); - readyClickOperation = null; - } - } - private void onRoomUpdated() { // may happen if the client is kicked or otherwise removed from the room. @@ -433,9 +375,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; StartPlay(); - - readyClickOperation?.Dispose(); - readyClickOperation = null; } protected override Screen CreateGameplayScreen() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index bd2f49a9e5..043315c790 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -10,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; +using osu.Framework.Screens; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -76,7 +77,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer }); // todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area. - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(ScoreProcessor, users), l => + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(GameplayState.Ruleset.RulesetInfo, ScoreProcessor, users), l => { if (!LoadedBeatmapSuccessfully) return; @@ -171,11 +172,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void onMatchStarted() => Scheduler.Add(() => { + if (!this.IsCurrentScreen()) + return; + loadingDisplay.Hide(); base.StartGameplay(); }); - private void onResultsReady() => resultsReady.SetResult(true); + private void onResultsReady() + { + // Schedule is required to ensure that `TaskCompletionSource.SetResult` is not called more than once. + // A scenario where this can occur is if this instance is not immediately disposed (ie. async disposal queue). + Schedule(() => + { + if (!this.IsCurrentScreen()) + return; + + resultsReady.SetResult(true); + }); + } protected override async Task PrepareScoreForResultsAsync(Score score) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index 7d2fe44c4e..f6f815a3cb 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -21,6 +21,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.LoadComplete(); Client.RoomUpdated += invokeOnRoomUpdated; + Client.LoadRequested += invokeOnRoomLoadRequested; Client.UserLeft += invokeUserLeft; Client.UserKicked += invokeUserKicked; Client.UserJoined += invokeUserJoined; @@ -38,6 +39,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item)); private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item)); private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item)); + private void invokeOnRoomLoadRequested() => Scheduler.AddOnce(OnRoomLoadRequested); /// /// Invoked when a user has joined the room. @@ -94,6 +96,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { } + /// + /// Invoked when the room requests the local user to load into gameplay. + /// + protected virtual void OnRoomLoadRequested() + { + } + protected override void Dispose(bool isDisposing) { if (Client != null) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 96a665f33d..70f4030b79 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -187,9 +186,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; var currentItem = Playlist.GetCurrentItem(); - Debug.Assert(currentItem != null); - - var ruleset = rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance(); + var ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs index 1614828a78..4545913db8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs @@ -5,6 +5,7 @@ using System; using JetBrains.Annotations; using osu.Framework.Timing; using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; @@ -12,8 +13,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { public class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard { - public MultiSpectatorLeaderboard([NotNull] ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) - : base(scoreProcessor, users) + public MultiSpectatorLeaderboard(RulesetInfo ruleset, [NotNull] ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) + : base(ruleset, scoreProcessor, users) { } @@ -33,7 +34,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate ((SpectatingTrackedUserData)data).Clock = null; } - protected override TrackedUserData CreateUserData(MultiplayerRoomUser user, ScoreProcessor scoreProcessor) => new SpectatingTrackedUserData(user, scoreProcessor); + protected override TrackedUserData CreateUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) => new SpectatingTrackedUserData(user, ruleset, scoreProcessor); protected override void Update() { @@ -48,8 +49,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate [CanBeNull] public IClock Clock; - public SpectatingTrackedUserData(MultiplayerRoomUser user, ScoreProcessor scoreProcessor) - : base(user, scoreProcessor) + public SpectatingTrackedUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) + : base(user, ruleset, scoreProcessor) { } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 3bb76c4a76..6747b8fc66 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -133,7 +133,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate var scoreProcessor = Ruleset.Value.CreateInstance().CreateScoreProcessor(); scoreProcessor.ApplyBeatmap(playableBeatmap); - LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, users) + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(Ruleset.Value, scoreProcessor, users) { Expanded = { Value = true }, }, l => diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index bf1699dca0..c56d04d5ac 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -115,6 +115,8 @@ namespace osu.Game.Screens.OnlinePlay this.FadeIn(); waves.Show(); + Mods.SetDefault(); + if (loungeSubScreen.IsCurrentScreen()) loungeSubScreen.OnEntering(last); else diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 7efeae8129..5a7762a3d8 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -11,6 +11,7 @@ using osu.Framework.Screens; using osu.Game.Extensions; using osu.Game.Online.Rooms; using osu.Game.Rulesets; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; @@ -64,7 +65,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); - Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); + Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.ComputeFinalScore(ScoringMode.Standardised, Score.ScoreInfo)); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 7903e54960..dca50c07ad 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -4,36 +4,46 @@ using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { public class BarHitErrorMeter : HitErrorMeter { - private const int arrow_move_duration = 400; + private const int judgement_line_width = 14; - private const int judgement_line_width = 6; + [SettingSource("Judgement line thickness", "How thick the individual lines should be.")] + public BindableNumber JudgementLineThickness { get; } = new BindableNumber(4) + { + MinValue = 1, + MaxValue = 8, + Precision = 0.1f, + }; - private const int bar_height = 200; + [SettingSource("Show moving average arrow", "Whether an arrow should move beneath the bar showing the average error.")] + public Bindable ShowMovingAverage { get; } = new BindableBool(true); - private const int bar_width = 2; + [SettingSource("Centre marker style", "How to signify the centre of the display")] + public Bindable CentreMarkerStyle { get; } = new Bindable(CentreMarkerStyles.Circle); - private const int spacing = 2; - - private const float chevron_size = 8; + [SettingSource("Label style", "How to show early/late extremities")] + public Bindable LabelStyle { get; } = new Bindable(LabelStyles.Icons); private SpriteIcon arrow; - private SpriteIcon iconEarly; - private SpriteIcon iconLate; + private Drawable labelEarly; + private Drawable labelLate; private Container colourBarsEarly; private Container colourBarsLate; @@ -42,6 +52,18 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private double maxHitWindow; + private double floatingAverage; + private Container colourBars; + private Container arrowContainer; + + private (HitResult result, double length)[] hitWindows; + + private const int max_concurrent_judgements = 50; + + private Drawable[] centreMarkerDrawables; + + private const int centre_marker_size = 8; + public BarHitErrorMeter() { AutoSizeAxes = Axes.Both; @@ -50,21 +72,66 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters [BackgroundDependencyLoader] private void load() { - InternalChild = new FillFlowContainer + const int bar_height = 200; + const int bar_width = 2; + const float chevron_size = 8; + + hitWindows = HitWindows.GetAllAvailableWindows().ToArray(); + + InternalChild = new Container { AutoSizeAxes = Axes.X, Height = bar_height, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(spacing, 0), Margin = new MarginPadding(2), Children = new Drawable[] { - new Container + colourBars = new Container { + Name = "colour axis", + X = chevron_size, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Width = chevron_size, + Width = judgement_line_width, RelativeSizeAxes = Axes.Y, + Children = new Drawable[] + { + colourBarsEarly = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Width = bar_width, + RelativeSizeAxes = Axes.Y, + Height = 0.5f, + Scale = new Vector2(1, -1), + }, + colourBarsLate = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Width = bar_width, + RelativeSizeAxes = Axes.Y, + Height = 0.5f, + }, + judgementsContainer = new Container + { + Name = "judgements", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Y, + Width = judgement_line_width, + }, + } + }, + arrowContainer = new Container + { + Name = "average chevron", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Width = chevron_size, + X = chevron_size, + RelativeSizeAxes = Axes.Y, + Alpha = 0, + Scale = new Vector2(0, 1), Child = arrow = new SpriteIcon { Anchor = Anchor.TopCentre, @@ -75,58 +142,10 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Size = new Vector2(chevron_size), } }, - colourBars = new Container - { - Width = bar_width, - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Children = new Drawable[] - { - colourBarsEarly = new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Scale = new Vector2(1, -1), - }, - colourBarsLate = new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - }, - iconEarly = new SpriteIcon - { - Y = -10, - Size = new Vector2(10), - Icon = FontAwesome.Solid.ShippingFast, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, - }, - iconLate = new SpriteIcon - { - Y = 10, - Size = new Vector2(10), - Icon = FontAwesome.Solid.Bicycle, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, - } - } - }, - judgementsContainer = new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = judgement_line_width, - RelativeSizeAxes = Axes.Y, - }, } }; - createColourBars(); + createColourBars(hitWindows); } protected override void LoadComplete() @@ -136,8 +155,180 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters colourBars.Height = 0; colourBars.ResizeHeightTo(1, 800, Easing.OutQuint); - arrow.Alpha = 0; - arrow.Delay(200).FadeInFromZero(600); + CentreMarkerStyle.BindValueChanged(style => recreateCentreMarker(style.NewValue), true); + LabelStyle.BindValueChanged(style => recreateLabels(style.NewValue), true); + + // delay the appearance animations for only the initial appearance. + using (arrowContainer.BeginDelayedSequence(450)) + { + ShowMovingAverage.BindValueChanged(visible => + { + arrowContainer.FadeTo(visible.NewValue ? 1 : 0, 250, Easing.OutQuint); + arrowContainer.ScaleTo(visible.NewValue ? new Vector2(1) : new Vector2(0, 1), 250, Easing.OutQuint); + }, true); + } + } + + private void recreateCentreMarker(CentreMarkerStyles style) + { + if (centreMarkerDrawables != null) + { + foreach (var d in centreMarkerDrawables) + { + d.ScaleTo(0, 500, Easing.OutQuint) + .FadeOut(500, Easing.OutQuint); + + d.Expire(); + } + + centreMarkerDrawables = null; + } + + switch (style) + { + case CentreMarkerStyles.None: + break; + + case CentreMarkerStyles.Circle: + centreMarkerDrawables = new Drawable[] + { + new Circle + { + Name = "middle marker behind", + Colour = GetColourForHitResult(hitWindows.Last().result), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MaxValue, + Size = new Vector2(centre_marker_size), + }, + new Circle + { + Name = "middle marker in front", + Colour = GetColourForHitResult(hitWindows.Last().result).Darken(0.3f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MinValue, + Size = new Vector2(centre_marker_size / 2f), + }, + }; + break; + + case CentreMarkerStyles.Line: + const float border_size = 1.5f; + + centreMarkerDrawables = new Drawable[] + { + new Box + { + Name = "middle marker behind", + Colour = GetColourForHitResult(hitWindows.Last().result), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MaxValue, + Size = new Vector2(judgement_line_width, centre_marker_size / 3f), + }, + new Box + { + Name = "middle marker in front", + Colour = GetColourForHitResult(hitWindows.Last().result).Darken(0.3f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MinValue, + Size = new Vector2(judgement_line_width - border_size, centre_marker_size / 3f - border_size), + }, + }; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(style), style, null); + } + + if (centreMarkerDrawables != null) + { + foreach (var d in centreMarkerDrawables) + { + colourBars.Add(d); + + d.FadeInFromZero(500, Easing.OutQuint) + .ScaleTo(0).ScaleTo(1, 1000, Easing.OutElasticHalf); + } + } + } + + private void recreateLabels(LabelStyles style) + { + const float icon_size = 14; + + labelEarly?.Expire(); + labelEarly = null; + + labelLate?.Expire(); + labelLate = null; + + switch (style) + { + case LabelStyles.None: + break; + + case LabelStyles.Icons: + labelEarly = new SpriteIcon + { + Y = -10, + Size = new Vector2(icon_size), + Icon = FontAwesome.Solid.ShippingFast, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + }; + + labelLate = new SpriteIcon + { + Y = 10, + Size = new Vector2(icon_size), + Icon = FontAwesome.Solid.Bicycle, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + }; + + break; + + case LabelStyles.Text: + labelEarly = new OsuSpriteText + { + Y = -10, + Text = "Early", + Font = OsuFont.Default.With(size: 10), + Height = 12, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + }; + + labelLate = new OsuSpriteText + { + Y = 10, + Text = "Late", + Font = OsuFont.Default.With(size: 10), + Height = 12, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + }; + + break; + + default: + throw new ArgumentOutOfRangeException(nameof(style), style, null); + } + + if (labelEarly != null) + { + colourBars.Add(labelEarly); + labelEarly.FadeInFromZero(500); + } + + if (labelLate != null) + { + colourBars.Add(labelLate); + labelLate.FadeInFromZero(500); + } } protected override void Update() @@ -145,14 +336,12 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters base.Update(); // undo any layout rotation to display icons in the correct orientation - iconEarly.Rotation = -Rotation; - iconLate.Rotation = -Rotation; + if (labelEarly != null) labelEarly.Rotation = -Rotation; + if (labelLate != null) labelLate.Rotation = -Rotation; } - private void createColourBars() + private void createColourBars((HitResult result, double length)[] windows) { - var windows = HitWindows.GetAllAvailableWindows().ToArray(); - // max to avoid div-by-zero. maxHitWindow = Math.Max(1, windows.First().length); @@ -166,17 +355,11 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters colourBarsLate.Add(createColourBar(result, hitWindow, i == 0)); } - // a little nub to mark the centre point. - var centre = createColourBar(windows.Last().result, 0.01f); - centre.Anchor = centre.Origin = Anchor.CentreLeft; - centre.Width = 2.5f; - colourBars.Add(centre); - - Drawable createColourBar(HitResult result, float height, bool first = false) + Drawable createColourBar(HitResult result, float height, bool requireGradient = false) { var colour = GetColourForHitResult(result); - if (first) + if (requireGradient) { // the first bar needs gradient rendering. const float gradient_start = 0.8f; @@ -213,13 +396,10 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } - private double floatingAverage; - private Container colourBars; - - private const int max_concurrent_judgements = 50; - protected override void OnNewJudgement(JudgementResult judgement) { + const int arrow_move_duration = 800; + if (!judgement.IsHit || judgement.HitObject.HitWindows?.WindowFor(HitResult.Miss) == 0) return; @@ -242,50 +422,74 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters judgementsContainer.Add(new JudgementLine { + JudgementLineThickness = { BindTarget = JudgementLineThickness }, Y = getRelativeJudgementPosition(judgement.TimeOffset), - Origin = Anchor.CentreLeft, + Colour = GetColourForHitResult(judgement.Type), }); arrow.MoveToY( getRelativeJudgementPosition(floatingAverage = floatingAverage * 0.9 + judgement.TimeOffset * 0.1) - , arrow_move_duration, Easing.Out); + , arrow_move_duration, Easing.OutQuint); } private float getRelativeJudgementPosition(double value) => Math.Clamp((float)((value / maxHitWindow) + 1) / 2, 0, 1); internal class JudgementLine : CompositeDrawable { - private const int judgement_fade_duration = 5000; + public readonly BindableNumber JudgementLineThickness = new BindableFloat(); public JudgementLine() { RelativeSizeAxes = Axes.X; RelativePositionAxes = Axes.Y; - Height = 3; - InternalChild = new CircularContainer + Blending = BlendingParameters.Additive; + + Origin = Anchor.Centre; + Anchor = Anchor.TopCentre; + + InternalChild = new Circle { - Masking = true, RelativeSizeAxes = Axes.Both, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - } }; } protected override void LoadComplete() { + const int judgement_fade_in_duration = 100; + const int judgement_fade_out_duration = 5000; + base.LoadComplete(); + Alpha = 0; Width = 0; - this.ResizeWidthTo(1, 200, Easing.OutElasticHalf); - this.FadeTo(0.8f, 150).Then().FadeOut(judgement_fade_duration).Expire(); + JudgementLineThickness.BindValueChanged(thickness => Height = thickness.NewValue, true); + + this + .FadeTo(0.6f, judgement_fade_in_duration, Easing.OutQuint) + .ResizeWidthTo(1, judgement_fade_in_duration, Easing.OutQuint) + .Then() + .FadeOut(judgement_fade_out_duration) + .ResizeWidthTo(0, judgement_fade_out_duration, Easing.InQuint) + .Expire(); } } public override void Clear() => judgementsContainer.Clear(); + + public enum CentreMarkerStyles + { + None, + Circle, + Line + } + + public enum LabelStyles + { + None, + Icons, + Text + } } } diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 4087011933..3da63ec2cc 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -84,7 +84,7 @@ namespace osu.Game.Screens.Play.HUD protected override bool OnMouseMove(MouseMoveEvent e) { - positionalAdjust = Vector2.Distance(e.ScreenSpaceMousePosition, button.ScreenSpaceDrawQuad.Centre) / 200; + positionalAdjust = Vector2.Distance(e.MousePosition, button.ToSpaceOfOtherDrawable(button.DrawRectangle.Centre, Parent)) / 100; return base.OnMouseMove(e); } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 83c73e5a70..71998622ef 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -17,7 +17,9 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Spectator; +using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD @@ -41,6 +43,7 @@ namespace osu.Game.Screens.Play.HUD [Resolved] private UserLookupCache userLookupCache { get; set; } + private readonly RulesetInfo ruleset; private readonly ScoreProcessor scoreProcessor; private readonly MultiplayerRoomUser[] playingUsers; private Bindable scoringMode; @@ -52,11 +55,13 @@ namespace osu.Game.Screens.Play.HUD /// /// Construct a new leaderboard. /// + /// The ruleset. /// A score processor instance to handle score calculation for scores of users in the match. /// IDs of all users in this match. - public MultiplayerGameplayLeaderboard(ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) + public MultiplayerGameplayLeaderboard(RulesetInfo ruleset, ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) { // todo: this will eventually need to be created per user to support different mod combinations. + this.ruleset = ruleset; this.scoreProcessor = scoreProcessor; playingUsers = users; @@ -69,7 +74,7 @@ namespace osu.Game.Screens.Play.HUD foreach (var user in playingUsers) { - var trackedUser = CreateUserData(user, scoreProcessor); + var trackedUser = CreateUserData(user, ruleset, scoreProcessor); trackedUser.ScoringMode.BindTo(scoringMode); UserScores[user.UserID] = trackedUser; @@ -119,7 +124,7 @@ namespace osu.Game.Screens.Play.HUD spectatorClient.OnNewFrames += handleIncomingFrames; } - protected virtual TrackedUserData CreateUserData(MultiplayerRoomUser user, ScoreProcessor scoreProcessor) => new TrackedUserData(user, scoreProcessor); + protected virtual TrackedUserData CreateUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) => new TrackedUserData(user, ruleset, scoreProcessor); protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) { @@ -222,8 +227,12 @@ namespace osu.Game.Screens.Play.HUD public int? Team => (User.MatchState as TeamVersusUserState)?.TeamID; - public TrackedUserData(MultiplayerRoomUser user, ScoreProcessor scoreProcessor) + private readonly RulesetInfo ruleset; + + public TrackedUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) { + this.ruleset = ruleset; + User = user; ScoreProcessor = scoreProcessor; @@ -244,7 +253,13 @@ namespace osu.Game.Screens.Play.HUD { var header = frame.Header; - Score.Value = ScoreProcessor.GetImmediateScore(ScoringMode.Value, header.MaxCombo, header.Statistics); + Score.Value = ScoreProcessor.ComputePartialScore(ScoringMode.Value, new ScoreInfo + { + Ruleset = ruleset, + MaxCombo = header.MaxCombo, + Statistics = header.Statistics + }); + Accuracy.Value = header.Accuracy; CurrentCombo.Value = header.Combo; } diff --git a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs index 21a7698248..7a1f724cfb 100644 --- a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs +++ b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs @@ -26,6 +26,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osu.Game.Skinning; using osuTK; @@ -41,12 +42,10 @@ namespace osu.Game.Screens.Play.HUD private const float alpha_when_invalid = 0.3f; - [CanBeNull] - [Resolved(CanBeNull = true)] + [Resolved] private ScoreProcessor scoreProcessor { get; set; } - [Resolved(CanBeNull = true)] - [CanBeNull] + [Resolved] private GameplayState gameplayState { get; set; } [CanBeNull] @@ -55,6 +54,8 @@ namespace osu.Game.Screens.Play.HUD private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private JudgementResult lastJudgement; + private PerformanceCalculator performanceCalculator; + private ScoreInfo scoreInfo; public PerformancePointsCounter() { @@ -70,8 +71,11 @@ namespace osu.Game.Screens.Play.HUD if (gameplayState != null) { + performanceCalculator = gameplayState.Ruleset.CreatePerformanceCalculator(); clonedMods = gameplayState.Mods.Select(m => m.DeepClone()).ToArray(); + scoreInfo = new ScoreInfo(gameplayState.Score.ScoreInfo.BeatmapInfo, gameplayState.Score.ScoreInfo.Ruleset) { Mods = clonedMods }; + var gameplayWorkingBeatmap = new GameplayWorkingBeatmap(gameplayState.Beatmap); difficultyCache.GetTimedDifficultyAttributesAsync(gameplayWorkingBeatmap, gameplayState.Ruleset, clonedMods, loadCancellationSource.Token) .ContinueWith(task => Schedule(() => @@ -120,19 +124,14 @@ namespace osu.Game.Screens.Play.HUD var attrib = getAttributeAtTime(judgement); - if (gameplayState == null || attrib == null) + if (gameplayState == null || attrib == null || scoreProcessor == null) { IsValid = false; return; } - // awkward but we need to make sure the true mods are not passed to PerformanceCalculator as it makes a mess of track applications. - var scoreInfo = gameplayState.Score.ScoreInfo.DeepClone(); - scoreInfo.Mods = clonedMods; - - var calculator = gameplayState.Ruleset.CreatePerformanceCalculator(attrib, scoreInfo); - - Current.Value = (int)Math.Round(calculator?.Calculate().Total ?? 0, MidpointRounding.AwayFromZero); + scoreProcessor.PopulateScore(scoreInfo); + Current.Value = (int)Math.Round(performanceCalculator?.Calculate(scoreInfo, attrib).Total ?? 0, MidpointRounding.AwayFromZero); IsValid = true; } diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs index a2b84c79af..95395f8181 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -4,9 +4,12 @@ using System; using System.Collections.Generic; using System.Linq; +using Humanizer; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Skinning; using osuTK; @@ -34,6 +37,8 @@ namespace osu.Game.Screens.Play.HUD /// public bool UsesFixedAnchor { get; set; } + public Dictionary Settings { get; set; } = new Dictionary(); + public List Children { get; } = new List(); [JsonConstructor] @@ -58,6 +63,14 @@ namespace osu.Game.Screens.Play.HUD if (component is ISkinnableDrawable skinnable) UsesFixedAnchor = skinnable.UsesFixedAnchor; + foreach (var (_, property) in component.GetSettingsSourceProperties()) + { + var bindable = (IBindable)property.GetValue(component); + + if (!bindable.IsDefault) + Settings.Add(property.Name.Underscore(), bindable.GetUnderlyingSettingValue()); + } + if (component is Container container) { foreach (var child in container.OfType().OfType()) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index b6f576ff2b..73bdeb5783 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -185,6 +185,12 @@ namespace osu.Game.Screens.Play { var gameplayMods = Mods.Value.Select(m => m.DeepClone()).ToArray(); + if (gameplayMods.Any(m => m is UnknownMod)) + { + Logger.Log("Gameplay was started with an unknown mod applied.", level: LogLevel.Important); + return; + } + if (Beatmap.Value is DummyWorkingBeatmap) return; @@ -609,16 +615,22 @@ namespace osu.Game.Screens.Play /// The destination time to seek to. internal void NonFrameStableSeek(double time) { - if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed) - frameStablePlaybackResetDelegate.RunTask(); + // TODO: This schedule should not be required and is a temporary hotfix. + // See https://github.com/ppy/osu/issues/17267 for the issue. + // See https://github.com/ppy/osu/pull/17302 for a better fix which needs some more time. + ScheduleAfterChildren(() => + { + if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed) + frameStablePlaybackResetDelegate.RunTask(); - bool wasFrameStable = DrawableRuleset.FrameStablePlayback; - DrawableRuleset.FrameStablePlayback = false; + bool wasFrameStable = DrawableRuleset.FrameStablePlayback; + DrawableRuleset.FrameStablePlayback = false; - Seek(time); + Seek(time); - // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. - frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); + // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. + frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); + }); } /// @@ -988,24 +1000,27 @@ namespace osu.Game.Screens.Play public override bool OnExiting(IScreen next) { - if (!GameplayState.HasPassed && !GameplayState.HasFailed) - GameplayState.HasQuit = true; - screenSuspension?.RemoveAndDisposeImmediately(); failAnimationLayer?.RemoveFilters(); - // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap. - if (prepareScoreForDisplayTask == null) + if (LoadedBeatmapSuccessfully) { - Score.ScoreInfo.Passed = false; - // potentially should be ScoreRank.F instead? this is the best alternative for now. - Score.ScoreInfo.Rank = ScoreRank.D; - } + if (!GameplayState.HasPassed && !GameplayState.HasFailed) + GameplayState.HasQuit = true; - // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous. - // To resolve test failures, forcefully end playing synchronously when this screen exits. - // Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method. - spectatorClient.EndPlaying(GameplayState); + // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap. + if (prepareScoreForDisplayTask == null) + { + Score.ScoreInfo.Passed = false; + // potentially should be ScoreRank.F instead? this is the best alternative for now. + Score.ScoreInfo.Rank = ScoreRank.D; + } + + // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous. + // To resolve test failures, forcefully end playing synchronously when this screen exits. + // Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method. + spectatorClient.EndPlaying(GameplayState); + } // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. // as we are no longer the current screen, we cannot guarantee the track is still usable. diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 41eb822e39..ba720af2a1 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -1,10 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Diagnostics; using System.Threading.Tasks; -using JetBrains.Annotations; using ManagedBass.Fx; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -48,31 +49,31 @@ namespace osu.Game.Screens.Play public override bool HandlePositionalInput => true; // We show the previous screen status - protected override UserActivity InitialActivity => null; + protected override UserActivity? InitialActivity => null; protected override bool PlayResumeSound => false; - protected BeatmapMetadataDisplay MetadataInfo { get; private set; } + protected BeatmapMetadataDisplay MetadataInfo { get; private set; } = null!; /// /// A fill flow containing the player settings groups, exposed for the ability to hide it from inheritors of the player loader. /// - protected FillFlowContainer PlayerSettings { get; private set; } + protected FillFlowContainer PlayerSettings { get; private set; } = null!; - protected VisualSettings VisualSettings { get; private set; } + protected VisualSettings VisualSettings { get; private set; } = null!; - protected AudioSettings AudioSettings { get; private set; } + protected AudioSettings AudioSettings { get; private set; } = null!; - protected Task LoadTask { get; private set; } + protected Task? LoadTask { get; private set; } - protected Task DisposalTask { get; private set; } + protected Task? DisposalTask { get; private set; } private bool backgroundBrightnessReduction; private readonly BindableDouble volumeAdjustment = new BindableDouble(1); - private AudioFilter lowPassFilter; - private AudioFilter highPassFilter; + private AudioFilter lowPassFilter = null!; + private AudioFilter highPassFilter = null!; protected bool BackgroundBrightnessReduction { @@ -90,47 +91,49 @@ namespace osu.Game.Screens.Play private bool readyForPush => !playerConsumed // don't push unless the player is completely loaded - && player?.LoadState == LoadState.Ready + && CurrentPlayer?.LoadState == LoadState.Ready // don't push if the user is hovering one of the panes, unless they are idle. && (IsHovered || idleTracker.IsIdle.Value) // don't push if the user is dragging a slider or otherwise. - && inputManager?.DraggedDrawable == null + && inputManager.DraggedDrawable == null // don't push if a focused overlay is visible, like settings. - && inputManager?.FocusedDrawable == null; + && inputManager.FocusedDrawable == null; private readonly Func createPlayer; - private Player player; + /// + /// The instance being loaded by this screen. + /// + public Player? CurrentPlayer { get; private set; } /// - /// Whether the curent player instance has been consumed via . + /// Whether the current player instance has been consumed via . /// private bool playerConsumed; - private LogoTrackingContainer content; + private LogoTrackingContainer content = null!; private bool hideOverlays; - private InputManager inputManager; + private InputManager inputManager = null!; - private IdleTracker idleTracker; + private IdleTracker idleTracker = null!; - private ScheduledDelegate scheduledPushPlayer; + private ScheduledDelegate? scheduledPushPlayer; - [CanBeNull] - private EpilepsyWarning epilepsyWarning; + private EpilepsyWarning? epilepsyWarning; [Resolved(CanBeNull = true)] - private NotificationOverlay notificationOverlay { get; set; } + private NotificationOverlay? notificationOverlay { get; set; } [Resolved(CanBeNull = true)] - private VolumeOverlay volumeOverlay { get; set; } + private VolumeOverlay? volumeOverlay { get; set; } [Resolved] - private AudioManager audioManager { get; set; } + private AudioManager audioManager { get; set; } = null!; [Resolved(CanBeNull = true)] - private BatteryInfo batteryInfo { get; set; } + private BatteryInfo? batteryInfo { get; set; } public PlayerLoader(Func createPlayer) { @@ -237,12 +240,14 @@ namespace osu.Game.Screens.Play { base.OnResuming(last); - var lastScore = player.Score; + Debug.Assert(CurrentPlayer != null); + + var lastScore = CurrentPlayer.Score; AudioSettings.ReferenceScore.Value = lastScore?.ScoreInfo; // prepare for a retry. - player = null; + CurrentPlayer = null; playerConsumed = false; cancelLoad(); @@ -344,9 +349,10 @@ namespace osu.Game.Screens.Play private Player consumePlayer() { Debug.Assert(!playerConsumed); + Debug.Assert(CurrentPlayer != null); playerConsumed = true; - return player; + return CurrentPlayer; } private void prepareNewPlayer() @@ -354,11 +360,11 @@ namespace osu.Game.Screens.Play if (!this.IsCurrentScreen()) return; - player = createPlayer(); - player.RestartCount = restartCount++; - player.RestartRequested = restartRequested; + CurrentPlayer = createPlayer(); + CurrentPlayer.RestartCount = restartCount++; + CurrentPlayer.RestartRequested = restartRequested; - LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false); + LoadTask = LoadComponentAsync(CurrentPlayer, _ => MetadataInfo.Loading = false); } private void restartRequested() @@ -472,7 +478,7 @@ namespace osu.Game.Screens.Play if (isDisposing) { // if the player never got pushed, we should explicitly dispose it. - DisposalTask = LoadTask?.ContinueWith(_ => player?.Dispose()); + DisposalTask = LoadTask?.ContinueWith(_ => CurrentPlayer?.Dispose()); } } @@ -480,7 +486,7 @@ namespace osu.Game.Screens.Play #region Mute warning - private Bindable muteWarningShownOnce; + private Bindable muteWarningShownOnce = null!; private int restartCount; @@ -535,7 +541,7 @@ namespace osu.Game.Screens.Play #region Low battery warning - private Bindable batteryWarningShownOnce; + private Bindable batteryWarningShownOnce = null!; private void showBatteryWarningIfNeeded() { diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index c00b2f56dc..42091c521f 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Diagnostics; using System.Linq; @@ -23,8 +25,6 @@ using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics; using osuTK; -#nullable enable - namespace osu.Game.Screens.Play.PlayerSettings { public class BeatmapOffsetControl : CompositeDrawable @@ -122,7 +122,19 @@ namespace osu.Game.Screens.Play.PlayerSettings beatmapOffsetSubscription = realm.SubscribeToPropertyChanged( r => r.Find(beatmap.Value.BeatmapInfo.ID)?.UserSettings, settings => settings.Offset, - val => Current.Value = val); + val => + { + // At the point we reach here, it's not guaranteed that all realm writes have taken place (there may be some in-flight). + // We are only aware of writes that originated from our own flow, so if we do see one that's active we can avoid handling the feedback value arriving. + if (realmWriteTask == null) + Current.Value = val; + + if (realmWriteTask?.IsCompleted == true) + { + // we can also mark any in-flight write that is managed locally as "seen" and start handling any incoming changes again. + realmWriteTask = null; + } + }); Current.BindValueChanged(currentChanged); } @@ -158,10 +170,12 @@ namespace osu.Game.Screens.Play.PlayerSettings if (settings == null) // only the case for tests. return; - if (settings.Offset == Current.Value) + double val = Current.Value; + + if (settings.Offset == val) return; - settings.Offset = Current.Value; + settings.Offset = val; }); } } diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index b27a9c5f5d..e620abb90f 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -73,9 +73,12 @@ namespace osu.Game.Screens.Play [Resolved(canBeNull: true)] private Player player { get; set; } - [Resolved(canBeNull: true)] + [Resolved] private GameplayClock gameplayClock { get; set; } + [Resolved(canBeNull: true)] + private DrawableRuleset drawableRuleset { get; set; } + private IClock referenceClock; public bool UsesFixedAnchor { get; set; } @@ -113,7 +116,7 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, OsuConfigManager config, DrawableRuleset drawableRuleset) + private void load(OsuColour colours, OsuConfigManager config) { base.LoadComplete(); diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 2cf56be659..b1f2bccddf 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -119,7 +119,8 @@ namespace osu.Game.Screens.Play { bool exiting = base.OnExiting(next); - submitScore(Score.DeepClone()); + if (LoadedBeatmapSuccessfully) + submitScore(Score.DeepClone()); return exiting; } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 7e39708e65..5b3129dad6 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -65,10 +65,12 @@ namespace osu.Game.Screens.Ranking.Expanded var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; string creator = metadata.Author.Username; + int? beatmapMaxCombo = scoreManager.GetMaximumAchievableComboAsync(score).GetResultSafely(); + var topStatistics = new List { new AccuracyStatistic(score.Accuracy), - new ComboStatistic(score.MaxCombo, !score.Statistics.TryGetValue(HitResult.Miss, out int missCount) || missCount == 0), + new ComboStatistic(score.MaxCombo, beatmapMaxCombo), new PerformanceStatistic(score), }; @@ -80,8 +82,6 @@ namespace osu.Game.Screens.Ranking.Expanded statisticDisplays.AddRange(topStatistics); statisticDisplays.AddRange(bottomStatistics); - var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely(); - AddInternal(new FillFlowContainer { RelativeSizeAxes = Axes.Both, @@ -224,6 +224,8 @@ namespace osu.Game.Screens.Ranking.Expanded if (score.Date != default) AddInternal(new PlayedOnText(score.Date)); + var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely(); + if (starDifficulty != null) { starAndModDisplay.Add(new StarRatingDisplay(starDifficulty.Value) diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs index b92c244174..0e42ec026a 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs @@ -25,11 +25,11 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics /// Creates a new . /// /// The combo to be displayed. - /// Whether this is a perfect combo. - public ComboStatistic(int combo, bool isPerfect) - : base("combo", combo) + /// The maximum value of . + public ComboStatistic(int combo, int? maxCombo) + : base("combo", combo, maxCombo) { - this.isPerfect = isPerfect; + isPerfect = combo == maxCombo; } public override void Appear() diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index ea531e89c8..1e0aaf9c27 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -152,7 +152,6 @@ namespace osu.Game.Screens.Select public OsuSpriteText VersionLabel { get; private set; } public OsuSpriteText TitleLabel { get; private set; } public OsuSpriteText ArtistLabel { get; private set; } - public BeatmapSetOnlineStatusPill StatusPill { get; private set; } public FillFlowContainer MapperContainer { get; private set; } private Container difficultyColourBar; @@ -169,6 +168,12 @@ namespace osu.Game.Screens.Select [Resolved] private IBindable> mods { get; set; } + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + private ModSettingChangeTracker settingChangeTracker; public WedgeInfoText(WorkingBeatmap working, RulesetInfo userRuleset) @@ -181,7 +186,7 @@ namespace osu.Game.Screens.Select private IBindable starDifficulty; [BackgroundDependencyLoader] - private void load(OsuColour colours, LocalisationManager localisation, BeatmapDifficultyCache difficultyCache) + private void load(LocalisationManager localisation) { var beatmapInfo = working.BeatmapInfo; var metadata = beatmapInfo.Metadata; @@ -255,7 +260,7 @@ namespace osu.Game.Screens.Select Shear = -wedged_container_shear, Alpha = 0f, }, - StatusPill = new BeatmapSetOnlineStatusPill + new BeatmapSetOnlineStatusPill { AutoSizeAxes = Axes.Both, Anchor = Anchor.TopRight, @@ -264,6 +269,7 @@ namespace osu.Game.Screens.Select TextSize = 11, TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, Status = beatmapInfo.Status, + Alpha = string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? 0 : 1 } } }, @@ -311,22 +317,6 @@ namespace osu.Game.Screens.Select titleBinding.BindValueChanged(_ => setMetadata(metadata.Source)); artistBinding.BindValueChanged(_ => setMetadata(metadata.Source), true); - starRatingDisplay.DisplayedStars.BindValueChanged(s => - { - difficultyColourBar.Colour = colours.ForStarDifficulty(s.NewValue); - }, true); - - starDifficulty = difficultyCache.GetBindableDifficulty(beatmapInfo, (cancellationSource = new CancellationTokenSource()).Token); - starDifficulty.BindValueChanged(s => - { - starRatingDisplay.FadeIn(transition_duration); - starRatingDisplay.Current.Value = s.NewValue ?? default; - }); - - // no difficulty means it can't have a status to show - if (string.IsNullOrEmpty(beatmapInfo.DifficultyName)) - StatusPill.Hide(); - addInfoLabels(); } @@ -334,6 +324,23 @@ namespace osu.Game.Screens.Select { base.LoadComplete(); + starRatingDisplay.DisplayedStars.BindValueChanged(s => + { + difficultyColourBar.Colour = colours.ForStarDifficulty(s.NewValue); + }, true); + + starDifficulty = difficultyCache.GetBindableDifficulty(working.BeatmapInfo, (cancellationSource = new CancellationTokenSource()).Token); + starDifficulty.BindValueChanged(s => + { + starRatingDisplay.Current.Value = s.NewValue ?? default; + + // Don't roll the counter on initial display (but still allow it to roll on applying mods etc.) + if (!starRatingDisplay.IsPresent) + starRatingDisplay.FinishTransforms(true); + + starRatingDisplay.FadeIn(transition_duration); + }); + mods.BindValueChanged(m => { settingChangeTracker?.Dispose(); diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index f5b11448f8..2d1a2bce4e 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -37,6 +37,7 @@ using osu.Game.Graphics.UserInterface; using System.Diagnostics; using osu.Game.Screens.Play; using osu.Game.Database; +using osu.Game.Skinning; namespace osu.Game.Screens.Select { @@ -235,6 +236,10 @@ namespace osu.Game.Screens.Select } } }, + new SkinnableTargetContainer(SkinnableTarget.SongSelect) + { + RelativeSizeAxes = Axes.Both, + }, }); if (ShowFooter) diff --git a/osu.Game/Skinning/Components/BigBlackBox.cs b/osu.Game/Skinning/Components/BigBlackBox.cs new file mode 100644 index 0000000000..373e6467e8 --- /dev/null +++ b/osu.Game/Skinning/Components/BigBlackBox.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning.Components +{ + /// + /// Intended to be a test bed for skinning. May be removed at some point in the future. + /// + [UsedImplicitly] + public class BigBlackBox : CompositeDrawable, ISkinnableDrawable + { + public bool UsesFixedAnchor { get; set; } + + [SettingSource("Spinning text", "Whether the big text should spin")] + public Bindable TextSpin { get; } = new BindableBool(); + + [SettingSource("Alpha", "The alpha value of this box")] + public BindableNumber BoxAlpha { get; } = new BindableNumber(1) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + }; + + private readonly Box box; + private readonly OsuSpriteText text; + private readonly OsuTextFlowContainer disclaimer; + + public BigBlackBox() + { + Size = new Vector2(250); + + Masking = true; + CornerRadius = 20; + CornerExponent = 5; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Text = "Big Black Box", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 40) + }, + disclaimer = new OsuTextFlowContainer(st => st.Font = OsuFont.Default.With(size: 10)) + { + Text = "This is intended to be a test component and may disappear in the future!", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding(10), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + TextAnchor = Anchor.TopCentre, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BoxAlpha.BindValueChanged(alpha => box.Alpha = alpha.NewValue, true); + TextSpin.BindValueChanged(spin => + { + if (spin.NewValue) + text.Spin(1000, RotationDirection.Clockwise); + else + text.ClearTransforms(); + }, true); + + disclaimer.FadeOutFromOne(5000, Easing.InQuint); + } + } +} diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 951e3f9cc5..7c6d138f4c 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -70,6 +70,14 @@ namespace osu.Game.Skinning case SkinnableTargetComponent target: switch (target.Target) { + case SkinnableTarget.SongSelect: + var songSelectComponents = new SkinnableTargetComponentsContainer(container => + { + // do stuff when we need to. + }); + + return songSelectComponents; + case SkinnableTarget.MainHUDComponents: var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => { diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs index ce9afd650a..756f229927 100644 --- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs +++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs @@ -5,78 +5,62 @@ using System; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; using osu.Framework.Input.Events; -using osu.Framework.Utils; +using osu.Framework.Logging; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Scoring; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Components; using osuTK; -using osuTK.Graphics; namespace osu.Game.Skinning.Editor { - public class SkinComponentToolbox : ScrollingToolboxGroup + public class SkinComponentToolbox : EditorSidebarSection { - public const float WIDTH = 200; - public Action RequestPlacement; - private const float component_display_scale = 0.8f; + private readonly CompositeDrawable target; - [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor + public SkinComponentToolbox(CompositeDrawable target = null) + : base("Components") { - Combo = { Value = RNG.Next(1, 1000) }, - TotalScore = { Value = RNG.Next(1000, 10000000) } - }; - - [Cached(typeof(HealthProcessor))] - private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); - - public SkinComponentToolbox(float height) - : base("Components", height) - { - RelativeSizeAxes = Axes.None; - Width = WIDTH; + this.target = target; } + private FillFlowContainer fill; + [BackgroundDependencyLoader] private void load() { - FillFlowContainer fill; - Child = fill = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Spacing = new Vector2(20) + Spacing = new Vector2(2) }; + reloadComponents(); + } + + private void reloadComponents() + { + fill.Clear(); + var skinnableTypes = typeof(OsuGame).Assembly.GetTypes() - .Where(t => !t.IsInterface) + .Where(t => !t.IsInterface && !t.IsAbstract) .Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t)) + .OrderBy(t => t.Name) .ToArray(); foreach (var type in skinnableTypes) - { - var component = attemptAddComponent(type); - - if (component != null) - { - component.RequestPlacement = t => RequestPlacement?.Invoke(t); - fill.Add(component); - } - } + attemptAddComponent(type); } - private static ToolboxComponentButton attemptAddComponent(Type type) + private void attemptAddComponent(Type type) { try { @@ -84,14 +68,21 @@ namespace osu.Game.Skinning.Editor Debug.Assert(instance != null); - if (!((ISkinnableDrawable)instance).IsEditable) - return null; + if (!((ISkinnableDrawable)instance).IsEditable) return; - return new ToolboxComponentButton(instance); + fill.Add(new ToolboxComponentButton(instance, target) + { + RequestPlacement = t => RequestPlacement?.Invoke(t) + }); } - catch + catch (DependencyNotRegisteredException) { - return null; + // This loading code relies on try-catching any dependency injection errors to know which components are valid for the current target screen. + // If a screen can't provide the required dependencies, a skinnable component should not be displayed in the list. + } + catch (Exception e) + { + Logger.Error(e, $"Skin component {type} could not be loaded in the editor component list due to an error"); } } @@ -102,51 +93,65 @@ namespace osu.Game.Skinning.Editor public override bool PropagateNonPositionalInputSubTree => false; private readonly Drawable component; + private readonly CompositeDrawable dependencySource; public Action RequestPlacement; private Container innerContainer; - public ToolboxComponentButton(Drawable component) + private const float contracted_size = 60; + private const float expanded_size = 120; + + public ToolboxComponentButton(Drawable component, CompositeDrawable dependencySource) { this.component = component; + this.dependencySource = dependencySource; Enabled.Value = true; RelativeSizeAxes = Axes.X; - Height = 70; + Height = contracted_size; + } + + protected override bool OnHover(HoverEvent e) + { + this.Delay(300).ResizeHeightTo(expanded_size, 500, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + this.ResizeHeightTo(contracted_size, 500, Easing.OutQuint); } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { - BackgroundColour = colours.Gray3; - Content.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 2, - Offset = new Vector2(0, 1), - Colour = Color4.Black.Opacity(0.5f) - }; + BackgroundColour = colourProvider.Background3; AddRange(new Drawable[] { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10) { Bottom = 20 }, + Masking = true, + Child = innerContainer = new DependencyBorrowingContainer(dependencySource) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = component + }, + }, new OsuSpriteText { Text = component.GetType().Name, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding(5), }, - innerContainer = new Container - { - Y = 10, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Scale = new Vector2(component_display_scale), - Masking = true, - Child = component - } }); // adjust provided component to fit / display in a known state. @@ -154,14 +159,17 @@ namespace osu.Game.Skinning.Editor component.Origin = Anchor.Centre; } - protected override void LoadComplete() + protected override void Update() { - base.LoadComplete(); + base.Update(); - if (component.RelativeSizeAxes != Axes.None) + if (component.DrawSize != Vector2.Zero) { - innerContainer.AutoSizeAxes = Axes.None; - innerContainer.Height = 100; + float bestScale = Math.Min( + innerContainer.DrawWidth / component.DrawWidth, + innerContainer.DrawHeight / component.DrawHeight); + + innerContainer.Scale = new Vector2(bestScale); } } @@ -171,5 +179,18 @@ namespace osu.Game.Skinning.Editor return true; } } + + public class DependencyBorrowingContainer : Container + { + private readonly CompositeDrawable donor; + + public DependencyBorrowingContainer(CompositeDrawable donor) + { + this.donor = donor; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => + new DependencyContainer(donor?.Dependencies ?? base.CreateChildDependencies(parent)); + } } } diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index ae5cbc95f0..4cc7e0bcdb 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -15,6 +15,8 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Menus; namespace osu.Game.Skinning.Editor @@ -28,7 +30,7 @@ namespace osu.Game.Skinning.Editor protected override bool StartHidden => true; - private readonly Drawable targetScreen; + private Drawable targetScreen; private OsuTextFlowContainer headerText; @@ -40,89 +42,121 @@ namespace osu.Game.Skinning.Editor [Resolved] private OsuColour colours { get; set; } + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + private bool hasBegunMutating; + private Container content; + + private EditorSidebar componentsSidebar; + private EditorSidebar settingsSidebar; + + public SkinEditor() + { + } + public SkinEditor(Drawable targetScreen) { - this.targetScreen = targetScreen; - - RelativeSizeAxes = Axes.Both; + UpdateTargetScreen(targetScreen); } [BackgroundDependencyLoader] private void load() { + RelativeSizeAxes = Axes.Both; + + const float menu_height = 40; + InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + Child = new GridContainer { - new Container + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - Name = "Top bar", - RelativeSizeAxes = Axes.X, - Depth = float.MinValue, - Height = 40, - Children = new Drawable[] + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + + Content = new[] + { + new Drawable[] { - new EditorMenuBar + new Container { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Items = new[] + Name = "Menu container", + RelativeSizeAxes = Axes.X, + Depth = float.MinValue, + Height = menu_height, + Children = new Drawable[] { - new MenuItem("File") + new EditorMenuBar { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, Items = new[] { - new EditorMenuItem("Save", MenuItemType.Standard, Save), - new EditorMenuItem("Revert to default", MenuItemType.Destructive, revert), - new EditorMenuItemSpacer(), - new EditorMenuItem("Exit", MenuItemType.Standard, Hide), - }, + new MenuItem("File") + { + Items = new[] + { + new EditorMenuItem("Save", MenuItemType.Standard, Save), + new EditorMenuItem("Revert to default", MenuItemType.Destructive, revert), + new EditorMenuItemSpacer(), + new EditorMenuItem("Exit", MenuItemType.Standard, Hide), + }, + }, + } }, - } - }, - headerText = new OsuTextFlowContainer - { - TextAnchor = Anchor.TopRight, - Padding = new MarginPadding(5), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - }, - }, - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension() - }, - Content = new[] - { - new Drawable[] - { - new SkinComponentToolbox(600) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RequestPlacement = placeComponent - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + headerText = new OsuTextFlowContainer { - new SkinBlueprintContainer(targetScreen), - } + TextAnchor = Anchor.TopRight, + Padding = new MarginPadding(5), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + }, }, + }, + }, + new Drawable[] + { + new SkinEditorSceneLibrary + { + RelativeSizeAxes = Axes.X, + }, + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + componentsSidebar = new EditorSidebar(), + content = new Container + { + Depth = float.MaxValue, + RelativeSizeAxes = Axes.Both, + }, + settingsSidebar = new EditorSidebar(), + } + } } - } + }, } } }; @@ -145,6 +179,28 @@ namespace osu.Game.Skinning.Editor hasBegunMutating = false; Scheduler.AddOnce(skinChanged); }, true); + + SelectedComponents.BindCollectionChanged((_, __) => Scheduler.AddOnce(populateSettings), true); + } + + public void UpdateTargetScreen(Drawable targetScreen) + { + this.targetScreen = targetScreen; + + SelectedComponents.Clear(); + + Scheduler.AddOnce(loadBlueprintContainer); + Scheduler.AddOnce(populateSettings); + + void loadBlueprintContainer() + { + content.Child = new SkinBlueprintContainer(targetScreen); + + componentsSidebar.Child = new SkinComponentToolbox(getFirstTarget() as CompositeDrawable) + { + RequestPlacement = placeComponent + }; + } } private void skinChanged() @@ -171,7 +227,7 @@ namespace osu.Game.Skinning.Editor private void placeComponent(Type type) { - var targetContainer = getTarget(SkinnableTarget.MainHUDComponents); + var targetContainer = getFirstTarget(); if (targetContainer == null) return; @@ -192,8 +248,18 @@ namespace osu.Game.Skinning.Editor SelectedComponents.Add(component); } + private void populateSettings() + { + settingsSidebar.Clear(); + + foreach (var component in SelectedComponents.OfType()) + settingsSidebar.Add(new SkinSettingsToolbox(component)); + } + private IEnumerable availableTargets => targetScreen.ChildrenOfType(); + private ISkinnableTarget getFirstTarget() => availableTargets.FirstOrDefault(); + private ISkinnableTarget getTarget(SkinnableTarget target) { return availableTargets.FirstOrDefault(c => c.Target == target); diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index 61c363b019..497283a820 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -1,8 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using JetBrains.Annotations; -using osu.Framework.Bindables; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; @@ -10,6 +11,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; +using osu.Game.Screens; namespace osu.Game.Skinning.Editor { @@ -17,18 +19,23 @@ namespace osu.Game.Skinning.Editor /// A container which handles loading a skin editor on user request for a specified target. /// This also handles the scaling / positioning adjustment of the target. /// - public class SkinEditorOverlay : CompositeDrawable, IKeyBindingHandler + public class SkinEditorOverlay : OverlayContainer, IKeyBindingHandler { - private readonly ScalingContainer target; + private readonly ScalingContainer scalingContainer; + + protected override bool BlockNonPositionalInput => true; [CanBeNull] private SkinEditor skinEditor; - public const float VISIBLE_TARGET_SCALE = 0.8f; + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } - public SkinEditorOverlay(ScalingContainer target) + private OsuScreen lastTargetScreen; + + public SkinEditorOverlay(ScalingContainer scalingContainer) { - this.target = target; + this.scalingContainer = scalingContainer; RelativeSizeAxes = Axes.Both; } @@ -42,70 +49,57 @@ namespace osu.Game.Skinning.Editor Hide(); return true; - - case GlobalAction.ToggleSkinEditor: - Toggle(); - return true; } return false; } - public void Toggle() + protected override void PopIn() { - if (skinEditor == null) - Show(); - else - skinEditor.ToggleVisibility(); - } - - public override void Hide() - { - // base call intentionally omitted. - skinEditor?.Hide(); - } - - public override void Show() - { - // base call intentionally omitted as we have custom behaviour. - if (skinEditor != null) { skinEditor.Show(); return; } - var editor = new SkinEditor(target); - editor.State.BindValueChanged(editorVisibilityChanged); + var editor = new SkinEditor(); + + editor.State.BindValueChanged(visibility => updateComponentVisibility()); skinEditor = editor; - // Schedule ensures that if `Show` is called before this overlay is loaded, - // it will not throw (LoadComponentAsync requires the load target to be in a loaded state). - Schedule(() => + LoadComponentAsync(editor, _ => { if (editor != skinEditor) return; - LoadComponentAsync(editor, _ => - { - if (editor != skinEditor) - return; + AddInternal(editor); - AddInternal(editor); - }); + SetTarget(lastTargetScreen); }); } - private void editorVisibilityChanged(ValueChangedEvent visibility) + protected override void PopOut() => skinEditor?.Hide(); + + private void updateComponentVisibility() { - if (visibility.NewValue == Visibility.Visible) + Debug.Assert(skinEditor != null); + + const float toolbar_padding_requirement = 0.18f; + + if (skinEditor.State.Value == Visibility.Visible) { - target.SetCustomRect(new RectangleF(0.18f, 0.1f, VISIBLE_TARGET_SCALE, VISIBLE_TARGET_SCALE), true); + scalingContainer.SetCustomRect(new RectangleF(toolbar_padding_requirement, 0.2f, 0.8f - toolbar_padding_requirement, 0.7f), true); + + game?.Toolbar.Hide(); + game?.CloseAllOverlays(); } else { - target.SetCustomRect(null); + scalingContainer.SetCustomRect(null); + + if (lastTargetScreen?.HideOverlaysOnEnter != true) + game?.Toolbar.Show(); } } @@ -114,15 +108,41 @@ namespace osu.Game.Skinning.Editor } /// - /// Exit any existing skin editor due to the game state changing. + /// Set a new target screen which will be used to find skinnable components. /// - public void Reset() + public void SetTarget(OsuScreen screen) { - skinEditor?.Save(); - skinEditor?.Hide(); - skinEditor?.Expire(); + lastTargetScreen = screen; - skinEditor = null; + if (skinEditor == null) return; + + skinEditor.Save(); + + // ensure the toolbar is re-hidden even if a new screen decides to try and show it. + updateComponentVisibility(); + + // AddOnce with parameter will ensure the newest target is loaded if there is any overlap. + Scheduler.AddOnce(setTarget, screen); + } + + private void setTarget(OsuScreen target) + { + Debug.Assert(skinEditor != null); + + if (!target.IsLoaded) + { + Scheduler.AddOnce(setTarget, target); + return; + } + + if (skinEditor.State.Value == Visibility.Visible) + skinEditor.UpdateTargetScreen(target); + else + { + skinEditor.Hide(); + skinEditor.Expire(); + skinEditor = null; + } } } } diff --git a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs b/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs new file mode 100644 index 0000000000..d126eff075 --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Skinning.Editor +{ + public class SkinEditorSceneLibrary : CompositeDrawable + { + public const float BUTTON_HEIGHT = 40; + + private const float padding = 10; + + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + + [Resolved] + private IBindable ruleset { get; set; } + + public SkinEditorSceneLibrary() + { + Height = BUTTON_HEIGHT + padding * 2; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider overlayColourProvider) + { + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = overlayColourProvider.Background6, + }, + new OsuScrollContainer(Direction.Horizontal) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new FillFlowContainer + { + Name = "Scene library", + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Spacing = new Vector2(padding), + Padding = new MarginPadding(padding), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Scene library", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding(10), + }, + new SceneButton + { + Text = "Song Select", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Action = () => game?.PerformFromScreen(screen => + { + if (screen is SongSelect) + return; + + screen.Push(new PlaySongSelect()); + }, new[] { typeof(SongSelect) }) + }, + new SceneButton + { + Text = "Gameplay", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Action = () => game?.PerformFromScreen(screen => + { + if (screen is Player) + return; + + var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod(); + if (replayGeneratingMod != null) + screen.Push(new PlayerLoader(() => new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateReplayScore(beatmap, mods)))); + }, new[] { typeof(Player), typeof(SongSelect) }) + }, + } + }, + } + } + }; + } + + public class SceneButton : OsuButton + { + public SceneButton() + { + Width = 100; + Height = BUTTON_HEIGHT; + } + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider overlayColourProvider, OsuColour colours) + { + BackgroundColour = overlayColourProvider?.Background3 ?? colours.Blue3; + Content.CornerRadius = 5; + } + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index bd6d097eb2..d7fb5c0498 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -199,6 +199,12 @@ namespace osu.Game.Skinning.Editor Items = createAnchorItems((d, o) => ((Drawable)d).Origin == o, applyOrigins).ToArray() }; + yield return new OsuMenuItem("Reset position", MenuItemType.Standard, () => + { + foreach (var blueprint in SelectedBlueprints) + ((Drawable)blueprint.Item).Position = Vector2.Zero; + }); + foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; diff --git a/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs b/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs new file mode 100644 index 0000000000..d2823ed0e4 --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs @@ -0,0 +1,30 @@ +// 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.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Screens.Edit.Components; +using osuTK; + +namespace osu.Game.Skinning.Editor +{ + internal class SkinSettingsToolbox : EditorSidebarSection + { + protected override Container Content { get; } + + public SkinSettingsToolbox(Drawable component) + : base($"Settings ({component.GetType().Name})") + { + base.Content.Add(Content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = component.CreateSettingsControls().ToArray() + }); + } + } +} diff --git a/osu.Game/Skinning/ISkinnableDrawable.cs b/osu.Game/Skinning/ISkinnableDrawable.cs index 60b40982e5..3fc6a2fdd8 100644 --- a/osu.Game/Skinning/ISkinnableDrawable.cs +++ b/osu.Game/Skinning/ISkinnableDrawable.cs @@ -1,6 +1,9 @@ // 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.TypeExtensions; using osu.Framework.Graphics; namespace osu.Game.Skinning @@ -21,5 +24,22 @@ namespace osu.Game.Skinning /// If , a fixed anchor point has been defined. /// bool UsesFixedAnchor { get; set; } + + void CopyAdjustedSetting(IBindable target, object source) + { + if (source is IBindable sourceBindable) + { + // copy including transfer of default values. + target.BindTo(sourceBindable); + target.UnbindFrom(sourceBindable); + } + else + { + if (!(target is IParseable parseable)) + throw new InvalidOperationException($"Bindable type {target.GetType().ReadableName()} is not {nameof(IParseable)}."); + + parseable.Parse(source); + } + } } } diff --git a/osu.Game/Skinning/SkinnableTarget.cs b/osu.Game/Skinning/SkinnableTarget.cs index 7b1eae126c..09de8a5d71 100644 --- a/osu.Game/Skinning/SkinnableTarget.cs +++ b/osu.Game/Skinning/SkinnableTarget.cs @@ -5,6 +5,7 @@ namespace osu.Game.Skinning { public enum SkinnableTarget { - MainHUDComponents + MainHUDComponents, + SongSelect } } diff --git a/osu.Game/Stores/BeatmapImporter.cs b/osu.Game/Stores/BeatmapImporter.cs index e6b655589c..f04a0210ef 100644 --- a/osu.Game/Stores/BeatmapImporter.cs +++ b/osu.Game/Stores/BeatmapImporter.cs @@ -163,6 +163,12 @@ namespace osu.Game.Stores return existing.OnlineID == import.OnlineID && existingIds.SequenceEqual(importIds); } + protected override void UndeleteForReuse(BeatmapSetInfo existing) + { + base.UndeleteForReuse(existing); + existing.DateAdded = DateTimeOffset.UtcNow; + } + public override bool IsAvailableLocally(BeatmapSetInfo model) { return Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs index 3011bc0320..1d0e16d549 100644 --- a/osu.Game/Stores/RealmArchiveModelImporter.cs +++ b/osu.Game/Stores/RealmArchiveModelImporter.cs @@ -351,7 +351,8 @@ namespace osu.Game.Stores using (var transaction = realm.BeginWrite()) { - existing.DeletePending = false; + if (existing.DeletePending) + UndeleteForReuse(existing); transaction.Commit(); } @@ -387,7 +388,9 @@ namespace osu.Game.Stores { LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); - existing.DeletePending = false; + if (existing.DeletePending) + UndeleteForReuse(existing); + transaction.Commit(); return existing.ToLive(Realm); @@ -527,6 +530,15 @@ namespace osu.Game.Stores private bool checkAllFilesExist(TModel model) => model.Files.All(f => Files.Storage.Exists(f.File.GetStoragePath())); + /// + /// Called when an existing model is in a soft deleted state but being recovered. + /// + /// The existing model. + protected virtual void UndeleteForReuse(TModel existing) + { + existing.DeletePending = false; + } + /// /// Whether this specified path should be removed after successful import. /// diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index 81623a9307..88cb5f40a1 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -3,7 +3,6 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Textures; @@ -73,31 +72,7 @@ namespace osu.Game.Storyboards.Drawables protected override Vector2 DrawScale => new Vector2(FlipH ? -base.DrawScale.X : base.DrawScale.X, FlipV ? -base.DrawScale.Y : base.DrawScale.Y) * VectorScale; - public override Anchor Origin - { - get - { - var origin = base.Origin; - - if (FlipH) - { - if (origin.HasFlagFast(Anchor.x0)) - origin = Anchor.x2 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - else if (origin.HasFlagFast(Anchor.x2)) - origin = Anchor.x0 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - } - - if (FlipV) - { - if (origin.HasFlagFast(Anchor.y0)) - origin = Anchor.y2 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - else if (origin.HasFlagFast(Anchor.y2)) - origin = Anchor.y0 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - } - - return origin; - } - } + public override Anchor Origin => StoryboardExtensions.AdjustOrigin(base.Origin, VectorScale, FlipH, FlipV); public override bool IsPresent => !float.IsNaN(DrawPosition.X) && !float.IsNaN(DrawPosition.Y) && base.IsPresent; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index eb877f3dff..db10f13896 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -3,7 +3,6 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; @@ -73,31 +72,7 @@ namespace osu.Game.Storyboards.Drawables protected override Vector2 DrawScale => new Vector2(FlipH ? -base.DrawScale.X : base.DrawScale.X, FlipV ? -base.DrawScale.Y : base.DrawScale.Y) * VectorScale; - public override Anchor Origin - { - get - { - var origin = base.Origin; - - if (FlipH) - { - if (origin.HasFlagFast(Anchor.x0)) - origin = Anchor.x2 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - else if (origin.HasFlagFast(Anchor.x2)) - origin = Anchor.x0 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - } - - if (FlipV) - { - if (origin.HasFlagFast(Anchor.y0)) - origin = Anchor.y2 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - else if (origin.HasFlagFast(Anchor.y2)) - origin = Anchor.y0 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - } - - return origin; - } - } + public override Anchor Origin => StoryboardExtensions.AdjustOrigin(base.Origin, VectorScale, FlipH, FlipV); public override bool IsPresent => !float.IsNaN(DrawPosition.X) && !float.IsNaN(DrawPosition.Y) && base.IsPresent; diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 2faed98ae0..b662b98e4e 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -104,7 +104,13 @@ namespace osu.Game.Storyboards drawable = new Sprite { Texture = textureStore.Get(storyboardPath) }; // if the texture isn't available locally in the beatmap, some storyboards choose to source from the underlying skin lookup hierarchy. else if (UseSkinSprites) - drawable = new SkinnableSprite(path); + { + drawable = new SkinnableSprite(path) + { + RelativeSizeAxes = Axes.None, + AutoSizeAxes = Axes.Both, + }; + } return drawable; } diff --git a/osu.Game/Storyboards/StoryboardExtensions.cs b/osu.Game/Storyboards/StoryboardExtensions.cs new file mode 100644 index 0000000000..4e8251c9e7 --- /dev/null +++ b/osu.Game/Storyboards/StoryboardExtensions.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics; +using osuTK; + +namespace osu.Game.Storyboards +{ + public static class StoryboardExtensions + { + /// + /// Given an origin and a set of properties, adjust the origin to display the sprite/animation correctly. + /// + /// The current origin. + /// The vector scale. + /// Whether the element is flipped horizontally. + /// Whether the element is flipped vertically. + /// The adjusted origin. + public static Anchor AdjustOrigin(Anchor origin, Vector2 vectorScale, bool flipH, bool flipV) + { + // Either flip horizontally or negative X scale, but not both. + if (flipH ^ (vectorScale.X < 0)) + { + if (origin.HasFlagFast(Anchor.x0)) + origin = Anchor.x2 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); + else if (origin.HasFlagFast(Anchor.x2)) + origin = Anchor.x0 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); + } + + // Either flip vertically or negative Y scale, but not both. + if (flipV ^ (vectorScale.Y < 0)) + { + if (origin.HasFlagFast(Anchor.y0)) + origin = Anchor.y2 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); + else if (origin.HasFlagFast(Anchor.y2)) + origin = Anchor.y0 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); + } + + return origin; + } + } +} diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index 24015590e2..51221cb8fe 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -7,10 +7,13 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; @@ -93,6 +96,10 @@ namespace osu.Game.Tests.Visual protected class TestEditor : Editor { + [Resolved(canBeNull: true)] + [CanBeNull] + private DialogOverlay dialogOverlay { get; set; } + public new void Undo() => base.Undo(); public new void Redo() => base.Redo(); @@ -111,6 +118,18 @@ namespace osu.Game.Tests.Visual public new bool HasUnsavedChanges => base.HasUnsavedChanges; + public override bool OnExiting(IScreen next) + { + // For testing purposes allow the screen to exit without saving on second attempt. + if (!ExitConfirmed && dialogOverlay?.CurrentDialog is PromptForSaveDialog saveDialog) + { + saveDialog.PerformAction(); + return true; + } + + return base.OnExiting(next); + } + public TestEditor(EditorLoader loader = null) : base(loader) { diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 6dc5159b6f..9be1b18062 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -7,12 +7,15 @@ 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.Bindables; +using osu.Framework.Development; using osu.Framework.Extensions; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; @@ -114,12 +117,24 @@ namespace osu.Game.Tests.Visual.Multiplayer public void ChangeUserState(int userId, MultiplayerUserState newState) { Debug.Assert(Room != null); + ((IMultiplayerClient)this).UserStateChanged(userId, newState); Schedule(() => { switch (Room.State) { + case MultiplayerRoomState.Open: + // If there are no remaining ready users or the host is not ready, stop any existing countdown. + // Todo: When we have an "automatic start" mode, this should also start a new countdown if any users _are_ ready. + // Todo: This doesn't yet support non-match-start countdowns. + bool shouldStopCountdown = Room.Users.All(u => u.State != MultiplayerUserState.Ready); + shouldStopCountdown |= Room.Host?.State != MultiplayerUserState.Ready && Room.Host?.State != MultiplayerUserState.Spectating; + + if (shouldStopCountdown) + countdownStopSource?.Cancel(); + break; + case MultiplayerRoomState.WaitingForLoad: if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad)) { @@ -282,6 +297,16 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + private CancellationTokenSource? countdownSkipSource; + private CancellationTokenSource? countdownStopSource; + private Task countdownTask = Task.CompletedTask; + + /// + /// Skips to the end of the currently-running countdown, if one is running, + /// and runs the callback (e.g. to start the match) as soon as possible unless the countdown has been cancelled. + /// + public void SkipToEndOfCountdown() => countdownSkipSource?.Cancel(); + public override async Task SendMatchRequest(MatchUserRequest request) { Debug.Assert(Room != null); @@ -289,6 +314,67 @@ namespace osu.Game.Tests.Visual.Multiplayer switch (request) { + case StartMatchCountdownRequest matchCountdownRequest: + Debug.Assert(ThreadSafety.IsUpdateThread); + + countdownStopSource?.Cancel(); + + // Note that this will leak CTSs, however this is a test method and we haven't noticed foregoing disposal of non-linked CTSs to be detrimental. + // If necessary, this can be moved into the final schedule below, and the class-level fields be nulled out accordingly. + var stopSource = countdownStopSource = new CancellationTokenSource(); + var skipSource = countdownSkipSource = new CancellationTokenSource(); + var countdown = new MatchStartCountdown { TimeRemaining = matchCountdownRequest.Duration }; + + Task lastCountdownTask = countdownTask; + countdownTask = start(); + + async Task start() + { + await lastCountdownTask; + + Schedule(() => + { + if (stopSource.IsCancellationRequested) + return; + + Room.Countdown = countdown; + MatchEvent(new CountdownChangedEvent { Countdown = countdown }); + }); + + try + { + using (var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(stopSource.Token, skipSource.Token)) + await Task.Delay(matchCountdownRequest.Duration, cancellationSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Clients need to be notified of cancellations in the following code. + } + + Schedule(() => + { + if (Room.Countdown != countdown) + return; + + Room.Countdown = null; + MatchEvent(new CountdownChangedEvent { Countdown = null }); + + if (stopSource.IsCancellationRequested) + return; + + StartMatch().WaitSafely(); + }); + } + + break; + + case StopCountdownRequest _: + countdownStopSource?.Cancel(); + + Room.Countdown = null; + await MatchEvent(new CountdownChangedEvent { Countdown = Room.Countdown }); + break; + case ChangeTeamRequest changeTeam: TeamVersusRoomState roomState = (TeamVersusRoomState)Room.MatchState!; diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index b7aa8af4aa..2deb8686cc 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -127,9 +127,9 @@ namespace osu.Game.Tests.Visual where T : Drawable { if (typeof(T) == typeof(Button)) - AddUntilStep($"wait for {typeof(T).Name} enabled", () => (this.ChildrenOfType().Single() as Button)?.Enabled.Value == true); + AddUntilStep($"wait for {typeof(T).Name} enabled", () => (this.ChildrenOfType().Single() as ClickableContainer)?.Enabled.Value == true); else - AddUntilStep($"wait for {typeof(T).Name} enabled", () => this.ChildrenOfType().Single().ChildrenOfType