1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-28 02:02:53 +08:00

Merge branch 'master' into fix-overzealousmouse-button-blocking

This commit is contained in:
Dean Herbert 2021-04-05 22:07:41 +09:00
commit 38e95a0e73
49 changed files with 731 additions and 238 deletions

View File

@ -52,6 +52,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.211.1" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2021.211.1" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.329.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2021.402.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@ -18,6 +19,7 @@ using osu.Framework.Screens;
using osu.Game.Screens.Menu; using osu.Game.Screens.Menu;
using osu.Game.Updater; using osu.Game.Updater;
using osu.Desktop.Windows; using osu.Desktop.Windows;
using osu.Framework.Threading;
using osu.Game.IO; using osu.Game.IO;
namespace osu.Desktop namespace osu.Desktop
@ -144,13 +146,39 @@ namespace osu.Desktop
desktopWindow.DragDrop += f => fileDrop(new[] { f }); desktopWindow.DragDrop += f => fileDrop(new[] { f });
} }
private readonly List<string> importableFiles = new List<string>();
private ScheduledDelegate importSchedule;
private void fileDrop(string[] filePaths) private void fileDrop(string[] filePaths)
{ {
var firstExtension = Path.GetExtension(filePaths.First()); lock (importableFiles)
{
var firstExtension = Path.GetExtension(filePaths.First());
if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return; if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return;
Task.Factory.StartNew(() => Import(filePaths), TaskCreationOptions.LongRunning); importableFiles.AddRange(filePaths);
Logger.Log($"Adding {filePaths.Length} files for import");
// File drag drop operations can potentially trigger hundreds or thousands of these calls on some platforms.
// In order to avoid spawning multiple import tasks for a single drop operation, debounce a touch.
importSchedule?.Cancel();
importSchedule = Scheduler.AddDelayed(handlePendingImports, 100);
}
}
private void handlePendingImports()
{
lock (importableFiles)
{
Logger.Log($"Handling batch import of {importableFiles.Count} files");
var paths = importableFiles.ToArray();
importableFiles.Clear();
Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning);
}
} }
} }
} }

View File

@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.Tests
public void Test(double expected, string name) public void Test(double expected, string name)
=> base.Test(expected, name); => base.Test(expected, name);
[TestCase(5.0565038923984691d, "diffcalc-test")] [TestCase(5.169743871843191d, "diffcalc-test")]
public void TestClockRateAdjusted(double expected, string name) public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new CatchModDoubleTime()); => Test(expected, name, new CatchModDoubleTime());

View File

@ -2,7 +2,7 @@
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" /> <PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" /> <PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -3,13 +3,16 @@
using System.Linq; using System.Linq;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Catch.Mods namespace osu.Game.Rulesets.Catch.Mods
{ {
public class CatchModHidden : ModHidden public class CatchModHidden : ModHidden, IApplicableToDrawableRuleset<CatchHitObject>
{ {
public override string Description => @"Play with fading fruits."; public override string Description => @"Play with fading fruits.";
public override double ScoreMultiplier => 1.06; public override double ScoreMultiplier => 1.06;
@ -17,6 +20,14 @@ namespace osu.Game.Rulesets.Catch.Mods
private const double fade_out_offset_multiplier = 0.6; private const double fade_out_offset_multiplier = 0.6;
private const double fade_out_duration_multiplier = 0.44; private const double fade_out_duration_multiplier = 0.44;
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
{
var drawableCatchRuleset = (DrawableCatchRuleset)drawableRuleset;
var catchPlayfield = (CatchPlayfield)drawableCatchRuleset.Playfield;
catchPlayfield.CatcherArea.MovableCatcher.CatchFruitOnPlate = false;
}
protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state)
{ {
base.ApplyNormalVisibilityState(hitObject, state); base.ApplyNormalVisibilityState(hitObject, state);

View File

@ -43,6 +43,11 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary> /// </summary>
public bool HyperDashing => hyperDashModifier != 1; public bool HyperDashing => hyperDashModifier != 1;
/// <summary>
/// Whether <see cref="DrawablePalpableCatchHitObject"/> fruit should appear on the plate.
/// </summary>
public bool CatchFruitOnPlate { get; set; } = true;
/// <summary> /// <summary>
/// The relative space to cover in 1 millisecond. based on 1 game pixel per millisecond as in osu-stable. /// The relative space to cover in 1 millisecond. based on 1 game pixel per millisecond as in osu-stable.
/// </summary> /// </summary>
@ -237,7 +242,8 @@ namespace osu.Game.Rulesets.Catch.UI
{ {
var positionInStack = computePositionInStack(new Vector2(palpableObject.X - X, 0), palpableObject.DisplaySize.X / 2); var positionInStack = computePositionInStack(new Vector2(palpableObject.X - X, 0), palpableObject.DisplaySize.X / 2);
placeCaughtObject(palpableObject, positionInStack); if (CatchFruitOnPlate)
placeCaughtObject(palpableObject, positionInStack);
if (hitLighting.Value) if (hitLighting.Value)
addLighting(hitObject, positionInStack.X, drawableObject.AccentColour.Value); addLighting(hitObject, positionInStack.X, drawableObject.AccentColour.Value);

View File

@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Tests
public void Test(double expected, string name) public void Test(double expected, string name)
=> base.Test(expected, name); => base.Test(expected, name);
[TestCase(2.7646128945056723d, "diffcalc-test")] [TestCase(2.7879104989252959d, "diffcalc-test")]
public void TestClockRateAdjusted(double expected, string name) public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new ManiaModDoubleTime()); => Test(expected, name, new ManiaModDoubleTime());

View File

@ -288,17 +288,56 @@ namespace osu.Game.Rulesets.Mania.Tests
.All(j => j.Type.IsHit())); .All(j => j.Type.IsHit()));
} }
[Test]
public void TestHitTailBeforeLastTick()
{
const int tick_rate = 8;
const double tick_spacing = TimingControlPoint.DEFAULT_BEAT_LENGTH / tick_rate;
const double time_last_tick = time_head + tick_spacing * (int)((time_tail - time_head) / tick_spacing - 1);
var beatmap = new Beatmap<ManiaHitObject>
{
HitObjects =
{
new HoldNote
{
StartTime = time_head,
Duration = time_tail - time_head,
Column = 0,
}
},
BeatmapInfo =
{
BaseDifficulty = new BeatmapDifficulty { SliderTickRate = tick_rate },
Ruleset = new ManiaRuleset().RulesetInfo
},
};
performTest(new List<ReplayFrame>
{
new ManiaReplayFrame(time_head, ManiaAction.Key1),
new ManiaReplayFrame(time_last_tick - 5)
}, beatmap);
assertHeadJudgement(HitResult.Perfect);
assertLastTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Ok);
}
private void assertHeadJudgement(HitResult result) private void assertHeadJudgement(HitResult result)
=> AddAssert($"head judged as {result}", () => judgementResults[0].Type == result); => AddAssert($"head judged as {result}", () => judgementResults.First(j => j.HitObject is Note).Type == result);
private void assertTailJudgement(HitResult result) private void assertTailJudgement(HitResult result)
=> AddAssert($"tail judged as {result}", () => judgementResults[^2].Type == result); => AddAssert($"tail judged as {result}", () => judgementResults.Single(j => j.HitObject is TailNote).Type == result);
private void assertNoteJudgement(HitResult result) private void assertNoteJudgement(HitResult result)
=> AddAssert($"hold note judged as {result}", () => judgementResults[^1].Type == result); => AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type == result);
private void assertTickJudgement(HitResult result) private void assertTickJudgement(HitResult result)
=> AddAssert($"tick judged as {result}", () => judgementResults[6].Type == result); // arbitrary tick => AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Any(j => j.Type == result));
private void assertLastTickJudgement(HitResult result)
=> AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type == result);
private ScoreAccessibleReplayPlayer currentPlayer; private ScoreAccessibleReplayPlayer currentPlayer;

