1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-13 19:52:55 +08:00

Merge branch 'master' into editor-exit-stability

This commit is contained in:
Dean Herbert 2021-05-28 14:32:32 +09:00
commit f1b5aced6f
200 changed files with 4928 additions and 2638 deletions

View File

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

View File

@ -1,7 +0,0 @@
---
name: Feature Request
about: Propose a feature you would like to see in the game!
---
**Describe the new feature:**
**Proposal designs of the feature:**

View File

@ -1,5 +1,12 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Suggestions or feature request
url: https://github.com/ppy/osu/discussions/categories/ideas
about: Got something you think should change or be added? Search for or start a new discussion!
- name: Help
url: https://github.com/ppy/osu/discussions/categories/q-a
about: osu! not working as you'd expect? Not sure it's a bug? Check the Q&A section!
- name: osu!stable issues - name: osu!stable issues
url: https://github.com/ppy/osu-stable-issues url: https://github.com/ppy/osu-stable-issues
about: For issues regarding osu!stable (not osu!lazer), open them here. about: For osu!stable bugs (not osu!lazer), check out the dedicated repository. Note that we only accept serious bug reports.

View File

@ -52,6 +52,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.422.0" /> <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> </ItemGroup>
</Project> </Project>

View File

@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Width = 0.5f, Width = 0.5f,
Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 0), _ => new DefaultColumnBackground()) Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground())
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
} }
@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Width = 0.5f, Width = 0.5f,
Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 1), _ => new DefaultColumnBackground()) Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground())
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
} }

View File

@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Width = 0.5f, Width = 0.5f,
Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 0), _ => new DefaultKeyArea()) Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea())
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
}, },
@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Width = 0.5f, Width = 0.5f,
Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 1), _ => new DefaultKeyArea()) Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea())
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
}, },

View File

@ -4,6 +4,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Screens;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays; using osu.Game.Replays;
@ -408,6 +409,9 @@ namespace osu.Game.Rulesets.Mania.Tests
judgementResults = new List<JudgementResult>(); judgementResults = new List<JudgementResult>();
}); });
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor?.HasCompleted.Value == true); AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor?.HasCompleted.Value == true);
} }

View File

@ -58,8 +58,9 @@ namespace osu.Game.Rulesets.Mania.Edit
EditorBeatmap.PerformOnSelection(h => EditorBeatmap.PerformOnSelection(h =>
{ {
if (h is ManiaHitObject maniaObj) maniaPlayfield.Remove(h);
maniaObj.Column += columnDelta; ((ManiaHitObject)h).Column += columnDelta;
maniaPlayfield.Add(h);
}); });
} }
} }

View File

