Merge branch 'master' into import-from-stable-select-location

This commit is contained in:
Dean Herbert 2021-05-21 17:37:34 +09:00
commit 2958b9adf4
141 changed files with 3366 additions and 2071 deletions

View File

@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
} }
}; };
AddBlueprint(new HoldNoteSelectionBlueprint(drawableObject)); AddBlueprint(new HoldNoteSelectionBlueprint(holdNote), drawableObject);
} }
protected override void Update() protected override void Update()

View File

@ -184,8 +184,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddAssert("head note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.BottomLeft, holdNote.Head.ScreenSpaceDrawQuad.BottomLeft)); AddAssert("head note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.BottomLeft, holdNote.Head.ScreenSpaceDrawQuad.BottomLeft));
AddAssert("tail note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.TopLeft, holdNote.Tail.ScreenSpaceDrawQuad.BottomLeft)); AddAssert("tail note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.TopLeft, holdNote.Tail.ScreenSpaceDrawQuad.BottomLeft));
AddAssert("head blueprint positioned correctly", () => this.ChildrenOfType<HoldNoteNoteSelectionBlueprint>().ElementAt(0).DrawPosition == holdNote.Head.DrawPosition); AddAssert("head blueprint positioned correctly", () => this.ChildrenOfType<HoldNoteNoteOverlay>().ElementAt(0).DrawPosition == holdNote.Head.DrawPosition);
AddAssert("tail blueprint positioned correctly", () => this.ChildrenOfType<HoldNoteNoteSelectionBlueprint>().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition); AddAssert("tail blueprint positioned correctly", () => this.ChildrenOfType<HoldNoteNoteOverlay>().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition);
} }
private void setScrollStep(ScrollingDirection direction) private void setScrollStep(ScrollingDirection direction)

View File

@ -15,7 +15,6 @@ 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;
using osu.Game.Tests.Visual; using osu.Game.Tests.Visual;
using osuTK;
using osuTK.Input; using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Tests.Editor namespace osu.Game.Rulesets.Mania.Tests.Editor
@ -35,7 +34,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test] [Test]
public void TestPlaceBeforeCurrentTimeDownwards() public void TestPlaceBeforeCurrentTimeDownwards()
{ {
AddStep("move mouse before current time", () => InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Single().ScreenSpaceDrawQuad.BottomLeft - new Vector2(0, 10))); AddStep("move mouse before current time", () =>
{
var column = this.ChildrenOfType<Column>().Single();
InputManager.MoveMouseTo(column.ScreenSpacePositionAtTime(-100));
});
AddStep("click", () => InputManager.Click(MouseButton.Left)); AddStep("click", () => InputManager.Click(MouseButton.Left));
@ -45,7 +48,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test] [Test]
public void TestPlaceAfterCurrentTimeDownwards() public void TestPlaceAfterCurrentTimeDownwards()
{ {
AddStep("move mouse after current time", () => InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Single())); AddStep("move mouse after current time", () =>
{
var column = this.ChildrenOfType<Column>().Single();
InputManager.MoveMouseTo(column.ScreenSpacePositionAtTime(100));
});
AddStep("click", () => InputManager.Click(MouseButton.Left)); AddStep("click", () => InputManager.Click(MouseButton.Left));

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
Child = drawableObject = new DrawableNote(note) Child = drawableObject = new DrawableNote(note)
}; };
AddBlueprint(new NoteSelectionBlueprint(drawableObject)); AddBlueprint(new NoteSelectionBlueprint(note), drawableObject);
} }
} }
} }

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

@ -0,0 +1,67 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Tests.Visual;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.Tests
{
public class TestSceneDrawableManiaHitObject : OsuTestScene
{
private readonly ManualClock clock = new ManualClock();
private Column column;
[SetUp]
public void SetUp() => Schedule(() =>
{
Child = new ScrollingTestContainer(ScrollingDirection.Down)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.X,
RelativeSizeAxes = Axes.Y,
TimeRange = 2000,
Clock = new FramedClock(clock),
Child = column = new Column(0)
{
Action = { Value = ManiaAction.Key1 },
Height = 0.85f,
AccentColour = Color4.Gray
},
};
});
[Test]
public void TestHoldNoteHeadVisibility()
{
DrawableHoldNote note = null;
AddStep("Add hold note", () =>
{
var h = new HoldNote
{
StartTime = 0,
Duration = 1000
};
h.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
column.Add(note = new DrawableHoldNote(h));
});
AddStep("Hold key", () =>
{
clock.CurrentTime = 0;
note.OnPressed(ManiaAction.Key1);
});
AddStep("progress time", () => clock.CurrentTime = 500);
AddAssert("head is visible", () => note.Head.Alpha == 1);
}
}
}

View File

@ -5,13 +5,11 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Testing;
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;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
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.Mania.Scoring; using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -414,14 +412,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddUntilStep("wait for head", () => currentPlayer.GameplayClockContainer.GameplayClock.CurrentTime >= time_head); AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor?.HasCompleted.Value == true);
AddAssert("head is visible",
() => currentPlayer.ChildrenOfType<DrawableHoldNote>()
.Single(note => note.HitObject == beatmap.HitObjects[0])
.Head
.Alpha == 1);
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
} }
private class ScoreAccessibleReplayPlayer : ReplayPlayer private class ScoreAccessibleReplayPlayer : ReplayPlayer

View File

@ -2,34 +2,35 @@
// 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.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Objects.Drawables;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{ {
public class HoldNoteNoteSelectionBlueprint : ManiaSelectionBlueprint public class HoldNoteNoteOverlay : CompositeDrawable
{ {
protected new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; private readonly HoldNoteSelectionBlueprint holdNoteBlueprint;
private readonly HoldNotePosition position; private readonly HoldNotePosition position;
public HoldNoteNoteSelectionBlueprint(DrawableHoldNote holdNote, HoldNotePosition position) public HoldNoteNoteOverlay(HoldNoteSelectionBlueprint holdNoteBlueprint, HoldNotePosition position)
: base(holdNote)
{ {
this.holdNoteBlueprint = holdNoteBlueprint;
this.position = position; this.position = position;
InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X };
Select(); InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X };
} }
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
var drawableObject = holdNoteBlueprint.DrawableObject;
// Todo: This shouldn't exist, mania should not reference the drawable hitobject directly. // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly.
if (DrawableObject.IsLoaded) if (drawableObject.IsLoaded)
{ {
DrawableNote note = position == HoldNotePosition.Start ? (DrawableNote)DrawableObject.Head : DrawableObject.Tail; DrawableNote note = position == HoldNotePosition.Start ? (DrawableNote)drawableObject.Head : drawableObject.Tail;
Anchor = note.Anchor; Anchor = note.Anchor;
Origin = note.Origin; Origin = note.Origin;
@ -38,8 +39,5 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
Position = note.DrawPosition; Position = note.DrawPosition;
} }
} }
// Todo: This is temporary, since the note masks don't do anything special yet. In the future they will handle input.
public override bool HandlePositionalInput => false;
} }
} }

View File