View File

@ -2,7 +2,7 @@
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" /> <PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" /> <PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
if (IsForCurrentRuleset) if (IsForCurrentRuleset)
{ {
TargetColumns = (int)Math.Max(1, roundedCircleSize); TargetColumns = GetColumnCountForNonConvert(beatmap.BeatmapInfo);
if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS) if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
{ {
@ -71,6 +71,12 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
originalTargetColumns = TargetColumns; originalTargetColumns = TargetColumns;
} }
public static int GetColumnCountForNonConvert(BeatmapInfo beatmap)
{
var roundedCircleSize = Math.Round(beatmap.BaseDifficulty.CircleSize);
return (int)Math.Max(1, roundedCircleSize);
}
public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition); public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition);
protected override Beatmap<ManiaHitObject> ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) protected override Beatmap<ManiaHitObject> ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken)

View File

@ -73,8 +73,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
} }
protected override double GetPeakStrain(double offset) protected override double GetPeakStrain(double offset)
=> applyDecay(individualStrain, offset - Previous[0].BaseObject.StartTime, individual_decay_base) => applyDecay(individualStrain, offset - Previous[0].StartTime, individual_decay_base)
+ applyDecay(overallStrain, offset - Previous[0].BaseObject.StartTime, overall_decay_base); + applyDecay(overallStrain, offset - Previous[0].StartTime, overall_decay_base);
private double applyDecay(double value, double deltaTime, double decayBase) private double applyDecay(double value, double deltaTime, double decayBase)
=> value * Math.Pow(decayBase, deltaTime / 1000); => value * Math.Pow(decayBase, deltaTime / 1000);

View File

@ -0,0 +1,33 @@
// 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.Game.Beatmaps;
using osu.Game.Rulesets.Filter;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
namespace osu.Game.Rulesets.Mania
{
public class ManiaFilterCriteria : IRulesetFilterCriteria
{
private FilterCriteria.OptionalRange<float> keys;
public bool Matches(BeatmapInfo beatmap)
{
return !keys.HasFilter || (beatmap.RulesetID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmap)));
}
public bool TryParseCustomKeywordCriteria(string key, Operator op, string value)
{
switch (key)
{
case "key":
case "keys":
return FilterQueryParser.TryUpdateCriteriaRange(ref keys, op, value);
}
return false;
}
}
}

View File

@ -22,6 +22,7 @@ using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Configuration;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Filter;
using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Difficulty; using osu.Game.Rulesets.Mania.Difficulty;
@ -382,6 +383,11 @@ namespace osu.Game.Rulesets.Mania
} }
} }
}; };
public override IRulesetFilterCriteria CreateRulesetFilterCriteria()
{
return new ManiaFilterCriteria();
}
} }
public enum PlayfieldType public enum PlayfieldType

View File

@ -233,6 +233,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
if (Tail.AllJudged) if (Tail.AllJudged)
{ {
foreach (var tick in tickContainer)
{
if (!tick.Judged)
tick.MissForcefully();
}
ApplyResult(r => r.Type = r.Judgement.MaxResult); ApplyResult(r => r.Type = r.Judgement.MaxResult);
endHold(); endHold();
} }

View File

@ -20,8 +20,8 @@ namespace osu.Game.Rulesets.Osu.Tests
public void Test(double expected, string name) public void Test(double expected, string name)
=> base.Test(expected, name); => base.Test(expected, name);
[TestCase(8.6228371119271454d, "diffcalc-test")] [TestCase(8.7212283220412345d, "diffcalc-test")]
[TestCase(1.2864585280364178d, "zero-length-sliders")] [TestCase(1.3212137158641493d, "zero-length-sliders")]
public void TestClockRateAdjusted(double expected, string name) public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new OsuModDoubleTime()); => Test(expected, name, new OsuModDoubleTime());

View File

@ -2,7 +2,7 @@
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" /> <PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" /> <PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -44,6 +44,9 @@ namespace osu.Game.Rulesets.Osu.Mods
[SettingSource("Use fixed slider follow circle hit area", "Makes the slider follow circle track its final size at all times.")] [SettingSource("Use fixed slider follow circle hit area", "Makes the slider follow circle track its final size at all times.")]
public Bindable<bool> FixedFollowCircleHitArea { get; } = new BindableBool(true); public Bindable<bool> FixedFollowCircleHitArea { get; } = new BindableBool(true);
[SettingSource("Always play a slider's tail sample", "Always plays a slider's tail sample regardless of whether it was hit or not.")]
public Bindable<bool> AlwaysPlayTailSample { get; } = new BindableBool(true);
public void ApplyToHitObject(HitObject hitObject) public void ApplyToHitObject(HitObject hitObject)
{ {
switch (hitObject) switch (hitObject)
@ -79,6 +82,10 @@ namespace osu.Game.Rulesets.Osu.Mods
case DrawableSliderHead head: case DrawableSliderHead head:
head.TrackFollowCircle = !NoSliderHeadMovement.Value; head.TrackFollowCircle = !NoSliderHeadMovement.Value;
break; break;
case DrawableSliderTail tail:
tail.SamplePlaysOnlyOnHit = !AlwaysPlayTailSample.Value;
break;
} }
} }
} }