@ -9,12 +9,6 @@ namespace osu.Game.Rulesets.Mania
{ {
public class ManiaSkinComponent : GameplaySkinComponent<ManiaSkinComponents> public class ManiaSkinComponent : GameplaySkinComponent<ManiaSkinComponents>
{ {
/// <summary>
/// The intended <see cref="Column"/> index for this component.
/// May be null if the component does not exist in a <see cref="Column"/>.
/// </summary>
public readonly int? TargetColumn;
/// <summary> /// <summary>
/// The intended <see cref="StageDefinition"/> for this component. /// The intended <see cref="StageDefinition"/> for this component.
/// May be null if the component is not a direct member of a <see cref="Stage"/>. /// May be null if the component is not a direct member of a <see cref="Stage"/>.
@ -25,12 +19,10 @@ namespace osu.Game.Rulesets.Mania
/// Creates a new <see cref="ManiaSkinComponent"/>. /// Creates a new <see cref="ManiaSkinComponent"/>.
/// </summary> /// </summary>
/// <param name="component">The component.</param> /// <param name="component">The component.</param>
/// <param name="targetColumn">The intended <see cref="Column"/> index for this component. May be null if the component does not exist in a <see cref="Column"/>.</param>
/// <param name="stageDefinition">The intended <see cref="StageDefinition"/> for this component. May be null if the component is not a direct member of a <see cref="Stage"/>.</param> /// <param name="stageDefinition">The intended <see cref="StageDefinition"/> for this component. May be null if the component is not a direct member of a <see cref="Stage"/>.</param>
public ManiaSkinComponent(ManiaSkinComponents component, int? targetColumn = null, StageDefinition? stageDefinition = null) public ManiaSkinComponent(ManiaSkinComponents component, StageDefinition? stageDefinition = null)
: base(component) : base(component)
{ {
TargetColumn = targetColumn;
StageDefinition = stageDefinition; StageDefinition = stageDefinition;
} }

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -12,6 +13,7 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Mania.Objects.Drawables namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
@ -29,21 +31,21 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public DrawableHoldNoteHead Head => headContainer.Child; public DrawableHoldNoteHead Head => headContainer.Child;
public DrawableHoldNoteTail Tail => tailContainer.Child; public DrawableHoldNoteTail Tail => tailContainer.Child;
private readonly Container<DrawableHoldNoteHead> headContainer; private Container<DrawableHoldNoteHead> headContainer;
private readonly Container<DrawableHoldNoteTail> tailContainer; private Container<DrawableHoldNoteTail> tailContainer;
private readonly Container<DrawableHoldNoteTick> tickContainer; private Container<DrawableHoldNoteTick> tickContainer;
/// <summary> /// <summary>
/// Contains the size of the hold note covering the whole head/tail bounds. The size of this container changes as the hold note is being pressed. /// Contains the size of the hold note covering the whole head/tail bounds. The size of this container changes as the hold note is being pressed.
/// </summary> /// </summary>
private readonly Container sizingContainer; private Container sizingContainer;
/// <summary> /// <summary>
/// Contains the contents of the hold note that should be masked as the hold note is being pressed. Follows changes in the size of <see cref="sizingContainer"/>. /// Contains the contents of the hold note that should be masked as the hold note is being pressed. Follows changes in the size of <see cref="sizingContainer"/>.
/// </summary> /// </summary>
private readonly Container maskingContainer; private Container maskingContainer;
private readonly SkinnableDrawable bodyPiece; private SkinnableDrawable bodyPiece;
/// <summary> /// <summary>
/// Time at which the user started holding this hold note. Null if the user is not holding this hold note. /// Time at which the user started holding this hold note. Null if the user is not holding this hold note.
@ -60,11 +62,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
/// </summary> /// </summary>
private double? releaseTime; private double? releaseTime;
public DrawableHoldNote()
: this(null)
{
}
public DrawableHoldNote(HoldNote hitObject) public DrawableHoldNote(HoldNote hitObject)
: base(hitObject) : base(hitObject)
{ {
RelativeSizeAxes = Axes.X; }
[BackgroundDependencyLoader]
private void load()
{
Container maskedContents; Container maskedContents;
AddRangeInternal(new Drawable[] AddRangeInternal(new Drawable[]
@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both } headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both }
} }
}, },
bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody, hitObject.Column), _ => new DefaultBodyPiece bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
}) })
@ -105,6 +115,16 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
}); });
} }
protected override void OnApply()
{
base.OnApply();
sizingContainer.Size = Vector2.One;
HoldStartTime = null;
HoldBrokenTime = null;
releaseTime = null;
}
protected override void AddNestedHitObject(DrawableHitObject hitObject) protected override void AddNestedHitObject(DrawableHitObject hitObject)
{ {
base.AddNestedHitObject(hitObject); base.AddNestedHitObject(hitObject);
@ -128,37 +148,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected override void ClearNestedHitObjects() protected override void ClearNestedHitObjects()
{ {
base.ClearNestedHitObjects(); base.ClearNestedHitObjects();
headContainer.Clear(); headContainer.Clear(false);
tailContainer.Clear(); tailContainer.Clear(false);
tickContainer.Clear(); tickContainer.Clear(false);
} }
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
{ {
switch (hitObject) switch (hitObject)
{ {
case TailNote _: case TailNote tail:
return new DrawableHoldNoteTail(this) return new DrawableHoldNoteTail(tail);
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AccentColour = { BindTarget = AccentColour }
};
case Note _: case HeadNote head:
return new DrawableHoldNoteHead(this) return new DrawableHoldNoteHead(head);
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AccentColour = { BindTarget = AccentColour }
};
case HoldNoteTick tick: case HoldNoteTick tick:
return new DrawableHoldNoteTick(tick) return new DrawableHoldNoteTick(tick);
{
HoldStartTime = () => HoldStartTime,
AccentColour = { BindTarget = AccentColour }
};
} }
return base.CreateNestedHitObject(hitObject); return base.CreateNestedHitObject(hitObject);

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.Mania.Objects.Drawables namespace osu.Game.Rulesets.Mania.Objects.Drawables
@ -12,11 +13,18 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead; protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead;
public DrawableHoldNoteHead(DrawableHoldNote holdNote) public DrawableHoldNoteHead()
: base(holdNote.HitObject.Head) : this(null)
{ {
} }
public DrawableHoldNoteHead(HeadNote headNote)
: base(headNote)
{
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
}
public void UpdateResult() => base.UpdateResult(true); public void UpdateResult() => base.UpdateResult(true);
protected override void UpdateInitialTransforms() protected override void UpdateInitialTransforms()

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Diagnostics; using System.Diagnostics;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects.Drawables namespace osu.Game.Rulesets.Mania.Objects.Drawables
@ -20,12 +21,18 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail; protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail;
private readonly DrawableHoldNote holdNote; protected DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject;
public DrawableHoldNoteTail(DrawableHoldNote holdNote) public DrawableHoldNoteTail()
: base(holdNote.HitObject.Tail) : this(null)
{ {
this.holdNote = holdNote; }
public DrawableHoldNoteTail(TailNote tailNote)
: base(tailNote)
{
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
} }
public void UpdateResult() => base.UpdateResult(true); public void UpdateResult() => base.UpdateResult(true);
@ -54,7 +61,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
ApplyResult(r => ApplyResult(r =>
{ {
// If the head wasn't hit or the hold note was broken, cap the max score to Meh. // If the head wasn't hit or the hold note was broken, cap the max score to Meh.
if (result > HitResult.Meh && (!holdNote.Head.IsHit || holdNote.HoldBrokenTime != null)) if (result > HitResult.Meh && (!HoldNote.Head.IsHit || HoldNote.HoldBrokenTime != null))
result = HitResult.Meh; result = HitResult.Meh;
r.Type = result; r.Type = result;

View File

@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using osuTK; using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -19,38 +20,48 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
/// <summary> /// <summary>
/// References the time at which the user started holding the hold note. /// References the time at which the user started holding the hold note.
/// </summary> /// </summary>
public Func<double?> HoldStartTime; private Func<double?> holdStartTime;
private Container glowContainer;
public DrawableHoldNoteTick()
: this(null)
{
}
public DrawableHoldNoteTick(HoldNoteTick hitObject) public DrawableHoldNoteTick(HoldNoteTick hitObject)
: base(hitObject) : base(hitObject)
{ {
Container glowContainer;
Anchor = Anchor.TopCentre; Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre; Origin = Anchor.TopCentre;
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
Size = new Vector2(1); }
AddRangeInternal(new[] [BackgroundDependencyLoader]
private void load()
{
AddInternal(glowContainer = new CircularContainer
{ {
glowContainer = new CircularContainer Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
RelativeSizeAxes = Axes.Both,
Masking = true,
Children = new[]
{ {
Anchor = Anchor.TopCentre, new Box
Origin = Anchor.TopCentre,
RelativeSizeAxes = Axes.Both,
Masking = true,
Children = new[]
{ {
new Box RelativeSizeAxes = Axes.Both,
{ Alpha = 0,
RelativeSizeAxes = Axes.Both, AlwaysPresent = true
Alpha = 0,
AlwaysPresent = true
}
} }
} }
}); });
}
protected override void LoadComplete()
{
base.LoadComplete();
AccentColour.BindValueChanged(colour => AccentColour.BindValueChanged(colour =>
{ {
@ -64,12 +75,29 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
}, true); }, true);
} }
protected override void OnApply()
{
base.OnApply();
Debug.Assert(ParentHitObject != null);
var holdNote = (DrawableHoldNote)ParentHitObject;
holdStartTime = () => holdNote.HoldStartTime;
}
protected override void OnFree()
{
base.OnFree();
holdStartTime = null;
}
protected override void CheckForResult(bool userTriggered, double timeOffset) protected override void CheckForResult(bool userTriggered, double timeOffset)
{ {
if (Time.Current < HitObject.StartTime) if (Time.Current < HitObject.StartTime)
return; return;
var startTime = HoldStartTime?.Invoke(); var startTime = holdStartTime?.Invoke();
if (startTime == null || startTime > HitObject.StartTime) if (startTime == null || startTime > HitObject.StartTime)
ApplyResult(r => r.Type = r.Judgement.MinResult); ApplyResult(r => r.Type = r.Judgement.MinResult);

View File

@ -50,6 +50,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected DrawableManiaHitObject(ManiaHitObject hitObject) protected DrawableManiaHitObject(ManiaHitObject hitObject)
: base(hitObject) : base(hitObject)
{ {
RelativeSizeAxes = Axes.X;
} }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
@ -59,9 +60,31 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
Action.BindTo(action); Action.BindTo(action);
Direction.BindTo(scrollingInfo.Direction); Direction.BindTo(scrollingInfo.Direction);
}
protected override void LoadComplete()
{
base.LoadComplete();
Direction.BindValueChanged(OnDirectionChanged, true); Direction.BindValueChanged(OnDirectionChanged, true);
} }
protected override void OnApply()
{
base.OnApply();
if (ParentHitObject != null)
AccentColour.BindTo(ParentHitObject.AccentColour);
}
protected override void OnFree()
{
base.OnFree();
if (ParentHitObject != null)
AccentColour.UnbindFrom(ParentHitObject.AccentColour);
}
private double computedLifetimeStart; private double computedLifetimeStart;
public override double LifetimeStart public override double LifetimeStart
@ -147,12 +170,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public abstract class DrawableManiaHitObject<TObject> : DrawableManiaHitObject public abstract class DrawableManiaHitObject<TObject> : DrawableManiaHitObject
where TObject : ManiaHitObject where TObject : ManiaHitObject
{ {
public new readonly TObject HitObject; public new TObject HitObject => (TObject)base.HitObject;
protected DrawableManiaHitObject(TObject hitObject) protected DrawableManiaHitObject(TObject hitObject)
: base(hitObject) : base(hitObject)
{ {
HitObject = hitObject;
} }
} }
} }

View File

@ -33,31 +33,37 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected virtual ManiaSkinComponents Component => ManiaSkinComponents.Note; protected virtual ManiaSkinComponents Component => ManiaSkinComponents.Note;
private readonly Drawable headPiece; private Drawable headPiece;
public DrawableNote()
: this(null)
{
}
public DrawableNote(Note hitObject) public DrawableNote(Note hitObject)
: base(hitObject) : base(hitObject)
{ {
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y; AutoSizeAxes = Axes.Y;
AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component, hitObject.Column), _ => new DefaultNotePiece())
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
});
} }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(ManiaRulesetConfigManager rulesetConfig) private void load(ManiaRulesetConfigManager rulesetConfig)
{ {
rulesetConfig?.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring); rulesetConfig?.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring);
AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component), _ => new DefaultNotePiece())
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
});
} }
protected override void LoadComplete() protected override void LoadComplete()
{ {
HitObject.StartTimeBindable.BindValueChanged(_ => updateSnapColour()); base.LoadComplete();
configTimingBasedNoteColouring.BindValueChanged(_ => updateSnapColour(), true);
configTimingBasedNoteColouring.BindValueChanged(_ => updateSnapColour());
StartTimeBindable.BindValueChanged(_ => updateSnapColour(), true);
} }
protected override void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> e) protected override void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> e)
@ -102,7 +108,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
private void updateSnapColour() private void updateSnapColour()
{ {
if (beatmap == null) return; if (beatmap == null || HitObject == null) return;
int snapDivisor = beatmap.ControlPointInfo.GetClosestBeatDivisor(HitObject.StartTime); int snapDivisor = beatmap.ControlPointInfo.GetClosestBeatDivisor(HitObject.StartTime);

View File

@ -0,0 +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.
namespace osu.Game.Rulesets.Mania.Objects
{
public class HeadNote : Note
{
}
}

View File

@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Mania.Objects
/// <summary> /// <summary>
/// The head note of the hold. /// The head note of the hold.
/// </summary> /// </summary>
public Note Head { get; private set; } public HeadNote Head { get; private set; }
/// <summary> /// <summary>
/// The tail note of the hold. /// The tail note of the hold.
@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Mania.Objects
createTicks(cancellationToken); createTicks(cancellationToken);
AddNested(Head = new Note AddNested(Head = new HeadNote
{ {
StartTime = StartTime, StartTime = StartTime,
Column = Column, Column = Column,

View File

@ -17,6 +17,7 @@ using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Objects.Drawables;
namespace osu.Game.Rulesets.Mania.UI namespace osu.Game.Rulesets.Mania.UI
@ -55,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.UI
RelativeSizeAxes = Axes.Y; RelativeSizeAxes = Axes.Y;
Width = COLUMN_WIDTH; Width = COLUMN_WIDTH;
Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, Index), _ => new DefaultColumnBackground()) Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground())
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
}; };
@ -66,7 +67,7 @@ namespace osu.Game.Rulesets.Mania.UI
// For input purposes, the background is added at the highest depth, but is then proxied back below all other elements // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements
background.CreateProxy(), background.CreateProxy(),
HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both }, HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both },
new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, Index), _ => new DefaultKeyArea()) new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea())
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
}, },
@ -83,6 +84,19 @@ namespace osu.Game.Rulesets.Mania.UI
hitPolicy = new OrderedHitPolicy(HitObjectContainer); hitPolicy = new OrderedHitPolicy(HitObjectContainer);
TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy());
RegisterPool<Note, DrawableNote>(10, 50);
RegisterPool<HoldNote, DrawableHoldNote>(10, 50);
RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50);
RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);
RegisterPool<HoldNoteTick, DrawableHoldNoteTick>(50, 250);
}
protected override void LoadComplete()
{
base.LoadComplete();
NewResult += OnNewResult;
} }
public ColumnType ColumnType { get; set; } public ColumnType ColumnType { get; set; }
@ -98,28 +112,14 @@ namespace osu.Game.Rulesets.Mania.UI
return dependencies; return dependencies;
} }
/// <summary> protected override void OnNewDrawableHitObject(DrawableHitObject drawableHitObject)
/// Adds a DrawableHitObject to this Playfield.
/// </summary>
/// <param name="hitObject">The DrawableHitObject to add.</param>
public override void Add(DrawableHitObject hitObject)
{ {
hitObject.AccentColour.Value = AccentColour; base.OnNewDrawableHitObject(drawableHitObject);
hitObject.OnNewResult += OnNewResult;
DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject; DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)drawableHitObject;
maniaObject.AccentColour.Value = AccentColour;
maniaObject.CheckHittable = hitPolicy.IsHittable; maniaObject.CheckHittable = hitPolicy.IsHittable;
base.Add(hitObject);
}
public override bool Remove(DrawableHitObject h)
{
if (!base.Remove(h))
return false;
h.OnNewResult -= OnNewResult;
return true;
} }
internal void OnNewResult(DrawableHitObject judgedObject, JudgementResult result) internal void OnNewResult(DrawableHitObject judgedObject, JudgementResult result)

View File

@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.UI.Components
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Depth = 2, Depth = 2,
}, },
hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget, columnIndex), _ => new DefaultHitTarget()) hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget), _ => new DefaultHitTarget())
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Depth = 1 Depth = 1

View File

@ -18,7 +18,6 @@ using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -134,20 +133,7 @@ namespace osu.Game.Rulesets.Mania.UI
protected override PassThroughInputManager CreateInputManager() => new ManiaInputManager(Ruleset.RulesetInfo, Variant); protected override PassThroughInputManager CreateInputManager() => new ManiaInputManager(Ruleset.RulesetInfo, Variant);
public override DrawableHitObject<ManiaHitObject> CreateDrawableRepresentation(ManiaHitObject h) public override DrawableHitObject<ManiaHitObject> CreateDrawableRepresentation(ManiaHitObject h) => null;
{
switch (h)
{
case HoldNote holdNote:
return new DrawableHoldNote(holdNote);
case Note note:
return new DrawableNote(note);
default:
return null;
}
}
protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay); protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay);

