diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs index bce855ae45..4133b96d42 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs @@ -72,13 +72,13 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(200); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); } [Test] @@ -100,13 +100,13 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); seekTo(200); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); } [Test] @@ -145,23 +145,23 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first)); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); seekTo(120); AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first)); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); seekTo(480); AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second)); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); seekTo(700); AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second)); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); } [Test] @@ -174,8 +174,8 @@ namespace osu.Game.Rulesets.Taiko.Tests StartTime = 100, Samples = new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), - new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM), + new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong } }; hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); @@ -184,13 +184,13 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); seekTo(200); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); } [Test] @@ -213,18 +213,18 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(600); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(1200); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); } [Test] @@ -247,18 +247,18 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); seekTo(600); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); seekTo(1200); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); } [Test] @@ -272,8 +272,8 @@ namespace osu.Game.Rulesets.Taiko.Tests EndTime = 1100, Samples = new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), - new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM), + new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong } }; drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); @@ -282,18 +282,18 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); seekTo(600); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); seekTo(1200); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); } [Test] @@ -319,18 +319,18 @@ namespace osu.Game.Rulesets.Taiko.Tests // This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits. // But for sample playback purposes they can be ignored as noise. AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(600); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(1200); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); } [Test] @@ -344,7 +344,7 @@ namespace osu.Game.Rulesets.Taiko.Tests EndTime = 1100, Samples = new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum") + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM) } }; swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); @@ -356,25 +356,26 @@ namespace osu.Game.Rulesets.Taiko.Tests // This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits. // But for sample playback purposes they can be ignored as noise. AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM); seekTo(600); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM); seekTo(1200); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM); } - private void checkSound(HitType hitType, string expectedName, string expectedBank) + private void checkSamples(HitType hitType, string expectedSamplesCsv, string expectedBank) { AddStep($"hit {hitType}", () => triggerSource.Play(hitType)); - AddAssert($"last played sample is {expectedName}", () => triggerSource.LastPlayedSamples!.OfType().Single().Name, () => Is.EqualTo(expectedName)); - AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType().Single().Bank, () => Is.EqualTo(expectedBank)); + AddAssert($"last played sample is {expectedSamplesCsv}", () => string.Join(',', triggerSource.LastPlayedSamples!.OfType().Select(s => s.Name)), + () => Is.EqualTo(expectedSamplesCsv)); + AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType().First().Bank, () => Is.EqualTo(expectedBank)); } private void seekTo(double time) => AddStep($"seek to {time}", () => gameplayClock.Seek(time)); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs index 2429b71095..a548a14d88 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs @@ -3,15 +3,16 @@ using System.Collections.Generic; using System.Linq; +using NUnit.Framework; using osu.Framework.Testing; -using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; namespace osu.Game.Rulesets.Taiko.Tests { /// - /// Taiko has some interesting rules for legacy mappings. + /// Taiko doesn't output any samples. They are all handled externally by . /// [HeadlessTest] public partial class TestSceneSampleOutput : TestSceneTaikoPlayer @@ -26,10 +27,10 @@ namespace osu.Game.Rulesets.Taiko.Tests string.Empty, string.Empty, string.Empty, - HitSampleInfo.HIT_FINISH, - HitSampleInfo.HIT_WHISTLE, - HitSampleInfo.HIT_WHISTLE, - HitSampleInfo.HIT_WHISTLE, + string.Empty, + string.Empty, + string.Empty, + string.Empty, }; var actualSampleNames = new List(); @@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Tests AddUntilStep("all samples collected", () => actualSampleNames.Count == expectedSampleNames.Length); - AddAssert("samples are correct", () => actualSampleNames.SequenceEqual(expectedSampleNames)); + AddAssert("samples are correct", () => actualSampleNames, () => Is.EqualTo(expectedSampleNames)); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TaikoBeatmapConversionTest().GetBeatmap("sample-to-type-conversions"); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 62c8457c58..5b79151225 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -4,14 +4,12 @@ #nullable disable using System; -using System.Collections.Generic; using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Events; -using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; @@ -93,40 +91,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables ? new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.CentreHit), _ => new CentreHitCirclePiece(), confineMode: ConfineMode.ScaleToFit) : new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.RimHit), _ => new RimHitCirclePiece(), confineMode: ConfineMode.ScaleToFit); - public override IEnumerable GetSamples() - { - // normal and claps are always handled by the drum (see DrumSampleMapping). - // in addition, whistles are excluded as they are an alternative rim marker. - - var samples = HitObject.Samples.Where(s => - s.Name != HitSampleInfo.HIT_NORMAL - && s.Name != HitSampleInfo.HIT_CLAP - && s.Name != HitSampleInfo.HIT_WHISTLE); - - if (HitObject.Type == HitType.Rim && HitObject.IsStrong) - { - // strong + rim always maps to whistle. - // TODO: this should really be in the legacy decoder, but can't be because legacy encoding parity would be broken. - // when we add a taiko editor, this is probably not going to play nice. - - var corrected = samples.ToList(); - - for (int i = 0; i < corrected.Count; i++) - { - var s = corrected[i]; - - if (s.Name != HitSampleInfo.HIT_FINISH) - continue; - - corrected[i] = s.With(HitSampleInfo.HIT_WHISTLE); - } - - return corrected; - } - - return samples; - } - protected override void CheckForResult(bool userTriggered, double timeOffset) { Debug.Assert(HitObject.HitWindows != null); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 1b5d641612..3f4694d71d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -119,8 +119,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public override bool RemoveWhenNotAlive => false; } - // Most osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource). - public override IEnumerable GetSamples() => Enumerable.Empty(); + // osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource). + public sealed override IEnumerable GetSamples() => Enumerable.Empty(); } public abstract partial class DrawableTaikoHitObject : DrawableTaikoHitObject diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs index 92f2b74568..6098db4f7a 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Game.Audio; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.UI; @@ -18,12 +17,25 @@ namespace osu.Game.Rulesets.Taiko.UI public void Play(HitType hitType) { - var hitSample = GetMostValidObject()?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); + TaikoHitObject? hitObject = GetMostValidObject() as TaikoHitObject; - if (hitSample == null) + if (hitObject == null) return; - PlaySamples(new ISampleInfo[] { new HitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL, hitSample.Bank, volume: hitSample.Volume) }); + var baseSample = hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL); + + if ((hitObject as TaikoStrongableHitObject)?.IsStrong == true || hitObject is StrongNestedHitObject) + { + PlaySamples(new ISampleInfo[] + { + baseSample, + hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_WHISTLE : HitSampleInfo.HIT_FINISH) + }); + } + else + { + PlaySamples(new ISampleInfo[] { baseSample }); + } } public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead"); diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 446eb72b04..84e6a6c00f 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -18,6 +18,7 @@ using osu.Game.Extensions; using osu.Game.Models; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; +using osu.Game.Scoring; using osu.Game.Tests.Resources; using Realms; using SharpCompress.Archives; @@ -416,6 +417,53 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestImport_ThenModifyMapWithScore_ThenImport() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var store = new RealmRulesetStore(realm, storage); + + string? temp = TestResources.GetTestBeatmapForImport(); + + var imported = await LoadOszIntoStore(importer, realm.Realm); + + await createScoreForBeatmap(realm.Realm, imported.Beatmaps.First()); + + // imitate making local changes via editor + // ReSharper disable once MethodHasAsyncOverload + realm.Write(_ => + { + BeatmapInfo beatmap = imported.Beatmaps.First(); + beatmap.Hash = "new_hash"; + beatmap.ResetOnlineInfo(); + }); + + // for now, making changes to a beatmap doesn't remove the backlink from the score to the beatmap. + // the logic of ensuring that scores match the beatmap is upheld via comparing the hash in usages (see: https://github.com/ppy/osu/pull/22539). + // TODO: revisit when fixing https://github.com/ppy/osu/issues/24069. + Assert.That(imported.Beatmaps.First().Scores.Any()); + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + EnsureLoaded(realm.Realm); + + // check the newly "imported" beatmap is not the original. + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + Assert.That(imported.ID != importedSecondTime.ID); + + var importedFirstTimeBeatmap = imported.Beatmaps.First(); + var importedSecondTimeBeatmap = importedSecondTime.PerformRead(s => s.Beatmaps.First()); + + Assert.That(importedFirstTimeBeatmap.ID != importedSecondTimeBeatmap.ID); + Assert.That(importedFirstTimeBeatmap.Hash != importedSecondTimeBeatmap.Hash); + Assert.That(!importedFirstTimeBeatmap.Scores.Any()); + Assert.That(importedSecondTimeBeatmap.Scores.Count() == 1); + }); + } + [Test] public void TestImportThenImportWithChangedFile() { @@ -1074,18 +1122,16 @@ namespace osu.Game.Tests.Database Assert.IsTrue(realm.All().First(_ => true).DeletePending); } - private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) - { - // TODO: reimplement when we have score support in realm. - // return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo - // { - // OnlineID = 2, - // Beatmap = beatmap, - // BeatmapInfoID = beatmap.ID - // }, new ImportScoreTest.TestArchiveReader()); - - return Task.CompletedTask; - } + private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) => + realm.WriteAsync(() => + { + realm.Add(new ScoreInfo + { + OnlineID = 2, + BeatmapInfo = beatmap, + BeatmapHash = beatmap.Hash + }); + }); private static void checkBeatmapSetCount(Realm realm, int expected, bool includeDeletePending = false) { diff --git a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs index b94cff2a9a..83cb54df3f 100644 --- a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs @@ -347,6 +347,73 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestDanglingScoreTransferred() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchive(out string pathOnlineCopy); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + string scoreTargetBeatmapHash = string.Empty; + + // set a score on the beatmap + importBeforeUpdate.PerformWrite(s => + { + var beatmapInfo = s.Beatmaps.First(); + + scoreTargetBeatmapHash = beatmapInfo.Hash; + + s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); + }); + + // locally modify beatmap + const string new_beatmap_hash = "new_hash"; + importBeforeUpdate.PerformWrite(s => + { + var beatmapInfo = s.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash); + + beatmapInfo.Hash = new_beatmap_hash; + beatmapInfo.ResetOnlineInfo(); + }); + + realm.Run(r => r.Refresh()); + + // for now, making changes to a beatmap doesn't remove the backlink from the score to the beatmap. + // the logic of ensuring that scores match the beatmap is upheld via comparing the hash in usages (https://github.com/ppy/osu/pull/22539). + // TODO: revisit when fixing https://github.com/ppy/osu/issues/24069. + checkCount(realm, 1); + + // reimport the original beatmap before local modifications + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOnlineCopy), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + // both original and locally modified versions present + checkCount(realm, count_beatmaps + 1); + checkCount(realm, count_beatmaps + 1); + checkCount(realm, 2); + + // score is preserved + checkCount(realm, 1); + + // score is transferred to new beatmap + Assert.That(importBeforeUpdate.Value.Beatmaps.First(b => b.Hash == new_beatmap_hash).Scores, Has.Count.EqualTo(0)); + Assert.That(importAfterUpdate.Value.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash).Scores, Has.Count.EqualTo(1)); + }); + } + [Test] public void TestScoreLostOnModification() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs index 4d99c47f77..d6934a3770 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs @@ -133,6 +133,32 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("objects reverted to original position", () => addedObjects[0].Position == new Vector2(0)); } + [Test] + public void TestGlobalFlipHotkeys() + { + HitCircle addedObject = null; + + AddStep("add hitobjects", () => EditorBeatmap.Add(addedObject = new HitCircle { StartTime = 100 })); + + AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("flip horizontally across playfield", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.H); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("objects flipped horizontally", () => addedObject.Position == new Vector2(OsuPlayfield.BASE_SIZE.X, 0)); + + AddStep("flip vertically across playfield", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.J); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("objects flipped vertically", () => addedObject.Position == OsuPlayfield.BASE_SIZE); + } + [Test] public void TestBasicSelect() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index d16f51f36e..fea7456472 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -170,7 +170,16 @@ namespace osu.Game.Tests.Visual.Gameplay ManualClock clock = null; var beatmap = new Beatmap(); - beatmap.HitObjects.Add(new TestHitObjectWithNested { Duration = 40 }); + beatmap.HitObjects.Add(new TestHitObjectWithNested + { + Duration = 40, + NestedObjects = new HitObject[] + { + new PooledNestedHitObject { StartTime = 10 }, + new PooledNestedHitObject { StartTime = 20 }, + new PooledNestedHitObject { StartTime = 30 } + } + }); createTest(beatmap, 10, () => new FramedClock(clock = new ManualClock())); @@ -209,6 +218,49 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("object judged", () => playfield.JudgedObjects.Count == 1); } + [Test] + public void TestPooledObjectWithNonPooledNesteds() + { + ManualClock clock = null; + TestHitObjectWithNested hitObjectWithNested; + + var beatmap = new Beatmap(); + beatmap.HitObjects.Add(hitObjectWithNested = new TestHitObjectWithNested + { + Duration = 40, + NestedObjects = new HitObject[] + { + new PooledNestedHitObject { StartTime = 10 }, + new NonPooledNestedHitObject { StartTime = 20 }, + new NonPooledNestedHitObject { StartTime = 30 } + } + }); + + createTest(beatmap, 10, () => new FramedClock(clock = new ManualClock())); + + AddAssert("hitobject entry has all nesteds", () => playfield.HitObjectContainer.Entries.Single().NestedEntries, () => Has.Count.EqualTo(3)); + + AddStep("skip to middle of object", () => clock.CurrentTime = (hitObjectWithNested.StartTime + hitObjectWithNested.GetEndTime()) / 2); + AddAssert("2 objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(2)); + AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False); + + AddStep("skip to before end of object", () => clock.CurrentTime = hitObjectWithNested.GetEndTime() - 1); + AddAssert("3 objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(3)); + AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False); + + AddStep("removing object doesn't crash", () => playfield.Remove(hitObjectWithNested)); + AddStep("clear judged", () => playfield.JudgedObjects.Clear()); + + AddStep("add object back", () => playfield.Add(hitObjectWithNested)); + AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False); + + AddStep("skip to long past object", () => clock.CurrentTime = 100_000); + // the parent entry should still be linked to nested entries of pooled objects that are managed externally + // but not contain synthetic entries that were created for the non-pooled objects. + AddAssert("entry still has non-synthetic nested entries", () => playfield.HitObjectContainer.Entries.Single().NestedEntries, () => Has.Count.EqualTo(1)); + AddAssert("entry all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.True); + } + private void createTest(IBeatmap beatmap, int poolSize, Func createClock = null) { AddStep("create test", () => @@ -289,7 +341,7 @@ namespace osu.Game.Tests.Visual.Gameplay RegisterPool(poolSize); RegisterPool(poolSize); RegisterPool(poolSize); - RegisterPool(poolSize); + RegisterPool(poolSize); } protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) => new TestHitObjectLifetimeEntry(hitObject); @@ -422,16 +474,22 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestHitObjectWithNested : TestHitObject { + public IEnumerable NestedObjects { get; init; } = Array.Empty(); + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { base.CreateNestedHitObjects(cancellationToken); - for (int i = 0; i < 3; ++i) - AddNested(new NestedHitObject { StartTime = (float)Duration * (i + 1) / 4 }); + foreach (var ho in NestedObjects) + AddNested(ho); } } - private class NestedHitObject : ConvertHitObject + private class PooledNestedHitObject : ConvertHitObject + { + } + + private class NonPooledNestedHitObject : ConvertHitObject { } @@ -482,6 +540,9 @@ namespace osu.Game.Tests.Visual.Gameplay nestedContainer.Clear(false); } + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + => hitObject is NonPooledNestedHitObject nonPooled ? new DrawableNestedHitObject(nonPooled) : null; + protected override void CheckForResult(bool userTriggered, double timeOffset) { base.CheckForResult(userTriggered, timeOffset); @@ -490,25 +551,30 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private partial class DrawableNestedHitObject : DrawableHitObject + private partial class DrawableNestedHitObject : DrawableHitObject { public DrawableNestedHitObject() - : this(null) { } - public DrawableNestedHitObject(NestedHitObject hitObject) + public DrawableNestedHitObject(PooledNestedHitObject hitObject) + : base(hitObject) + { + } + + public DrawableNestedHitObject(NonPooledNestedHitObject hitObject) : base(hitObject) { - Size = new Vector2(15); - Colour = Colour4.White; - RelativePositionAxes = Axes.Both; - Origin = Anchor.Centre; } [BackgroundDependencyLoader] private void load() { + Size = new Vector2(15); + Colour = Colour4.White; + RelativePositionAxes = Axes.Both; + Origin = Anchor.Centre; + AddInternal(new Circle { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index aeceece160..4f4a02ccf1 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -188,7 +188,7 @@ namespace osu.Game.Tournament.Components Children = new Drawable[] { new DiffPiece(stats), - new DiffPiece(("Star Rating", $"{beatmap.StarRating:0.##}{srExtra}")) + new DiffPiece(("Star Rating", $"{beatmap.StarRating:0.00}{srExtra}")) } }, new FillFlowContainer diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 7d367ef77d..fd766490fc 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -20,6 +20,7 @@ using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; +using osu.Game.Scoring; using Realms; namespace osu.Game.Beatmaps @@ -204,6 +205,15 @@ namespace osu.Game.Beatmaps protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters) { base.PostImport(model, realm, parameters); + + // Scores are stored separately from beatmaps, and persist even when a beatmap is modified or deleted. + // Let's reattach any matching scores that exist in the database, based on hash. + foreach (BeatmapInfo beatmap in model.Beatmaps) + { + foreach (var score in realm.All().Where(score => score.BeatmapHash == beatmap.Hash)) + score.BeatmapInfo = beatmap; + } + ProcessBeatmap?.Invoke(model, parameters.Batch ? MetadataLookupScope.LocalCacheFirst : MetadataLookupScope.OnlineFirst); } diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs index 19fa3a3d66..e95565a5c8 100644 --- a/osu.Game/Collections/CollectionDropdown.cs +++ b/osu.Game/Collections/CollectionDropdown.cs @@ -188,7 +188,7 @@ namespace osu.Game.Collections { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - X = -OsuScrollContainer.SCROLL_BAR_HEIGHT, + X = -OsuScrollContainer.SCROLL_BAR_WIDTH, Scale = new Vector2(0.65f), Action = addOrRemove, }); diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index a771aa04df..5a6c2e3232 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Runtime.Serialization; using AutoMapper; using AutoMapper.Internal; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Input.Bindings; using osu.Game.Models; @@ -52,10 +54,23 @@ namespace osu.Game.Database { foreach (var beatmap in s.Beatmaps) { - var existing = d.Beatmaps.FirstOrDefault(b => b.ID == beatmap.ID); + // Importantly, search all of realm for the beatmap (not just the set's beatmaps). + // It may have gotten detached, and if that's the case let's use this opportunity to fix + // things up. + var existingBeatmap = d.Realm.Find(beatmap.ID); - if (existing != null) - copyChangesToRealm(beatmap, existing); + if (existingBeatmap != null) + { + // As above, reattach if it happens to not be in the set's beatmaps. + if (!d.Beatmaps.Contains(existingBeatmap)) + { + Debug.Fail("Beatmaps should never become detached under normal circumstances. If this ever triggers, it should be investigated further."); + Logger.Log("WARNING: One of the difficulties in a beatmap was detached from its set. Please save a copy of logs and report this to devs.", LoggingTarget.Database, LogLevel.Important); + d.Beatmaps.Add(existingBeatmap); + } + + copyChangesToRealm(beatmap, existingBeatmap); + } else { var newBeatmap = new BeatmapInfo @@ -64,6 +79,7 @@ namespace osu.Game.Database BeatmapSet = d, Ruleset = d.Realm.Find(beatmap.Ruleset.ShortName) }; + d.Beatmaps.Add(newBeatmap); copyChangesToRealm(beatmap, newBeatmap); } diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index e39fd45a16..da6996c170 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -28,7 +28,7 @@ namespace osu.Game.Graphics.Containers public partial class OsuScrollContainer : ScrollContainer where T : Drawable { - public const float SCROLL_BAR_HEIGHT = 10; + public const float SCROLL_BAR_WIDTH = 10; public const float SCROLL_BAR_PADDING = 3; /// @@ -139,6 +139,8 @@ namespace osu.Game.Graphics.Containers private readonly Box box; + protected override float MinimumDimSize => SCROLL_BAR_WIDTH * 3; + public OsuScrollbar(Direction scrollDir) : base(scrollDir) { @@ -147,7 +149,7 @@ namespace osu.Game.Graphics.Containers CornerRadius = 5; // needs to be set initially for the ResizeTo to respect minimum size - Size = new Vector2(SCROLL_BAR_HEIGHT); + Size = new Vector2(SCROLL_BAR_WIDTH); const float margin = 3; @@ -173,11 +175,10 @@ namespace osu.Game.Graphics.Containers public override void ResizeTo(float val, int duration = 0, Easing easing = Easing.None) { - Vector2 size = new Vector2(SCROLL_BAR_HEIGHT) + this.ResizeTo(new Vector2(SCROLL_BAR_WIDTH) { [(int)ScrollDirection] = val - }; - this.ResizeTo(size, duration, easing); + }, duration, easing); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Localisation/LoginPanelStrings.cs b/osu.Game/Localisation/LoginPanelStrings.cs index 19b0ca3b52..925c2b9146 100644 --- a/osu.Game/Localisation/LoginPanelStrings.cs +++ b/osu.Game/Localisation/LoginPanelStrings.cs @@ -24,6 +24,11 @@ namespace osu.Game.Localisation /// public static LocalisableString SignedIn => new TranslatableString(getKey(@"signed_in"), @"Signed in"); + /// + /// "Sign out" + /// + public static LocalisableString SignOut => new TranslatableString(getKey(@"sign_out"), @"Sign out"); + /// /// "Account" /// diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index b6f2a55e37..44e440e8d9 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -88,6 +88,16 @@ Please try changing your audio device to a working setting."); /// public static LocalisableString LinkTypeNotSupported => new TranslatableString(getKey(@"unsupported_link_type"), @"This link type is not yet supported!"); + /// + /// "You received a private message from '{0}'. Click to read it!" + /// + public static LocalisableString PrivateMessageReceived(string username) => new TranslatableString(getKey(@"private_message_received"), @"You received a private message from '{0}'. Click to read it!", username); + + /// + /// "Your name was mentioned in chat by '{0}'. Click to find out why!" + /// + public static LocalisableString YourNameWasMentioned(string username) => new TranslatableString(getKey(@"your_name_was_mentioned"), @"Your name was mentioned in chat by '{0}'. Click to find out why!", username); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index 52bdd36169..ae249d1b7f 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -154,7 +155,7 @@ namespace osu.Game.Online.Chat : base(message, channel) { Icon = FontAwesome.Solid.Envelope; - Text = $"You received a private message from '{message.Sender.Username}'. Click to read it!"; + Text = NotificationsStrings.PrivateMessageReceived(message.Sender.Username); } } @@ -164,7 +165,7 @@ namespace osu.Game.Online.Chat : base(message, channel) { Icon = FontAwesome.Solid.At; - Text = $"Your name was mentioned in chat by '{message.Sender.Username}'. Click to find out why!"; + Text = NotificationsStrings.YourNameWasMentioned(message.Sender.Username); } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index fe98a8e286..93dd97ea15 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1164,7 +1164,9 @@ namespace osu.Game private void forwardTabletLogsToNotifications() { const string tablet_prefix = @"[Tablet] "; + bool notifyOnWarning = true; + bool notifyOnError = true; Logger.NewEntry += entry => { @@ -1175,6 +1177,11 @@ namespace osu.Game if (entry.Level == LogLevel.Error) { + if (!notifyOnError) + return; + + notifyOnError = false; + Schedule(() => { Notifications.Post(new SimpleNotification @@ -1213,7 +1220,11 @@ namespace osu.Game Schedule(() => { ITabletHandler tablet = Host.AvailableInputHandlers.OfType().SingleOrDefault(); - tablet?.Tablet.BindValueChanged(_ => notifyOnWarning = true, true); + tablet?.Tablet.BindValueChanged(_ => + { + notifyOnWarning = true; + notifyOnError = true; + }, true); }); } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index 104f861df7..1f38e2ed6c 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -185,7 +185,7 @@ namespace osu.Game.Overlays.BeatmapSet OnHovered = beatmap => { showBeatmap(beatmap); - starRating.Text = beatmap.StarRating.ToLocalisableString(@"0.##"); + starRating.Text = beatmap.StarRating.ToLocalisableString(@"0.00"); starRatingContainer.FadeIn(100); }, OnClicked = beatmap => { Beatmap.Value = beatmap; }, diff --git a/osu.Game/Overlays/INotificationOverlay.cs b/osu.Game/Overlays/INotificationOverlay.cs index 6a1b66bbd2..c5ff10c619 100644 --- a/osu.Game/Overlays/INotificationOverlay.cs +++ b/osu.Game/Overlays/INotificationOverlay.cs @@ -44,6 +44,6 @@ namespace osu.Game.Overlays /// /// All ongoing operations (ie. any not in a completed state). /// - public IEnumerable OngoingOperations => AllNotifications.OfType().Where(p => p.State != ProgressNotificationState.Completed); + public IEnumerable OngoingOperations => AllNotifications.OfType().Where(p => p.State != ProgressNotificationState.Completed && p.State != ProgressNotificationState.Cancelled); } } diff --git a/osu.Game/Overlays/Login/UserAction.cs b/osu.Game/Overlays/Login/UserAction.cs index d4d639f2fb..813968a053 100644 --- a/osu.Game/Overlays/Login/UserAction.cs +++ b/osu.Game/Overlays/Login/UserAction.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Login [LocalisableDescription(typeof(LoginPanelStrings), nameof(LoginPanelStrings.AppearOffline))] AppearOffline, - [LocalisableDescription(typeof(UserVerificationStrings), nameof(UserVerificationStrings.BoxInfoLogoutLink))] + [LocalisableDescription(typeof(LoginPanelStrings), nameof(LoginPanelStrings.SignOut))] SignOut, } } diff --git a/osu.Game/Overlays/OverlaySidebar.cs b/osu.Game/Overlays/OverlaySidebar.cs index f1bdfbddac..070f1f0c37 100644 --- a/osu.Game/Overlays/OverlaySidebar.cs +++ b/osu.Game/Overlays/OverlaySidebar.cs @@ -28,7 +28,7 @@ namespace osu.Game.Overlays scrollbarBackground = new Box { RelativeSizeAxes = Axes.Y, - Width = OsuScrollContainer.SCROLL_BAR_HEIGHT, + Width = OsuScrollContainer.SCROLL_BAR_WIDTH, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Alpha = 0.5f diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 07c0d1f8a1..08dcf91e52 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -98,9 +98,9 @@ namespace osu.Game.Rulesets.Objects.Drawables public virtual bool DisplayResult => true; /// - /// Whether this and all of its nested s have been judged. + /// The scoring result of this . /// - public bool AllJudged => Judged && NestedHitObjects.All(h => h.AllJudged); + public JudgementResult Result => Entry?.Result; /// /// Whether this has been hit. This occurs if is hit. @@ -112,12 +112,12 @@ namespace osu.Game.Rulesets.Objects.Drawables /// Whether this has been judged. /// Note: This does NOT include nested hitobjects. /// - public bool Judged => Result?.HasResult ?? true; + public bool Judged => Entry?.Judged ?? true; /// - /// The scoring result of this . + /// Whether this and all of its nested s have been judged. /// - public JudgementResult Result => Entry?.Result; + public bool AllJudged => Entry?.AllJudged ?? true; /// /// The relative X position of this hit object for sample playback balance adjustment. @@ -218,6 +218,8 @@ namespace osu.Game.Rulesets.Objects.Drawables protected sealed override void OnApply(HitObjectLifetimeEntry entry) { + Debug.Assert(Entry != null); + // LifetimeStart is already computed using HitObjectLifetimeEntry's InitialLifetimeOffset. // We override this with DHO's InitialLifetimeOffset for a non-pooled DHO. if (entry is SyntheticHitObjectEntry) @@ -247,6 +249,12 @@ namespace osu.Game.Rulesets.Objects.Drawables drawableNested.ParentHitObject = this; nestedHitObjects.Add(drawableNested); + + // assume that synthetic entries are not pooled and therefore need to be managed from within the DHO. + // this is important for the correctness of value of flags such as `AllJudged`. + if (drawableNested.Entry is SyntheticHitObjectEntry syntheticNestedEntry) + Entry.NestedEntries.Add(syntheticNestedEntry); + AddNestedHitObject(drawableNested); } @@ -290,6 +298,8 @@ namespace osu.Game.Rulesets.Objects.Drawables protected sealed override void OnFree(HitObjectLifetimeEntry entry) { + Debug.Assert(Entry != null); + StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable); if (HitObject is IHasComboInformation combo) @@ -318,6 +328,8 @@ namespace osu.Game.Rulesets.Objects.Drawables } nestedHitObjects.Clear(); + // clean up synthetic entries manually added in `Apply()`. + Entry.NestedEntries.RemoveAll(nestedEntry => nestedEntry is SyntheticHitObjectEntry); ClearNestedHitObjects(); HitObject.DefaultsApplied -= onDefaultsApplied; diff --git a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs index b517f6b9e6..1d99e7440f 100644 --- a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs +++ b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Performance; using osu.Game.Rulesets.Judgements; @@ -19,12 +21,28 @@ namespace osu.Game.Rulesets.Objects /// public readonly HitObject HitObject; + /// + /// The list of for the 's nested objects (if any). + /// + public List NestedEntries { get; internal set; } = new List(); + /// /// The result that was judged with. /// This is set by the accompanying , and reused when required for rewinding. /// internal JudgementResult? Result; + /// + /// Whether has been judged. + /// Note: This does NOT include nested hitobjects. + /// + public bool Judged => Result?.HasResult ?? true; + + /// + /// Whether and all of its nested objects have been judged. + /// + public bool AllJudged => Judged && NestedEntries.All(h => h.AllJudged); + private readonly IBindable startTimeBindable = new BindableDouble(); internal event Action? RevertResult; diff --git a/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs b/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs index 6c39ea44da..fabf4fc444 100644 --- a/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs +++ b/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs @@ -43,11 +43,6 @@ namespace osu.Game.Rulesets.Objects.Pooling /// private readonly Dictionary parentMap = new Dictionary(); - /// - /// Stores the list of child entries for each hit object managed by this . - /// - private readonly Dictionary> childrenMap = new Dictionary>(); - public void Add(HitObjectLifetimeEntry entry, HitObject? parent) { HitObject hitObject = entry.HitObject; @@ -57,22 +52,24 @@ namespace osu.Game.Rulesets.Objects.Pooling // Add the entry. entryMap[hitObject] = entry; - childrenMap[hitObject] = new List(); // If the entry has a parent, set it and add the entry to the parent's children. if (parent != null) { parentMap[entry] = parent; - if (childrenMap.TryGetValue(parent, out var parentChildEntries)) - parentChildEntries.Add(entry); + if (entryMap.TryGetValue(parent, out var parentEntry)) + parentEntry.NestedEntries.Add(entry); } hitObject.DefaultsApplied += onDefaultsApplied; OnEntryAdded?.Invoke(entry, parent); } - public void Remove(HitObjectLifetimeEntry entry) + public bool Remove(HitObjectLifetimeEntry entry) { + if (entry is SyntheticHitObjectEntry) + return false; + HitObject hitObject = entry.HitObject; if (!entryMap.ContainsKey(hitObject)) @@ -81,18 +78,16 @@ namespace osu.Game.Rulesets.Objects.Pooling entryMap.Remove(hitObject); // If the entry has a parent, unset it and remove the entry from the parents' children. - if (parentMap.Remove(entry, out var parent) && childrenMap.TryGetValue(parent, out var parentChildEntries)) - parentChildEntries.Remove(entry); + if (parentMap.Remove(entry, out var parent) && entryMap.TryGetValue(parent, out var parentEntry)) + parentEntry.NestedEntries.Remove(entry); // Remove all the entries' children. - if (childrenMap.Remove(hitObject, out var childEntries)) - { - foreach (var childEntry in childEntries) - Remove(childEntry); - } + foreach (var childEntry in entry.NestedEntries) + Remove(childEntry); hitObject.DefaultsApplied -= onDefaultsApplied; OnEntryRemoved?.Invoke(entry, parent); + return true; } public bool TryGet(HitObject hitObject, [MaybeNullWhen(false)] out HitObjectLifetimeEntry entry) @@ -105,16 +100,16 @@ namespace osu.Game.Rulesets.Objects.Pooling /// private void onDefaultsApplied(HitObject hitObject) { - if (!childrenMap.Remove(hitObject, out var childEntries)) + if (!entryMap.TryGetValue(hitObject, out var entry)) return; - // Remove all the entries' children. At this point the parents' (this entries') children list has been removed from the map, so this does not cause upwards traversal. - foreach (var entry in childEntries) - Remove(entry); + // Replace the entire list rather than clearing to prevent circular traversal later. + var previousEntries = entry.NestedEntries; + entry.NestedEntries = new List(); - // The removed children list needs to be added back to the map for the entry to potentially receive children. - childEntries.Clear(); - childrenMap[hitObject] = childEntries; + // Remove all the entries' children. At this point the parents' (this entries') children list has been reconstructed, so this does not cause upwards traversal. + foreach (var nested in previousEntries) + Remove(nested); } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index d56338c6a4..6dfac419e5 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -82,6 +82,7 @@ namespace osu.Game.Scoring { Ruleset = ruleset ?? new RulesetInfo(); BeatmapInfo = beatmap ?? new BeatmapInfo(); + BeatmapHash = BeatmapInfo.Hash; RealmUser = realmUser ?? new RealmUser(); ID = Guid.NewGuid(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index e93b9f0691..5d9fac739c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -377,10 +377,13 @@ namespace osu.Game.Screens.Edit.Compose.Components float leftExcess = thisQuad.TopLeft.X - parentQuad.TopLeft.X; float rightExcess = parentQuad.TopRight.X - thisQuad.TopRight.X; - if (topExcess + bottomExcess < buttons.Height + button_padding) + float minHeight = buttons.ScreenSpaceDrawQuad.Height; + + if (topExcess < minHeight && bottomExcess < minHeight) { buttons.Anchor = Anchor.BottomCentre; buttons.Origin = Anchor.BottomCentre; + buttons.Y = Math.Min(0, ToLocalSpace(Parent.ScreenSpaceDrawQuad.BottomLeft).Y - DrawHeight); } else if (topExcess > bottomExcess) { diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 5cedf1ca42..052cb18a5d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -160,13 +160,23 @@ namespace osu.Game.Screens.Edit.Compose.Components if (e.Repeat) return false; + bool handled; + switch (e.Action) { case GlobalAction.EditorFlipHorizontally: - return HandleFlip(Direction.Horizontal, true); + ChangeHandler?.BeginChange(); + handled = HandleFlip(Direction.Horizontal, true); + ChangeHandler?.EndChange(); + + return handled; case GlobalAction.EditorFlipVertically: - return HandleFlip(Direction.Vertical, true); + ChangeHandler?.BeginChange(); + handled = HandleFlip(Direction.Vertical, true); + ChangeHandler?.EndChange(); + + return handled; } return false; diff --git a/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs index e6999771d3..5128bc4c14 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.OnlinePlay.Components RelativeSizeAxes = Axes.X; scroll.RelativeSizeAxes = Axes.X; - scroll.Height = ParticipantsList.TILE_SIZE + OsuScrollContainer.SCROLL_BAR_HEIGHT + OsuScrollContainer.SCROLL_BAR_PADDING * 2; + scroll.Height = ParticipantsList.TILE_SIZE + OsuScrollContainer.SCROLL_BAR_WIDTH + OsuScrollContainer.SCROLL_BAR_PADDING * 2; list.RelativeSizeAxes = Axes.Y; list.AutoSizeAxes = Axes.X;