View File

@ -280,7 +280,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
// rather than doing it this way, we should probably attach the sample to the tail circle. // rather than doing it this way, we should probably attach the sample to the tail circle.
// this can only be done after we stop using LegacyLastTick. // this can only be done after we stop using LegacyLastTick.
if (TailCircle.IsHit) if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit)
base.PlaySamples(); base.PlaySamples();
} }

View File

@ -26,6 +26,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// </summary> /// </summary>
public override bool DisplayResult => false; public override bool DisplayResult => false;
/// <summary>
/// Whether the hit samples only play on successful hits.
/// If <c>false</c>, the hit samples will also play on misses.
/// </summary>
public bool SamplePlaysOnlyOnHit { get; set; } = true;
public bool Tracking { get; set; } public bool Tracking { get; set; }
private SkinnableDrawable circlePiece; private SkinnableDrawable circlePiece;

View File

@ -19,8 +19,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
public void Test(double expected, string name) public void Test(double expected, string name)
=> base.Test(expected, name); => base.Test(expected, name);
[TestCase(3.1473940254109078d, "diffcalc-test")] [TestCase(3.1704781712282624d, "diffcalc-test")]
[TestCase(3.1473940254109078d, "diffcalc-test-strong")] [TestCase(3.1704781712282624d, "diffcalc-test-strong")]
public void TestClockRateAdjusted(double expected, string name) public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new TaikoModDoubleTime()); => Test(expected, name, new TaikoModDoubleTime());

View File

@ -2,7 +2,7 @@
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" /> <PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" /> <PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -3,6 +3,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -11,6 +13,7 @@ using osu.Framework.Screens;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online; using osu.Game.Online;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy; using osu.Game.Replays.Legacy;
@ -29,10 +32,13 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(SpectatorStreamingClient))] [Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient(); private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
// used just to show beatmap card for the time being. // used just to show beatmap card for the time being.
protected override bool UseOnlineAPI => true; protected override bool UseOnlineAPI => true;
private Spectator spectatorScreen; private SoloSpectator spectatorScreen;
[Resolved] [Resolved]
private OsuGameBase game { get; set; } private OsuGameBase game { get; set; }
@ -69,7 +75,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{ {
loadSpectatingScreen(); loadSpectatingScreen();
AddAssert("screen hasn't changed", () => Stack.CurrentScreen is Spectator); AddAssert("screen hasn't changed", () => Stack.CurrentScreen is SoloSpectator);
start(); start();
sendFrames(); sendFrames();
@ -195,7 +201,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start(-1234); start(-1234);
sendFrames(); sendFrames();
AddAssert("screen didn't change", () => Stack.CurrentScreen is Spectator); AddAssert("screen didn't change", () => Stack.CurrentScreen is SoloSpectator);
} }
private OsuFramedReplayInputHandler replayHandler => private OsuFramedReplayInputHandler replayHandler =>
@ -226,7 +232,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private void loadSpectatingScreen() private void loadSpectatingScreen()
{ {
AddStep("load screen", () => LoadScreen(spectatorScreen = new Spectator(testSpectatorStreamingClient.StreamingUser))); AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(testSpectatorStreamingClient.StreamingUser)));
AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded);
} }
@ -301,5 +307,14 @@ namespace osu.Game.Tests.Visual.Gameplay
}); });
} }
} }
internal class TestUserLookupCache : UserLookupCache
{
protected override Task<User> ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User
{
Id = lookup,
Username = $"User {lookup}"
});
}
} }
} }

View File

@ -43,6 +43,29 @@ namespace osu.Game.Tests.Visual.Navigation
exitViaEscapeAndConfirm(); exitViaEscapeAndConfirm();
} }
[Test]
public void TestRetryCountIncrements()
{
Player player = null;
PushAndConfirm(() => new TestSongSelect());
AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("press enter", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null);
AddAssert("retry count is 0", () => player.RestartCount == 0);
AddStep("attempt to retry", () => player.ChildrenOfType<HotkeyRetryOverlay>().First().Action());
AddUntilStep("wait for old player gone", () => Game.ScreenStack.CurrentScreen != player);
AddUntilStep("get new player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null);
AddAssert("retry count is 1", () => player.RestartCount == 1);
}
[Test] [Test]
public void TestRetryFromResults() public void TestRetryFromResults()
{ {

View File

@ -45,6 +45,8 @@ namespace osu.Game.Tests.Visual.Settings
public Bindable<Vector2> AreaOffset { get; } = new Bindable<Vector2>(); public Bindable<Vector2> AreaOffset { get; } = new Bindable<Vector2>();
public Bindable<Vector2> AreaSize { get; } = new Bindable<Vector2>(); public Bindable<Vector2> AreaSize { get; } = new Bindable<Vector2>();
public Bindable<float> Rotation { get; } = new Bindable<float>();
public IBindable<TabletInfo> Tablet => tablet; public IBindable<TabletInfo> Tablet => tablet;
private readonly Bindable<TabletInfo> tablet = new Bindable<TabletInfo>(); private readonly Bindable<TabletInfo> tablet = new Bindable<TabletInfo>();

View File

@ -3,7 +3,7 @@
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" /> <PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="DeepEqual" Version="2.0.0" /> <PackageReference Include="DeepEqual" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" /> <PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -5,7 +5,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" /> <PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NUnit" Version="3.13.1" /> <PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
</ItemGroup> </ItemGroup>

View File

@ -147,7 +147,7 @@ namespace osu.Game.Beatmaps
{ {
string version = string.IsNullOrEmpty(Version) ? string.Empty : $"[{Version}]"; string version = string.IsNullOrEmpty(Version) ? string.Empty : $"[{Version}]";
return $"{Metadata} {version}".Trim(); return $"{Metadata ?? BeatmapSet?.Metadata} {version}".Trim();
} }
public bool Equals(BeatmapInfo other) public bool Equals(BeatmapInfo other)

View File

@ -19,8 +19,13 @@ namespace osu.Game.Beatmaps
public int ID { get; set; } public int ID { get; set; }
public string Title { get; set; } public string Title { get; set; }
[JsonProperty("title_unicode")]
public string TitleUnicode { get; set; } public string TitleUnicode { get; set; }
public string Artist { get; set; } public string Artist { get; set; }
[JsonProperty("artist_unicode")]
public string ArtistUnicode { get; set; } public string ArtistUnicode { get; set; }
[JsonIgnore] [JsonIgnore]

View File

@ -61,6 +61,9 @@ namespace osu.Game.Online.Leaderboards
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private SongSelect songSelect { get; set; } private SongSelect songSelect { get; set; }
[Resolved]
private ScoreManager scoreManager { get; set; }
public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true) public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true)
{ {
this.score = score; this.score = score;
@ -388,6 +391,9 @@ namespace osu.Game.Online.Leaderboards
if (score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) if (score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null)
items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = score.Mods)); items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = score.Mods));
if (score.Files.Count > 0)
items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(score)));
if (score.ID != 0) if (score.ID != 0)
items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score))));

