Merge branch 'master' into fix-editor-alt-scroll

This commit is contained in:
Dean Herbert 2021-04-13 19:36:25 +09:00
commit 2c1f20a38f
56 changed files with 1241 additions and 292 deletions

View File

@ -51,7 +51,7 @@
<Reference Include="Java.Interop" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.410.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.412.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.410.0" />
</ItemGroup>
</Project>

View File

@ -69,7 +69,6 @@ namespace osu.Desktop
/// Allow a maximum of one unhandled exception, per second of execution.
/// </summary>
/// <param name="arg"></param>
/// <returns></returns>
private static bool handleException(Exception arg)
{
bool continueExecution = Interlocked.Decrement(ref allowableExceptions) >= 0;

View File

@ -482,7 +482,6 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
/// Retrieves the sample info list at a point in time.
/// </summary>
/// <param name="time">The time to retrieve the sample info list from.</param>
/// <returns></returns>
private IList<HitSampleInfo> sampleInfoListAt(int time) => nodeSamplesAt(time)?.First() ?? HitObject.Samples;
/// <summary>

View File

@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
private void runSpmTest(Mod mod)
{
SpinnerSpmCounter spmCounter = null;
SpinnerSpmCalculator spmCalculator = null;
CreateModTest(new ModTestData
{
@ -53,13 +53,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
PassCondition = () => Player.ScoreProcessor.JudgedHits >= 1
});
AddUntilStep("fetch SPM counter", () =>
AddUntilStep("fetch SPM calculator", () =>
{
spmCounter = this.ChildrenOfType<SpinnerSpmCounter>().SingleOrDefault();
return spmCounter != null;
spmCalculator = this.ChildrenOfType<SpinnerSpmCalculator>().SingleOrDefault();
return spmCalculator != null;
});
AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCounter.SpinsPerMinute, 477, 5));
AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCalculator.Result.Value, 477, 5));
}
}
}

View File

@ -47,8 +47,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Beatmap = singleSpinnerBeatmap,
PassCondition = () =>
{
var counter = Player.ChildrenOfType<SpinnerSpmCounter>().SingleOrDefault();
return counter != null && Precision.AlmostEquals(counter.SpinsPerMinute, 286, 1);
var counter = Player.ChildrenOfType<SpinnerSpmCalculator>().SingleOrDefault();
return counter != null && Precision.AlmostEquals(counter.Result.Value, 286, 1);
}
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -168,13 +168,13 @@ namespace osu.Game.Rulesets.Osu.Tests
double estimatedSpm = 0;
addSeekStep(1000);
AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpmCounter.SpinsPerMinute);
AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpinsPerMinute.Value);
addSeekStep(2000);
AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpmCounter.SpinsPerMinute, estimatedSpm, 1.0));
AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0));
addSeekStep(1000);
AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpmCounter.SpinsPerMinute, estimatedSpm, 1.0));
AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0));
}
[TestCase(0.5)]
@ -188,7 +188,7 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep("retrieve spinner state", () =>
{
expectedProgress = drawableSpinner.Progress;
expectedSpm = drawableSpinner.SpmCounter.SpinsPerMinute;
expectedSpm = drawableSpinner.SpinsPerMinute.Value;
});
addSeekStep(0);
@ -197,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Tests
addSeekStep(1000);
AddAssert("progress almost same", () => Precision.AlmostEquals(expectedProgress, drawableSpinner.Progress, 0.05));
AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpmCounter.SpinsPerMinute, 2.0));
AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0));
}
private Replay applyRateAdjustment(Replay scoreReplay, double rate) => new Replay

View File

@ -30,7 +30,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public new OsuSpinnerJudgementResult Result => (OsuSpinnerJudgementResult)base.Result;
public SpinnerRotationTracker RotationTracker { get; private set; }
public SpinnerSpmCounter SpmCounter { get; private set; }
private SpinnerSpmCalculator spmCalculator;
private Container<DrawableSpinnerTick> ticks;
private PausableSkinnableSound spinningSample;
@ -43,7 +44,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// </summary>
public IBindable<double> GainedBonus => gainedBonus;
private readonly Bindable<double> gainedBonus = new Bindable<double>();
private readonly Bindable<double> gainedBonus = new BindableDouble();
/// <summary>
/// The number of spins per minute this spinner is spinning at, for display purposes.
/// </summary>
public readonly IBindable<double> SpinsPerMinute = new BindableDouble();
private const double fade_out_duration = 160;
@ -63,8 +69,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Origin = Anchor.Centre;
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
AddRangeInternal(new Drawable[]
{
spmCalculator = new SpinnerSpmCalculator
{
Result = { BindTarget = SpinsPerMinute },
},
ticks = new Container<DrawableSpinnerTick>(),
new AspectContainer
{
@ -77,20 +87,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
RotationTracker = new SpinnerRotationTracker(this)
}
},
SpmCounter = new SpinnerSpmCounter
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Y = 120,
Alpha = 0
},
spinningSample = new PausableSkinnableSound
{
Volume = { Value = 0 },
Looping = true,
Frequency = { Value = spinning_sample_initial_frequency }
}
};
});
PositionBindable.BindValueChanged(pos => Position = pos.NewValue);
}
@ -161,17 +164,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
}
}
protected override void UpdateStartTimeStateTransforms()
{
base.UpdateStartTimeStateTransforms();
if (Result?.TimeStarted is double startTime)
{
using (BeginAbsoluteSequence(startTime))
fadeInCounter();
}
}
protected override void UpdateHitStateTransforms(ArmedState state)
{
base.UpdateHitStateTransforms(state);
@ -282,22 +274,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
base.UpdateAfterChildren();
if (!SpmCounter.IsPresent && RotationTracker.Tracking)
{
Result.TimeStarted ??= Time.Current;
fadeInCounter();
}
if (Result.TimeStarted == null && RotationTracker.Tracking)
Result.TimeStarted = Time.Current;
// don't update after end time to avoid the rate display dropping during fade out.
// this shouldn't be limited to StartTime as it causes weirdness with the underlying calculation, which is expecting updates during that period.
if (Time.Current <= HitObject.EndTime)
SpmCounter.SetRotation(Result.RateAdjustedRotation);
spmCalculator.SetRotation(Result.RateAdjustedRotation);
updateBonusScore();
}
private void fadeInCounter() => SpmCounter.FadeIn(HitObject.TimeFadeIn);
private static readonly int score_per_tick = new SpinnerBonusTick.OsuSpinnerBonusTickJudgement().MaxNumericResult;
private int wholeSpins;

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Globalization;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -19,6 +20,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
private OsuSpriteText bonusCounter;
private Container spmContainer;
private OsuSpriteText spmCounter;
public DefaultSpinner()
{
RelativeSizeAxes = Axes.Both;
@ -46,11 +50,37 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
Origin = Anchor.Centre,
Font = OsuFont.Numeric.With(size: 24),
Y = -120,
},
spmContainer = new Container
{
Alpha = 0f,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Y = 120,
Children = new[]
{
spmCounter = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = @"0",
Font = OsuFont.Numeric.With(size: 24)
},
new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = @"SPINS PER MINUTE",
Font = OsuFont.Numeric.With(size: 12),
Y = 30
}
}
}
});
}
private IBindable<double> gainedBonus;
private IBindable<double> spinsPerMinute;
protected override void LoadComplete()
{
@ -63,6 +93,40 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
bonusCounter.FadeOutFromOne(1500);
bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
});
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();
spinsPerMinute.BindValueChanged(spm =>
{
spmCounter.Text = Math.Truncate(spm.NewValue).ToString(@"#0");
}, true);
drawableSpinner.ApplyCustomUpdateState += updateStateTransforms;
updateStateTransforms(drawableSpinner, drawableSpinner.State.Value);
}
protected override void Update()
{
base.Update();
if (!spmContainer.IsPresent && drawableSpinner.Result?.TimeStarted != null)
fadeCounterOnTimeStart();
}
private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state)
{
if (!(drawableHitObject is DrawableSpinner))
return;
fadeCounterOnTimeStart();
}
private void fadeCounterOnTimeStart()
{
if (drawableSpinner.Result?.TimeStarted is double startTime)
{
using (BeginAbsoluteSequence(startTime))
spmContainer.FadeIn(drawableSpinner.HitObject.TimeFadeIn);
}
}
}
}