View File

@ -9,6 +9,7 @@ using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK; using osuTK;
@ -56,6 +57,10 @@ namespace osu.Game.Rulesets.Mania.UI
} }
} }
public override void Add(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Add(hitObject);
public override bool Remove(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Remove(hitObject);
public override void Add(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Add(h); public override void Add(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Add(h);
public override bool Remove(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Remove(h); public override bool Remove(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Remove(h);

View File

@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.UI
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, column.Index), _ => new DefaultHitExplosion()) InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion), _ => new DefaultHitExplosion())
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both
}; };

View File

@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.Mania.UI.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
@ -132,33 +133,19 @@ namespace osu.Game.Rulesets.Mania.UI
} }
} }
public override void Add(DrawableHitObject h) protected override void LoadComplete()
{ {
var maniaObject = (ManiaHitObject)h.HitObject; base.LoadComplete();
NewResult += OnNewResult;
int columnIndex = -1;
maniaObject.ColumnBindable.BindValueChanged(_ =>
{
if (columnIndex != -1)
Columns.ElementAt(columnIndex).Remove(h);
columnIndex = maniaObject.Column - firstColumnIndex;
Columns.ElementAt(columnIndex).Add(h);
}, true);
h.OnNewResult += OnNewResult;
} }
public override bool Remove(DrawableHitObject h) public override void Add(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Add(hitObject);
{
var maniaObject = (ManiaHitObject)h.HitObject;
int columnIndex = maniaObject.Column - firstColumnIndex;
Columns.ElementAt(columnIndex).Remove(h);
h.OnNewResult -= OnNewResult; public override bool Remove(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Remove(hitObject);
return true;
} public override void Add(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Add(h);
public override bool Remove(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Remove(h);
public void Add(BarLine barline) => base.Add(new DrawableBarLine(barline)); public void Add(BarLine barline) => base.Add(new DrawableBarLine(barline));

View File

@ -5,12 +5,14 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Osu.Tests namespace osu.Game.Rulesets.Osu.Tests
@ -32,6 +34,16 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep($"{term} Small", () => SetContents(() => testSingle(7, autoplay))); 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(false)]
[TestCase(true)] [TestCase(true)]
public void TestLongSpinner(bool autoplay) public void TestLongSpinner(bool autoplay)
@ -93,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
base.Update(); base.Update();
if (auto) 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 totalCount = Pieces.Count(p => p.IsSelected.Value);
int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type.Value == type); 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)) foreach (var p in Pieces.Where(p => p.IsSelected.Value))
updatePathType(p, type); updatePathType(p, type);
@ -258,15 +258,5 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
return item; 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.Objects;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose;
using osuTK; using osuTK;
@ -25,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{ {
public class SliderSelectionBlueprint : OsuSelectionBlueprint<Slider> public class SliderSelectionBlueprint : OsuSelectionBlueprint<Slider>
{ {
protected new DrawableSlider DrawableObject => (DrawableSlider)base.DrawableObject;
protected SliderBodyPiece BodyPiece { get; private set; } protected SliderBodyPiece BodyPiece { get; private set; }
protected SliderCircleOverlay HeadOverlay { get; private set; } protected SliderCircleOverlay HeadOverlay { get; private set; }
protected SliderCircleOverlay TailOverlay { 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)), 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) => public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
BodyPiece.ReceivePositionalInputAt(screenSpacePos) || ControlPointVisualiser?.Pieces.Any(p => p.ReceivePositionalInputAt(screenSpacePos)) == true; 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. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -12,7 +11,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
using Vector2 = osuTK.Vector2; using osuTK;
namespace osu.Game.Rulesets.Osu.Edit namespace osu.Game.Rulesets.Osu.Edit
{ {
@ -173,12 +172,12 @@ namespace osu.Game.Rulesets.Osu.Edit
foreach (var h in hitObjects) 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) if (h is IHasPath path)
{ {
foreach (var point in path.Path.ControlPoints) 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) private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale)
{ {
scale = getClampedScale(hitObjects, reference, 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); Quad selectionQuad = getSurroundingQuad(hitObjects);
foreach (var h in hitObjects) foreach (var h in hitObjects)
{ h.Position = GetScaledPosition(reference, scale, selectionQuad, h.Position);
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;
}
} }
private (bool X, bool Y) isQuadInBounds(Quad quad) private (bool X, bool Y) isQuadInBounds(Quad quad)
@ -340,28 +323,5 @@ namespace osu.Game.Rulesets.Osu.Edit
private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType<OsuHitObject>() private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType<OsuHitObject>()
.Where(h => !(h is Spinner)) .Where(h => !(h is Spinner))
.ToArray(); .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; 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; public IBindable<int> PathVersion => pathVersion;
private readonly Bindable<int> pathVersion = new Bindable<int>(); 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); double completionProgress = Math.Clamp((Time.Current - HitObject.StartTime) / HitObject.Duration, 0, 1);
Ball.UpdateProgress(completionProgress); Ball.UpdateProgress(completionProgress);
sliderBody?.UpdateProgress(completionProgress); SliderBody?.UpdateProgress(completionProgress);
foreach (DrawableHitObject hitObject in NestedHitObjects) 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; if (hitObject is IRequireTracking t) t.Tracking = Ball.Tracking;
} }
Size = sliderBody?.Size ?? Vector2.Zero; Size = SliderBody?.Size ?? Vector2.Zero;
OriginPosition = sliderBody?.PathOffset ?? Vector2.Zero; OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero;
if (DrawSize != Vector2.Zero) if (DrawSize != Vector2.Zero)
{ {
@ -238,7 +239,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public override void OnKilled() public override void OnKilled()
{ {
base.OnKilled(); base.OnKilled();
sliderBody?.RecyclePath(); SliderBody?.RecyclePath();
} }
protected override void ApplySkin(ISkinSource skin, bool allowFallback) protected override void ApplySkin(ISkinSource skin, bool allowFallback)
@ -324,7 +325,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
case ArmedState.Hit: case ArmedState.Hit:
Ball.ScaleTo(HitObject.Scale * 1.4f, fade_out_time, Easing.Out); 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. Body.FadeOut(40); // short fade to allow for any body colour to smoothly disappear.
break; break;
} }
@ -332,7 +333,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
this.FadeOut(fade_out_time, Easing.OutQuint).Expire(); 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 private class DefaultSliderBody : PlaySliderBody
{ {

View File

@ -7,13 +7,14 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Osu.Objects.Drawables namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, ITrackSnaking, IHasMainCirclePiece public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, IHasMainCirclePiece
{ {
public new SliderTailCircle HitObject => (SliderTailCircle)base.HitObject; public new SliderTailCircle HitObject => (SliderTailCircle)base.HitObject;
@ -111,7 +112,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult);
} }
public void UpdateSnakingPosition(Vector2 start, Vector2 end) => protected override void OnApply()
Position = HitObject.RepeatIndex % 2 == 0 ? end : start; {
base.OnApply();
if (Slider != null)
Position = Slider.CurvePositionAt(HitObject.RepeatIndex % 2 == 0 ? 1 : 0);
}
} }
} }

View File

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

View File