View File

@ -429,6 +429,9 @@ namespace osu.Game
public async Task Import(params string[] paths) public async Task Import(params string[] paths)
{ {
if (paths.Length == 0)
return;
var extension = Path.GetExtension(paths.First())?.ToLowerInvariant(); var extension = Path.GetExtension(paths.First())?.ToLowerInvariant();
foreach (var importer in fileImporters) foreach (var importer in fileImporters)

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
@ -228,8 +229,8 @@ namespace osu.Game.Overlays.BeatmapSet
loading.Hide(); loading.Hide();
title.Text = setInfo.NewValue.Metadata.Title ?? string.Empty; title.Text = new RomanisableString(setInfo.NewValue.Metadata.TitleUnicode, setInfo.NewValue.Metadata.Title);
artist.Text = setInfo.NewValue.Metadata.Artist ?? string.Empty; artist.Text = new RomanisableString(setInfo.NewValue.Metadata.ArtistUnicode, setInfo.NewValue.Metadata.Artist);
explicitContentPill.Alpha = setInfo.NewValue.OnlineInfo.HasExplicitContent ? 1 : 0; explicitContentPill.Alpha = setInfo.NewValue.OnlineInfo.HasExplicitContent ? 1 : 0;

View File

@ -137,7 +137,7 @@ namespace osu.Game.Overlays.Dashboard
Text = "Watch", Text = "Watch",
Anchor = Anchor.TopCentre, Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre, Origin = Anchor.TopCentre,
Action = () => game?.PerformFromScreen(s => s.Push(new Spectator(User))), Action = () => game?.PerformFromScreen(s => s.Push(new SoloSpectator(User))),
Enabled = { Value = User.Id != api.LocalUser.Value.Id } Enabled = { Value = User.Id != api.LocalUser.Value.Id }
} }
} }

View File

@ -0,0 +1,109 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Handlers.Tablet;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Settings.Sections.Input
{
internal class RotationPresetButtons : FillFlowContainer
{
private readonly ITabletHandler tabletHandler;
private Bindable<float> rotation;
private const int height = 50;
public RotationPresetButtons(ITabletHandler tabletHandler)
{
this.tabletHandler = tabletHandler;
RelativeSizeAxes = Axes.X;
Height = height;
for (int i = 0; i < 360; i += 90)
{
var presetRotation = i;
Add(new RotationButton(i)
{
RelativeSizeAxes = Axes.X,
Height = height,
Width = 0.25f,
Text = $"{presetRotation}º",
Action = () => tabletHandler.Rotation.Value = presetRotation,
});
}
}
protected override void LoadComplete()
{
base.LoadComplete();
rotation = tabletHandler.Rotation.GetBoundCopy();
rotation.BindValueChanged(val =>
{
foreach (var b in Children.OfType<RotationButton>())
b.IsSelected = b.Preset == val.NewValue;
}, true);
}
public class RotationButton : TriangleButton
{
[Resolved]
private OsuColour colours { get; set; }
public readonly int Preset;
public RotationButton(int preset)
{
Preset = preset;
}
private bool isSelected;
public bool IsSelected
{
get => isSelected;
set
{
if (value == isSelected)
return;
isSelected = value;
if (IsLoaded)
updateColour();
}
}
protected override void LoadComplete()
{
base.LoadComplete();
updateColour();
}
private void updateColour()
{
if (isSelected)
{
BackgroundColour = colours.BlueDark;
Triangles.ColourDark = colours.BlueDarker;
Triangles.ColourLight = colours.Blue;
}
else
{
BackgroundColour = colours.Gray4;
Triangles.ColourDark = colours.Gray5;
Triangles.ColourLight = colours.Gray6;
}
}
}
}
}

View File

@ -25,6 +25,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input
private readonly Bindable<Vector2> areaOffset = new Bindable<Vector2>(); private readonly Bindable<Vector2> areaOffset = new Bindable<Vector2>();
private readonly Bindable<Vector2> areaSize = new Bindable<Vector2>(); private readonly Bindable<Vector2> areaSize = new Bindable<Vector2>();
private readonly BindableNumber<float> rotation = new BindableNumber<float>();
private readonly IBindable<TabletInfo> tablet = new Bindable<TabletInfo>(); private readonly IBindable<TabletInfo> tablet = new Bindable<TabletInfo>();
private OsuSpriteText tabletName; private OsuSpriteText tabletName;
@ -124,6 +126,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input
usableAreaText.Text = $"{(float)x / commonDivider}:{(float)y / commonDivider}"; usableAreaText.Text = $"{(float)x / commonDivider}:{(float)y / commonDivider}";
}, true); }, true);
rotation.BindTo(handler.Rotation);
rotation.BindValueChanged(val =>
{
usableAreaContainer.RotateTo(val.NewValue, 100, Easing.OutQuint)
.OnComplete(_ => checkBounds()); // required as we are using SSDQ.
});
tablet.BindTo(handler.Tablet); tablet.BindTo(handler.Tablet);
tablet.BindValueChanged(_ => Scheduler.AddOnce(updateTabletDetails)); tablet.BindValueChanged(_ => Scheduler.AddOnce(updateTabletDetails));

View File