View File

@ -1,77 +1,37 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public class SpinnerSpmCounter : Container
public class SpinnerSpmCalculator : Component
{
private readonly Queue<RotationRecord> records = new Queue<RotationRecord>();
private const double spm_count_duration = 595; // not using hundreds to avoid frame rounding issues
/// <summary>
/// The resultant spins per minute value, which is updated via <see cref="SetRotation"/>.
/// </summary>
public IBindable<double> Result => result;
private readonly Bindable<double> result = new BindableDouble();
[Resolved]
private DrawableHitObject drawableSpinner { get; set; }
private readonly OsuSpriteText spmText;
public SpinnerSpmCounter()
{
Children = new Drawable[]
{
spmText = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = @"0",
Font = OsuFont.Numeric.With(size: 24)
},
new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = @"SPINS PER MINUTE",
Font = OsuFont.Numeric.With(size: 12),
Y = 30
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
drawableSpinner.HitObjectApplied += resetState;
}
private double spm;
public double SpinsPerMinute
{
get => spm;
private set
{
if (value == spm) return;
spm = value;
spmText.Text = Math.Truncate(value).ToString(@"#0");
}
}
private struct RotationRecord
{
public float Rotation;
public double Time;
}
private readonly Queue<RotationRecord> records = new Queue<RotationRecord>();
private const double spm_count_duration = 595; // not using hundreds to avoid frame rounding issues
public void SetRotation(float currentRotation)
{
// Never calculate SPM by same time of record to avoid 0 / 0 = NaN or X / 0 = Infinity result.
@ -88,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
while (records.Count > 0 && Time.Current - records.Peek().Time > spm_count_duration)
record = records.Dequeue();
SpinsPerMinute = (currentRotation - record.Rotation) / (Time.Current - record.Time) * 1000 * 60 / 360;
result.Value = (currentRotation - record.Rotation) / (Time.Current - record.Time) * 1000 * 60 / 360;
}
records.Enqueue(new RotationRecord { Rotation = currentRotation, Time = Time.Current });
@ -96,7 +56,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
private void resetState(DrawableHitObject hitObject)
{
SpinsPerMinute = 0;
result.Value = 0;
records.Clear();
}
@ -107,5 +67,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
if (drawableSpinner != null)
drawableSpinner.HitObjectApplied -= resetState;
}
private struct RotationRecord
{
public float Rotation;
public double Time;
}
}
}

View File

@ -28,6 +28,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
protected const float SPRITE_SCALE = 0.625f;
private const float spm_hide_offset = 50f;
protected DrawableSpinner DrawableSpinner { get; private set; }
private Sprite spin;
@ -35,6 +37,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private LegacySpriteText bonusCounter;
private Sprite spmBackground;
private LegacySpriteText spmCounter;
[BackgroundDependencyLoader]
private void load(DrawableHitObject drawableHitObject, ISkinSource source)
{
@ -79,11 +84,27 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 299,
}.With(s => s.Font = s.Font.With(fixedWidth: false)),
spmBackground = new Sprite
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopLeft,
Texture = source.GetTexture("spinner-rpm"),
Scale = new Vector2(SPRITE_SCALE),
Position = new Vector2(-87, 445 + spm_hide_offset),
},
spmCounter = new LegacySpriteText(source, LegacyFont.Score)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopRight,
Scale = new Vector2(SPRITE_SCALE * 0.9f),
Position = new Vector2(80, 448 + spm_hide_offset),
}.With(s => s.Font = s.Font.With(fixedWidth: false)),
}
});
}
private IBindable<double> gainedBonus;
private IBindable<double> spinsPerMinute;
private readonly Bindable<bool> completed = new Bindable<bool>();
@ -99,6 +120,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out);
});
spinsPerMinute = DrawableSpinner.SpinsPerMinute.GetBoundCopy();
spinsPerMinute.BindValueChanged(spm =>
{
spmCounter.Text = Math.Truncate(spm.NewValue).ToString(@"#0");
}, true);
completed.BindValueChanged(onCompletedChanged, true);
DrawableSpinner.ApplyCustomUpdateState += UpdateStateTransforms;
@ -142,10 +169,16 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
switch (drawableHitObject)
{
case DrawableSpinner d:
double fadeOutLength = Math.Min(400, d.HitObject.Duration);
using (BeginAbsoluteSequence(d.HitObject.StartTime - d.HitObject.TimeFadeIn))
{
spmBackground.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out);
spmCounter.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out);
}
using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - fadeOutLength, true))
spin.FadeOutFromOne(fadeOutLength);
double spinFadeOutLength = Math.Min(400, d.HitObject.Duration);
using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - spinFadeOutLength, true))
spin.FadeOutFromOne(spinFadeOutLength);
break;
case DrawableSpinnerTick d:

View File

@ -0,0 +1,36 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Online.API;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Tests.Mods
{
[TestFixture]
public class ModSettingsEqualityComparison
{
[Test]
public void Test()
{
var mod1 = new OsuModDoubleTime { SpeedChange = { Value = 1.25 } };
var mod2 = new OsuModDoubleTime { SpeedChange = { Value = 1.26 } };
var mod3 = new OsuModDoubleTime { SpeedChange = { Value = 1.26 } };
var apiMod1 = new APIMod(mod1);
var apiMod2 = new APIMod(mod2);
var apiMod3 = new APIMod(mod3);
Assert.That(mod1, Is.Not.EqualTo(mod2));
Assert.That(apiMod1, Is.Not.EqualTo(apiMod2));
Assert.That(mod2, Is.EqualTo(mod2));
Assert.That(apiMod2, Is.EqualTo(apiMod2));
Assert.That(mod2, Is.EqualTo(mod3));
Assert.That(apiMod2, Is.EqualTo(apiMod3));
Assert.That(mod3, Is.EqualTo(mod2));
Assert.That(apiMod3, Is.EqualTo(apiMod2));
}
}
}

View File