@ -8,13 +8,14 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{ {
public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint<HoldNote>
{ {
public new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; public new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject;
@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
[Resolved] [Resolved]
private OsuColour colours { get; set; } private OsuColour colours { get; set; }
public HoldNoteSelectionBlueprint(DrawableHoldNote hold) public HoldNoteSelectionBlueprint(HoldNote hold)
: base(hold) : base(hold)
{ {
} }
@ -32,16 +33,11 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
private void load(IScrollingInfo scrollingInfo) private void load(IScrollingInfo scrollingInfo)
{ {
direction.BindTo(scrollingInfo.Direction); direction.BindTo(scrollingInfo.Direction);
}
protected override void LoadComplete()
{
base.LoadComplete();
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.Start), new HoldNoteNoteOverlay(this, HoldNotePosition.Start),
new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.End), new HoldNoteNoteOverlay(this, HoldNotePosition.End),
new Container new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,

View File

@ -4,22 +4,23 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{ {
public abstract class ManiaSelectionBlueprint : OverlaySelectionBlueprint public abstract class ManiaSelectionBlueprint<T> : HitObjectSelectionBlueprint<T>
where T : ManiaHitObject
{ {
public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject; public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject;
[Resolved] [Resolved]
private IScrollingInfo scrollingInfo { get; set; } private IScrollingInfo scrollingInfo { get; set; }
protected ManiaSelectionBlueprint(DrawableHitObject drawableObject) protected ManiaSelectionBlueprint(T hitObject)
: base(drawableObject) : base(hitObject)
{ {
RelativeSizeAxes = Axes.None; RelativeSizeAxes = Axes.None;
} }

View File

@ -3,13 +3,13 @@
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Objects;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{ {
public class NoteSelectionBlueprint : ManiaSelectionBlueprint public class NoteSelectionBlueprint : ManiaSelectionBlueprint<Note>
{ {
public NoteSelectionBlueprint(DrawableNote note) public NoteSelectionBlueprint(Note note)
: base(note) : base(note)
{ {
AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X }); AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X });

View File

@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Mania.Edit
foreach (var line in grid.Objects.OfType<DrawableGridLine>()) foreach (var line in grid.Objects.OfType<DrawableGridLine>())
availableLines.Push(line); availableLines.Push(line);
grid.Clear(false); grid.Clear();
} }
if (selectionTimeRange == null) if (selectionTimeRange == null)

View File

@ -3,9 +3,8 @@
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
namespace osu.Game.Rulesets.Mania.Edit namespace osu.Game.Rulesets.Mania.Edit
@ -17,18 +16,18 @@ namespace osu.Game.Rulesets.Mania.Edit
{ {
} }
public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject)
{ {
switch (hitObject) switch (hitObject)
{ {
case DrawableNote note: case Note note:
return new NoteSelectionBlueprint(note); return new NoteSelectionBlueprint(note);
case DrawableHoldNote holdNote: case HoldNote holdNote:
return new HoldNoteSelectionBlueprint(holdNote); return new HoldNoteSelectionBlueprint(holdNote);
} }
return base.CreateBlueprintFor(hitObject); return base.CreateHitObjectBlueprintFor(hitObject);
} }
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new ManiaSelectionHandler(); protected override SelectionHandler<HitObject> CreateSelectionHandler() => new ManiaSelectionHandler();

View File

@ -5,7 +5,6 @@ using System;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
@ -23,8 +22,8 @@ namespace osu.Game.Rulesets.Mania.Edit
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent) public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent)
{ {
var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint; var hitObjectBlueprint = (HitObjectSelectionBlueprint)moveEvent.Blueprint;
int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column; int lastColumn = ((ManiaHitObject)hitObjectBlueprint.Item).Column;
performColumnMovement(lastColumn, moveEvent); performColumnMovement(lastColumn, moveEvent);
@ -59,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

@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 });
Add(drawableObject = new DrawableHitCircle(hitCircle)); Add(drawableObject = new DrawableHitCircle(hitCircle));
AddBlueprint(blueprint = new TestBlueprint(drawableObject)); AddBlueprint(blueprint = new TestBlueprint(hitCircle), drawableObject);
}); });
[Test] [Test]
@ -63,8 +63,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{ {
public new HitCirclePiece CirclePiece => base.CirclePiece; public new HitCirclePiece CirclePiece => base.CirclePiece;
public TestBlueprint(DrawableHitCircle drawableCircle) public TestBlueprint(HitCircle circle)
: base(drawableCircle) : base(circle)
{ {
} }
} }

View File

@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 });
Add(drawableObject = new DrawableSlider(slider)); Add(drawableObject = new DrawableSlider(slider));
AddBlueprint(new TestSliderBlueprint(drawableObject)); AddBlueprint(new TestSliderBlueprint(slider), drawableObject);
}); });
[Test] [Test]
@ -150,23 +150,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private class TestSliderBlueprint : SliderSelectionBlueprint private class TestSliderBlueprint : SliderSelectionBlueprint
{ {
public new SliderBodyPiece BodyPiece => base.BodyPiece; public new SliderBodyPiece BodyPiece => base.BodyPiece;
public new TestSliderCircleBlueprint HeadBlueprint => (TestSliderCircleBlueprint)base.HeadBlueprint; public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay;
public new TestSliderCircleBlueprint TailBlueprint => (TestSliderCircleBlueprint)base.TailBlueprint; public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay;
public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser;
public TestSliderBlueprint(DrawableSlider slider) public TestSliderBlueprint(Slider slider)
: base(slider) : base(slider)
{ {
} }
protected override SliderCircleSelectionBlueprint CreateCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) => new TestSliderCircleBlueprint(slider, position); protected override SliderCircleOverlay CreateCircleOverlay(Slider slider, SliderPosition position) => new TestSliderCircleOverlay(slider, position);
} }
private class TestSliderCircleBlueprint : SliderCircleSelectionBlueprint private class TestSliderCircleOverlay : SliderCircleOverlay
{ {
public new HitCirclePiece CirclePiece => base.CirclePiece; public new HitCirclePiece CirclePiece => base.CirclePiece;
public TestSliderCircleBlueprint(DrawableSlider slider, SliderPosition position) public TestSliderCircleOverlay(Slider slider, SliderPosition position)
: base(slider, position) : base(slider, position)
{ {
} }

View File

@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 });
Add(drawableObject = new DrawableSlider(slider)); Add(drawableObject = new DrawableSlider(slider));
AddBlueprint(blueprint = new TestSliderBlueprint(drawableObject)); AddBlueprint(blueprint = new TestSliderBlueprint(slider), drawableObject);
}); });
[Test] [Test]
@ -174,10 +174,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddAssert("body positioned correctly", () => blueprint.BodyPiece.Position == slider.StackedPosition); AddAssert("body positioned correctly", () => blueprint.BodyPiece.Position == slider.StackedPosition);
AddAssert("head positioned correctly", AddAssert("head positioned correctly",
() => Precision.AlmostEquals(blueprint.HeadBlueprint.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.HeadCircle.ScreenSpaceDrawQuad.Centre)); () => Precision.AlmostEquals(blueprint.HeadOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.HeadCircle.ScreenSpaceDrawQuad.Centre));
AddAssert("tail positioned correctly", AddAssert("tail positioned correctly",
() => Precision.AlmostEquals(blueprint.TailBlueprint.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre)); () => Precision.AlmostEquals(blueprint.TailOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre));
} }
private void moveMouseToControlPoint(int index) private void moveMouseToControlPoint(int index)
@ -195,23 +195,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private class TestSliderBlueprint : SliderSelectionBlueprint private class TestSliderBlueprint : SliderSelectionBlueprint
{ {
public new SliderBodyPiece BodyPiece => base.BodyPiece; public new SliderBodyPiece BodyPiece => base.BodyPiece;
public new TestSliderCircleBlueprint HeadBlueprint => (TestSliderCircleBlueprint)base.HeadBlueprint; public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay;
public new TestSliderCircleBlueprint TailBlueprint => (TestSliderCircleBlueprint)base.TailBlueprint; public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay;
public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser;
public TestSliderBlueprint(DrawableSlider slider) public TestSliderBlueprint(Slider slider)
: base(slider) : base(slider)
{ {
} }
protected override SliderCircleSelectionBlueprint CreateCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) => new TestSliderCircleBlueprint(slider, position); protected override SliderCircleOverlay CreateCircleOverlay(Slider slider, SliderPosition position) => new TestSliderCircleOverlay(slider, position);
} }
private class TestSliderCircleBlueprint : SliderCircleSelectionBlueprint private class TestSliderCircleOverlay : SliderCircleOverlay
{ {
public new HitCirclePiece CirclePiece => base.CirclePiece; public new HitCirclePiece CirclePiece => base.CirclePiece;
public TestSliderCircleBlueprint(DrawableSlider slider, SliderPosition position) public TestSliderCircleOverlay(Slider slider, SliderPosition position)
: base(slider, position) : base(slider, position)
{ {
} }

View File

@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
Child = drawableSpinner = new DrawableSpinner(spinner) Child = drawableSpinner = new DrawableSpinner(spinner)
}); });
AddBlueprint(new SpinnerSelectionBlueprint(drawableSpinner) { Size = new Vector2(0.5f) }); AddBlueprint(new SpinnerSelectionBlueprint(spinner) { Size = new Vector2(0.5f) }, drawableSpinner);
} }
} }
} }

View File

@ -15,8 +15,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
protected readonly HitCirclePiece CirclePiece; protected readonly HitCirclePiece CirclePiece;
public HitCircleSelectionBlueprint(DrawableHitCircle drawableCircle) public HitCircleSelectionBlueprint(HitCircle circle)
: base(drawableCircle) : base(circle)
{ {
InternalChild = CirclePiece = new HitCirclePiece(); InternalChild = CirclePiece = new HitCirclePiece();
} }

View File

@ -2,20 +2,20 @@
// 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.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints namespace osu.Game.Rulesets.Osu.Edit.Blueprints
{ {
public abstract class OsuSelectionBlueprint<T> : OverlaySelectionBlueprint public abstract class OsuSelectionBlueprint<T> : HitObjectSelectionBlueprint<T>
where T : OsuHitObject where T : OsuHitObject
{ {
protected T HitObject => (T)DrawableObject.HitObject; protected new DrawableOsuHitObject DrawableObject => (DrawableOsuHitObject)base.DrawableObject;
protected override bool AlwaysShowWhenSelected => true; protected override bool AlwaysShowWhenSelected => true;
protected OsuSelectionBlueprint(DrawableHitObject drawableObject) protected OsuSelectionBlueprint(T hitObject)
: base(drawableObject) : base(hitObject)
{ {
} }
} }

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

@ -1,36 +1,32 @@
// 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.Containers;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{ {
public class SliderCircleSelectionBlueprint : OsuSelectionBlueprint<Slider> public class SliderCircleOverlay : CompositeDrawable
{ {
protected readonly HitCirclePiece CirclePiece; protected readonly HitCirclePiece CirclePiece;
private readonly Slider slider;
private readonly SliderPosition position; private readonly SliderPosition position;
public SliderCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) public SliderCircleOverlay(Slider slider, SliderPosition position)
: base(slider)
{ {
this.slider = slider;
this.position = position; this.position = position;
InternalChild = CirclePiece = new HitCirclePiece(); InternalChild = CirclePiece = new HitCirclePiece();
Select();
} }
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
CirclePiece.UpdateFrom(position == SliderPosition.Start ? (HitCircle)HitObject.HeadCircle : HitObject.TailCircle); CirclePiece.UpdateFrom(position == SliderPosition.Start ? (HitCircle)slider.HeadCircle : slider.TailCircle);
} }
// Todo: This is temporary, since the slider circle masks don't do anything special yet. In the future they will handle input.
public override bool HandlePositionalInput => false;
} }
} }

View File

@ -16,7 +16,6 @@ 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;
@ -27,14 +26,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
public class SliderSelectionBlueprint : OsuSelectionBlueprint<Slider> public class SliderSelectionBlueprint : OsuSelectionBlueprint<Slider>
{ {
protected SliderBodyPiece BodyPiece { get; private set; } protected SliderBodyPiece BodyPiece { get; private set; }
protected SliderCircleSelectionBlueprint HeadBlueprint { get; private set; } protected SliderCircleOverlay HeadOverlay { get; private set; }
protected SliderCircleSelectionBlueprint TailBlueprint { get; private set; } protected SliderCircleOverlay TailOverlay { get; private set; }
[CanBeNull] [CanBeNull]
protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } protected PathControlPointVisualiser ControlPointVisualiser { get; private set; }
private readonly DrawableSlider slider;
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private HitObjectComposer composer { get; set; } private HitObjectComposer composer { get; set; }
@ -52,10 +49,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private readonly BindableList<PathControlPoint> controlPoints = new BindableList<PathControlPoint>(); private readonly BindableList<PathControlPoint> controlPoints = new BindableList<PathControlPoint>();
private readonly IBindable<int> pathVersion = new Bindable<int>(); private readonly IBindable<int> pathVersion = new Bindable<int>();
public SliderSelectionBlueprint(DrawableSlider slider) public SliderSelectionBlueprint(Slider slider)
: base(slider) : base(slider)
{ {
this.slider = slider;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -64,8 +60,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
BodyPiece = new SliderBodyPiece(), BodyPiece = new SliderBodyPiece(),
HeadBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.Start), HeadOverlay = CreateCircleOverlay(HitObject, SliderPosition.Start),
TailBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.End), TailOverlay = CreateCircleOverlay(HitObject, SliderPosition.End),
}; };
} }
@ -103,7 +99,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
protected override void OnSelected() protected override void OnSelected()
{ {
AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(slider.HitObject, true) AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true)
{ {
RemoveControlPointsRequested = removeControlPoints RemoveControlPointsRequested = removeControlPoints
}); });
@ -215,7 +211,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
} }
// If there are 0 or 1 remaining control points, the slider is in a degenerate (single point) form and should be deleted // If there are 0 or 1 remaining control points, the slider is in a degenerate (single point) form and should be deleted
if (controlPoints.Count <= 1 || !slider.HitObject.Path.HasValidLength) if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLength)
{ {
placementHandler?.Delete(HitObject); placementHandler?.Delete(HitObject);
return; return;
@ -245,6 +241,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
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;
protected virtual SliderCircleSelectionBlueprint CreateCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) => new SliderCircleSelectionBlueprint(slider, position); protected virtual SliderCircleOverlay CreateCircleOverlay(Slider slider, SliderPosition position) => new SliderCircleOverlay(slider, position);
} }
} }