@ -79,8 +79,6 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
// Old osu! used hit sounding to determine various hit type information // Old osu! used hit sounding to determine various hit type information
IList<HitSampleInfo> samples = obj.Samples; IList<HitSampleInfo> samples = obj.Samples;
bool strong = samples.Any(s => s.Name == HitSampleInfo.HIT_FINISH);
switch (obj) switch (obj)
{ {
case IHasDistance distanceData: case IHasDistance distanceData:
@ -94,15 +92,11 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
for (double j = obj.StartTime; j <= obj.StartTime + taikoDuration + tickSpacing / 8; j += tickSpacing) for (double j = obj.StartTime; j <= obj.StartTime + taikoDuration + tickSpacing / 8; j += tickSpacing)
{ {
IList<HitSampleInfo> currentSamples = allSamples[i]; IList<HitSampleInfo> currentSamples = allSamples[i];
bool isRim = currentSamples.Any(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE);
strong = currentSamples.Any(s => s.Name == HitSampleInfo.HIT_FINISH);
yield return new Hit yield return new Hit
{ {
StartTime = j, StartTime = j,
Type = isRim ? HitType.Rim : HitType.Centre,
Samples = currentSamples, Samples = currentSamples,
IsStrong = strong
}; };
i = (i + 1) % allSamples.Count; i = (i + 1) % allSamples.Count;
@ -117,7 +111,6 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
{ {
StartTime = obj.StartTime, StartTime = obj.StartTime,
Samples = obj.Samples, Samples = obj.Samples,
IsStrong = strong,
Duration = taikoDuration, Duration = taikoDuration,
TickRate = beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate == 3 ? 3 : 4 TickRate = beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate == 3 ? 3 : 4
}; };
@ -143,16 +136,10 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps
default: default:
{ {
bool isRimDefinition(HitSampleInfo s) => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE;
bool isRim = samples.Any(isRimDefinition);
yield return new Hit yield return new Hit
{ {
StartTime = obj.StartTime, StartTime = obj.StartTime,
Type = isRim ? HitType.Rim : HitType.Centre,
Samples = samples, Samples = samples,
IsStrong = strong
}; };
break; break;

View File

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

View File

@ -69,17 +69,21 @@ namespace osu.Game.Rulesets.Taiko.Edit
{ {
EditorBeatmap.PerformOnSelection(h => EditorBeatmap.PerformOnSelection(h =>
{ {
if (h is Hit taikoHit) taikoHit.Type = state ? HitType.Rim : HitType.Centre; if (h is Hit taikoHit)
{
taikoHit.Type = state ? HitType.Rim : HitType.Centre;
EditorBeatmap.Update(h);
}
}); });
} }
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection) protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
{ {
if (selection.All(s => s.Item is Hit)) 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)) 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)) foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item; yield return item;

View File

@ -52,23 +52,18 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
protected override void OnApply() protected override void OnApply()
{ {
type.BindTo(HitObject.TypeBindable); type.BindTo(HitObject.TypeBindable);
type.BindValueChanged(_ => // this doesn't need to be run inline as RecreatePieces is called by the base call below.
{ type.BindValueChanged(_ => Scheduler.AddOnce(RecreatePieces));
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();
base.OnApply(); base.OnApply();
} }
protected override void RecreatePieces()
{
updateActionsFromType();
base.RecreatePieces();
}
protected override void OnFree() protected override void OnFree()
{ {
base.OnFree(); base.OnFree();
@ -83,33 +78,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
validActionPressed = pressHandledThisFrame = false; 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() private void updateActionsFromType()
{ {
HitActions = HitActions =

View File

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

View File

@ -1,11 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using JetBrains.Annotations; using JetBrains.Annotations;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osuTK; using osuTK;
@ -29,14 +27,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
protected override void OnApply() protected override void OnApply()
{ {
isStrong.BindTo(HitObject.IsStrongBindable); isStrong.BindTo(HitObject.IsStrongBindable);
isStrong.BindValueChanged(_ => // this doesn't need to be run inline as RecreatePieces is called by the base call below.
{ isStrong.BindValueChanged(_ => Scheduler.AddOnce(RecreatePieces));
// will overwrite samples, should only be called on subsequent changes
// after the initial application.
updateSamplesFromStrong();
RecreatePieces();
});
base.OnApply(); base.OnApply();
} }
@ -50,30 +42,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
isStrong.UnbindEvents(); 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() protected override void RecreatePieces()
{ {
base.RecreatePieces(); base.RecreatePieces();

View File

@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Game.Audio;
namespace osu.Game.Rulesets.Taiko.Objects namespace osu.Game.Rulesets.Taiko.Objects
{ {
@ -18,6 +20,40 @@ namespace osu.Game.Rulesets.Taiko.Objects
set => TypeBindable.Value = value; set => TypeBindable.Value = value;
} }
public Hit()
{
TypeBindable.BindValueChanged(_ => updateSamplesFromType());
SamplesBindable.BindCollectionChanged((_, __) => updateTypeFromSamples());
}
private void updateTypeFromSamples()
{
Type = getRimSamples().Any() ? HitType.Rim : HitType.Centre;
}
/// <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();
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);
}
}
}
protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime };
public class StrongNestedHit : StrongNestedHitObject public class StrongNestedHit : StrongNestedHitObject

View File

@ -1,8 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using System.Threading; using System.Threading;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Taiko.Objects namespace osu.Game.Rulesets.Taiko.Objects
@ -34,6 +36,35 @@ namespace osu.Game.Rulesets.Taiko.Objects
set => IsStrongBindable.Value = value; set => IsStrongBindable.Value = value;
} }
protected TaikoStrongableHitObject()
{
IsStrongBindable.BindValueChanged(_ => updateSamplesFromType());
SamplesBindable.BindCollectionChanged((_, __) => updateTypeFromSamples());
}
private void updateTypeFromSamples()
{
IsStrong = getStrongSamples().Any();
}
private void updateSamplesFromType()
{
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) protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{ {
base.CreateNestedHitObjects(cancellationToken); base.CreateNestedHitObjects(cancellationToken);

View File

@ -23,7 +23,7 @@ namespace osu.Game.Tests.Collections.IO
{ {
var osu = LoadOsuIntoHost(host); var osu = LoadOsuIntoHost(host);
await osu.CollectionManager.Import(new MemoryStream()); await importCollectionsFromStream(osu, new MemoryStream());
Assert.That(osu.CollectionManager.Collections.Count, Is.Zero); Assert.That(osu.CollectionManager.Collections.Count, Is.Zero);
} }
@ -43,7 +43,7 @@ namespace osu.Game.Tests.Collections.IO
{ {
var osu = LoadOsuIntoHost(host); var osu = LoadOsuIntoHost(host);
await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db"));
Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2));
@ -69,7 +69,7 @@ namespace osu.Game.Tests.Collections.IO
{ {
var osu = LoadOsuIntoHost(host, true); var osu = LoadOsuIntoHost(host, true);
await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db"));
Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2));
@ -110,7 +110,7 @@ namespace osu.Game.Tests.Collections.IO
ms.Seek(0, SeekOrigin.Begin); ms.Seek(0, SeekOrigin.Begin);
await osu.CollectionManager.Import(ms); await importCollectionsFromStream(osu, ms);
} }
Assert.That(host.UpdateThread.Running, Is.True); Assert.That(host.UpdateThread.Running, Is.True);
@ -134,7 +134,7 @@ namespace osu.Game.Tests.Collections.IO
{ {
var osu = LoadOsuIntoHost(host, true); var osu = LoadOsuIntoHost(host, true);
await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db"));
// Move first beatmap from second collection into the first. // Move first beatmap from second collection into the first.
osu.CollectionManager.Collections[0].Beatmaps.Add(osu.CollectionManager.Collections[1].Beatmaps[0]); osu.CollectionManager.Collections[0].Beatmaps.Add(osu.CollectionManager.Collections[1].Beatmaps[0]);
@ -169,5 +169,12 @@ namespace osu.Game.Tests.Collections.IO
} }
} }
} }
private static async Task importCollectionsFromStream(TestOsuGameBase osu, Stream stream)
{
// intentionally spin this up on a separate task to avoid disposal deadlocks.
// see https://github.com/EventStore/EventStore/issues/1179
await Task.Run(() => osu.CollectionManager.Import(stream).Wait());
}
} }
} }

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

@ -1,6 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -11,29 +14,35 @@ using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Catch.Scoring; using osu.Game.Rulesets.Catch.Scoring;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Rulesets.Taiko.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Screens.Play.HUD.HitErrorMeters;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
{ {
public class TestSceneHitErrorMeter : OsuTestScene public class TestSceneHitErrorMeter : OsuTestScene
{ {
private HitWindows hitWindows;
[Cached] [Cached]
private ScoreProcessor scoreProcessor = new ScoreProcessor(); private ScoreProcessor scoreProcessor = new ScoreProcessor();
[Cached(typeof(DrawableRuleset))]
private TestDrawableRuleset drawableRuleset = new TestDrawableRuleset();
public TestSceneHitErrorMeter() public TestSceneHitErrorMeter()
{ {
recreateDisplay(new OsuHitWindows(), 5); recreateDisplay(new OsuHitWindows(), 5);
AddRepeatStep("New random judgement", () => newJudgement(), 40); AddRepeatStep("New random judgement", () => newJudgement(), 40);
AddRepeatStep("New max negative", () => newJudgement(-hitWindows.WindowFor(HitResult.Meh)), 20); AddRepeatStep("New max negative", () => newJudgement(-drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20);
AddRepeatStep("New max positive", () => newJudgement(hitWindows.WindowFor(HitResult.Meh)), 20); AddRepeatStep("New max positive", () => newJudgement(drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20);
AddStep("New fixed judgement (50ms)", () => newJudgement(50)); AddStep("New fixed judgement (50ms)", () => newJudgement(50));
AddStep("Judgement barrage", () => AddStep("Judgement barrage", () =>
@ -83,10 +92,10 @@ namespace osu.Game.Tests.Visual.Gameplay
private void recreateDisplay(HitWindows hitWindows, float overallDifficulty) private void recreateDisplay(HitWindows hitWindows, float overallDifficulty)
{ {
this.hitWindows = hitWindows;
hitWindows?.SetDifficulty(overallDifficulty); hitWindows?.SetDifficulty(overallDifficulty);
drawableRuleset.HitWindows = hitWindows;
Clear(); Clear();
Add(new FillFlowContainer Add(new FillFlowContainer
@ -103,40 +112,40 @@ namespace osu.Game.Tests.Visual.Gameplay
} }
}); });
Add(new BarHitErrorMeter(hitWindows, true) Add(new BarHitErrorMeter
{ {
Anchor = Anchor.CentreRight, Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight, Origin = Anchor.CentreRight,
}); });
Add(new BarHitErrorMeter(hitWindows, false) Add(new BarHitErrorMeter
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
}); });
Add(new BarHitErrorMeter(hitWindows, true) Add(new BarHitErrorMeter
{ {
Anchor = Anchor.BottomCentre, Anchor = Anchor.BottomCentre,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Rotation = 270, Rotation = 270,
}); });
Add(new ColourHitErrorMeter(hitWindows) Add(new ColourHitErrorMeter
{ {
Anchor = Anchor.CentreRight, Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight, Origin = Anchor.CentreRight,
Margin = new MarginPadding { Right = 50 } Margin = new MarginPadding { Right = 50 }
}); });
Add(new ColourHitErrorMeter(hitWindows) Add(new ColourHitErrorMeter
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Margin = new MarginPadding { Left = 50 } Margin = new MarginPadding { Left = 50 }
}); });
Add(new ColourHitErrorMeter(hitWindows) Add(new ColourHitErrorMeter
{ {
Anchor = Anchor.BottomCentre, Anchor = Anchor.BottomCentre,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
@ -147,11 +156,47 @@ namespace osu.Game.Tests.Visual.Gameplay
private void newJudgement(double offset = 0) private void newJudgement(double offset = 0)
{ {
scoreProcessor.ApplyResult(new JudgementResult(new HitCircle { HitWindows = hitWindows }, new Judgement()) scoreProcessor.ApplyResult(new JudgementResult(new HitCircle { HitWindows = drawableRuleset.HitWindows }, new Judgement())
{ {
TimeOffset = offset == 0 ? RNG.Next(-150, 150) : offset, TimeOffset = offset == 0 ? RNG.Next(-150, 150) : offset,
Type = HitResult.Perfect, Type = HitResult.Perfect,
}); });
} }
[SuppressMessage("ReSharper", "UnassignedGetOnlyAutoProperty")]
private class TestDrawableRuleset : DrawableRuleset
{
public HitWindows HitWindows;
public override IEnumerable<HitObject> Objects => new[] { new HitCircle { HitWindows = HitWindows } };
public override event Action<JudgementResult> NewResult;
public override event Action<JudgementResult> RevertResult;
public override Playfield Playfield { get; }
public override Container Overlays { get; }
public override Container FrameStableComponents { get; }
public override IFrameStableClock FrameStableClock { get; }
public override IReadOnlyList<Mod> Mods { get; }
public override double GameplayStartTime { get; }
public override GameplayCursorContainer Cursor { get; }
public TestDrawableRuleset()
: base(new OsuRuleset())
{
// won't compile without this.
NewResult?.Invoke(null);
RevertResult?.Invoke(null);
}
public override void SetReplayScore(Score replayScore) => throw new NotImplementedException();
public override void SetRecordTarget(Score score) => throw new NotImplementedException();
public override void RequestResume(Action continueResume) => throw new NotImplementedException();
public override void CancelResume() => throw new NotImplementedException();
}
} }
} }

View File

@ -88,13 +88,18 @@ namespace osu.Game.Tests.Visual.Gameplay
{ {
beforeLoadAction?.Invoke(); beforeLoadAction?.Invoke();
prepareBeatmap();
LoadScreen(loader = new TestPlayerLoader(() => player = new TestPlayer(interactive, interactive)));
}
private void prepareBeatmap()
{
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
Beatmap.Value.BeatmapInfo.EpilepsyWarning = epilepsyWarning; Beatmap.Value.BeatmapInfo.EpilepsyWarning = epilepsyWarning;
foreach (var mod in SelectedMods.Value.OfType<IApplicableToTrack>()) foreach (var mod in SelectedMods.Value.OfType<IApplicableToTrack>())
mod.ApplyToTrack(Beatmap.Value.Track); mod.ApplyToTrack(Beatmap.Value.Track);
LoadScreen(loader = new TestPlayerLoader(() => player = new TestPlayer(interactive, interactive)));
} }
[Test] [Test]
@ -178,10 +183,13 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("load slow dummy beatmap", () => AddStep("load slow dummy beatmap", () =>
{ {
LoadScreen(loader = new TestPlayerLoader(() => slowPlayer = new SlowLoadPlayer(false, false))); prepareBeatmap();
Scheduler.AddDelayed(() => slowPlayer.AllowLoad.Set(), 5000); slowPlayer = new SlowLoadPlayer(false, false);
LoadScreen(loader = new TestPlayerLoader(() => slowPlayer));
}); });
AddStep("schedule slow load", () => Scheduler.AddDelayed(() => slowPlayer.AllowLoad.Set(), 5000));
AddUntilStep("wait for player to be current", () => slowPlayer.IsCurrentScreen()); AddUntilStep("wait for player to be current", () => slowPlayer.IsCurrentScreen());
} }

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

View File

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

View File

@ -53,7 +53,8 @@ namespace osu.Game.Tests.Visual.Gameplay
CreateTest(null); CreateTest(null);
AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
AddStep("skip outro", () => InputManager.Key(osuTK.Input.Key.Space)); AddStep("skip outro", () => InputManager.Key(osuTK.Input.Key.Space));
AddAssert("score shown", () => Player.IsScoreShown); AddUntilStep("wait for score shown", () => Player.IsScoreShown);
AddUntilStep("time less than storyboard duration", () => Player.GameplayClockContainer.GameplayClock.CurrentTime < currentStoryboardDuration);
} }
[Test] [Test]