@ -11,7 +11,10 @@ using osu.Game.Online.API;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
namespace osu.Game.Tests.Online
{
@ -84,6 +87,36 @@ namespace osu.Game.Tests.Online
Assert.That(converted?.OverallDifficulty.Value, Is.EqualTo(11));
}
[Test]
public void TestDeserialiseScoreInfoWithEmptyMods()
{
var score = new ScoreInfo { Ruleset = new OsuRuleset().RulesetInfo };
var deserialised = JsonConvert.DeserializeObject<ScoreInfo>(JsonConvert.SerializeObject(score));
if (deserialised != null)
deserialised.Ruleset = new OsuRuleset().RulesetInfo;
Assert.That(deserialised?.Mods.Length, Is.Zero);
}
[Test]
public void TestDeserialiseScoreInfoWithCustomModSetting()
{
var score = new ScoreInfo
{
Ruleset = new OsuRuleset().RulesetInfo,
Mods = new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 2 } } }
};
var deserialised = JsonConvert.DeserializeObject<ScoreInfo>(JsonConvert.SerializeObject(score));
if (deserialised != null)
deserialised.Ruleset = new OsuRuleset().RulesetInfo;
Assert.That(((OsuModDoubleTime)deserialised?.Mods[0])?.SpeedChange.Value, Is.EqualTo(2));
}
private class TestRuleset : Ruleset
{
public override IEnumerable<Mod> GetModsFor(ModType type) => new Mod[]

View File

@ -64,6 +64,13 @@ namespace osu.Game.Tests.Visual.Editing
});
}
protected override void LoadComplete()
{
base.LoadComplete();
Clock.Seek(2500);
}
public abstract Drawable CreateTestComponent();
private class AudioVisualiser : CompositeDrawable

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Online;
using osu.Game.Online.API;
@ -38,6 +39,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
private OsuConfigManager config;
public TestSceneMultiplayerGameplayLeaderboard()
{
base.Content.Children = new Drawable[]
@ -48,6 +51,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
};
}
[BackgroundDependencyLoader]
private void load()
{
Dependencies.Cache(config = new OsuConfigManager(LocalStorage));
}
[SetUpSteps]
public override void SetUpSteps()
{
@ -97,6 +106,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddRepeatStep("mark user quit", () => Client.CurrentMatchPlayingUserIds.RemoveAt(0), users);
}
[Test]
public void TestChangeScoringMode()
{
AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 5);
AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic));
AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised));
}
public class TestMultiplayerStreaming : SpectatorStreamingClient
{
public new BindableList<int> PlayingUsers => (BindableList<int>)base.PlayingUsers;
@ -163,7 +180,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
break;
}
((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, Array.Empty<LegacyReplayFrame>()));
((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) }));
}
}
}

View File

@ -134,7 +134,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
InputManager.Click(MouseButton.Left);
});
AddAssert("match started", () => Client.Room?.State == MultiplayerRoomState.WaitingForLoad);
AddUntilStep("match started", () => Client.Room?.State == MultiplayerRoomState.WaitingForLoad);
}
}
}

View File

@ -0,0 +1,229 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Database;
using osu.Game.Online;
using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osu.Game.Screens.Play.HUD;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiplayerSpectatorLeaderboard : MultiplayerTestScene
{
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
protected override Container<Drawable> Content => content;
private readonly Container content;
private readonly Dictionary<int, ManualClock> clocks = new Dictionary<int, ManualClock>
{
{ 55, new ManualClock() },
{ 56, new ManualClock() }
};
public TestSceneMultiplayerSpectatorLeaderboard()
{
base.Content.AddRange(new Drawable[]
{
streamingClient,
lookupCache,
content = new Container { RelativeSizeAxes = Axes.Both }
});
}
[SetUpSteps]
public new void SetUpSteps()
{
MultiplayerSpectatorLeaderboard leaderboard = null;
AddStep("reset", () =>
{
Clear();
foreach (var (userId, clock) in clocks)
{
streamingClient.EndPlay(userId, 0);
clock.CurrentTime = 0;
}
});
AddStep("create leaderboard", () =>
{
foreach (var (userId, _) in clocks)
streamingClient.StartPlay(userId, 0);
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
var scoreProcessor = new OsuScoreProcessor();
scoreProcessor.ApplyBeatmap(playable);
LoadComponentAsync(leaderboard = new MultiplayerSpectatorLeaderboard(scoreProcessor, clocks.Keys.ToArray()) { Expanded = { Value = true } }, Add);
});
AddUntilStep("wait for load", () => leaderboard.IsLoaded);
AddStep("add clock sources", () =>
{
foreach (var (userId, clock) in clocks)
leaderboard.AddClock(userId, clock);
});
}
[Test]
public void TestLeaderboardTracksCurrentTime()
{
AddStep("send frames", () =>
{
// For user 55, send frames in sets of 1.
// For user 56, send frames in sets of 10.
for (int i = 0; i < 100; i++)
{
streamingClient.SendFrames(55, i, 1);
if (i % 10 == 0)
streamingClient.SendFrames(56, i, 10);
}
});
assertCombo(55, 1);
assertCombo(56, 10);
// Advance to a point where only user 55's frame changes.
setTime(500);
assertCombo(55, 5);
assertCombo(56, 10);
// Advance to a point where both user's frame changes.
setTime(1100);
assertCombo(55, 11);
assertCombo(56, 20);
// Advance user 56 only to a point where its frame changes.
setTime(56, 2100);
assertCombo(55, 11);
assertCombo(56, 30);
// Advance both users beyond their last frame
setTime(101 * 100);
assertCombo(55, 100);
assertCombo(56, 100);
}
[Test]
public void TestNoFrames()
{
assertCombo(55, 0);
assertCombo(56, 0);
}
private void setTime(double time) => AddStep($"set time {time}", () =>
{
foreach (var (_, clock) in clocks)
clock.CurrentTime = time;
});
private void setTime(int userId, double time)
=> AddStep($"set user {userId} time {time}", () => clocks[userId].CurrentTime = time);
private void assertCombo(int userId, int expectedCombo)
=> AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo);
private class TestSpectatorStreamingClient : SpectatorStreamingClient
{
private readonly Dictionary<int, int> userBeatmapDictionary = new Dictionary<int, int>();
private readonly Dictionary<int, bool> userSentStateDictionary = new Dictionary<int, bool>();
public TestSpectatorStreamingClient()
: base(new DevelopmentEndpointConfiguration())
{
}
public void StartPlay(int userId, int beatmapId)
{
userBeatmapDictionary[userId] = beatmapId;
userSentStateDictionary[userId] = false;
sendState(userId, beatmapId);
}
public void EndPlay(int userId, int beatmapId)
{
((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
userSentStateDictionary[userId] = false;
}
public void SendFrames(int userId, int index, int count)
{
var frames = new List<LegacyReplayFrame>();
for (int i = index; i < index + count; i++)
{
var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1;
frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState));
}
var bundle = new FrameDataBundle(new ScoreInfo { Combo = index + count }, frames);
((ISpectatorClient)this).UserSentFrames(userId, bundle);
if (!userSentStateDictionary[userId])
sendState(userId, userBeatmapDictionary[userId]);
}
public override void WatchUser(int userId)
{
if (userSentStateDictionary[userId])
{
// usually the server would do this.
sendState(userId, userBeatmapDictionary[userId]);
}
base.WatchUser(userId);
}
private void sendState(int userId, int beatmapId)
{
((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
userSentStateDictionary[userId] = true;
}
}
private class TestUserLookupCache : UserLookupCache
{
protected override Task<User> ComputeValueAsync(int lookup, CancellationToken token = default)
{
return Task.FromResult(new User
{
Id = lookup,
Username = $"User {lookup}"
});
}
}
}
}

View File

