1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-22 03:27:24 +08:00

Merge branch 'master' into fix-spectator-seeks

This commit is contained in:
Dean Herbert 2022-04-13 12:33:41 +09:00
commit 9c68b3edc5
76 changed files with 1602 additions and 846 deletions

View File

@ -26,14 +26,6 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<PropertyGroup Label="Project">
<!--
NU1701:
DeepEqual is not netstandard-compatible. This is fine since we run tests with .NET Framework anyway.
This is required due to https://github.com/NuGet/Home/issues/5740
-->
<NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup>
<PropertyGroup Label="Nuget">
<IsPackable>false</IsPackable>
<Authors>ppy Pty Ltd</Authors>
@ -42,7 +34,7 @@
<RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl>
<PackageReleaseNotes>Automated release.</PackageReleaseNotes>
<Company>ppy Pty Ltd</Company>
<Copyright>Copyright (c) 2021 ppy Pty Ltd</Copyright>
<Copyright>Copyright (c) 2022 ppy Pty Ltd</Copyright>
<PackageTags>osu game</PackageTags>
</PropertyGroup>
</Project>

View File

@ -1,4 +1,4 @@
Copyright (c) 2021 ppy Pty Ltd <contact@ppy.sh>.
Copyright (c) 2022 ppy Pty Ltd <contact@ppy.sh>.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -8,7 +8,7 @@
<PackageProjectUrl>https://github.com/ppy/osu/blob/master/Templates</PackageProjectUrl>
<RepositoryUrl>https://github.com/ppy/osu</RepositoryUrl>
<PackageReleaseNotes>Automated release.</PackageReleaseNotes>
<copyright>Copyright (c) 2021 ppy Pty Ltd</copyright>
<copyright>Copyright (c) 2022 ppy Pty Ltd</copyright>
<Description>Templates to use when creating a ruleset for consumption in osu!.</Description>
<PackageTags>dotnet-new;templates;osu</PackageTags>
<TargetFramework>netstandard2.1</TargetFramework>

View File

@ -51,8 +51,8 @@
<Reference Include="Java.Interop" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.404.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.407.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.408.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->

View File

@ -25,7 +25,6 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Clowd.Squirrel" Version="2.8.28-pre" />
<PackageReference Include="Microsoft.NETCore.Targets" Version="5.0.0" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="System.IO.Packaging" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
@ -33,7 +32,6 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="DiscordRichPresence" Version="1.0.175" />
</ItemGroup>
<ItemGroup Label="Resources">

View File

@ -11,7 +11,7 @@
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>A free-to-win rhythm game. Rhythm is just a *click* away!</description>
<releaseNotes>testing</releaseNotes>
<copyright>Copyright (c) 2021 ppy Pty Ltd</copyright>
<copyright>Copyright (c) 2022 ppy Pty Ltd</copyright>
<language>en-AU</language>
</metadata>
<files>

View File

@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.DifficultyIncrease;
public override double ScoreMultiplier => 1.12;
public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight) };
private DrawableOsuBlinds blinds;
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input;
@ -19,6 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public class OsuModFlashlight : ModFlashlight<OsuHitObject>, IApplicableToDrawableHitObject
{
public override double ScoreMultiplier => 1.12;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModBlinds)).ToArray();
private const double default_follow_delay = 120;

View File

@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods
protected virtual float EndScale => 1;
public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn) };
public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn), typeof(OsuModObjectScaleTween) };
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
{

View File

@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public readonly IBindable<float> ScaleBindable = new BindableFloat();
public readonly IBindable<int> IndexInCurrentComboBindable = new Bindable<int>();
// Must be set to update IsHovered as it's used in relax mdo to detect osu hit objects.
// Must be set to update IsHovered as it's used in relax mod to detect osu hit objects.
public override bool HandlePositionalInput => true;
protected override float SamplePlaybackPosition => HitObject.X / OsuPlayfield.BASE_SIZE.X;

View File

@ -0,0 +1,65 @@
// 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 NUnit.Framework;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Taiko;
using osu.Game.Utils;
namespace osu.Game.Tests.Mods
{
[TestFixture]
public class MultiModIncompatibilityTest
{
/// <summary>
/// Ensures that all mods grouped into <see cref="MultiMod"/>s, as declared by the default rulesets, are pairwise incompatible with each other.
/// </summary>
[TestCase(typeof(OsuRuleset))]
[TestCase(typeof(TaikoRuleset))]
[TestCase(typeof(CatchRuleset))]
[TestCase(typeof(ManiaRuleset))]
public void TestAllMultiModsFromRulesetAreIncompatible(Type rulesetType)
{
var ruleset = (Ruleset)Activator.CreateInstance(rulesetType);
Assert.That(ruleset, Is.Not.Null);
var allMultiMods = getMultiMods(ruleset);
Assert.Multiple(() =>
{
foreach (var multiMod in allMultiMods)
{
int modCount = multiMod.Mods.Length;
for (int i = 0; i < modCount; ++i)
{
// indexing from i + 1 ensures that only pairs of different mods are checked, and are checked only once
// (indexing from 0 would check each pair twice, and also check each mod against itself).
for (int j = i + 1; j < modCount; ++j)
{
var firstMod = multiMod.Mods[i];
var secondMod = multiMod.Mods[j];
Assert.That(
ModUtils.CheckCompatibleSet(new[] { firstMod, secondMod }), Is.False,
$"{firstMod.Name} ({firstMod.Acronym}) and {secondMod.Name} ({secondMod.Acronym}) should be incompatible.");
}
}
}
});
}
/// <remarks>
/// This local helper is used rather than <see cref="Ruleset.CreateAllMods"/>, because the aforementioned method flattens multi mods.
/// </remarks>>
private static IEnumerable<MultiMod> getMultiMods(Ruleset ruleset)
=> Enum.GetValues(typeof(ModType)).Cast<ModType>().SelectMany(ruleset.GetModsFor).OfType<MultiMod>();
}
}

View File

@ -69,6 +69,34 @@ namespace osu.Game.Tests.NonVisual.Skinning
"Gameplay/osu/followpoint",
"followpoint", 1
},
new object[]
{
// Looking up a filename with extension specified should work.
new[] { "followpoint.png" },
"followpoint.png",
"followpoint.png", 1
},
new object[]
{
// Looking up a filename with extension specified should also work with @2x sprites.
new[] { "followpoint@2x.png" },
"followpoint.png",
"followpoint@2x.png", 2
},
new object[]
{
// Looking up a path with extension specified should work.
new[] { "Gameplay/osu/followpoint.png" },
"Gameplay/osu/followpoint.png",
"Gameplay/osu/followpoint.png", 1
},
new object[]
{
// Looking up a path with extension specified should also work with @2x sprites.
new[] { "Gameplay/osu/followpoint@2x.png" },
"Gameplay/osu/followpoint.png",
"Gameplay/osu/followpoint@2x.png", 2
},
};
[TestCaseSource(nameof(fallbackTestCases))]

View File

@ -11,6 +11,7 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Skinning;
@ -110,6 +111,27 @@ namespace osu.Game.Tests.Skins.IO
assertImportedOnce(import1, import2);
});
[Test]
public Task TestImportExportedSkinFilename() => runSkinTest(async osu =>
{
MemoryStream exportStream = new MemoryStream();
var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "custom.osk"));
assertCorrectMetadata(import1, "name 1 [custom]", "author 1", osu);
import1.PerformRead(s =>
{
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
});
string exportFilename = import1.GetDisplayString();
var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(exportStream, $"{exportFilename}.osk"));
assertCorrectMetadata(import2, "name 1 [custom]", "author 1", osu);
assertImportedOnce(import1, import2);
});
[Test]
public Task TestSameMetadataNameSameFolderName() => runSkinTest(async osu =>
{

View File

@ -7,11 +7,10 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Skinning;
using osu.Game.Storyboards;
using osu.Game.Storyboards.Drawables;
using osuTK;
@ -36,7 +35,8 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
assertSpritesFromSkin(false);
AddAssert("sprite didn't find texture", () =>
sprites.All(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Texture == null)));
}
[Test]
@ -48,9 +48,12 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
assertSpritesFromSkin(true);
// Only checking for at least one sprite that succeeded, as not all skins in this test provide the hitcircleoverlay texture.
AddAssert("sprite found texture", () =>
sprites.Any(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Texture != null)));
AddAssert("skinnable sprite has correct size", () => sprites.Any(s => Precision.AlmostEquals(s.ChildrenOfType<SkinnableSprite>().Single().Size, new Vector2(128, 128))));
AddAssert("skinnable sprite has correct size", () =>
sprites.Any(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Size == new Vector2(128))));
}
[Test]
@ -104,9 +107,5 @@ namespace osu.Game.Tests.Visual.Gameplay
s.LifetimeStart = double.MinValue;
s.LifetimeEnd = double.MaxValue;
});
private void assertSpritesFromSkin(bool fromSkin) =>
AddAssert($"sprites are {(fromSkin ? "from skin" : "from storyboard")}",
() => sprites.All(sprite => sprite.ChildrenOfType<SkinnableSprite>().Any() == fromSkin));
}
}

View File

@ -150,10 +150,12 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("import score", () => imported = scoreManager.Import(getScoreInfo(true)));
AddUntilStep("state is available", () => downloadButton.State.Value == DownloadState.LocallyAvailable);
AddAssert("button is enabled", () => downloadButton.ChildrenOfType<DownloadButton>().First().Enabled.Value);
AddStep("delete score", () => scoreManager.Delete(imported.Value));
AddUntilStep("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded);
AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType<DownloadButton>().First().Enabled.Value);
}
[Test]

View File