View File

@ -23,8 +23,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene
{ {
[Cached(typeof(SpectatorStreamingClient))] [Cached(typeof(SpectatorClient))]
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient(); private TestSpectatorClient spectatorClient = new TestSpectatorClient();
[Cached(typeof(UserLookupCache))] [Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache(); private UserLookupCache lookupCache = new TestUserLookupCache();
@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
base.Content.AddRange(new Drawable[] base.Content.AddRange(new Drawable[]
{ {
streamingClient, spectatorClient,
lookupCache, lookupCache,
content = new Container { RelativeSizeAxes = Axes.Both } content = new Container { RelativeSizeAxes = Axes.Both }
}); });
@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
foreach (var (userId, clock) in clocks) foreach (var (userId, clock) in clocks)
{ {
streamingClient.EndPlay(userId, 0); spectatorClient.EndPlay(userId);
clock.CurrentTime = 0; clock.CurrentTime = 0;
} }
}); });
@ -67,7 +67,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("create leaderboard", () => AddStep("create leaderboard", () =>
{ {
foreach (var (userId, _) in clocks) foreach (var (userId, _) in clocks)
streamingClient.StartPlay(userId, 0); spectatorClient.StartPlay(userId, 0);
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); 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 player 2, send frames in sets of 10.
for (int i = 0; i < 100; i++) 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) 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 public class TestSceneMultiSpectatorScreen : MultiplayerTestScene
{ {
[Cached(typeof(SpectatorStreamingClient))] [Cached(typeof(SpectatorClient))]
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient(); private TestSpectatorClient spectatorClient = new TestSpectatorClient();
[Cached(typeof(UserLookupCache))] [Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache(); private UserLookupCache lookupCache = new TestUserLookupCache();
@ -59,14 +59,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("add streaming client", () => AddStep("add streaming client", () =>
{ {
Remove(streamingClient); Remove(spectatorClient);
Add(streamingClient); Add(spectatorClient);
}); });
AddStep("finish previous gameplay", () => AddStep("finish previous gameplay", () =>
{ {
foreach (var id in playingUserIds) foreach (var id in playingUserIds)
streamingClient.EndPlay(id, importedBeatmapId); spectatorClient.EndPlay(id);
playingUserIds.Clear(); playingUserIds.Clear();
}); });
} }
@ -87,11 +87,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
loadSpectateScreen(false); loadSpectateScreen(false);
AddWaitStep("wait a bit", 10); 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); AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType<Player>().Count() == 1);
AddWaitStep("wait a bit", 10); 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); AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType<Player>().Count() == 2);
} }
@ -251,18 +251,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
foreach (int id in userIds) foreach (int id in userIds)
{ {
Client.CurrentMatchPlayingUserIds.Add(id); Client.CurrentMatchPlayingUserIds.Add(id);
streamingClient.StartPlay(id, beatmapId ?? importedBeatmapId); spectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId);
playingUserIds.Add(id); playingUserIds.Add(id);
nextFrame[id] = 0; nextFrame[id] = 0;
} }
}); });
} }
private void finish(int userId, int? beatmapId = null) private void finish(int userId)
{ {
AddStep("end play", () => AddStep("end play", () =>
{ {
streamingClient.EndPlay(userId, beatmapId ?? importedBeatmapId); spectatorClient.EndPlay(userId);
playingUserIds.Remove(userId); playingUserIds.Remove(userId);
nextFrame.Remove(userId); nextFrame.Remove(userId);
}); });
@ -276,7 +276,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
foreach (int id in userIds) foreach (int id in userIds)
{ {
streamingClient.SendFrames(id, nextFrame[id], count); spectatorClient.SendFrames(id, nextFrame[id], count);
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 private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer
{ {
[Cached(typeof(StatefulMultiplayerClient))] [Cached(typeof(MultiplayerClient))]
public readonly TestMultiplayerClient Client; public readonly TestMultiplayerClient Client;
public TestMultiplayer() public TestMultiplayer()

View File

@ -28,8 +28,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
private const int users = 16; private const int users = 16;
[Cached(typeof(SpectatorStreamingClient))] [Cached(typeof(SpectatorClient))]
private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming(); private TestMultiplayerSpectatorClient spectatorClient = new TestMultiplayerSpectatorClient();
[Cached(typeof(UserLookupCache))] [Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache(); private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache();
@ -44,7 +44,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
base.Content.Children = new Drawable[] base.Content.Children = new Drawable[]
{ {
streamingClient, spectatorClient,
lookupCache, lookupCache,
Content Content
}; };
@ -71,10 +71,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
for (int i = 0; i < users; i++) 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(); spectatorClient.Schedule(() =>
Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers); {
Client.CurrentMatchPlayingUserIds.Clear();
Client.CurrentMatchPlayingUserIds.AddRange(spectatorClient.PlayingUsers);
});
Children = new Drawable[] Children = new Drawable[]
{ {
@ -83,7 +86,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
scoreProcessor.ApplyBeatmap(playable); scoreProcessor.ApplyBeatmap(playable);
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, streamingClient.PlayingUsers.ToArray()) LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, spectatorClient.PlayingUsers.ToArray())
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
@ -91,12 +94,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
}); });
AddUntilStep("wait for load", () => leaderboard.IsLoaded); AddUntilStep("wait for load", () => leaderboard.IsLoaded);
AddUntilStep("wait for user population", () => Client.CurrentMatchPlayingUserIds.Count > 0);
} }
[Test] [Test]
public void TestScoreUpdates() public void TestScoreUpdates()
{ {
AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 100); AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 100);
AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded); AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded);
} }
@ -109,12 +113,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test] [Test]
public void TestChangeScoringMode() 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 classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic));
AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); 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>(); 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;
using osu.Game.Screens.Menu; using osu.Game.Screens.Menu;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Screens.Select;
using osuTK.Input; using osuTK.Input;
using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation;
namespace osu.Game.Tests.Visual.Navigation namespace osu.Game.Tests.Visual.Navigation
{ {
@ -37,17 +37,17 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestPerformAtSongSelect() 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("did perform", () => actionPerformed);
AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect);
} }
[Test] [Test]
public void TestPerformAtMenuFromSongSelect() public void TestPerformAtMenuFromSongSelect()
{ {
PushAndConfirm(() => new PlaySongSelect()); PushAndConfirm(() => new TestPlaySongSelect());
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true));
AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu); AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu);
@ -57,18 +57,18 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestPerformAtSongSelectFromPlayerLoader() public void TestPerformAtSongSelectFromPlayerLoader()
{ {
PushAndConfirm(() => new PlaySongSelect()); PushAndConfirm(() => new TestPlaySongSelect());
PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer())); PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer()));
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) })); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) }));
AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect);
AddAssert("did perform", () => actionPerformed); AddAssert("did perform", () => actionPerformed);
} }
[Test] [Test]
public void TestPerformAtMenuFromPlayerLoader() public void TestPerformAtMenuFromPlayerLoader()
{ {
PushAndConfirm(() => new PlaySongSelect()); PushAndConfirm(() => new TestPlaySongSelect());
PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer())); PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer()));
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true));

View File

