1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 12:17:26 +08:00

Merge branch 'master' into beatmap-skin

This commit is contained in:
Dean Herbert 2021-05-26 18:39:47 +09:00
commit 14a4095140
147 changed files with 3722 additions and 2004 deletions

View File

@ -31,6 +31,12 @@
"commands": [
"CodeFileSanity"
]
},
"ppy.localisationanalyser.tools": {
"version": "2021.524.0",
"commands": [
"localisation"
]
}
}
}

View File

@ -52,6 +52,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.422.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.513.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.524.0" />
</ItemGroup>
</Project>

View File

@ -5,12 +5,14 @@ using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
@ -32,6 +34,16 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep($"{term} Small", () => SetContents(() => testSingle(7, autoplay)));
}
[Test]
public void TestSpinningSamplePitchShift()
{
AddStep("Add spinner", () => SetContents(() => testSingle(5, true, 4000)));
AddUntilStep("Pitch starts low", () => getSpinningSample().Frequency.Value < 0.8);
AddUntilStep("Pitch increases", () => getSpinningSample().Frequency.Value > 0.8);
PausableSkinnableSound getSpinningSample() => drawableSpinner.ChildrenOfType<PausableSkinnableSound>().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin"))));
}
[TestCase(false)]
[TestCase(true)]
public void TestLongSpinner(bool autoplay)
@ -93,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
base.Update();
if (auto)
RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 3));
RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 2));
}
}
}

View File

@ -243,7 +243,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
int totalCount = Pieces.Count(p => p.IsSelected.Value);
int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type.Value == type);
var item = new PathTypeMenuItem(type, () =>
var item = new TernaryStateRadioMenuItem(type == null ? "Inherit" : type.ToString().Humanize(), MenuItemType.Standard, _ =>
{
foreach (var p in Pieces.Where(p => p.IsSelected.Value))
updatePathType(p, type);
@ -258,15 +258,5 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
return item;
}
private class PathTypeMenuItem : TernaryStateMenuItem
{
public PathTypeMenuItem(PathType? type, Action action)
: base(type == null ? "Inherit" : type.ToString().Humanize(), changeState, MenuItemType.Standard, _ => action?.Invoke())
{
}
private static TernaryState changeState(TernaryState state) => TernaryState.True;
}
}
}

View File

@ -16,6 +16,7 @@ using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose;
using osuTK;
@ -25,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
public class SliderSelectionBlueprint : OsuSelectionBlueprint<Slider>
{
protected new DrawableSlider DrawableObject => (DrawableSlider)base.DrawableObject;
protected SliderBodyPiece BodyPiece { get; private set; }
protected SliderCircleOverlay HeadOverlay { get; private set; }
protected SliderCircleOverlay TailOverlay { get; private set; }
@ -236,7 +239,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
new OsuMenuItem("Add control point", MenuItemType.Standard, () => addControlPoint(rightClickPosition)),
};
public override Vector2 ScreenSpaceSelectionPoint => BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation);
// Always refer to the drawable object's slider body so subsequent movement deltas are calculated with updated positions.
public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset)
?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation);
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
BodyPiece.ReceivePositionalInputAt(screenSpacePos) || ControlPointVisualiser?.Pieces.Any(p => p.ReceivePositionalInputAt(screenSpacePos)) == true;

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 osu.Framework.Graphics;
@ -12,7 +11,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components;
using Vector2 = osuTK.Vector2;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
@ -173,12 +172,12 @@ namespace osu.Game.Rulesets.Osu.Edit
foreach (var h in hitObjects)
{
h.Position = rotatePointAroundOrigin(h.Position, referenceOrigin.Value, delta);
h.Position = RotatePointAroundOrigin(h.Position, referenceOrigin.Value, delta);
if (h is IHasPath path)
{
foreach (var point in path.Path.ControlPoints)
point.Position.Value = rotatePointAroundOrigin(point.Position.Value, Vector2.Zero, delta);
point.Position.Value = RotatePointAroundOrigin(point.Position.Value, Vector2.Zero, delta);
}
}
@ -225,26 +224,10 @@ namespace osu.Game.Rulesets.Osu.Edit
private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale)
{
scale = getClampedScale(hitObjects, reference, scale);
// move the selection before scaling if dragging from top or left anchors.
float xOffset = ((reference & Anchor.x0) > 0) ? -scale.X : 0;
float yOffset = ((reference & Anchor.y0) > 0) ? -scale.Y : 0;
Quad selectionQuad = getSurroundingQuad(hitObjects);
foreach (var h in hitObjects)
{
var newPosition = h.Position;
// guard against no-ops and NaN.
if (scale.X != 0 && selectionQuad.Width > 0)
newPosition.X = selectionQuad.TopLeft.X + xOffset + (h.X - selectionQuad.TopLeft.X) / selectionQuad.Width * (selectionQuad.Width + scale.X);
if (scale.Y != 0 && selectionQuad.Height > 0)
newPosition.Y = selectionQuad.TopLeft.Y + yOffset + (h.Y - selectionQuad.TopLeft.Y) / selectionQuad.Height * (selectionQuad.Height + scale.Y);
h.Position = newPosition;
}
h.Position = GetScaledPosition(reference, scale, selectionQuad, h.Position);
}
private (bool X, bool Y) isQuadInBounds(Quad quad)
@ -340,28 +323,5 @@ namespace osu.Game.Rulesets.Osu.Edit
private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType<OsuHitObject>()
.Where(h => !(h is Spinner))
.ToArray();
/// <summary>
/// Rotate a point around an arbitrary origin.
/// </summary>
/// <param name="point">The point.</param>
/// <param name="origin">The centre origin to rotate around.</param>
/// <param name="angle">The angle to rotate (in degrees).</param>
private static Vector2 rotatePointAroundOrigin(Vector2 point, Vector2 origin, float angle)
{
angle = -angle;
point.X -= origin.X;
point.Y -= origin.Y;
Vector2 ret;
ret.X = point.X * MathF.Cos(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Sin(MathUtils.DegreesToRadians(angle));
ret.Y = point.X * -MathF.Sin(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Cos(MathUtils.DegreesToRadians(angle));
ret.X += origin.X;
ret.Y += origin.Y;
return ret;
}
}
}

View File

@ -34,7 +34,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public override bool DisplayResult => !HitObject.OnlyJudgeNestedObjects;
private PlaySliderBody sliderBody => Body.Drawable as PlaySliderBody;
[CanBeNull]
public PlaySliderBody SliderBody => Body.Drawable as PlaySliderBody;
public IBindable<int> PathVersion => pathVersion;
private readonly Bindable<int> pathVersion = new Bindable<int>();
@ -215,16 +216,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
double completionProgress = Math.Clamp((Time.Current - HitObject.StartTime) / HitObject.Duration, 0, 1);
Ball.UpdateProgress(completionProgress);
sliderBody?.UpdateProgress(completionProgress);
SliderBody?.UpdateProgress(completionProgress);
foreach (DrawableHitObject hitObject in NestedHitObjects)
{
if (hitObject is ITrackSnaking s) s.UpdateSnakingPosition(HitObject.Path.PositionAt(sliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(sliderBody?.SnakedEnd ?? 0));
if (hitObject is ITrackSnaking s) s.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0));
if (hitObject is IRequireTracking t) t.Tracking = Ball.Tracking;
}
Size = sliderBody?.Size ?? Vector2.Zero;
OriginPosition = sliderBody?.PathOffset ?? Vector2.Zero;
Size = SliderBody?.Size ?? Vector2.Zero;
OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero;
if (DrawSize != Vector2.Zero)
{
@ -238,7 +239,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public override void OnKilled()
{
base.OnKilled();
sliderBody?.RecyclePath();
SliderBody?.RecyclePath();
}
protected override void ApplySkin(ISkinSource skin, bool allowFallback)
@ -324,7 +325,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
case ArmedState.Hit:
Ball.ScaleTo(HitObject.Scale * 1.4f, fade_out_time, Easing.Out);
if (sliderBody?.SnakingOut.Value == true)
if (SliderBody?.SnakingOut.Value == true)
Body.FadeOut(40); // short fade to allow for any body colour to smoothly disappear.
break;
}
@ -332,7 +333,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
this.FadeOut(fade_out_time, Easing.OutQuint).Expire();
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => sliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos);
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos);
private class DefaultSliderBody : PlaySliderBody
{

View File

@ -39,6 +39,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
private Bindable<bool> isSpinning;
private bool spinnerFrequencyModulate;
private const float spinning_sample_initial_frequency = 1.0f;
private const float spinning_sample_modulated_base_frequency = 0.5f;
/// <summary>
/// The amount of bonus score gained from spinning after the required number of spins, for display purposes.
/// </summary>
@ -106,9 +109,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
isSpinning.BindValueChanged(updateSpinningSample);
}
private const float spinning_sample_initial_frequency = 1.0f;
private const float spinning_sample_modulated_base_frequency = 0.5f;
protected override void OnFree()
{
base.OnFree();

View File

@ -14,10 +14,10 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
{
private readonly HitPiece piece;
private static Hit hit;
public new Hit HitObject => (Hit)base.HitObject;
public HitPlacementBlueprint()
: base(hit = new Hit())
: base(new Hit())
{
InternalChild = piece = new HitPiece
{
@ -30,12 +30,12 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
switch (e.Button)
{
case MouseButton.Left:
hit.Type = HitType.Centre;
HitObject.Type = HitType.Centre;
EndPlacement(true);
return true;
case MouseButton.Right:
hit.Type = HitType.Rim;
HitObject.Type = HitType.Rim;
EndPlacement(true);
return true;
}

View File

@ -76,10 +76,10 @@ namespace osu.Game.Rulesets.Taiko.Edit
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
{
if (selection.All(s => s.Item is Hit))
yield return new TernaryStateMenuItem("Rim") { State = { BindTarget = selectionRimState } };
yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } };
if (selection.All(s => s.Item is TaikoHitObject))
yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } };
yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } };
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;

View File

@ -52,23 +52,18 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
protected override void OnApply()
{
type.BindTo(HitObject.TypeBindable);
type.BindValueChanged(_ =>
{
updateActionsFromType();
// will overwrite samples, should only be called on subsequent changes
// after the initial application.
updateSamplesFromTypeChange();
RecreatePieces();
});
// action update also has to happen immediately on application.
updateActionsFromType();
// this doesn't need to be run inline as RecreatePieces is called by the base call below.
type.BindValueChanged(_ => Scheduler.AddOnce(RecreatePieces));
base.OnApply();
}
protected override void RecreatePieces()
{
updateActionsFromType();
base.RecreatePieces();
}
protected override void OnFree()
{
base.OnFree();
@ -83,33 +78,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
validActionPressed = pressHandledThisFrame = false;
}
private HitSampleInfo[] getRimSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE).ToArray();
protected override void LoadSamples()
{
base.LoadSamples();
type.Value = getRimSamples().Any() ? HitType.Rim : HitType.Centre;
}
private void updateSamplesFromTypeChange()
{
var rimSamples = getRimSamples();
bool isRimType = HitObject.Type == HitType.Rim;
if (isRimType != rimSamples.Any())
{
if (isRimType)
HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP));
else
{
foreach (var sample in rimSamples)
HitObject.Samples.Remove(sample);
}
}
}
private void updateActionsFromType()
{
HitActions =

View File

@ -137,7 +137,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE);
MainPiece?.Expire();
if (MainPiece != null)
Content.Remove(MainPiece);
Content.Add(MainPiece = CreateMainPiece());
}

View File

@ -1,11 +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.Linq;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK;
@ -29,14 +27,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
protected override void OnApply()
{
isStrong.BindTo(HitObject.IsStrongBindable);
isStrong.BindValueChanged(_ =>
{
// will overwrite samples, should only be called on subsequent changes
// after the initial application.
updateSamplesFromStrong();
RecreatePieces();
});
// this doesn't need to be run inline as RecreatePieces is called by the base call below.
isStrong.BindValueChanged(_ => Scheduler.AddOnce(RecreatePieces));
base.OnApply();
}
@ -50,30 +42,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
isStrong.UnbindEvents();
}
private HitSampleInfo[] getStrongSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_FINISH).ToArray();
protected override void LoadSamples()
{
base.LoadSamples();
isStrong.Value = getStrongSamples().Any();
}
private void updateSamplesFromStrong()
{
var strongSamples = getStrongSamples();
if (isStrong.Value != strongSamples.Any())
{
if (isStrong.Value)
HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH));
else
{
foreach (var sample in strongSamples)
HitObject.Samples.Remove(sample);
}
}
}
protected override void RecreatePieces()
{
base.RecreatePieces();

View File

@ -1,7 +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.Linq;
using osu.Framework.Bindables;
using osu.Game.Audio;
namespace osu.Game.Rulesets.Taiko.Objects
{
@ -15,9 +17,36 @@ namespace osu.Game.Rulesets.Taiko.Objects
public HitType Type
{
get => TypeBindable.Value;
set => TypeBindable.Value = value;
set
{
TypeBindable.Value = value;
updateSamplesFromType();
}
}
private void updateSamplesFromType()
{
var rimSamples = getRimSamples();
bool isRimType = Type == HitType.Rim;
if (isRimType != rimSamples.Any())
{
if (isRimType)
Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP));
else
{
foreach (var sample in rimSamples)
Samples.Remove(sample);
}
}
}
/// <summary>
/// Returns an array of any samples which would cause this object to be a "rim" type hit.
/// </summary>
private HitSampleInfo[] getRimSamples() => Samples.Where(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE).ToArray();
protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime };
public class StrongNestedHit : StrongNestedHitObject

View File

@ -1,8 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using System.Threading;
using osu.Framework.Bindables;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Taiko.Objects
@ -31,9 +33,31 @@ namespace osu.Game.Rulesets.Taiko.Objects
public bool IsStrong
{
get => IsStrongBindable.Value;
set => IsStrongBindable.Value = value;
set
{
IsStrongBindable.Value = value;
updateSamplesFromStrong();
}
}
private void updateSamplesFromStrong()
{
var strongSamples = getStrongSamples();
if (IsStrongBindable.Value != strongSamples.Any())
{
if (IsStrongBindable.Value)
Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH));
else
{
foreach (var sample in strongSamples)
Samples.Remove(sample);
}
}
}
private HitSampleInfo[] getStrongSamples() => Samples.Where(s => s.Name == HitSampleInfo.HIT_FINISH).ToArray();
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
base.CreateNestedHitObjects(cancellationToken);

View File

@ -0,0 +1,85 @@
// 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 NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Gameplay
{
[HeadlessTest]
public class TestSceneProxyContainer : OsuTestScene
{
private HitObjectContainer hitObjectContainer;
private ProxyContainer proxyContainer;
private readonly ManualClock clock = new ManualClock();
[SetUp]
public void SetUp() => Schedule(() =>
{
Child = new Container
{
Children = new Drawable[]
{
hitObjectContainer = new HitObjectContainer(),
proxyContainer = new ProxyContainer()
},
Clock = new FramedClock(clock)
};
clock.CurrentTime = 0;
});
[Test]
public void TestProxyLifetimeManagement()
{
AddStep("Add proxy drawables", () =>
{
addProxy(new TestDrawableHitObject(1000));
addProxy(new TestDrawableHitObject(3000));
addProxy(new TestDrawableHitObject(5000));
});
AddStep("time = 1000", () => clock.CurrentTime = 1000);
AddAssert("One proxy is alive", () => proxyContainer.AliveChildren.Count == 1);
AddStep("time = 5000", () => clock.CurrentTime = 5000);
AddAssert("One proxy is alive", () => proxyContainer.AliveChildren.Count == 1);
AddStep("time = 6000", () => clock.CurrentTime = 6000);
AddAssert("No proxy is alive", () => proxyContainer.AliveChildren.Count == 0);
}
private void addProxy(DrawableHitObject drawableHitObject)
{
hitObjectContainer.Add(drawableHitObject);
proxyContainer.AddProxy(drawableHitObject);
}
private class ProxyContainer : LifetimeManagementContainer
{
public IReadOnlyList<Drawable> AliveChildren => AliveInternalChildren;
public void AddProxy(Drawable d) => AddInternal(d.CreateProxy());
}
private class TestDrawableHitObject : DrawableHitObject
{
protected override double InitialLifetimeOffset => 100;
public TestDrawableHitObject(double startTime)
: base(new HitObject { StartTime = startTime })
{
}
protected override void UpdateInitialTransforms()
{
LifetimeEnd = LifetimeStart + 500;
}
}
}
}

View File

@ -27,8 +27,8 @@ namespace osu.Game.Tests.Visual.Gameplay
{
private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" };
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
[Cached(typeof(SpectatorClient))]
private TestSpectatorClient testSpectatorClient = new TestSpectatorClient();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
@ -61,8 +61,8 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("add streaming client", () =>
{
Remove(testSpectatorStreamingClient);
Add(testSpectatorStreamingClient);
Remove(testSpectatorClient);
Add(testSpectatorClient);
});
finish();
@ -212,9 +212,9 @@ namespace osu.Game.Tests.Visual.Gameplay
private void waitForPlayer() => AddUntilStep("wait for player", () => Stack.CurrentScreen is Player);
private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorStreamingClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
private void finish() => AddStep("end play", () => testSpectatorClient.EndPlay(streamingUser.Id));
private void checkPaused(bool state) =>
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state);
@ -223,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("send frames", () =>
{
testSpectatorStreamingClient.SendFrames(streamingUser.Id, nextFrame, count);
testSpectatorClient.SendFrames(streamingUser.Id, nextFrame, count);
nextFrame += count;
});
}