View File

@ -3,7 +3,6 @@
using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners
@ -12,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners
{ {
private readonly SpinnerPiece piece; private readonly SpinnerPiece piece;
public SpinnerSelectionBlueprint(DrawableSpinner spinner) public SpinnerSelectionBlueprint(Spinner spinner)
: base(spinner) : base(spinner)
{ {
InternalChild = piece = new SpinnerPiece(); InternalChild = piece = new SpinnerPiece();

View File

@ -3,11 +3,10 @@
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners;
using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
namespace osu.Game.Rulesets.Osu.Edit namespace osu.Game.Rulesets.Osu.Edit
@ -21,21 +20,21 @@ namespace osu.Game.Rulesets.Osu.Edit
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new OsuSelectionHandler(); protected override SelectionHandler<HitObject> CreateSelectionHandler() => new OsuSelectionHandler();
public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject)
{ {
switch (hitObject) switch (hitObject)
{ {
case DrawableHitCircle circle: case HitCircle circle:
return new HitCircleSelectionBlueprint(circle); return new HitCircleSelectionBlueprint(circle);
case DrawableSlider slider: case Slider slider:
return new SliderSelectionBlueprint(slider); return new SliderSelectionBlueprint(slider);
case DrawableSpinner spinner: case Spinner spinner:
return new SpinnerSelectionBlueprint(spinner); return new SpinnerSelectionBlueprint(spinner);
} }
return base.CreateBlueprintFor(hitObject); return base.CreateHitObjectBlueprintFor(hitObject);
} }
} }
} }

View File