@ -0,0 +1,115 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Utils;
using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiplayerSpectatorPlayerGrid : OsuManualInputManagerTestScene
{
private PlayerGrid grid;
[SetUp]
public void Setup() => Schedule(() =>
{
Child = grid = new PlayerGrid { RelativeSizeAxes = Axes.Both };
});
[Test]
public void TestMaximiseAndMinimise()
{
addCells(2);
assertMaximisation(0, false, true);
assertMaximisation(1, false, true);
clickCell(0);
assertMaximisation(0, true);
assertMaximisation(1, false, true);
clickCell(0);
assertMaximisation(0, false);
assertMaximisation(1, false, true);
clickCell(1);
assertMaximisation(1, true);
assertMaximisation(0, false, true);
clickCell(1);
assertMaximisation(1, false);
assertMaximisation(0, false, true);
}
[Test]
public void TestClickBothCellsSimultaneously()
{
addCells(2);
AddStep("click cell 0 then 1", () =>
{
InputManager.MoveMouseTo(grid.Content.ElementAt(0));
InputManager.Click(MouseButton.Left);
InputManager.MoveMouseTo(grid.Content.ElementAt(1));
InputManager.Click(MouseButton.Left);
});
assertMaximisation(1, true);
assertMaximisation(0, false);
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
[TestCase(4)]
[TestCase(5)]
[TestCase(9)]
[TestCase(11)]
[TestCase(12)]
[TestCase(15)]
[TestCase(16)]
public void TestCellCount(int count)
{
addCells(count);
AddWaitStep("wait for display", 2);
}
private void addCells(int count) => AddStep($"add {count} grid cells", () =>
{
for (int i = 0; i < count; i++)
grid.Add(new GridContent());
});
private void clickCell(int index) => AddStep($"click cell index {index}", () =>
{
InputManager.MoveMouseTo(grid.Content.ElementAt(index));
InputManager.Click(MouseButton.Left);
});
private void assertMaximisation(int index, bool shouldBeMaximised, bool instant = false)
{
string assertionText = $"cell index {index} {(shouldBeMaximised ? "is" : "is not")} maximised";
if (instant)
AddAssert(assertionText, checkAction);
else
AddUntilStep(assertionText, checkAction);
bool checkAction() => Precision.AlmostEquals(grid.MaximisedFacade.DrawSize, grid.Content.ElementAt(index).DrawSize, 10) == shouldBeMaximised;
}
private class GridContent : Box
{
public GridContent()
{
RelativeSizeAxes = Axes.Both;
Colour = new Color4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1f);
}
}
}
}

View File

@ -19,13 +19,12 @@ namespace osu.Game.Tests.Visual.Online
{
UserHistoryGraph graph;
Add(graph = new UserHistoryGraph
Add(graph = new UserHistoryGraph("Test")
{
RelativeSizeAxes = Axes.X,
Height = 200,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
TooltipCounterName = "Test"
});
var values = new[]

View File

@ -141,7 +141,6 @@ namespace osu.Game.Tournament
/// <summary>
/// Add missing player info based on user IDs.
/// </summary>
/// <returns></returns>
private bool addPlayers()
{
bool addedInfo = false;

View File

@ -44,7 +44,6 @@ namespace osu.Game.Beatmaps
/// <summary>
/// Returns statistics for the <see cref="HitObjects"/> contained in this beatmap.
/// </summary>
/// <returns></returns>
IEnumerable<BeatmapStatistic> GetStatistics();
/// <summary>

View File

@ -3,7 +3,6 @@
using System;
using System.Diagnostics;
using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Configuration.Tracking;
using osu.Framework.Extensions;
@ -143,7 +142,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full);
SetDefault(OsuSetting.EditorWaveformOpacity, 1f);
SetDefault(OsuSetting.EditorWaveformOpacity, 0.25f);
}
public OsuConfigManager(Storage storage)
@ -169,14 +168,9 @@ namespace osu.Game.Configuration
int combined = (year * 10000) + monthDay;
if (combined < 20200305)
if (combined < 20210413)
{
// the maximum value of this setting was changed.
// if we don't manually increase this, it causes song select to filter out beatmaps the user expects to see.
var maxStars = (BindableDouble)GetOriginalBindable<double>(OsuSetting.DisplayStarsMaximum);
if (maxStars.Value == 10)
maxStars.Value = maxStars.MaxValue;
SetValue(OsuSetting.EditorWaveformOpacity, 0.25f);
}
}

View File

@ -22,7 +22,6 @@ namespace osu.Game.Configuration
/// </summary>
/// <param name="rulesetId">The ruleset's internal ID.</param>
/// <param name="variant">An optional variant.</param>
/// <returns></returns>
public List<DatabasedSetting> Query(int? rulesetId = null, int? variant = null) =>
ContextFactory.Get().DatabasedSetting.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList();

View File

@ -346,7 +346,6 @@ namespace osu.Game.Graphics.Backgrounds
/// such that the smaller triangles appear on top.
/// </summary>
/// <param name="other"></param>
/// <returns></returns>
public int CompareTo(TriangleParticle other) => other.Scale.CompareTo(Scale);
}
}

View File

@ -22,7 +22,6 @@ namespace osu.Game.IO.Serialization
/// <summary>
/// Creates the default <see cref="JsonSerializerSettings"/> that should be used for all <see cref="IJsonSerializable"/>s.
/// </summary>
/// <returns></returns>
public static JsonSerializerSettings CreateGlobalSettings() => new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,

View File

@ -85,7 +85,6 @@ namespace osu.Game.Input
/// </summary>
/// <param name="rulesetId">The ruleset's internal ID.</param>
/// <param name="variant">An optional variant.</param>
/// <returns></returns>
public List<DatabasedKeyBinding> Query(int? rulesetId = null, int? variant = null) =>
ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList();

View File

@ -11,11 +11,12 @@ using osu.Framework.Bindables;
using osu.Game.Configuration;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
namespace osu.Game.Online.API
{
[MessagePackObject]
public class APIMod : IMod
public class APIMod : IMod, IEquatable<APIMod>
{
[JsonProperty("acronym")]
[Key(0)]
@ -63,7 +64,16 @@ namespace osu.Game.Online.API
return resultMod;
}
public bool Equals(IMod other) => Acronym == other?.Acronym;
public bool Equals(IMod other) => other is APIMod them && Equals(them);
public bool Equals(APIMod other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Acronym == other.Acronym &&
Settings.SequenceEqual(other.Settings, ModSettingsEqualityComparer.Default);
}
public override string ToString()
{
@ -72,5 +82,20 @@ namespace osu.Game.Online.API
return $"{Acronym}";
}
private class ModSettingsEqualityComparer : IEqualityComparer<KeyValuePair<string, object>>
{
public static ModSettingsEqualityComparer Default { get; } = new ModSettingsEqualityComparer();
public bool Equals(KeyValuePair<string, object> x, KeyValuePair<string, object> y)
{
object xValue = ModUtils.GetSettingUnderlyingValue(x.Value);
object yValue = ModUtils.GetSettingUnderlyingValue(y.Value);
return x.Key == y.Key && EqualityComparer<object>.Default.Equals(xValue, yValue);
}
public int GetHashCode(KeyValuePair<string, object> obj) => HashCode.Combine(obj.Key, ModUtils.GetSettingUnderlyingValue(obj.Value));
}
}
}

View File

@ -3,11 +3,10 @@
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using MessagePack;
using MessagePack.Formatters;
using osu.Framework.Bindables;
using osu.Game.Utils;
namespace osu.Game.Online.API
{
@ -24,36 +23,7 @@ namespace osu.Game.Online.API
var stringBytes = new ReadOnlySequence<byte>(Encoding.UTF8.GetBytes(kvp.Key));
writer.WriteString(in stringBytes);
switch (kvp.Value)
{
case Bindable<double> d:
primitiveFormatter.Serialize(ref writer, d.Value, options);
break;
case Bindable<int> i:
primitiveFormatter.Serialize(ref writer, i.Value, options);
break;
case Bindable<float> f:
primitiveFormatter.Serialize(ref writer, f.Value, options);
break;
case Bindable<bool> b:
primitiveFormatter.Serialize(ref writer, b.Value, options);
break;
case IBindable u:
// A mod with unknown (e.g. enum) generic type.
var valueMethod = u.GetType().GetProperty(nameof(IBindable<int>.Value));
Debug.Assert(valueMethod != null);
primitiveFormatter.Serialize(ref writer, valueMethod.GetValue(u), options);
break;
default:
// fall back for non-bindable cases.
primitiveFormatter.Serialize(ref writer, kvp.Value, options);
break;
}
primitiveFormatter.Serialize(ref writer, ModUtils.GetSettingUnderlyingValue(kvp.Value), options);
}
}

View File