@ -0,0 +1,204 @@
// 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.Diagnostics;
using System.Linq;
using Moq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Tests.Visual.Multiplayer
{
public abstract class MultiplayerGameplayLeaderboardTestScene : OsuTestScene
{
protected readonly BindableList<MultiplayerRoomUser> MultiplayerUsers = new BindableList<MultiplayerRoomUser>();
protected MultiplayerGameplayLeaderboard Leaderboard { get; private set; }
protected virtual MultiplayerRoomUser CreateUser(int userId) => new MultiplayerRoomUser(userId);
protected abstract MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor);
private readonly BindableList<int> multiplayerUserIds = new BindableList<int>();
private OsuConfigManager config;
private readonly Mock<SpectatorClient> spectatorClient = new Mock<SpectatorClient>();
private readonly Mock<MultiplayerClient> multiplayerClient = new Mock<MultiplayerClient>();
private readonly Dictionary<int, FrameHeader> lastHeaders = new Dictionary<int, FrameHeader>();
[BackgroundDependencyLoader]
private void load()
{
Dependencies.Cache(config = new OsuConfigManager(LocalStorage));
Dependencies.CacheAs(spectatorClient.Object);
Dependencies.CacheAs(multiplayerClient.Object);
// To emulate `MultiplayerClient.CurrentMatchPlayingUserIds` we need a bindable list of *only IDs*.
// This tracks the list of users 1:1.
MultiplayerUsers.BindCollectionChanged((c, e) =>
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
Debug.Assert(e.NewItems != null);
foreach (var user in e.NewItems.OfType<MultiplayerRoomUser>())
multiplayerUserIds.Add(user.UserID);
break;
case NotifyCollectionChangedAction.Remove:
Debug.Assert(e.OldItems != null);
foreach (var user in e.OldItems.OfType<MultiplayerRoomUser>())
multiplayerUserIds.Remove(user.UserID);
break;
case NotifyCollectionChangedAction.Reset:
multiplayerUserIds.Clear();
break;
}
});
multiplayerClient.SetupGet(c => c.CurrentMatchPlayingUserIds)
.Returns(() => multiplayerUserIds);
}
[SetUpSteps]
public virtual void SetUpSteps()
{
AddStep("reset counts", () => spectatorClient.Invocations.Clear());
AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = new APIUser
{
Id = 1,
});
AddStep("populate users", () =>
{
MultiplayerUsers.Clear();
for (int i = 0; i < 16; i++)
MultiplayerUsers.Add(CreateUser(i));
});
AddStep("create leaderboard", () =>
{
Leaderboard?.Expire();
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
OsuScoreProcessor scoreProcessor = new OsuScoreProcessor();
scoreProcessor.ApplyBeatmap(playableBeatmap);
Child = scoreProcessor;
LoadComponentAsync(Leaderboard = CreateLeaderboard(scoreProcessor), Add);
});
AddUntilStep("wait for load", () => Leaderboard.IsLoaded);
AddStep("check watch requests were sent", () =>
{
foreach (var user in MultiplayerUsers)
spectatorClient.Verify(s => s.WatchUser(user.UserID), Times.Once);
});
}
[Test]
public void TestScoreUpdates()
{
AddRepeatStep("update state", UpdateUserStatesRandomly, 100);
AddToggleStep("switch compact mode", expanded => Leaderboard.Expanded.Value = expanded);
}
[Test]
public void TestUserQuit()
{
AddUntilStep("mark users quit", () =>
{
if (MultiplayerUsers.Count == 0)
return true;
MultiplayerUsers.RemoveAt(0);
return false;
});
AddStep("check stop watching requests were sent", () =>
{
foreach (var user in MultiplayerUsers)
spectatorClient.Verify(s => s.StopWatchingUser(user.UserID), Times.Once);
});
}
[Test]
public void TestChangeScoringMode()
{
AddRepeatStep("update state", UpdateUserStatesRandomly, 5);
AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic));
AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised));
}
protected void UpdateUserStatesRandomly()
{
foreach (var user in MultiplayerUsers)
{
if (RNG.NextBool())
continue;
int userId = user.UserID;
if (!lastHeaders.TryGetValue(userId, out var header))
{
lastHeaders[userId] = header = new FrameHeader(new ScoreInfo
{
Statistics = new Dictionary<HitResult, int>
{
[HitResult.Miss] = 0,
[HitResult.Meh] = 0,
[HitResult.Great] = 0
}
});
}
switch (RNG.Next(0, 3))
{
case 0:
header.Combo = 0;
header.Statistics[HitResult.Miss]++;
break;
case 1:
header.Combo++;
header.MaxCombo = Math.Max(header.MaxCombo, header.Combo);
header.Statistics[HitResult.Meh]++;
break;
default:
header.Combo++;
header.MaxCombo = Math.Max(header.MaxCombo, header.Combo);
header.Statistics[HitResult.Great]++;
break;
}
spectatorClient.Raise(s => s.OnNewFrames -= null, userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) }));
}
}
}
}

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
@ -34,9 +35,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestMultipleStatuses()
{
FillFlowContainer rooms = null;
AddStep("create rooms", () =>
{
Child = new FillFlowContainer
Child = rooms = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -124,6 +127,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
};
});
AddUntilStep("wait for panel load", () => rooms.Count == 5);
AddUntilStep("correct status text", () => rooms.ChildrenOfType<OsuSpriteText>().Count(s => s.Text.ToString().StartsWith("Currently playing", StringComparison.Ordinal)) == 2);
AddUntilStep("correct status text", () => rooms.ChildrenOfType<OsuSpriteText>().Count(s => s.Text.ToString().StartsWith("Ready to play", StringComparison.Ordinal)) == 3);
}
[Test]

View File

@ -9,13 +9,14 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.Play;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneGameplayChatDisplay : MultiplayerTestScene
public class TestSceneGameplayChatDisplay : OsuManualInputManagerTestScene
{
private GameplayChatDisplay chatDisplay;
@ -35,11 +36,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
[SetUpSteps]
public override void SetUpSteps()
public void SetUpSteps()
{
base.SetUpSteps();
AddStep("load chat display", () => Child = chatDisplay = new GameplayChatDisplay(SelectedRoom.Value)
AddStep("load chat display", () => Child = chatDisplay = new GameplayChatDisplay(new Room())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -1,161 +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 System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Tests.Visual.OnlinePlay;
using osu.Game.Tests.Visual.Spectator;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiplayerGameplayLeaderboard : MultiplayerTestScene
public class TestSceneMultiplayerGameplayLeaderboard : MultiplayerGameplayLeaderboardTestScene
{
private static IEnumerable<int> users => Enumerable.Range(0, 16);
public new TestMultiplayerSpectatorClient SpectatorClient => (TestMultiplayerSpectatorClient)OnlinePlayDependencies?.SpectatorClient;
private MultiplayerGameplayLeaderboard leaderboard;
private OsuConfigManager config;
[BackgroundDependencyLoader]
private void load()
protected override MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor)
{
Dependencies.Cache(config = new OsuConfigManager(LocalStorage));
}
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = UserLookupCache.GetUserAsync(1).GetResultSafely());
AddStep("create leaderboard", () =>
return new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, MultiplayerUsers.ToArray())
{
leaderboard?.Expire();
OsuScoreProcessor scoreProcessor;
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
var multiplayerUsers = new List<MultiplayerRoomUser>();
foreach (int user in users)
{
SpectatorClient.SendStartPlay(user, Beatmap.Value.BeatmapInfo.OnlineID);
multiplayerUsers.Add(OnlinePlayDependencies.MultiplayerClient.AddUser(new APIUser { Id = user }, true));
}
Children = new Drawable[]
{
scoreProcessor = new OsuScoreProcessor(),
};
scoreProcessor.ApplyBeatmap(playableBeatmap);
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, multiplayerUsers.ToArray())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
}, Add);
});
AddUntilStep("wait for load", () => leaderboard.IsLoaded);
AddUntilStep("wait for user population", () => MultiplayerClient.CurrentMatchPlayingUserIds.Count > 0);
}
[Test]
public void TestScoreUpdates()
{
AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 100);
AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded);
}
[Test]
public void TestUserQuit()
{
foreach (int user in users)
AddStep($"mark user {user} quit", () => MultiplayerClient.RemoveUser(UserLookupCache.GetUserAsync(user).GetResultSafely().AsNonNull()));
}
[Test]
public void TestChangeScoringMode()
{
AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 5);
AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic));
AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised));
}
protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies();
protected class TestDependencies : MultiplayerTestSceneDependencies
{
protected override TestSpectatorClient CreateSpectatorClient() => new TestMultiplayerSpectatorClient();
}
public class TestMultiplayerSpectatorClient : TestSpectatorClient
{
private readonly Dictionary<int, FrameHeader> lastHeaders = new Dictionary<int, FrameHeader>();
public void RandomlyUpdateState()
{
foreach ((int userId, _) in WatchedUserStates)
{
if (RNG.NextBool())
continue;
if (!lastHeaders.TryGetValue(userId, out var header))
{
lastHeaders[userId] = header = new FrameHeader(new ScoreInfo
{
Statistics = new Dictionary<HitResult, int>
{
[HitResult.Miss] = 0,
[HitResult.Meh] = 0,
[HitResult.Great] = 0
}
});
}
switch (RNG.Next(0, 3))
{
case 0:
header.Combo = 0;
header.Statistics[HitResult.Miss]++;
break;
case 1:
header.Combo++;
header.MaxCombo = Math.Max(header.MaxCombo, header.Combo);
header.Statistics[HitResult.Meh]++;
break;
default:
header.Combo++;
header.MaxCombo = Math.Max(header.MaxCombo, header.Combo);
header.Statistics[HitResult.Great]++;
break;
}
((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) }));
}
}
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
}
}
}

View File

@ -1,121 +1,56 @@
// 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 NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.Play.HUD;
using osu.Game.Tests.Visual.OnlinePlay;
using osu.Game.Tests.Visual.Spectator;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiplayerGameplayLeaderboardTeams : MultiplayerTestScene
public class TestSceneMultiplayerGameplayLeaderboardTeams : MultiplayerGameplayLeaderboardTestScene
{
private static IEnumerable<int> users => Enumerable.Range(0, 16);
public new TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient SpectatorClient =>
(TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient)OnlinePlayDependencies?.SpectatorClient;
protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies();
protected class TestDependencies : MultiplayerTestSceneDependencies
protected override MultiplayerRoomUser CreateUser(int userId)
{
protected override TestSpectatorClient CreateSpectatorClient() => new TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient();
var user = base.CreateUser(userId);
user.MatchState = new TeamVersusUserState
{
TeamID = RNG.Next(0, 2)
};
return user;
}
private MultiplayerGameplayLeaderboard leaderboard;
private GameplayMatchScoreDisplay gameplayScoreDisplay;
protected override Room CreateRoom()
{
var room = base.CreateRoom();
room.Type.Value = MatchType.TeamVersus;
return room;
}
protected override MultiplayerGameplayLeaderboard CreateLeaderboard(OsuScoreProcessor scoreProcessor) =>
new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, MultiplayerUsers.ToArray())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = UserLookupCache.GetUserAsync(1).GetResultSafely());
AddStep("create leaderboard", () =>
AddStep("Add external display components", () =>
{
leaderboard?.Expire();
OsuScoreProcessor scoreProcessor;
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
var multiplayerUsers = new List<MultiplayerRoomUser>();
foreach (int user in users)
LoadComponentAsync(new MatchScoreDisplay
{
SpectatorClient.SendStartPlay(user, Beatmap.Value.BeatmapInfo.OnlineID);
var roomUser = OnlinePlayDependencies.MultiplayerClient.AddUser(new APIUser { Id = user }, true);
Team1Score = { BindTarget = Leaderboard.TeamScores[0] },
Team2Score = { BindTarget = Leaderboard.TeamScores[1] }
}, Add);
roomUser.MatchState = new TeamVersusUserState
{
TeamID = RNG.Next(0, 2)
};
multiplayerUsers.Add(roomUser);
}
Children = new Drawable[]
LoadComponentAsync(new GameplayMatchScoreDisplay
{
scoreProcessor = new OsuScoreProcessor(),
};
scoreProcessor.ApplyBeatmap(playableBeatmap);
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, multiplayerUsers.ToArray())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
}, gameplayLeaderboard =>
{
LoadComponentAsync(new MatchScoreDisplay
{
Team1Score = { BindTarget = leaderboard.TeamScores[0] },
Team2Score = { BindTarget = leaderboard.TeamScores[1] }
}, Add);
LoadComponentAsync(gameplayScoreDisplay = new GameplayMatchScoreDisplay
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Team1Score = { BindTarget = leaderboard.TeamScores[0] },
Team2Score = { BindTarget = leaderboard.TeamScores[1] }
}, Add);
Add(gameplayLeaderboard);
});
});
AddUntilStep("wait for load", () => leaderboard.IsLoaded);
AddUntilStep("wait for user population", () => MultiplayerClient.CurrentMatchPlayingUserIds.Count > 0);
}
[Test]
public void TestScoreUpdates()
{
AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 100);
AddToggleStep("switch compact mode", expanded =>
{
leaderboard.Expanded.Value = expanded;
gameplayScoreDisplay.Expanded.Value = expanded;
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Team1Score = { BindTarget = Leaderboard.TeamScores[0] },
Team2Score = { BindTarget = Leaderboard.TeamScores[1] },
Expanded = { BindTarget = Leaderboard.Expanded },
}, Add);
});
}
}

View File

@ -4,6 +4,7 @@
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
namespace osu.Game.Tests.Visual.Multiplayer
@ -13,13 +14,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
[SetUp]
public new void Setup() => Schedule(() =>
{
Child = new Container
Child = new PopoverContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
Height = 50,
Child = new MultiplayerMatchFooter()
RelativeSizeAxes = Axes.Both,
Child = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
Height = 50,
Child = new MultiplayerMatchFooter()
}
};
});
}

View File