@ -12,12 +12,23 @@ 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 osuTK; using Vector2 = osuTK.Vector2;
namespace osu.Game.Rulesets.Osu.Edit namespace osu.Game.Rulesets.Osu.Edit
{ {
public class OsuSelectionHandler : EditorSelectionHandler public class OsuSelectionHandler : EditorSelectionHandler
{ {
/// <summary>
/// During a transform, the initial origin is stored so it can be used throughout the operation.
/// </summary>
private Vector2? referenceOrigin;
/// <summary>
/// During a transform, the initial path types of a single selected slider are stored so they
/// can be maintained throughout the operation.
/// </summary>
private List<PathType?> referencePathTypes;
protected override void OnSelectionChanged() protected override void OnSelectionChanged()
{ {
base.OnSelectionChanged(); base.OnSelectionChanged();
@ -50,17 +61,6 @@ namespace osu.Game.Rulesets.Osu.Edit
return true; return true;
} }
/// <summary>
/// During a transform, the initial origin is stored so it can be used throughout the operation.
/// </summary>
private Vector2? referenceOrigin;
/// <summary>
/// During a transform, the initial path types of a single selected slider are stored so they
/// can be maintained throughout the operation.
/// </summary>
private List<PathType?> referencePathTypes;
public override bool HandleReverse() public override bool HandleReverse()
{ {
var hitObjects = EditorBeatmap.SelectedHitObjects; var hitObjects = EditorBeatmap.SelectedHitObjects;
@ -114,24 +114,10 @@ namespace osu.Game.Rulesets.Osu.Edit
var hitObjects = selectedMovableObjects; var hitObjects = selectedMovableObjects;
var selectedObjectsQuad = getSurroundingQuad(hitObjects); var selectedObjectsQuad = getSurroundingQuad(hitObjects);
var centre = selectedObjectsQuad.Centre;
foreach (var h in hitObjects) foreach (var h in hitObjects)
{ {
var pos = h.Position; h.Position = GetFlippedPosition(direction, selectedObjectsQuad, h.Position);
switch (direction)
{
case Direction.Horizontal:
pos.X = centre.X - (pos.X - centre.X);
break;
case Direction.Vertical:
pos.Y = centre.Y - (pos.Y - centre.Y);
break;
}
h.Position = pos;
if (h is Slider slider) if (h is Slider slider)
{ {
@ -204,7 +190,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type.Value).ToList(); referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type.Value).ToList();
Quad sliderQuad = getSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position.Value)); Quad sliderQuad = GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position.Value));
// Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0. // Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0.
scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size; scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size;
@ -333,7 +319,7 @@ namespace osu.Game.Rulesets.Osu.Edit
/// </summary> /// </summary>
/// <param name="hitObjects">The hit objects to calculate a quad for.</param> /// <param name="hitObjects">The hit objects to calculate a quad for.</param>
private Quad getSurroundingQuad(OsuHitObject[] hitObjects) => private Quad getSurroundingQuad(OsuHitObject[] hitObjects) =>
getSurroundingQuad(hitObjects.SelectMany(h => GetSurroundingQuad(hitObjects.SelectMany(h =>
{ {
if (h is IHasPath path) if (h is IHasPath path)
{ {
@ -348,30 +334,6 @@ namespace osu.Game.Rulesets.Osu.Edit
return new[] { h.Position }; return new[] { h.Position };
})); }));
/// <summary>
/// Returns a gamefield-space quad surrounding the provided points.
/// </summary>
/// <param name="points">The points to calculate a quad for.</param>
private Quad getSurroundingQuad(IEnumerable<Vector2> points)
{
if (!EditorBeatmap.SelectedHitObjects.Any())
return new Quad();
Vector2 minPosition = new Vector2(float.MaxValue, float.MaxValue);
Vector2 maxPosition = new Vector2(float.MinValue, float.MinValue);
// Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted
foreach (var p in points)
{
minPosition = Vector2.ComponentMin(minPosition, p);
maxPosition = Vector2.ComponentMax(maxPosition, p);
}
Vector2 size = maxPosition - minPosition;
return new Quad(minPosition.X, minPosition.Y, size.X, size.Y);
}
/// <summary> /// <summary>
/// All osu! hitobjects which can be moved/rotated/scaled. /// All osu! hitobjects which can be moved/rotated/scaled.
/// </summary> /// </summary>

View File

@ -166,7 +166,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
var point = new HitPoint(pointType, this) var point = new HitPoint(pointType, this)
{ {
Colour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255) BaseColour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255)
}; };
points[r][c] = point; points[r][c] = point;
@ -234,6 +234,11 @@ namespace osu.Game.Rulesets.Osu.Statistics
private class HitPoint : Circle private class HitPoint : Circle
{ {
/// <summary>
/// The base colour which will be lightened/darkened depending on the value of this <see cref="HitPoint"/>.
/// </summary>
public Color4 BaseColour;
private readonly HitPointType pointType; private readonly HitPointType pointType;
private readonly AccuracyHeatmap heatmap; private readonly AccuracyHeatmap heatmap;
@ -284,7 +289,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
Alpha = Math.Min(amount / lighten_cutoff, 1); Alpha = Math.Min(amount / lighten_cutoff, 1);
if (pointType == HitPointType.Hit) if (pointType == HitPointType.Hit)
Colour = ((Color4)Colour).Lighten(Math.Max(0, amount - lighten_cutoff)); Colour = BaseColour.Lighten(Math.Max(0, amount - lighten_cutoff));
} }
} }

View File

@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
[SetUpSteps] [SetUpSteps]
public void SetUp() public void SetUp()
=> AddStep("clear SHOC", () => hitObjectContainer.Clear(false)); => AddStep("clear SHOC", () => hitObjectContainer.Clear());
protected void AddHitObject(DrawableHitObject hitObject) protected void AddHitObject(DrawableHitObject hitObject)
=> AddStep("add to SHOC", () => hitObjectContainer.Add(hitObject)); => AddStep("add to SHOC", () => hitObjectContainer.Add(hitObject));

View File

@ -3,14 +3,14 @@
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Taiko.Edit.Blueprints namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
{ {
public class TaikoSelectionBlueprint : OverlaySelectionBlueprint public class TaikoSelectionBlueprint : HitObjectSelectionBlueprint
{ {
public TaikoSelectionBlueprint(DrawableHitObject hitObject) public TaikoSelectionBlueprint(HitObject hitObject)
: base(hitObject) : base(hitObject)
{ {
RelativeSizeAxes = Axes.None; RelativeSizeAxes = Axes.None;

View File

@ -3,7 +3,6 @@
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Edit.Blueprints; using osu.Game.Rulesets.Taiko.Edit.Blueprints;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
@ -18,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new TaikoSelectionHandler(); protected override SelectionHandler<HitObject> CreateSelectionHandler() => new TaikoSelectionHandler();
public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) =>
new TaikoSelectionBlueprint(hitObject); new TaikoSelectionBlueprint(hitObject);
} }
} }

View File

@ -76,10 +76,10 @@ namespace osu.Game.Rulesets.Taiko.Edit
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

@ -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 NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit.Compose;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Editing
{
[HeadlessTest]
public class TestSceneHitObjectContainerEventBuffer : OsuTestScene
{
private readonly TestHitObject testObj = new TestHitObject();
private TestPlayfield playfield1;
private TestPlayfield playfield2;
private TestDrawable intermediateDrawable;
private HitObjectUsageEventBuffer eventBuffer;
private HitObject beganUsage;
private HitObject finishedUsage;
private HitObject transferredUsage;
[SetUp]
public void Setup() => Schedule(() =>
{
reset();
if (eventBuffer != null)
{
eventBuffer.HitObjectUsageBegan -= onHitObjectUsageBegan;
eventBuffer.HitObjectUsageFinished -= onHitObjectUsageFinished;
eventBuffer.HitObjectUsageTransferred -= onHitObjectUsageTransferred;
}
var topPlayfield = new TestPlayfield();
topPlayfield.AddNested(playfield1 = new TestPlayfield());
topPlayfield.AddNested(playfield2 = new TestPlayfield());
eventBuffer = new HitObjectUsageEventBuffer(topPlayfield);
eventBuffer.HitObjectUsageBegan += onHitObjectUsageBegan;
eventBuffer.HitObjectUsageFinished += onHitObjectUsageFinished;
eventBuffer.HitObjectUsageTransferred += onHitObjectUsageTransferred;
Children = new Drawable[]
{
topPlayfield,
intermediateDrawable = new TestDrawable(),
};
});
private void onHitObjectUsageBegan(HitObject obj) => beganUsage = obj;
private void onHitObjectUsageFinished(HitObject obj) => finishedUsage = obj;
private void onHitObjectUsageTransferred(HitObject obj, DrawableHitObject drawableObj) => transferredUsage = obj;
[Test]
public void TestUsageBeganAfterAdd()
{
AddStep("add hitobject", () => playfield1.Add(testObj));
addCheckStep(began: true);
}
[Test]
public void TestUsageFinishedAfterRemove()
{
AddStep("add hitobject", () => playfield1.Add(testObj));
addResetStep();
AddStep("remove hitobject", () => playfield1.Remove(testObj));
addCheckStep(finished: true);
}
[Test]
public void TestUsageTransferredWhenMovedBetweenPlayfields()
{
AddStep("add hitobject", () => playfield1.Add(testObj));
addResetStep();
AddStep("transfer hitobject to other playfield", () =>
{
playfield1.Remove(testObj);
playfield2.Add(testObj);
});
addCheckStep(transferred: true);
}
[Test]
public void TestRemoveImmediatelyAfterUsageBegan()
{
AddStep("add hitobject and schedule removal", () =>
{
playfield1.Add(testObj);
intermediateDrawable.Schedule(() => playfield1.Remove(testObj));
});
addCheckStep(began: true, finished: true);
}
[Test]
public void TestRemoveImmediatelyAfterTransferred()
{
AddStep("add hitobject", () => playfield1.Add(testObj));
addResetStep();
AddStep("transfer hitobject to other playfield and schedule removal", () =>
{
playfield1.Remove(testObj);
playfield2.Add(testObj);
intermediateDrawable.Schedule(() => playfield2.Remove(testObj));
});
addCheckStep(transferred: true, finished: true);
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
eventBuffer.Update();
}
private void addResetStep() => AddStep("reset", reset);
private void reset()
{
beganUsage = null;
finishedUsage = null;
transferredUsage = null;
}
private void addCheckStep(bool began = false, bool finished = false, bool transferred = false)
=> AddAssert($"began = {began}, finished = {finished}, transferred = {transferred}",
() => (beganUsage == testObj) == began && (finishedUsage == testObj) == finished && (transferredUsage == testObj) == transferred);
private class TestPlayfield : Playfield
{
public TestPlayfield()
{
RegisterPool<TestHitObject, TestDrawableHitObject>(1);
}
public new void AddNested(Playfield playfield)
{
AddInternal(playfield);
base.AddNested(playfield);
}
protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject)
{
var entry = base.CreateLifetimeEntry(hitObject);
entry.KeepAlive = true;
return entry;
}
}
private class TestHitObject : HitObject
{
public override string ToString() => "TestHitObject";
}
private class TestDrawableHitObject : DrawableHitObject
{
}
private class TestDrawable : Drawable
{
public new void Schedule(Action action) => base.Schedule(action);
}
}
}

View File

@ -45,15 +45,16 @@ namespace osu.Game.Tests.Gameplay
AddStep("Create DHO", () => AddStep("Create DHO", () =>
{ {
dho = new TestDrawableHitObject(null); dho = new TestDrawableHitObject(null);
dho.Apply(entry = new TestLifetimeEntry(new HitObject()) dho.Apply(entry = new TestLifetimeEntry(new HitObject()));
{
LifetimeStart = 0,
LifetimeEnd = 1000,
});
Child = dho; Child = dho;
}); });
AddStep("KeepAlive = true", () => entry.KeepAlive = true); AddStep("KeepAlive = true", () =>
{
entry.LifetimeStart = 0;
entry.LifetimeEnd = 1000;
entry.KeepAlive = true;
});
AddAssert("Lifetime is overriden", () => entry.LifetimeStart == double.MinValue && entry.LifetimeEnd == double.MaxValue); AddAssert("Lifetime is overriden", () => entry.LifetimeStart == double.MinValue && entry.LifetimeEnd == double.MaxValue);
AddStep("Set LifetimeStart", () => dho.LifetimeStart = 500); AddStep("Set LifetimeStart", () => dho.LifetimeStart = 500);

View File

@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private void seekToBreak(int breakIndex) private void seekToBreak(int breakIndex)
{ {
AddStep($"seek to break {breakIndex}", () => Player.GameplayClockContainer.Seek(destBreak().StartTime)); AddStep($"seek to break {breakIndex}", () => Player.GameplayClockContainer.Seek(destBreak().StartTime));
AddUntilStep("wait for seek to complete", () => Player.HUDOverlay.Progress.ReferenceClock.CurrentTime >= destBreak().StartTime); AddUntilStep("wait for seek to complete", () => Player.DrawableRuleset.FrameStableClock.CurrentTime >= destBreak().StartTime);
BreakPeriod destBreak() => Beatmap.Value.Beatmap.Breaks.ElementAt(breakIndex); BreakPeriod destBreak() => Beatmap.Value.Beatmap.Breaks.ElementAt(breakIndex);
} }

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

@ -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

@ -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,10 @@ 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(); Client.CurrentMatchPlayingUserIds.Clear();
Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers); Client.CurrentMatchPlayingUserIds.AddRange(spectatorClient.PlayingUsers);
Children = new Drawable[] Children = new Drawable[]
{ {
@ -83,7 +83,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,
@ -96,7 +96,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[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 +109,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

@ -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,152 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Testing;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.News.Sidebar;
using static osu.Game.Overlays.News.Sidebar.YearsPanel;
namespace osu.Game.Tests.Visual.Online
{
public class TestSceneNewsSidebar : OsuTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
private TestNewsSidebar sidebar;
[SetUp]
public void SetUp() => Schedule(() => Child = sidebar = new TestNewsSidebar { YearChanged = onYearChanged });
[Test]
public void TestBasic()
{
AddStep("Add metadata", () => sidebar.Metadata.Value = getMetadata(2021));
AddUntilStep("Month sections exist", () => sidebar.ChildrenOfType<MonthSection>().Any());
}
[Test]
public void TestMetadataWithNoPosts()
{
AddStep("Add data with no posts", () => sidebar.Metadata.Value = metadata_with_no_posts);
AddUntilStep("No month sections were created", () => !sidebar.ChildrenOfType<MonthSection>().Any());
}
[Test]
public void TestYearsPanelVisibility()
{
AddUntilStep("Years panel is hidden", () => yearsPanel?.Alpha == 0);
AddStep("Add data", () => sidebar.Metadata.Value = getMetadata(2021));
AddUntilStep("Years panel is visible", () => yearsPanel?.Alpha == 1);
}
private void onYearChanged(int year) => sidebar.Metadata.Value = getMetadata(year);
private YearsPanel yearsPanel => sidebar.ChildrenOfType<YearsPanel>().FirstOrDefault();
private APINewsSidebar getMetadata(int year) => new APINewsSidebar
{
CurrentYear = year,
Years = new[]
{
2021,
2020,
2019,
2018,
2017,
2016,
2015,
2014,
2013
},
NewsPosts = new List<APINewsPost>
{
new APINewsPost
{
Title = "(Mar) Short title",
PublishedAt = new DateTime(year, 3, 1)
},
new APINewsPost
{
Title = "(Mar) Oh boy that's a long post title I wonder if it will break anything",
PublishedAt = new DateTime(year, 3, 1)
},
new APINewsPost
{
Title = "(Mar) Medium title, nothing to see here",
PublishedAt = new DateTime(year, 3, 1)
},
new APINewsPost
{
Title = "(Feb) Short title",
PublishedAt = new DateTime(year, 2, 1)
},
new APINewsPost
{
Title = "(Feb) Oh boy that's a long post title I wonder if it will break anything",
PublishedAt = new DateTime(year, 2, 1)
},
new APINewsPost
{
Title = "(Feb) Medium title, nothing to see here",
PublishedAt = new DateTime(year, 2, 1)
},
new APINewsPost
{
Title = "Short title",
PublishedAt = new DateTime(year, 1, 1)
},
new APINewsPost
{
Title = "Oh boy that's a long post title I wonder if it will break anything",
PublishedAt = new DateTime(year, 1, 1)
},
new APINewsPost
{
Title = "Medium title, nothing to see here",
PublishedAt = new DateTime(year, 1, 1)
}
}
};
private static readonly APINewsSidebar metadata_with_no_posts = new APINewsSidebar
{
CurrentYear = 2021,
Years = new[]
{
2021,
2020,
2019,
2018,
2017,
2016,
2015,
2014,
2013
},
NewsPosts = Array.Empty<APINewsPost>()
};
private class TestNewsSidebar : NewsSidebar
{
public Action<int> YearChanged;
protected override void LoadComplete()
{
base.LoadComplete();
Metadata.BindValueChanged(metadata =>
{
foreach (var b in this.ChildrenOfType<YearButton>())
b.Action = () => YearChanged?.Invoke(b.Year);
}, true);
}
}
}
}

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

@ -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

@ -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

@ -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

@ -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

@ -11,5 +11,8 @@ namespace osu.Game.Online.API.Requests
{ {
[JsonProperty("news_posts")] [JsonProperty("news_posts")]
public IEnumerable<APINewsPost> NewsPosts; public IEnumerable<APINewsPost> NewsPosts;
[JsonProperty("news_sidebar")]
public APINewsSidebar SidebarMetadata;
} }
} }

View File

@ -0,0 +1,20 @@
// 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 Newtonsoft.Json;
using System.Collections.Generic;
namespace osu.Game.Online.API.Requests.Responses
{
public class APINewsSidebar
{
[JsonProperty("current_year")]
public int CurrentYear { get; set; }
[JsonProperty("news_posts")]
public IEnumerable<APINewsPost> NewsPosts { get; set; }
[JsonProperty("years")]
public int[] Years { get; set; }
}
}

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osuTK; using osuTK;
namespace osu.Game.Online.Chat namespace osu.Game.Online.Chat
@ -20,7 +21,10 @@ namespace osu.Game.Online.Chat
/// <summary> /// <summary>
/// Each word part of a chat link (split for word-wrap support). /// Each word part of a chat link (split for word-wrap support).
/// </summary> /// </summary>
public List<Drawable> Parts; public readonly List<Drawable> Parts;
[Resolved(CanBeNull = true)]
private OverlayColourProvider overlayColourProvider { get; set; }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parts.Any(d => d.ReceivePositionalInputAt(screenSpacePos)); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parts.Any(d => d.ReceivePositionalInputAt(screenSpacePos));
@ -34,7 +38,7 @@ namespace osu.Game.Online.Chat
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuColour colours) private void load(OsuColour colours)
{ {
IdleColour = colours.Blue; IdleColour = overlayColourProvider?.Light2 ?? colours.Blue;
} }
protected override IEnumerable<Drawable> EffectTargets => Parts; protected override IEnumerable<Drawable> EffectTargets => Parts;

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,647 +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!;
// Only exists for compatibility with old osu-server-spectator build.
// Todo: Can be removed on 2021/02/26.
private long defaultPlaylistItemId;
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;
defaultPlaylistItemId = apiRoom.Playlist.FirstOrDefault()?.ID ?? 0;
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 == 0 ? defaultPlaylistItemId : 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