@ -433,12 +433,15 @@ namespace osu.Game
if (paths.Length == 0)
return;
var extension = Path.GetExtension(paths.First())?.ToLowerInvariant();
var filesPerExtension = paths.GroupBy(p => Path.GetExtension(p).ToLowerInvariant());
foreach (var importer in fileImporters)
foreach (var groups in filesPerExtension)
{
if (importer.HandledExtensions.Contains(extension))
await importer.Import(paths).ConfigureAwait(false);
foreach (var importer in fileImporters)
{
if (importer.HandledExtensions.Contains(groups.Key))
await importer.Import(groups.ToArray()).ConfigureAwait(false);
}
}
}

View File

@ -15,6 +15,11 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
{
private ProfileLineChart chart;
/// <summary>
/// Text describing the value being plotted on the graph, which will be displayed as a prefix to the value in the history graph tooltip.
/// </summary>
protected abstract string GraphCounterName { get; }
protected ChartProfileSubsection(Bindable<User> user, string headerText)
: base(user, headerText)
{
@ -30,7 +35,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
Left = 20,
Right = 40
},
Child = chart = new ProfileLineChart()
Child = chart = new ProfileLineChart(GraphCounterName)
};
protected override void LoadComplete()

View File

@ -9,6 +9,8 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
{
public class PlayHistorySubsection : ChartProfileSubsection
{
protected override string GraphCounterName => "Plays";
public PlayHistorySubsection(Bindable<User> user)
: base(user, "Play History")
{

View File

@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
private readonly Container<TickLine> rowLinesContainer;
private readonly Container<TickLine> columnLinesContainer;
public ProfileLineChart()
public ProfileLineChart(string graphCounterName)
{
RelativeSizeAxes = Axes.X;
Height = 250;
@ -88,7 +88,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
}
}
},
graph = new UserHistoryGraph
graph = new UserHistoryGraph(graphCounterName)
{
RelativeSizeAxes = Axes.Both
}

View File

@ -9,6 +9,8 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
{
public class ReplaysSubsection : ChartProfileSubsection
{
protected override string GraphCounterName => "Replays Watched";
public ReplaysSubsection(Bindable<User> user)
: base(user, "Replays Watched History")
{

View File

@ -11,25 +11,28 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
{
public class UserHistoryGraph : UserGraph<DateTime, long>
{
private readonly string tooltipCounterName;
[CanBeNull]
public UserHistoryCount[] Values
{
set => Data = value?.Select(v => new KeyValuePair<DateTime, long>(v.Date, v.Count)).ToArray();
}
/// <summary>
/// Text describing the value being plotted on the graph, which will be displayed as a prefix to the value in the <see cref="HistoryGraphTooltip"/>.
/// </summary>
public string TooltipCounterName { get; set; } = "Plays";
public UserHistoryGraph(string tooltipCounterName)
{
this.tooltipCounterName = tooltipCounterName;
}
protected override float GetDataPointHeight(long playCount) => playCount;
protected override UserGraphTooltip GetTooltip() => new HistoryGraphTooltip(TooltipCounterName);
protected override UserGraphTooltip GetTooltip() => new HistoryGraphTooltip(tooltipCounterName);
protected override object GetTooltipContent(DateTime date, long playCount)
{
return new TooltipDisplayContent
{
Name = tooltipCounterName,
Count = playCount.ToString("N0"),
Date = date.ToString("MMMM yyyy")
};
@ -37,14 +40,17 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
protected class HistoryGraphTooltip : UserGraphTooltip
{
private readonly string tooltipCounterName;
public HistoryGraphTooltip(string tooltipCounterName)
: base(tooltipCounterName)
{
this.tooltipCounterName = tooltipCounterName;
}
public override bool SetContent(object content)
{
if (!(content is TooltipDisplayContent info))
if (!(content is TooltipDisplayContent info) || info.Name != tooltipCounterName)
return false;
Counter.Text = info.Count;
@ -55,6 +61,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
private class TooltipDisplayContent
{
public string Name;
public string Count;
public string Date;
}

View File

@ -12,6 +12,7 @@ using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.IO.Serialization;
using osu.Game.Rulesets.UI;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Mods
{
@ -19,7 +20,7 @@ namespace osu.Game.Rulesets.Mods
/// The base class for gameplay modifiers.
/// </summary>
[ExcludeFromDynamicCompile]
public abstract class Mod : IMod, IJsonSerializable
public abstract class Mod : IMod, IEquatable<Mod>, IJsonSerializable
{
/// <summary>
/// The name of this mod.
@ -172,7 +173,19 @@ namespace osu.Game.Rulesets.Mods
target.Parse(source);
}
public bool Equals(IMod other) => GetType() == other?.GetType();
public bool Equals(IMod other) => other is Mod them && Equals(them);
public bool Equals(Mod other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return GetType() == other.GetType() &&
this.GetSettingsSourceProperties().All(pair =>
EqualityComparer<object>.Default.Equals(
ModUtils.GetSettingUnderlyingValue(pair.Item2.GetValue(this)),
ModUtils.GetSettingUnderlyingValue(pair.Item2.GetValue(other))));
}
/// <summary>
/// Reset all custom settings for this mod back to their defaults.

View File

@ -574,7 +574,6 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// Calculate the position to be used for sample playback at a specified X position (0..1).
/// </summary>
/// <param name="position">The lookup X position. Generally should be <see cref="SamplePlaybackPosition"/>.</param>
/// <returns></returns>
protected double CalculateSamplePlaybackBalance(double position)
{
const float balance_adjust_amount = 0.4f;

View File

@ -147,7 +147,6 @@ namespace osu.Game.Rulesets.Objects
/// to 1 (end of the path).
/// </summary>
/// <param name="progress">Ranges from 0 (beginning of the path) to 1 (end of the path).</param>
/// <returns></returns>
public Vector2 PositionAt(double progress)
{
ensureValid();
@ -161,7 +160,6 @@ namespace osu.Game.Rulesets.Objects
/// The first point has a PathType which all other points inherit.
/// </summary>
/// <param name="controlPoint">One of the control points in the segment.</param>
/// <returns></returns>
public List<PathControlPoint> PointsInSegment(PathControlPoint controlPoint)
{
bool found = false;

View File

@ -146,7 +146,6 @@ namespace osu.Game.Rulesets
/// <param name="beatmap">The beatmap to create the hit renderer for.</param>
/// <param name="mods">The <see cref="Mod"/>s to apply.</param>
/// <exception cref="BeatmapInvalidForRulesetException">Unable to successfully load the beatmap to be usable with this ruleset.</exception>
/// <returns></returns>
public abstract DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null);
/// <summary>

View File

@ -62,7 +62,6 @@ namespace osu.Game.Rulesets.Scoring
/// <summary>
/// Retrieves a mapping of <see cref="HitResult"/>s to their timing windows for all allowed <see cref="HitResult"/>s.
/// </summary>
/// <returns></returns>
public IEnumerable<(HitResult result, double length)> GetAllAvailableWindows()
{
for (var result = HitResult.Meh; result <= HitResult.Perfect; ++result)

View File

@ -10,6 +10,7 @@ using Newtonsoft.Json.Converters;
using osu.Framework.Extensions;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
@ -55,9 +56,10 @@ namespace osu.Game.Scoring
[JsonIgnore]
public virtual RulesetInfo Ruleset { get; set; }
private APIMod[] localAPIMods;
private Mod[] mods;
[JsonProperty("mods")]
[JsonIgnore]
[NotMapped]
public Mod[] Mods
{
@ -66,43 +68,50 @@ namespace osu.Game.Scoring
if (mods != null)
return mods;
if (modsJson == null)
if (localAPIMods == null)
return Array.Empty<Mod>();
return getModsFromRuleset(JsonConvert.DeserializeObject<DeserializedMod[]>(modsJson));
var rulesetInstance = Ruleset.CreateInstance();
return apiMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
}
set
{
modsJson = null;
localAPIMods = null;
mods = value;
}
}
private Mod[] getModsFromRuleset(DeserializedMod[] mods) => Ruleset.CreateInstance().GetAllMods().Where(mod => mods.Any(d => d.Acronym == mod.Acronym)).ToArray();
// Used for API serialisation/deserialisation.
[JsonProperty("mods")]
[NotMapped]
private APIMod[] apiMods
{
get
{
if (localAPIMods != null)
return localAPIMods;
private string modsJson;
if (mods == null)
return Array.Empty<APIMod>();
return localAPIMods = mods.Select(m => new APIMod(m)).ToArray();
}
set
{
localAPIMods = value;
// We potentially can't update this yet due to Ruleset being late-bound, so instead update on read as necessary.
mods = null;
}
}
// Used for database serialisation/deserialisation.
[JsonIgnore]
[Column("Mods")]
public string ModsJson
{
get
{
if (modsJson != null)
return modsJson;
if (mods == null)
return null;
return modsJson = JsonConvert.SerializeObject(mods.Select(m => new DeserializedMod { Acronym = m.Acronym }));
}
set
{
modsJson = value;
// we potentially can't update this yet due to Ruleset being late-bound, so instead update on read as necessary.
mods = null;
}
get => JsonConvert.SerializeObject(apiMods);
set => apiMods = JsonConvert.DeserializeObject<APIMod[]>(value);
}
[NotMapped]
@ -251,14 +260,6 @@ namespace osu.Game.Scoring
}
}
[Serializable]
protected class DeserializedMod : IMod
{
public string Acronym { get; set; }
public bool Equals(IMod other) => Acronym == other?.Acronym;
}
public override string ToString() => $"{User} playing {Beatmap}";
public bool Equals(ScoreInfo other)

View File

@ -12,7 +12,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
/// <summary>
/// Whether this <see cref="RadioButton"/> is selected.
/// </summary>
/// <returns></returns>
public readonly BindableBool Selected;
/// <summary>

View File

@ -3,7 +3,6 @@
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osuTK;
namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations
{
@ -12,7 +11,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations
/// </summary>
public class PointVisualisation : Box
{
public const float WIDTH = 1;
public const float MAX_WIDTH = 4;
public PointVisualisation(double startTime)
: this()
@ -27,8 +26,11 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations
RelativePositionAxes = Axes.X;
RelativeSizeAxes = Axes.Y;
Width = WIDTH;
EdgeSmoothness = new Vector2(WIDTH, 0);
Anchor = Anchor.CentreLeft;
Origin = Anchor.Centre;
Width = MAX_WIDTH;
Height = 0.75f;
}
}
}

View File

@ -6,9 +6,7 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts;
@ -33,6 +31,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
[Resolved]
private OsuColour colours { get; set; }
private static readonly int highest_divisor = BindableBeatDivisor.VALID_DIVISORS.Last();
public TimelineTickDisplay()
{
RelativeSizeAxes = Axes.Both;
@ -80,8 +80,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
if (timeline != null)
{
var newRange = (
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X,
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X);
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X,
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X);
if (visibleRange != newRange)
{
@ -100,7 +100,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private void createTicks()
{
int drawableIndex = 0;
int highestDivisor = BindableBeatDivisor.VALID_DIVISORS.Last();
nextMinTick = null;
nextMaxTick = null;
@ -131,25 +130,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
var divisor = BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value);
var colour = BindableBeatDivisor.GetColourFor(divisor, colours);
bool isMainBeat = indexInBar == 0;
// even though "bar lines" take up the full vertical space, we render them in two pieces because it allows for less anchor/origin churn.
float height = isMainBeat ? 0.5f : 0.4f - (float)divisor / highestDivisor * 0.2f;
float gradientOpacity = isMainBeat ? 1 : 0;
var topPoint = getNextUsablePoint();
topPoint.X = xPos;
topPoint.Height = height;
topPoint.Colour = ColourInfo.GradientVertical(colour, colour.Opacity(gradientOpacity));
topPoint.Anchor = Anchor.TopLeft;
topPoint.Origin = Anchor.TopCentre;
var bottomPoint = getNextUsablePoint();
bottomPoint.X = xPos;
bottomPoint.Anchor = Anchor.BottomLeft;
bottomPoint.Colour = ColourInfo.GradientVertical(colour.Opacity(gradientOpacity), colour);
bottomPoint.Origin = Anchor.BottomCentre;
bottomPoint.Height = height;
var line = getNextUsableLine();
line.X = xPos;
line.Width = PointVisualisation.MAX_WIDTH * getWidth(indexInBar, divisor);
line.Height = 0.9f * getHeight(indexInBar, divisor);
line.Colour = colour;
}
beat++;
@ -168,7 +155,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
tickCache.Validate();
Drawable getNextUsablePoint()
Drawable getNextUsableLine()
{
PointVisualisation point;
if (drawableIndex >= Count)
@ -183,6 +170,54 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
}
}
private static float getWidth(int indexInBar, int divisor)
{
if (indexInBar == 0)
return 1;
switch (divisor)
{
case 1:
case 2:
return 0.6f;
case 3:
case 4:
return 0.5f;
case 6:
case 8:
return 0.4f;
default:
return 0.3f;
}
}
private static float getHeight(int indexInBar, int divisor)
{
if (indexInBar == 0)
return 1;
switch (divisor)
{
case 1:
case 2:
return 0.9f;
case 3:
case 4:
return 0.8f;
case 6:
case 8:
return 0.7f;
default:
return 0.6f;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

View File

@ -0,0 +1,72 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using JetBrains.Annotations;
using osu.Framework.Timing;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
public class MultiplayerSpectatorLeaderboard : MultiplayerGameplayLeaderboard
{
public MultiplayerSpectatorLeaderboard(ScoreProcessor scoreProcessor, int[] userIds)
: base(scoreProcessor, userIds)
{
}
public void AddClock(int userId, IClock clock)
{
if (!UserScores.TryGetValue(userId, out var data))
return;
((SpectatingTrackedUserData)data).Clock = clock;
}
public void RemoveClock(int userId)
{
if (!UserScores.TryGetValue(userId, out var data))
return;
((SpectatingTrackedUserData)data).Clock = null;
}
protected override TrackedUserData CreateUserData(int userId, ScoreProcessor scoreProcessor) => new SpectatingTrackedUserData(userId, scoreProcessor);
protected override void Update()
{
base.Update();
foreach (var (_, data) in UserScores)
data.UpdateScore();
}
private class SpectatingTrackedUserData : TrackedUserData
{
[CanBeNull]
public IClock Clock;
public SpectatingTrackedUserData(int userId, ScoreProcessor scoreProcessor)
: base(userId, scoreProcessor)
{
}
public override void UpdateScore()
{
if (Frames.Count == 0)
return;
if (Clock == null)
return;
int frameIndex = Frames.BinarySearch(new TimedFrame(Clock.CurrentTime));
if (frameIndex < 0)
frameIndex = ~frameIndex;
frameIndex = Math.Clamp(frameIndex - 1, 0, Frames.Count - 1);
SetFrame(Frames[frameIndex]);
}
}
}
}

View File

@ -0,0 +1,167 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A grid of players playing the multiplayer match.
/// </summary>
public partial class PlayerGrid : CompositeDrawable
{
private const float player_spacing = 5;
/// <summary>
/// The currently-maximised facade.
/// </summary>
public Drawable MaximisedFacade => maximisedFacade;
private readonly Facade maximisedFacade;
private readonly Container paddingContainer;
private readonly FillFlowContainer<Facade> facadeContainer;
private readonly Container<Cell> cellContainer;
public PlayerGrid()
{
InternalChildren = new Drawable[]
{
paddingContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(player_spacing),
Children = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Child = facadeContainer = new FillFlowContainer<Facade>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(player_spacing),
}
},
maximisedFacade = new Facade { RelativeSizeAxes = Axes.Both }
}
},
cellContainer = new Container<Cell> { RelativeSizeAxes = Axes.Both }
};
}
/// <summary>
/// Adds a new cell with content to this grid.
/// </summary>
/// <param name="content">The content the cell should contain.</param>
/// <exception cref="InvalidOperationException">If more than 16 cells are added.</exception>
public void Add(Drawable content)
{
if (cellContainer.Count == 16)
throw new InvalidOperationException("Only 16 cells are supported.");
int index = cellContainer.Count;
var facade = new Facade();
facadeContainer.Add(facade);
var cell = new Cell(index, content) { ToggleMaximisationState = toggleMaximisationState };
cell.SetFacade(facade);
cellContainer.Add(cell);
}
/// <summary>
/// The content added to this grid.
/// </summary>
public IEnumerable<Drawable> Content => cellContainer.OrderBy(c => c.FacadeIndex).Select(c => c.Content);
// A depth value that gets decremented every time a new instance is maximised in order to reduce underlaps.
private float maximisedInstanceDepth;
private void toggleMaximisationState(Cell target)
{
// Iterate through all cells to ensure only one is maximised at any time.
foreach (var i in cellContainer.ToList())
{
if (i == target)
i.IsMaximised = !i.IsMaximised;
else
i.IsMaximised = false;
if (i.IsMaximised)
{
// Transfer cell to the maximised facade.
i.SetFacade(maximisedFacade);
cellContainer.ChangeChildDepth(i, maximisedInstanceDepth -= 0.001f);
}
else
{
// Transfer cell back to its original facade.
i.SetFacade(facadeContainer[i.FacadeIndex]);
}
}
}
protected override void Update()
{
base.Update();
// Different layouts are used for varying cell counts in order to maximise dimensions.
Vector2 cellsPerDimension;
switch (facadeContainer.Count)
{
case 1:
cellsPerDimension = Vector2.One;
break;
case 2:
cellsPerDimension = new Vector2(2, 1);
break;
case 3:
case 4:
cellsPerDimension = new Vector2(2);
break;
case 5:
case 6:
cellsPerDimension = new Vector2(3, 2);
break;
case 7:
case 8:
case 9:
// 3 rows / 3 cols.
cellsPerDimension = new Vector2(3);
break;
case 10:
case 11:
case 12:
// 3 rows / 4 cols.
cellsPerDimension = new Vector2(4, 3);
break;
default:
// 4 rows / 4 cols.
cellsPerDimension = new Vector2(4);
break;
}
// Total inter-cell spacing.
Vector2 totalCellSpacing = player_spacing * (cellsPerDimension - Vector2.One);
Vector2 fullSize = paddingContainer.ChildSize - totalCellSpacing;
Vector2 cellSize = Vector2.Divide(fullSize, new Vector2(cellsPerDimension.X, cellsPerDimension.Y));
foreach (var cell in facadeContainer)
cell.Size = cellSize;
}
}
}

View File

@ -0,0 +1,99 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using JetBrains.Annotations;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
public partial class PlayerGrid
{
/// <summary>
/// A cell of the grid. Contains the content and tracks to the linked facade.
/// </summary>
private class Cell : CompositeDrawable
{
/// <summary>
/// The index of the original facade of this cell.
/// </summary>
public readonly int FacadeIndex;
/// <summary>
/// The contained content.
/// </summary>
public readonly Drawable Content;
/// <summary>
/// An action that toggles the maximisation state of this cell.
/// </summary>
public Action<Cell> ToggleMaximisationState;
/// <summary>
/// Whether this cell is currently maximised.
/// </summary>
public bool IsMaximised;
private Facade facade;
private bool isTracking = true;
public Cell(int facadeIndex, Drawable content)
{
FacadeIndex = facadeIndex;
Origin = Anchor.Centre;
InternalChild = Content = content;
}
protected override void Update()
{
base.Update();
if (isTracking)
{
Position = getFinalPosition();
Size = getFinalSize();
}
}
/// <summary>
/// Makes this cell track a new facade.
/// </summary>
public void SetFacade([NotNull] Facade newFacade)
{
Facade lastFacade = facade;
facade = newFacade;
if (lastFacade == null || lastFacade == newFacade)
return;
isTracking = false;
this.MoveTo(getFinalPosition(), 400, Easing.OutQuint).ResizeTo(getFinalSize(), 400, Easing.OutQuint)
.Then()
.OnComplete(_ =>
{
if (facade == newFacade)
isTracking = true;
});
}
private Vector2 getFinalPosition()
{
var topLeft = Parent.ToLocalSpace(facade.ToScreenSpace(Vector2.Zero));
return topLeft + facade.DrawSize / 2;
}
private Vector2 getFinalSize() => facade.DrawSize;
protected override bool OnClick(ClickEvent e)
{
ToggleMaximisationState(this);
return true;
}
}
}
}

View File

@ -0,0 +1,22 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
public partial class PlayerGrid
{
/// <summary>
/// A facade of the grid which is used as a dummy object to store the required position/size of cells.
/// </summary>
private class Facade : Drawable
{
public Facade()
{
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
}
}
}
}

View File

@ -1,10 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Configuration;
@ -19,9 +19,7 @@ namespace osu.Game.Screens.Play.HUD
[LongRunningLoad]
public class MultiplayerGameplayLeaderboard : GameplayLeaderboard
{
private readonly ScoreProcessor scoreProcessor;
private readonly Dictionary<int, TrackedUserData> userScores = new Dictionary<int, TrackedUserData>();
protected readonly Dictionary<int, TrackedUserData> UserScores = new Dictionary<int, TrackedUserData>();
[Resolved]
private SpectatorStreamingClient streamingClient { get; set; }
@ -32,9 +30,9 @@ namespace osu.Game.Screens.Play.HUD
[Resolved]
private UserLookupCache userLookupCache { get; set; }
private Bindable<ScoringMode> scoringMode;
private readonly ScoreProcessor scoreProcessor;
private readonly BindableList<int> playingUsers;
private Bindable<ScoringMode> scoringMode;
/// <summary>
/// Construct a new leaderboard.
@ -53,6 +51,8 @@ namespace osu.Game.Screens.Play.HUD
[BackgroundDependencyLoader]
private void load(OsuConfigManager config, IAPIProvider api)
{
scoringMode = config.GetBindable<ScoringMode>(OsuSetting.ScoreDisplayMode);
foreach (var userId in playingUsers)
{
streamingClient.WatchUser(userId);
@ -60,19 +60,17 @@ namespace osu.Game.Screens.Play.HUD
// probably won't be required in the final implementation.
var resolvedUser = userLookupCache.GetUserAsync(userId).Result;
var trackedUser = new TrackedUserData();
var trackedUser = CreateUserData(userId, scoreProcessor);
trackedUser.ScoringMode.BindTo(scoringMode);
userScores[userId] = trackedUser;
var leaderboardScore = AddPlayer(resolvedUser, resolvedUser?.Id == api.LocalUser.Value.Id);
leaderboardScore.Accuracy.BindTo(trackedUser.Accuracy);
leaderboardScore.TotalScore.BindTo(trackedUser.Score);
leaderboardScore.Combo.BindTo(trackedUser.CurrentCombo);
leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit);
((IBindable<double>)leaderboardScore.Accuracy).BindTo(trackedUser.Accuracy);
((IBindable<double>)leaderboardScore.TotalScore).BindTo(trackedUser.Score);
((IBindable<int>)leaderboardScore.Combo).BindTo(trackedUser.CurrentCombo);
((IBindable<bool>)leaderboardScore.HasQuit).BindTo(trackedUser.UserQuit);
UserScores[userId] = trackedUser;
}
scoringMode = config.GetBindable<ScoringMode>(OsuSetting.ScoreDisplayMode);
scoringMode.BindValueChanged(updateAllScores, true);
}
protected override void LoadComplete()
@ -102,7 +100,7 @@ namespace osu.Game.Screens.Play.HUD
{
streamingClient.StopWatchingUser(userId);
if (userScores.TryGetValue(userId, out var trackedData))
if (UserScores.TryGetValue(userId, out var trackedData))
trackedData.MarkUserQuit();
}
@ -110,20 +108,16 @@ namespace osu.Game.Screens.Play.HUD
}
}
private void updateAllScores(ValueChangedEvent<ScoringMode> mode)
private void handleIncomingFrames(int userId, FrameDataBundle bundle) => Schedule(() =>
{
foreach (var trackedData in userScores.Values)
trackedData.UpdateScore(scoreProcessor, mode.NewValue);
}
if (!UserScores.TryGetValue(userId, out var trackedData))
return;
private void handleIncomingFrames(int userId, FrameDataBundle bundle)
{
if (userScores.TryGetValue(userId, out var trackedData))
{
trackedData.LastHeader = bundle.Header;
trackedData.UpdateScore(scoreProcessor, scoringMode.Value);
}
}
trackedData.Frames.Add(new TimedFrame(bundle.Frames.First().Time, bundle.Header));
trackedData.UpdateScore();
});
protected virtual TrackedUserData CreateUserData(int userId, ScoreProcessor scoreProcessor) => new TrackedUserData(userId, scoreProcessor);
protected override void Dispose(bool isDisposing)
{
@ -140,38 +134,65 @@ namespace osu.Game.Screens.Play.HUD
}
}
private class TrackedUserData
protected class TrackedUserData
{
public IBindableNumber<double> Score => score;
public readonly int UserId;
public readonly ScoreProcessor ScoreProcessor;
private readonly BindableDouble score = new BindableDouble();
public readonly BindableDouble Score = new BindableDouble();
public readonly BindableDouble Accuracy = new BindableDouble(1);
public readonly BindableInt CurrentCombo = new BindableInt();
public readonly BindableBool UserQuit = new BindableBool();
public IBindableNumber<double> Accuracy => accuracy;
public readonly IBindable<ScoringMode> ScoringMode = new Bindable<ScoringMode>();
private readonly BindableDouble accuracy = new BindableDouble(1);
public readonly List<TimedFrame> Frames = new List<TimedFrame>();
public IBindableNumber<int> CurrentCombo => currentCombo;
private readonly BindableInt currentCombo = new BindableInt();
public IBindable<bool> UserQuit => userQuit;
private readonly BindableBool userQuit = new BindableBool();
[CanBeNull]
public FrameHeader LastHeader;
public void MarkUserQuit() => userQuit.Value = true;
public void UpdateScore(ScoreProcessor processor, ScoringMode mode)
public TrackedUserData(int userId, ScoreProcessor scoreProcessor)
{
if (LastHeader == null)
UserId = userId;
ScoreProcessor = scoreProcessor;
ScoringMode.BindValueChanged(_ => UpdateScore());
}
public void MarkUserQuit() => UserQuit.Value = true;
public virtual void UpdateScore()
{
if (Frames.Count == 0)
return;
score.Value = processor.GetImmediateScore(mode, LastHeader.MaxCombo, LastHeader.Statistics);
accuracy.Value = LastHeader.Accuracy;
currentCombo.Value = LastHeader.Combo;
SetFrame(Frames.Last());
}
protected void SetFrame(TimedFrame frame)
{
var header = frame.Header;
Score.Value = ScoreProcessor.GetImmediateScore(ScoringMode.Value, header.MaxCombo, header.Statistics);
Accuracy.Value = header.Accuracy;
CurrentCombo.Value = header.Combo;
}
}
protected class TimedFrame : IComparable<TimedFrame>
{
public readonly double Time;
public readonly FrameHeader Header;
public TimedFrame(double time)
{
Time = time;
}
public TimedFrame(double time, FrameHeader header)
{
Time = time;
Header = header;
}
public int CompareTo(TimedFrame other) => Time.CompareTo(other.Time);
}
}
}