View File

@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private IAPIProvider api { get; set; }
[Resolved]
private SpectatorStreamingClient streamingClient { get; set; }
private SpectatorClient spectatorClient { get; set; }
[Cached]
private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap());
@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
replay = new Replay();
users.BindTo(streamingClient.PlayingUsers);
users.BindTo(spectatorClient.PlayingUsers);
users.BindCollectionChanged((obj, args) =>
{
switch (args.Action)
@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Gameplay
foreach (int user in args.NewItems)
{
if (user == api.LocalUser.Value.Id)
streamingClient.WatchUser(user);
spectatorClient.WatchUser(user);
}
break;
@ -91,14 +91,14 @@ namespace osu.Game.Tests.Visual.Gameplay
foreach (int user in args.OldItems)
{
if (user == api.LocalUser.Value.Id)
streamingClient.StopWatchingUser(user);
spectatorClient.StopWatchingUser(user);
}
break;
}
}, true);
streamingClient.OnNewFrames += onNewFrames;
spectatorClient.OnNewFrames += onNewFrames;
Add(new GridContainer
{
@ -189,7 +189,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
}
private double latency = SpectatorStreamingClient.TIME_BETWEEN_SENDS;
private double latency = SpectatorClient.TIME_BETWEEN_SENDS;
protected override void Update()
{
@ -233,7 +233,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("stop recorder", () =>
{
recorder.Expire();
streamingClient.OnNewFrames -= onNewFrames;
spectatorClient.OnNewFrames -= onNewFrames;
});
}

View File

@ -23,8 +23,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene
{
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient();
[Cached(typeof(SpectatorClient))]
private TestSpectatorClient spectatorClient = new TestSpectatorClient();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
base.Content.AddRange(new Drawable[]
{
streamingClient,
spectatorClient,
lookupCache,
content = new Container { RelativeSizeAxes = Axes.Both }
});
@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
foreach (var (userId, clock) in clocks)
{
streamingClient.EndPlay(userId, 0);
spectatorClient.EndPlay(userId);
clock.CurrentTime = 0;
}
});
@ -67,7 +67,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("create leaderboard", () =>
{
foreach (var (userId, _) in clocks)
streamingClient.StartPlay(userId, 0);
spectatorClient.StartPlay(userId, 0);
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
@ -96,10 +96,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
// For player 2, send frames in sets of 10.
for (int i = 0; i < 100; i++)
{
streamingClient.SendFrames(PLAYER_1_ID, i, 1);
spectatorClient.SendFrames(PLAYER_1_ID, i, 1);
if (i % 10 == 0)
streamingClient.SendFrames(PLAYER_2_ID, i, 10);
spectatorClient.SendFrames(PLAYER_2_ID, i, 10);
}
});

View File

@ -22,8 +22,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiSpectatorScreen : MultiplayerTestScene
{
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient();
[Cached(typeof(SpectatorClient))]
private TestSpectatorClient spectatorClient = new TestSpectatorClient();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
@ -59,14 +59,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("add streaming client", () =>
{
Remove(streamingClient);
Add(streamingClient);
Remove(spectatorClient);
Add(spectatorClient);
});
AddStep("finish previous gameplay", () =>
{
foreach (var id in playingUserIds)
streamingClient.EndPlay(id, importedBeatmapId);
spectatorClient.EndPlay(id);
playingUserIds.Clear();
});
}
@ -87,11 +87,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
loadSpectateScreen(false);
AddWaitStep("wait a bit", 10);
AddStep("load player first_player_id", () => streamingClient.StartPlay(PLAYER_1_ID, importedBeatmapId));
AddStep("load player first_player_id", () => spectatorClient.StartPlay(PLAYER_1_ID, importedBeatmapId));
AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType<Player>().Count() == 1);
AddWaitStep("wait a bit", 10);
AddStep("load player second_player_id", () => streamingClient.StartPlay(PLAYER_2_ID, importedBeatmapId));
AddStep("load player second_player_id", () => spectatorClient.StartPlay(PLAYER_2_ID, importedBeatmapId));
AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType<Player>().Count() == 2);
}
@ -251,18 +251,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
foreach (int id in userIds)
{
Client.CurrentMatchPlayingUserIds.Add(id);
streamingClient.StartPlay(id, beatmapId ?? importedBeatmapId);
spectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId);
playingUserIds.Add(id);
nextFrame[id] = 0;
}
});
}
private void finish(int userId, int? beatmapId = null)
private void finish(int userId)
{
AddStep("end play", () =>
{
streamingClient.EndPlay(userId, beatmapId ?? importedBeatmapId);
spectatorClient.EndPlay(userId);
playingUserIds.Remove(userId);
nextFrame.Remove(userId);
});
@ -276,7 +276,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
foreach (int id in userIds)
{
streamingClient.SendFrames(id, nextFrame[id], count);
spectatorClient.SendFrames(id, nextFrame[id], count);
nextFrame[id] += count;
}
});

View File

@ -195,7 +195,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer
{
[Cached(typeof(StatefulMultiplayerClient))]
[Cached(typeof(MultiplayerClient))]
public readonly TestMultiplayerClient Client;
public TestMultiplayer()

View File

@ -28,8 +28,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
private const int users = 16;
[Cached(typeof(SpectatorStreamingClient))]
private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming();
[Cached(typeof(SpectatorClient))]
private TestMultiplayerSpectatorClient spectatorClient = new TestMultiplayerSpectatorClient();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache();
@ -44,7 +44,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
base.Content.Children = new Drawable[]
{
streamingClient,
spectatorClient,
lookupCache,
Content
};
@ -71,10 +71,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
for (int i = 0; i < users; i++)
streamingClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0);
spectatorClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0);
Client.CurrentMatchPlayingUserIds.Clear();
Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers);
Client.CurrentMatchPlayingUserIds.AddRange(spectatorClient.PlayingUsers);
Children = new Drawable[]
{
@ -83,7 +83,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
scoreProcessor.ApplyBeatmap(playable);
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, streamingClient.PlayingUsers.ToArray())
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, spectatorClient.PlayingUsers.ToArray())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -96,7 +96,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestScoreUpdates()
{
AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 100);
AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 100);
AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded);
}
@ -109,12 +109,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestChangeScoringMode()
{
AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 5);
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));
}
public class TestMultiplayerStreaming : TestSpectatorStreamingClient
public class TestMultiplayerSpectatorClient : TestSpectatorClient
{
private readonly Dictionary<int, FrameHeader> lastHeaders = new Dictionary<int, FrameHeader>();

View File

@ -11,8 +11,8 @@ using osu.Game.Overlays;
using osu.Game.Screens;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Play;
using osu.Game.Screens.Select;
using osuTK.Input;
using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation;
namespace osu.Game.Tests.Visual.Navigation
{
@ -37,17 +37,17 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestPerformAtSongSelect()
{
PushAndConfirm(() => new PlaySongSelect());
PushAndConfirm(() => new TestPlaySongSelect());
AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) }));
AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) }));
AddAssert("did perform", () => actionPerformed);
AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is PlaySongSelect);
AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect);
}
[Test]
public void TestPerformAtMenuFromSongSelect()
{
PushAndConfirm(() => new PlaySongSelect());
PushAndConfirm(() => new TestPlaySongSelect());
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true));
AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu);
@ -57,18 +57,18 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestPerformAtSongSelectFromPlayerLoader()
{
PushAndConfirm(() => new PlaySongSelect());
PushAndConfirm(() => new TestPlaySongSelect());
PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer()));
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) }));
AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect);
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) }));
AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect);
AddAssert("did perform", () => actionPerformed);
}
[Test]
public void TestPerformAtMenuFromPlayerLoader()
{
PushAndConfirm(() => new PlaySongSelect());
PushAndConfirm(() => new TestPlaySongSelect());
PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer()));
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true));

View File

@ -34,9 +34,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestExitSongSelectWithEscape()
{
TestSongSelect songSelect = null;
TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect());
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show());
AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
pushEscape();
@ -51,9 +51,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestOpenModSelectOverlayUsingAction()
{
TestSongSelect songSelect = null;
TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect());
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show mods overlay", () => InputManager.Key(Key.F1));
AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
}
@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Navigation
{
Player player = null;
PushAndConfirm(() => new TestSongSelect());
PushAndConfirm(() => new TestPlaySongSelect());
AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait());
@ -89,7 +89,7 @@ namespace osu.Game.Tests.Visual.Navigation
WorkingBeatmap beatmap() => Game.Beatmap.Value;
PushAndConfirm(() => new TestSongSelect());
PushAndConfirm(() => new TestPlaySongSelect());
AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait());
@ -113,7 +113,7 @@ namespace osu.Game.Tests.Visual.Navigation
WorkingBeatmap beatmap() => Game.Beatmap.Value;
PushAndConfirm(() => new TestSongSelect());
PushAndConfirm(() => new TestPlaySongSelect());
AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait());
@ -139,9 +139,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestMenuMakesMusic()
{
TestSongSelect songSelect = null;
TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect());
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddUntilStep("wait for no track", () => Game.MusicController.CurrentTrack.IsDummyDevice);
@ -153,9 +153,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestExitSongSelectWithClick()
{
TestSongSelect songSelect = null;
TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect());
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show());
AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
AddStep("Move mouse to backButton", () => InputManager.MoveMouseTo(backButtonPosition));
@ -213,9 +213,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestModSelectInput()
{
TestSongSelect songSelect = null;
TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect());
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show());
@ -234,9 +234,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestBeatmapOptionsInput()
{
TestSongSelect songSelect = null;
TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect());
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show());
@ -312,11 +312,13 @@ namespace osu.Game.Tests.Visual.Navigation
ConfirmAtMainMenu();
}
private class TestSongSelect : PlaySongSelect
public class TestPlaySongSelect : PlaySongSelect
{
public ModSelectOverlay ModSelectOverlay => ModSelect;
public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions;
protected override bool DisplayStableImportPrompt => false;
}
}
}

View File

@ -1,12 +1,16 @@
// 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.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Overlays;
using osu.Game.Overlays.AccountCreation;
using osu.Game.Overlays.Settings;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Online
@ -36,8 +40,6 @@ namespace osu.Game.Tests.Visual.Online
[BackgroundDependencyLoader]
private void load()
{
API.Logout();
localUser = API.LocalUser.GetBoundCopy();
localUser.BindValueChanged(user => { userPanelArea.Child = new UserGridPanel(user.NewValue) { Width = 200 }; }, true);
}
@ -46,11 +48,14 @@ namespace osu.Game.Tests.Visual.Online
public void TestOverlayVisibility()
{
AddStep("start hidden", () => accountCreation.Hide());
AddStep("log out", API.Logout);
AddStep("log out", () => API.Logout());
AddStep("show manually", () => accountCreation.Show());
AddUntilStep("overlay is visible", () => accountCreation.State.Value == Visibility.Visible);
AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().Click());
AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType<ScreenWarning>().SingleOrDefault()?.IsPresent == true);
AddStep("log back in", () => API.Login("dummy", "password"));
AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden);
}

View File

@ -19,8 +19,10 @@ namespace osu.Game.Tests.Visual.Online
{
public class TestSceneCurrentlyPlayingDisplay : OsuTestScene
{
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
private readonly User streamingUser = new User { Id = 2, Username = "Test user" };
[Cached(typeof(SpectatorClient))]
private TestSpectatorClient testSpectatorClient = new TestSpectatorClient();
private CurrentlyPlayingDisplay currentlyPlaying;
@ -34,7 +36,7 @@ namespace osu.Game.Tests.Visual.Online
{
AddStep("add streaming client", () =>
{
nestedContainer?.Remove(testSpectatorStreamingClient);
nestedContainer?.Remove(testSpectatorClient);
Remove(lookupCache);
Children = new Drawable[]
@ -45,7 +47,7 @@ namespace osu.Game.Tests.Visual.Online
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
testSpectatorStreamingClient,
testSpectatorClient,
currentlyPlaying = new CurrentlyPlayingDisplay
{
RelativeSizeAxes = Axes.Both,
@ -55,15 +57,15 @@ namespace osu.Game.Tests.Visual.Online
};
});
AddStep("Reset players", () => testSpectatorStreamingClient.PlayingUsers.Clear());
AddStep("Reset players", () => testSpectatorClient.EndPlay(streamingUser.Id));
}
[Test]
public void TestBasicDisplay()
{
AddStep("Add playing user", () => testSpectatorStreamingClient.PlayingUsers.Add(2));
AddStep("Add playing user", () => testSpectatorClient.StartPlay(streamingUser.Id, 0));
AddUntilStep("Panel loaded", () => currentlyPlaying.ChildrenOfType<UserGridPanel>()?.FirstOrDefault()?.User.Id == 2);
AddStep("Remove playing user", () => testSpectatorStreamingClient.PlayingUsers.Remove(2));
AddStep("Remove playing user", () => testSpectatorClient.EndPlay(streamingUser.Id));
AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType<UserGridPanel>().Any());
}

View File

@ -22,82 +22,17 @@ namespace osu.Game.Tests.Visual.Ranking
{
public class TestSceneAccuracyCircle : OsuTestScene
{
[Test]
public void TestLowDRank()
[TestCase(0.2, ScoreRank.D)]
[TestCase(0.5, ScoreRank.D)]
[TestCase(0.75, ScoreRank.C)]
[TestCase(0.85, ScoreRank.B)]
[TestCase(0.925, ScoreRank.A)]
[TestCase(0.975, ScoreRank.S)]
[TestCase(0.9999, ScoreRank.S)]
[TestCase(1, ScoreRank.X)]
public void TestRank(double accuracy, ScoreRank rank)
{
var score = createScore();
score.Accuracy = 0.2;
score.Rank = ScoreRank.D;
addCircleStep(score);
}
[Test]
public void TestDRank()
{
var score = createScore();
score.Accuracy = 0.5;
score.Rank = ScoreRank.D;
addCircleStep(score);
}
[Test]
public void TestCRank()
{
var score = createScore();
score.Accuracy = 0.75;
score.Rank = ScoreRank.C;
addCircleStep(score);
}
[Test]
public void TestBRank()
{
var score = createScore();
score.Accuracy = 0.85;
score.Rank = ScoreRank.B;
addCircleStep(score);
}
[Test]
public void TestARank()
{
var score = createScore();
score.Accuracy = 0.925;
score.Rank = ScoreRank.A;
addCircleStep(score);
}
[Test]
public void TestSRank()
{
var score = createScore();
score.Accuracy = 0.975;
score.Rank = ScoreRank.S;
addCircleStep(score);
}
[Test]
public void TestAlmostSSRank()
{
var score = createScore();
score.Accuracy = 0.9999;
score.Rank = ScoreRank.S;
addCircleStep(score);
}
[Test]
public void TestSSRank()
{
var score = createScore();
score.Accuracy = 1;
score.Rank = ScoreRank.X;
var score = createScore(accuracy, rank);
addCircleStep(score);
}
@ -120,7 +55,7 @@ namespace osu.Game.Tests.Visual.Ranking
}
}
},
new AccuracyCircle(score, true)
new AccuracyCircle(score)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -129,7 +64,7 @@ namespace osu.Game.Tests.Visual.Ranking
};
});
private ScoreInfo createScore() => new ScoreInfo
private ScoreInfo createScore(double accuracy, ScoreRank rank) => new ScoreInfo
{
User = new User
{
@ -139,9 +74,9 @@ namespace osu.Game.Tests.Visual.Ranking
Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo,
Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() },
TotalScore = 2845370,
Accuracy = 0.95,
Accuracy = accuracy,
MaxCombo = 999,
Rank = ScoreRank.S,
Rank = rank,
Date = DateTimeOffset.Now,
Statistics =
{

View File

@ -29,13 +29,8 @@ namespace osu.Game.Tests.Visual.Ranking
[TestFixture]
public class TestSceneResultsScreen : OsuManualInputManagerTestScene
{
private BeatmapManager beatmaps;
[BackgroundDependencyLoader]
private void load(BeatmapManager beatmaps)
{
this.beatmaps = beatmaps;
}
[Resolved]
private BeatmapManager beatmaps { get; set; }
protected override void LoadComplete()
{
@ -46,10 +41,6 @@ namespace osu.Game.Tests.Visual.Ranking
Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo);
}
private TestResultsScreen createResultsScreen() => new TestResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo));
private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo));
[Test]
public void TestResultsWithoutPlayer()
{
@ -69,12 +60,25 @@ namespace osu.Game.Tests.Visual.Ranking
AddAssert("retry overlay not present", () => screen.RetryOverlay == null);
}
[Test]
public void TestResultsWithPlayer()
[TestCase(0.2, ScoreRank.D)]
[TestCase(0.5, ScoreRank.D)]
[TestCase(0.75, ScoreRank.C)]
[TestCase(0.85, ScoreRank.B)]
[TestCase(0.925, ScoreRank.A)]
[TestCase(0.975, ScoreRank.S)]
[TestCase(0.9999, ScoreRank.S)]
[TestCase(1, ScoreRank.X)]
public void TestResultsWithPlayer(double accuracy, ScoreRank rank)
{
TestResultsScreen screen = null;
AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen()));
var score = new TestScoreInfo(new OsuRuleset().RulesetInfo)
{
Accuracy = accuracy,
Rank = rank
};
AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen(score)));
AddUntilStep("wait for loaded", () => screen.IsLoaded);
AddAssert("retry overlay present", () => screen.RetryOverlay != null);
}
@ -232,6 +236,10 @@ namespace osu.Game.Tests.Visual.Ranking
AddAssert("download button is enabled", () => screen.ChildrenOfType<DownloadButton>().Last().Enabled.Value);
}
private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? new TestScoreInfo(new OsuRuleset().RulesetInfo));
private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo));
private class TestResultsContainer : Container
{
[Cached(typeof(Player))]

View File

@ -6,6 +6,7 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osu.Game.Overlays.KeyBinding;
using osuTK.Input;
@ -28,6 +29,39 @@ namespace osu.Game.Tests.Visual.Settings
panel.Show();
}
[SetUpSteps]
public void SetUpSteps()
{
AddStep("Scroll to top", () => panel.ChildrenOfType<SettingsPanel.SettingsSectionsContainer>().First().ScrollToTop());
AddWaitStep("wait for scroll", 5);
}
[Test]
public void TestBindingMouseWheelToNonGameplay()
{
scrollToAndStartBinding("Increase volume");
AddStep("press k", () => InputManager.Key(Key.K));
checkBinding("Increase volume", "K");
AddStep("click again", () => InputManager.Click(MouseButton.Left));
AddStep("scroll mouse wheel", () => InputManager.ScrollVerticalBy(1));
checkBinding("Increase volume", "Wheel Up");
}
[Test]
public void TestBindingMouseWheelToGameplay()
{
scrollToAndStartBinding("Left button");
AddStep("press k", () => InputManager.Key(Key.Z));
checkBinding("Left button", "Z");
AddStep("click again", () => InputManager.Click(MouseButton.Left));
AddStep("scroll mouse wheel", () => InputManager.ScrollVerticalBy(1));
checkBinding("Left button", "Z");
}
[Test]
public void TestClickTwiceOnClearButton()
{
@ -135,5 +169,37 @@ namespace osu.Game.Tests.Visual.Settings
AddAssert("first binding selected", () => multiBindingRow.ChildrenOfType<KeyBindingRow.KeyButton>().First().IsBinding);
}
private void checkBinding(string name, string keyName)
{
AddAssert($"Check {name} is bound to {keyName}", () =>
{
var firstRow = panel.ChildrenOfType<KeyBindingRow>().First(r => r.ChildrenOfType<OsuSpriteText>().Any(s => s.Text == name));
var firstButton = firstRow.ChildrenOfType<KeyBindingRow.KeyButton>().First();
return firstButton.Text.Text == keyName;
});
}
private void scrollToAndStartBinding(string name)
{
KeyBindingRow.KeyButton firstButton = null;
AddStep($"Scroll to {name}", () =>
{
var firstRow = panel.ChildrenOfType<KeyBindingRow>().First(r => r.ChildrenOfType<OsuSpriteText>().Any(s => s.Text == name));
firstButton = firstRow.ChildrenOfType<KeyBindingRow.KeyButton>().First();
panel.ChildrenOfType<SettingsPanel.SettingsSectionsContainer>().First().ScrollTo(firstButton);
});
AddWaitStep("wait for scroll", 5);
AddStep("click to bind", () =>
{
InputManager.MoveMouseTo(firstButton);
InputManager.Click(MouseButton.Left);
});
}
}
}