@ -34,9 +34,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestExitSongSelectWithEscape() public void TestExitSongSelectWithEscape()
{ {
TestSongSelect songSelect = null; TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect()); PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show());
AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
pushEscape(); pushEscape();
@ -51,9 +51,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestOpenModSelectOverlayUsingAction() 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)); AddStep("Show mods overlay", () => InputManager.Key(Key.F1));
AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
} }
@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Navigation
{ {
Player player = null; Player player = null;
PushAndConfirm(() => new TestSongSelect()); PushAndConfirm(() => new TestPlaySongSelect());
AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait());
@ -89,7 +89,7 @@ namespace osu.Game.Tests.Visual.Navigation
WorkingBeatmap beatmap() => Game.Beatmap.Value; WorkingBeatmap beatmap() => Game.Beatmap.Value;
PushAndConfirm(() => new TestSongSelect()); PushAndConfirm(() => new TestPlaySongSelect());
AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait());
@ -113,7 +113,7 @@ namespace osu.Game.Tests.Visual.Navigation
WorkingBeatmap beatmap() => Game.Beatmap.Value; WorkingBeatmap beatmap() => Game.Beatmap.Value;
PushAndConfirm(() => new TestSongSelect()); PushAndConfirm(() => new TestPlaySongSelect());
AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait()); AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait());
@ -139,9 +139,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestMenuMakesMusic() 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); AddUntilStep("wait for no track", () => Game.MusicController.CurrentTrack.IsDummyDevice);
@ -153,9 +153,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestExitSongSelectWithClick() public void TestExitSongSelectWithClick()
{ {
TestSongSelect songSelect = null; TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect()); PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show());
AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
AddStep("Move mouse to backButton", () => InputManager.MoveMouseTo(backButtonPosition)); AddStep("Move mouse to backButton", () => InputManager.MoveMouseTo(backButtonPosition));
@ -213,9 +213,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestModSelectInput() public void TestModSelectInput()
{ {
TestSongSelect songSelect = null; TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect()); PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show());
@ -234,9 +234,9 @@ namespace osu.Game.Tests.Visual.Navigation
[Test] [Test]
public void TestBeatmapOptionsInput() public void TestBeatmapOptionsInput()
{ {
TestSongSelect songSelect = null; TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestSongSelect()); PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show()); AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show());
@ -312,11 +312,13 @@ namespace osu.Game.Tests.Visual.Navigation
ConfirmAtMainMenu(); ConfirmAtMainMenu();
} }
private class TestSongSelect : PlaySongSelect public class TestPlaySongSelect : PlaySongSelect
{ {
public ModSelectOverlay ModSelectOverlay => ModSelect; public ModSelectOverlay ModSelectOverlay => ModSelect;
public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions; 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. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.AccountCreation;
using osu.Game.Overlays.Settings;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Tests.Visual.Online namespace osu.Game.Tests.Visual.Online
@ -36,8 +40,6 @@ namespace osu.Game.Tests.Visual.Online
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
API.Logout();
localUser = API.LocalUser.GetBoundCopy(); localUser = API.LocalUser.GetBoundCopy();
localUser.BindValueChanged(user => { userPanelArea.Child = new UserGridPanel(user.NewValue) { Width = 200 }; }, true); 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() public void TestOverlayVisibility()
{ {
AddStep("start hidden", () => accountCreation.Hide()); AddStep("start hidden", () => accountCreation.Hide());
AddStep("log out", API.Logout); AddStep("log out", () => API.Logout());
AddStep("show manually", () => accountCreation.Show()); AddStep("show manually", () => accountCreation.Show());
AddUntilStep("overlay is visible", () => accountCreation.State.Value == Visibility.Visible); 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")); AddStep("log back in", () => API.Login("dummy", "password"));
AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden); 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 public class TestSceneCurrentlyPlayingDisplay : OsuTestScene
{ {
[Cached(typeof(SpectatorStreamingClient))] private readonly User streamingUser = new User { Id = 2, Username = "Test user" };
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
[Cached(typeof(SpectatorClient))]
private TestSpectatorClient testSpectatorClient = new TestSpectatorClient();
private CurrentlyPlayingDisplay currentlyPlaying; private CurrentlyPlayingDisplay currentlyPlaying;
@ -34,7 +36,7 @@ namespace osu.Game.Tests.Visual.Online
{ {
AddStep("add streaming client", () => AddStep("add streaming client", () =>
{ {
nestedContainer?.Remove(testSpectatorStreamingClient); nestedContainer?.Remove(testSpectatorClient);
Remove(lookupCache); Remove(lookupCache);
Children = new Drawable[] Children = new Drawable[]
@ -45,7 +47,7 @@ namespace osu.Game.Tests.Visual.Online
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = new Drawable[] Children = new Drawable[]
{ {
testSpectatorStreamingClient, testSpectatorClient,
currentlyPlaying = new CurrentlyPlayingDisplay currentlyPlaying = new CurrentlyPlayingDisplay
{ {
RelativeSizeAxes = Axes.Both, 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] [Test]
public void TestBasicDisplay() 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); 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()); AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType<UserGridPanel>().Any());
} }

View File

@ -0,0 +1,175 @@
// 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 Markdig.Syntax.Inlines;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.Containers.Markdown;
using osu.Game.Overlays;
using osu.Game.Overlays.Wiki.Markdown;
namespace osu.Game.Tests.Visual.Online
{
public class TestSceneWikiMarkdownContainer : OsuTestScene
{
private TestMarkdownContainer markdownContainer;
[Cached]
private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Orange);
[SetUp]
public void Setup() => Schedule(() =>
{
Children = new Drawable[]
{
new Box
{
Colour = overlayColour.Background5,
RelativeSizeAxes = Axes.Both,
},
new BasicScrollContainer
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(20),
Child = markdownContainer = new TestMarkdownContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
}
}
};
});
[Test]
public void TestLink()
{
AddStep("set current path", () => markdownContainer.CurrentPath = "Article_styling_criteria/");
AddStep("set '/wiki/Main_Page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_Page)");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_Page");
AddStep("set '../FAQ''", () => markdownContainer.Text = "[FAQ](../FAQ)");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/FAQ");
AddStep("set './Writing''", () => markdownContainer.Text = "[wiki writing guidline](./Writing)");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Writing");
AddStep("set 'Formatting''", () => markdownContainer.Text = "[wiki formatting guidline](Formatting)");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Formatting");
}
[Test]
public void TestOutdatedNoticeBox()
{
AddStep("Add outdated yaml header", () =>
{
markdownContainer.Text = @"---
outdated: true
---";
});
}
[Test]
public void TestNeedsCleanupNoticeBox()
{
AddStep("Add needs cleanup yaml header", () =>
{
markdownContainer.Text = @"---
needs_cleanup: true
---";
});
}
[Test]
public void TestOnlyShowOutdatedNoticeBox()
{
AddStep("Add outdated and needs cleanup yaml", () =>
{
markdownContainer.Text = @"---
outdated: true
needs_cleanup: true
---";
});
}
[Test]
public void TestAbsoluteImage()
{
AddStep("Add absolute image", () =>
{
markdownContainer.DocumentUrl = "https://dev.ppy.sh";
markdownContainer.Text = "![intro](/wiki/Interface/img/intro-screen.jpg)";
});
}
[Test]
public void TestRelativeImage()
{
AddStep("Add relative image", () =>
{
markdownContainer.DocumentUrl = "https://dev.ppy.sh";
markdownContainer.CurrentPath = "Interface/";
markdownContainer.Text = "![intro](img/intro-screen.jpg)";
});
}
[Test]
public void TestBlockImage()
{
AddStep("Add paragraph with block image", () =>
{
markdownContainer.DocumentUrl = "https://dev.ppy.sh";
markdownContainer.CurrentPath = "Interface/";
markdownContainer.Text = @"Line before image
![play menu](img/play-menu.jpg ""Main Menu in osu!"")
Line after image";
});
}
[Test]
public void TestInlineImage()
{
AddStep("Add inline image", () =>
{
markdownContainer.DocumentUrl = "https://dev.ppy.sh";
markdownContainer.Text = "![osu! mode icon](/wiki/shared/mode/osu.png) osu!";
});
}
private class TestMarkdownContainer : WikiMarkdownContainer
{
public LinkInline Link;
public new string DocumentUrl
{
set => base.DocumentUrl = value;
}
public override MarkdownTextFlowContainer CreateTextFlow() => new TestMarkdownTextFlowContainer
{
UrlAdded = link => Link = link,
};
private class TestMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer
{
public Action<LinkInline> UrlAdded;
protected override void AddLinkText(string text, LinkInline linkInline)
{
base.AddLinkText(text, linkInline);
UrlAdded?.Invoke(linkInline);
}
protected override void AddImage(LinkInline linkInline) => AddDrawable(new WikiMarkdownImage(linkInline));
}
}
}
}

View File

@ -22,82 +22,17 @@ namespace osu.Game.Tests.Visual.Ranking
{ {
public class TestSceneAccuracyCircle : OsuTestScene public class TestSceneAccuracyCircle : OsuTestScene
{ {
[Test] [TestCase(0.2, ScoreRank.D)]
public void TestLowDRank() [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(); var score = createScore(accuracy, rank);
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;
addCircleStep(score); addCircleStep(score);
} }
@ -120,7 +55,7 @@ namespace osu.Game.Tests.Visual.Ranking
} }
} }
}, },
new AccuracyCircle(score, true) new AccuracyCircle(score)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = 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 User = new User
{ {
@ -139,9 +74,9 @@ namespace osu.Game.Tests.Visual.Ranking
Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo,
Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() },
TotalScore = 2845370, TotalScore = 2845370,
Accuracy = 0.95, Accuracy = accuracy,
MaxCombo = 999, MaxCombo = 999,
Rank = ScoreRank.S, Rank = rank,
Date = DateTimeOffset.Now, Date = DateTimeOffset.Now,
Statistics = Statistics =
{ {

View File

@ -29,13 +29,8 @@ namespace osu.Game.Tests.Visual.Ranking
[TestFixture] [TestFixture]
public class TestSceneResultsScreen : OsuManualInputManagerTestScene public class TestSceneResultsScreen : OsuManualInputManagerTestScene
{ {
private BeatmapManager beatmaps; [Resolved]
private BeatmapManager beatmaps { get; set; }
[BackgroundDependencyLoader]
private void load(BeatmapManager beatmaps)
{
this.beatmaps = beatmaps;
}
protected override void LoadComplete() protected override void LoadComplete()
{ {
@ -46,10 +41,6 @@ namespace osu.Game.Tests.Visual.Ranking
Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); 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] [Test]
public void TestResultsWithoutPlayer() public void TestResultsWithoutPlayer()
{ {
@ -69,12 +60,25 @@ namespace osu.Game.Tests.Visual.Ranking
AddAssert("retry overlay not present", () => screen.RetryOverlay == null); AddAssert("retry overlay not present", () => screen.RetryOverlay == null);
} }
[Test] [TestCase(0.2, ScoreRank.D)]
public void TestResultsWithPlayer() [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; 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); AddUntilStep("wait for loaded", () => screen.IsLoaded);
AddAssert("retry overlay present", () => screen.RetryOverlay != null); 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); 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 private class TestResultsContainer : Container
{ {
[Cached(typeof(Player))] [Cached(typeof(Player))]

View File

@ -6,6 +6,7 @@ using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.KeyBinding; using osu.Game.Overlays.KeyBinding;
using osuTK.Input; using osuTK.Input;
@ -28,6 +29,39 @@ namespace osu.Game.Tests.Visual.Settings
panel.Show(); 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] [Test]
public void TestClickTwiceOnClearButton() public void TestClickTwiceOnClearButton()
{ {
@ -36,6 +70,7 @@ namespace osu.Game.Tests.Visual.Settings
AddStep("click first row", () => AddStep("click first row", () =>
{ {
firstRow = panel.ChildrenOfType<KeyBindingRow>().First(); firstRow = panel.ChildrenOfType<KeyBindingRow>().First();
InputManager.MoveMouseTo(firstRow); InputManager.MoveMouseTo(firstRow);
InputManager.Click(MouseButton.Left); InputManager.Click(MouseButton.Left);
}); });
@ -104,6 +139,64 @@ namespace osu.Game.Tests.Visual.Settings
} }
} }
[Test]
public void TestSingleBindingResetButton()
{
KeyBindingRow settingsKeyBindingRow = null;
AddStep("click first row", () =>
{
settingsKeyBindingRow = panel.ChildrenOfType<KeyBindingRow>().First();
InputManager.MoveMouseTo(settingsKeyBindingRow);
InputManager.Click(MouseButton.Left);
InputManager.PressKey(Key.P);
InputManager.ReleaseKey(Key.P);
});
AddUntilStep("restore button shown", () => settingsKeyBindingRow.ChildrenOfType<RestoreDefaultValueButton<bool>>().First().Alpha > 0);
AddStep("click reset button for bindings", () =>
{
var resetButton = settingsKeyBindingRow.ChildrenOfType<RestoreDefaultValueButton<bool>>().First();
resetButton.Click();
});
AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType<RestoreDefaultValueButton<bool>>().First().Alpha == 0);
AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType<KeyBindingRow.KeyButton>().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0)));
}
[Test]
public void TestResetAllBindingsButton()
{
KeyBindingRow settingsKeyBindingRow = null;
AddStep("click first row", () =>
{
settingsKeyBindingRow = panel.ChildrenOfType<KeyBindingRow>().First();
InputManager.MoveMouseTo(settingsKeyBindingRow);
InputManager.Click(MouseButton.Left);
InputManager.PressKey(Key.P);
InputManager.ReleaseKey(Key.P);
});
AddUntilStep("restore button shown", () => settingsKeyBindingRow.ChildrenOfType<RestoreDefaultValueButton<bool>>().First().Alpha > 0);
AddStep("click reset button for bindings", () =>
{
var resetButton = panel.ChildrenOfType<ResetButton>().First();
resetButton.Click();
});
AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType<RestoreDefaultValueButton<bool>>().First().Alpha == 0);
AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType<KeyBindingRow.KeyButton>().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0)));
}
[Test] [Test]
public void TestClickRowSelectsFirstBinding() public void TestClickRowSelectsFirstBinding()
{ {
@ -135,5 +228,37 @@ namespace osu.Game.Tests.Visual.Settings
AddAssert("first binding selected", () => multiBindingRow.ChildrenOfType<KeyBindingRow.KeyButton>().First().IsBinding); 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

@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
using osu.Game.Overlays;
namespace osu.Game.Tests.Visual.Settings namespace osu.Game.Tests.Visual.Settings
{ {
@ -37,7 +38,7 @@ namespace osu.Game.Tests.Visual.Settings
private class TestSettingsTextBox : SettingsTextBox private class TestSettingsTextBox : SettingsTextBox
{ {
public new Drawable RestoreDefaultValueButton => this.ChildrenOfType<RestoreDefaultValueButton>().Single(); public Drawable RestoreDefaultValueButton => this.ChildrenOfType<RestoreDefaultValueButton<string>>().Single();
} }
} }
} }