@ -27,6 +27,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input
private readonly BindableNumber<float> sizeX = new BindableNumber<float> { MinValue = 10 }; private readonly BindableNumber<float> sizeX = new BindableNumber<float> { MinValue = 10 };
private readonly BindableNumber<float> sizeY = new BindableNumber<float> { MinValue = 10 }; private readonly BindableNumber<float> sizeY = new BindableNumber<float> { MinValue = 10 };
private readonly BindableNumber<float> rotation = new BindableNumber<float> { MinValue = 0, MaxValue = 360 };
[Resolved] [Resolved]
private GameHost host { get; set; } private GameHost host { get; set; }
@ -110,12 +112,6 @@ namespace osu.Game.Overlays.Settings.Sections.Input
} }
}, },
new SettingsSlider<float> new SettingsSlider<float>
{
TransferValueOnCommit = true,
LabelText = "Aspect Ratio",
Current = aspectRatio
},
new SettingsSlider<float>
{ {
TransferValueOnCommit = true, TransferValueOnCommit = true,
LabelText = "X Offset", LabelText = "X Offset",
@ -127,6 +123,19 @@ namespace osu.Game.Overlays.Settings.Sections.Input
LabelText = "Y Offset", LabelText = "Y Offset",
Current = offsetY Current = offsetY
}, },
new SettingsSlider<float>
{
TransferValueOnCommit = true,
LabelText = "Rotation",
Current = rotation
},
new RotationPresetButtons(tabletHandler),
new SettingsSlider<float>
{
TransferValueOnCommit = true,
LabelText = "Aspect Ratio",
Current = aspectRatio
},
new SettingsCheckbox new SettingsCheckbox
{ {
LabelText = "Lock aspect ratio", LabelText = "Lock aspect ratio",
@ -153,6 +162,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input
{ {
base.LoadComplete(); base.LoadComplete();
rotation.BindTo(tabletHandler.Rotation);
areaOffset.BindTo(tabletHandler.AreaOffset); areaOffset.BindTo(tabletHandler.AreaOffset);
areaOffset.BindValueChanged(val => areaOffset.BindValueChanged(val =>
{ {

View File

@ -83,7 +83,7 @@ namespace osu.Game.Rulesets.Difficulty
foreach (Skill s in skills) foreach (Skill s in skills)
{ {
s.SaveCurrentPeak(); s.SaveCurrentPeak();
s.StartNewSectionFrom(currentSectionEnd); s.StartNewSectionFrom(currentSectionEnd / clockRate);
} }
currentSectionEnd += sectionLength; currentSectionEnd += sectionLength;

View File

@ -25,6 +25,16 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing
/// </summary> /// </summary>
public readonly double DeltaTime; public readonly double DeltaTime;
/// <summary>
/// Clockrate adjusted start time of <see cref="BaseObject"/>.
/// </summary>
public readonly double StartTime;
/// <summary>
/// Clockrate adjusted end time of <see cref="BaseObject"/>.
/// </summary>
public readonly double EndTime;
/// <summary> /// <summary>
/// Creates a new <see cref="DifficultyHitObject"/>. /// Creates a new <see cref="DifficultyHitObject"/>.
/// </summary> /// </summary>
@ -36,6 +46,8 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing
BaseObject = hitObject; BaseObject = hitObject;
LastObject = lastObject; LastObject = lastObject;
DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate;
StartTime = hitObject.StartTime / clockRate;
EndTime = hitObject.GetEndTime() / clockRate;
} }
} }
} }

View File

@ -88,7 +88,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills
/// <summary> /// <summary>
/// Sets the initial strain level for a new section. /// Sets the initial strain level for a new section.
/// </summary> /// </summary>
/// <param name="time">The beginning of the new section in milliseconds.</param> /// <param name="time">The beginning of the new section in milliseconds, adjusted by clockrate.</param>
public void StartNewSectionFrom(double time) public void StartNewSectionFrom(double time)
{ {
// The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries. // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries.
@ -100,9 +100,9 @@ namespace osu.Game.Rulesets.Difficulty.Skills
/// <summary> /// <summary>
/// Retrieves the peak strain at a point in time. /// Retrieves the peak strain at a point in time.
/// </summary> /// </summary>
/// <param name="time">The time to retrieve the peak strain at.</param> /// <param name="time">The time to retrieve the peak strain at, adjusted by clockrate.</param>
/// <returns>The peak strain.</returns> /// <returns>The peak strain.</returns>
protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].BaseObject.StartTime); protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].StartTime);
/// <summary> /// <summary>
/// Returns the calculated difficulty value representing all processed <see cref="DifficultyHitObject"/>s. /// Returns the calculated difficulty value representing all processed <see cref="DifficultyHitObject"/>s.

View File