View File

@ -0,0 +1,86 @@
// 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.Game.Overlays;
namespace osu.Game.Tests.Visual.UserInterface
{
public class TestSceneBreadcrumbControlHeader : OsuTestScene
{
private static readonly string[] items = { "first", "second", "third", "fourth", "fifth" };
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Red);
private TestHeader header;
[SetUp]
public void SetUp() => Schedule(() =>
{
Child = header = new TestHeader
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
});
[Test]
public void TestAddAndRemoveItem()
{
foreach (var item in items.Skip(1))
AddStep($"Add {item} item", () => header.AddItem(item));
foreach (var item in items.Reverse().SkipLast(3))
AddStep($"Remove {item} item", () => header.RemoveItem(item));
AddStep("Clear items", () => header.ClearItems());
foreach (var item in items)
AddStep($"Add {item} item", () => header.AddItem(item));
foreach (var item in items)
AddStep($"Remove {item} item", () => header.RemoveItem(item));
}
private class TestHeader : BreadcrumbControlOverlayHeader
{
public TestHeader()
{
TabControl.AddItem(items[0]);
Current.Value = items[0];
}
public void AddItem(string value)
{
TabControl.AddItem(value);
Current.Value = TabControl.Items.LastOrDefault();
}
public void RemoveItem(string value)
{
TabControl.RemoveItem(value);
Current.Value = TabControl.Items.LastOrDefault();
}
public void ClearItems()
{
TabControl.Clear();
Current.Value = null;
}
protected override OverlayTitle CreateTitle() => new TestTitle();
}
private class TestTitle : OverlayTitle
{
public TestTitle()
{
Title = "Test Title";
}
}
}
}

View File

@ -86,6 +86,15 @@ _**italic with underscore, bold with asterisk**_";
});
}
[Test]
public void TestLinkWithTitle()
{
AddStep("Add Link with title", () =>
{
markdownContainer.Text = "[wikipedia](https://www.wikipedia.org \"The Free Encyclopedia\")";
});
}
[Test]
public void TestInlineCode()
{

View File

@ -14,7 +14,7 @@ namespace osu.Game.Tests.Visual.UserInterface
public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene
{
[Test]
public void TestTernaryMenuItem()
public void TestTernaryRadioMenuItem()
{
OsuMenu menu = null;
@ -30,9 +30,57 @@ namespace osu.Game.Tests.Visual.UserInterface
Origin = Anchor.Centre,
Items = new[]
{
new TernaryStateMenuItem("First"),
new TernaryStateMenuItem("Second") { State = { BindTarget = state } },
new TernaryStateMenuItem("Third") { State = { Value = TernaryState.True } },
new TernaryStateRadioMenuItem("First"),
new TernaryStateRadioMenuItem("Second") { State = { BindTarget = state } },
new TernaryStateRadioMenuItem("Third") { State = { Value = TernaryState.True } },
}
};
});
checkState(TernaryState.Indeterminate);
click();
checkState(TernaryState.True);
click();
checkState(TernaryState.True);
click();
checkState(TernaryState.True);
AddStep("change state via bindable", () => state.Value = TernaryState.True);
void click() =>
AddStep("click", () =>
{
InputManager.MoveMouseTo(menu.ScreenSpaceDrawQuad.Centre);
InputManager.Click(MouseButton.Left);
});
void checkState(TernaryState expected)
=> AddAssert($"state is {expected}", () => state.Value == expected);
}
[Test]
public void TestTernaryToggleMenuItem()
{
OsuMenu menu = null;
Bindable<TernaryState> state = new Bindable<TernaryState>(TernaryState.Indeterminate);
AddStep("create menu", () =>
{
state.Value = TernaryState.Indeterminate;
Child = menu = new OsuMenu(Direction.Vertical, true)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Items = new[]
{
new TernaryStateToggleMenuItem("First"),
new TernaryStateToggleMenuItem("Second") { State = { BindTarget = state } },
new TernaryStateToggleMenuItem("Third") { State = { Value = TernaryState.True } },
}
};
});

View File

@ -0,0 +1,183 @@
// 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 System.Threading;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics.Containers;
using osuTK;
namespace osu.Game.Tests.Visual.UserInterface
{
public class TestSceneUpdateableBeatmapSetCover : OsuTestScene
{
[Test]
public void TestLocal([Values] BeatmapSetCoverType coverType)
{
AddStep("setup cover", () => Child = new UpdateableBeatmapSetCover(coverType)
{
BeatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet,
RelativeSizeAxes = Axes.Both,
Masking = true,
});
AddUntilStep("wait for load", () => this.ChildrenOfType<BeatmapSetCover>().SingleOrDefault()?.IsLoaded ?? false);
}
[Test]
public void TestUnloadAndReload()
{
OsuScrollContainer scroll = null;
List<UpdateableBeatmapSetCover> covers = new List<UpdateableBeatmapSetCover>();
AddStep("setup covers", () =>
{
BeatmapSetInfo setInfo = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet;
FillFlowContainer fillFlow;
Child = scroll = new OsuScrollContainer
{
Size = new Vector2(500f),
Child = fillFlow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(10),
Padding = new MarginPadding { Bottom = 550 }
}
};
var coverTypes = Enum.GetValues(typeof(BeatmapSetCoverType))
.Cast<BeatmapSetCoverType>()
.ToList();
for (int i = 0; i < 25; i++)
{
var coverType = coverTypes[i % coverTypes.Count];
var cover = new UpdateableBeatmapSetCover(coverType)
{
BeatmapSet = setInfo,
Height = 100,
Masking = true,
};
if (coverType == BeatmapSetCoverType.Cover)
cover.Width = 500;
else if (coverType == BeatmapSetCoverType.Card)
cover.Width = 400;
else if (coverType == BeatmapSetCoverType.List)
cover.Size = new Vector2(100, 50);
fillFlow.Add(cover);
covers.Add(cover);
}
});
var loadedCovers = covers.Where(c => c.ChildrenOfType<BeatmapSetCover>().SingleOrDefault()?.IsLoaded ?? false);
AddUntilStep("some loaded", () => loadedCovers.Any());
AddStep("scroll to end", () => scroll.ScrollToEnd());
AddUntilStep("all unloaded", () => !loadedCovers.Any());
}
[Test]
public void TestSetNullBeatmapWhileLoading()
{
TestUpdateableBeatmapSetCover updateableCover = null;
AddStep("setup cover", () => Child = updateableCover = new TestUpdateableBeatmapSetCover
{
BeatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet,
RelativeSizeAxes = Axes.Both,
Masking = true,
});
AddStep("change model", () => updateableCover.BeatmapSet = null);
AddWaitStep("wait some", 5);
AddAssert("no cover added", () => !updateableCover.ChildrenOfType<DelayedLoadUnloadWrapper>().Any());
}
[Test]
public void TestCoverChangeOnNewBeatmap()
{
TestUpdateableBeatmapSetCover updateableCover = null;
BeatmapSetCover initialCover = null;
AddStep("setup cover", () => Child = updateableCover = new TestUpdateableBeatmapSetCover(0)
{
BeatmapSet = createBeatmapWithCover("https://assets.ppy.sh/beatmaps/1189904/covers/cover.jpg"),
RelativeSizeAxes = Axes.Both,
Masking = true,
Alpha = 0.4f
});
AddUntilStep("cover loaded", () => updateableCover.ChildrenOfType<BeatmapSetCover>().Any());
AddStep("store initial cover", () => initialCover = updateableCover.ChildrenOfType<BeatmapSetCover>().Single());
AddUntilStep("wait for fade complete", () => initialCover.Alpha == 1);
AddStep("switch beatmap",
() => updateableCover.BeatmapSet = createBeatmapWithCover("https://assets.ppy.sh/beatmaps/1079428/covers/cover.jpg"));
AddUntilStep("new cover loaded", () => updateableCover.ChildrenOfType<BeatmapSetCover>().Except(new[] { initialCover }).Any());
}
private static BeatmapSetInfo createBeatmapWithCover(string coverUrl) => new BeatmapSetInfo
{
OnlineInfo = new BeatmapSetOnlineInfo
{
Covers = new BeatmapSetOnlineCovers { Cover = coverUrl }
}
};
private class TestUpdateableBeatmapSetCover : UpdateableBeatmapSetCover
{
private readonly int loadDelay;
public TestUpdateableBeatmapSetCover(int loadDelay = 10000)
{
this.loadDelay = loadDelay;
}
protected override Drawable CreateDrawable(BeatmapSetInfo model)
{
if (model == null)
return null;
return new TestBeatmapSetCover(model, loadDelay)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fill,
};
}
}
private class TestBeatmapSetCover : BeatmapSetCover
{
private readonly int loadDelay;
public TestBeatmapSetCover(BeatmapSetInfo set, int loadDelay)
: base(set)
{
this.loadDelay = loadDelay;
}
[BackgroundDependencyLoader]
private void load()
{
Thread.Sleep(loadDelay);
}
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -8,78 +9,52 @@ using osu.Game.Graphics;
namespace osu.Game.Beatmaps.Drawables
{
public class UpdateableBeatmapSetCover : Container
public class UpdateableBeatmapSetCover : ModelBackedDrawable<BeatmapSetInfo>
{
private Drawable displayedCover;
private BeatmapSetInfo beatmapSet;
private readonly BeatmapSetCoverType coverType;
public BeatmapSetInfo BeatmapSet
{
get => beatmapSet;
set
{
if (value == beatmapSet) return;
beatmapSet = value;
if (IsLoaded)
updateCover();
}
get => Model;
set => Model = value;
}
private BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover;
public BeatmapSetCoverType CoverType
public new bool Masking
{
get => coverType;
set
{
if (value == coverType) return;
coverType = value;
if (IsLoaded)
updateCover();
}
get => base.Masking;
set => base.Masking = value;
}
public UpdateableBeatmapSetCover()
public UpdateableBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover)
{
Child = new Box
this.coverType = coverType;
InternalChild = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = OsuColour.Gray(0.2f),
};
}
protected override void LoadComplete()
{
base.LoadComplete();
updateCover();
}
protected override double LoadDelay => 500;
private void updateCover()
{
displayedCover?.FadeOut(400);
displayedCover?.Expire();
displayedCover = null;
protected override double TransformDuration => 400;
if (beatmapSet != null)
protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func<Drawable> createContentFunc, double timeBeforeLoad)
=> new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad);
protected override Drawable CreateDrawable(BeatmapSetInfo model)
{
if (model == null)
return null;
return new BeatmapSetCover(model, coverType)
{
Add(displayedCover = new DelayedLoadUnloadWrapper(() =>
{
var cover = new BeatmapSetCover(beatmapSet, coverType)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fill,
};
cover.OnLoadComplete += d => d.FadeInFromZero(400, Easing.Out);
return cover;
}));
}
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
FillMode = FillMode.Fill,
};
}
}
}

View File