@ -1,15 +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.
#nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Online.API; using osu.Game.Online.API;
@ -23,121 +24,89 @@ using osu.Game.Screens.Play;
namespace osu.Game.Online.Spectator namespace osu.Game.Online.Spectator
{ {
public class SpectatorStreamingClient : Component, ISpectatorClient public abstract class SpectatorClient : Component, ISpectatorClient
{ {
/// <summary> /// <summary>
/// The maximum milliseconds between frame bundle sends. /// The maximum milliseconds between frame bundle sends.
/// </summary> /// </summary>
public const double TIME_BETWEEN_SENDS = 200; public const double TIME_BETWEEN_SENDS = 200;
private readonly string endpoint; /// <summary>
/// Whether the <see cref="SpectatorClient"/> is currently connected.
[CanBeNull] /// This is NOT thread safe and usage should be scheduled.
private IHubClientConnector connector; /// </summary>
public abstract IBindable<bool> IsConnected { get; }
private readonly IBindable<bool> isConnected = new BindableBool();
private HubConnection connection => connector?.CurrentConnection;
private readonly List<int> watchingUsers = new List<int>(); private readonly List<int> watchingUsers = new List<int>();
private readonly object userLock = new object();
public IBindableList<int> PlayingUsers => playingUsers; public IBindableList<int> PlayingUsers => playingUsers;
private readonly BindableList<int> playingUsers = new BindableList<int>(); private readonly BindableList<int> playingUsers = new BindableList<int>();
private readonly Dictionary<int, SpectatorState> playingUserStates = new Dictionary<int, SpectatorState>(); private readonly Dictionary<int, SpectatorState> playingUserStates = new Dictionary<int, SpectatorState>();
[CanBeNull] private IBeatmap? currentBeatmap;
private IBeatmap currentBeatmap;
[CanBeNull] private Score? currentScore;
private Score currentScore;
[Resolved] [Resolved]
private IBindable<RulesetInfo> currentRuleset { get; set; } private IBindable<RulesetInfo> currentRuleset { get; set; } = null!;
[Resolved] [Resolved]
private IBindable<IReadOnlyList<Mod>> currentMods { get; set; } private IBindable<IReadOnlyList<Mod>> currentMods { get; set; } = null!;
private readonly SpectatorState currentState = new SpectatorState(); private readonly SpectatorState currentState = new SpectatorState();
private bool isPlaying; /// <summary>
/// Whether the local user is playing.
/// </summary>
protected bool IsPlaying { get; private set; }
/// <summary> /// <summary>
/// Called whenever new frames arrive from the server. /// Called whenever new frames arrive from the server.
/// </summary> /// </summary>
public event Action<int, FrameDataBundle> OnNewFrames; public event Action<int, FrameDataBundle>? OnNewFrames;
/// <summary> /// <summary>
/// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session. /// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session.
/// </summary> /// </summary>
public event Action<int, SpectatorState> OnUserBeganPlaying; public event Action<int, SpectatorState>? OnUserBeganPlaying;
/// <summary> /// <summary>
/// Called whenever a user finishes a play session. /// Called whenever a user finishes a play session.
/// </summary> /// </summary>
public event Action<int, SpectatorState> OnUserFinishedPlaying; public event Action<int, SpectatorState>? OnUserFinishedPlaying;
public SpectatorStreamingClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.SpectatorEndpointUrl;
}
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(IAPIProvider api) private void load()
{ {
connector = api.GetHubConnector(nameof(SpectatorStreamingClient), endpoint); IsConnected.BindValueChanged(connected => Schedule(() =>
if (connector != null)
{ {
connector.ConfigureConnection = connection => if (connected.NewValue)
{ {
// until strong typed client support is added, each method must be manually bound // get all the users that were previously being watched
// (see https://github.com/dotnet/aspnetcore/issues/15198) int[] users = watchingUsers.ToArray();
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); watchingUsers.Clear();
connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
};
isConnected.BindTo(connector.IsConnected); // resubscribe to watched users.
isConnected.BindValueChanged(connected => foreach (var userId in users)
WatchUser(userId);
// re-send state in case it wasn't received
if (IsPlaying)
BeginPlayingInternal(currentState);
}
else
{ {
if (connected.NewValue) playingUsers.Clear();
{ playingUserStates.Clear();
// get all the users that were previously being watched }
int[] users; }), true);
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) Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state)
{ {
lock (userLock) Schedule(() =>
{ {
if (!playingUsers.Contains(userId)) if (!playingUsers.Contains(userId))
playingUsers.Add(userId); playingUsers.Add(userId);
@ -147,39 +116,41 @@ namespace osu.Game.Online.Spectator
// We don't want the user states to update unless the player is being watched, otherwise calling BindUserBeganPlaying() can lead to double invocations. // 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)) if (watchingUsers.Contains(userId))
playingUserStates[userId] = state; playingUserStates[userId] = state;
}
OnUserBeganPlaying?.Invoke(userId, state); OnUserBeganPlaying?.Invoke(userId, state);
});
return Task.CompletedTask; return Task.CompletedTask;
} }
Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state) Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
{ {
lock (userLock) Schedule(() =>
{ {
playingUsers.Remove(userId); playingUsers.Remove(userId);
playingUserStates.Remove(userId); playingUserStates.Remove(userId);
}
OnUserFinishedPlaying?.Invoke(userId, state); OnUserFinishedPlaying?.Invoke(userId, state);
});
return Task.CompletedTask; return Task.CompletedTask;
} }
Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data) Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
{ {
OnNewFrames?.Invoke(userId, data); Schedule(() => OnNewFrames?.Invoke(userId, data));
return Task.CompletedTask; return Task.CompletedTask;
} }
public void BeginPlaying(GameplayBeatmap beatmap, Score score) public void BeginPlaying(GameplayBeatmap beatmap, Score score)
{ {
if (isPlaying) Debug.Assert(ThreadSafety.IsUpdateThread);
if (IsPlaying)
throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
isPlaying = true; IsPlaying = true;
// transfer state at point of beginning play // transfer state at point of beginning play
currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID; currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID;
@ -189,69 +160,65 @@ namespace osu.Game.Online.Spectator
currentBeatmap = beatmap.PlayableBeatmap; currentBeatmap = beatmap.PlayableBeatmap;
currentScore = score; currentScore = score;
beginPlaying(); BeginPlayingInternal(currentState);
} }
private void beginPlaying() public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data);
{
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() public void EndPlaying()
{ {
isPlaying = false; // This method is most commonly called via Dispose(), which is asynchronous.
currentBeatmap = null; // Todo: This should not be a thing, but requires framework changes.
Schedule(() =>
if (!isConnected.Value) return;
connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
}
public virtual void WatchUser(int userId)
{
lock (userLock)
{ {
if (watchingUsers.Contains(userId)) if (!IsPlaying)
return; return;
watchingUsers.Add(userId); IsPlaying = false;
currentBeatmap = null;
if (!isConnected.Value) EndPlayingInternal(currentState);
return; });
}
connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId);
} }
public virtual void StopWatchingUser(int userId) public void WatchUser(int userId)
{ {
lock (userLock) 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); watchingUsers.Remove(userId);
StopWatchingUserInternal(userId);
if (!isConnected.Value) });
return;
}
connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), 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 readonly Queue<LegacyReplayFrame> pendingFrames = new Queue<LegacyReplayFrame>();
private double lastSendTime; private double lastSendTime;
private Task lastSend; private Task? lastSend;
private const int max_pending_frames = 30; private const int max_pending_frames = 30;
@ -265,6 +232,8 @@ namespace osu.Game.Online.Spectator
public void HandleFrame(ReplayFrame frame) public void HandleFrame(ReplayFrame frame)
{ {
Debug.Assert(ThreadSafety.IsUpdateThread);
if (frame is IConvertibleReplayFrame convertible) if (frame is IConvertibleReplayFrame convertible)
pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap)); pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap));
@ -296,8 +265,7 @@ namespace osu.Game.Online.Spectator
/// <returns><c>true</c> if successful (the user is playing), <c>false</c> otherwise.</returns> /// <returns><c>true</c> if successful (the user is playing), <c>false</c> otherwise.</returns>
public bool TryGetPlayingUserState(int userId, out SpectatorState state) public bool TryGetPlayingUserState(int userId, out SpectatorState state)
{ {
lock (userLock) return playingUserStates.TryGetValue(userId, out state);
return playingUserStates.TryGetValue(userId, out state);
} }
/// <summary> /// <summary>
@ -308,16 +276,13 @@ namespace osu.Game.Online.Spectator
public void BindUserBeganPlaying(Action<int, SpectatorState> callback, bool runOnceImmediately = false) 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. // The lock is taken before the event is subscribed to to prevent doubling of events.
lock (userLock) OnUserBeganPlaying += callback;
{
OnUserBeganPlaying += callback;
if (!runOnceImmediately) if (!runOnceImmediately)
return; return;
foreach (var (userId, state) in playingUserStates) foreach (var (userId, state) in playingUserStates)
callback(userId, state); callback(userId, state);
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,179 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Graphics.Containers;
using osuTK;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics;
using System.Linq;
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Sprites;
using System.Diagnostics;
using osu.Framework.Platform;
namespace osu.Game.Overlays.News.Sidebar
{
public class MonthSection : CompositeDrawable
{
private const int animation_duration = 250;
public readonly BindableBool Expanded = new BindableBool();
public MonthSection(int month, int year, IEnumerable<APINewsPost> posts)
{
Debug.Assert(posts.All(p => p.PublishedAt.Month == month && p.PublishedAt.Year == year));
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Masking = true;
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
new DropdownHeader(month, year)
{
Expanded = { BindTarget = Expanded }
},
new PostsContainer
{
Expanded = { BindTarget = Expanded },
Children = posts.Select(p => new PostButton(p)).ToArray()
}
}
};
}
private class DropdownHeader : OsuClickableContainer
{
public readonly BindableBool Expanded = new BindableBool();
private readonly SpriteIcon icon;
public DropdownHeader(int month, int year)
{
var date = new DateTime(year, month, 1);
RelativeSizeAxes = Axes.X;
Height = 15;
Action = Expanded.Toggle;
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
Text = date.ToString("MMM yyyy")
},
icon = new SpriteIcon
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Size = new Vector2(10),
Icon = FontAwesome.Solid.ChevronDown
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Expanded.BindValueChanged(open =>
{
icon.Scale = new Vector2(1, open.NewValue ? -1 : 1);
}, true);
}
}
private class PostButton : OsuHoverContainer
{
protected override IEnumerable<Drawable> EffectTargets => new[] { text };
private readonly TextFlowContainer text;
private readonly APINewsPost post;
public PostButton(APINewsPost post)
{
this.post = post;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Child = text = new TextFlowContainer(t => t.Font = OsuFont.GetFont(size: 12))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Text = post.Title
};
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider overlayColours, GameHost host)
{
IdleColour = overlayColours.Light2;
HoverColour = overlayColours.Light1;
TooltipText = "view in browser";
Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug);
}
}
private class PostsContainer : Container
{
public readonly BindableBool Expanded = new BindableBool();
protected override Container<Drawable> Content { get; }
public PostsContainer()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
AutoSizeDuration = animation_duration;
AutoSizeEasing = Easing.Out;
InternalChild = Content = new FillFlowContainer
{
Margin = new MarginPadding { Top = 5 },
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 5),
Alpha = 0
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Expanded.BindValueChanged(updateState, true);
}
private void updateState(ValueChangedEvent<bool> expanded)
{
ClearTransforms(true);
if (expanded.NewValue)
{
AutoSizeAxes = Axes.Y;
Content.FadeIn(animation_duration, Easing.OutQuint);
}
else
{
AutoSizeAxes = Axes.None;
this.ResizeHeightTo(0, animation_duration, Easing.OutQuint);
Content.FadeOut(animation_duration, Easing.OutQuint);
}
}
}
}
}