View File

@ -109,7 +109,12 @@ namespace osu.Game.Screens.Play
request.Success += s =>
{
score.ScoreInfo.OnlineScoreID = s.ID;
// For the time being, online ID responses are not really useful for anything.
// In addition, the IDs provided via new (lazer) endpoints are based on a different autoincrement from legacy (stable) scores.
//
// Until we better define the server-side logic behind this, let's not store the online ID to avoid potential unique constraint
// conflicts across various systems (ie. solo and multiplayer).
// score.ScoreInfo.OnlineScoreID = s.ID;
tcs.SetResult(true);
};

View File

@ -226,7 +226,6 @@ namespace osu.Game.Screens.Ranking
/// <summary>
/// Enumerates all <see cref="ScorePanel"/>s contained in this <see cref="ScorePanelList"/>.
/// </summary>
/// <returns></returns>
public IEnumerable<ScorePanel> GetScorePanels() => flow.Select(t => t.Panel);
/// <summary>

View File

@ -189,7 +189,6 @@ namespace osu.Game.Tests.Beatmaps
/// <summary>
/// Creates the <see cref="Ruleset"/> applicable to this <see cref="BeatmapConversionTest{TConvertMapping,TConvertValue}"/>.
/// </summary>
/// <returns></returns>
protected abstract Ruleset CreateRuleset();
private class ConvertResult