@ -8,13 +8,13 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.IO;
using osu.Game.IO.Legacy;
using osu.Game.Overlays.Notifications;
@ -38,8 +38,6 @@ namespace osu.Game.Collections
public readonly BindableList<BeatmapCollection> Collections = new BindableList<BeatmapCollection>();
public bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
[Resolved]
private GameHost host { get; set; }
@ -96,25 +94,12 @@ namespace osu.Game.Collections
/// </summary>
public Action<Notification> PostNotification { protected get; set; }
/// <summary>
/// Set a storage with access to an osu-stable install for import purposes.
/// </summary>
public Func<Storage> GetStableStorage { private get; set; }
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
public Task ImportFromStableAsync()
public Task ImportFromStableAsync(StableStorage stableStorage)
{
var stable = GetStableStorage?.Invoke();
if (stable == null)
{
Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
if (!stable.Exists(database_name))
if (!stableStorage.Exists(database_name))
{
// This handles situations like when the user does not have a collections.db file
Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
@ -123,7 +108,7 @@ namespace osu.Game.Collections
return Task.Run(async () =>
{
using (var stream = stable.GetStream(database_name))
using (var stream = stableStorage.GetStream(database_name))
await Import(stream).ConfigureAwait(false);
});
}

View File

@ -10,7 +10,6 @@ using System.Threading.Tasks;
using Humanizer;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using osu.Framework;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
@ -81,8 +80,6 @@ namespace osu.Game.Database
public virtual IEnumerable<string> HandledExtensions => new[] { ".zip" };
public virtual bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
protected readonly FileStore Files;
protected readonly IDatabaseContextFactory ContextFactory;
@ -669,16 +666,6 @@ namespace osu.Game.Database
#region osu-stable import
/// <summary>
/// Set a storage with access to an osu-stable install for import purposes.
/// </summary>
public Func<StableStorage> GetStableStorage { private get; set; }
/// <summary>
/// Denotes whether an osu-stable installation is present to perform automated imports from.
/// </summary>
public bool StableInstallationAvailable => GetStableStorage?.Invoke() != null;
/// <summary>
/// The relative path from osu-stable's data directory to import items from.
/// </summary>
@ -700,22 +687,16 @@ namespace osu.Game.Database
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
public Task ImportFromStableAsync()
public Task ImportFromStableAsync(StableStorage stableStorage)
{
var stableStorage = GetStableStorage?.Invoke();
if (stableStorage == null)
{
Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
var storage = PrepareStableStorage(stableStorage);
// Handle situations like when the user does not have a Skins folder.
if (!storage.ExistsDirectory(ImportFromStablePath))
{
// This handles situations like when the user does not have a Skins folder
Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
string fullPath = storage.GetFullPath(ImportFromStablePath);
Logger.Log($"Folder \"{fullPath}\" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}

View File

@ -0,0 +1,96 @@
// 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.Threading.Tasks;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.IO;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings.Sections.Maintenance;
using osu.Game.Scoring;
using osu.Game.Skinning;
namespace osu.Game.Database
{
public class StableImportManager : Component
{
[Resolved]
private SkinManager skins { get; set; }
[Resolved]
private BeatmapManager beatmaps { get; set; }
[Resolved]
private ScoreManager scores { get; set; }
[Resolved]
private CollectionManager collections { get; set; }
[Resolved]
private OsuGame game { get; set; }
[Resolved]
private DialogOverlay dialogOverlay { get; set; }
[Resolved(CanBeNull = true)]
private DesktopGameHost desktopGameHost { get; set; }
private StableStorage cachedStorage;
public bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
public async Task ImportFromStableAsync(StableContent content)
{
var stableStorage = await getStableStorage().ConfigureAwait(false);
var importTasks = new List<Task>();
Task beatmapImportTask = Task.CompletedTask;
if (content.HasFlagFast(StableContent.Beatmaps))
importTasks.Add(beatmapImportTask = beatmaps.ImportFromStableAsync(stableStorage));
if (content.HasFlagFast(StableContent.Skins))
importTasks.Add(skins.ImportFromStableAsync(stableStorage));
if (content.HasFlagFast(StableContent.Collections))
importTasks.Add(beatmapImportTask.ContinueWith(_ => collections.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
if (content.HasFlagFast(StableContent.Scores))
importTasks.Add(beatmapImportTask.ContinueWith(_ => scores.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false);
}
private async Task<StableStorage> getStableStorage()
{
if (cachedStorage != null)
return cachedStorage;
var stableStorage = game.GetStorageForStableInstall();
if (stableStorage != null)
return cachedStorage = stableStorage;
var taskCompletionSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
Schedule(() => dialogOverlay.Push(new StableDirectoryLocationDialog(taskCompletionSource)));
var stablePath = await taskCompletionSource.Task.ConfigureAwait(false);
return cachedStorage = new StableStorage(stablePath, desktopGameHost);
}
}
[Flags]
public enum StableContent
{
Beatmaps = 1 << 0,
Scores = 1 << 1,
Skins = 1 << 2,
Collections = 1 << 3,
All = Beatmaps | Scores | Skins | Collections
}
}

View File

@ -6,11 +6,13 @@ using Markdig.Extensions.AutoIdentifiers;
using Markdig.Extensions.Tables;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
namespace osu.Game.Graphics.Containers.Markdown
{
@ -21,6 +23,16 @@ namespace osu.Game.Graphics.Containers.Markdown
LineSpacing = 21;
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var api = parent.Get<IAPIProvider>();
// needs to be set before the base BDL call executes to avoid invalidating any already populated markdown content.
DocumentUrl = api.WebsiteRootUrl;
return base.CreateChildDependencies(parent);
}
protected override void AddMarkdownComponent(IMarkdownObject markdownObject, FillFlowContainer container, int level)
{
switch (markdownObject)

View File

@ -1,48 +1,63 @@
// 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 Markdig.Syntax.Inlines;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Online.Chat;
using osu.Game.Overlays;
namespace osu.Game.Graphics.Containers.Markdown
{
public class OsuMarkdownLinkText : MarkdownLinkText
{
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
[Resolved(canBeNull: true)]
private OsuGame game { get; set; }
private SpriteText spriteText;
private readonly string text;
private readonly string title;
public OsuMarkdownLinkText(string text, LinkInline linkInline)
: base(text, linkInline)
{
this.text = text;
title = linkInline.Title;
}
[BackgroundDependencyLoader]
private void load()
{
spriteText.Colour = colourProvider.Light2;
var textDrawable = CreateSpriteText().With(t => t.Text = text);
InternalChildren = new Drawable[]
{
textDrawable,
new OsuMarkdownLinkCompiler(new[] { textDrawable })
{
RelativeSizeAxes = Axes.Both,
Action = OnLinkPressed,
TooltipText = title ?? Url,
}
};
}
public override SpriteText CreateSpriteText()
{
return spriteText = base.CreateSpriteText();
}
protected override void OnLinkPressed() => game?.HandleLink(Url);
protected override bool OnHover(HoverEvent e)
private class OsuMarkdownLinkCompiler : DrawableLinkCompiler
{
spriteText.Colour = colourProvider.Light1;
return base.OnHover(e);
}
public OsuMarkdownLinkCompiler(IEnumerable<Drawable> parts)
: base(parts)
{
}
protected override void OnHoverLost(HoverLostEvent e)
{
spriteText.Colour = colourProvider.Light2;
base.OnHoverLost(e);
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
IdleColour = colourProvider.Light2;
HoverColour = colourProvider.Light1;
}
}
}
}

View File

@ -15,6 +15,7 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics.Effects;
using osu.Game.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.UserInterface
{
@ -180,9 +181,9 @@ namespace osu.Game.Graphics.UserInterface
}
}
private string text;
private LocalisableString text;
public string Text
public LocalisableString Text
{
get => text;
set

View File

@ -9,28 +9,17 @@ namespace osu.Game.Graphics.UserInterface
/// <summary>
/// An <see cref="OsuMenuItem"/> with three possible states.
/// </summary>
public class TernaryStateMenuItem : StatefulMenuItem<TernaryState>
public abstract class TernaryStateMenuItem : StatefulMenuItem<TernaryState>
{
/// <summary>
/// Creates a new <see cref="TernaryStateMenuItem"/>.
/// </summary>
/// <param name="text">The text to display.</param>
/// <param name="nextStateFunction">A function to inform what the next state should be when this item is clicked.</param>
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
public TernaryStateMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: this(text, getNextState, type, action)
{
}
/// <summary>
/// Creates a new <see cref="TernaryStateMenuItem"/>.
/// </summary>
/// <param name="text">The text to display.</param>
/// <param name="changeStateFunc">A function that mutates a state to another state after this <see cref="TernaryStateMenuItem"/> is pressed.</param>
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
protected TernaryStateMenuItem(string text, Func<TernaryState, TernaryState> changeStateFunc, MenuItemType type, Action<TernaryState> action)
: base(text, changeStateFunc, type, action)
protected TernaryStateMenuItem(string text, Func<TernaryState, TernaryState> nextStateFunction, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: base(text, nextStateFunction, type, action)
{
}
@ -47,23 +36,5 @@ namespace osu.Game.Graphics.UserInterface
return null;
}
private static TernaryState getNextState(TernaryState state)
{
switch (state)
{
case TernaryState.False:
return TernaryState.True;
case TernaryState.Indeterminate:
return TernaryState.True;
case TernaryState.True:
return TernaryState.False;
default:
throw new ArgumentOutOfRangeException(nameof(state), state, null);
}
}
}
}

View File

@ -0,0 +1,26 @@
// 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;
namespace osu.Game.Graphics.UserInterface
{
/// <summary>
/// A ternary state menu item which will always set the item to <c>true</c> on click, even if already <c>true</c>.
/// </summary>
public class TernaryStateRadioMenuItem : TernaryStateMenuItem
{
/// <summary>
/// Creates a new <see cref="TernaryStateMenuItem"/>.
/// </summary>
/// <param name="text">The text to display.</param>
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
public TernaryStateRadioMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: base(text, getNextState, type, action)
{
}
private static TernaryState getNextState(TernaryState state) => TernaryState.True;
}
}

View File

@ -0,0 +1,42 @@
// 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;
namespace osu.Game.Graphics.UserInterface
{
/// <summary>
/// A ternary state menu item which toggles the state of this item <c>false</c> if clicked when <c>true</c>.
/// </summary>
public class TernaryStateToggleMenuItem : TernaryStateMenuItem
{
/// <summary>
/// Creates a new <see cref="TernaryStateToggleMenuItem"/>.
/// </summary>
/// <param name="text">The text to display.</param>
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
public TernaryStateToggleMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: base(text, getNextState, type, action)
{
}
private static TernaryState getNextState(TernaryState state)
{
switch (state)
{
case TernaryState.False:
return TernaryState.True;
case TernaryState.Indeterminate:
return TernaryState.True;
case TernaryState.True:
return TernaryState.False;
default:
throw new ArgumentOutOfRangeException(nameof(state), state, null);
}
}
}
}

View File

@ -73,10 +73,11 @@ namespace osu.Game.Input.Bindings
else
{
KeyBindings = store.Query(ruleset?.ID, variant)
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction))
// this ordering is important to ensure that we read entries from the database in the order
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order.
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction)).ToList();
.ToList();
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Input.Bindings;
using osu.Framework.Platform;
@ -16,6 +17,17 @@ namespace osu.Game.Input
{
public event Action KeyBindingChanged;
/// <summary>
/// Keys which should not be allowed for gameplay input purposes.
/// </summary>
private static readonly IEnumerable<InputKey> banned_keys = new[]
{
InputKey.MouseWheelDown,
InputKey.MouseWheelLeft,
InputKey.MouseWheelUp,
InputKey.MouseWheelRight
};
public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null)
: base(contextFactory, storage)
{
@ -93,6 +105,9 @@ namespace osu.Game.Input
using (ContextFactory.GetForWrite())
{
var dbKeyBinding = (DatabasedKeyBinding)keyBinding;
Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination));
Refresh(ref dbKeyBinding);
if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination))
@ -103,5 +118,16 @@ namespace osu.Game.Input
KeyBindingChanged?.Invoke();
}
public static bool CheckValidForGameplay(KeyCombination combination)
{
foreach (var key in banned_keys)
{
if (combination.Keys.Contains(key))
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,38 @@
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="solo" xml:space="preserve">
<value>ソロ</value>
</data>
<data name="playlists" xml:space="preserve">
<value>プレイリスト</value>
</data>
<data name="play" xml:space="preserve">
<value>遊ぶ</value>
</data>
<data name="multi" xml:space="preserve">
<value>マルチ</value>
</data>
<data name="edit" xml:space="preserve">
<value>エディット</value>
</data>
<data name="browse" xml:space="preserve">
<value>ブラウズ</value>
</data>
<data name="exit" xml:space="preserve">
<value>閉じる</value>
</data>
<data name="settings" xml:space="preserve">
<value>設定</value>
</data>
</root>

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="solo" xml:space="preserve">
<value>solo</value>
</data>
<data name="multi" xml:space="preserve">
<value>multi</value>
</data>
<data name="playlists" xml:space="preserve">
<value>playlists</value>
</data>
<data name="play" xml:space="preserve">
<value>play</value>
</data>
<data name="edit" xml:space="preserve">
<value>edit</value>
</data>
<data name="browse" xml:space="preserve">
<value>browse</value>
</data>
<data name="settings" xml:space="preserve">
<value>settings</value>
</data>
<data name="back" xml:space="preserve">
<value>back</value>
</data>
<data name="exit" xml:space="preserve">
<value>exit</value>
</data>
</root>

View File

@ -0,0 +1,59 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class ButtonSystemStrings
{
private const string prefix = @"osu.Game.Localisation.ButtonSystem";
/// <summary>
/// "solo"
/// </summary>
public static LocalisableString Solo => new TranslatableString(getKey(@"solo"), @"solo");
/// <summary>
/// "multi"
/// </summary>
public static LocalisableString Multi => new TranslatableString(getKey(@"multi"), @"multi");
/// <summary>
/// "playlists"
/// </summary>
public static LocalisableString Playlists => new TranslatableString(getKey(@"playlists"), @"playlists");
/// <summary>
/// "play"
/// </summary>
public static LocalisableString Play => new TranslatableString(getKey(@"play"), @"play");
/// <summary>
/// "edit"
/// </summary>
public static LocalisableString Edit => new TranslatableString(getKey(@"edit"), @"edit");
/// <summary>
/// "browse"
/// </summary>
public static LocalisableString Browse => new TranslatableString(getKey(@"browse"), @"browse");
/// <summary>
/// "settings"
/// </summary>
public static LocalisableString Settings => new TranslatableString(getKey(@"settings"), @"settings");
/// <summary>
/// "back"
/// </summary>
public static LocalisableString Back => new TranslatableString(getKey(@"back"), @"back");
/// <summary>
/// "exit"
/// </summary>
public static LocalisableString Exit => new TranslatableString(getKey(@"exit"), @"exit");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="header_title" xml:space="preserve">
<value>chat</value>
</data>
<data name="header_description" xml:space="preserve">
<value>join the real-time discussion</value>
</data>
</root>

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class ChatStrings
{
private const string prefix = "osu.Game.Localisation.Chat";
/// <summary>
/// "chat"
/// </summary>
public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "chat");
/// <summary>
/// "join the real-time discussion"
/// </summary>
public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "join the real-time discussion");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="cancel" xml:space="preserve">
<value>Cancel</value>
</data>
</root>

View File

@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class CommonStrings
{
private const string prefix = "osu.Game.Localisation.Common";
/// <summary>
/// "Cancel"
/// </summary>
public static LocalisableString Cancel => new TranslatableString(getKey("cancel"), "Cancel");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -0,0 +1,16 @@
// 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.ComponentModel;
namespace osu.Game.Localisation
{
public enum Language
{
[Description("English")]
en,
[Description("日本語")]
ja
}
}

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="header_title" xml:space="preserve">
<value>notifications</value>
</data>
<data name="header_description" xml:space="preserve">
<value>waiting for 'ya</value>
</data>
</root>

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class NotificationsStrings
{
private const string prefix = "osu.Game.Localisation.Notifications";
/// <summary>
/// "notifications"
/// </summary>
public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "notifications");
/// <summary>
/// "waiting for 'ya"
/// </summary>
public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "waiting for 'ya");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="header_title" xml:space="preserve">
<value>now playing</value>
</data>
<data name="header_description" xml:space="preserve">
<value>manage the currently playing track</value>
</data>
</root>

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class NowPlayingStrings
{
private const string prefix = "osu.Game.Localisation.NowPlaying";
/// <summary>
/// "now playing"
/// </summary>
public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "now playing");
/// <summary>
/// "manage the currently playing track"
/// </summary>
public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "manage the currently playing track");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -0,0 +1,69 @@
// 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.Globalization;
using System.IO;
using System.Resources;
using System.Threading.Tasks;
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public class ResourceManagerLocalisationStore : ILocalisationStore
{
private readonly Dictionary<string, ResourceManager> resourceManagers = new Dictionary<string, ResourceManager>();
public ResourceManagerLocalisationStore(string cultureCode)
{
EffectiveCulture = new CultureInfo(cultureCode);
}
public void Dispose()
{
}
public string Get(string lookup)
{
var split = lookup.Split(':');
string ns = split[0];
string key = split[1];
lock (resourceManagers)
{
if (!resourceManagers.TryGetValue(ns, out var manager))
resourceManagers[ns] = manager = new ResourceManager(ns, GetType().Assembly);
try
{
return manager.GetString(key, EffectiveCulture);
}
catch (MissingManifestResourceException)
{
// in the case the manifest is missing, it is likely that the user is adding code-first implementations of new localisation namespaces.
// it's fine to ignore this as localisation will fallback to default values.
return null;
}
}
}
public Task<string> GetAsync(string lookup)
{
return Task.FromResult(Get(lookup));
}
public Stream GetStream(string name)
{
throw new NotImplementedException();
}
public IEnumerable<string> GetAvailableResources()
{
throw new NotImplementedException();
}
public CultureInfo EffectiveCulture { get; }
}
}

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="header_title" xml:space="preserve">
<value>settings</value>
</data>
<data name="header_description" xml:space="preserve">
<value>change the way osu! behaves</value>
</data>
</root>

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class SettingsStrings
{
private const string prefix = "osu.Game.Localisation.Settings";
/// <summary>
/// "settings"
/// </summary>
public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "settings");
/// <summary>
/// "change the way osu! behaves"
/// </summary>
public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "change the way osu! behaves");
private static string getKey(string key) => $"{prefix}:{key}";
}
}

View File