@ -8,13 +8,12 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets;
@ -27,6 +26,7 @@ using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Screens.OnlinePlay;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.Select;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.Multiplayer
{
@ -35,10 +35,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
private BeatmapManager manager;
private RulesetStore rulesets;
private List<BeatmapInfo> beatmaps;
private IList<BeatmapInfo> beatmaps => importedBeatmapSet?.PerformRead(s => s.Beatmaps) ?? new List<BeatmapInfo>();
private TestMultiplayerMatchSongSelect songSelect;
private Live<BeatmapSetInfo> importedBeatmapSet;
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
@ -46,44 +48,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
beatmaps = new List<BeatmapInfo>();
var metadata = new BeatmapMetadata
{
Artist = "Some Artist",
Title = "Some Beatmap",
Author = { Username = "Some Author" },
};
var beatmapSetInfo = new BeatmapSetInfo
{
OnlineID = 10,
Hash = Guid.NewGuid().ToString().ComputeMD5Hash(),
DateAdded = DateTimeOffset.UtcNow
};
for (int i = 0; i < 8; ++i)
{
int beatmapId = 10 * 10 + i;
int length = RNG.Next(30000, 200000);
double bpm = RNG.NextSingle(80, 200);
var beatmap = new BeatmapInfo
{
Ruleset = rulesets.GetRuleset(i % 4) ?? throw new InvalidOperationException(),
OnlineID = beatmapId,
Length = length,
BPM = bpm,
Metadata = metadata,
Difficulty = new BeatmapDifficulty()
};
beatmaps.Add(beatmap);
beatmapSetInfo.Beatmaps.Add(beatmap);
}
manager.Import(beatmapSetInfo);
importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray()));
}
public override void SetUpSteps()

View File

@ -1,67 +1,68 @@
// 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 Moq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneRankRangePill : MultiplayerTestScene
public class TestSceneRankRangePill : OsuTestScene
{
[SetUp]
public new void Setup() => Schedule(() =>
private readonly Mock<MultiplayerClient> multiplayerClient = new Mock<MultiplayerClient>();
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
// not used directly in component, but required due to it inheriting from OnlinePlayComposite.
new CachedModelDependencyContainer<Room>(base.CreateChildDependencies(parent));
[BackgroundDependencyLoader]
private void load()
{
Dependencies.CacheAs(multiplayerClient.Object);
Child = new RankRangePill
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
};
});
}
[Test]
public void TestSingleUser()
{
AddStep("add user", () =>
setupRoomWithUsers(new APIUser
{
MultiplayerClient.AddUser(new APIUser
{
Id = 2,
Statistics = { GlobalRank = 1234 }
});
// Remove the local user so only the one above is displayed.
MultiplayerClient.RemoveUser(API.LocalUser.Value);
Id = 2,
Statistics = { GlobalRank = 1234 }
});
}
[Test]
public void TestMultipleUsers()
{
AddStep("add users", () =>
{
MultiplayerClient.AddUser(new APIUser
setupRoomWithUsers(
new APIUser
{
Id = 2,
Statistics = { GlobalRank = 1234 }
});
MultiplayerClient.AddUser(new APIUser
},
new APIUser
{
Id = 3,
Statistics = { GlobalRank = 3333 }
});
MultiplayerClient.AddUser(new APIUser
},
new APIUser
{
Id = 4,
Statistics = { GlobalRank = 4321 }
});
// Remove the local user so only the ones above are displayed.
MultiplayerClient.RemoveUser(API.LocalUser.Value);
});
}
[TestCase(1, 10)]
@ -73,22 +74,29 @@ namespace osu.Game.Tests.Visual.Multiplayer
[TestCase(1000000, 10000000)]
public void TestRange(int min, int max)
{
AddStep("add users", () =>
{
MultiplayerClient.AddUser(new APIUser
setupRoomWithUsers(
new APIUser
{
Id = 2,
Statistics = { GlobalRank = min }
});
MultiplayerClient.AddUser(new APIUser
},
new APIUser
{
Id = 3,
Statistics = { GlobalRank = max }
});
}
// Remove the local user so only the ones above are displayed.
MultiplayerClient.RemoveUser(API.LocalUser.Value);
private void setupRoomWithUsers(params APIUser[] users)
{
AddStep("setup room", () =>
{
multiplayerClient.SetupGet(m => m.Room).Returns(new MultiplayerRoom(0)
{
Users = new List<MultiplayerRoomUser>(users.Select(apiUser => new MultiplayerRoomUser(apiUser.Id) { User = apiUser }))
});
multiplayerClient.Raise(m => m.RoomUpdated -= null);
});
}
}

View File

@ -534,11 +534,33 @@ namespace osu.Game.Tests.Visual.Online
});
});
AddStep("Highlight message and open chat", () =>
AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel1));
}
[Test]
public void TestHighlightWithNullChannel()
{
Message message = null;
AddStep("Join channel 1", () => channelManager.JoinChannel(channel1));
AddStep("Send message in channel 1", () =>
{
chatOverlay.HighlightMessage(message, channel1);
chatOverlay.Show();
channel1.AddNewMessages(message = new Message
{
ChannelId = channel1.Id,
Content = "Message to highlight!",
Timestamp = DateTimeOffset.Now,
Sender = new APIUser
{
Id = 2,
Username = "Someone",
}
});
});
AddStep("Set null channel", () => channelManager.CurrentChannel.Value = null);
AddStep("Highlight message", () => chatOverlay.HighlightMessage(message, channel1));
}
private void pressChannelHotkey(int number)

View File

@ -259,7 +259,7 @@ namespace osu.Game.Tests.Visual.Playlists
{
multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore
{
ID = --highestScoreId,
ID = getNextLowestScoreId(),
Accuracy = userScore.Accuracy,
Passed = true,
Rank = userScore.Rank,
@ -274,7 +274,7 @@ namespace osu.Game.Tests.Visual.Playlists
multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore
{
ID = ++lowestScoreId,
ID = getNextHighestScoreId(),
Accuracy = userScore.Accuracy,
Passed = true,
Rank = userScore.Rank,
@ -306,7 +306,7 @@ namespace osu.Game.Tests.Visual.Playlists
{
result.Scores.Add(new MultiplayerScore
{
ID = sort == "score_asc" ? --highestScoreId : ++lowestScoreId,
ID = sort == "score_asc" ? getNextHighestScoreId() : getNextLowestScoreId(),
Accuracy = 1,
Passed = true,
Rank = ScoreRank.X,
@ -327,6 +327,17 @@ namespace osu.Game.Tests.Visual.Playlists
return result;
}
/// <summary>
/// The next highest score ID to appear at the left of the list. Monotonically decreasing.
/// </summary>
private int getNextHighestScoreId() => --highestScoreId;
/// <summary>
/// The next lowest score ID to appear at the right of the list. Monotonically increasing.
/// </summary>
/// <returns></returns>
private int getNextLowestScoreId() => ++lowestScoreId;
private void addCursor(MultiplayerScores scores)
{
scores.Cursor = new Cursor
@ -342,7 +353,9 @@ namespace osu.Game.Tests.Visual.Playlists
{
Properties = new Dictionary<string, JToken>
{
{ "sort", JToken.FromObject(scores.Scores[^1].ID > scores.Scores[^2].ID ? "score_asc" : "score_desc") }
// [ 1, 2, 3, ... ] => score_desc (will be added to the right of the list)
// [ 3, 2, 1, ... ] => score_asc (will be added to the left of the list)
{ "sort", JToken.FromObject(scores.Scores[^1].ID > scores.Scores[^2].ID ? "score_desc" : "score_asc") }
}
};
}

View File

@ -1,7 +1,6 @@
// 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 NUnit.Framework;
@ -17,7 +16,6 @@ using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Models;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
@ -60,20 +58,7 @@ namespace osu.Game.Tests.Visual.UserInterface
Anchor = Anchor.Centre,
Size = new Vector2(550f, 450f),
Scope = BeatmapLeaderboardScope.Local,
BeatmapInfo = new BeatmapInfo
{
ID = Guid.NewGuid(),
Metadata = new BeatmapMetadata
{
Title = "TestSong",
Artist = "TestArtist",
Author = new RealmUser
{
Username = "TestAuthor"
},
},
DifficultyName = "Insane"
},
BeatmapInfo = TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()
}
},
dialogOverlay = new DialogOverlay()

View File

@ -0,0 +1,140 @@
// 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.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osuTK.Input;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public class TestSceneModSelectScreen : OsuManualInputManagerTestScene
{
[Resolved]
private RulesetStore rulesetStore { get; set; }
private ModSelectScreen modSelectScreen;
[SetUpSteps]
public void SetUpSteps()
{
AddStep("clear contents", Clear);
AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0));
AddStep("reset mods", () => SelectedMods.SetDefault());
}
private void createScreen()
{
AddStep("create screen", () => Child = modSelectScreen = new ModSelectScreen
{
RelativeSizeAxes = Axes.Both,
State = { Value = Visibility.Visible },
SelectedMods = { BindTarget = SelectedMods }
});
waitForColumnLoad();
}
[Test]
public void TestStateChange()
{
createScreen();
AddStep("toggle state", () => modSelectScreen.ToggleVisibility());
}
[Test]
public void TestPreexistingSelection()
{
AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModAlternate(), new OsuModDaycore() });
createScreen();
AddUntilStep("two panels active", () => modSelectScreen.ChildrenOfType<ModPanel>().Count(panel => panel.Active.Value) == 2);
AddAssert("mod multiplier correct", () =>
{
double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier);
return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType<DifficultyMultiplierDisplay>().Single().Current.Value);
});
assertCustomisationToggleState(disabled: false, active: false);
}
[Test]
public void TestExternalSelection()
{
createScreen();
AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModAlternate(), new OsuModDaycore() });
AddUntilStep("two panels active", () => modSelectScreen.ChildrenOfType<ModPanel>().Count(panel => panel.Active.Value) == 2);
AddAssert("mod multiplier correct", () =>
{
double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier);
return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType<DifficultyMultiplierDisplay>().Single().Current.Value);
});
assertCustomisationToggleState(disabled: false, active: false);
}
[Test]
public void TestRulesetChange()
{
createScreen();
changeRuleset(0);
changeRuleset(1);
changeRuleset(2);
changeRuleset(3);
}
[Test]
public void TestCustomisationToggleState()
{
createScreen();
assertCustomisationToggleState(disabled: true, active: false);
AddStep("select customisable mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() });
assertCustomisationToggleState(disabled: false, active: false);
AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() });
assertCustomisationToggleState(disabled: false, active: true);
AddStep("dismiss mod customisation", () =>
{
InputManager.MoveMouseTo(modSelectScreen.ChildrenOfType<ShearedToggleButton>().Single());
InputManager.Click(MouseButton.Left);
});
AddStep("append another mod not requiring config", () => SelectedMods.Value = SelectedMods.Value.Append(new OsuModFlashlight()).ToArray());
assertCustomisationToggleState(disabled: false, active: false);
AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() });
assertCustomisationToggleState(disabled: true, active: false);
AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() });
assertCustomisationToggleState(disabled: false, active: true);
AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() });
assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action.
}
private void waitForColumnLoad() => AddUntilStep("all column content loaded",
() => modSelectScreen.ChildrenOfType<ModColumn>().Any() && modSelectScreen.ChildrenOfType<ModColumn>().All(column => column.IsLoaded && column.ItemsLoaded));
private void changeRuleset(int id)
{
AddStep($"set ruleset to {id}", () => Ruleset.Value = rulesetStore.GetRuleset(id));
waitForColumnLoad();
}
private void assertCustomisationToggleState(bool disabled, bool active)
{
ShearedToggleButton getToggle() => modSelectScreen.ChildrenOfType<ShearedToggleButton>().Single();
AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => getToggle().Active.Disabled == disabled);
AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => getToggle().Active.Value == active);
}
}
}

View File