View File

@ -0,0 +1,129 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Framework.Graphics.Shapes;
using osuTK;
using System.Linq;
using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.News.Sidebar
{
public class NewsSidebar : CompositeDrawable
{
[Cached]
public readonly Bindable<APINewsSidebar> Metadata = new Bindable<APINewsSidebar>();
private FillFlowContainer<MonthSection> monthsFlow;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
RelativeSizeAxes = Axes.Y;
Width = 250;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4
},
new Box
{
RelativeSizeAxes = Axes.Y,
Width = OsuScrollContainer.SCROLL_BAR_HEIGHT,
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Colour = colourProvider.Background3,
Alpha = 0.5f
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin
Child = new OsuScrollContainer
{
RelativeSizeAxes = Axes.Both,
Child = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Right = 3 }, // Addeded 3px back
Child = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding
{
Vertical = 20,
Left = 50,
Right = 30
},
Child = new FillFlowContainer
{
Direction = FillDirection.Vertical,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0, 20),
Children = new Drawable[]
{
new YearsPanel(),
monthsFlow = new FillFlowContainer<MonthSection>
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 10)
}
}
}
}
}
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Metadata.BindValueChanged(onMetadataChanged, true);
}
private void onMetadataChanged(ValueChangedEvent<APINewsSidebar> metadata)
{
monthsFlow.Clear();
if (metadata.NewValue == null)
return;
var allPosts = metadata.NewValue.NewsPosts;
if (allPosts?.Any() != true)
return;
var lookup = metadata.NewValue.NewsPosts.ToLookup(post => post.PublishedAt.Month);
var keys = lookup.Select(kvp => kvp.Key);
var sortedKeys = keys.OrderByDescending(k => k).ToList();
var year = metadata.NewValue.CurrentYear;
for (int i = 0; i < sortedKeys.Count; i++)
{
var month = sortedKeys[i];
var posts = lookup[month];
monthsFlow.Add(new MonthSection(month, year, posts)
{
Expanded = { Value = i == 0 }
});
}
}
}
}

View File

@ -0,0 +1,120 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Overlays.News.Sidebar
{
public class YearsPanel : CompositeDrawable
{
private readonly Bindable<APINewsSidebar> metadata = new Bindable<APINewsSidebar>();
private FillFlowContainer yearsFlow;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider overlayColours, Bindable<APINewsSidebar> metadata)
{
this.metadata.BindTo(metadata);
AutoSizeAxes = Axes.Y;
RelativeSizeAxes = Axes.X;
Masking = true;
CornerRadius = 6;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = overlayColours.Background3
},
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding(5),
Child = yearsFlow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0, 5)
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
metadata.BindValueChanged(_ => recreateDrawables(), true);
}
private void recreateDrawables()
{
yearsFlow.Clear();
if (metadata.Value == null)
{
Hide();
return;
}
var currentYear = metadata.Value.CurrentYear;
foreach (var y in metadata.Value.Years)
yearsFlow.Add(new YearButton(y, y == currentYear));
Show();
}
public class YearButton : OsuHoverContainer
{
public int Year { get; }
[Resolved(canBeNull: true)]
private NewsOverlay overlay { get; set; }
private readonly bool isCurrent;
public YearButton(int year, bool isCurrent)
{
Year = year;
this.isCurrent = isCurrent;
RelativeSizeAxes = Axes.X;
Width = 0.25f;
Height = 15;
Child = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.GetFont(size: 12, weight: isCurrent ? FontWeight.SemiBold : FontWeight.Medium),
Text = year.ToString()
};
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
IdleColour = isCurrent ? Color4.White : colourProvider.Light2;
HoverColour = isCurrent ? Color4.White : colourProvider.Light1;
Action = () =>
{
if (!isCurrent)
overlay?.ShowYear(Year);
};
}
}
}
}

View File

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

View File

@ -73,11 +73,6 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay
LabelText = "Always play first combo break sound", LabelText = "Always play first combo break sound",
Current = config.GetBindable<bool>(OsuSetting.AlwaysPlayFirstComboBreak) Current = config.GetBindable<bool>(OsuSetting.AlwaysPlayFirstComboBreak)
}, },
new SettingsEnumDropdown<ScoreMeterType>
{
LabelText = "Score meter type",
Current = config.GetBindable<ScoreMeterType>(OsuSetting.ScoreMeter)
},
new SettingsEnumDropdown<ScoringMode> new SettingsEnumDropdown<ScoringMode>
{ {
LabelText = "Score display mode", LabelText = "Score display mode",

View File

@ -9,12 +9,12 @@ using osuTK;
namespace osu.Game.Rulesets.Edit namespace osu.Game.Rulesets.Edit
{ {
public abstract class OverlaySelectionBlueprint : SelectionBlueprint<HitObject> public abstract class HitObjectSelectionBlueprint : SelectionBlueprint<HitObject>
{ {
/// <summary> /// <summary>
/// The <see cref="DrawableHitObject"/> which this <see cref="OverlaySelectionBlueprint"/> applies to. /// The <see cref="DrawableHitObject"/> which this <see cref="HitObjectSelectionBlueprint"/> applies to.
/// </summary> /// </summary>
public readonly DrawableHitObject DrawableObject; public DrawableHitObject DrawableObject { get; internal set; }
/// <summary> /// <summary>
/// Whether the blueprint should be shown even when the <see cref="DrawableObject"/> is not alive. /// Whether the blueprint should be shown even when the <see cref="DrawableObject"/> is not alive.
@ -23,10 +23,9 @@ namespace osu.Game.Rulesets.Edit
protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected);
protected OverlaySelectionBlueprint(DrawableHitObject drawableObject) protected HitObjectSelectionBlueprint(HitObject hitObject)
: base(drawableObject.HitObject) : base(hitObject)
{ {
DrawableObject = drawableObject;
} }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos);
@ -35,4 +34,15 @@ namespace osu.Game.Rulesets.Edit
public override Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad; public override Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad;
} }
public abstract class HitObjectSelectionBlueprint<T> : HitObjectSelectionBlueprint
where T : HitObject
{
public T HitObject => (T)Item;
protected HitObjectSelectionBlueprint(T item)
: base(item)
{
}
}
} }

View File

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

View File

@ -105,34 +105,34 @@ namespace osu.Game.Rulesets.Edit
protected override bool ShouldBeConsideredForInput(Drawable child) => State == SelectionState.Selected; protected override bool ShouldBeConsideredForInput(Drawable child) => State == SelectionState.Selected;
/// <summary> /// <summary>
/// Selects this <see cref="OverlaySelectionBlueprint"/>, causing it to become visible. /// Selects this <see cref="SelectionBlueprint{T}"/>, causing it to become visible.
/// </summary> /// </summary>
public void Select() => State = SelectionState.Selected; public void Select() => State = SelectionState.Selected;
/// <summary> /// <summary>
/// Deselects this <see cref="OverlaySelectionBlueprint"/>, causing it to become invisible. /// Deselects this <see cref="HitObjectSelectionBlueprint"/>, causing it to become invisible.
/// </summary> /// </summary>
public void Deselect() => State = SelectionState.NotSelected; public void Deselect() => State = SelectionState.NotSelected;
/// <summary> /// <summary>
/// Toggles the selection state of this <see cref="OverlaySelectionBlueprint"/>. /// Toggles the selection state of this <see cref="HitObjectSelectionBlueprint"/>.
/// </summary> /// </summary>
public void ToggleSelection() => State = IsSelected ? SelectionState.NotSelected : SelectionState.Selected; public void ToggleSelection() => State = IsSelected ? SelectionState.NotSelected : SelectionState.Selected;
public bool IsSelected => State == SelectionState.Selected; public bool IsSelected => State == SelectionState.Selected;
/// <summary> /// <summary>
/// The <see cref="MenuItem"/>s to be displayed in the context menu for this <see cref="OverlaySelectionBlueprint"/>. /// The <see cref="MenuItem"/>s to be displayed in the context menu for this <see cref="HitObjectSelectionBlueprint"/>.
/// </summary> /// </summary>
public virtual MenuItem[] ContextMenuItems => Array.Empty<MenuItem>(); public virtual MenuItem[] ContextMenuItems => Array.Empty<MenuItem>();
/// <summary> /// <summary>
/// The screen-space point that causes this <see cref="OverlaySelectionBlueprint"/> to be selected via a drag. /// The screen-space point that causes this <see cref="HitObjectSelectionBlueprint"/> to be selected via a drag.
/// </summary> /// </summary>
public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre; public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre;
/// <summary> /// <summary>
/// The screen-space quad that outlines this <see cref="OverlaySelectionBlueprint"/> for selections. /// The screen-space quad that outlines this <see cref="HitObjectSelectionBlueprint"/> for selections.
/// </summary> /// </summary>
public virtual Quad SelectionQuad => ScreenSpaceDrawQuad; public virtual Quad SelectionQuad => ScreenSpaceDrawQuad;