@ -8,10 +8,12 @@ namespace osu.Game.Online.API.Requests
{
public class GetNewsRequest : APIRequest<GetNewsResponse>
{
private readonly int? year;
private readonly Cursor cursor;
public GetNewsRequest(Cursor cursor = null)
public GetNewsRequest(int? year = null, Cursor cursor = null)
{
this.year = year;
this.cursor = cursor;
}
@ -19,6 +21,10 @@ namespace osu.Game.Online.API.Requests
{
var req = base.CreateWebRequest();
req.AddCursor(cursor);
if (year.HasValue)
req.AddParameter("year", year.Value.ToString());
return req;
}

View File

@ -3,132 +3,621 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Rooms;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Users;
using osu.Game.Utils;
namespace osu.Game.Online.Multiplayer
{
public class MultiplayerClient : StatefulMultiplayerClient
public abstract class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
{
private readonly string endpoint;
/// <summary>
/// Invoked when any change occurs to the multiplayer room.
/// </summary>
public event Action? RoomUpdated;
private IHubClientConnector? connector;
/// <summary>
/// Invoked when the multiplayer server requests the current beatmap to be loaded into play.
/// </summary>
public event Action? LoadRequested;
public override IBindable<bool> IsConnected { get; } = new BindableBool();
/// <summary>
/// Invoked when the multiplayer server requests gameplay to be started.
/// </summary>
public event Action? MatchStarted;
private HubConnection? connection => connector?.CurrentConnection;
/// <summary>
/// Invoked when the multiplayer server has finished collating results.
/// </summary>
public event Action? ResultsReady;
public MultiplayerClient(EndpointConfiguration endpoints)
/// <summary>
/// Whether the <see cref="MultiplayerClient"/> is currently connected.
/// This is NOT thread safe and usage should be scheduled.
/// </summary>
public abstract IBindable<bool> IsConnected { get; }
/// <summary>
/// The joined <see cref="MultiplayerRoom"/>.
/// </summary>
public MultiplayerRoom? Room { get; private set; }
/// <summary>
/// The users in the joined <see cref="Room"/> which are participating in the current gameplay loop.
/// </summary>
public readonly BindableList<int> CurrentMatchPlayingUserIds = new BindableList<int>();
public readonly Bindable<PlaylistItem?> CurrentMatchPlayingItem = new Bindable<PlaylistItem?>();
/// <summary>
/// The <see cref="MultiplayerRoomUser"/> corresponding to the local player, if available.
/// </summary>
public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id);
/// <summary>
/// Whether the <see cref="LocalUser"/> is the host in <see cref="Room"/>.
/// </summary>
public bool IsHost
{
endpoint = endpoints.MultiplayerEndpointUrl;
}
[BackgroundDependencyLoader]
private void load(IAPIProvider api)
{
connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint);
if (connector != null)
get
{
connector.ConfigureConnection = connection =>
{
// this is kind of SILLY
// https://github.com/dotnet/aspnetcore/issues/15198
connection.On<MultiplayerRoomState>(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
connection.On<int>(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
connection.On<MultiplayerRoomSettings>(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
connection.On<int, MultiplayerUserState>(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
connection.On<int, IEnumerable<APIMod>>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
connection.On<int, BeatmapAvailability>(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
};
IsConnected.BindTo(connector.IsConnected);
var localUser = LocalUser;
return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
}
}
protected override Task<MultiplayerRoom> JoinRoom(long roomId)
{
if (!IsConnected.Value)
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
[Resolved]
protected IAPIProvider API { get; private set; } = null!;
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoom), roomId);
[Resolved]
protected RulesetStore Rulesets { get; private set; } = null!;
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
private Room? apiRoom;
[BackgroundDependencyLoader]
private void load()
{
IsConnected.BindValueChanged(connected =>
{
// clean up local room state on server disconnect.
if (!connected.NewValue && Room != null)
{
Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
LeaveRoom();
}
});
}
protected override Task LeaveRoomInternal()
{
if (!IsConnected.Value)
return Task.FromCanceled(new CancellationToken(true));
private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
private CancellationTokenSource? joinCancellationSource;
return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
/// <summary>
/// Joins the <see cref="MultiplayerRoom"/> for a given API <see cref="Room"/>.
/// </summary>
/// <param name="room">The API <see cref="Room"/>.</param>
public async Task JoinRoom(Room room)
{
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.
var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false);
Debug.Assert(joinedRoom != null);
// Populate users.
Debug.Assert(joinedRoom.Users != null);
await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false);
// Update the stored room (must be done on update thread for thread-safety).
await scheduleAsync(() =>
{
Room = joinedRoom;
apiRoom = room;
foreach (var user in joinedRoom.Users)
updateUserPlayingState(user.UserID, user.State);
}, cancellationSource.Token).ConfigureAwait(false);
// Update room settings.
await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false);
}, cancellationSource.Token).ConfigureAwait(false);
}
public override Task TransferHost(int userId)
/// <summary>
/// Joins the <see cref="MultiplayerRoom"/> with a given ID.
/// </summary>
/// <param name="roomId">The room ID.</param>
/// <returns>The joined <see cref="MultiplayerRoom"/>.</returns>
protected abstract Task<MultiplayerRoom> JoinRoom(long roomId);
public Task LeaveRoom()
{
if (!IsConnected.Value)
// The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled.
// This includes the setting of Room itself along with the initial update of the room settings on join.
joinCancellationSource?.Cancel();
// 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(() =>
{
apiRoom = null;
Room = null;
CurrentMatchPlayingUserIds.Clear();
RoomUpdated?.Invoke();
});
return joinOrLeaveTaskChain.Add(async () =>
{
await scheduledReset.ConfigureAwait(false);
await LeaveRoomInternal().ConfigureAwait(false);
});
}
protected abstract Task LeaveRoomInternal();
/// <summary>
/// Change the current <see cref="MultiplayerRoom"/> settings.
/// </summary>
/// <remarks>
/// A room must be joined for this to have any effect.
/// </remarks>
/// <param name="name">The new room name, if any.</param>
/// <param name="item">The new room playlist item, if any.</param>
public Task ChangeSettings(Optional<string> name = default, Optional<PlaylistItem> item = default)
{
if (Room == null)
throw new InvalidOperationException("Must be joined to a match to change settings.");
// A dummy playlist item filled with the current room settings (except mods).
var existingPlaylistItem = new PlaylistItem
{
Beatmap =
{
Value = new BeatmapInfo
{
OnlineBeatmapID = Room.Settings.BeatmapID,
MD5Hash = Room.Settings.BeatmapChecksum
}
},
RulesetID = Room.Settings.RulesetID
};
return ChangeSettings(new MultiplayerRoomSettings
{
Name = name.GetOr(Room.Settings.Name),
BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods,
AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods,
});
}
/// <summary>
/// Toggles the <see cref="LocalUser"/>'s ready state.
/// </summary>
/// <exception cref="InvalidOperationException">If a toggle of ready state is not valid at this time.</exception>
public async Task ToggleReady()
{
var localUser = LocalUser;
if (localUser == null)
return;
switch (localUser.State)
{
case MultiplayerUserState.Idle:
await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false);
return;
case MultiplayerUserState.Ready:
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
return;
default:
throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}");
}
}
/// <summary>
/// Toggles the <see cref="LocalUser"/>'s spectating state.
/// </summary>
/// <exception cref="InvalidOperationException">If a toggle of the spectating state is not valid at this time.</exception>
public async Task ToggleSpectate()
{
var localUser = LocalUser;
if (localUser == null)
return;
switch (localUser.State)
{
case MultiplayerUserState.Idle:
case MultiplayerUserState.Ready:
await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false);
return;
case MultiplayerUserState.Spectating:
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
return;
default:
throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}");
}
}
public abstract Task TransferHost(int userId);
public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
public abstract Task ChangeState(MultiplayerUserState newState);
public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
/// <summary>
/// Change the local user's mods in the currently joined room.
/// </summary>
/// <param name="newMods">The proposed new mods, excluding any required by the room itself.</param>
public Task ChangeUserMods(IEnumerable<Mod> newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList());
public abstract Task ChangeUserMods(IEnumerable<APIMod> newMods);
public abstract Task StartMatch();
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
{
if (Room == null)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
Scheduler.Add(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
Room.State = state;
switch (state)
{
case MultiplayerRoomState.Open:
apiRoom.Status.Value = new RoomStatusOpen();
break;
case MultiplayerRoomState.Playing:
apiRoom.Status.Value = new RoomStatusPlaying();
break;
case MultiplayerRoomState.Closed:
apiRoom.Status.Value = new RoomStatusEnded();
break;
}
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
public override Task ChangeSettings(MultiplayerRoomSettings settings)
async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
{
if (!IsConnected.Value)
if (Room == null)
return;
await PopulateUser(user).ConfigureAwait(false);
Scheduler.Add(() =>
{
if (Room == null)
return;
// for sanity, ensure that there can be no duplicate users in the room user list.
if (Room.Users.Any(existing => existing.UserID == user.UserID))
return;
Room.Users.Add(user);
RoomUpdated?.Invoke();
}, false);
}
Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user)
{
if (Room == null)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
Scheduler.Add(() =>
{
if (Room == null)
return;
Room.Users.Remove(user);
CurrentMatchPlayingUserIds.Remove(user.UserID);
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
public override Task ChangeState(MultiplayerUserState newState)
Task IMultiplayerClient.HostChanged(int userId)
{
if (!IsConnected.Value)
if (Room == null)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
Scheduler.Add(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
Room.Host = user;
apiRoom.Host.Value = user?.User;
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
{
if (!IsConnected.Value)
updateLocalRoomSettings(newSettings);
return Task.CompletedTask;
}
Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
{
if (Room == null)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
Scheduler.Add(() =>
{
if (Room == null)
return;
Room.Users.Single(u => u.UserID == userId).State = state;
updateUserPlayingState(userId, state);
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability)
{
if (!IsConnected.Value)
if (Room == null)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
Scheduler.Add(() =>
{
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
// errors here are not critical - beatmap availability state is mostly for display.
if (user == null)
return;
user.BeatmapAvailability = beatmapAvailability;
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
public override Task StartMatch()
public Task UserModsChanged(int userId, IEnumerable<APIMod> mods)
{
if (!IsConnected.Value)
if (Room == null)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
Scheduler.Add(() =>
{
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
// errors here are not critical - user mods are mostly for display.
if (user == null)
return;
user.Mods = mods;
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
protected override Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
Task IMultiplayerClient.LoadRequested()
{
var tcs = new TaskCompletionSource<BeatmapSetInfo>();
var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
if (Room == null)
return Task.CompletedTask;
req.Success += res =>
Scheduler.Add(() =>
{
if (Room == null)
return;
LoadRequested?.Invoke();
}, false);
return Task.CompletedTask;
}
Task IMultiplayerClient.MatchStarted()
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
MatchStarted?.Invoke();
}, false);
return Task.CompletedTask;
}
Task IMultiplayerClient.ResultsReady()
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
ResultsReady?.Invoke();
}, false);
return Task.CompletedTask;
}
/// <summary>
/// Populates the <see cref="User"/> for a given <see cref="MultiplayerRoomUser"/>.
/// </summary>
/// <param name="multiplayerUser">The <see cref="MultiplayerRoomUser"/> to populate.</param>
protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false);
/// <summary>
/// Updates the local room settings with the given <see cref="MultiplayerRoomSettings"/>.
/// </summary>
/// <remarks>
/// This updates both the joined <see cref="MultiplayerRoom"/> and the respective API <see cref="Room"/>.
/// </remarks>
/// <param name="settings">The new <see cref="MultiplayerRoomSettings"/> to update from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the update.</param>
private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
// Update a few properties of the room instantaneously.
Room.Settings = settings;
apiRoom.Name.Value = Room.Settings.Name;
// The current item update is delayed until an online beatmap lookup (below) succeeds.
// In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here.
CurrentMatchPlayingItem.Value = null;
RoomUpdated?.Invoke();
GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() =>
{
if (cancellationToken.IsCancellationRequested)
return;
updatePlaylist(settings, set.Result);
}), TaskContinuationOptions.OnlyOnRanToCompletion);
}, cancellationToken);
private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet)
{
if (Room == null || !Room.Settings.Equals(settings))
return;
Debug.Assert(apiRoom != null);
var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
beatmap.MD5Hash = settings.BeatmapChecksum;
var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance();
var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset));
var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset));
// Try to retrieve the existing playlist item from the API room.
var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId);
if (playlistItem != null)
updateItem(playlistItem);
else
{
// An existing playlist item does not exist, so append a new one.
updateItem(playlistItem = new PlaylistItem());
apiRoom.Playlist.Add(playlistItem);
}
CurrentMatchPlayingItem.Value = playlistItem;
void updateItem(PlaylistItem item)
{
item.ID = settings.PlaylistItemId;
item.Beatmap.Value = beatmap;
item.Ruleset.Value = ruleset.RulesetInfo;
item.RequiredMods.Clear();
item.RequiredMods.AddRange(mods);
item.AllowedMods.Clear();
item.AllowedMods.AddRange(allowedMods);
}
}
/// <summary>
/// Retrieves a <see cref="BeatmapSetInfo"/> from an online source.
/// </summary>
/// <param name="beatmapId">The beatmap set ID.</param>
/// <param name="cancellationToken">A token to cancel the request.</param>
/// <returns>The <see cref="BeatmapSetInfo"/> retrieval task.</returns>
protected abstract Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default);
/// <summary>
/// For the provided user ID, update whether the user is included in <see cref="CurrentMatchPlayingUserIds"/>.
/// </summary>
/// <param name="userId">The user's ID.</param>
/// <param name="state">The new state of the user.</param>
private void updateUserPlayingState(int userId, MultiplayerUserState state)
{
bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId);
bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay;
if (isPlaying == wasPlaying)
return;
if (isPlaying)
CurrentMatchPlayingUserIds.Add(userId);
else
CurrentMatchPlayingUserIds.Remove(userId);
}
private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
{
var tcs = new TaskCompletionSource<bool>();
Scheduler.Add(() =>
{
if (cancellationToken.IsCancellationRequested)
{
@ -136,20 +625,18 @@ namespace osu.Game.Online.Multiplayer
return;
}
tcs.SetResult(res.ToBeatmapSet(Rulesets));
};
req.Failure += e => tcs.SetException(e);
API.Queue(req);
try
{
action();
tcs.SetResult(true);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
return tcs.Task;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
connector?.Dispose();
}
}
}

View File

@ -0,0 +1,158 @@
// 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.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Rooms;
namespace osu.Game.Online.Multiplayer
{
/// <summary>
/// A <see cref="MultiplayerClient"/> with online connectivity.
/// </summary>
public class OnlineMultiplayerClient : MultiplayerClient
{
private readonly string endpoint;
private IHubClientConnector? connector;
public override IBindable<bool> IsConnected { get; } = new BindableBool();
private HubConnection? connection => connector?.CurrentConnection;
public OnlineMultiplayerClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.MultiplayerEndpointUrl;
}
[BackgroundDependencyLoader]
private void load(IAPIProvider api)
{
connector = api.GetHubConnector(nameof(OnlineMultiplayerClient), endpoint);
if (connector != null)
{
connector.ConfigureConnection = connection =>
{
// this is kind of SILLY
// https://github.com/dotnet/aspnetcore/issues/15198
connection.On<MultiplayerRoomState>(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
connection.On<int>(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
connection.On<MultiplayerRoomSettings>(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
connection.On<int, MultiplayerUserState>(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
connection.On<int, IEnumerable<APIMod>>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
connection.On<int, BeatmapAvailability>(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
};
IsConnected.BindTo(connector.IsConnected);
}
}
protected override Task<MultiplayerRoom> JoinRoom(long roomId)
{
if (!IsConnected.Value)
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoom), roomId);
}
protected override Task LeaveRoomInternal()
{
if (!IsConnected.Value)
return Task.FromCanceled(new CancellationToken(true));
return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
}
public override Task TransferHost(int userId)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
}
public override Task ChangeSettings(MultiplayerRoomSettings settings)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
}
public override Task ChangeState(MultiplayerUserState newState)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
}
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
}
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
}
public override Task StartMatch()
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
}
protected override Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
{
var tcs = new TaskCompletionSource<BeatmapSetInfo>();
var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
req.Success += res =>
{
if (cancellationToken.IsCancellationRequested)
{
tcs.SetCanceled();
return;
}
tcs.SetResult(res.ToBeatmapSet(Rulesets));
};
req.Failure += e => tcs.SetException(e);
API.Queue(req);
return tcs.Task;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
connector?.Dispose();
}
}
}

View File