@ -4,25 +4,58 @@
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Game.Overlays.Dialog;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public class TestScenePopupDialog : OsuTestScene
public class TestScenePopupDialog : OsuManualInputManagerTestScene
{
public TestScenePopupDialog()
private TestPopupDialog dialog;
[SetUpSteps]
public void SetUpSteps()
{
AddStep("new popup", () =>
Add(new TestPopupDialog
{
Add(dialog = new TestPopupDialog
{
RelativeSizeAxes = Axes.Both,
State = { Value = Framework.Graphics.Containers.Visibility.Visible },
}));
});
});
}
[Test]
public void TestDangerousButton([Values(false, true)] bool atEdge)
{
if (atEdge)
{
AddStep("move mouse to button edge", () =>
{
var dangerousButtonQuad = dialog.DangerousButton.ScreenSpaceDrawQuad;
InputManager.MoveMouseTo(new Vector2(dangerousButtonQuad.TopLeft.X + 5, dangerousButtonQuad.Centre.Y));
});
}
else
AddStep("move mouse to button", () => InputManager.MoveMouseTo(dialog.DangerousButton));
AddStep("click button", () => InputManager.Click(MouseButton.Left));
AddAssert("action not invoked", () => !dialog.DangerousButtonInvoked);
AddStep("hold button", () => InputManager.PressButton(MouseButton.Left));
AddUntilStep("action invoked", () => dialog.DangerousButtonInvoked);
AddStep("release button", () => InputManager.ReleaseButton(MouseButton.Left));
}
private class TestPopupDialog : PopupDialog
{
public PopupDialogDangerousButton DangerousButton { get; }
public bool DangerousButtonInvoked;
public TestPopupDialog()
{
Icon = FontAwesome.Solid.AssistiveListeningSystems;
@ -40,9 +73,10 @@ namespace osu.Game.Tests.Visual.UserInterface
{
Text = @"You're a fake!",
},
new PopupDialogDangerousButton
DangerousButton = new PopupDialogDangerousButton
{
Text = @"Careful with this one..",
Action = () => DangerousButtonInvoked = true,
},
};
}

View File

@ -17,7 +17,6 @@ using osu.Framework.Logging;
using osu.Framework.Testing;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.UI;
using osu.Game.Skinning;
using osu.Game.Storyboards;
@ -152,24 +151,7 @@ namespace osu.Game.Beatmaps
{
const double excess_length = 1000;
var lastObject = Beatmap?.HitObjects.LastOrDefault();
double length;
switch (lastObject)
{
case null:
length = emptyLength;
break;
case IHasDuration endTime:
length = endTime.EndTime + excess_length;
break;
default:
length = lastObject.StartTime + excess_length;
break;
}
double length = (BeatmapInfo?.Length + excess_length) ?? emptyLength;
return audioManager.Tracks.GetVirtual(length);
}

View File

@ -3,6 +3,7 @@
using System;
using System.Diagnostics;
using System.Globalization;
using osu.Framework.Configuration;
using osu.Framework.Configuration.Tracking;
using osu.Framework.Extensions;
@ -102,6 +103,9 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.MenuParallax, true);
// See https://stackoverflow.com/a/63307411 for default sourcing.
SetDefault(OsuSetting.Prefer24HourTime, CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains(@"tt"));
// Gameplay
SetDefault(OsuSetting.PositionalHitsounds, true); // replaced by level setting below, can be removed 20220703.
SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.2f, 0, 1);
@ -287,6 +291,7 @@ namespace osu.Game.Configuration
MenuVoice,
CursorRotation,
MenuParallax,
Prefer24HourTime,
BeatmapDetailTab,
BeatmapDetailModsFilter,
Username,

View File

@ -109,6 +109,9 @@ namespace osu.Game.Graphics
{
foreach (var p in particles)
{
if (p.Duration == 0)
continue;
float timeSinceStart = currentTime - p.StartTime;
// ignore particles from the future.

View File

@ -3,11 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Binary;
using System.Text;
namespace osu.Game.IO.Legacy
@ -26,15 +22,6 @@ namespace osu.Game.IO.Legacy
public int RemainingBytes => (int)(stream.Length - stream.Position);
/// <summary> Static method to take a SerializationInfo object (an input to an ISerializable constructor)
/// and produce a SerializationReader from which serialized objects can be read </summary>.
public static SerializationReader GetReader(SerializationInfo info)
{
byte[] byteArray = (byte[])info.GetValue("X", typeof(byte[]));
MemoryStream ms = new MemoryStream(byteArray);
return new SerializationReader(ms);
}
/// <summary> Reads a string from the buffer. Overrides the base implementation so it can cope with nulls. </summary>
public override string ReadString()
{
@ -186,98 +173,12 @@ namespace osu.Game.IO.Legacy
return ReadCharArray();
case ObjType.otherType:
return DynamicDeserializer.Deserialize(BaseStream);
throw new IOException("Deserialization of arbitrary type is not supported.");
default:
return null;
}
}
public static class DynamicDeserializer
{
private static VersionConfigToNamespaceAssemblyObjectBinder versionBinder;
private static BinaryFormatter formatter;
private static void initialize()
{
versionBinder = new VersionConfigToNamespaceAssemblyObjectBinder();
formatter = new BinaryFormatter
{
// AssemblyFormat = FormatterAssemblyStyle.Simple,
Binder = versionBinder
};
}
public static object Deserialize(Stream stream)
{
if (formatter == null)
initialize();
Debug.Assert(formatter != null, "formatter != null");
// ReSharper disable once PossibleNullReferenceException
return formatter.Deserialize(stream);
}
#region Nested type: VersionConfigToNamespaceAssemblyObjectBinder
public sealed class VersionConfigToNamespaceAssemblyObjectBinder : SerializationBinder
{
private readonly Dictionary<string, Type> cache = new Dictionary<string, Type>();
public override Type BindToType(string assemblyName, string typeName)
{
if (cache.TryGetValue(assemblyName + typeName, out var typeToDeserialize))
return typeToDeserialize;
List<Type> tmpTypes = new List<Type>();
Type genType = null;
if (typeName.Contains("System.Collections.Generic") && typeName.Contains("[["))
{
string[] splitTypes = typeName.Split('[');
foreach (string typ in splitTypes)
{
if (typ.Contains("Version"))
{
string asmTmp = typ.Substring(typ.IndexOf(',') + 1);
string asmName = asmTmp.Remove(asmTmp.IndexOf(']')).Trim();
string typName = typ.Remove(typ.IndexOf(','));
tmpTypes.Add(BindToType(asmName, typName));
}
else if (typ.Contains("Generic"))
{
genType = BindToType(assemblyName, typ);
}
}
if (genType != null && tmpTypes.Count > 0)
{
return genType.MakeGenericType(tmpTypes.ToArray());
}
}
string toAssemblyName = assemblyName.Split(',')[0];
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (Assembly a in assemblies)
{
if (a.FullName.Split(',')[0] == toAssemblyName)
{
typeToDeserialize = a.GetType(typeName);
break;
}
}
cache.Add(assemblyName + typeName, typeToDeserialize);
return typeToDeserialize;
}
}
#endregion
}
}
public enum ObjType : byte

View File

@ -4,9 +4,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters;
using System.Runtime.Serialization.Formatters.Binary;
using System.Text;
// ReSharper disable ConditionIsAlwaysTrueOrFalse (we're allowing nulls to be passed to the writer where the underlying class doesn't).
@ -218,25 +215,11 @@ namespace osu.Game.IO.Legacy
break;
default:
Write((byte)ObjType.otherType);
BinaryFormatter b = new BinaryFormatter
{
// AssemblyFormat = FormatterAssemblyStyle.Simple,
TypeFormat = FormatterTypeStyle.TypesWhenNeeded
};
b.Serialize(BaseStream, obj);
break;
throw new IOException("Serialization of arbitrary type is not supported.");
} // switch
} // if obj==null
} // WriteObject
/// <summary> Adds the SerializationWriter buffer to the SerializationInfo at the end of GetObjectData(). </summary>
public void AddToInfo(SerializationInfo info)
{
byte[] b = ((MemoryStream)BaseStream).ToArray();
info.AddValue("X", b, typeof(byte[]));
}
public void WriteRawBytes(byte[] b)
{
base.Write(b);

View File

@ -29,6 +29,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString PreferOriginalMetadataLanguage => new TranslatableString(getKey(@"prefer_original"), @"Prefer metadata in original language");
/// <summary>
/// "Prefer 24-hour time display"
/// </summary>
public static LocalisableString Prefer24HourTimeDisplay => new TranslatableString(getKey(@"prefer_24_hour_time_display"), @"Prefer 24-hour time display");
/// <summary>
/// "Updates"
/// </summary>

View File

@ -178,8 +178,6 @@ namespace osu.Game.Online.Chat
{
notificationOverlay.Hide();
chatOverlay.HighlightMessage(message, channel);
chatOverlay.Show();
return true;
};
}

View File