View File

@ -786,9 +786,12 @@ namespace osu.Game.Tests.Visual.SongSelect
} }
} }
private void checkVisibleItemCount(bool diff, int count) => private void checkVisibleItemCount(bool diff, int count)
AddAssert($"{count} {(diff ? "diffs" : "sets")} visible", () => {
// until step required as we are querying against alive items, which are loaded asynchronously inside DrawableCarouselBeatmapSet.
AddUntilStep($"{count} {(diff ? "diffs" : "sets")} visible", () =>
carousel.Items.Count(s => (diff ? s.Item is CarouselBeatmap : s.Item is CarouselBeatmapSet) && s.Item.Visible) == count); carousel.Items.Count(s => (diff ? s.Item is CarouselBeatmap : s.Item is CarouselBeatmapSet) && s.Item.Visible) == count);
}
private void checkNoSelection() => AddAssert("Selection is null", () => currentSelection == null); private void checkNoSelection() => AddAssert("Selection is null", () => currentSelection == null);

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] [Test]
public void TestInlineCode() public void TestInlineCode()
{ {

View File

@ -14,7 +14,7 @@ namespace osu.Game.Tests.Visual.UserInterface
public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene
{ {
[Test] [Test]
public void TestTernaryMenuItem() public void TestTernaryRadioMenuItem()
{ {
OsuMenu menu = null; OsuMenu menu = null;
@ -30,9 +30,57 @@ namespace osu.Game.Tests.Visual.UserInterface
Origin = Anchor.Centre, Origin = Anchor.Centre,
Items = new[] Items = new[]
{ {
new TernaryStateMenuItem("First"), new TernaryStateRadioMenuItem("First"),
new TernaryStateMenuItem("Second") { State = { BindTarget = state } }, new TernaryStateRadioMenuItem("Second") { State = { BindTarget = state } },
new TernaryStateMenuItem("Third") { State = { Value = TernaryState.True } }, 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. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
@ -8,78 +9,52 @@ using osu.Game.Graphics;
namespace osu.Game.Beatmaps.Drawables namespace osu.Game.Beatmaps.Drawables
{ {
public class UpdateableBeatmapSetCover : Container public class UpdateableBeatmapSetCover : ModelBackedDrawable<BeatmapSetInfo>
{ {
private Drawable displayedCover; private readonly BeatmapSetCoverType coverType;
private BeatmapSetInfo beatmapSet;
public BeatmapSetInfo BeatmapSet public BeatmapSetInfo BeatmapSet
{ {
get => beatmapSet; get => Model;
set set => Model = value;
{
if (value == beatmapSet) return;
beatmapSet = value;
if (IsLoaded)
updateCover();
}
} }
private BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover; public new bool Masking
public BeatmapSetCoverType CoverType
{ {
get => coverType; get => base.Masking;
set set => base.Masking = value;
{
if (value == coverType) return;
coverType = value;
if (IsLoaded)
updateCover();
}
} }
public UpdateableBeatmapSetCover() public UpdateableBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover)
{ {
Child = new Box this.coverType = coverType;
InternalChild = new Box
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Colour = OsuColour.Gray(0.2f), Colour = OsuColour.Gray(0.2f),
}; };
} }
protected override void LoadComplete() protected override double LoadDelay => 500;
{
base.LoadComplete();
updateCover();
}
private void updateCover() protected override double TransformDuration => 400;
{
displayedCover?.FadeOut(400);
displayedCover?.Expire();
displayedCover = null;
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(() => RelativeSizeAxes = Axes.Both,
{ Anchor = Anchor.Centre,
var cover = new BeatmapSetCover(beatmapSet, coverType) Origin = Anchor.Centre,
{ FillMode = FillMode.Fill,
Anchor = Anchor.Centre, };
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fill,
};
cover.OnLoadComplete += d => d.FadeInFromZero(400, Easing.Out);
return cover;
}));
}
} }
} }
} }

View File

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

View File

@ -104,7 +104,6 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.KeyOverlay, false); SetDefault(OsuSetting.KeyOverlay, false);
SetDefault(OsuSetting.PositionalHitSounds, true); SetDefault(OsuSetting.PositionalHitSounds, true);
SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true); SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true);
SetDefault(OsuSetting.ScoreMeter, ScoreMeterType.HitErrorBoth);
SetDefault(OsuSetting.FloatingComments, false); SetDefault(OsuSetting.FloatingComments, false);
@ -213,7 +212,6 @@ namespace osu.Game.Configuration
KeyOverlay, KeyOverlay,
PositionalHitSounds, PositionalHitSounds,
AlwaysPlayFirstComboBreak, AlwaysPlayFirstComboBreak,
ScoreMeter,
FloatingComments, FloatingComments,
HUDVisibilityMode, HUDVisibilityMode,
ShowProgressGraph, ShowProgressGraph,

View File

@ -1,37 +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.ComponentModel;
namespace osu.Game.Configuration
{
public enum ScoreMeterType
{
[Description("None")]
None,
[Description("Hit Error (left)")]
HitErrorLeft,
[Description("Hit Error (right)")]
HitErrorRight,
[Description("Hit Error (left+right)")]
HitErrorBoth,
[Description("Hit Error (bottom)")]
HitErrorBottom,
[Description("Colour (left)")]
ColourLeft,
[Description("Colour (right)")]
ColourRight,
[Description("Colour (left+right)")]
ColourBoth,
[Description("Colour (bottom)")]
ColourBottom,
}
}