@ -1,642 +0,0 @@
// 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 System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Users;
using osu.Game.Utils;
namespace osu.Game.Online.Multiplayer
{
public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
{
/// <summary>
/// Invoked when any change occurs to the multiplayer room.
/// </summary>
public event Action? RoomUpdated;
/// <summary>
/// Invoked when the multiplayer server requests the current beatmap to be loaded into play.
/// </summary>
public event Action? LoadRequested;
/// <summary>
/// Invoked when the multiplayer server requests gameplay to be started.
/// </summary>
public event Action? MatchStarted;
/// <summary>
/// Invoked when the multiplayer server has finished collating results.
/// </summary>
public event Action? ResultsReady;
/// <summary>
/// Whether the <see cref="StatefulMultiplayerClient"/> is currently connected.
/// This is NOT thread safe and usage should be scheduled.
/// </summary>
public abstract IBindable<bool> IsConnected { get; }
/// <summary>
/// The joined <see cref="MultiplayerRoom"/>.
/// </summary>
public MultiplayerRoom? Room { get; private set; }
/// <summary>
/// The users in the joined <see cref="Room"/> which are participating in the current gameplay loop.
/// </summary>
public readonly BindableList<int> CurrentMatchPlayingUserIds = new BindableList<int>();
public readonly Bindable<PlaylistItem?> CurrentMatchPlayingItem = new Bindable<PlaylistItem?>();
/// <summary>
/// The <see cref="MultiplayerRoomUser"/> corresponding to the local player, if available.
/// </summary>
public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id);
/// <summary>
/// Whether the <see cref="LocalUser"/> is the host in <see cref="Room"/>.
/// </summary>
public bool IsHost
{
get
{
var localUser = LocalUser;
return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
}
}
[Resolved]
protected IAPIProvider API { get; private set; } = null!;
[Resolved]
protected RulesetStore Rulesets { get; private set; } = null!;
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
private Room? apiRoom;
[BackgroundDependencyLoader]
private void load()
{
IsConnected.BindValueChanged(connected =>
{
// clean up local room state on server disconnect.
if (!connected.NewValue && Room != null)
{
Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
LeaveRoom();
}
});
}
private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
private CancellationTokenSource? joinCancellationSource;
/// <summary>
/// Joins the <see cref="MultiplayerRoom"/> for a given API <see cref="Room"/>.
/// </summary>
/// <param name="room">The API <see cref="Room"/>.</param>
public async Task JoinRoom(Room room)
{
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.
var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false);
Debug.Assert(joinedRoom != null);
// Populate users.
Debug.Assert(joinedRoom.Users != null);
await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false);
// Update the stored room (must be done on update thread for thread-safety).
await scheduleAsync(() =>
{
Room = joinedRoom;
apiRoom = room;
foreach (var user in joinedRoom.Users)
updateUserPlayingState(user.UserID, user.State);
}, cancellationSource.Token).ConfigureAwait(false);
// Update room settings.
await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false);
}, cancellationSource.Token).ConfigureAwait(false);
}
/// <summary>
/// Joins the <see cref="MultiplayerRoom"/> with a given ID.
/// </summary>
/// <param name="roomId">The room ID.</param>
/// <returns>The joined <see cref="MultiplayerRoom"/>.</returns>
protected abstract Task<MultiplayerRoom> JoinRoom(long roomId);
public Task LeaveRoom()
{
// The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled.
// This includes the setting of Room itself along with the initial update of the room settings on join.
joinCancellationSource?.Cancel();
// 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(() =>
{
apiRoom = null;
Room = null;
CurrentMatchPlayingUserIds.Clear();
RoomUpdated?.Invoke();
});
return joinOrLeaveTaskChain.Add(async () =>
{
await scheduledReset.ConfigureAwait(false);
await LeaveRoomInternal().ConfigureAwait(false);
});
}
protected abstract Task LeaveRoomInternal();
/// <summary>
/// Change the current <see cref="MultiplayerRoom"/> settings.
/// </summary>
/// <remarks>
/// A room must be joined for this to have any effect.
/// </remarks>
/// <param name="name">The new room name, if any.</param>
/// <param name="item">The new room playlist item, if any.</param>
public Task ChangeSettings(Optional<string> name = default, Optional<PlaylistItem> item = default)
{
if (Room == null)
throw new InvalidOperationException("Must be joined to a match to change settings.");
// A dummy playlist item filled with the current room settings (except mods).
var existingPlaylistItem = new PlaylistItem
{
Beatmap =
{
Value = new BeatmapInfo
{
OnlineBeatmapID = Room.Settings.BeatmapID,
MD5Hash = Room.Settings.BeatmapChecksum
}
},
RulesetID = Room.Settings.RulesetID
};
return ChangeSettings(new MultiplayerRoomSettings
{
Name = name.GetOr(Room.Settings.Name),
BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods,
AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods,
});
}
/// <summary>
/// Toggles the <see cref="LocalUser"/>'s ready state.
/// </summary>
/// <exception cref="InvalidOperationException">If a toggle of ready state is not valid at this time.</exception>
public async Task ToggleReady()
{
var localUser = LocalUser;
if (localUser == null)
return;
switch (localUser.State)
{
case MultiplayerUserState.Idle:
await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false);
return;
case MultiplayerUserState.Ready:
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
return;
default:
throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}");
}
}
/// <summary>
/// Toggles the <see cref="LocalUser"/>'s spectating state.
/// </summary>
/// <exception cref="InvalidOperationException">If a toggle of the spectating state is not valid at this time.</exception>
public async Task ToggleSpectate()
{
var localUser = LocalUser;
if (localUser == null)
return;
switch (localUser.State)
{
case MultiplayerUserState.Idle:
case MultiplayerUserState.Ready:
await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false);
return;
case MultiplayerUserState.Spectating:
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
return;
default:
throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}");
}
}
public abstract Task TransferHost(int userId);
public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
public abstract Task ChangeState(MultiplayerUserState newState);
public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
/// <summary>
/// Change the local user's mods in the currently joined room.
/// </summary>
/// <param name="newMods">The proposed new mods, excluding any required by the room itself.</param>
public Task ChangeUserMods(IEnumerable<Mod> newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList());
public abstract Task ChangeUserMods(IEnumerable<APIMod> newMods);
public abstract Task StartMatch();
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
Room.State = state;
switch (state)
{
case MultiplayerRoomState.Open:
apiRoom.Status.Value = new RoomStatusOpen();
break;
case MultiplayerRoomState.Playing:
apiRoom.Status.Value = new RoomStatusPlaying();
break;
case MultiplayerRoomState.Closed:
apiRoom.Status.Value = new RoomStatusEnded();
break;
}
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
{
if (Room == null)
return;
await PopulateUser(user).ConfigureAwait(false);
Scheduler.Add(() =>
{
if (Room == null)
return;
// for sanity, ensure that there can be no duplicate users in the room user list.
if (Room.Users.Any(existing => existing.UserID == user.UserID))
return;
Room.Users.Add(user);
RoomUpdated?.Invoke();
}, false);
}
Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
Room.Users.Remove(user);
CurrentMatchPlayingUserIds.Remove(user.UserID);
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
Task IMultiplayerClient.HostChanged(int userId)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
Room.Host = user;
apiRoom.Host.Value = user?.User;
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
{
updateLocalRoomSettings(newSettings);
return Task.CompletedTask;
}
Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
Room.Users.Single(u => u.UserID == userId).State = state;
updateUserPlayingState(userId, state);
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability)
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
// errors here are not critical - beatmap availability state is mostly for display.
if (user == null)
return;
user.BeatmapAvailability = beatmapAvailability;
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
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);
// errors here are not critical - user mods are mostly for display.
if (user == null)
return;
user.Mods = mods;
RoomUpdated?.Invoke();
}, false);
return Task.CompletedTask;
}
Task IMultiplayerClient.LoadRequested()
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
LoadRequested?.Invoke();
}, false);
return Task.CompletedTask;
}
Task IMultiplayerClient.MatchStarted()
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
MatchStarted?.Invoke();
}, false);
return Task.CompletedTask;
}
Task IMultiplayerClient.ResultsReady()
{
if (Room == null)
return Task.CompletedTask;
Scheduler.Add(() =>
{
if (Room == null)
return;
ResultsReady?.Invoke();
}, false);
return Task.CompletedTask;
}
/// <summary>
/// Populates the <see cref="User"/> for a given <see cref="MultiplayerRoomUser"/>.
/// </summary>
/// <param name="multiplayerUser">The <see cref="MultiplayerRoomUser"/> to populate.</param>
protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false);
/// <summary>
/// Updates the local room settings with the given <see cref="MultiplayerRoomSettings"/>.
/// </summary>
/// <remarks>
/// This updates both the joined <see cref="MultiplayerRoom"/> and the respective API <see cref="Room"/>.
/// </remarks>
/// <param name="settings">The new <see cref="MultiplayerRoomSettings"/> to update from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the update.</param>
private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() =>
{
if (Room == null)
return;
Debug.Assert(apiRoom != null);
// Update a few properties of the room instantaneously.
Room.Settings = settings;
apiRoom.Name.Value = Room.Settings.Name;
// The current item update is delayed until an online beatmap lookup (below) succeeds.
// In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here.
CurrentMatchPlayingItem.Value = null;
RoomUpdated?.Invoke();
GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() =>
{
if (cancellationToken.IsCancellationRequested)
return;
updatePlaylist(settings, set.Result);
}), TaskContinuationOptions.OnlyOnRanToCompletion);
}, cancellationToken);
private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet)
{
if (Room == null || !Room.Settings.Equals(settings))
return;
Debug.Assert(apiRoom != null);
var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
beatmap.MD5Hash = settings.BeatmapChecksum;
var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance();
var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset));
var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset));
// Try to retrieve the existing playlist item from the API room.
var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId);
if (playlistItem != null)
updateItem(playlistItem);
else
{
// An existing playlist item does not exist, so append a new one.
updateItem(playlistItem = new PlaylistItem());
apiRoom.Playlist.Add(playlistItem);
}
CurrentMatchPlayingItem.Value = playlistItem;
void updateItem(PlaylistItem item)
{
item.ID = settings.PlaylistItemId;
item.Beatmap.Value = beatmap;
item.Ruleset.Value = ruleset.RulesetInfo;
item.RequiredMods.Clear();
item.RequiredMods.AddRange(mods);
item.AllowedMods.Clear();
item.AllowedMods.AddRange(allowedMods);
}
}
/// <summary>
/// Retrieves a <see cref="BeatmapSetInfo"/> from an online source.
/// </summary>
/// <param name="beatmapId">The beatmap set ID.</param>
/// <param name="cancellationToken">A token to cancel the request.</param>
/// <returns>The <see cref="BeatmapSetInfo"/> retrieval task.</returns>
protected abstract Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default);
/// <summary>
/// For the provided user ID, update whether the user is included in <see cref="CurrentMatchPlayingUserIds"/>.
/// </summary>
/// <param name="userId">The user's ID.</param>
/// <param name="state">The new state of the user.</param>
private void updateUserPlayingState(int userId, MultiplayerUserState state)
{
bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId);
bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay;
if (isPlaying == wasPlaying)
return;
if (isPlaying)
CurrentMatchPlayingUserIds.Add(userId);
else
CurrentMatchPlayingUserIds.Remove(userId);
}
private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
{
var tcs = new TaskCompletionSource<bool>();
Scheduler.Add(() =>
{
if (cancellationToken.IsCancellationRequested)
{
tcs.SetCanceled();
return;
}
try
{
action();
tcs.SetResult(true);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
return tcs.Task;
}
}
}

View File

@ -0,0 +1,89 @@
// 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.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Online.API;
namespace osu.Game.Online.Spectator
{
public class OnlineSpectatorClient : SpectatorClient
{
private readonly string endpoint;
private IHubClientConnector? connector;
public override IBindable<bool> IsConnected { get; } = new BindableBool();
private HubConnection? connection => connector?.CurrentConnection;
public OnlineSpectatorClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.SpectatorEndpointUrl;
}
[BackgroundDependencyLoader]
private void load(IAPIProvider api)
{
connector = api.GetHubConnector(nameof(SpectatorClient), endpoint);
if (connector != null)
{
connector.ConfigureConnection = connection =>
{
// until strong typed client support is added, each method must be manually bound
// (see https://github.com/dotnet/aspnetcore/issues/15198)
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying);
connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
};
IsConnected.BindTo(connector.IsConnected);
}
}
protected override Task BeginPlayingInternal(SpectatorState state)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), state);
}
protected override Task SendFramesInternal(FrameDataBundle data)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data);
}
protected override Task EndPlayingInternal(SpectatorState state)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), state);
}
protected override Task WatchUserInternal(int userId)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId);
}
protected override Task StopWatchingUserInternal(int userId)
{
if (!IsConnected.Value)
return Task.CompletedTask;
return connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId);
}
}
}

View File

@ -0,0 +1,261 @@
// 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 System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
namespace osu.Game.Online.Spectator
{
public abstract class SpectatorClient : Component, ISpectatorClient
{
/// <summary>
/// The maximum milliseconds between frame bundle sends.
/// </summary>
public const double TIME_BETWEEN_SENDS = 200;
/// <summary>
/// Whether the <see cref="SpectatorClient"/> is currently connected.
/// This is NOT thread safe and usage should be scheduled.
/// </summary>
public abstract IBindable<bool> IsConnected { get; }
private readonly List<int> watchingUsers = new List<int>();
public IBindableList<int> PlayingUsers => playingUsers;
private readonly BindableList<int> playingUsers = new BindableList<int>();
public IBindableDictionary<int, SpectatorState> PlayingUserStates => playingUserStates;
private readonly BindableDictionary<int, SpectatorState> playingUserStates = new BindableDictionary<int, SpectatorState>();
private IBeatmap? currentBeatmap;
private Score? currentScore;
[Resolved]
private IBindable<RulesetInfo> currentRuleset { get; set; } = null!;
[Resolved]
private IBindable<IReadOnlyList<Mod>> currentMods { get; set; } = null!;
private readonly SpectatorState currentState = new SpectatorState();
/// <summary>
/// Whether the local user is playing.
/// </summary>
protected bool IsPlaying { get; private set; }
/// <summary>
/// Called whenever new frames arrive from the server.
/// </summary>
public 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;
/// <summary>
/// Called whenever a user finishes a play session.
/// </summary>
public event Action<int, SpectatorState>? OnUserFinishedPlaying;
[BackgroundDependencyLoader]
private void load()
{
IsConnected.BindValueChanged(connected => Schedule(() =>
{
if (connected.NewValue)
{
// get all the users that were previously being watched
int[] users = watchingUsers.ToArray();
watchingUsers.Clear();
// resubscribe to watched users.
foreach (var userId in users)
WatchUser(userId);
// re-send state in case it wasn't received
if (IsPlaying)
BeginPlayingInternal(currentState);
}
else
{
playingUsers.Clear();
playingUserStates.Clear();
}
}), true);
}
Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state)
{
Schedule(() =>
{
if (!playingUsers.Contains(userId))
playingUsers.Add(userId);
// UserBeganPlaying() is called by the server regardless of whether the local user is watching the remote user, and is called a further time when the remote user is watched.
// This may be a temporary thing (see: https://github.com/ppy/osu-server-spectator/blob/2273778e02cfdb4a9c6a934f2a46a8459cb5d29c/osu.Server.Spectator/Hubs/SpectatorHub.cs#L28-L29).
// We don't want the user states to update unless the player is being watched, otherwise calling BindUserBeganPlaying() can lead to double invocations.
if (watchingUsers.Contains(userId))
playingUserStates[userId] = state;
OnUserBeganPlaying?.Invoke(userId, state);
});
return Task.CompletedTask;
}
Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
{
Schedule(() =>
{
playingUsers.Remove(userId);
playingUserStates.Remove(userId);
OnUserFinishedPlaying?.Invoke(userId, state);
});
return Task.CompletedTask;
}
Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
{
Schedule(() => OnNewFrames?.Invoke(userId, data));
return Task.CompletedTask;
}
public void BeginPlaying(GameplayBeatmap beatmap, Score score)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
if (IsPlaying)
throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
IsPlaying = true;
// transfer state at point of beginning play
currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID;
currentState.RulesetID = currentRuleset.Value.ID;
currentState.Mods = currentMods.Value.Select(m => new APIMod(m));
currentBeatmap = beatmap.PlayableBeatmap;
currentScore = score;
BeginPlayingInternal(currentState);
}
public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data);
public void EndPlaying()
{
// This method is most commonly called via Dispose(), which is asynchronous.
// Todo: This should not be a thing, but requires framework changes.
Schedule(() =>
{
if (!IsPlaying)
return;
IsPlaying = false;
currentBeatmap = null;
EndPlayingInternal(currentState);
});
}
public void WatchUser(int userId)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
if (watchingUsers.Contains(userId))
return;
watchingUsers.Add(userId);
WatchUserInternal(userId);
}
public void StopWatchingUser(int userId)
{
// This method is most commonly called via Dispose(), which is asynchronous.
// Todo: This should not be a thing, but requires framework changes.
Schedule(() =>
{
watchingUsers.Remove(userId);
playingUserStates.Remove(userId);
StopWatchingUserInternal(userId);
});
}
protected abstract Task BeginPlayingInternal(SpectatorState state);
protected abstract Task SendFramesInternal(FrameDataBundle data);
protected abstract Task EndPlayingInternal(SpectatorState state);
protected abstract Task WatchUserInternal(int userId);
protected abstract Task StopWatchingUserInternal(int userId);
private readonly Queue<LegacyReplayFrame> pendingFrames = new Queue<LegacyReplayFrame>();
private double lastSendTime;
private Task? lastSend;
private const int max_pending_frames = 30;
protected override void Update()
{
base.Update();
if (pendingFrames.Count > 0 && Time.Current - lastSendTime > TIME_BETWEEN_SENDS)
purgePendingFrames();
}
public void HandleFrame(ReplayFrame frame)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
if (frame is IConvertibleReplayFrame convertible)
pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap));
if (pendingFrames.Count > max_pending_frames)
purgePendingFrames();
}
private void purgePendingFrames()
{
if (lastSend?.IsCompleted == false)
return;
var frames = pendingFrames.ToArray();
pendingFrames.Clear();
Debug.Assert(currentScore != null);
SendFrames(new FrameDataBundle(currentScore.ScoreInfo, frames));
lastSendTime = Time.Current;
}
}
}

View File