@ -173,7 +173,7 @@ namespace osu.Game.Rulesets
{ {
var filename = Path.GetFileNameWithoutExtension(file); var filename = Path.GetFileNameWithoutExtension(file);
if (loadedAssemblies.Values.Any(t => t.Namespace == filename)) if (loadedAssemblies.Values.Any(t => Path.GetFileNameWithoutExtension(t.Assembly.Location) == filename))
return; return;
try try

View File

@ -54,7 +54,7 @@ namespace osu.Game.Screens.Edit.Setup
{ {
FileSelector fileSelector; FileSelector fileSelector;
Target.Child = fileSelector = new FileSelector(validFileExtensions: ResourcesSection.AudioExtensions) Target.Child = fileSelector = new FileSelector(currentFile.Value?.DirectoryName, ResourcesSection.AudioExtensions)
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Height = 400, Height = 400,

View File

@ -73,7 +73,8 @@ namespace osu.Game.Screens.Edit.Setup
audioTrackTextBox = new FileChooserLabelledTextBox audioTrackTextBox = new FileChooserLabelledTextBox
{ {
Label = "Audio Track", Label = "Audio Track",
Current = { Value = working.Value.Metadata.AudioFile ?? "Click to select a track" }, PlaceholderText = "Click to select a track",
Current = { Value = working.Value.Metadata.AudioFile },
Target = audioTrackFileChooserContainer, Target = audioTrackFileChooserContainer,
TabbableContentContainer = this TabbableContentContainer = this
}, },

View File

@ -28,7 +28,16 @@ namespace osu.Game.Screens.Edit.Timing
{ {
if (point.NewValue != null) if (point.NewValue != null)
{ {
multiplierSlider.Current = point.NewValue.SpeedMultiplierBindable; var selectedPointBindable = point.NewValue.SpeedMultiplierBindable;
// there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint).
// generally that level of precision could only be set by externally editing the .osu file, so at the point
// a user is looking to update this within the editor it should be safe to obliterate this additional precision.
double expectedPrecision = new DifficultyControlPoint().SpeedMultiplierBindable.Precision;
if (selectedPointBindable.Precision < expectedPrecision)
selectedPointBindable.Precision = expectedPrecision;
multiplierSlider.Current = selectedPointBindable;
multiplierSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); multiplierSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
} }
} }

View File

@ -309,10 +309,8 @@ namespace osu.Game.Screens.Play
if (!this.IsCurrentScreen()) if (!this.IsCurrentScreen())
return; return;
var restartCount = player?.RestartCount + 1 ?? 0;
player = createPlayer(); player = createPlayer();
player.RestartCount = restartCount; player.RestartCount = restartCount++;
player.RestartRequested = restartRequested; player.RestartRequested = restartRequested;
LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false); LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false);
@ -428,6 +426,8 @@ namespace osu.Game.Screens.Play
private Bindable<bool> muteWarningShownOnce; private Bindable<bool> muteWarningShownOnce;
private int restartCount;
private void showMuteWarningIfNeeded() private void showMuteWarningIfNeeded()
{ {
if (!muteWarningShownOnce.Value) if (!muteWarningShownOnce.Value)

View File

@ -1,18 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations; using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Threading;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Configuration; using osu.Game.Configuration;
@ -24,73 +21,49 @@ using osu.Game.Online.API.Requests;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Overlays.BeatmapListing.Panels;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
using osu.Game.Replays;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.Spectate;
using osu.Game.Users; using osu.Game.Users;
using osuTK; using osuTK;
namespace osu.Game.Screens.Play namespace osu.Game.Screens.Play
{ {
[Cached(typeof(IPreviewTrackOwner))] [Cached(typeof(IPreviewTrackOwner))]
public class Spectator : OsuScreen, IPreviewTrackOwner public class SoloSpectator : SpectatorScreen, IPreviewTrackOwner
{ {
[NotNull]
private readonly User targetUser; private readonly User targetUser;
[Resolved]
private Bindable<WorkingBeatmap> beatmap { get; set; }
[Resolved]
private Bindable<RulesetInfo> ruleset { get; set; }
private Ruleset rulesetInstance;
[Resolved]
private Bindable<IReadOnlyList<Mod>> mods { get; set; }
[Resolved] [Resolved]
private IAPIProvider api { get; set; } private IAPIProvider api { get; set; }
[Resolved] [Resolved]
private SpectatorStreamingClient spectatorStreaming { get; set; } private PreviewTrackManager previewTrackManager { get; set; }
[Resolved]
private BeatmapManager beatmaps { get; set; }
[Resolved] [Resolved]
private RulesetStore rulesets { get; set; } private RulesetStore rulesets { get; set; }
[Resolved] [Resolved]
private PreviewTrackManager previewTrackManager { get; set; } private BeatmapManager beatmaps { get; set; }
private Score score;
private readonly object scoreLock = new object();
private Container beatmapPanelContainer; private Container beatmapPanelContainer;
private SpectatorState state;
private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated;
private TriangleButton watchButton; private TriangleButton watchButton;
private SettingsCheckbox automaticDownload; private SettingsCheckbox automaticDownload;
private BeatmapSetInfo onlineBeatmap; private BeatmapSetInfo onlineBeatmap;
/// <summary> /// <summary>
/// Becomes true if a new state is waiting to be loaded (while this screen was not active). /// The player's immediate online gameplay state.
/// This doesn't always reflect the gameplay state being watched.
/// </summary> /// </summary>
private bool newStatePending; private GameplayState immediateGameplayState;
public Spectator([NotNull] User targetUser) private GetBeatmapSetRequest onlineBeatmapRequest;
public SoloSpectator([NotNull] User targetUser)
: base(targetUser.Id)
{ {
this.targetUser = targetUser ?? throw new ArgumentNullException(nameof(targetUser)); this.targetUser = targetUser;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -173,7 +146,7 @@ namespace osu.Game.Screens.Play
Width = 250, Width = 250,
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Action = attemptStart, Action = () => scheduleStart(immediateGameplayState),
Enabled = { Value = false } Enabled = { Value = false }
} }
} }
@ -185,169 +158,76 @@ namespace osu.Game.Screens.Play
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
spectatorStreaming.OnUserBeganPlaying += userBeganPlaying;
spectatorStreaming.OnUserFinishedPlaying += userFinishedPlaying;
spectatorStreaming.OnNewFrames += userSentFrames;
spectatorStreaming.WatchUser(targetUser.Id);
managerUpdated = beatmaps.ItemUpdated.GetBoundCopy();
managerUpdated.BindValueChanged(beatmapUpdated);
automaticDownload.Current.BindValueChanged(_ => checkForAutomaticDownload()); automaticDownload.Current.BindValueChanged(_ => checkForAutomaticDownload());
} }
private void beatmapUpdated(ValueChangedEvent<WeakReference<BeatmapSetInfo>> beatmap) protected override void OnUserStateChanged(int userId, SpectatorState spectatorState)
{ {
if (beatmap.NewValue.TryGetTarget(out var beatmapSet) && beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == state.BeatmapID)) clearDisplay();
Schedule(attemptStart); showBeatmapPanel(spectatorState);
} }
private void userSentFrames(int userId, FrameDataBundle data) protected override void StartGameplay(int userId, GameplayState gameplayState)
{ {
// this is not scheduled as it handles propagation of frames even when in a child screen (at which point we are not alive). immediateGameplayState = gameplayState;
// probably not the safest way to handle this. watchButton.Enabled.Value = true;
if (userId != targetUser.Id) scheduleStart(gameplayState);
return;
lock (scoreLock)
{
// this should never happen as the server sends the user's state on watching,
// but is here as a safety measure.
if (score == null)
return;
// rulesetInstance should be guaranteed to be in sync with the score via scoreLock.
Debug.Assert(rulesetInstance != null && rulesetInstance.RulesetInfo.Equals(score.ScoreInfo.Ruleset));
foreach (var frame in data.Frames)
{
IConvertibleReplayFrame convertibleFrame = rulesetInstance.CreateConvertibleReplayFrame();
convertibleFrame.FromLegacy(frame, beatmap.Value.Beatmap);
var convertedFrame = (ReplayFrame)convertibleFrame;
convertedFrame.Time = frame.Time;
score.Replay.Frames.Add(convertedFrame);
}
}
} }
private void userBeganPlaying(int userId, SpectatorState state) protected override void EndGameplay(int userId)
{ {
if (userId != targetUser.Id) scheduledStart?.Cancel();
return; immediateGameplayState = null;
watchButton.Enabled.Value = false;
this.state = state; clearDisplay();
if (this.IsCurrentScreen())
Schedule(attemptStart);
else
newStatePending = true;
}
public override void OnResuming(IScreen last)
{
base.OnResuming(last);
if (newStatePending)
{
attemptStart();
newStatePending = false;
}
}
private void userFinishedPlaying(int userId, SpectatorState state)
{
if (userId != targetUser.Id)
return;
lock (scoreLock)
{
if (score != null)
{
score.Replay.HasReceivedAllFrames = true;
score = null;
}
}
Schedule(clearDisplay);
} }
private void clearDisplay() private void clearDisplay()
{ {
watchButton.Enabled.Value = false; watchButton.Enabled.Value = false;
onlineBeatmapRequest?.Cancel();
beatmapPanelContainer.Clear(); beatmapPanelContainer.Clear();
previewTrackManager.StopAnyPlaying(this); previewTrackManager.StopAnyPlaying(this);
} }
private void attemptStart() private ScheduledDelegate scheduledStart;
private void scheduleStart(GameplayState gameplayState)
{ {
clearDisplay(); // This function may be called multiple times in quick succession once the screen becomes current again.
showBeatmapPanel(state); scheduledStart?.Cancel();
scheduledStart = Schedule(() =>
var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == state.RulesetID)?.CreateInstance();
// ruleset not available
if (resolvedRuleset == null)
return;
if (state.BeatmapID == null)
return;
var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == state.BeatmapID);
if (resolvedBeatmap == null)
{ {
return; if (this.IsCurrentScreen())
} start();
else
scheduleStart(gameplayState);
});
lock (scoreLock) void start()
{ {
score = new Score Beatmap.Value = gameplayState.Beatmap;
{ Ruleset.Value = gameplayState.Ruleset.RulesetInfo;
ScoreInfo = new ScoreInfo
{
Beatmap = resolvedBeatmap,
User = targetUser,
Mods = state.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(),
Ruleset = resolvedRuleset.RulesetInfo,
},
Replay = new Replay { HasReceivedAllFrames = false },
};
ruleset.Value = resolvedRuleset.RulesetInfo; this.Push(new SpectatorPlayerLoader(gameplayState.Score));
rulesetInstance = resolvedRuleset;
beatmap.Value = beatmaps.GetWorkingBeatmap(resolvedBeatmap);
watchButton.Enabled.Value = true;
this.Push(new SpectatorPlayerLoader(score));
} }
} }
private void showBeatmapPanel(SpectatorState state) private void showBeatmapPanel(SpectatorState state)
{ {
if (state?.BeatmapID == null) Debug.Assert(state.BeatmapID != null);
{
onlineBeatmap = null;
return;
}
var req = new GetBeatmapSetRequest(state.BeatmapID.Value, BeatmapSetLookupType.BeatmapId); onlineBeatmapRequest = new GetBeatmapSetRequest(state.BeatmapID.Value, BeatmapSetLookupType.BeatmapId);
req.Success += res => Schedule(() => onlineBeatmapRequest.Success += res => Schedule(() =>
{ {
if (state != this.state)
return;
onlineBeatmap = res.ToBeatmapSet(rulesets); onlineBeatmap = res.ToBeatmapSet(rulesets);
beatmapPanelContainer.Child = new GridBeatmapPanel(onlineBeatmap); beatmapPanelContainer.Child = new GridBeatmapPanel(onlineBeatmap);
checkForAutomaticDownload(); checkForAutomaticDownload();
}); });
api.Queue(req); api.Queue(onlineBeatmapRequest);
} }
private void checkForAutomaticDownload() private void checkForAutomaticDownload()
@ -369,21 +249,5 @@ namespace osu.Game.Screens.Play
previewTrackManager.StopAnyPlaying(this); previewTrackManager.StopAnyPlaying(this);
return base.OnExiting(next); return base.OnExiting(next);
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (spectatorStreaming != null)
{
spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying;
spectatorStreaming.OnUserFinishedPlaying -= userFinishedPlaying;
spectatorStreaming.OnNewFrames -= userSentFrames;
spectatorStreaming.StopWatchingUser(targetUser.Id);
}
managerUpdated?.UnbindAll();
}
} }
} }