View File

@ -3,6 +3,7 @@
#nullable enable #nullable enable
using System;
using System.Diagnostics; using System.Diagnostics;
using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Pooling;
@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// <summary> /// <summary>
/// The entry holding essential state of this <see cref="PoolableDrawableWithLifetime{TEntry}"/>. /// The entry holding essential state of this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
/// </summary> /// </summary>
protected TEntry? Entry { get; private set; } public TEntry? Entry { get; private set; }
/// <summary> /// <summary>
/// Whether <see cref="Entry"/> is applied to this <see cref="PoolableDrawableWithLifetime{TEntry}"/>. /// Whether <see cref="Entry"/> is applied to this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
@ -28,14 +29,28 @@ namespace osu.Game.Rulesets.Objects.Pooling
public override double LifetimeStart public override double LifetimeStart
{ {
get => base.LifetimeStart; get => Entry?.LifetimeStart ?? double.MinValue;
set => setLifetime(value, LifetimeEnd); set
{
if (Entry == null && LifetimeStart != value)
throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime<TEntry>)} when entry is not set");
if (Entry != null)
Entry.LifetimeStart = value;
}
} }
public override double LifetimeEnd public override double LifetimeEnd
{ {
get => base.LifetimeEnd; get => Entry?.LifetimeEnd ?? double.MaxValue;
set => setLifetime(LifetimeStart, value); set
{
if (Entry == null && LifetimeEnd != value)
throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime<TEntry>)} when entry is not set");
if (Entry != null)
Entry.LifetimeEnd = value;
}
} }
public override bool RemoveWhenNotAlive => false; public override bool RemoveWhenNotAlive => false;
@ -64,11 +79,8 @@ namespace osu.Game.Rulesets.Objects.Pooling
if (HasEntryApplied) if (HasEntryApplied)
free(); free();
setLifetime(entry.LifetimeStart, entry.LifetimeEnd);
Entry = entry; Entry = entry;
OnApply(entry); OnApply(entry);
HasEntryApplied = true; HasEntryApplied = true;
} }
@ -95,27 +107,12 @@ namespace osu.Game.Rulesets.Objects.Pooling
{ {
} }
private void setLifetime(double start, double end)
{
base.LifetimeStart = start;
base.LifetimeEnd = end;
if (Entry != null)
{
Entry.LifetimeStart = start;
Entry.LifetimeEnd = end;
}
}
private void free() private void free()
{ {
Debug.Assert(Entry != null && HasEntryApplied); Debug.Assert(Entry != null && HasEntryApplied);
OnFree(Entry); OnFree(Entry);
Entry = null; Entry = null;
setLifetime(double.MaxValue, double.MaxValue);
HasEntryApplied = false; HasEntryApplied = false;
} }
} }

View File

@ -17,8 +17,18 @@ using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.UI namespace osu.Game.Rulesets.UI
{ {
public class HitObjectContainer : LifetimeManagementContainer, IHitObjectContainer public class HitObjectContainer : CompositeDrawable, IHitObjectContainer
{ {
/// <summary>
/// All entries in this <see cref="HitObjectContainer"/> including dead entries.
/// </summary>
public IEnumerable<HitObjectLifetimeEntry> Entries => allEntries;
/// <summary>
/// All alive entries and <see cref="DrawableHitObject"/>s used by the entries.
/// </summary>
public IEnumerable<(HitObjectLifetimeEntry Entry, DrawableHitObject Drawable)> AliveEntries => aliveDrawableMap.Select(x => (x.Key, x.Value));
public IEnumerable<DrawableHitObject> Objects => InternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime); public IEnumerable<DrawableHitObject> Objects => InternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime);
public IEnumerable<DrawableHitObject> AliveObjects => AliveInternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime); public IEnumerable<DrawableHitObject> AliveObjects => AliveInternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime);
@ -60,8 +70,12 @@ namespace osu.Game.Rulesets.UI
internal double FutureLifetimeExtension { get; set; } internal double FutureLifetimeExtension { get; set; }
private readonly Dictionary<DrawableHitObject, IBindable> startTimeMap = new Dictionary<DrawableHitObject, IBindable>(); private readonly Dictionary<DrawableHitObject, IBindable> startTimeMap = new Dictionary<DrawableHitObject, IBindable>();
private readonly Dictionary<HitObjectLifetimeEntry, DrawableHitObject> drawableMap = new Dictionary<HitObjectLifetimeEntry, DrawableHitObject>();
private readonly Dictionary<HitObjectLifetimeEntry, DrawableHitObject> aliveDrawableMap = new Dictionary<HitObjectLifetimeEntry, DrawableHitObject>();
private readonly Dictionary<HitObjectLifetimeEntry, DrawableHitObject> nonPooledDrawableMap = new Dictionary<HitObjectLifetimeEntry, DrawableHitObject>();
private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager();
private readonly HashSet<HitObjectLifetimeEntry> allEntries = new HashSet<HitObjectLifetimeEntry>();
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private IPooledHitObjectProvider pooledObjectProvider { get; set; } private IPooledHitObjectProvider pooledObjectProvider { get; set; }
@ -72,6 +86,7 @@ namespace osu.Game.Rulesets.UI
lifetimeManager.EntryBecameAlive += entryBecameAlive; lifetimeManager.EntryBecameAlive += entryBecameAlive;
lifetimeManager.EntryBecameDead += entryBecameDead; lifetimeManager.EntryBecameDead += entryBecameDead;
lifetimeManager.EntryCrossedBoundary += entryCrossedBoundary;
} }
protected override void LoadAsyncComplete() protected override void LoadAsyncComplete()
@ -84,93 +99,113 @@ namespace osu.Game.Rulesets.UI
#region Pooling support #region Pooling support
public void Add(HitObjectLifetimeEntry entry) => lifetimeManager.AddEntry(entry); public void Add(HitObjectLifetimeEntry entry)
public bool Remove(HitObjectLifetimeEntry entry) => lifetimeManager.RemoveEntry(entry);
private void entryBecameAlive(LifetimeEntry entry) => addDrawable((HitObjectLifetimeEntry)entry);
private void entryBecameDead(LifetimeEntry entry) => removeDrawable((HitObjectLifetimeEntry)entry);
private void addDrawable(HitObjectLifetimeEntry entry)
{ {
Debug.Assert(!drawableMap.ContainsKey(entry)); allEntries.Add(entry);
lifetimeManager.AddEntry(entry);
}
var drawable = pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null); public bool Remove(HitObjectLifetimeEntry entry)
{
if (!lifetimeManager.RemoveEntry(entry)) return false;
// This logic is not in `Remove(DrawableHitObject)` because a non-pooled drawable may be removed by specifying its entry.
if (nonPooledDrawableMap.Remove(entry, out var drawable))
removeDrawable(drawable);
allEntries.Remove(entry);
return true;
}
private void entryBecameAlive(LifetimeEntry lifetimeEntry)
{
var entry = (HitObjectLifetimeEntry)lifetimeEntry;
Debug.Assert(!aliveDrawableMap.ContainsKey(entry));
bool isNonPooled = nonPooledDrawableMap.TryGetValue(entry, out var drawable);
drawable ??= pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null);
if (drawable == null) if (drawable == null)
throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}."); throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}.");
aliveDrawableMap[entry] = drawable;
OnAdd(drawable);
if (isNonPooled) return;
addDrawable(drawable);
HitObjectUsageBegan?.Invoke(entry.HitObject);
}
private void entryBecameDead(LifetimeEntry lifetimeEntry)
{
var entry = (HitObjectLifetimeEntry)lifetimeEntry;
Debug.Assert(aliveDrawableMap.ContainsKey(entry));
var drawable = aliveDrawableMap[entry];
bool isNonPooled = nonPooledDrawableMap.ContainsKey(entry);
drawable.OnKilled();
aliveDrawableMap.Remove(entry);
OnRemove(drawable);
if (isNonPooled) return;
removeDrawable(drawable);
// The hit object is not freed when the DHO was not pooled.
HitObjectUsageFinished?.Invoke(entry.HitObject);
}
private void addDrawable(DrawableHitObject drawable)
{
drawable.OnNewResult += onNewResult; drawable.OnNewResult += onNewResult;
drawable.OnRevertResult += onRevertResult; drawable.OnRevertResult += onRevertResult;
bindStartTime(drawable); bindStartTime(drawable);
AddInternal(drawableMap[entry] = drawable, false); AddInternal(drawable);
OnAdd(drawable);
HitObjectUsageBegan?.Invoke(entry.HitObject);
} }
private void removeDrawable(HitObjectLifetimeEntry entry) private void removeDrawable(DrawableHitObject drawable)
{ {
Debug.Assert(drawableMap.ContainsKey(entry));
var drawable = drawableMap[entry];
// OnKilled can potentially change the hitobject's result, so it needs to run first before unbinding.
drawable.OnKilled();
drawable.OnNewResult -= onNewResult; drawable.OnNewResult -= onNewResult;
drawable.OnRevertResult -= onRevertResult; drawable.OnRevertResult -= onRevertResult;
drawableMap.Remove(entry);
OnRemove(drawable);
unbindStartTime(drawable); unbindStartTime(drawable);
RemoveInternal(drawable);
HitObjectUsageFinished?.Invoke(entry.HitObject); RemoveInternal(drawable);
} }
#endregion #endregion
#region Non-pooling support #region Non-pooling support
public virtual void Add(DrawableHitObject hitObject) public virtual void Add(DrawableHitObject drawable)
{ {
bindStartTime(hitObject); if (drawable.Entry == null)
throw new InvalidOperationException($"May not add a {nameof(DrawableHitObject)} without {nameof(HitObject)} associated");
hitObject.OnNewResult += onNewResult; nonPooledDrawableMap.Add(drawable.Entry, drawable);
hitObject.OnRevertResult += onRevertResult; addDrawable(drawable);
Add(drawable.Entry);
AddInternal(hitObject);
OnAdd(hitObject);
} }
public virtual bool Remove(DrawableHitObject hitObject) public virtual bool Remove(DrawableHitObject drawable)
{ {
OnRemove(hitObject); if (drawable.Entry == null)
if (!RemoveInternal(hitObject))
return false; return false;
hitObject.OnNewResult -= onNewResult; return Remove(drawable.Entry);
hitObject.OnRevertResult -= onRevertResult;
unbindStartTime(hitObject);
return true;
} }
public int IndexOf(DrawableHitObject hitObject) => IndexOfInternal(hitObject); public int IndexOf(DrawableHitObject hitObject) => IndexOfInternal(hitObject);
protected override void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e) private void entryCrossedBoundary(LifetimeEntry entry, LifetimeBoundaryKind kind, LifetimeBoundaryCrossingDirection direction)
{ {
if (!(e.Child is DrawableHitObject hitObject)) if (nonPooledDrawableMap.TryGetValue((HitObjectLifetimeEntry)entry, out var drawable))
return; OnChildLifetimeBoundaryCrossed(new LifetimeBoundaryCrossedEvent(drawable, kind, direction));
}
if ((e.Kind == LifetimeBoundaryKind.End && e.Direction == LifetimeBoundaryCrossingDirection.Forward) protected virtual void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e)
|| (e.Kind == LifetimeBoundaryKind.Start && e.Direction == LifetimeBoundaryCrossingDirection.Backward)) {
{
hitObject.OnKilled();
}
} }
#endregion #endregion
@ -195,12 +230,13 @@ namespace osu.Game.Rulesets.UI
{ {
} }
public virtual void Clear(bool disposeChildren = true) public virtual void Clear()
{ {
lifetimeManager.ClearEntries(); lifetimeManager.ClearEntries();
foreach (var drawable in nonPooledDrawableMap.Values)
ClearInternal(disposeChildren); removeDrawable(drawable);
unbindAllStartTimes(); nonPooledDrawableMap.Clear();
Debug.Assert(InternalChildren.Count == 0 && startTimeMap.Count == 0 && aliveDrawableMap.Count == 0, "All hit objects should have been removed");
} }
protected override bool CheckChildrenLife() protected override bool CheckChildrenLife()