@ -1,323 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
namespace osu.Game.Online.Spectator
{
public class SpectatorStreamingClient : Component, ISpectatorClient
{
/// <summary>
/// The maximum milliseconds between frame bundle sends.
/// </summary>
public const double TIME_BETWEEN_SENDS = 200;
private readonly string endpoint;
[CanBeNull]
private IHubClientConnector connector;
private readonly IBindable<bool> isConnected = new BindableBool();
private HubConnection connection => connector?.CurrentConnection;
private readonly List<int> watchingUsers = new List<int>();
private readonly object userLock = new object();
public IBindableList<int> PlayingUsers => playingUsers;
private readonly BindableList<int> playingUsers = new BindableList<int>();
private readonly Dictionary<int, SpectatorState> playingUserStates = new Dictionary<int, SpectatorState>();
[CanBeNull]
private IBeatmap currentBeatmap;
[CanBeNull]
private Score currentScore;
[Resolved]
private IBindable<RulesetInfo> currentRuleset { get; set; }
[Resolved]
private IBindable<IReadOnlyList<Mod>> currentMods { get; set; }
private readonly SpectatorState currentState = new SpectatorState();
private bool isPlaying;
/// <summary>
/// Called whenever new frames arrive from the server.
/// </summary>
public 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;
/// <summary>
/// Called whenever a user finishes a play session.
/// </summary>
public event Action<int, SpectatorState> OnUserFinishedPlaying;
public SpectatorStreamingClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.SpectatorEndpointUrl;
}
[BackgroundDependencyLoader]
private void load(IAPIProvider api)
{
connector = api.GetHubConnector(nameof(SpectatorStreamingClient), endpoint);
if (connector != null)
{
connector.ConfigureConnection = connection =>
{
// until strong typed client support is added, each method must be manually bound
// (see https://github.com/dotnet/aspnetcore/issues/15198)
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying);
connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
};
isConnected.BindTo(connector.IsConnected);
isConnected.BindValueChanged(connected =>
{
if (connected.NewValue)
{
// get all the users that were previously being watched
int[] users;
lock (userLock)
{
users = watchingUsers.ToArray();
watchingUsers.Clear();
}
// resubscribe to watched users.
foreach (var userId in users)
WatchUser(userId);
// re-send state in case it wasn't received
if (isPlaying)
beginPlaying();
}
else
{
lock (userLock)
{
playingUsers.Clear();
playingUserStates.Clear();
}
}
}, true);
}
}
Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state)
{
lock (userLock)
{
if (!playingUsers.Contains(userId))
playingUsers.Add(userId);
// UserBeganPlaying() is called by the server regardless of whether the local user is watching the remote user, and is called a further time when the remote user is watched.
// This may be a temporary thing (see: https://github.com/ppy/osu-server-spectator/blob/2273778e02cfdb4a9c6a934f2a46a8459cb5d29c/osu.Server.Spectator/Hubs/SpectatorHub.cs#L28-L29).
// We don't want the user states to update unless the player is being watched, otherwise calling BindUserBeganPlaying() can lead to double invocations.
if (watchingUsers.Contains(userId))
playingUserStates[userId] = state;
}
OnUserBeganPlaying?.Invoke(userId, state);
return Task.CompletedTask;
}
Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
{
lock (userLock)
{
playingUsers.Remove(userId);
playingUserStates.Remove(userId);
}
OnUserFinishedPlaying?.Invoke(userId, state);
return Task.CompletedTask;
}
Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
{
OnNewFrames?.Invoke(userId, data);
return Task.CompletedTask;
}
public void BeginPlaying(GameplayBeatmap beatmap, Score score)
{
if (isPlaying)
throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
isPlaying = true;
// transfer state at point of beginning play
currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID;
currentState.RulesetID = currentRuleset.Value.ID;
currentState.Mods = currentMods.Value.Select(m => new APIMod(m));
currentBeatmap = beatmap.PlayableBeatmap;
currentScore = score;
beginPlaying();
}
private void beginPlaying()
{
Debug.Assert(isPlaying);
if (!isConnected.Value) return;
connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState);
}
public void SendFrames(FrameDataBundle data)
{
if (!isConnected.Value) return;
lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data);
}
public void EndPlaying()
{
isPlaying = false;
currentBeatmap = null;
if (!isConnected.Value) return;
connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
}
public virtual void WatchUser(int userId)
{
lock (userLock)
{
if (watchingUsers.Contains(userId))
return;
watchingUsers.Add(userId);
if (!isConnected.Value)
return;
}
connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId);
}
public virtual void StopWatchingUser(int userId)
{
lock (userLock)
{
watchingUsers.Remove(userId);
if (!isConnected.Value)
return;
}
connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId);
}
private readonly Queue<LegacyReplayFrame> pendingFrames = new Queue<LegacyReplayFrame>();
private double lastSendTime;
private Task lastSend;
private const int max_pending_frames = 30;
protected override void Update()
{
base.Update();
if (pendingFrames.Count > 0 && Time.Current - lastSendTime > TIME_BETWEEN_SENDS)
purgePendingFrames();
}
public void HandleFrame(ReplayFrame frame)
{
if (frame is IConvertibleReplayFrame convertible)
pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap));
if (pendingFrames.Count > max_pending_frames)
purgePendingFrames();
}
private void purgePendingFrames()
{
if (lastSend?.IsCompleted == false)
return;
var frames = pendingFrames.ToArray();
pendingFrames.Clear();
Debug.Assert(currentScore != null);
SendFrames(new FrameDataBundle(currentScore.ScoreInfo, frames));
lastSendTime = Time.Current;
}
/// <summary>
/// Attempts to retrieve the <see cref="SpectatorState"/> for a currently-playing user.
/// </summary>
/// <param name="userId">The user.</param>
/// <param name="state">The current <see cref="SpectatorState"/> for the user, if they're playing. <c>null</c> if the user is not playing.</param>
/// <returns><c>true</c> if successful (the user is playing), <c>false</c> otherwise.</returns>
public bool TryGetPlayingUserState(int userId, out SpectatorState state)
{
lock (userLock)
return playingUserStates.TryGetValue(userId, out state);
}
/// <summary>
/// Bind an action to <see cref="OnUserBeganPlaying"/> with the option of running the bound action once immediately.
/// </summary>
/// <param name="callback">The action to perform when a user begins playing.</param>
/// <param name="runOnceImmediately">Whether the action provided in <paramref name="callback"/> should be run once immediately for all users currently playing.</param>
public void BindUserBeganPlaying(Action<int, SpectatorState> callback, bool runOnceImmediately = false)
{
// The lock is taken before the event is subscribed to to prevent doubling of events.
lock (userLock)
{
OnUserBeganPlaying += callback;
if (!runOnceImmediately)
return;
foreach (var (userId, state) in playingUserStates)
callback(userId, state);
}
}
}
}

View File

@ -51,6 +51,7 @@ using osu.Game.Utils;
using LogLevel = osu.Framework.Logging.LogLevel;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.Localisation;
using osu.Game.Skinning.Editor;
namespace osu.Game
@ -100,6 +101,9 @@ namespace osu.Game
[Cached]
private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender();
[Cached]
private readonly StableImportManager stableImportManager = new StableImportManager();
[Cached]
private readonly ScreenshotManager screenshotManager = new ScreenshotManager();
@ -559,6 +563,12 @@ namespace osu.Game
{
base.LoadComplete();
foreach (var language in Enum.GetValues(typeof(Language)).OfType<Language>())
{
var cultureCode = language.ToString();
Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode));
}
// The next time this is updated is in UpdateAfterChildren, which occurs too late and results
// in the cursor being shown for a few frames during the intro.
// This prevents the cursor from showing until we have a screen with CursorVisible = true
@ -566,14 +576,11 @@ namespace osu.Game
// todo: all archive managers should be able to be looped here.
SkinManager.PostNotification = n => notifications.Post(n);
SkinManager.GetStableStorage = GetStorageForStableInstall;
BeatmapManager.PostNotification = n => notifications.Post(n);
BeatmapManager.GetStableStorage = GetStorageForStableInstall;
BeatmapManager.PresentImport = items => PresentBeatmap(items.First());
ScoreManager.PostNotification = n => notifications.Post(n);
ScoreManager.GetStableStorage = GetStorageForStableInstall;
ScoreManager.PresentImport = items => PresentScore(items.First());
// make config aware of how to lookup skins for on-screen display purposes.
@ -690,10 +697,10 @@ namespace osu.Game
loadComponentSingleFile(new CollectionManager(Storage)
{
PostNotification = n => notifications.Post(n),
GetStableStorage = GetStorageForStableInstall
}, Add, true);
loadComponentSingleFile(difficultyRecommender, Add);
loadComponentSingleFile(stableImportManager, Add);
loadComponentSingleFile(screenshotManager, Add);

View File

@ -85,8 +85,8 @@ namespace osu.Game
protected IAPIProvider API;
private SpectatorStreamingClient spectatorStreaming;
private StatefulMultiplayerClient multiplayerClient;
private SpectatorClient spectatorClient;
private MultiplayerClient multiplayerClient;
protected MenuCursorContainer MenuCursorContainer;
@ -240,8 +240,8 @@ namespace osu.Game
dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash));
dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints));
dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints));
dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints));
dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints));
var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures);
@ -313,7 +313,7 @@ namespace osu.Game
// add api components to hierarchy.
if (API is APIAccess apiAccess)
AddInternal(apiAccess);
AddInternal(spectatorStreaming);
AddInternal(spectatorClient);
AddInternal(multiplayerClient);
AddInternal(RulesetConfigCache);

View File

@ -23,14 +23,17 @@ namespace osu.Game.Overlays.AccountCreation
private OsuTextFlowContainer multiAccountExplanationText;
private LinkFlowContainer furtherAssistance;
[Resolved(CanBeNull = true)]
[Resolved(canBeNull: true)]
private IAPIProvider api { get; set; }
[Resolved(canBeNull: true)]
private OsuGame game { get; set; }
private const string help_centre_url = "/help/wiki/Help_Centre#login";
public override void OnEntering(IScreen last)
{
if (string.IsNullOrEmpty(api?.ProvidedUsername))
if (string.IsNullOrEmpty(api?.ProvidedUsername) || game?.UseDevelopmentServer == true)
{
this.FadeOut();
this.Push(new ScreenEntry());
@ -41,7 +44,7 @@ namespace osu.Game.Overlays.AccountCreation
}
[BackgroundDependencyLoader(true)]
private void load(OsuColour colours, OsuGame game, TextureStore textures)
private void load(OsuColour colours, TextureStore textures)
{
if (string.IsNullOrEmpty(api?.ProvidedUsername))
return;

View File

@ -90,7 +90,7 @@ namespace osu.Game.Overlays.BeatmapListing
{
RelativeSizeAxes = Axes.Both,
Masking = true,
Child = beatmapCover = new UpdateableBeatmapSetCover
Child = beatmapCover = new TopSearchBeatmapSetCover
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
@ -184,5 +184,10 @@ namespace osu.Game.Overlays.BeatmapListing
return true;
}
}
private class TopSearchBeatmapSetCover : UpdateableBeatmapSetCover
{
protected override bool TransformImmediately => true;
}
}
}

View File

@ -26,7 +26,10 @@ namespace osu.Game.Overlays
AccentColour = colourProvider.Light2;
}
protected override TabItem<string> CreateTabItem(string value) => new ControlTabItem(value);
protected override TabItem<string> CreateTabItem(string value) => new ControlTabItem(value)
{
AccentColour = AccentColour,
};
private class ControlTabItem : BreadcrumbTabItem
{

View File

@ -24,6 +24,8 @@ using osu.Game.Overlays.Chat.Tabs;
using osuTK.Input;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Localisation;
using osu.Game.Localisation;
using osu.Game.Online;
namespace osu.Game.Overlays
@ -31,8 +33,8 @@ namespace osu.Game.Overlays
public class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent
{
public string IconTexture => "Icons/Hexacons/messaging";
public string Title => "chat";
public string Description => "join the real-time discussion";
public LocalisableString Title => ChatStrings.HeaderTitle;
public LocalisableString Description => ChatStrings.HeaderDescription;
private const float textbox_height = 60;
private const float channel_selection_min_height = 0.3f;

View File

@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Dashboard
private FillFlowContainer<PlayingUserPanel> userFlow;
[Resolved]
private SpectatorStreamingClient spectatorStreaming { get; set; }
private SpectatorClient spectatorClient { get; set; }
[BackgroundDependencyLoader]
private void load()
@ -52,7 +52,7 @@ namespace osu.Game.Overlays.Dashboard
{
base.LoadComplete();
playingUsers.BindTo(spectatorStreaming.PlayingUsers);
playingUsers.BindTo(spectatorClient.PlayingUsers);
playingUsers.BindCollectionChanged(onUsersChanged, true);
}

View File

@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Dialog
},
new PopupDialogCancelButton
{
Text = @"Cancel",
Text = Localisation.CommonStrings.Cancel,
Action = onCancel
},
};

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API;
using osuTK.Graphics;
@ -18,8 +19,8 @@ namespace osu.Game.Overlays
where T : OverlayHeader
{
public virtual string IconTexture => Header.Title.IconTexture ?? string.Empty;
public virtual string Title => Header.Title.Title ?? string.Empty;
public virtual string Description => Header.Title.Description ?? string.Empty;
public virtual LocalisableString Title => Header.Title.Title;
public virtual LocalisableString Description => Header.Title.Description;
public T Header { get; }

View File

@ -1,14 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Overlays
{
public interface INamedOverlayComponent
{
string IconTexture { get; }
string Title { get; }
LocalisableString Title { get; }
string Description { get; }
LocalisableString Description { get; }
}
}

View File

@ -16,6 +16,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
@ -445,6 +446,9 @@ namespace osu.Game.Overlays.KeyBinding
public void UpdateKeyCombination(KeyCombination newCombination)
{
if ((KeyBinding as DatabasedKeyBinding)?.RulesetID != null && !KeyBindingStore.CheckValidForGameplay(newCombination))
return;
KeyBinding.KeyCombination = newCombination;
Text.Text = KeyBinding.KeyCombination.ReadableString();
}

View File

@ -96,7 +96,8 @@ namespace osu.Game.Overlays.Mods
Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774");
Waves.FourthWaveColour = Color4Extensions.FromHex(@"003a4e");
RelativeSizeAxes = Axes.Both;
RelativeSizeAxes = Axes.X;
Height = HEIGHT;
Padding = new MarginPadding { Horizontal = -OsuScreen.HORIZONTAL_OVERFLOW_PADDING };

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
@ -9,12 +10,18 @@ using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
namespace osu.Game.Overlays.News.Displays
{
public class FrontPageDisplay : CompositeDrawable
/// <summary>
/// Lists articles in a vertical flow for a specified year.
/// </summary>
public class ArticleListing : CompositeDrawable
{
public Action<APINewsSidebar> SidebarMetadataUpdated;
[Resolved]
private IAPIProvider api { get; set; }
@ -24,6 +31,17 @@ namespace osu.Game.Overlays.News.Displays
private GetNewsRequest request;
private Cursor lastCursor;
private readonly int? year;
/// <summary>
/// Instantiate a listing for the specified year.
/// </summary>
/// <param name="year">The year to load articles from. If null, will show the most recent articles.</param>
public ArticleListing(int? year = null)
{
this.year = year;
}
[BackgroundDependencyLoader]
private void load()
{
@ -74,7 +92,7 @@ namespace osu.Game.Overlays.News.Displays
{
request?.Cancel();
request = new GetNewsRequest(lastCursor);
request = new GetNewsRequest(year, lastCursor);
request.Success += response => Schedule(() => onSuccess(response));
api.PerformAsync(request);
}
@ -85,22 +103,19 @@ namespace osu.Game.Overlays.News.Displays
{
cancellationToken?.Cancel();
// only needs to be updated on the initial load, as the content won't change during pagination.
if (lastCursor == null)
SidebarMetadataUpdated?.Invoke(response.SidebarMetadata);
// store cursor for next pagination request.
lastCursor = response.Cursor;
var flow = new FillFlowContainer<NewsCard>
LoadComponentsAsync(response.NewsPosts.Select(p => new NewsCard(p)).ToList(), loaded =>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 10),
Children = response.NewsPosts.Select(p => new NewsCard(p)).ToList()
};
content.AddRange(loaded);
LoadComponentAsync(flow, loaded =>
{
content.Add(loaded);
showMore.IsLoading = false;
showMore.Alpha = lastCursor == null ? 0 : 1;
showMore.Alpha = response.Cursor != null ? 1 : 0;
}, (cancellationToken = new CancellationTokenSource()).Token);
}

View File

@ -19,13 +19,18 @@ namespace osu.Game.Overlays.News
{
TabControl.AddItem(front_page_string);
article.BindValueChanged(onArticleChanged, true);
}
protected override void LoadComplete()
{
base.LoadComplete();
Current.BindValueChanged(e =>
{
if (e.NewValue == front_page_string)
ShowFrontPage?.Invoke();
});
article.BindValueChanged(onArticleChanged, true);
}
public void SetFrontPage() => article.Value = null;

View File

@ -9,6 +9,7 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Framework.Graphics.Shapes;
using osuTK;
using System.Linq;
using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.News.Sidebar
{
@ -31,30 +32,55 @@ namespace osu.Game.Overlays.News.Sidebar
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4
},
new Box
{
RelativeSizeAxes = Axes.Y,
Width = OsuScrollContainer.SCROLL_BAR_HEIGHT,
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Colour = colourProvider.Background3,
Alpha = 0.5f
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin
Child = new OsuScrollContainer
{
Vertical = 20,
Left = 50,
Right = 30
},
Child = new FillFlowContainer
{
Direction = FillDirection.Vertical,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0, 20),
Children = new Drawable[]
RelativeSizeAxes = Axes.Both,
Child = new Container
{
new YearsPanel(),
monthsFlow = new FillFlowContainer<MonthSection>
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Right = 3 }, // Addeded 3px back
Child = new Container
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 10)
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding
{
Vertical = 20,
Left = 50,
Right = 30
},
Child = new FillFlowContainer
{
Direction = FillDirection.Vertical,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0, 20),
Children = new Drawable[]
{
new YearsPanel(),
monthsFlow = new FillFlowContainer<MonthSection>
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 10)
}
}
}
}
}
}