@ -11,6 +11,7 @@ using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Database;
@ -31,7 +32,7 @@ namespace osu.Game.Online.Multiplayer
/// <summary>
/// Invoked when any change occurs to the multiplayer room.
/// </summary>
public event Action? RoomUpdated;
public virtual event Action? RoomUpdated;
/// <summary>
/// Invoked when a new user joins the room.
@ -41,7 +42,7 @@ namespace osu.Game.Online.Multiplayer
/// <summary>
/// Invoked when a user leaves the room of their own accord.
/// </summary>
public event Action<MultiplayerRoomUser>? UserLeft;
public virtual event Action<MultiplayerRoomUser>? UserLeft;
/// <summary>
/// Invoked when a user was kicked from the room forcefully.
@ -87,12 +88,26 @@ namespace osu.Game.Online.Multiplayer
/// <summary>
/// The joined <see cref="MultiplayerRoom"/>.
/// </summary>
public MultiplayerRoom? Room { get; private set; }
public virtual MultiplayerRoom? Room
{
get
{
Debug.Assert(ThreadSafety.IsUpdateThread);
return room;
}
private set
{
Debug.Assert(ThreadSafety.IsUpdateThread);
room = value;
}
}
private MultiplayerRoom? room;
/// <summary>
/// The users in the joined <see cref="Room"/> which are participating in the current gameplay loop.
/// </summary>
public IBindableList<int> CurrentMatchPlayingUserIds => PlayingUserIds;
public virtual IBindableList<int> CurrentMatchPlayingUserIds => PlayingUserIds;
protected readonly BindableList<int> PlayingUserIds = new BindableList<int>();
@ -127,7 +142,7 @@ namespace osu.Game.Online.Multiplayer
[BackgroundDependencyLoader]
private void load()
{
IsConnected.BindValueChanged(connected =>
IsConnected.BindValueChanged(connected => Scheduler.Add(() =>
{
// clean up local room state on server disconnect.
if (!connected.NewValue && Room != null)
@ -135,7 +150,7 @@ namespace osu.Game.Online.Multiplayer
Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
LeaveRoom();
}
});
}));
}
private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
@ -148,13 +163,13 @@ namespace osu.Game.Online.Multiplayer
/// <param name="password">An optional password to use for the join operation.</param>
public async Task JoinRoom(Room room, string? password = null)
{
if (Room != null)
throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
var cancellationSource = joinCancellationSource = new CancellationTokenSource();
await joinOrLeaveTaskChain.Add(async () =>
{
if (Room != null)
throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
Debug.Assert(room.RoomID.Value != null);
// Join the server-side room.
@ -166,8 +181,10 @@ namespace osu.Game.Online.Multiplayer
await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false);
// Update the stored room (must be done on update thread for thread-safety).
await scheduleAsync(() =>
await runOnUpdateThreadAsync(() =>
{
Debug.Assert(Room == null);
Room = joinedRoom;
APIRoom = room;
@ -213,7 +230,7 @@ namespace osu.Game.Online.Multiplayer
// Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background.
// However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed.
// For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time.
var scheduledReset = scheduleAsync(() =>
var scheduledReset = runOnUpdateThreadAsync(() =>
{
APIRoom = null;
Room = null;
@ -343,9 +360,6 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -378,9 +392,6 @@ namespace osu.Game.Online.Multiplayer
async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
{
if (Room == null)
return;
await PopulateUser(user).ConfigureAwait(false);
Scheduler.Add(() =>
@ -429,9 +440,6 @@ namespace osu.Game.Online.Multiplayer
private Task handleUserLeft(MultiplayerRoomUser user, Action<MultiplayerRoomUser>? callback)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -453,9 +461,6 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.HostChanged(int userId)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -476,26 +481,21 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
{
Debug.Assert(APIRoom != null);
Debug.Assert(Room != null);
Scheduler.Add(() => updateLocalRoomSettings(newSettings));
return Task.CompletedTask;
}
Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
// TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713.
if (user == null)
return;
Room.Users.Single(u => u.UserID == userId).State = state;
user.State = state;
updateUserPlayingState(userId, state);
RoomUpdated?.Invoke();
@ -506,15 +506,15 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.MatchUserStateChanged(int userId, MatchUserState state)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
// TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713.
if (user == null)
return;
Room.Users.Single(u => u.UserID == userId).MatchState = state;
user.MatchState = state;
RoomUpdated?.Invoke();
}, false);
@ -523,9 +523,6 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.MatchRoomStateChanged(MatchRoomState state)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -540,9 +537,6 @@ namespace osu.Game.Online.Multiplayer
public Task MatchEvent(MatchServerEvent e)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -563,9 +557,6 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
@ -584,9 +575,6 @@ namespace osu.Game.Online.Multiplayer
public Task UserModsChanged(int userId, IEnumerable<APIMod> mods)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
@ -605,9 +593,6 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.LoadRequested()
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -621,9 +606,6 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.MatchStarted()
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -637,9 +619,6 @@ namespace osu.Game.Online.Multiplayer
Task IMultiplayerClient.ResultsReady()
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -653,9 +632,6 @@ namespace osu.Game.Online.Multiplayer
public Task PlaylistItemAdded(MultiplayerPlaylistItem item)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -675,9 +651,6 @@ namespace osu.Game.Online.Multiplayer
public Task PlaylistItemRemoved(long playlistItemId)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -699,9 +672,6 @@ namespace osu.Game.Online.Multiplayer
public Task PlaylistItemChanged(MultiplayerPlaylistItem item)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
@ -784,7 +754,7 @@ namespace osu.Game.Online.Multiplayer
PlayingUserIds.Remove(userId);
}
private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
private Task runOnUpdateThreadAsync(Action action, CancellationToken cancellationToken = default)
{
var tcs = new TaskCompletionSource<bool>();

View File

@ -0,0 +1,44 @@
// 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.
#nullable enable
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using osu.Framework.Logging;
namespace osu.Game.Online.Multiplayer
{
public static class MultiplayerClientExtensions
{
public static void FireAndForget(this Task task, Action? onSuccess = null, Action<Exception>? onError = null) =>
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
Exception? exception = t.Exception;
if (exception is AggregateException ae)
exception = ae.InnerException;
Debug.Assert(exception != null);
string message = exception is HubException
// HubExceptions arrive with additional message context added, but we want to display the human readable message:
// "An unexpected error occurred invoking 'AddPlaylistItem' on the server.InvalidStateException: Can't enqueue more than 3 items at once."
// We generally use the message field for a user-parseable error (eventually to be replaced), so drop the first part for now.
? exception.Message.Substring(exception.Message.IndexOf(':') + 1).Trim()
: exception.Message;
Logger.Log(message, level: LogLevel.Important);
onError?.Invoke(exception);
}
else
{
onSuccess?.Invoke();
}
});
}
}

View File

@ -54,17 +54,17 @@ namespace osu.Game.Online.Spectator
/// <summary>
/// Called whenever new frames arrive from the server.
/// </summary>
public event Action<int, FrameDataBundle>? OnNewFrames;
public virtual event Action<int, FrameDataBundle>? OnNewFrames;
/// <summary>
/// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session.
/// </summary>
public event Action<int, SpectatorState>? OnUserBeganPlaying;
public virtual event Action<int, SpectatorState>? OnUserBeganPlaying;
/// <summary>
/// Called whenever a user finishes a play session.
/// </summary>
public event Action<int, SpectatorState>? OnUserFinishedPlaying;
public virtual event Action<int, SpectatorState>? OnUserFinishedPlaying;
/// <summary>
/// All users currently being watched.
@ -221,7 +221,7 @@ namespace osu.Game.Online.Spectator
});
}
public void WatchUser(int userId)
public virtual void WatchUser(int userId)
{
Debug.Assert(ThreadSafety.IsUpdateThread);

View File

@ -315,7 +315,7 @@ namespace osu.Game.Overlays
{
Debug.Assert(channel.Id == message.ChannelId);
if (currentChannel.Value.Id != channel.Id)
if (currentChannel.Value?.Id != channel.Id)
{
if (!channel.Joined.Value)
channel = channelManager.JoinChannel(channel);
@ -324,6 +324,8 @@ namespace osu.Game.Overlays
}
channel.HighlightedMessage.Value = message;
Show();
}
private float startDragChatHeight;

View File

@ -12,37 +12,38 @@ namespace osu.Game.Overlays.Dialog
{
public class PopupDialogDangerousButton : PopupDialogButton
{
private Box progressBox;
private DangerousConfirmContainer confirmContainer;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
ButtonColour = colours.Red3;
ColourContainer.Add(new ConfirmFillBox
ColourContainer.Add(progressBox = new Box
{
Action = () => Action(),
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive,
});
AddInternal(confirmContainer = new DangerousConfirmContainer
{
Action = () => Action(),
RelativeSizeAxes = Axes.Both,
});
}
private class ConfirmFillBox : HoldToConfirmContainer
protected override void LoadComplete()
{
private Box box;
base.LoadComplete();
confirmContainer.Progress.BindValueChanged(progress => progressBox.Width = (float)progress.NewValue, true);
}
private class DangerousConfirmContainer : HoldToConfirmContainer
{
protected override double? HoldActivationDelay => 500;
protected override void LoadComplete()
{
base.LoadComplete();
Child = box = new Box
{
RelativeSizeAxes = Axes.Both,
};
Progress.BindValueChanged(progress => box.Width = (float)progress.NewValue, true);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
BeginConfirm();

View File

@ -20,6 +20,8 @@ namespace osu.Game.Overlays.Mods
{
public class DifficultyMultiplierDisplay : CompositeDrawable, IHasCurrentValue<double>
{
public const float HEIGHT = 42;
public Bindable<double> Current
{
get => current.Current;
@ -42,13 +44,12 @@ namespace osu.Game.Overlays.Mods
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
private const float height = 42;
private const float multiplier_value_area_width = 56;
private const float transition_duration = 200;
public DifficultyMultiplierDisplay()
{
Height = height;
Height = HEIGHT;
AutoSizeAxes = Axes.X;
InternalChild = new Container
@ -145,8 +146,9 @@ namespace osu.Game.Overlays.Mods
protected override void LoadComplete()
{
base.LoadComplete();
current.BindValueChanged(_ => updateState(), true);
FinishTransforms(true);
// required to prevent the counter initially rolling up from 0 to 1
// due to `Current.Value` having a nonstandard default value of 1.
multiplierCounter.SetCountWithoutRolling(Current.Value);

View File

@ -31,6 +31,10 @@ namespace osu.Game.Overlays.Mods
{
public class ModColumn : CompositeDrawable
{
public readonly Container TopLevelContent;
public readonly ModType ModType;
private Func<Mod, bool>? filter;
/// <summary>
@ -48,7 +52,8 @@ namespace osu.Game.Overlays.Mods
}
}
private readonly ModType modType;
public Bindable<IReadOnlyList<Mod>> SelectedMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
private readonly Key[]? toggleKeys;
private readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> availableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>();
@ -69,95 +74,103 @@ namespace osu.Game.Overlays.Mods
public ModColumn(ModType modType, bool allowBulkSelection, Key[]? toggleKeys = null)
{
this.modType = modType;
ModType = modType;
this.toggleKeys = toggleKeys;
Width = 320;
RelativeSizeAxes = Axes.Y;
Shear = new Vector2(ModPanel.SHEAR_X, 0);
CornerRadius = ModPanel.CORNER_RADIUS;
Masking = true;
Container controlContainer;
InternalChildren = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.X,
Height = header_height + ModPanel.CORNER_RADIUS,
Children = new Drawable[]
{
headerBackground = new Box
{
RelativeSizeAxes = Axes.X,
Height = header_height + ModPanel.CORNER_RADIUS
},
headerText = new OsuTextFlowContainer(t =>
{
t.Font = OsuFont.TorusAlternate.With(size: 17);
t.Shadow = false;
t.Colour = Colour4.Black;
})
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Shear = new Vector2(-ModPanel.SHEAR_X, 0),
Padding = new MarginPadding
{
Horizontal = 17,
Bottom = ModPanel.CORNER_RADIUS
}
}
}
},
new Container
TopLevelContent = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Top = header_height },
Child = contentContainer = new Container
CornerRadius = ModPanel.CORNER_RADIUS,
Masking = true,
Children = new Drawable[]
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = ModPanel.CORNER_RADIUS,
BorderThickness = 3,
Children = new Drawable[]
new Container
{
contentBackground = new Box
RelativeSizeAxes = Axes.X,
Height = header_height + ModPanel.CORNER_RADIUS,
Children = new Drawable[]
{
RelativeSizeAxes = Axes.Both
},
new GridContainer
headerBackground = new Box
{
RelativeSizeAxes = Axes.X,
Height = header_height + ModPanel.CORNER_RADIUS
},
headerText = new OsuTextFlowContainer(t =>
{
t.Font = OsuFont.TorusAlternate.With(size: 17);
t.Shadow = false;
t.Colour = Colour4.Black;
})
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Shear = new Vector2(-ModPanel.SHEAR_X, 0),
Padding = new MarginPadding
{
Horizontal = 17,
Bottom = ModPanel.CORNER_RADIUS
}
}
}
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Top = header_height },
Child = contentContainer = new Container
{
RelativeSizeAxes = Axes.Both,
RowDimensions = new[]
Masking = true,
CornerRadius = ModPanel.CORNER_RADIUS,
BorderThickness = 3,
Children = new Drawable[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension()
},
Content = new[]
{
new Drawable[]
contentBackground = new Box
{
controlContainer = new Container
{
RelativeSizeAxes = Axes.X,
Padding = new MarginPadding { Horizontal = 14 }
}
RelativeSizeAxes = Axes.Both
},
new Drawable[]
new GridContainer
{
new OsuScrollContainer
RelativeSizeAxes = Axes.Both,
RowDimensions = new[]
{
RelativeSizeAxes = Axes.Both,
ScrollbarOverlapsContent = false,
Child = panelFlow = new FillFlowContainer<ModPanel>
new Dimension(GridSizeMode.AutoSize),
new Dimension()
},
Content = new[]
{
new Drawable[]
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0, 7),
Padding = new MarginPadding(7)
controlContainer = new Container
{
RelativeSizeAxes = Axes.X,
Padding = new MarginPadding { Horizontal = 14 }
}
},
new Drawable[]
{
new NestedVerticalScrollContainer
{
RelativeSizeAxes = Axes.Both,
ClampExtension = 100,
ScrollbarOverlapsContent = false,
Child = panelFlow = new FillFlowContainer<ModPanel>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0, 7),
Padding = new MarginPadding(7)
}
}
}
}
}
@ -193,7 +206,7 @@ namespace osu.Game.Overlays.Mods
private void createHeaderText()
{
IEnumerable<string> headerTextWords = modType.Humanize(LetterCasing.Title).Split(' ');
IEnumerable<string> headerTextWords = ModType.Humanize(LetterCasing.Title).Split(' ');
if (headerTextWords.Count() > 1)
{
@ -209,7 +222,7 @@ namespace osu.Game.Overlays.Mods
{
availableMods.BindTo(game.AvailableMods);
headerBackground.Colour = accentColour = colours.ForModType(modType);
headerBackground.Colour = accentColour = colours.ForModType(ModType);
if (toggleAllCheckbox != null)
{
@ -225,6 +238,12 @@ namespace osu.Game.Overlays.Mods
{
base.LoadComplete();
availableMods.BindValueChanged(_ => Scheduler.AddOnce(updateMods));
SelectedMods.BindValueChanged(_ =>
{
// if a load is in progress, don't try to update the selection - the load flow will do so.
if (latestLoadTask == null)
updateActiveState();
});
updateMods();
}
@ -232,7 +251,7 @@ namespace osu.Game.Overlays.Mods
private void updateMods()
{
var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(modType) ?? Array.Empty<Mod>()).ToList();
var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(ModType) ?? Array.Empty<Mod>()).ToList();
if (newMods.SequenceEqual(panelFlow.Children.Select(p => p.Mod)))
return;
@ -250,11 +269,20 @@ namespace osu.Game.Overlays.Mods
{
panelFlow.ChildrenEnumerable = loaded;
foreach (var panel in panelFlow)
panel.Active.BindValueChanged(_ => updateToggleState());
updateToggleState();
updateActiveState();
updateToggleAllState();
updateFilter();
foreach (var panel in panelFlow)
{
panel.Active.BindValueChanged(_ =>
{
updateToggleAllState();
SelectedMods.Value = panel.Active.Value
? SelectedMods.Value.Append(panel.Mod).ToArray()
: SelectedMods.Value.Except(new[] { panel.Mod }).ToArray();
});
}
}, (cancellationTokenSource = new CancellationTokenSource()).Token);
loadTask.ContinueWith(_ =>
{
@ -263,6 +291,12 @@ namespace osu.Game.Overlays.Mods
});
}
private void updateActiveState()
{
foreach (var panel in panelFlow)
panel.Active.Value = SelectedMods.Value.Contains(panel.Mod, EqualityComparer<Mod>.Default);
}
#region Bulk select / deselect
private const double initial_multiple_selection_delay = 120;
@ -297,7 +331,7 @@ namespace osu.Game.Overlays.Mods
}
}
private void updateToggleState()
private void updateToggleAllState()
{
if (toggleAllCheckbox != null && !SelectionAnimationRunning)
{
@ -399,7 +433,7 @@ namespace osu.Game.Overlays.Mods
foreach (var modPanel in panelFlow)
modPanel.ApplyFilter(Filter);
updateToggleState();
updateToggleAllState();
}
#endregion

View File

@ -0,0 +1,392 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Layout;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Mods;
using osuTK;
using osuTK.Input;
namespace osu.Game.Overlays.Mods
{
public class ModSelectScreen : OsuFocusedOverlayContainer
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
[Cached]
public Bindable<IReadOnlyList<Mod>> SelectedMods { get; private set; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
protected override bool StartHidden => true;
private readonly BindableBool customisationVisible = new BindableBool();
private DifficultyMultiplierDisplay multiplierDisplay;
private ModSettingsArea modSettingsArea;
private FillFlowContainer<ModColumn> columnFlow;
private GridContainer grid;
private Container mainContent;
private PopupScreenTitle header;
private Container footer;
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Both;
RelativePositionAxes = Axes.Both;
InternalChildren = new Drawable[]
{
mainContent = new Container
{
Origin = Anchor.BottomCentre,
Anchor = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
grid = new GridContainer
{
RelativeSizeAxes = Axes.Both,
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(),
new Dimension(GridSizeMode.Absolute, 75),
},
Content = new[]
{
new Drawable[]
{
header = new PopupScreenTitle
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Title = "Mod Select",
Description = "Mods provide different ways to enjoy gameplay. Some have an effect on the score you can achieve during ranked play. Others are just for fun.",
Close = Hide
}
},
new Drawable[]
{
new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.X,
RelativePositionAxes = Axes.X,
X = 0.3f,
Height = DifficultyMultiplierDisplay.HEIGHT,
Margin = new MarginPadding
{
Horizontal = 100,
Vertical = 10
},
Child = multiplierDisplay = new DifficultyMultiplierDisplay
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
}
}
},
new Drawable[]
{
new Container
{
Depth = float.MaxValue,
RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Both,
Children = new Drawable[]
{
new OsuScrollContainer(Direction.Horizontal)
{
RelativeSizeAxes = Axes.Both,
Masking = false,
ClampExtension = 100,
ScrollbarOverlapsContent = false,
Child = columnFlow = new ModColumnContainer
{
Direction = FillDirection.Horizontal,
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Spacing = new Vector2(10, 0),
Margin = new MarginPadding { Right = 70 },
Children = new[]
{
new ModColumn(ModType.DifficultyReduction, false, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }),
new ModColumn(ModType.DifficultyIncrease, false, new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }),
new ModColumn(ModType.Automation, false, new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }),
new ModColumn(ModType.Conversion, false),
new ModColumn(ModType.Fun, false)
}
}
}
}
}
},
new[] { Empty() }
}
},
footer = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.X,
Height = 50,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Colour = colourProvider.Background5
},
new ShearedToggleButton(200)
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Margin = new MarginPadding { Vertical = 14, Left = 70 },
Text = "Mod Customisation",
Active = { BindTarget = customisationVisible }
}
}
},
new ClickToReturnContainer
{
RelativeSizeAxes = Axes.Both,
HandleMouse = { BindTarget = customisationVisible },
OnClicked = () => customisationVisible.Value = false
}
}
},
modSettingsArea = new ModSettingsArea
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Height = 0
}
};
columnFlow.Shear = new Vector2(ModPanel.SHEAR_X, 0);
}
protected override void LoadComplete()
{
base.LoadComplete();
((IBindable<IReadOnlyList<Mod>>)modSettingsArea.SelectedMods).BindTo(SelectedMods);
SelectedMods.BindValueChanged(val =>
{
updateMultiplier();
updateCustomisation(val);
updateSelectionFromBindable();
}, true);
foreach (var column in columnFlow)
{
column.SelectedMods.BindValueChanged(_ => updateBindableFromSelection());
}
customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true);
}
private void updateMultiplier()
{
double multiplier = 1.0;
foreach (var mod in SelectedMods.Value)
multiplier *= mod.ScoreMultiplier;
multiplierDisplay.Current.Value = multiplier;
}
private void updateCustomisation(ValueChangedEvent<IReadOnlyList<Mod>> valueChangedEvent)
{
bool anyCustomisableMod = false;
bool anyModWithRequiredCustomisationAdded = false;
foreach (var mod in SelectedMods.Value)
{
anyCustomisableMod |= mod.GetSettingsSourceProperties().Any();
anyModWithRequiredCustomisationAdded |= !valueChangedEvent.OldValue.Contains(mod) && mod.RequiresConfiguration;
}
if (anyCustomisableMod)
{
customisationVisible.Disabled = false;
if (anyModWithRequiredCustomisationAdded && !customisationVisible.Value)
customisationVisible.Value = true;
}
else
{
if (customisationVisible.Value)
customisationVisible.Value = false;
customisationVisible.Disabled = true;
}
}
private void updateCustomisationVisualState()
{
const double transition_duration = 300;
grid.FadeColour(customisationVisible.Value ? Colour4.Gray : Colour4.White, transition_duration, Easing.InOutCubic);
float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0;
modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic);
mainContent.TransformTo(nameof(Margin), new MarginPadding { Bottom = modAreaHeight }, transition_duration, Easing.InOutCubic);
}
private bool selectionBindableSyncInProgress;
private void updateSelectionFromBindable()
{
if (selectionBindableSyncInProgress)
return;
selectionBindableSyncInProgress = true;
foreach (var column in columnFlow)
column.SelectedMods.Value = SelectedMods.Value.Where(mod => mod.Type == column.ModType).ToArray();
selectionBindableSyncInProgress = false;
}
private void updateBindableFromSelection()
{
if (selectionBindableSyncInProgress)
return;
selectionBindableSyncInProgress = true;
SelectedMods.Value = columnFlow.SelectMany(column => column.SelectedMods.Value).ToArray();
selectionBindableSyncInProgress = false;
}
protected override void PopIn()
{
const double fade_in_duration = 400;
base.PopIn();
this.FadeIn(fade_in_duration, Easing.OutQuint);
header.MoveToY(0, fade_in_duration, Easing.OutQuint);
footer.MoveToY(0, fade_in_duration, Easing.OutQuint);
multiplierDisplay
.Delay(fade_in_duration * 0.65f)
.FadeIn(fade_in_duration / 2, Easing.OutQuint)
.ScaleTo(1, fade_in_duration, Easing.OutElastic);
for (int i = 0; i < columnFlow.Count; i++)
{
columnFlow[i].TopLevelContent
.Delay(i * 30)
.MoveToY(0, fade_in_duration, Easing.OutQuint)
.FadeIn(fade_in_duration, Easing.OutQuint);
}
}
protected override void PopOut()
{
const double fade_out_duration = 500;
base.PopOut();
this.FadeOut(fade_out_duration, Easing.OutQuint);
multiplierDisplay
.FadeOut(fade_out_duration / 2, Easing.OutQuint)
.ScaleTo(0.75f, fade_out_duration, Easing.OutQuint);
header.MoveToY(-header.DrawHeight, fade_out_duration, Easing.OutQuint);
footer.MoveToY(footer.DrawHeight, fade_out_duration, Easing.OutQuint);
for (int i = 0; i < columnFlow.Count; i++)
{
const float distance = 700;
columnFlow[i].TopLevelContent
.MoveToY(i % 2 == 0 ? -distance : distance, fade_out_duration, Easing.OutQuint)
.FadeOut(fade_out_duration, Easing.OutQuint);
}
}
private class ModColumnContainer : FillFlowContainer<ModColumn>
{
private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize);
public ModColumnContainer()
{
AddLayout(drawSizeLayout);
}
public override void Add(ModColumn column)
{
base.Add(column);
Debug.Assert(column != null);
column.Shear = Vector2.Zero;
}
protected override void Update()
{
base.Update();
if (!drawSizeLayout.IsValid)
{
Padding = new MarginPadding
{
Left = DrawHeight * ModPanel.SHEAR_X,
Bottom = 10
};
drawSizeLayout.Validate();
}
}
}
private class ClickToReturnContainer : Container
{
public BindableBool HandleMouse { get; } = new BindableBool();
public Action OnClicked { get; set; }
protected override bool Handle(UIEvent e)
{
if (!HandleMouse.Value)
return base.Handle(e);
switch (e)
{
case ClickEvent _:
OnClicked?.Invoke();
return true;
case MouseEvent _:
return true;
}
return base.Handle(e);
}
}
}
}