View File

@ -354,8 +354,11 @@ namespace osu.Game.Rulesets.UI
// If this is the first time this DHO is being used, then apply the DHO mods. // If this is the first time this DHO is being used, then apply the DHO mods.
// This is done before Apply() so that the state is updated once when the hitobject is applied. // This is done before Apply() so that the state is updated once when the hitobject is applied.
foreach (var m in mods.OfType<IApplicableToDrawableHitObjects>()) if (mods != null)
m.ApplyToDrawableHitObjects(dho.Yield()); {
foreach (var m in mods.OfType<IApplicableToDrawableHitObjects>())
m.ApplyToDrawableHitObjects(dho.Yield());
}
} }
if (!lifetimeEntryMap.TryGetValue(hitObject, out var entry)) if (!lifetimeEntryMap.TryGetValue(hitObject, out var entry))

View File

@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.UI
public int RecordFrameRate = 60; public int RecordFrameRate = 60;
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private SpectatorStreamingClient spectatorStreaming { get; set; } private SpectatorClient spectatorClient { get; set; }
[Resolved] [Resolved]
private GameplayBeatmap gameplayBeatmap { get; set; } private GameplayBeatmap gameplayBeatmap { get; set; }
@ -49,13 +49,13 @@ namespace osu.Game.Rulesets.UI
inputManager = GetContainingInputManager(); inputManager = GetContainingInputManager();
spectatorStreaming?.BeginPlaying(gameplayBeatmap, target); spectatorClient?.BeginPlaying(gameplayBeatmap, target);
} }
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
spectatorStreaming?.EndPlaying(); spectatorClient?.EndPlaying();
} }
protected override void Update() protected override void Update()
@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.UI
{ {
target.Replay.Frames.Add(frame); target.Replay.Frames.Add(frame);
spectatorStreaming?.HandleFrame(frame); spectatorClient?.HandleFrame(frame);
} }
} }

View File

@ -50,9 +50,9 @@ namespace osu.Game.Rulesets.UI.Scrolling
timeRange.ValueChanged += _ => layoutCache.Invalidate(); timeRange.ValueChanged += _ => layoutCache.Invalidate();
} }
public override void Clear(bool disposeChildren = true) public override void Clear()
{ {
base.Clear(disposeChildren); base.Clear();
toComputeLifetime.Clear(); toComputeLifetime.Clear();
layoutComputed.Clear(); layoutComputed.Clear();

View File

@ -299,6 +299,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
{ {
} }
/// <summary>
/// Retrieves an item's blueprint.
/// </summary>
/// <param name="item">The item to retrieve the blueprint of.</param>
/// <returns>The blueprint.</returns>
protected SelectionBlueprint<T> GetBlueprintFor(T item) => blueprintMap[item];
#endregion #endregion
#region Selection #region Selection

View File

@ -74,6 +74,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
} }
} }
protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject)
{
base.TransferBlueprintFor(hitObject, drawableObject);
var blueprint = (HitObjectSelectionBlueprint)GetBlueprintFor(hitObject);
blueprint.DrawableObject = drawableObject;
}
protected override bool OnKeyDown(KeyDownEvent e) protected override bool OnKeyDown(KeyDownEvent e)
{ {
if (e.ControlPressed) if (e.ControlPressed)
@ -246,10 +254,10 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (drawable == null) if (drawable == null)
return null; return null;
return CreateBlueprintFor(drawable); return CreateHitObjectBlueprintFor(item).With(b => b.DrawableObject = drawable);
} }
public virtual OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null; public virtual HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) => null;
protected override void OnBlueprintAdded(HitObject item) protected override void OnBlueprintAdded(HitObject item)
{ {

View File

@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Screens.Edit.Compose.Components namespace osu.Game.Screens.Edit.Compose.Components
{ {
@ -22,6 +23,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected readonly HitObjectComposer Composer; protected readonly HitObjectComposer Composer;
private HitObjectUsageEventBuffer usageEventBuffer;
protected EditorBlueprintContainer(HitObjectComposer composer) protected EditorBlueprintContainer(HitObjectComposer composer)
{ {
Composer = composer; Composer = composer;
@ -45,11 +48,19 @@ namespace osu.Game.Screens.Edit.Compose.Components
foreach (var obj in Composer.HitObjects) foreach (var obj in Composer.HitObjects)
AddBlueprintFor(obj.HitObject); AddBlueprintFor(obj.HitObject);
Composer.Playfield.HitObjectUsageBegan += AddBlueprintFor; usageEventBuffer = new HitObjectUsageEventBuffer(Composer.Playfield);
Composer.Playfield.HitObjectUsageFinished += RemoveBlueprintFor; usageEventBuffer.HitObjectUsageBegan += AddBlueprintFor;
usageEventBuffer.HitObjectUsageFinished += RemoveBlueprintFor;
usageEventBuffer.HitObjectUsageTransferred += TransferBlueprintFor;
} }
} }
protected override void Update()
{
base.Update();
usageEventBuffer?.Update();
}
protected override IEnumerable<SelectionBlueprint<HitObject>> SortForMovement(IReadOnlyList<SelectionBlueprint<HitObject>> blueprints) protected override IEnumerable<SelectionBlueprint<HitObject>> SortForMovement(IReadOnlyList<SelectionBlueprint<HitObject>> blueprints)
=> blueprints.OrderBy(b => b.Item.StartTime); => blueprints.OrderBy(b => b.Item.StartTime);
@ -80,6 +91,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
base.AddBlueprintFor(item); base.AddBlueprintFor(item);
} }
/// <summary>
/// Invoked when a <see cref="HitObject"/> has been transferred to another <see cref="DrawableHitObject"/>.
/// </summary>
/// <param name="hitObject">The hit object which has been assigned to a new drawable.</param>
/// <param name="drawableObject">The new drawable that is representing the hit object.</param>
protected virtual void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject)
{
}
protected override void DragOperationCompleted() protected override void DragOperationCompleted()
{ {
base.DragOperationCompleted(); base.DragOperationCompleted();
@ -133,11 +153,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
Beatmap.HitObjectRemoved -= RemoveBlueprintFor; Beatmap.HitObjectRemoved -= RemoveBlueprintFor;
} }
if (Composer != null) usageEventBuffer?.Dispose();
{
Composer.Playfield.HitObjectUsageBegan -= AddBlueprintFor;
Composer.Playfield.HitObjectUsageFinished -= RemoveBlueprintFor;
}
} }
} }
} }

View File

@ -168,13 +168,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
{ {
if (SelectedBlueprints.All(b => b.Item is IHasComboInformation)) if (SelectedBlueprints.All(b => b.Item is IHasComboInformation))
{ {
yield return new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } };
} }
yield return new OsuMenuItem("Sound") yield return new OsuMenuItem("Sound")
{ {
Items = SelectionSampleStates.Select(kvp => Items = SelectionSampleStates.Select(kvp =>
new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
}; };
} }

View File

@ -7,7 +7,7 @@ using osuTK;
namespace osu.Game.Screens.Edit.Compose.Components namespace osu.Game.Screens.Edit.Compose.Components
{ {
/// <summary> /// <summary>
/// An event which occurs when a <see cref="OverlaySelectionBlueprint"/> is moved. /// An event which occurs when a <see cref="SelectionBlueprint{T}"/> is moved.
/// </summary> /// </summary>
public class MoveSelectionEvent<T> public class MoveSelectionEvent<T>
{ {

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