View File

@ -0,0 +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 osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Scoring;
namespace osu.Game.Screens.Spectate
{
/// <summary>
/// The gameplay state of a spectated user. This class is immutable.
/// </summary>
public class GameplayState
{
/// <summary>
/// The score which the user is playing.
/// </summary>
public readonly Score Score;
/// <summary>
/// The ruleset which the user is playing.
/// </summary>
public readonly Ruleset Ruleset;
/// <summary>
/// The beatmap which the user is playing.
/// </summary>
public readonly WorkingBeatmap Beatmap;
public GameplayState(Score score, Ruleset ruleset, WorkingBeatmap beatmap)
{
Score = score;
Ruleset = ruleset;
Beatmap = beatmap;
}
}
}

View File

@ -0,0 +1,238 @@
// 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.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.Spectator;
using osu.Game.Replays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Screens.Spectate
{
/// <summary>
/// A <see cref="OsuScreen"/> which spectates one or more users.
/// </summary>
public abstract class SpectatorScreen : OsuScreen
{
private readonly int[] userIds;
[Resolved]
private BeatmapManager beatmaps { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
[Resolved]
private SpectatorStreamingClient spectatorClient { get; set; }
[Resolved]
private UserLookupCache userLookupCache { get; set; }
// A lock is used to synchronise access to spectator/gameplay states, since this class is a screen which may become non-current and stop receiving updates at any point.
private readonly object stateLock = new object();
private readonly Dictionary<int, User> userMap = new Dictionary<int, User>();
private readonly Dictionary<int, SpectatorState> spectatorStates = new Dictionary<int, SpectatorState>();
private readonly Dictionary<int, GameplayState> gameplayStates = new Dictionary<int, GameplayState>();
private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated;
/// <summary>
/// Creates a new <see cref="SpectatorScreen"/>.
/// </summary>
/// <param name="userIds">The users to spectate.</param>
protected SpectatorScreen(params int[] userIds)
{
this.userIds = userIds;
}
protected override void LoadComplete()
{
base.LoadComplete();
spectatorClient.OnUserBeganPlaying += userBeganPlaying;
spectatorClient.OnUserFinishedPlaying += userFinishedPlaying;
spectatorClient.OnNewFrames += userSentFrames;
foreach (var id in userIds)
{
userLookupCache.GetUserAsync(id).ContinueWith(u => Schedule(() =>
{
if (u.Result == null)
return;
lock (stateLock)
userMap[id] = u.Result;
spectatorClient.WatchUser(id);
}), TaskContinuationOptions.OnlyOnRanToCompletion);
}
managerUpdated = beatmaps.ItemUpdated.GetBoundCopy();
managerUpdated.BindValueChanged(beatmapUpdated);
}
private void beatmapUpdated(ValueChangedEvent<WeakReference<BeatmapSetInfo>> e)
{
if (!e.NewValue.TryGetTarget(out var beatmapSet))
return;
lock (stateLock)
{
foreach (var (userId, state) in spectatorStates)
{
if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == state.BeatmapID))
updateGameplayState(userId);
}
}
}
private void userBeganPlaying(int userId, SpectatorState state)
{
if (state.RulesetID == null || state.BeatmapID == null)
return;
lock (stateLock)
{
if (!userMap.ContainsKey(userId))
return;
spectatorStates[userId] = state;
Schedule(() => OnUserStateChanged(userId, state));
updateGameplayState(userId);
}
}
private void updateGameplayState(int userId)
{
lock (stateLock)
{
Debug.Assert(userMap.ContainsKey(userId));
var spectatorState = spectatorStates[userId];
var user = userMap[userId];
var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance();
if (resolvedRuleset == null)
return;
var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID);
if (resolvedBeatmap == null)
return;
var score = new Score
{
ScoreInfo = new ScoreInfo
{
Beatmap = resolvedBeatmap,
User = user,
Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(),
Ruleset = resolvedRuleset.RulesetInfo,
},
Replay = new Replay { HasReceivedAllFrames = false },
};
var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap));
gameplayStates[userId] = gameplayState;
Schedule(() => StartGameplay(userId, gameplayState));
}
}
private void userSentFrames(int userId, FrameDataBundle bundle)
{
lock (stateLock)
{
if (!userMap.ContainsKey(userId))
return;
if (!gameplayStates.TryGetValue(userId, out var gameplayState))
return;
// The ruleset instance should be guaranteed to be in sync with the score via ScoreLock.
Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset));
foreach (var frame in bundle.Frames)
{
IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame();
convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap);
var convertedFrame = (ReplayFrame)convertibleFrame;
convertedFrame.Time = frame.Time;
gameplayState.Score.Replay.Frames.Add(convertedFrame);
}
}
}
private void userFinishedPlaying(int userId, SpectatorState state)
{
lock (stateLock)
{
if (!userMap.ContainsKey(userId))
return;
if (!gameplayStates.TryGetValue(userId, out var gameplayState))
return;
gameplayState.Score.Replay.HasReceivedAllFrames = true;
gameplayStates.Remove(userId);
Schedule(() => EndGameplay(userId));
}
}
/// <summary>
/// Invoked when a spectated user's state has changed.
/// </summary>
/// <param name="userId">The user whose state has changed.</param>
/// <param name="spectatorState">The new state.</param>
protected abstract void OnUserStateChanged(int userId, [NotNull] SpectatorState spectatorState);
/// <summary>
/// Starts gameplay for a user.
/// </summary>
/// <param name="userId">The user to start gameplay for.</param>
/// <param name="gameplayState">The gameplay state.</param>
protected abstract void StartGameplay(int userId, [NotNull] GameplayState gameplayState);
/// <summary>
/// Ends gameplay for a user.
/// </summary>
/// <param name="userId">The user to end gameplay for.</param>
protected abstract void EndGameplay(int userId);
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (spectatorClient != null)
{
spectatorClient.OnUserBeganPlaying -= userBeganPlaying;
spectatorClient.OnUserFinishedPlaying -= userFinishedPlaying;
spectatorClient.OnNewFrames -= userSentFrames;
lock (stateLock)
{
foreach (var (userId, _) in userMap)
spectatorClient.StopWatchingUser(userId);
}
}
managerUpdated?.UnbindAll();
}
}
}