View File

@ -10,7 +10,6 @@ using System.Threading.Tasks;
using Humanizer; using Humanizer;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using osu.Framework;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
@ -81,8 +80,6 @@ namespace osu.Game.Database
public virtual IEnumerable<string> HandledExtensions => new[] { ".zip" }; public virtual IEnumerable<string> HandledExtensions => new[] { ".zip" };
public virtual bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
protected readonly FileStore Files; protected readonly FileStore Files;
protected readonly IDatabaseContextFactory ContextFactory; protected readonly IDatabaseContextFactory ContextFactory;
@ -669,16 +666,6 @@ namespace osu.Game.Database
#region osu-stable import #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> /// <summary>
/// The relative path from osu-stable's data directory to import items from. /// The relative path from osu-stable's data directory to import items from.
/// </summary> /// </summary>
@ -700,22 +687,16 @@ namespace osu.Game.Database
/// <summary> /// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary> /// </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); var storage = PrepareStableStorage(stableStorage);
// Handle situations like when the user does not have a Skins folder.
if (!storage.ExistsDirectory(ImportFromStablePath)) if (!storage.ExistsDirectory(ImportFromStablePath))
{ {
// This handles situations like when the user does not have a Skins folder string fullPath = storage.GetFullPath(ImportFromStablePath);
Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
Logger.Log($"Folder \"{fullPath}\" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask; 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.Tables;
using Markdig.Extensions.Yaml; using Markdig.Extensions.Yaml;
using Markdig.Syntax; using Markdig.Syntax;
using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Containers.Markdown; using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
namespace osu.Game.Graphics.Containers.Markdown namespace osu.Game.Graphics.Containers.Markdown
{ {
@ -21,6 +23,16 @@ namespace osu.Game.Graphics.Containers.Markdown
LineSpacing = 21; 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) protected override void AddMarkdownComponent(IMarkdownObject markdownObject, FillFlowContainer container, int level)
{ {
switch (markdownObject) switch (markdownObject)

View File

@ -1,48 +1,63 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using Markdig.Syntax.Inlines; using Markdig.Syntax.Inlines;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers.Markdown; using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Sprites; using osu.Game.Online.Chat;
using osu.Framework.Input.Events;
using osu.Game.Overlays; using osu.Game.Overlays;
namespace osu.Game.Graphics.Containers.Markdown namespace osu.Game.Graphics.Containers.Markdown
{ {
public class OsuMarkdownLinkText : MarkdownLinkText public class OsuMarkdownLinkText : MarkdownLinkText
{ {
[Resolved] [Resolved(canBeNull: true)]
private OverlayColourProvider colourProvider { get; set; } private OsuGame game { get; set; }
private SpriteText spriteText; private readonly string text;
private readonly string title;
public OsuMarkdownLinkText(string text, LinkInline linkInline) public OsuMarkdownLinkText(string text, LinkInline linkInline)
: base(text, linkInline) : base(text, linkInline)
{ {
this.text = text;
title = linkInline.Title;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() 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() protected override void OnLinkPressed() => game?.HandleLink(Url);
{
return spriteText = base.CreateSpriteText();
}
protected override bool OnHover(HoverEvent e) private class OsuMarkdownLinkCompiler : DrawableLinkCompiler
{ {
spriteText.Colour = colourProvider.Light1; public OsuMarkdownLinkCompiler(IEnumerable<Drawable> parts)
return base.OnHover(e); : base(parts)
} {
}
protected override void OnHoverLost(HoverLostEvent e) [BackgroundDependencyLoader]
{ private void load(OverlayColourProvider colourProvider)
spriteText.Colour = colourProvider.Light2; {
base.OnHoverLost(e); IdleColour = colourProvider.Light2;
HoverColour = colourProvider.Light1;
}
} }
} }
} }

View File

@ -15,6 +15,7 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Effects;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.UserInterface 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; get => text;
set set

View File

@ -9,28 +9,17 @@ namespace osu.Game.Graphics.UserInterface
/// <summary> /// <summary>
/// An <see cref="OsuMenuItem"/> with three possible states. /// An <see cref="OsuMenuItem"/> with three possible states.
/// </summary> /// </summary>
public class TernaryStateMenuItem : StatefulMenuItem<TernaryState> public abstract class TernaryStateMenuItem : StatefulMenuItem<TernaryState>
{ {
/// <summary> /// <summary>
/// Creates a new <see cref="TernaryStateMenuItem"/>. /// Creates a new <see cref="TernaryStateMenuItem"/>.
/// </summary> /// </summary>
/// <param name="text">The text to display.</param> /// <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="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> /// <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) protected TernaryStateMenuItem(string text, Func<TernaryState, TernaryState> nextStateFunction, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: this(text, getNextState, type, action) : base(text, nextStateFunction, 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)
{ {
} }
@ -47,23 +36,5 @@ namespace osu.Game.Graphics.UserInterface
return null; 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 else
{ {
KeyBindings = store.Query(ruleset?.ID, variant) 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 // 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 // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order. // 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Framework.Platform; using osu.Framework.Platform;
@ -16,6 +17,17 @@ namespace osu.Game.Input
{ {
public event Action KeyBindingChanged; 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) public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null)
: base(contextFactory, storage) : base(contextFactory, storage)
{ {
@ -93,6 +105,9 @@ namespace osu.Game.Input
using (ContextFactory.GetForWrite()) using (ContextFactory.GetForWrite())
{ {
var dbKeyBinding = (DatabasedKeyBinding)keyBinding; var dbKeyBinding = (DatabasedKeyBinding)keyBinding;
Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination));
Refresh(ref dbKeyBinding); Refresh(ref dbKeyBinding);
if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination)) if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination))
@ -103,5 +118,16 @@ namespace osu.Game.Input
KeyBindingChanged?.Invoke(); 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> public class GetNewsRequest : APIRequest<GetNewsResponse>
{ {
private readonly int? year;
private readonly Cursor cursor; private readonly Cursor cursor;
public GetNewsRequest(Cursor cursor = null) public GetNewsRequest(int? year = null, Cursor cursor = null)
{ {
this.year = year;
this.cursor = cursor; this.cursor = cursor;
} }
@ -19,6 +21,10 @@ namespace osu.Game.Online.API.Requests
{ {
var req = base.CreateWebRequest(); var req = base.CreateWebRequest();
req.AddCursor(cursor); req.AddCursor(cursor);
if (year.HasValue)
req.AddParameter("year", year.Value.ToString());
return req; return req;
} }

View File

@ -3,132 +3,621 @@
#nullable enable #nullable enable
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Rooms; 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 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; get
}
[BackgroundDependencyLoader]
private void load(IAPIProvider api)
{
connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint);
if (connector != null)
{ {
connector.ConfigureConnection = connection => var localUser = LocalUser;
{ return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
// 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) [Resolved]
{ protected IAPIProvider API { get; private set; } = null!;
if (!IsConnected.Value)
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
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() private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
{ private CancellationTokenSource? joinCancellationSource;
if (!IsConnected.Value)
return Task.FromCanceled(new CancellationToken(true));
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 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 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 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 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 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 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>(); if (Room == null)
var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId); 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) if (cancellationToken.IsCancellationRequested)
{ {
@ -136,20 +625,18 @@ namespace osu.Game.Online.Multiplayer
return; return;
} }
tcs.SetResult(res.ToBeatmapSet(Rulesets)); try
}; {
action();
req.Failure += e => tcs.SetException(e); tcs.SetResult(true);
}
API.Queue(req); catch (Exception ex)
{
tcs.SetException(ex);
}
});
return tcs.Task; 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 LogLevel = osu.Framework.Logging.LogLevel;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.IO; using osu.Game.IO;
using osu.Game.Localisation;
using osu.Game.Skinning.Editor; using osu.Game.Skinning.Editor;
namespace osu.Game namespace osu.Game
@ -100,6 +101,9 @@ namespace osu.Game
[Cached] [Cached]
private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender(); private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender();
[Cached]
private readonly StableImportManager stableImportManager = new StableImportManager();
[Cached] [Cached]
private readonly ScreenshotManager screenshotManager = new ScreenshotManager(); private readonly ScreenshotManager screenshotManager = new ScreenshotManager();
@ -559,6 +563,12 @@ namespace osu.Game
{ {
base.LoadComplete(); 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 // 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. // 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 // 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. // todo: all archive managers should be able to be looped here.
SkinManager.PostNotification = n => notifications.Post(n); SkinManager.PostNotification = n => notifications.Post(n);
SkinManager.GetStableStorage = GetStorageForStableInstall;
BeatmapManager.PostNotification = n => notifications.Post(n); BeatmapManager.PostNotification = n => notifications.Post(n);
BeatmapManager.GetStableStorage = GetStorageForStableInstall;
BeatmapManager.PresentImport = items => PresentBeatmap(items.First()); BeatmapManager.PresentImport = items => PresentBeatmap(items.First());
ScoreManager.PostNotification = n => notifications.Post(n); ScoreManager.PostNotification = n => notifications.Post(n);
ScoreManager.GetStableStorage = GetStorageForStableInstall;
ScoreManager.PresentImport = items => PresentScore(items.First()); ScoreManager.PresentImport = items => PresentScore(items.First());
// make config aware of how to lookup skins for on-screen display purposes. // make config aware of how to lookup skins for on-screen display purposes.
@ -690,10 +697,10 @@ namespace osu.Game
loadComponentSingleFile(new CollectionManager(Storage) loadComponentSingleFile(new CollectionManager(Storage)
{ {
PostNotification = n => notifications.Post(n), PostNotification = n => notifications.Post(n),
GetStableStorage = GetStorageForStableInstall
}, Add, true); }, Add, true);
loadComponentSingleFile(difficultyRecommender, Add); loadComponentSingleFile(difficultyRecommender, Add);
loadComponentSingleFile(stableImportManager, Add);
loadComponentSingleFile(screenshotManager, Add); loadComponentSingleFile(screenshotManager, Add);

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