View File

@ -17,7 +17,6 @@ namespace osu.Game.Tests.Beatmaps
/// <summary>
/// Creates the <see cref="Ruleset"/> whose legacy mod conversion is to be tested.
/// </summary>
/// <returns></returns>
protected abstract Ruleset CreateRuleset();
protected void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods)

View File

@ -3,8 +3,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using osu.Framework.Bindables;
using osu.Game.Online.API;
using osu.Game.Rulesets.Mods;
#nullable enable
@ -129,5 +132,38 @@ namespace osu.Game.Utils
else
yield return mod;
}
/// <summary>
/// Returns the underlying value of the given mod setting object.
/// Used in <see cref="APIMod"/> for serialization and equality comparison purposes.
/// </summary>
/// <param name="setting">The mod setting.</param>
public static object GetSettingUnderlyingValue(object setting)
{
switch (setting)
{
case Bindable<double> d:
return d.Value;
case Bindable<int> i:
return i.Value;
case Bindable<float> f:
return f.Value;
case Bindable<bool> b:
return b.Value;
case IBindable u:
// A mod with unknown (e.g. enum) generic type.
var valueMethod = u.GetType().GetProperty(nameof(IBindable<int>.Value));
Debug.Assert(valueMethod != null);
return valueMethod.GetValue(u);
default:
// fall back for non-bindable cases.
return setting;
}
}
}
}

View File

@ -37,7 +37,6 @@ namespace osu.Game.Utils
/// Shortcase for: <c>optional.HasValue ? optional.Value : fallback</c>.
/// </remarks>
/// <param name="fallback">The fallback value to return if <see cref="HasValue"/> is <c>false</c>.</param>
/// <returns></returns>
public T GetOr(T fallback) => HasValue ? Value : fallback;
public static implicit operator Optional<T>(T value) => new Optional<T>(value);

View File

@ -30,7 +30,7 @@
<PackageReference Include="Microsoft.NETCore.Targets" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.410.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.410.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.412.0" />
<PackageReference Include="Sentry" Version="3.2.0" />
<PackageReference Include="SharpCompress" Version="0.28.1" />
<PackageReference Include="NUnit" Version="3.13.1" />

View File

@ -71,7 +71,7 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.410.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.410.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.412.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
<PropertyGroup>