View File

@ -45,7 +45,7 @@ namespace osu.Game.Users
public double Accuracy; public double Accuracy;
[JsonIgnore] [JsonIgnore]
public string DisplayAccuracy => Accuracy.FormatAccuracy(); public string DisplayAccuracy => (Accuracy / 100).FormatAccuracy();
[JsonProperty(@"play_count")] [JsonProperty(@"play_count")]
public int PlayCount; public int PlayCount;

View File

@ -29,7 +29,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.NETCore.Targets" Version="3.1.0" /> <PackageReference Include="Microsoft.NETCore.Targets" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.329.0" /> <PackageReference Include="ppy.osu.Framework" Version="2021.402.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.211.1" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2021.211.1" />
<PackageReference Include="Sentry" Version="3.2.0" /> <PackageReference Include="Sentry" Version="3.2.0" />
<PackageReference Include="SharpCompress" Version="0.28.1" /> <PackageReference Include="SharpCompress" Version="0.28.1" />

View File

@ -70,7 +70,7 @@
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.329.0" /> <PackageReference Include="ppy.osu.Framework.iOS" Version="2021.402.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.211.1" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2021.211.1" />
</ItemGroup> </ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) --> <!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
@ -93,7 +93,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="ppy.osu.Framework" Version="2021.329.0" /> <PackageReference Include="ppy.osu.Framework" Version="2021.402.0" />
<PackageReference Include="SharpCompress" Version="0.28.1" /> <PackageReference Include="SharpCompress" Version="0.28.1" />
<PackageReference Include="NUnit" Version="3.12.0" /> <PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="SharpRaven" Version="2.4.0" /> <PackageReference Include="SharpRaven" Version="2.4.0" />