View File

@ -23,6 +23,8 @@ namespace osu.Game.Overlays.Mods
{
public Bindable<IReadOnlyList<Mod>> SelectedMods { get; } = new Bindable<IReadOnlyList<Mod>>();
public const float HEIGHT = 250;
private readonly Box background;
private readonly FillFlowContainer modSettingsFlow;
@ -32,7 +34,7 @@ namespace osu.Game.Overlays.Mods
public ModSettingsArea()
{
RelativeSizeAxes = Axes.X;
Height = 250;
Height = HEIGHT;
Anchor = Anchor.BottomRight;
Origin = Anchor.BottomRight;
@ -52,6 +54,7 @@ namespace osu.Game.Overlays.Mods
{
RelativeSizeAxes = Axes.Both,
ScrollbarOverlapsContent = false,
ClampExtension = 100,
Child = modSettingsFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.X,
@ -155,9 +158,10 @@ namespace osu.Game.Overlays.Mods
new[] { Empty() },
new Drawable[]
{
new OsuScrollContainer(Direction.Vertical)
new NestedVerticalScrollContainer
{
RelativeSizeAxes = Axes.Both,
ClampExtension = 100,
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,

View File

@ -0,0 +1,48 @@
// 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.
#nullable enable
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.Mods
{
/// <summary>
/// A scroll container that handles the case of vertically scrolling content inside a larger horizontally scrolling parent container.
/// </summary>
public class NestedVerticalScrollContainer : OsuScrollContainer
{
private OsuScrollContainer? parentScrollContainer;
protected override void LoadComplete()
{
base.LoadComplete();
parentScrollContainer = this.FindClosestParent<OsuScrollContainer>();
}
protected override bool OnScroll(ScrollEvent e)
{
if (parentScrollContainer == null)
return base.OnScroll(e);
bool topRightInView = parentScrollContainer.ScreenSpaceDrawQuad.Contains(ScreenSpaceDrawQuad.TopRight);
bool bottomLeftInView = parentScrollContainer.ScreenSpaceDrawQuad.Contains(ScreenSpaceDrawQuad.BottomLeft);
// If not completely on-screen, handle scroll but also allow parent to scroll at the same time (to hopefully bring our content into full view).
if (!topRightInView || !bottomLeftInView)
return false;
bool scrollingPastEnd = e.ScrollDelta.Y < 0 && IsScrolledToEnd();
bool scrollingPastStart = e.ScrollDelta.Y > 0 && Target <= 0;
// If at either of our extents, delegate scroll to the horizontal parent container.
if (scrollingPastStart || scrollingPastEnd)
return false;
return base.OnScroll(e);
}
}
}

View File

@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Localisation;
@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Settings.Sections.General
protected override LocalisableString Header => GeneralSettingsStrings.LanguageHeader;
[BackgroundDependencyLoader]
private void load(FrameworkConfigManager frameworkConfig)
private void load(FrameworkConfigManager frameworkConfig, OsuConfigManager config)
{
frameworkLocale = frameworkConfig.GetBindable<string>(FrameworkSetting.Locale);
@ -34,6 +35,11 @@ namespace osu.Game.Overlays.Settings.Sections.General
LabelText = GeneralSettingsStrings.PreferOriginalMetadataLanguage,
Current = frameworkConfig.GetBindable<bool>(FrameworkSetting.ShowUnicode)
},
new SettingsCheckbox
{
LabelText = GeneralSettingsStrings.Prefer24HourTimeDisplay,
Current = config.GetBindable<bool>(OsuSetting.Prefer24HourTime)
},
};
if (!LanguageExtensions.TryParseCultureCode(frameworkLocale.Value, out var locale))

View File

@ -29,6 +29,23 @@ namespace osu.Game.Overlays.Toolbar
}
}
private bool use24HourDisplay;
public bool Use24HourDisplay
{
get => use24HourDisplay;
set
{
if (use24HourDisplay == value)
return;
use24HourDisplay = value;
updateMetrics();
UpdateDisplay(DateTimeOffset.Now); //Update realTime.Text immediately instead of waiting until next second
}
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
@ -50,13 +67,14 @@ namespace osu.Game.Overlays.Toolbar
protected override void UpdateDisplay(DateTimeOffset now)
{
realTime.Text = $"{now:HH:mm:ss}";
realTime.Text = use24HourDisplay ? $"{now:HH:mm:ss}" : $"{now:h:mm:ss tt}";
gameTime.Text = $"running {new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}";
}
private void updateMetrics()
{
Width = showRuntime ? 66 : 45; // Allows for space for game time up to 99 days (in the padding area since this is quite rare).
Width = showRuntime || !use24HourDisplay ? 66 : 45; // Allows for space for game time up to 99 days (in the padding area since this is quite rare).
gameTime.FadeTo(showRuntime ? 1 : 0);
}
}

View File

@ -20,6 +20,7 @@ namespace osu.Game.Overlays.Toolbar
public class ToolbarClock : OsuClickableContainer
{
private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
private Bindable<bool> prefer24HourTime;
private Box hoverBackground;
private Box flashBackground;
@ -38,6 +39,7 @@ namespace osu.Game.Overlays.Toolbar
private void load(OsuConfigManager config)
{
clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
prefer24HourTime = config.GetBindable<bool>(OsuSetting.Prefer24HourTime);
Children = new Drawable[]
{
@ -94,6 +96,8 @@ namespace osu.Game.Overlays.Toolbar
analog.FadeTo(showAnalog ? 1 : 0);
}, true);
prefer24HourTime.BindValueChanged(prefer24H => digital.Use24HourDisplay = prefer24H.NewValue, true);
}
protected override bool OnClick(ClickEvent e)

View File

@ -1,8 +1,6 @@
// 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.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration;
@ -18,8 +16,6 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.DifficultyIncrease;
public override string Description => "Zoooooooooom...";
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModHalfTime)).ToArray();
[SettingSource("Speed increase", "The actual increase to apply")]
public override BindableNumber<double> SpeedChange { get; } = new BindableDouble
{

View File

@ -1,8 +1,6 @@
// 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.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration;
@ -18,8 +16,6 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.DifficultyReduction;
public override string Description => "Less zoom...";
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModDoubleTime)).ToArray();
[SettingSource("Speed decrease", "The actual decrease to apply")]
public override BindableNumber<double> SpeedChange { get; } = new BindableDouble
{

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mods
public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value;
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed) };
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) };
public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x";
}