View File

@ -81,6 +81,9 @@ namespace osu.Game.Overlays.News.Sidebar
{
public int Year { get; }
[Resolved(canBeNull: true)]
private NewsOverlay overlay { get; set; }
private readonly bool isCurrent;
public YearButton(int year, bool isCurrent)
@ -106,7 +109,11 @@ namespace osu.Game.Overlays.News.Sidebar
{
IdleColour = isCurrent ? Color4.White : colourProvider.Light2;
HoverColour = isCurrent ? Color4.White : colourProvider.Light1;
Action = () => { }; // Avoid button being disabled since there's no proper action assigned.
Action = () =>
{
if (!isCurrent)
overlay?.ShowYear(Year);
};
}
}
}

View File

@ -1,11 +1,14 @@
// 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.Threading;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Overlays.News;
using osu.Game.Overlays.News.Displays;
using osu.Game.Overlays.News.Sidebar;
namespace osu.Game.Overlays
{
@ -13,9 +16,48 @@ namespace osu.Game.Overlays
{
private readonly Bindable<string> article = new Bindable<string>(null);
private readonly Container sidebarContainer;
private readonly NewsSidebar sidebar;
private readonly Container content;
private CancellationTokenSource cancellationToken;
private bool displayUpdateRequired = true;
public NewsOverlay()
: base(OverlayColourScheme.Purple, false)
{
Child = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension()
},
Content = new[]
{
new Drawable[]
{
sidebarContainer = new Container
{
AutoSizeAxes = Axes.X,
Child = sidebar = new NewsSidebar()
},
content = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}
}
}
};
}
protected override void LoadComplete()
@ -26,12 +68,7 @@ namespace osu.Game.Overlays
article.BindValueChanged(onArticleChanged);
}
protected override NewsHeader CreateHeader() => new NewsHeader
{
ShowFrontPage = ShowFrontPage
};
private bool displayUpdateRequired = true;
protected override NewsHeader CreateHeader() => new NewsHeader { ShowFrontPage = ShowFrontPage };
protected override void PopIn()
{
@ -56,38 +93,69 @@ namespace osu.Game.Overlays
Show();
}
public void ShowYear(int year)
{
loadFrontPage(year);
Show();
}
public void ShowArticle(string slug)
{
article.Value = slug;
Show();
}
private CancellationTokenSource cancellationToken;
private void onArticleChanged(ValueChangedEvent<string> e)
{
cancellationToken?.Cancel();
Loading.Show();
if (e.NewValue == null)
{
Header.SetFrontPage();
LoadDisplay(new FrontPageDisplay());
return;
}
Header.SetArticle(e.NewValue);
LoadDisplay(Empty());
}
protected void LoadDisplay(Drawable display)
{
ScrollFlow.ScrollToStart();
LoadComponentAsync(display, loaded =>
LoadComponentAsync(display, loaded => content.Child = loaded, (cancellationToken = new CancellationTokenSource()).Token);
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
sidebarContainer.Height = DrawHeight;
sidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0));
}
private void onArticleChanged(ValueChangedEvent<string> article)
{
if (article.NewValue == null)
loadFrontPage();
else
loadArticle(article.NewValue);
}
private void loadFrontPage(int? year = null)
{
beginLoading();
Header.SetFrontPage();
var page = new ArticleListing(year);
page.SidebarMetadataUpdated += metadata => Schedule(() =>
{
Child = loaded;
sidebar.Metadata.Value = metadata;
Loading.Hide();
}, (cancellationToken = new CancellationTokenSource()).Token);
});
LoadDisplay(page);
}
private void loadArticle(string article)
{
beginLoading();
Header.SetArticle(article);
// Temporary, should be handled by ArticleDisplay later
LoadDisplay(Empty());
Loading.Hide();
}
private void beginLoading()
{
cancellationToken?.Cancel();
Loading.Show();
}
protected override void Dispose(bool isDisposing)

View File

@ -11,16 +11,18 @@ using osu.Game.Graphics.Containers;
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Localisation;
namespace osu.Game.Overlays
{
public class NotificationOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent
{
public string IconTexture => "Icons/Hexacons/notification";
public string Title => "notifications";
public string Description => "waiting for 'ya";
public LocalisableString Title => NotificationsStrings.HeaderTitle;
public LocalisableString Description => NotificationsStrings.HeaderDescription;
private const float width = 320;

View File

@ -19,6 +19,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Overlays.Music;
using osuTK;
using osuTK.Graphics;
@ -28,8 +29,8 @@ namespace osu.Game.Overlays
public class NowPlayingOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent
{
public string IconTexture => "Icons/Hexacons/music";
public string Title => "now playing";
public string Description => "manage the currently playing track";
public LocalisableString Title => NowPlayingStrings.HeaderTitle;
public LocalisableString Description => NowPlayingStrings.HeaderDescription;
private const float player_height = 130;
private const float transition_length = 800;

View File

@ -6,6 +6,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
@ -19,15 +20,15 @@ namespace osu.Game.Overlays
private readonly OsuSpriteText titleText;
private readonly Container icon;
private string title;
private LocalisableString title;
public string Title
public LocalisableString Title
{
get => title;
protected set => titleText.Text = title = value;
}
public string Description { get; protected set; }
public LocalisableString Description { get; protected set; }
private string iconTexture;

View File

@ -41,12 +41,11 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
{
AddRangeInternal(new Drawable[]
{
new UpdateableBeatmapSetCover
new UpdateableBeatmapSetCover(BeatmapSetCoverType.List)
{
RelativeSizeAxes = Axes.Y,
Width = cover_width,
BeatmapSet = beatmap.BeatmapSet,
CoverType = BeatmapSetCoverType.List,
},
new Container
{

View File

@ -1,27 +1,45 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Graphics;
using osu.Game.Localisation;
namespace osu.Game.Overlays.Settings.Sections.General
{
public class LanguageSettings : SettingsSubsection
{
private SettingsDropdown<Language> languageSelection;
private Bindable<string> frameworkLocale;
protected override string Header => "Language";
[BackgroundDependencyLoader]
private void load(FrameworkConfigManager frameworkConfig)
{
frameworkLocale = frameworkConfig.GetBindable<string>(FrameworkSetting.Locale);
Children = new Drawable[]
{
languageSelection = new SettingsEnumDropdown<Language>
{
LabelText = "Language",
},
new SettingsCheckbox
{
LabelText = "Prefer metadata in original language",
Current = frameworkConfig.GetBindable<bool>(FrameworkSetting.ShowUnicode)
},
};
if (!Enum.TryParse<Language>(frameworkLocale.Value, out var locale))
locale = Language.en;
languageSelection.Current.Value = locale;
languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToString());
}
}
}

View File

@ -11,9 +11,9 @@ using osuTK;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Framework.Screens;
using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.Settings.Sections.Maintenance
{
@ -69,20 +69,24 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
RelativeSizeAxes = Axes.Both,
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(),
new Dimension(GridSizeMode.Relative, 0.8f),
new Dimension(),
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
new Drawable[]
{
new OsuSpriteText
new OsuTextFlowContainer(cp =>
{
Text = HeaderText,
Font = OsuFont.Default.With(size: 40),
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
cp.Font = OsuFont.Default.With(size: 24);
})
{
Text = HeaderText.ToString(),
TextAnchor = Anchor.TopCentre,
Margin = new MarginPadding(10),
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
}
},
new Drawable[]
@ -99,6 +103,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 300,
Margin = new MarginPadding(10),
Text = "Select directory",
Action = () => OnSelection(directorySelector.CurrentPath.Value)
},

View File

@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Scoring;
using osu.Game.Skinning;
@ -29,9 +30,9 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
private TriangleButton undeleteButton;
[BackgroundDependencyLoader(permitNulls: true)]
private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, DialogOverlay dialogOverlay)
private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] StableImportManager stableImportManager, DialogOverlay dialogOverlay)
{
if (beatmaps.SupportsImportFromStable)
if (stableImportManager?.SupportsImportFromStable == true)
{
Add(importBeatmapsButton = new SettingsButton
{
@ -39,7 +40,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Action = () =>
{
importBeatmapsButton.Enabled.Value = false;
beatmaps.ImportFromStableAsync().ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true));
stableImportManager.ImportFromStableAsync(StableContent.Beatmaps).ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true));
}
});
}
@ -57,7 +58,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
}
});
if (scores.SupportsImportFromStable)
if (stableImportManager?.SupportsImportFromStable == true)
{
Add(importScoresButton = new SettingsButton
{
@ -65,7 +66,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Action = () =>
{
importScoresButton.Enabled.Value = false;
scores.ImportFromStableAsync().ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true));
stableImportManager.ImportFromStableAsync(StableContent.Scores).ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true));
}
});
}
@ -83,7 +84,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
}
});
if (skins.SupportsImportFromStable)
if (stableImportManager?.SupportsImportFromStable == true)
{
Add(importSkinsButton = new SettingsButton
{
@ -91,7 +92,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Action = () =>
{
importSkinsButton.Enabled.Value = false;
skins.ImportFromStableAsync().ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true));
stableImportManager.ImportFromStableAsync(StableContent.Skins).ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true));
}
});
}
@ -111,7 +112,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
if (collectionManager != null)
{
if (collectionManager.SupportsImportFromStable)
if (stableImportManager?.SupportsImportFromStable == true)
{
Add(importCollectionsButton = new SettingsButton
{
@ -119,7 +120,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Action = () =>
{
importCollectionsButton.Enabled.Value = false;
collectionManager.ImportFromStableAsync().ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true));
stableImportManager.ImportFromStableAsync(StableContent.Collections).ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true));
}
});
}

View File

@ -0,0 +1,38 @@
// 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.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Screens;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Overlays.Settings.Sections.Maintenance
{
public class StableDirectoryLocationDialog : PopupDialog
{
[Resolved]
private OsuGame game { get; set; }
public StableDirectoryLocationDialog(TaskCompletionSource<string> taskCompletionSource)
{
HeaderText = "Failed to automatically locate an osu!stable installation.";
BodyText = "An existing install could not be located. If you know where it is, you can help locate it.";
Icon = FontAwesome.Solid.QuestionCircle;
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
{
Text = "Sure! I know where it is located!",
Action = () => Schedule(() => game.PerformFromScreen(screen => screen.Push(new StableDirectorySelectScreen(taskCompletionSource))))
},
new PopupDialogCancelButton
{
Text = "Actually I don't have osu!stable installed.",
Action = () => taskCompletionSource.TrySetCanceled()
}
};
}
}
}

View File

@ -0,0 +1,39 @@
// 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.IO;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Localisation;
using osu.Framework.Screens;
namespace osu.Game.Overlays.Settings.Sections.Maintenance
{
public class StableDirectorySelectScreen : DirectorySelectScreen
{
private readonly TaskCompletionSource<string> taskCompletionSource;
protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled;
protected override bool IsValidDirectory(DirectoryInfo info) => info?.GetFiles("osu!.*.cfg").Any() ?? false;
public override LocalisableString HeaderText => "Please select your osu!stable install location";
public StableDirectorySelectScreen(TaskCompletionSource<string> taskCompletionSource)
{
this.taskCompletionSource = taskCompletionSource;
}
protected override void OnSelection(DirectoryInfo directory)
{
taskCompletionSource.TrySetResult(directory.FullName);
this.Exit();
}
public override bool OnExiting(IScreen next)
{
taskCompletionSource.TrySetCanceled();
return base.OnExiting(next);
}
}
}

View File

@ -4,6 +4,7 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
@ -11,10 +12,10 @@ namespace osu.Game.Overlays.Settings
{
public class SettingsHeader : Container
{
private readonly string heading;
private readonly string subheading;
private readonly LocalisableString heading;
private readonly LocalisableString subheading;
public SettingsHeader(string heading, string subheading)
public SettingsHeader(LocalisableString heading, LocalisableString subheading)
{
this.heading = heading;
this.subheading = subheading;

View File

@ -10,14 +10,16 @@ using osuTK.Graphics;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Overlays
{
public class SettingsOverlay : SettingsPanel, INamedOverlayComponent
{
public string IconTexture => "Icons/Hexacons/settings";
public string Title => "settings";
public string Description => "change the way osu! behaves";
public LocalisableString Title => SettingsStrings.HeaderTitle;
public LocalisableString Description => SettingsStrings.HeaderDescription;
protected override IEnumerable<SettingsSection> CreateSections() => new SettingsSection[]
{

View File

@ -191,7 +191,7 @@ namespace osu.Game.Overlays
Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 };
}
protected class SettingsSectionsContainer : SectionsContainer<SettingsSection>
public class SettingsSectionsContainer : SectionsContainer<SettingsSection>
{
public SearchContainer<SettingsSection> SearchContainer;

View File

@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Edit
}
}
private void updateReplay() => drawableRuleset.RegenerateAutoplay();
private void updateReplay() => Scheduler.AddOnce(drawableRuleset.RegenerateAutoplay);
private void addHitObject(HitObject hitObject)
{

View File

@ -36,7 +36,8 @@ namespace osu.Game.Rulesets.Edit
[Resolved(canBeNull: true)]
protected EditorClock EditorClock { get; private set; }
private readonly IBindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>();
[Resolved]
private EditorBeatmap beatmap { get; set; }
private Bindable<double> startTimeBindable;
@ -58,10 +59,8 @@ namespace osu.Game.Rulesets.Edit
}
[BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap)
private void load()
{
this.beatmap.BindTo(beatmap);
startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy();
startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true);
}
@ -113,7 +112,7 @@ namespace osu.Game.Rulesets.Edit
/// Invokes <see cref="Objects.HitObject.ApplyDefaults(ControlPointInfo,BeatmapDifficulty, CancellationToken)"/>,
/// refreshing <see cref="Objects.HitObject.NestedHitObjects"/> and parameters for the <see cref="HitObject"/>.
/// </summary>
protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.Value.Beatmap.ControlPointInfo, beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty);
protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty);
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false;

View File

@ -172,7 +172,13 @@ namespace osu.Game.Rulesets.Objects.Drawables
base.AddInternal(Samples = new PausableSkinnableSound());
CurrentSkin = skinSource;
CurrentSkin.SourceChanged += onSkinSourceChanged;
CurrentSkin.SourceChanged += skinSourceChanged;
}
protected override void LoadAsyncComplete()
{
base.LoadAsyncComplete();
skinChanged();
}
protected override void LoadComplete()
@ -495,7 +501,9 @@ namespace osu.Game.Rulesets.Objects.Drawables
protected ISkinSource CurrentSkin { get; private set; }
private void onSkinSourceChanged() => Scheduler.AddOnce(() =>
private void skinSourceChanged() => Scheduler.AddOnce(skinChanged);
private void skinChanged()
{
UpdateComboColour();
@ -503,7 +511,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (IsLoaded)
updateState(State.Value, true);
});
}
protected void UpdateComboColour()
{
@ -747,7 +755,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (HitObject != null)
HitObject.DefaultsApplied -= onDefaultsApplied;
CurrentSkin.SourceChanged -= onSkinSourceChanged;
CurrentSkin.SourceChanged -= skinSourceChanged;
}
}

View File

@ -3,7 +3,6 @@
#nullable enable
using System;
using System.Diagnostics;
using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Pooling;
@ -27,13 +26,14 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// </summary>
protected bool HasEntryApplied { get; private set; }
// Drawable's lifetime gets out of sync with entry's lifetime if entry's lifetime is modified.
// We cannot delegate getter to `Entry.LifetimeStart` because it is incompatible with `LifetimeManagementContainer` due to how lifetime change is detected.
public override double LifetimeStart
{
get => Entry?.LifetimeStart ?? double.MinValue;
get => base.LifetimeStart;
set
{
if (Entry == null && LifetimeStart != value)
throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime<TEntry>)} when entry is not set");
base.LifetimeStart = value;
if (Entry != null)
Entry.LifetimeStart = value;
@ -42,11 +42,10 @@ namespace osu.Game.Rulesets.Objects.Pooling
public override double LifetimeEnd
{
get => Entry?.LifetimeEnd ?? double.MaxValue;
get => base.LifetimeEnd;
set
{
if (Entry == null && LifetimeEnd != value)
throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime<TEntry>)} when entry is not set");
base.LifetimeEnd = value;
if (Entry != null)
Entry.LifetimeEnd = value;
@ -80,7 +79,12 @@ namespace osu.Game.Rulesets.Objects.Pooling
free();
Entry = entry;
base.LifetimeStart = entry.LifetimeStart;
base.LifetimeEnd = entry.LifetimeEnd;
OnApply(entry);
HasEntryApplied = true;
}
@ -112,7 +116,11 @@ namespace osu.Game.Rulesets.Objects.Pooling
Debug.Assert(Entry != null && HasEntryApplied);
OnFree(Entry);
Entry = null;
base.LifetimeStart = double.MinValue;
base.LifetimeEnd = double.MaxValue;
HasEntryApplied = false;
}
}

Some files were not shown because too many files have changed in this diff Show More