View File

@ -147,7 +147,7 @@ namespace osu.Game.Screens.Menu
bool loadThemedIntro()
{
var setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == BeatmapHash);
var setInfo = beatmaps.QueryBeatmapSet(b => b.Protected && b.Hash == BeatmapHash);
if (setInfo == null)
return false;

View File

@ -418,10 +418,16 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
var retrievedBeatmap = task.GetResultSafely();
statusText.Text = "Currently playing ";
beatmapText.AddLink(retrievedBeatmap.GetDisplayTitleRomanisable(),
LinkAction.OpenBeatmap,
retrievedBeatmap.OnlineID.ToString(),
creationParameters: s => s.Truncate = true);
if (retrievedBeatmap != null)
{
beatmapText.AddLink(retrievedBeatmap.GetDisplayTitleRomanisable(),
LinkAction.OpenBeatmap,
retrievedBeatmap.OnlineID.ToString(),
creationParameters: s => s.Truncate = true);
}
else
beatmapText.AddText("unknown beatmap");
}), cancellationSource.Token);
}
}

View File

@ -114,18 +114,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
bool isReady() => Client.LocalUser?.State == MultiplayerUserState.Ready || Client.LocalUser?.State == MultiplayerUserState.Spectating;
void toggleReady() => Client.ToggleReady().ContinueWith(_ => endOperation());
void toggleReady() => Client.ToggleReady().FireAndForget(
onSuccess: endOperation,
onError: _ => endOperation());
void startMatch() => Client.StartMatch().ContinueWith(t =>
void startMatch() => Client.StartMatch().FireAndForget(onSuccess: () =>
{
// accessing Exception here silences any potential errors from the antecedent task
if (t.Exception != null)
{
// gameplay was not started due to an exception; unblock button.
endOperation();
}
// gameplay is starting, the button will be unblocked on load requested.
}, onError: _ =>
{
// gameplay was not started due to an exception; unblock button.
endOperation();
});
}

View File

@ -30,13 +30,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private MultiplayerRoom room => multiplayerClient.Room;
private Sample countdownTickSample;
private Sample countdownWarnSample;
private Sample countdownWarnFinalSample;
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
countdownTickSample = audio.Samples.Get(@"Multiplayer/countdown-tick");
// disabled for now pending further work on sound effect
// countdownTickFinalSample = audio.Samples.Get(@"Multiplayer/countdown-tick-final");
countdownWarnSample = audio.Samples.Get(@"Multiplayer/countdown-warn");
countdownWarnFinalSample = audio.Samples.Get(@"Multiplayer/countdown-warn-final");
}
protected override void LoadComplete()
@ -102,8 +104,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void playTickSound(int secondsRemaining)
{
if (secondsRemaining < 10) countdownTickSample?.Play();
// disabled for now pending further work on sound effect
// if (secondsRemaining <= 3) countdownTickFinalSample?.Play();
if (secondsRemaining <= 3)
{
if (secondsRemaining > 0)
countdownWarnSample?.Play();
else
countdownWarnFinalSample?.Play();
}
}
private void updateButtonText()

View File

@ -62,7 +62,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
{
base.LoadComplete();
RequestDeletion = item => multiplayerClient.RemovePlaylistItem(item.ID);
RequestDeletion = item => multiplayerClient.RemovePlaylistItem(item.ID).FireAndForget();
multiplayerClient.RoomUpdated += onRoomUpdated;
onRoomUpdated();

View File

@ -48,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
// If gameplay wasn't finished, then we have a simple path back to the idle state by aborting gameplay.
if (!playerLoader.GameplayPassed)
{
client.AbortGameplay();
client.AbortGameplay().FireAndForget();
return;
}

View File

@ -1,13 +1,9 @@
// 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.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using osu.Framework.Allocation;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
@ -76,40 +72,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem);
task.ContinueWith(t =>
task.FireAndForget(onSuccess: () => Schedule(() =>
{
Schedule(() =>
{
// If an error or server side trigger occurred this screen may have already exited by external means.
if (!this.IsCurrentScreen())
return;
loadingLayer.Hide();
if (t.IsFaulted)
{
Exception exception = t.Exception;
if (exception is AggregateException ae)
exception = ae.InnerException;
Debug.Assert(exception != null);
string message = exception is HubException
// HubExceptions arrive with additional message context added, but we want to display the human readable message:
// "An unexpected error occurred invoking 'AddPlaylistItem' on the server.InvalidStateException: Can't enqueue more than 3 items at once."
// We generally use the message field for a user-parseable error (eventually to be replaced), so drop the first part for now.
? exception.Message.Substring(exception.Message.IndexOf(':') + 1).Trim()
: exception.Message;
Logger.Log(message, level: LogLevel.Important);
Carousel.AllowSelection = true;
return;
}
loadingLayer.Hide();
// If an error or server side trigger occurred this screen may have already exited by external means.
if (this.IsCurrentScreen())
this.Exit();
});
});
}), onError: _ => Schedule(() =>
{
loadingLayer.Hide();
Carousel.AllowSelection = true;
}));
}
else
{

View File

@ -281,7 +281,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (client.Room == null)
return;
client.ChangeUserMods(mods.NewValue);
client.ChangeUserMods(mods.NewValue).FireAndForget();
modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue);
modSettingChangeTracker.SettingChanged += onModSettingsChanged;
@ -296,7 +296,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (client.Room == null)
return;
client.ChangeUserMods(UserMods.Value);
client.ChangeUserMods(UserMods.Value).FireAndForget();
}, 500);
}
@ -305,7 +305,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (client.Room == null)
return;
client.ChangeBeatmapAvailability(availability.NewValue);
client.ChangeBeatmapAvailability(availability.NewValue).FireAndForget();
if (availability.NewValue.State != DownloadState.LocallyAvailable)
{

View File

@ -133,6 +133,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
failAndBail();
}
}), true);
}
protected override void LoadComplete()
{
base.LoadComplete();
Debug.Assert(client.Room != null);
}

View File

@ -169,7 +169,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Origin = Anchor.Centre,
Alpha = 0,
Margin = new MarginPadding(4),
Action = () => Client.KickUser(User.UserID),
Action = () => Client.KickUser(User.UserID).FireAndForget(),
},
},
}
@ -231,7 +231,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
if (!Client.IsHost)
return;
Client.TransferHost(targetUser);
Client.TransferHost(targetUser).FireAndForget();
}),
new OsuMenuItem("Kick", MenuItemType.Destructive, () =>
{
@ -239,7 +239,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
if (!Client.IsHost)
return;
Client.KickUser(targetUser);
Client.KickUser(targetUser).FireAndForget();
})
};
}

View File

@ -83,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Client.SendMatchRequest(new ChangeTeamRequest
{
TeamID = ((Client.LocalUser?.MatchState as TeamVersusUserState)?.TeamID + 1) % 2 ?? 0,
});
}).FireAndForget();
}
public int? DisplayedTeam { get; private set; }

View File

@ -6,7 +6,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
@ -80,13 +79,16 @@ namespace osu.Game.Screens.Play.HUD
difficultyCache.GetTimedDifficultyAttributesAsync(gameplayWorkingBeatmap, gameplayState.Ruleset, clonedMods, loadCancellationSource.Token)
.ContinueWith(task => Schedule(() =>
{
if (task.Exception != null)
return;
timedAttributes = task.GetResultSafely();
IsValid = true;
if (lastJudgement != null)
onJudgementChanged(lastJudgement);
}), TaskContinuationOptions.OnlyOnRanToCompletion);
}));
}
}

View File

@ -87,31 +87,33 @@ namespace osu.Game.Screens.Ranking
});
}
button.Enabled.Value = replayAvailability != ReplayAvailability.NotAvailable;
updateTooltip();
updateState();
}, true);
State.BindValueChanged(state =>
{
button.State.Value = state.NewValue;
updateTooltip();
updateState();
}, true);
}
private void updateTooltip()
private void updateState()
{
switch (replayAvailability)
{
case ReplayAvailability.Local:
button.TooltipText = @"watch replay";
button.Enabled.Value = true;
break;
case ReplayAvailability.Online:
button.TooltipText = @"download replay";
button.Enabled.Value = true;
break;
default:
button.TooltipText = @"replay unavailable";
button.Enabled.Value = false;
break;
}
}

View File

@ -21,21 +21,20 @@ namespace osu.Game.Skinning.Editor
private readonly List<BindableList<ISkinnableDrawable>> targetComponents = new List<BindableList<ISkinnableDrawable>>();
[Resolved]
private SkinEditor editor { get; set; }
public SkinBlueprintContainer(Drawable target)
{
this.target = target;
}
[BackgroundDependencyLoader(true)]
private void load(SkinEditor editor)
{
SelectedItems.BindTo(editor.SelectedComponents);
}
protected override void LoadComplete()
{
base.LoadComplete();
SelectedItems.BindTo(editor.SelectedComponents);
// track each target container on the current screen.
var targetContainers = target.ChildrenOfType<ISkinnableTarget>().ToArray();
@ -56,7 +55,7 @@ namespace osu.Game.Skinning.Editor
}
}
private void componentsChanged(object sender, NotifyCollectionChangedEventArgs e)
private void componentsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() =>
{
switch (e.Action)
{
@ -79,7 +78,7 @@ namespace osu.Game.Skinning.Editor
AddBlueprintFor(item);
break;
}
}
});
protected override void AddBlueprintFor(ISkinnableDrawable item)
{
@ -93,5 +92,13 @@ namespace osu.Game.Skinning.Editor
protected override SelectionBlueprint<ISkinnableDrawable> CreateBlueprintFor(ISkinnableDrawable component)
=> new SkinBlueprint(component);
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
foreach (var list in targetComponents)
list.UnbindAll();
}
}
}

View File

@ -203,6 +203,9 @@ namespace osu.Game.Skinning.Editor
SelectedComponents.Clear();
// Immediately clear the previous blueprint container to ensure it doesn't try to interact with the old target.
content?.Clear();
Scheduler.AddOnce(loadBlueprintContainer);
Scheduler.AddOnce(populateSettings);

View File

@ -443,7 +443,9 @@ namespace osu.Game.Skinning
string lookupName = name.Replace(@"@2x", string.Empty);
float ratio = 2;
var texture = Textures?.Get(@$"{lookupName}@2x", wrapModeS, wrapModeT);
string twoTimesFilename = $"{Path.ChangeExtension(lookupName, null)}@2x{Path.GetExtension(lookupName)}";
var texture = Textures?.Get(twoTimesFilename, wrapModeS, wrapModeT);
if (texture == null)
{

View File

@ -1,10 +1,12 @@
// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -18,39 +20,32 @@ namespace osu.Game.Skinning
{
public static class LegacySkinExtensions
{
[CanBeNull]
public static Drawable GetAnimation(this ISkin source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-",
bool startAtCurrentTime = true, double? frameLength = null)
public static Drawable? GetAnimation(this ISkin? source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-",
bool startAtCurrentTime = true, double? frameLength = null)
=> source.GetAnimation(componentName, default, default, animatable, looping, applyConfigFrameRate, animationSeparator, startAtCurrentTime, frameLength);
[CanBeNull]
public static Drawable GetAnimation(this ISkin source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false,
string animationSeparator = "-",
bool startAtCurrentTime = true, double? frameLength = null)
public static Drawable? GetAnimation(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false,
string animationSeparator = "-", bool startAtCurrentTime = true, double? frameLength = null)
{
Texture texture;
// find the first source which provides either the animated or non-animated version.
ISkin skin = (source as ISkinSource)?.FindProvider(s =>
{
if (animatable && s.GetTexture(getFrameName(0)) != null)
return true;
return s.GetTexture(componentName, wrapModeS, wrapModeT) != null;
}) ?? source;
if (skin == null)
if (source == null)
return null;
if (animatable)
{
var textures = getTextures().ToArray();
var textures = GetTextures(source, componentName, wrapModeS, wrapModeT, animatable, animationSeparator, out var retrievalSource);
switch (textures.Length)
{
case 0:
return null;
case 1:
return new Sprite { Texture = textures[0] };
default:
Debug.Assert(retrievalSource != null);
if (textures.Length > 0)
{
var animation = new SkinnableTextureAnimation(startAtCurrentTime)
{
DefaultFrameLength = frameLength ?? getFrameLength(skin, applyConfigFrameRate, textures),
DefaultFrameLength = frameLength ?? getFrameLength(retrievalSource, applyConfigFrameRate, textures),
Loop = looping,
};
@ -58,19 +53,46 @@ namespace osu.Game.Skinning
animation.AddFrame(t);
return animation;
}
}
}
public static Texture[] GetTextures(this ISkin? source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, string animationSeparator, out ISkin? retrievalSource)
{
retrievalSource = null;
if (source == null)
return Array.Empty<Texture>();
// find the first source which provides either the animated or non-animated version.
retrievalSource = (source as ISkinSource)?.FindProvider(s =>
{
if (animatable && s.GetTexture(getFrameName(0)) != null)
return true;
return s.GetTexture(componentName, wrapModeS, wrapModeT) != null;
}) ?? source;
if (animatable)
{
var textures = getTextures(retrievalSource).ToArray();
if (textures.Length > 0)
return textures;
}
// if an animation was not allowed or not found, fall back to a sprite retrieval.
if ((texture = skin.GetTexture(componentName, wrapModeS, wrapModeT)) != null)
return new Sprite { Texture = texture };
var singleTexture = retrievalSource.GetTexture(componentName, wrapModeS, wrapModeT);
return null;
return singleTexture != null
? new[] { singleTexture }
: Array.Empty<Texture>();
IEnumerable<Texture> getTextures()
IEnumerable<Texture> getTextures(ISkin skin)
{
for (int i = 0; true; i++)
{
Texture? texture;
if ((texture = skin.GetTexture(getFrameName(i), wrapModeS, wrapModeT)) == null)
break;
@ -130,7 +152,7 @@ namespace osu.Game.Skinning
public class SkinnableTextureAnimation : TextureAnimation
{
[Resolved(canBeNull: true)]
private IAnimationTimeReference timeReference { get; set; }
private IAnimationTimeReference? timeReference { get; set; }
private readonly Bindable<double> animationStartTime = new BindableDouble();

View File

@ -104,7 +104,9 @@ namespace osu.Game.Skinning
// For imports, we want to use the archive or folder name as part of the metadata, in addition to any existing skin.ini metadata.
// In an ideal world, skin.ini would be the only source of metadata, but a lot of skin creators and users don't update it when making modifications.
// In both of these cases, the expectation from the user is that the filename or folder name is displayed somewhere to identify the skin.
if (archiveName != item.Name)
if (archiveName != item.Name
// lazer exports use this format
&& archiveName != item.GetDisplayString())
item.Name = @$"{item.Name} [{archiveName}]";
}

View File

@ -2,16 +2,18 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.IO;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Textures;
using osu.Framework.Utils;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Storyboards.Drawables
{
public class DrawableStoryboardAnimation : DrawableAnimation, IFlippable, IVectorScalable
public class DrawableStoryboardAnimation : TextureAnimation, IFlippable, IVectorScalable
{
public StoryboardAnimation Animation { get; }
@ -88,17 +90,52 @@ namespace osu.Game.Storyboards.Drawables
LifetimeEnd = animation.EndTime;
}
[Resolved]
private ISkinSource skin { get; set; }
[BackgroundDependencyLoader]
private void load(TextureStore textureStore, Storyboard storyboard)
{
for (int frameIndex = 0; frameIndex < Animation.FrameCount; frameIndex++)
int frameIndex = 0;
Texture frameTexture = storyboard.GetTextureFromPath(getFramePath(frameIndex), textureStore);
if (frameTexture != null)
{
string framePath = Animation.Path.Replace(".", frameIndex + ".");
Drawable frame = storyboard.CreateSpriteFromResourcePath(framePath, textureStore) ?? Empty();
AddFrame(frame, Animation.FrameDelay);
// sourcing from storyboard.
for (frameIndex = 0; frameIndex < Animation.FrameCount; frameIndex++)
{
AddFrame(storyboard.GetTextureFromPath(getFramePath(frameIndex), textureStore), Animation.FrameDelay);
}
}
else if (storyboard.UseSkinSprites)
{
// fallback to skin if required.
skin.SourceChanged += skinSourceChanged;
skinSourceChanged();
}
Animation.ApplyTransforms(this);
}
private void skinSourceChanged()
{
ClearFrames();
// When reading from a skin, we match stables weird behaviour where `FrameCount` is ignored
// and resources are retrieved until the end of the animation.
foreach (var texture in skin.GetTextures(Path.GetFileNameWithoutExtension(Animation.Path), default, default, true, string.Empty, out _))
AddFrame(texture, Animation.FrameDelay);
}
private string getFramePath(int i) => Animation.Path.Replace(".", $"{i}.");
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (skin != null)
skin.SourceChanged -= skinSourceChanged;
}
}
}

View File

@ -4,14 +4,15 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Utils;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Storyboards.Drawables
{
public class DrawableStoryboardSprite : CompositeDrawable, IFlippable, IVectorScalable
public class DrawableStoryboardSprite : Sprite, IFlippable, IVectorScalable
{
public StoryboardSprite Sprite { get; }
@ -85,19 +86,33 @@ namespace osu.Game.Storyboards.Drawables
LifetimeStart = sprite.StartTime;
LifetimeEnd = sprite.EndTime;
AutoSizeAxes = Axes.Both;
}
[Resolved]
private ISkinSource skin { get; set; }
[BackgroundDependencyLoader]
private void load(TextureStore textureStore, Storyboard storyboard)
{
var drawable = storyboard.CreateSpriteFromResourcePath(Sprite.Path, textureStore);
Texture = storyboard.GetTextureFromPath(Sprite.Path, textureStore);
if (drawable != null)
InternalChild = drawable;
if (Texture == null && storyboard.UseSkinSprites)
{
skin.SourceChanged += skinSourceChanged;
skinSourceChanged();
}
Sprite.ApplyTransforms(this);
}
private void skinSourceChanged() => Texture = skin.GetTexture(Sprite.Path);
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (skin != null)
skin.SourceChanged -= skinSourceChanged;
}
}
}

View File

@ -4,13 +4,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Rulesets.Mods;
using osu.Game.Skinning;
using osu.Game.Storyboards.Drawables;
namespace osu.Game.Storyboards
@ -94,25 +91,14 @@ namespace osu.Game.Storyboards
public DrawableStoryboard CreateDrawable(IReadOnlyList<Mod> mods = null) =>
new DrawableStoryboard(this, mods);
public Drawable CreateSpriteFromResourcePath(string path, TextureStore textureStore)
public Texture GetTextureFromPath(string path, TextureStore textureStore)
{
Drawable drawable = null;
string storyboardPath = BeatmapInfo.BeatmapSet?.Files.FirstOrDefault(f => f.Filename.Equals(path, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath();
if (!string.IsNullOrEmpty(storyboardPath))
drawable = new Sprite { Texture = textureStore.Get(storyboardPath) };
// if the texture isn't available locally in the beatmap, some storyboards choose to source from the underlying skin lookup hierarchy.
else if (UseSkinSprites)
{
drawable = new SkinnableSprite(path)
{
RelativeSizeAxes = Axes.None,
AutoSizeAxes = Axes.Both,
};
}
return textureStore.Get(storyboardPath);
return drawable;
return null;
}
}
}

View File

@ -44,6 +44,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
return new Room
{
Name = { Value = "test name" },
Type = { Value = MatchType.HeadToHead },
Playlist =
{
new PlaylistItem(new TestBeatmap(Ruleset.Value).BeatmapInfo)

View File

@ -115,7 +115,9 @@ namespace osu.Game.Utils
{
mods = mods.ToArray();
CheckCompatibleSet(mods, out invalidMods);
// exclude multi mods from compatibility checks.
// the loop below automatically marks all multi mods as not valid for gameplay anyway.
CheckCompatibleSet(mods.Where(m => !(m is MultiMod)), out invalidMods);
foreach (var mod in mods)
{

View File

@ -29,15 +29,14 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.NETCore.Targets" Version="3.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.LocalisationAnalyser" Version="2022.320.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.10.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.408.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.407.0" />
<PackageReference Include="Sentry" Version="3.14.1" />
<PackageReference Include="SharpCompress" Version="0.30.1" />
<PackageReference Include="NUnit" Version="3.13.2" />

View File

@ -61,8 +61,8 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.404.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.408.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.407.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
<PropertyGroup>
@ -84,7 +84,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.408.0" />
<PackageReference Include="SharpCompress" Version="0.30.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />