mirror of
https://github.com/ppy/osu.git
synced 2025-03-22 00:07:19 +08:00
Merge branch 'master' into import-from-stable-select-location
This commit is contained in:
commit
2958b9adf4
@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
|
||||
}
|
||||
};
|
||||
|
||||
AddBlueprint(new HoldNoteSelectionBlueprint(drawableObject));
|
||||
AddBlueprint(new HoldNoteSelectionBlueprint(holdNote), drawableObject);
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
|
@ -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("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("tail blueprint positioned correctly", () => this.ChildrenOfType<HoldNoteNoteSelectionBlueprint>().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition);
|
||||
AddAssert("head blueprint positioned correctly", () => this.ChildrenOfType<HoldNoteNoteOverlay>().ElementAt(0).DrawPosition == holdNote.Head.DrawPosition);
|
||||
AddAssert("tail blueprint positioned correctly", () => this.ChildrenOfType<HoldNoteNoteOverlay>().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition);
|
||||
}
|
||||
|
||||
private void setScrollStep(ScrollingDirection direction)
|
||||
|
@ -15,7 +15,6 @@ using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.UI;
|
||||
using osu.Game.Rulesets.UI.Scrolling;
|
||||
using osu.Game.Tests.Visual;
|
||||
using osuTK;
|
||||
using osuTK.Input;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Tests.Editor
|
||||
@ -35,7 +34,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
|
||||
[Test]
|
||||
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));
|
||||
|
||||
@ -45,7 +48,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
|
||||
[Test]
|
||||
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));
|
||||
|
||||
|
@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
|
||||
Child = drawableObject = new DrawableNote(note)
|
||||
};
|
||||
|
||||
AddBlueprint(new NoteSelectionBlueprint(drawableObject));
|
||||
AddBlueprint(new NoteSelectionBlueprint(note), drawableObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
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
|
||||
}
|
||||
@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
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
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
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
|
||||
},
|
||||
@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
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
|
||||
},
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -5,13 +5,11 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Screens;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Replays;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Mania.Objects;
|
||||
using osu.Game.Rulesets.Mania.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Mania.Replays;
|
||||
using osu.Game.Rulesets.Mania.Scoring;
|
||||
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("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
|
||||
|
||||
AddUntilStep("wait for head", () => currentPlayer.GameplayClockContainer.GameplayClock.CurrentTime >= time_head);
|
||||
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);
|
||||
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor?.HasCompleted.Value == true);
|
||||
}
|
||||
|
||||
private class ScoreAccessibleReplayPlayer : ReplayPlayer
|
||||
|
@ -2,34 +2,35 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
|
||||
using osu.Game.Rulesets.Mania.Objects.Drawables;
|
||||
|
||||
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;
|
||||
|
||||
public HoldNoteNoteSelectionBlueprint(DrawableHoldNote holdNote, HoldNotePosition position)
|
||||
: base(holdNote)
|
||||
public HoldNoteNoteOverlay(HoldNoteSelectionBlueprint holdNoteBlueprint, HoldNotePosition position)
|
||||
{
|
||||
this.holdNoteBlueprint = holdNoteBlueprint;
|
||||
this.position = position;
|
||||
InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X };
|
||||
|
||||
Select();
|
||||
InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X };
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
var drawableObject = holdNoteBlueprint.DrawableObject;
|
||||
|
||||
// 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;
|
||||
Origin = note.Origin;
|
||||
@ -38,8 +39,5 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
|
||||
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;
|
||||
}
|
||||
}
|
@ -8,13 +8,14 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Primitives;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Rulesets.Mania.Objects;
|
||||
using osu.Game.Rulesets.Mania.Objects.Drawables;
|
||||
using osu.Game.Rulesets.UI.Scrolling;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
|
||||
{
|
||||
public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint
|
||||
public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint<HoldNote>
|
||||
{
|
||||
public new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject;
|
||||
|
||||
@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; }
|
||||
|
||||
public HoldNoteSelectionBlueprint(DrawableHoldNote hold)
|
||||
public HoldNoteSelectionBlueprint(HoldNote hold)
|
||||
: base(hold)
|
||||
{
|
||||
}
|
||||
@ -32,16 +33,11 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
|
||||
private void load(IScrollingInfo scrollingInfo)
|
||||
{
|
||||
direction.BindTo(scrollingInfo.Direction);
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.Start),
|
||||
new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.End),
|
||||
new HoldNoteNoteOverlay(this, HoldNotePosition.Start),
|
||||
new HoldNoteNoteOverlay(this, HoldNotePosition.End),
|
||||
new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
|
@ -4,22 +4,23 @@
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Mania.Objects;
|
||||
using osu.Game.Rulesets.Mania.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.UI.Scrolling;
|
||||
using osuTK;
|
||||
|
||||
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;
|
||||
|
||||
[Resolved]
|
||||
private IScrollingInfo scrollingInfo { get; set; }
|
||||
|
||||
protected ManiaSelectionBlueprint(DrawableHitObject drawableObject)
|
||||
: base(drawableObject)
|
||||
protected ManiaSelectionBlueprint(T hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
RelativeSizeAxes = Axes.None;
|
||||
}
|
||||
|
@ -3,13 +3,13 @@
|
||||
|
||||
using osu.Framework.Graphics;
|
||||
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
|
||||
{
|
||||
public class NoteSelectionBlueprint : ManiaSelectionBlueprint
|
||||
public class NoteSelectionBlueprint : ManiaSelectionBlueprint<Note>
|
||||
{
|
||||
public NoteSelectionBlueprint(DrawableNote note)
|
||||
public NoteSelectionBlueprint(Note note)
|
||||
: base(note)
|
||||
{
|
||||
AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X });
|
||||
|
@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Mania.Edit
|
||||
foreach (var line in grid.Objects.OfType<DrawableGridLine>())
|
||||
availableLines.Push(line);
|
||||
|
||||
grid.Clear(false);
|
||||
grid.Clear();
|
||||
}
|
||||
|
||||
if (selectionTimeRange == null)
|
||||
|
@ -3,9 +3,8 @@
|
||||
|
||||
using osu.Game.Rulesets.Edit;
|
||||
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.Drawables;
|
||||
using osu.Game.Screens.Edit.Compose.Components;
|
||||
|
||||
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)
|
||||
{
|
||||
case DrawableNote note:
|
||||
case Note note:
|
||||
return new NoteSelectionBlueprint(note);
|
||||
|
||||
case DrawableHoldNote holdNote:
|
||||
case HoldNote holdNote:
|
||||
return new HoldNoteSelectionBlueprint(holdNote);
|
||||
}
|
||||
|
||||
return base.CreateBlueprintFor(hitObject);
|
||||
return base.CreateHitObjectBlueprintFor(hitObject);
|
||||
}
|
||||
|
||||
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new ManiaSelectionHandler();
|
||||
|
@ -5,7 +5,6 @@ using System;
|
||||
using System.Linq;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Mania.Edit.Blueprints;
|
||||
using osu.Game.Rulesets.Mania.Objects;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.UI.Scrolling;
|
||||
@ -23,8 +22,8 @@ namespace osu.Game.Rulesets.Mania.Edit
|
||||
|
||||
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent)
|
||||
{
|
||||
var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint;
|
||||
int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column;
|
||||
var hitObjectBlueprint = (HitObjectSelectionBlueprint)moveEvent.Blueprint;
|
||||
int lastColumn = ((ManiaHitObject)hitObjectBlueprint.Item).Column;
|
||||
|
||||
performColumnMovement(lastColumn, moveEvent);
|
||||
|
||||
@ -59,8 +58,9 @@ namespace osu.Game.Rulesets.Mania.Edit
|
||||
|
||||
EditorBeatmap.PerformOnSelection(h =>
|
||||
{
|
||||
if (h is ManiaHitObject maniaObj)
|
||||
maniaObj.Column += columnDelta;
|
||||
maniaPlayfield.Remove(h);
|
||||
((ManiaHitObject)h).Column += columnDelta;
|
||||
maniaPlayfield.Add(h);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -9,12 +9,6 @@ namespace osu.Game.Rulesets.Mania
|
||||
{
|
||||
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>
|
||||
/// The intended <see cref="StageDefinition"/> for this component.
|
||||
/// 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"/>.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public ManiaSkinComponent(ManiaSkinComponents component, int? targetColumn = null, StageDefinition? stageDefinition = null)
|
||||
public ManiaSkinComponent(ManiaSkinComponents component, StageDefinition? stageDefinition = null)
|
||||
: base(component)
|
||||
{
|
||||
TargetColumn = targetColumn;
|
||||
StageDefinition = stageDefinition;
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
@ -12,6 +13,7 @@ using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.UI.Scrolling;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
{
|
||||
@ -29,21 +31,21 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
public DrawableHoldNoteHead Head => headContainer.Child;
|
||||
public DrawableHoldNoteTail Tail => tailContainer.Child;
|
||||
|
||||
private readonly Container<DrawableHoldNoteHead> headContainer;
|
||||
private readonly Container<DrawableHoldNoteTail> tailContainer;
|
||||
private readonly Container<DrawableHoldNoteTick> tickContainer;
|
||||
private Container<DrawableHoldNoteHead> headContainer;
|
||||
private Container<DrawableHoldNoteTail> tailContainer;
|
||||
private Container<DrawableHoldNoteTick> tickContainer;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private readonly Container sizingContainer;
|
||||
private Container sizingContainer;
|
||||
|
||||
/// <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"/>.
|
||||
/// </summary>
|
||||
private readonly Container maskingContainer;
|
||||
private Container maskingContainer;
|
||||
|
||||
private readonly SkinnableDrawable bodyPiece;
|
||||
private SkinnableDrawable bodyPiece;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
private double? releaseTime;
|
||||
|
||||
public DrawableHoldNote()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public DrawableHoldNote(HoldNote hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
RelativeSizeAxes = Axes.X;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
Container maskedContents;
|
||||
|
||||
AddRangeInternal(new Drawable[]
|
||||
@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
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,
|
||||
})
|
||||
@ -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)
|
||||
{
|
||||
base.AddNestedHitObject(hitObject);
|
||||
@ -128,37 +148,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
protected override void ClearNestedHitObjects()
|
||||
{
|
||||
base.ClearNestedHitObjects();
|
||||
headContainer.Clear();
|
||||
tailContainer.Clear();
|
||||
tickContainer.Clear();
|
||||
headContainer.Clear(false);
|
||||
tailContainer.Clear(false);
|
||||
tickContainer.Clear(false);
|
||||
}
|
||||
|
||||
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
|
||||
{
|
||||
switch (hitObject)
|
||||
{
|
||||
case TailNote _:
|
||||
return new DrawableHoldNoteTail(this)
|
||||
{
|
||||
Anchor = Anchor.TopCentre,
|
||||
Origin = Anchor.TopCentre,
|
||||
AccentColour = { BindTarget = AccentColour }
|
||||
};
|
||||
case TailNote tail:
|
||||
return new DrawableHoldNoteTail(tail);
|
||||
|
||||
case Note _:
|
||||
return new DrawableHoldNoteHead(this)
|
||||
{
|
||||
Anchor = Anchor.TopCentre,
|
||||
Origin = Anchor.TopCentre,
|
||||
AccentColour = { BindTarget = AccentColour }
|
||||
};
|
||||
case HeadNote head:
|
||||
return new DrawableHoldNoteHead(head);
|
||||
|
||||
case HoldNoteTick tick:
|
||||
return new DrawableHoldNoteTick(tick)
|
||||
{
|
||||
HoldStartTime = () => HoldStartTime,
|
||||
AccentColour = { BindTarget = AccentColour }
|
||||
};
|
||||
return new DrawableHoldNoteTick(tick);
|
||||
}
|
||||
|
||||
return base.CreateNestedHitObject(hitObject);
|
||||
|
@ -1,6 +1,7 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.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;
|
||||
|
||||
public DrawableHoldNoteHead(DrawableHoldNote holdNote)
|
||||
: base(holdNote.HitObject.Head)
|
||||
public DrawableHoldNoteHead()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public DrawableHoldNoteHead(HeadNote headNote)
|
||||
: base(headNote)
|
||||
{
|
||||
Anchor = Anchor.TopCentre;
|
||||
Origin = Anchor.TopCentre;
|
||||
}
|
||||
|
||||
public void UpdateResult() => base.UpdateResult(true);
|
||||
|
||||
protected override void UpdateInitialTransforms()
|
||||
|
@ -2,6 +2,7 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Diagnostics;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
@ -20,12 +21,18 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
|
||||
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail;
|
||||
|
||||
private readonly DrawableHoldNote holdNote;
|
||||
protected DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject;
|
||||
|
||||
public DrawableHoldNoteTail(DrawableHoldNote holdNote)
|
||||
: base(holdNote.HitObject.Tail)
|
||||
public DrawableHoldNoteTail()
|
||||
: this(null)
|
||||
{
|
||||
this.holdNote = holdNote;
|
||||
}
|
||||
|
||||
public DrawableHoldNoteTail(TailNote tailNote)
|
||||
: base(tailNote)
|
||||
{
|
||||
Anchor = Anchor.TopCentre;
|
||||
Origin = Anchor.TopCentre;
|
||||
}
|
||||
|
||||
public void UpdateResult() => base.UpdateResult(true);
|
||||
@ -54,7 +61,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
ApplyResult(r =>
|
||||
{
|
||||
// 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;
|
||||
|
||||
r.Type = result;
|
||||
|
@ -2,7 +2,8 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osuTK;
|
||||
using System.Diagnostics;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Extensions.Color4Extensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
@ -19,38 +20,48 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
/// <summary>
|
||||
/// References the time at which the user started holding the hold note.
|
||||
/// </summary>
|
||||
public Func<double?> HoldStartTime;
|
||||
private Func<double?> holdStartTime;
|
||||
|
||||
private Container glowContainer;
|
||||
|
||||
public DrawableHoldNoteTick()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public DrawableHoldNoteTick(HoldNoteTick hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
Container glowContainer;
|
||||
|
||||
Anchor = Anchor.TopCentre;
|
||||
Origin = Anchor.TopCentre;
|
||||
|
||||
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,
|
||||
Origin = Anchor.TopCentre,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Masking = true,
|
||||
Children = new[]
|
||||
new Box
|
||||
{
|
||||
new Box
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Alpha = 0,
|
||||
AlwaysPresent = true
|
||||
}
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Alpha = 0,
|
||||
AlwaysPresent = true
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
AccentColour.BindValueChanged(colour =>
|
||||
{
|
||||
@ -64,12 +75,29 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
}, 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)
|
||||
{
|
||||
if (Time.Current < HitObject.StartTime)
|
||||
return;
|
||||
|
||||
var startTime = HoldStartTime?.Invoke();
|
||||
var startTime = holdStartTime?.Invoke();
|
||||
|
||||
if (startTime == null || startTime > HitObject.StartTime)
|
||||
ApplyResult(r => r.Type = r.Judgement.MinResult);
|
||||
|
@ -50,6 +50,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
protected DrawableManiaHitObject(ManiaHitObject hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
RelativeSizeAxes = Axes.X;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader(true)]
|
||||
@ -59,9 +60,31 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
Action.BindTo(action);
|
||||
|
||||
Direction.BindTo(scrollingInfo.Direction);
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
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;
|
||||
|
||||
public override double LifetimeStart
|
||||
@ -147,12 +170,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
public abstract class DrawableManiaHitObject<TObject> : DrawableManiaHitObject
|
||||
where TObject : ManiaHitObject
|
||||
{
|
||||
public new readonly TObject HitObject;
|
||||
public new TObject HitObject => (TObject)base.HitObject;
|
||||
|
||||
protected DrawableManiaHitObject(TObject hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
HitObject = hitObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,31 +33,37 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
|
||||
protected virtual ManiaSkinComponents Component => ManiaSkinComponents.Note;
|
||||
|
||||
private readonly Drawable headPiece;
|
||||
private Drawable headPiece;
|
||||
|
||||
public DrawableNote()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public DrawableNote(Note hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
RelativeSizeAxes = Axes.X;
|
||||
AutoSizeAxes = Axes.Y;
|
||||
|
||||
AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component, hitObject.Column), _ => new DefaultNotePiece())
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y
|
||||
});
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader(true)]
|
||||
private void load(ManiaRulesetConfigManager rulesetConfig)
|
||||
{
|
||||
rulesetConfig?.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring);
|
||||
|
||||
AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component), _ => new DefaultNotePiece())
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y
|
||||
});
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
HitObject.StartTimeBindable.BindValueChanged(_ => updateSnapColour());
|
||||
configTimingBasedNoteColouring.BindValueChanged(_ => updateSnapColour(), true);
|
||||
base.LoadComplete();
|
||||
|
||||
configTimingBasedNoteColouring.BindValueChanged(_ => updateSnapColour());
|
||||
StartTimeBindable.BindValueChanged(_ => updateSnapColour(), true);
|
||||
}
|
||||
|
||||
protected override void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> e)
|
||||
@ -102,7 +108,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
|
||||
private void updateSnapColour()
|
||||
{
|
||||
if (beatmap == null) return;
|
||||
if (beatmap == null || HitObject == null) return;
|
||||
|
||||
int snapDivisor = beatmap.ControlPointInfo.GetClosestBeatDivisor(HitObject.StartTime);
|
||||
|
||||
|
9
osu.Game.Rulesets.Mania/Objects/HeadNote.cs
Normal file
9
osu.Game.Rulesets.Mania/Objects/HeadNote.cs
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Mania.Objects
|
||||
/// <summary>
|
||||
/// The head note of the hold.
|
||||
/// </summary>
|
||||
public Note Head { get; private set; }
|
||||
public HeadNote Head { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tail note of the hold.
|
||||
@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Mania.Objects
|
||||
|
||||
createTicks(cancellationToken);
|
||||
|
||||
AddNested(Head = new Note
|
||||
AddNested(Head = new HeadNote
|
||||
{
|
||||
StartTime = StartTime,
|
||||
Column = Column,
|
||||
|
@ -17,6 +17,7 @@ using osu.Game.Rulesets.UI.Scrolling;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps;
|
||||
using osu.Game.Rulesets.Mania.Objects;
|
||||
using osu.Game.Rulesets.Mania.Objects.Drawables;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.UI
|
||||
@ -55,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.UI
|
||||
RelativeSizeAxes = Axes.Y;
|
||||
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
|
||||
};
|
||||
@ -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
|
||||
background.CreateProxy(),
|
||||
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
|
||||
},
|
||||
@ -83,6 +84,19 @@ namespace osu.Game.Rulesets.Mania.UI
|
||||
hitPolicy = new OrderedHitPolicy(HitObjectContainer);
|
||||
|
||||
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; }
|
||||
@ -98,28 +112,14 @@ namespace osu.Game.Rulesets.Mania.UI
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a DrawableHitObject to this Playfield.
|
||||
/// </summary>
|
||||
/// <param name="hitObject">The DrawableHitObject to add.</param>
|
||||
public override void Add(DrawableHitObject hitObject)
|
||||
protected override void OnNewDrawableHitObject(DrawableHitObject drawableHitObject)
|
||||
{
|
||||
hitObject.AccentColour.Value = AccentColour;
|
||||
hitObject.OnNewResult += OnNewResult;
|
||||
base.OnNewDrawableHitObject(drawableHitObject);
|
||||
|
||||
DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject;
|
||||
DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)drawableHitObject;
|
||||
|
||||
maniaObject.AccentColour.Value = AccentColour;
|
||||
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)
|
||||
|
@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.UI.Components
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Depth = 2,
|
||||
},
|
||||
hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget, columnIndex), _ => new DefaultHitTarget())
|
||||
hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget), _ => new DefaultHitTarget())
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
Depth = 1
|
||||
|
@ -18,7 +18,6 @@ using osu.Game.Replays;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps;
|
||||
using osu.Game.Rulesets.Mania.Configuration;
|
||||
using osu.Game.Rulesets.Mania.Objects;
|
||||
using osu.Game.Rulesets.Mania.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Mania.Replays;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
@ -134,20 +133,7 @@ namespace osu.Game.Rulesets.Mania.UI
|
||||
|
||||
protected override PassThroughInputManager CreateInputManager() => new ManiaInputManager(Ruleset.RulesetInfo, Variant);
|
||||
|
||||
public override DrawableHitObject<ManiaHitObject> CreateDrawableRepresentation(ManiaHitObject h)
|
||||
{
|
||||
switch (h)
|
||||
{
|
||||
case HoldNote holdNote:
|
||||
return new DrawableHoldNote(holdNote);
|
||||
|
||||
case Note note:
|
||||
return new DrawableNote(note);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public override DrawableHitObject<ManiaHitObject> CreateDrawableRepresentation(ManiaHitObject h) => null;
|
||||
|
||||
protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay);
|
||||
|
||||
|
@ -9,6 +9,7 @@ using System.Linq;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps;
|
||||
using osu.Game.Rulesets.Mania.Objects;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.UI.Scrolling;
|
||||
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 bool Remove(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Remove(h);
|
||||
|
@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.UI
|
||||
[BackgroundDependencyLoader]
|
||||
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
|
||||
};
|
||||
|
@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mania.Beatmaps;
|
||||
using osu.Game.Rulesets.Mania.Objects;
|
||||
using osu.Game.Rulesets.Mania.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Mania.UI.Components;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.UI;
|
||||
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;
|
||||
|
||||
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;
|
||||
base.LoadComplete();
|
||||
NewResult += OnNewResult;
|
||||
}
|
||||
|
||||
public override bool Remove(DrawableHitObject h)
|
||||
{
|
||||
var maniaObject = (ManiaHitObject)h.HitObject;
|
||||
int columnIndex = maniaObject.Column - firstColumnIndex;
|
||||
Columns.ElementAt(columnIndex).Remove(h);
|
||||
public override void Add(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Add(hitObject);
|
||||
|
||||
h.OnNewResult -= OnNewResult;
|
||||
return true;
|
||||
}
|
||||
public override bool Remove(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Remove(hitObject);
|
||||
|
||||
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));
|
||||
|
||||
|
@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 });
|
||||
|
||||
Add(drawableObject = new DrawableHitCircle(hitCircle));
|
||||
AddBlueprint(blueprint = new TestBlueprint(drawableObject));
|
||||
AddBlueprint(blueprint = new TestBlueprint(hitCircle), drawableObject);
|
||||
});
|
||||
|
||||
[Test]
|
||||
@ -63,8 +63,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
{
|
||||
public new HitCirclePiece CirclePiece => base.CirclePiece;
|
||||
|
||||
public TestBlueprint(DrawableHitCircle drawableCircle)
|
||||
: base(drawableCircle)
|
||||
public TestBlueprint(HitCircle circle)
|
||||
: base(circle)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 });
|
||||
|
||||
Add(drawableObject = new DrawableSlider(slider));
|
||||
AddBlueprint(new TestSliderBlueprint(drawableObject));
|
||||
AddBlueprint(new TestSliderBlueprint(slider), drawableObject);
|
||||
});
|
||||
|
||||
[Test]
|
||||
@ -150,23 +150,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
private class TestSliderBlueprint : SliderSelectionBlueprint
|
||||
{
|
||||
public new SliderBodyPiece BodyPiece => base.BodyPiece;
|
||||
public new TestSliderCircleBlueprint HeadBlueprint => (TestSliderCircleBlueprint)base.HeadBlueprint;
|
||||
public new TestSliderCircleBlueprint TailBlueprint => (TestSliderCircleBlueprint)base.TailBlueprint;
|
||||
public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay;
|
||||
public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay;
|
||||
public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser;
|
||||
|
||||
public TestSliderBlueprint(DrawableSlider slider)
|
||||
public TestSliderBlueprint(Slider 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 TestSliderCircleBlueprint(DrawableSlider slider, SliderPosition position)
|
||||
public TestSliderCircleOverlay(Slider slider, SliderPosition position)
|
||||
: base(slider, position)
|
||||
{
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 });
|
||||
|
||||
Add(drawableObject = new DrawableSlider(slider));
|
||||
AddBlueprint(blueprint = new TestSliderBlueprint(drawableObject));
|
||||
AddBlueprint(blueprint = new TestSliderBlueprint(slider), drawableObject);
|
||||
});
|
||||
|
||||
[Test]
|
||||
@ -174,10 +174,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
AddAssert("body positioned correctly", () => blueprint.BodyPiece.Position == slider.StackedPosition);
|
||||
|
||||
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",
|
||||
() => 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)
|
||||
@ -195,23 +195,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
private class TestSliderBlueprint : SliderSelectionBlueprint
|
||||
{
|
||||
public new SliderBodyPiece BodyPiece => base.BodyPiece;
|
||||
public new TestSliderCircleBlueprint HeadBlueprint => (TestSliderCircleBlueprint)base.HeadBlueprint;
|
||||
public new TestSliderCircleBlueprint TailBlueprint => (TestSliderCircleBlueprint)base.TailBlueprint;
|
||||
public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay;
|
||||
public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay;
|
||||
public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser;
|
||||
|
||||
public TestSliderBlueprint(DrawableSlider slider)
|
||||
public TestSliderBlueprint(Slider 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 TestSliderCircleBlueprint(DrawableSlider slider, SliderPosition position)
|
||||
public TestSliderCircleOverlay(Slider slider, SliderPosition position)
|
||||
: base(slider, position)
|
||||
{
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
Child = drawableSpinner = new DrawableSpinner(spinner)
|
||||
});
|
||||
|
||||
AddBlueprint(new SpinnerSelectionBlueprint(drawableSpinner) { Size = new Vector2(0.5f) });
|
||||
AddBlueprint(new SpinnerSelectionBlueprint(spinner) { Size = new Vector2(0.5f) }, drawableSpinner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,8 +15,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
|
||||
|
||||
protected readonly HitCirclePiece CirclePiece;
|
||||
|
||||
public HitCircleSelectionBlueprint(DrawableHitCircle drawableCircle)
|
||||
: base(drawableCircle)
|
||||
public HitCircleSelectionBlueprint(HitCircle circle)
|
||||
: base(circle)
|
||||
{
|
||||
InternalChild = CirclePiece = new HitCirclePiece();
|
||||
}
|
||||
|
@ -2,20 +2,20 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Edit.Blueprints
|
||||
{
|
||||
public abstract class OsuSelectionBlueprint<T> : OverlaySelectionBlueprint
|
||||
public abstract class OsuSelectionBlueprint<T> : HitObjectSelectionBlueprint<T>
|
||||
where T : OsuHitObject
|
||||
{
|
||||
protected T HitObject => (T)DrawableObject.HitObject;
|
||||
protected new DrawableOsuHitObject DrawableObject => (DrawableOsuHitObject)base.DrawableObject;
|
||||
|
||||
protected override bool AlwaysShowWhenSelected => true;
|
||||
|
||||
protected OsuSelectionBlueprint(DrawableHitObject drawableObject)
|
||||
: base(drawableObject)
|
||||
protected OsuSelectionBlueprint(T hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
@ -243,7 +243,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
|
||||
int totalCount = Pieces.Count(p => p.IsSelected.Value);
|
||||
int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type.Value == type);
|
||||
|
||||
var item = new PathTypeMenuItem(type, () =>
|
||||
var item = new TernaryStateRadioMenuItem(type == null ? "Inherit" : type.ToString().Humanize(), MenuItemType.Standard, _ =>
|
||||
{
|
||||
foreach (var p in Pieces.Where(p => p.IsSelected.Value))
|
||||
updatePathType(p, type);
|
||||
@ -258,15 +258,5 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private class PathTypeMenuItem : TernaryStateMenuItem
|
||||
{
|
||||
public PathTypeMenuItem(PathType? type, Action action)
|
||||
: base(type == null ? "Inherit" : type.ToString().Humanize(), changeState, MenuItemType.Standard, _ => action?.Invoke())
|
||||
{
|
||||
}
|
||||
|
||||
private static TernaryState changeState(TernaryState state) => TernaryState.True;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
||||
{
|
||||
public class SliderCircleSelectionBlueprint : OsuSelectionBlueprint<Slider>
|
||||
public class SliderCircleOverlay : CompositeDrawable
|
||||
{
|
||||
protected readonly HitCirclePiece CirclePiece;
|
||||
|
||||
private readonly Slider slider;
|
||||
private readonly SliderPosition position;
|
||||
|
||||
public SliderCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position)
|
||||
: base(slider)
|
||||
public SliderCircleOverlay(Slider slider, SliderPosition position)
|
||||
{
|
||||
this.slider = slider;
|
||||
this.position = position;
|
||||
|
||||
InternalChild = CirclePiece = new HitCirclePiece();
|
||||
|
||||
Select();
|
||||
}
|
||||
|
||||
protected override void 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;
|
||||
}
|
||||
}
|
@ -16,7 +16,6 @@ using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
using osu.Game.Screens.Edit;
|
||||
using osu.Game.Screens.Edit.Compose;
|
||||
using osuTK;
|
||||
@ -27,14 +26,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
||||
public class SliderSelectionBlueprint : OsuSelectionBlueprint<Slider>
|
||||
{
|
||||
protected SliderBodyPiece BodyPiece { get; private set; }
|
||||
protected SliderCircleSelectionBlueprint HeadBlueprint { get; private set; }
|
||||
protected SliderCircleSelectionBlueprint TailBlueprint { get; private set; }
|
||||
protected SliderCircleOverlay HeadOverlay { get; private set; }
|
||||
protected SliderCircleOverlay TailOverlay { get; private set; }
|
||||
|
||||
[CanBeNull]
|
||||
protected PathControlPointVisualiser ControlPointVisualiser { get; private set; }
|
||||
|
||||
private readonly DrawableSlider slider;
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
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 IBindable<int> pathVersion = new Bindable<int>();
|
||||
|
||||
public SliderSelectionBlueprint(DrawableSlider slider)
|
||||
public SliderSelectionBlueprint(Slider slider)
|
||||
: base(slider)
|
||||
{
|
||||
this.slider = slider;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
@ -64,8 +60,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
BodyPiece = new SliderBodyPiece(),
|
||||
HeadBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.Start),
|
||||
TailBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.End),
|
||||
HeadOverlay = CreateCircleOverlay(HitObject, SliderPosition.Start),
|
||||
TailOverlay = CreateCircleOverlay(HitObject, SliderPosition.End),
|
||||
};
|
||||
}
|
||||
|
||||
@ -103,7 +99,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
||||
|
||||
protected override void OnSelected()
|
||||
{
|
||||
AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(slider.HitObject, true)
|
||||
AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true)
|
||||
{
|
||||
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 (controlPoints.Count <= 1 || !slider.HitObject.Path.HasValidLength)
|
||||
if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLength)
|
||||
{
|
||||
placementHandler?.Delete(HitObject);
|
||||
return;
|
||||
@ -245,6 +241,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
||||
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,6 @@
|
||||
|
||||
using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners
|
||||
@ -12,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners
|
||||
{
|
||||
private readonly SpinnerPiece piece;
|
||||
|
||||
public SpinnerSelectionBlueprint(DrawableSpinner spinner)
|
||||
public SpinnerSelectionBlueprint(Spinner spinner)
|
||||
: base(spinner)
|
||||
{
|
||||
InternalChild = piece = new SpinnerPiece();
|
||||
|
@ -3,11 +3,10 @@
|
||||
|
||||
using osu.Game.Rulesets.Edit;
|
||||
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.Sliders;
|
||||
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;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Edit
|
||||
@ -21,21 +20,21 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
|
||||
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new OsuSelectionHandler();
|
||||
|
||||
public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject)
|
||||
public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject)
|
||||
{
|
||||
switch (hitObject)
|
||||
{
|
||||
case DrawableHitCircle circle:
|
||||
case HitCircle circle:
|
||||
return new HitCircleSelectionBlueprint(circle);
|
||||
|
||||
case DrawableSlider slider:
|
||||
case Slider slider:
|
||||
return new SliderSelectionBlueprint(slider);
|
||||
|
||||
case DrawableSpinner spinner:
|
||||
case Spinner spinner:
|
||||
return new SpinnerSelectionBlueprint(spinner);
|
||||
}
|
||||
|
||||
return base.CreateBlueprintFor(hitObject);
|
||||
return base.CreateHitObjectBlueprintFor(hitObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,12 +12,23 @@ using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Screens.Edit.Compose.Components;
|
||||
using osuTK;
|
||||
using Vector2 = osuTK.Vector2;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Edit
|
||||
{
|
||||
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()
|
||||
{
|
||||
base.OnSelectionChanged();
|
||||
@ -50,17 +61,6 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
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()
|
||||
{
|
||||
var hitObjects = EditorBeatmap.SelectedHitObjects;
|
||||
@ -114,24 +114,10 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
var hitObjects = selectedMovableObjects;
|
||||
|
||||
var selectedObjectsQuad = getSurroundingQuad(hitObjects);
|
||||
var centre = selectedObjectsQuad.Centre;
|
||||
|
||||
foreach (var h in hitObjects)
|
||||
{
|
||||
var pos = 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;
|
||||
h.Position = GetFlippedPosition(direction, selectedObjectsQuad, h.Position);
|
||||
|
||||
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();
|
||||
|
||||
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.
|
||||
scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size;
|
||||
@ -333,7 +319,7 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
/// </summary>
|
||||
/// <param name="hitObjects">The hit objects to calculate a quad for.</param>
|
||||
private Quad getSurroundingQuad(OsuHitObject[] hitObjects) =>
|
||||
getSurroundingQuad(hitObjects.SelectMany(h =>
|
||||
GetSurroundingQuad(hitObjects.SelectMany(h =>
|
||||
{
|
||||
if (h is IHasPath path)
|
||||
{
|
||||
@ -348,30 +334,6 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
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>
|
||||
/// All osu! hitobjects which can be moved/rotated/scaled.
|
||||
/// </summary>
|
||||
|
@ -166,7 +166,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
|
||||
|
||||
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;
|
||||
@ -234,6 +234,11 @@ namespace osu.Game.Rulesets.Osu.Statistics
|
||||
|
||||
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 AccuracyHeatmap heatmap;
|
||||
|
||||
@ -284,7 +289,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
|
||||
|
||||
Alpha = Math.Min(amount / lighten_cutoff, 1);
|
||||
if (pointType == HitPointType.Hit)
|
||||
Colour = ((Color4)Colour).Lighten(Math.Max(0, amount - lighten_cutoff));
|
||||
Colour = BaseColour.Lighten(Math.Max(0, amount - lighten_cutoff));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
|
||||
|
||||
[SetUpSteps]
|
||||
public void SetUp()
|
||||
=> AddStep("clear SHOC", () => hitObjectContainer.Clear(false));
|
||||
=> AddStep("clear SHOC", () => hitObjectContainer.Clear());
|
||||
|
||||
protected void AddHitObject(DrawableHitObject hitObject)
|
||||
=> AddStep("add to SHOC", () => hitObjectContainer.Add(hitObject));
|
||||
|
@ -3,14 +3,14 @@
|
||||
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osuTK;
|
||||
|
||||
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)
|
||||
{
|
||||
RelativeSizeAxes = Axes.None;
|
||||
|
@ -3,7 +3,6 @@
|
||||
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
|
||||
using osu.Game.Screens.Edit.Compose.Components;
|
||||
|
||||
@ -18,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
|
||||
|
||||
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new TaikoSelectionHandler();
|
||||
|
||||
public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) =>
|
||||
public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) =>
|
||||
new TaikoSelectionBlueprint(hitObject);
|
||||
}
|
||||
}
|
||||
|
@ -76,10 +76,10 @@ namespace osu.Game.Rulesets.Taiko.Edit
|
||||
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
|
||||
{
|
||||
if (selection.All(s => s.Item is Hit))
|
||||
yield return new TernaryStateMenuItem("Rim") { State = { BindTarget = selectionRimState } };
|
||||
yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } };
|
||||
|
||||
if (selection.All(s => s.Item is TaikoHitObject))
|
||||
yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } };
|
||||
yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } };
|
||||
|
||||
foreach (var item in base.GetContextMenuItemsForSelection(selection))
|
||||
yield return item;
|
||||
|
175
osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs
Normal file
175
osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -45,15 +45,16 @@ namespace osu.Game.Tests.Gameplay
|
||||
AddStep("Create DHO", () =>
|
||||
{
|
||||
dho = new TestDrawableHitObject(null);
|
||||
dho.Apply(entry = new TestLifetimeEntry(new HitObject())
|
||||
{
|
||||
LifetimeStart = 0,
|
||||
LifetimeEnd = 1000,
|
||||
});
|
||||
dho.Apply(entry = new TestLifetimeEntry(new HitObject()));
|
||||
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);
|
||||
|
||||
AddStep("Set LifetimeStart", () => dho.LifetimeStart = 500);
|
||||
|
@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
private void seekToBreak(int breakIndex)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
@ -11,29 +14,35 @@ using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Rulesets.Catch.Scoring;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
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.Scoring;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Taiko.Scoring;
|
||||
using osu.Game.Rulesets.UI;
|
||||
using osu.Game.Scoring;
|
||||
using osu.Game.Screens.Play.HUD.HitErrorMeters;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Gameplay
|
||||
{
|
||||
public class TestSceneHitErrorMeter : OsuTestScene
|
||||
{
|
||||
private HitWindows hitWindows;
|
||||
|
||||
[Cached]
|
||||
private ScoreProcessor scoreProcessor = new ScoreProcessor();
|
||||
|
||||
[Cached(typeof(DrawableRuleset))]
|
||||
private TestDrawableRuleset drawableRuleset = new TestDrawableRuleset();
|
||||
|
||||
public TestSceneHitErrorMeter()
|
||||
{
|
||||
recreateDisplay(new OsuHitWindows(), 5);
|
||||
|
||||
AddRepeatStep("New random judgement", () => newJudgement(), 40);
|
||||
|
||||
AddRepeatStep("New max negative", () => newJudgement(-hitWindows.WindowFor(HitResult.Meh)), 20);
|
||||
AddRepeatStep("New max positive", () => newJudgement(hitWindows.WindowFor(HitResult.Meh)), 20);
|
||||
AddRepeatStep("New max negative", () => newJudgement(-drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20);
|
||||
AddRepeatStep("New max positive", () => newJudgement(drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20);
|
||||
AddStep("New fixed judgement (50ms)", () => newJudgement(50));
|
||||
|
||||
AddStep("Judgement barrage", () =>
|
||||
@ -83,10 +92,10 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
|
||||
private void recreateDisplay(HitWindows hitWindows, float overallDifficulty)
|
||||
{
|
||||
this.hitWindows = hitWindows;
|
||||
|
||||
hitWindows?.SetDifficulty(overallDifficulty);
|
||||
|
||||
drawableRuleset.HitWindows = hitWindows;
|
||||
|
||||
Clear();
|
||||
|
||||
Add(new FillFlowContainer
|
||||
@ -103,40 +112,40 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
}
|
||||
});
|
||||
|
||||
Add(new BarHitErrorMeter(hitWindows, true)
|
||||
Add(new BarHitErrorMeter
|
||||
{
|
||||
Anchor = Anchor.CentreRight,
|
||||
Origin = Anchor.CentreRight,
|
||||
});
|
||||
|
||||
Add(new BarHitErrorMeter(hitWindows, false)
|
||||
Add(new BarHitErrorMeter
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
});
|
||||
|
||||
Add(new BarHitErrorMeter(hitWindows, true)
|
||||
Add(new BarHitErrorMeter
|
||||
{
|
||||
Anchor = Anchor.BottomCentre,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Rotation = 270,
|
||||
});
|
||||
|
||||
Add(new ColourHitErrorMeter(hitWindows)
|
||||
Add(new ColourHitErrorMeter
|
||||
{
|
||||
Anchor = Anchor.CentreRight,
|
||||
Origin = Anchor.CentreRight,
|
||||
Margin = new MarginPadding { Right = 50 }
|
||||
});
|
||||
|
||||
Add(new ColourHitErrorMeter(hitWindows)
|
||||
Add(new ColourHitErrorMeter
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Margin = new MarginPadding { Left = 50 }
|
||||
});
|
||||
|
||||
Add(new ColourHitErrorMeter(hitWindows)
|
||||
Add(new ColourHitErrorMeter
|
||||
{
|
||||
Anchor = Anchor.BottomCentre,
|
||||
Origin = Anchor.CentreLeft,
|
||||
@ -147,11 +156,47 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
|
||||
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,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -27,8 +27,8 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
{
|
||||
private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" };
|
||||
|
||||
[Cached(typeof(SpectatorStreamingClient))]
|
||||
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
|
||||
[Cached(typeof(SpectatorClient))]
|
||||
private TestSpectatorClient testSpectatorClient = new TestSpectatorClient();
|
||||
|
||||
[Cached(typeof(UserLookupCache))]
|
||||
private UserLookupCache lookupCache = new TestUserLookupCache();
|
||||
@ -61,8 +61,8 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
|
||||
AddStep("add streaming client", () =>
|
||||
{
|
||||
Remove(testSpectatorStreamingClient);
|
||||
Add(testSpectatorStreamingClient);
|
||||
Remove(testSpectatorClient);
|
||||
Add(testSpectatorClient);
|
||||
});
|
||||
|
||||
finish();
|
||||
@ -212,9 +212,9 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
|
||||
private void waitForPlayer() => AddUntilStep("wait for player", () => Stack.CurrentScreen is Player);
|
||||
|
||||
private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorStreamingClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
|
||||
private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
|
||||
|
||||
private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
|
||||
private void finish() => AddStep("end play", () => testSpectatorClient.EndPlay(streamingUser.Id));
|
||||
|
||||
private void checkPaused(bool state) =>
|
||||
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state);
|
||||
@ -223,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
{
|
||||
AddStep("send frames", () =>
|
||||
{
|
||||
testSpectatorStreamingClient.SendFrames(streamingUser.Id, nextFrame, count);
|
||||
testSpectatorClient.SendFrames(streamingUser.Id, nextFrame, count);
|
||||
nextFrame += count;
|
||||
});
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private SpectatorStreamingClient streamingClient { get; set; }
|
||||
private SpectatorClient spectatorClient { get; set; }
|
||||
|
||||
[Cached]
|
||||
private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap());
|
||||
@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
{
|
||||
replay = new Replay();
|
||||
|
||||
users.BindTo(streamingClient.PlayingUsers);
|
||||
users.BindTo(spectatorClient.PlayingUsers);
|
||||
users.BindCollectionChanged((obj, args) =>
|
||||
{
|
||||
switch (args.Action)
|
||||
@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
foreach (int user in args.NewItems)
|
||||
{
|
||||
if (user == api.LocalUser.Value.Id)
|
||||
streamingClient.WatchUser(user);
|
||||
spectatorClient.WatchUser(user);
|
||||
}
|
||||
|
||||
break;
|
||||
@ -91,14 +91,14 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
foreach (int user in args.OldItems)
|
||||
{
|
||||
if (user == api.LocalUser.Value.Id)
|
||||
streamingClient.StopWatchingUser(user);
|
||||
spectatorClient.StopWatchingUser(user);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}, true);
|
||||
|
||||
streamingClient.OnNewFrames += onNewFrames;
|
||||
spectatorClient.OnNewFrames += onNewFrames;
|
||||
|
||||
Add(new GridContainer
|
||||
{
|
||||
@ -189,7 +189,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
{
|
||||
}
|
||||
|
||||
private double latency = SpectatorStreamingClient.TIME_BETWEEN_SENDS;
|
||||
private double latency = SpectatorClient.TIME_BETWEEN_SENDS;
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
@ -233,7 +233,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
||||
AddStep("stop recorder", () =>
|
||||
{
|
||||
recorder.Expire();
|
||||
streamingClient.OnNewFrames -= onNewFrames;
|
||||
spectatorClient.OnNewFrames -= onNewFrames;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -23,8 +23,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene
|
||||
{
|
||||
[Cached(typeof(SpectatorStreamingClient))]
|
||||
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient();
|
||||
[Cached(typeof(SpectatorClient))]
|
||||
private TestSpectatorClient spectatorClient = new TestSpectatorClient();
|
||||
|
||||
[Cached(typeof(UserLookupCache))]
|
||||
private UserLookupCache lookupCache = new TestUserLookupCache();
|
||||
@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
base.Content.AddRange(new Drawable[]
|
||||
{
|
||||
streamingClient,
|
||||
spectatorClient,
|
||||
lookupCache,
|
||||
content = new Container { RelativeSizeAxes = Axes.Both }
|
||||
});
|
||||
@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
foreach (var (userId, clock) in clocks)
|
||||
{
|
||||
streamingClient.EndPlay(userId, 0);
|
||||
spectatorClient.EndPlay(userId);
|
||||
clock.CurrentTime = 0;
|
||||
}
|
||||
});
|
||||
@ -67,7 +67,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
AddStep("create leaderboard", () =>
|
||||
{
|
||||
foreach (var (userId, _) in clocks)
|
||||
streamingClient.StartPlay(userId, 0);
|
||||
spectatorClient.StartPlay(userId, 0);
|
||||
|
||||
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
|
||||
|
||||
@ -96,10 +96,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
// For player 2, send frames in sets of 10.
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
streamingClient.SendFrames(PLAYER_1_ID, i, 1);
|
||||
spectatorClient.SendFrames(PLAYER_1_ID, i, 1);
|
||||
|
||||
if (i % 10 == 0)
|
||||
streamingClient.SendFrames(PLAYER_2_ID, i, 10);
|
||||
spectatorClient.SendFrames(PLAYER_2_ID, i, 10);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -22,8 +22,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
public class TestSceneMultiSpectatorScreen : MultiplayerTestScene
|
||||
{
|
||||
[Cached(typeof(SpectatorStreamingClient))]
|
||||
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient();
|
||||
[Cached(typeof(SpectatorClient))]
|
||||
private TestSpectatorClient spectatorClient = new TestSpectatorClient();
|
||||
|
||||
[Cached(typeof(UserLookupCache))]
|
||||
private UserLookupCache lookupCache = new TestUserLookupCache();
|
||||
@ -59,14 +59,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
AddStep("add streaming client", () =>
|
||||
{
|
||||
Remove(streamingClient);
|
||||
Add(streamingClient);
|
||||
Remove(spectatorClient);
|
||||
Add(spectatorClient);
|
||||
});
|
||||
|
||||
AddStep("finish previous gameplay", () =>
|
||||
{
|
||||
foreach (var id in playingUserIds)
|
||||
streamingClient.EndPlay(id, importedBeatmapId);
|
||||
spectatorClient.EndPlay(id);
|
||||
playingUserIds.Clear();
|
||||
});
|
||||
}
|
||||
@ -87,11 +87,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
loadSpectateScreen(false);
|
||||
|
||||
AddWaitStep("wait a bit", 10);
|
||||
AddStep("load player first_player_id", () => streamingClient.StartPlay(PLAYER_1_ID, importedBeatmapId));
|
||||
AddStep("load player first_player_id", () => spectatorClient.StartPlay(PLAYER_1_ID, importedBeatmapId));
|
||||
AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType<Player>().Count() == 1);
|
||||
|
||||
AddWaitStep("wait a bit", 10);
|
||||
AddStep("load player second_player_id", () => streamingClient.StartPlay(PLAYER_2_ID, importedBeatmapId));
|
||||
AddStep("load player second_player_id", () => spectatorClient.StartPlay(PLAYER_2_ID, importedBeatmapId));
|
||||
AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType<Player>().Count() == 2);
|
||||
}
|
||||
|
||||
@ -251,18 +251,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
foreach (int id in userIds)
|
||||
{
|
||||
Client.CurrentMatchPlayingUserIds.Add(id);
|
||||
streamingClient.StartPlay(id, beatmapId ?? importedBeatmapId);
|
||||
spectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId);
|
||||
playingUserIds.Add(id);
|
||||
nextFrame[id] = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void finish(int userId, int? beatmapId = null)
|
||||
private void finish(int userId)
|
||||
{
|
||||
AddStep("end play", () =>
|
||||
{
|
||||
streamingClient.EndPlay(userId, beatmapId ?? importedBeatmapId);
|
||||
spectatorClient.EndPlay(userId);
|
||||
playingUserIds.Remove(userId);
|
||||
nextFrame.Remove(userId);
|
||||
});
|
||||
@ -276,7 +276,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
foreach (int id in userIds)
|
||||
{
|
||||
streamingClient.SendFrames(id, nextFrame[id], count);
|
||||
spectatorClient.SendFrames(id, nextFrame[id], count);
|
||||
nextFrame[id] += count;
|
||||
}
|
||||
});
|
||||
|
@ -195,7 +195,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer
|
||||
{
|
||||
[Cached(typeof(StatefulMultiplayerClient))]
|
||||
[Cached(typeof(MultiplayerClient))]
|
||||
public readonly TestMultiplayerClient Client;
|
||||
|
||||
public TestMultiplayer()
|
||||
|
@ -28,8 +28,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
private const int users = 16;
|
||||
|
||||
[Cached(typeof(SpectatorStreamingClient))]
|
||||
private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming();
|
||||
[Cached(typeof(SpectatorClient))]
|
||||
private TestMultiplayerSpectatorClient spectatorClient = new TestMultiplayerSpectatorClient();
|
||||
|
||||
[Cached(typeof(UserLookupCache))]
|
||||
private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache();
|
||||
@ -44,7 +44,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
base.Content.Children = new Drawable[]
|
||||
{
|
||||
streamingClient,
|
||||
spectatorClient,
|
||||
lookupCache,
|
||||
Content
|
||||
};
|
||||
@ -71,10 +71,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
|
||||
|
||||
for (int i = 0; i < users; i++)
|
||||
streamingClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0);
|
||||
spectatorClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0);
|
||||
|
||||
Client.CurrentMatchPlayingUserIds.Clear();
|
||||
Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers);
|
||||
Client.CurrentMatchPlayingUserIds.AddRange(spectatorClient.PlayingUsers);
|
||||
|
||||
Children = new Drawable[]
|
||||
{
|
||||
@ -83,7 +83,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
scoreProcessor.ApplyBeatmap(playable);
|
||||
|
||||
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, streamingClient.PlayingUsers.ToArray())
|
||||
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, spectatorClient.PlayingUsers.ToArray())
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
@ -96,7 +96,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
[Test]
|
||||
public void TestScoreUpdates()
|
||||
{
|
||||
AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 100);
|
||||
AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 100);
|
||||
AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded);
|
||||
}
|
||||
|
||||
@ -109,12 +109,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
[Test]
|
||||
public void TestChangeScoringMode()
|
||||
{
|
||||
AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 5);
|
||||
AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 5);
|
||||
AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic));
|
||||
AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised));
|
||||
}
|
||||
|
||||
public class TestMultiplayerStreaming : TestSpectatorStreamingClient
|
||||
public class TestMultiplayerSpectatorClient : TestSpectatorClient
|
||||
{
|
||||
private readonly Dictionary<int, FrameHeader> lastHeaders = new Dictionary<int, FrameHeader>();
|
||||
|
||||
|
@ -1,12 +1,16 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Overlays;
|
||||
using osu.Game.Overlays.AccountCreation;
|
||||
using osu.Game.Overlays.Settings;
|
||||
using osu.Game.Users;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Online
|
||||
@ -36,8 +40,6 @@ namespace osu.Game.Tests.Visual.Online
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
API.Logout();
|
||||
|
||||
localUser = API.LocalUser.GetBoundCopy();
|
||||
localUser.BindValueChanged(user => { userPanelArea.Child = new UserGridPanel(user.NewValue) { Width = 200 }; }, true);
|
||||
}
|
||||
@ -46,11 +48,14 @@ namespace osu.Game.Tests.Visual.Online
|
||||
public void TestOverlayVisibility()
|
||||
{
|
||||
AddStep("start hidden", () => accountCreation.Hide());
|
||||
AddStep("log out", API.Logout);
|
||||
AddStep("log out", () => API.Logout());
|
||||
|
||||
AddStep("show manually", () => accountCreation.Show());
|
||||
AddUntilStep("overlay is visible", () => accountCreation.State.Value == Visibility.Visible);
|
||||
|
||||
AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().Click());
|
||||
AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType<ScreenWarning>().SingleOrDefault()?.IsPresent == true);
|
||||
|
||||
AddStep("log back in", () => API.Login("dummy", "password"));
|
||||
AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden);
|
||||
}
|
||||
|
@ -19,8 +19,10 @@ namespace osu.Game.Tests.Visual.Online
|
||||
{
|
||||
public class TestSceneCurrentlyPlayingDisplay : OsuTestScene
|
||||
{
|
||||
[Cached(typeof(SpectatorStreamingClient))]
|
||||
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
|
||||
private readonly User streamingUser = new User { Id = 2, Username = "Test user" };
|
||||
|
||||
[Cached(typeof(SpectatorClient))]
|
||||
private TestSpectatorClient testSpectatorClient = new TestSpectatorClient();
|
||||
|
||||
private CurrentlyPlayingDisplay currentlyPlaying;
|
||||
|
||||
@ -34,7 +36,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
{
|
||||
AddStep("add streaming client", () =>
|
||||
{
|
||||
nestedContainer?.Remove(testSpectatorStreamingClient);
|
||||
nestedContainer?.Remove(testSpectatorClient);
|
||||
Remove(lookupCache);
|
||||
|
||||
Children = new Drawable[]
|
||||
@ -45,7 +47,7 @@ namespace osu.Game.Tests.Visual.Online
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
testSpectatorStreamingClient,
|
||||
testSpectatorClient,
|
||||
currentlyPlaying = new CurrentlyPlayingDisplay
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
@ -55,15 +57,15 @@ namespace osu.Game.Tests.Visual.Online
|
||||
};
|
||||
});
|
||||
|
||||
AddStep("Reset players", () => testSpectatorStreamingClient.PlayingUsers.Clear());
|
||||
AddStep("Reset players", () => testSpectatorClient.EndPlay(streamingUser.Id));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestBasicDisplay()
|
||||
{
|
||||
AddStep("Add playing user", () => testSpectatorStreamingClient.PlayingUsers.Add(2));
|
||||
AddStep("Add playing user", () => testSpectatorClient.StartPlay(streamingUser.Id, 0));
|
||||
AddUntilStep("Panel loaded", () => currentlyPlaying.ChildrenOfType<UserGridPanel>()?.FirstOrDefault()?.User.Id == 2);
|
||||
AddStep("Remove playing user", () => testSpectatorStreamingClient.PlayingUsers.Remove(2));
|
||||
AddStep("Remove playing user", () => testSpectatorClient.EndPlay(streamingUser.Id));
|
||||
AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType<UserGridPanel>().Any());
|
||||
}
|
||||
|
||||
|
152
osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs
Normal file
152
osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -14,7 +14,7 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene
|
||||
{
|
||||
[Test]
|
||||
public void TestTernaryMenuItem()
|
||||
public void TestTernaryRadioMenuItem()
|
||||
{
|
||||
OsuMenu menu = null;
|
||||
|
||||
@ -30,9 +30,57 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
Origin = Anchor.Centre,
|
||||
Items = new[]
|
||||
{
|
||||
new TernaryStateMenuItem("First"),
|
||||
new TernaryStateMenuItem("Second") { State = { BindTarget = state } },
|
||||
new TernaryStateMenuItem("Third") { State = { Value = TernaryState.True } },
|
||||
new TernaryStateRadioMenuItem("First"),
|
||||
new TernaryStateRadioMenuItem("Second") { State = { BindTarget = state } },
|
||||
new TernaryStateRadioMenuItem("Third") { State = { Value = TernaryState.True } },
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
checkState(TernaryState.Indeterminate);
|
||||
|
||||
click();
|
||||
checkState(TernaryState.True);
|
||||
|
||||
click();
|
||||
checkState(TernaryState.True);
|
||||
|
||||
click();
|
||||
checkState(TernaryState.True);
|
||||
|
||||
AddStep("change state via bindable", () => state.Value = TernaryState.True);
|
||||
|
||||
void click() =>
|
||||
AddStep("click", () =>
|
||||
{
|
||||
InputManager.MoveMouseTo(menu.ScreenSpaceDrawQuad.Centre);
|
||||
InputManager.Click(MouseButton.Left);
|
||||
});
|
||||
|
||||
void checkState(TernaryState expected)
|
||||
=> AddAssert($"state is {expected}", () => state.Value == expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestTernaryToggleMenuItem()
|
||||
{
|
||||
OsuMenu menu = null;
|
||||
|
||||
Bindable<TernaryState> state = new Bindable<TernaryState>(TernaryState.Indeterminate);
|
||||
|
||||
AddStep("create menu", () =>
|
||||
{
|
||||
state.Value = TernaryState.Indeterminate;
|
||||
|
||||
Child = menu = new OsuMenu(Direction.Vertical, true)
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Items = new[]
|
||||
{
|
||||
new TernaryStateToggleMenuItem("First"),
|
||||
new TernaryStateToggleMenuItem("Second") { State = { BindTarget = state } },
|
||||
new TernaryStateToggleMenuItem("Third") { State = { Value = TernaryState.True } },
|
||||
}
|
||||
};
|
||||
});
|
||||
|
@ -104,7 +104,6 @@ namespace osu.Game.Configuration
|
||||
SetDefault(OsuSetting.KeyOverlay, false);
|
||||
SetDefault(OsuSetting.PositionalHitSounds, true);
|
||||
SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true);
|
||||
SetDefault(OsuSetting.ScoreMeter, ScoreMeterType.HitErrorBoth);
|
||||
|
||||
SetDefault(OsuSetting.FloatingComments, false);
|
||||
|
||||
@ -213,7 +212,6 @@ namespace osu.Game.Configuration
|
||||
KeyOverlay,
|
||||
PositionalHitSounds,
|
||||
AlwaysPlayFirstComboBreak,
|
||||
ScoreMeter,
|
||||
FloatingComments,
|
||||
HUDVisibilityMode,
|
||||
ShowProgressGraph,
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
@ -9,28 +9,17 @@ namespace osu.Game.Graphics.UserInterface
|
||||
/// <summary>
|
||||
/// An <see cref="OsuMenuItem"/> with three possible states.
|
||||
/// </summary>
|
||||
public class TernaryStateMenuItem : StatefulMenuItem<TernaryState>
|
||||
public abstract class TernaryStateMenuItem : StatefulMenuItem<TernaryState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="TernaryStateMenuItem"/>.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to display.</param>
|
||||
/// <param name="nextStateFunction">A function to inform what the next state should be when this item is clicked.</param>
|
||||
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
|
||||
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
|
||||
public TernaryStateMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
|
||||
: this(text, getNextState, type, action)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="TernaryStateMenuItem"/>.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to display.</param>
|
||||
/// <param name="changeStateFunc">A function that mutates a state to another state after this <see cref="TernaryStateMenuItem"/> is pressed.</param>
|
||||
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
|
||||
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
|
||||
protected TernaryStateMenuItem(string text, Func<TernaryState, TernaryState> changeStateFunc, MenuItemType type, Action<TernaryState> action)
|
||||
: base(text, changeStateFunc, type, action)
|
||||
protected TernaryStateMenuItem(string text, Func<TernaryState, TernaryState> nextStateFunction, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
|
||||
: base(text, nextStateFunction, type, action)
|
||||
{
|
||||
}
|
||||
|
||||
@ -47,23 +36,5 @@ namespace osu.Game.Graphics.UserInterface
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TernaryState getNextState(TernaryState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case TernaryState.False:
|
||||
return TernaryState.True;
|
||||
|
||||
case TernaryState.Indeterminate:
|
||||
return TernaryState.True;
|
||||
|
||||
case TernaryState.True:
|
||||
return TernaryState.False;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(state), state, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
26
osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs
Normal file
26
osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -8,10 +8,12 @@ namespace osu.Game.Online.API.Requests
|
||||
{
|
||||
public class GetNewsRequest : APIRequest<GetNewsResponse>
|
||||
{
|
||||
private readonly int? year;
|
||||
private readonly Cursor cursor;
|
||||
|
||||
public GetNewsRequest(Cursor cursor = null)
|
||||
public GetNewsRequest(int? year = null, Cursor cursor = null)
|
||||
{
|
||||
this.year = year;
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
@ -19,6 +21,10 @@ namespace osu.Game.Online.API.Requests
|
||||
{
|
||||
var req = base.CreateWebRequest();
|
||||
req.AddCursor(cursor);
|
||||
|
||||
if (year.HasValue)
|
||||
req.AddParameter("year", year.Value.ToString());
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
|
@ -11,5 +11,8 @@ namespace osu.Game.Online.API.Requests
|
||||
{
|
||||
[JsonProperty("news_posts")]
|
||||
public IEnumerable<APINewsPost> NewsPosts;
|
||||
|
||||
[JsonProperty("news_sidebar")]
|
||||
public APINewsSidebar SidebarMetadata;
|
||||
}
|
||||
}
|
||||
|
20
osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs
Normal file
20
osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@ using osu.Framework.Graphics;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Overlays;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Online.Chat
|
||||
@ -20,7 +21,10 @@ namespace osu.Game.Online.Chat
|
||||
/// <summary>
|
||||
/// Each word part of a chat link (split for word-wrap support).
|
||||
/// </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));
|
||||
|
||||
@ -34,7 +38,7 @@ namespace osu.Game.Online.Chat
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OsuColour colours)
|
||||
{
|
||||
IdleColour = colours.Blue;
|
||||
IdleColour = overlayColourProvider?.Light2 ?? colours.Blue;
|
||||
}
|
||||
|
||||
protected override IEnumerable<Drawable> EffectTargets => Parts;
|
||||
|
@ -3,132 +3,621 @@
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Online.Rooms;
|
||||
using osu.Game.Online.Rooms.RoomStatuses;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Users;
|
||||
using osu.Game.Utils;
|
||||
|
||||
namespace osu.Game.Online.Multiplayer
|
||||
{
|
||||
public class MultiplayerClient : StatefulMultiplayerClient
|
||||
public abstract class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
|
||||
{
|
||||
private readonly string endpoint;
|
||||
/// <summary>
|
||||
/// Invoked when any change occurs to the multiplayer room.
|
||||
/// </summary>
|
||||
public event Action? RoomUpdated;
|
||||
|
||||
private IHubClientConnector? connector;
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server requests the current beatmap to be loaded into play.
|
||||
/// </summary>
|
||||
public event Action? LoadRequested;
|
||||
|
||||
public override IBindable<bool> IsConnected { get; } = new BindableBool();
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server requests gameplay to be started.
|
||||
/// </summary>
|
||||
public event Action? MatchStarted;
|
||||
|
||||
private HubConnection? connection => connector?.CurrentConnection;
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server has finished collating results.
|
||||
/// </summary>
|
||||
public event Action? ResultsReady;
|
||||
|
||||
public MultiplayerClient(EndpointConfiguration endpoints)
|
||||
/// <summary>
|
||||
/// Whether the <see cref="MultiplayerClient"/> is currently connected.
|
||||
/// This is NOT thread safe and usage should be scheduled.
|
||||
/// </summary>
|
||||
public abstract IBindable<bool> IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The joined <see cref="MultiplayerRoom"/>.
|
||||
/// </summary>
|
||||
public MultiplayerRoom? Room { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The users in the joined <see cref="Room"/> which are participating in the current gameplay loop.
|
||||
/// </summary>
|
||||
public readonly BindableList<int> CurrentMatchPlayingUserIds = new BindableList<int>();
|
||||
|
||||
public readonly Bindable<PlaylistItem?> CurrentMatchPlayingItem = new Bindable<PlaylistItem?>();
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="MultiplayerRoomUser"/> corresponding to the local player, if available.
|
||||
/// </summary>
|
||||
public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the <see cref="LocalUser"/> is the host in <see cref="Room"/>.
|
||||
/// </summary>
|
||||
public bool IsHost
|
||||
{
|
||||
endpoint = endpoints.MultiplayerEndpointUrl;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(IAPIProvider api)
|
||||
{
|
||||
connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint);
|
||||
|
||||
if (connector != null)
|
||||
get
|
||||
{
|
||||
connector.ConfigureConnection = connection =>
|
||||
{
|
||||
// this is kind of SILLY
|
||||
// https://github.com/dotnet/aspnetcore/issues/15198
|
||||
connection.On<MultiplayerRoomState>(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
|
||||
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
|
||||
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
|
||||
connection.On<int>(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
|
||||
connection.On<MultiplayerRoomSettings>(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
|
||||
connection.On<int, MultiplayerUserState>(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
|
||||
connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
|
||||
connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
|
||||
connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
|
||||
connection.On<int, IEnumerable<APIMod>>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
|
||||
connection.On<int, BeatmapAvailability>(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
|
||||
};
|
||||
|
||||
IsConnected.BindTo(connector.IsConnected);
|
||||
var localUser = LocalUser;
|
||||
return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<MultiplayerRoom> JoinRoom(long roomId)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
|
||||
[Resolved]
|
||||
protected IAPIProvider API { get; private set; } = null!;
|
||||
|
||||
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoom), roomId);
|
||||
[Resolved]
|
||||
protected RulesetStore Rulesets { get; private set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private UserLookupCache userLookupCache { get; set; } = null!;
|
||||
|
||||
private Room? apiRoom;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
IsConnected.BindValueChanged(connected =>
|
||||
{
|
||||
// clean up local room state on server disconnect.
|
||||
if (!connected.NewValue && Room != null)
|
||||
{
|
||||
Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
|
||||
LeaveRoom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override Task LeaveRoomInternal()
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.FromCanceled(new CancellationToken(true));
|
||||
private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
|
||||
private CancellationTokenSource? joinCancellationSource;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
|
||||
/// <summary>
|
||||
/// Joins the <see cref="MultiplayerRoom"/> for a given API <see cref="Room"/>.
|
||||
/// </summary>
|
||||
/// <param name="room">The API <see cref="Room"/>.</param>
|
||||
public async Task JoinRoom(Room room)
|
||||
{
|
||||
var cancellationSource = joinCancellationSource = new CancellationTokenSource();
|
||||
|
||||
await joinOrLeaveTaskChain.Add(async () =>
|
||||
{
|
||||
if (Room != null)
|
||||
throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
|
||||
|
||||
Debug.Assert(room.RoomID.Value != null);
|
||||
|
||||
// Join the server-side room.
|
||||
var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false);
|
||||
Debug.Assert(joinedRoom != null);
|
||||
|
||||
// Populate users.
|
||||
Debug.Assert(joinedRoom.Users != null);
|
||||
await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false);
|
||||
|
||||
// Update the stored room (must be done on update thread for thread-safety).
|
||||
await scheduleAsync(() =>
|
||||
{
|
||||
Room = joinedRoom;
|
||||
apiRoom = room;
|
||||
foreach (var user in joinedRoom.Users)
|
||||
updateUserPlayingState(user.UserID, user.State);
|
||||
}, cancellationSource.Token).ConfigureAwait(false);
|
||||
|
||||
// Update room settings.
|
||||
await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false);
|
||||
}, cancellationSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override Task TransferHost(int userId)
|
||||
/// <summary>
|
||||
/// Joins the <see cref="MultiplayerRoom"/> with a given ID.
|
||||
/// </summary>
|
||||
/// <param name="roomId">The room ID.</param>
|
||||
/// <returns>The joined <see cref="MultiplayerRoom"/>.</returns>
|
||||
protected abstract Task<MultiplayerRoom> JoinRoom(long roomId);
|
||||
|
||||
public Task LeaveRoom()
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
// The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled.
|
||||
// This includes the setting of Room itself along with the initial update of the room settings on join.
|
||||
joinCancellationSource?.Cancel();
|
||||
|
||||
// Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background.
|
||||
// However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed.
|
||||
// For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time.
|
||||
var scheduledReset = scheduleAsync(() =>
|
||||
{
|
||||
apiRoom = null;
|
||||
Room = null;
|
||||
CurrentMatchPlayingUserIds.Clear();
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
});
|
||||
|
||||
return joinOrLeaveTaskChain.Add(async () =>
|
||||
{
|
||||
await scheduledReset.ConfigureAwait(false);
|
||||
await LeaveRoomInternal().ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract Task LeaveRoomInternal();
|
||||
|
||||
/// <summary>
|
||||
/// Change the current <see cref="MultiplayerRoom"/> settings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A room must be joined for this to have any effect.
|
||||
/// </remarks>
|
||||
/// <param name="name">The new room name, if any.</param>
|
||||
/// <param name="item">The new room playlist item, if any.</param>
|
||||
public Task ChangeSettings(Optional<string> name = default, Optional<PlaylistItem> item = default)
|
||||
{
|
||||
if (Room == null)
|
||||
throw new InvalidOperationException("Must be joined to a match to change settings.");
|
||||
|
||||
// A dummy playlist item filled with the current room settings (except mods).
|
||||
var existingPlaylistItem = new PlaylistItem
|
||||
{
|
||||
Beatmap =
|
||||
{
|
||||
Value = new BeatmapInfo
|
||||
{
|
||||
OnlineBeatmapID = Room.Settings.BeatmapID,
|
||||
MD5Hash = Room.Settings.BeatmapChecksum
|
||||
}
|
||||
},
|
||||
RulesetID = Room.Settings.RulesetID
|
||||
};
|
||||
|
||||
return ChangeSettings(new MultiplayerRoomSettings
|
||||
{
|
||||
Name = name.GetOr(Room.Settings.Name),
|
||||
BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
|
||||
BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
|
||||
RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
|
||||
RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods,
|
||||
AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the <see cref="LocalUser"/>'s ready state.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If a toggle of ready state is not valid at this time.</exception>
|
||||
public async Task ToggleReady()
|
||||
{
|
||||
var localUser = LocalUser;
|
||||
|
||||
if (localUser == null)
|
||||
return;
|
||||
|
||||
switch (localUser.State)
|
||||
{
|
||||
case MultiplayerUserState.Idle:
|
||||
await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
case MultiplayerUserState.Ready:
|
||||
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the <see cref="LocalUser"/>'s spectating state.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If a toggle of the spectating state is not valid at this time.</exception>
|
||||
public async Task ToggleSpectate()
|
||||
{
|
||||
var localUser = LocalUser;
|
||||
|
||||
if (localUser == null)
|
||||
return;
|
||||
|
||||
switch (localUser.State)
|
||||
{
|
||||
case MultiplayerUserState.Idle:
|
||||
case MultiplayerUserState.Ready:
|
||||
await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
case MultiplayerUserState.Spectating:
|
||||
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}");
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task TransferHost(int userId);
|
||||
|
||||
public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
|
||||
|
||||
public abstract Task ChangeState(MultiplayerUserState newState);
|
||||
|
||||
public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
|
||||
|
||||
/// <summary>
|
||||
/// Change the local user's mods in the currently joined room.
|
||||
/// </summary>
|
||||
/// <param name="newMods">The proposed new mods, excluding any required by the room itself.</param>
|
||||
public Task ChangeUserMods(IEnumerable<Mod> newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList());
|
||||
|
||||
public abstract Task ChangeUserMods(IEnumerable<APIMod> newMods);
|
||||
|
||||
public abstract Task StartMatch();
|
||||
|
||||
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
Room.State = state;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case MultiplayerRoomState.Open:
|
||||
apiRoom.Status.Value = new RoomStatusOpen();
|
||||
break;
|
||||
|
||||
case MultiplayerRoomState.Playing:
|
||||
apiRoom.Status.Value = new RoomStatusPlaying();
|
||||
break;
|
||||
|
||||
case MultiplayerRoomState.Closed:
|
||||
apiRoom.Status.Value = new RoomStatusEnded();
|
||||
break;
|
||||
}
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task ChangeSettings(MultiplayerRoomSettings settings)
|
||||
async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
await PopulateUser(user).ConfigureAwait(false);
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
// for sanity, ensure that there can be no duplicate users in the room user list.
|
||||
if (Room.Users.Any(existing => existing.UserID == user.UserID))
|
||||
return;
|
||||
|
||||
Room.Users.Add(user);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Room.Users.Remove(user);
|
||||
CurrentMatchPlayingUserIds.Remove(user.UserID);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task ChangeState(MultiplayerUserState newState)
|
||||
Task IMultiplayerClient.HostChanged(int userId)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
|
||||
|
||||
Room.Host = user;
|
||||
apiRoom.Host.Value = user?.User;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
|
||||
Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
updateLocalRoomSettings(newSettings);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Room.Users.Single(u => u.UserID == userId).State = state;
|
||||
|
||||
updateUserPlayingState(userId, state);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
|
||||
Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
|
||||
|
||||
// errors here are not critical - beatmap availability state is mostly for display.
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.BeatmapAvailability = beatmapAvailability;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task StartMatch()
|
||||
public Task UserModsChanged(int userId, IEnumerable<APIMod> mods)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
|
||||
|
||||
// errors here are not critical - user mods are mostly for display.
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.Mods = mods;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
|
||||
Task IMultiplayerClient.LoadRequested()
|
||||
{
|
||||
var tcs = new TaskCompletionSource<BeatmapSetInfo>();
|
||||
var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
req.Success += res =>
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
LoadRequested?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.MatchStarted()
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
MatchStarted?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.ResultsReady()
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
ResultsReady?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the <see cref="User"/> for a given <see cref="MultiplayerRoomUser"/>.
|
||||
/// </summary>
|
||||
/// <param name="multiplayerUser">The <see cref="MultiplayerRoomUser"/> to populate.</param>
|
||||
protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the local room settings with the given <see cref="MultiplayerRoomSettings"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This updates both the joined <see cref="MultiplayerRoom"/> and the respective API <see cref="Room"/>.
|
||||
/// </remarks>
|
||||
/// <param name="settings">The new <see cref="MultiplayerRoomSettings"/> to update from.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the update.</param>
|
||||
private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
// Update a few properties of the room instantaneously.
|
||||
Room.Settings = settings;
|
||||
apiRoom.Name.Value = Room.Settings.Name;
|
||||
|
||||
// The current item update is delayed until an online beatmap lookup (below) succeeds.
|
||||
// In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here.
|
||||
CurrentMatchPlayingItem.Value = null;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
|
||||
GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
updatePlaylist(settings, set.Result);
|
||||
}), TaskContinuationOptions.OnlyOnRanToCompletion);
|
||||
}, cancellationToken);
|
||||
|
||||
private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet)
|
||||
{
|
||||
if (Room == null || !Room.Settings.Equals(settings))
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
|
||||
beatmap.MD5Hash = settings.BeatmapChecksum;
|
||||
|
||||
var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance();
|
||||
var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset));
|
||||
var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset));
|
||||
|
||||
// Try to retrieve the existing playlist item from the API room.
|
||||
var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId);
|
||||
|
||||
if (playlistItem != null)
|
||||
updateItem(playlistItem);
|
||||
else
|
||||
{
|
||||
// An existing playlist item does not exist, so append a new one.
|
||||
updateItem(playlistItem = new PlaylistItem());
|
||||
apiRoom.Playlist.Add(playlistItem);
|
||||
}
|
||||
|
||||
CurrentMatchPlayingItem.Value = playlistItem;
|
||||
|
||||
void updateItem(PlaylistItem item)
|
||||
{
|
||||
item.ID = settings.PlaylistItemId;
|
||||
item.Beatmap.Value = beatmap;
|
||||
item.Ruleset.Value = ruleset.RulesetInfo;
|
||||
item.RequiredMods.Clear();
|
||||
item.RequiredMods.AddRange(mods);
|
||||
item.AllowedMods.Clear();
|
||||
item.AllowedMods.AddRange(allowedMods);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a <see cref="BeatmapSetInfo"/> from an online source.
|
||||
/// </summary>
|
||||
/// <param name="beatmapId">The beatmap set ID.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the request.</param>
|
||||
/// <returns>The <see cref="BeatmapSetInfo"/> retrieval task.</returns>
|
||||
protected abstract Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// For the provided user ID, update whether the user is included in <see cref="CurrentMatchPlayingUserIds"/>.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user's ID.</param>
|
||||
/// <param name="state">The new state of the user.</param>
|
||||
private void updateUserPlayingState(int userId, MultiplayerUserState state)
|
||||
{
|
||||
bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId);
|
||||
bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay;
|
||||
|
||||
if (isPlaying == wasPlaying)
|
||||
return;
|
||||
|
||||
if (isPlaying)
|
||||
CurrentMatchPlayingUserIds.Add(userId);
|
||||
else
|
||||
CurrentMatchPlayingUserIds.Remove(userId);
|
||||
}
|
||||
|
||||
private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@ -136,20 +625,18 @@ namespace osu.Game.Online.Multiplayer
|
||||
return;
|
||||
}
|
||||
|
||||
tcs.SetResult(res.ToBeatmapSet(Rulesets));
|
||||
};
|
||||
|
||||
req.Failure += e => tcs.SetException(e);
|
||||
|
||||
API.Queue(req);
|
||||
try
|
||||
{
|
||||
action();
|
||||
tcs.SetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.SetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
connector?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
158
osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs
Normal file
158
osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
89
osu.Game/Online/Spectator/OnlineSpectatorClient.cs
Normal file
89
osu.Game/Online/Spectator/OnlineSpectatorClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,15 +1,16 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Development;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Online.API;
|
||||
@ -23,121 +24,89 @@ using osu.Game.Screens.Play;
|
||||
|
||||
namespace osu.Game.Online.Spectator
|
||||
{
|
||||
public class SpectatorStreamingClient : Component, ISpectatorClient
|
||||
public abstract class SpectatorClient : Component, ISpectatorClient
|
||||
{
|
||||
/// <summary>
|
||||
/// The maximum milliseconds between frame bundle sends.
|
||||
/// </summary>
|
||||
public const double TIME_BETWEEN_SENDS = 200;
|
||||
|
||||
private readonly string endpoint;
|
||||
|
||||
[CanBeNull]
|
||||
private IHubClientConnector connector;
|
||||
|
||||
private readonly IBindable<bool> isConnected = new BindableBool();
|
||||
|
||||
private HubConnection connection => connector?.CurrentConnection;
|
||||
/// <summary>
|
||||
/// Whether the <see cref="SpectatorClient"/> is currently connected.
|
||||
/// This is NOT thread safe and usage should be scheduled.
|
||||
/// </summary>
|
||||
public abstract IBindable<bool> IsConnected { get; }
|
||||
|
||||
private readonly List<int> watchingUsers = new List<int>();
|
||||
|
||||
private readonly object userLock = new object();
|
||||
|
||||
public IBindableList<int> PlayingUsers => playingUsers;
|
||||
|
||||
private readonly BindableList<int> playingUsers = new BindableList<int>();
|
||||
|
||||
private readonly Dictionary<int, SpectatorState> playingUserStates = new Dictionary<int, SpectatorState>();
|
||||
|
||||
[CanBeNull]
|
||||
private IBeatmap currentBeatmap;
|
||||
private IBeatmap? currentBeatmap;
|
||||
|
||||
[CanBeNull]
|
||||
private Score currentScore;
|
||||
private Score? currentScore;
|
||||
|
||||
[Resolved]
|
||||
private IBindable<RulesetInfo> currentRuleset { get; set; }
|
||||
private IBindable<RulesetInfo> currentRuleset { get; set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private IBindable<IReadOnlyList<Mod>> currentMods { get; set; }
|
||||
private IBindable<IReadOnlyList<Mod>> currentMods { get; set; } = null!;
|
||||
|
||||
private readonly SpectatorState currentState = new SpectatorState();
|
||||
|
||||
private bool isPlaying;
|
||||
/// <summary>
|
||||
/// Whether the local user is playing.
|
||||
/// </summary>
|
||||
protected bool IsPlaying { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Called whenever new frames arrive from the server.
|
||||
/// </summary>
|
||||
public event Action<int, FrameDataBundle> OnNewFrames;
|
||||
public event Action<int, FrameDataBundle>? OnNewFrames;
|
||||
|
||||
/// <summary>
|
||||
/// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session.
|
||||
/// </summary>
|
||||
public event Action<int, SpectatorState> OnUserBeganPlaying;
|
||||
public event Action<int, SpectatorState>? OnUserBeganPlaying;
|
||||
|
||||
/// <summary>
|
||||
/// Called whenever a user finishes a play session.
|
||||
/// </summary>
|
||||
public event Action<int, SpectatorState> OnUserFinishedPlaying;
|
||||
|
||||
public SpectatorStreamingClient(EndpointConfiguration endpoints)
|
||||
{
|
||||
endpoint = endpoints.SpectatorEndpointUrl;
|
||||
}
|
||||
public event Action<int, SpectatorState>? OnUserFinishedPlaying;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(IAPIProvider api)
|
||||
private void load()
|
||||
{
|
||||
connector = api.GetHubConnector(nameof(SpectatorStreamingClient), endpoint);
|
||||
|
||||
if (connector != null)
|
||||
IsConnected.BindValueChanged(connected => Schedule(() =>
|
||||
{
|
||||
connector.ConfigureConnection = connection =>
|
||||
if (connected.NewValue)
|
||||
{
|
||||
// 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);
|
||||
};
|
||||
// get all the users that were previously being watched
|
||||
int[] users = watchingUsers.ToArray();
|
||||
watchingUsers.Clear();
|
||||
|
||||
isConnected.BindTo(connector.IsConnected);
|
||||
isConnected.BindValueChanged(connected =>
|
||||
// resubscribe to watched users.
|
||||
foreach (var userId in users)
|
||||
WatchUser(userId);
|
||||
|
||||
// re-send state in case it wasn't received
|
||||
if (IsPlaying)
|
||||
BeginPlayingInternal(currentState);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (connected.NewValue)
|
||||
{
|
||||
// get all the users that were previously being watched
|
||||
int[] users;
|
||||
|
||||
lock (userLock)
|
||||
{
|
||||
users = watchingUsers.ToArray();
|
||||
watchingUsers.Clear();
|
||||
}
|
||||
|
||||
// resubscribe to watched users.
|
||||
foreach (var userId in users)
|
||||
WatchUser(userId);
|
||||
|
||||
// re-send state in case it wasn't received
|
||||
if (isPlaying)
|
||||
beginPlaying();
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (userLock)
|
||||
{
|
||||
playingUsers.Clear();
|
||||
playingUserStates.Clear();
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
playingUsers.Clear();
|
||||
playingUserStates.Clear();
|
||||
}
|
||||
}), true);
|
||||
}
|
||||
|
||||
Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state)
|
||||
{
|
||||
lock (userLock)
|
||||
Schedule(() =>
|
||||
{
|
||||
if (!playingUsers.Contains(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.
|
||||
if (watchingUsers.Contains(userId))
|
||||
playingUserStates[userId] = state;
|
||||
}
|
||||
|
||||
OnUserBeganPlaying?.Invoke(userId, state);
|
||||
OnUserBeganPlaying?.Invoke(userId, state);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
|
||||
{
|
||||
lock (userLock)
|
||||
Schedule(() =>
|
||||
{
|
||||
playingUsers.Remove(userId);
|
||||
playingUserStates.Remove(userId);
|
||||
}
|
||||
|
||||
OnUserFinishedPlaying?.Invoke(userId, state);
|
||||
OnUserFinishedPlaying?.Invoke(userId, state);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
|
||||
{
|
||||
OnNewFrames?.Invoke(userId, data);
|
||||
Schedule(() => OnNewFrames?.Invoke(userId, data));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
isPlaying = true;
|
||||
IsPlaying = true;
|
||||
|
||||
// transfer state at point of beginning play
|
||||
currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID;
|
||||
@ -189,69 +160,65 @@ namespace osu.Game.Online.Spectator
|
||||
currentBeatmap = beatmap.PlayableBeatmap;
|
||||
currentScore = score;
|
||||
|
||||
beginPlaying();
|
||||
BeginPlayingInternal(currentState);
|
||||
}
|
||||
|
||||
private void beginPlaying()
|
||||
{
|
||||
Debug.Assert(isPlaying);
|
||||
|
||||
if (!isConnected.Value) return;
|
||||
|
||||
connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState);
|
||||
}
|
||||
|
||||
public void SendFrames(FrameDataBundle data)
|
||||
{
|
||||
if (!isConnected.Value) return;
|
||||
|
||||
lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data);
|
||||
}
|
||||
public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data);
|
||||
|
||||
public void EndPlaying()
|
||||
{
|
||||
isPlaying = false;
|
||||
currentBeatmap = null;
|
||||
|
||||
if (!isConnected.Value) return;
|
||||
|
||||
connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
|
||||
}
|
||||
|
||||
public virtual void WatchUser(int userId)
|
||||
{
|
||||
lock (userLock)
|
||||
// This method is most commonly called via Dispose(), which is asynchronous.
|
||||
// Todo: This should not be a thing, but requires framework changes.
|
||||
Schedule(() =>
|
||||
{
|
||||
if (watchingUsers.Contains(userId))
|
||||
if (!IsPlaying)
|
||||
return;
|
||||
|
||||
watchingUsers.Add(userId);
|
||||
IsPlaying = false;
|
||||
currentBeatmap = null;
|
||||
|
||||
if (!isConnected.Value)
|
||||
return;
|
||||
}
|
||||
|
||||
connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId);
|
||||
EndPlayingInternal(currentState);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!isConnected.Value)
|
||||
return;
|
||||
}
|
||||
|
||||
connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId);
|
||||
StopWatchingUserInternal(userId);
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract Task BeginPlayingInternal(SpectatorState state);
|
||||
|
||||
protected abstract Task SendFramesInternal(FrameDataBundle data);
|
||||
|
||||
protected abstract Task EndPlayingInternal(SpectatorState state);
|
||||
|
||||
protected abstract Task WatchUserInternal(int userId);
|
||||
|
||||
protected abstract Task StopWatchingUserInternal(int userId);
|
||||
|
||||
private readonly Queue<LegacyReplayFrame> pendingFrames = new Queue<LegacyReplayFrame>();
|
||||
|
||||
private double lastSendTime;
|
||||
|
||||
private Task lastSend;
|
||||
private Task? lastSend;
|
||||
|
||||
private const int max_pending_frames = 30;
|
||||
|
||||
@ -265,6 +232,8 @@ namespace osu.Game.Online.Spectator
|
||||
|
||||
public void HandleFrame(ReplayFrame frame)
|
||||
{
|
||||
Debug.Assert(ThreadSafety.IsUpdateThread);
|
||||
|
||||
if (frame is IConvertibleReplayFrame convertible)
|
||||
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>
|
||||
public bool TryGetPlayingUserState(int userId, out SpectatorState state)
|
||||
{
|
||||
lock (userLock)
|
||||
return playingUserStates.TryGetValue(userId, out state);
|
||||
return playingUserStates.TryGetValue(userId, out state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -308,16 +276,13 @@ namespace osu.Game.Online.Spectator
|
||||
public void BindUserBeganPlaying(Action<int, SpectatorState> callback, bool runOnceImmediately = false)
|
||||
{
|
||||
// The lock is taken before the event is subscribed to to prevent doubling of events.
|
||||
lock (userLock)
|
||||
{
|
||||
OnUserBeganPlaying += callback;
|
||||
OnUserBeganPlaying += callback;
|
||||
|
||||
if (!runOnceImmediately)
|
||||
return;
|
||||
if (!runOnceImmediately)
|
||||
return;
|
||||
|
||||
foreach (var (userId, state) in playingUserStates)
|
||||
callback(userId, state);
|
||||
}
|
||||
foreach (var (userId, state) in playingUserStates)
|
||||
callback(userId, state);
|
||||
}
|
||||
}
|
||||
}
|
@ -85,8 +85,8 @@ namespace osu.Game
|
||||
|
||||
protected IAPIProvider API;
|
||||
|
||||
private SpectatorStreamingClient spectatorStreaming;
|
||||
private StatefulMultiplayerClient multiplayerClient;
|
||||
private SpectatorClient spectatorClient;
|
||||
private MultiplayerClient multiplayerClient;
|
||||
|
||||
protected MenuCursorContainer MenuCursorContainer;
|
||||
|
||||
@ -240,8 +240,8 @@ namespace osu.Game
|
||||
|
||||
dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash));
|
||||
|
||||
dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints));
|
||||
dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints));
|
||||
dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints));
|
||||
dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints));
|
||||
|
||||
var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures);
|
||||
|
||||
@ -313,7 +313,7 @@ namespace osu.Game
|
||||
// add api components to hierarchy.
|
||||
if (API is APIAccess apiAccess)
|
||||
AddInternal(apiAccess);
|
||||
AddInternal(spectatorStreaming);
|
||||
AddInternal(spectatorClient);
|
||||
AddInternal(multiplayerClient);
|
||||
|
||||
AddInternal(RulesetConfigCache);
|
||||
|
@ -23,14 +23,17 @@ namespace osu.Game.Overlays.AccountCreation
|
||||
private OsuTextFlowContainer multiAccountExplanationText;
|
||||
private LinkFlowContainer furtherAssistance;
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
[Resolved(canBeNull: true)]
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
[Resolved(canBeNull: true)]
|
||||
private OsuGame game { get; set; }
|
||||
|
||||
private const string help_centre_url = "/help/wiki/Help_Centre#login";
|
||||
|
||||
public override void OnEntering(IScreen last)
|
||||
{
|
||||
if (string.IsNullOrEmpty(api?.ProvidedUsername))
|
||||
if (string.IsNullOrEmpty(api?.ProvidedUsername) || game?.UseDevelopmentServer == true)
|
||||
{
|
||||
this.FadeOut();
|
||||
this.Push(new ScreenEntry());
|
||||
@ -41,7 +44,7 @@ namespace osu.Game.Overlays.AccountCreation
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader(true)]
|
||||
private void load(OsuColour colours, OsuGame game, TextureStore textures)
|
||||
private void load(OsuColour colours, TextureStore textures)
|
||||
{
|
||||
if (string.IsNullOrEmpty(api?.ProvidedUsername))
|
||||
return;
|
||||
|
@ -26,7 +26,10 @@ namespace osu.Game.Overlays
|
||||
AccentColour = colourProvider.Light2;
|
||||
}
|
||||
|
||||
protected override TabItem<string> CreateTabItem(string value) => new ControlTabItem(value);
|
||||
protected override TabItem<string> CreateTabItem(string value) => new ControlTabItem(value)
|
||||
{
|
||||
AccentColour = AccentColour,
|
||||
};
|
||||
|
||||
private class ControlTabItem : BreadcrumbTabItem
|
||||
{
|
||||
|
@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Dashboard
|
||||
private FillFlowContainer<PlayingUserPanel> userFlow;
|
||||
|
||||
[Resolved]
|
||||
private SpectatorStreamingClient spectatorStreaming { get; set; }
|
||||
private SpectatorClient spectatorClient { get; set; }
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
@ -52,7 +52,7 @@ namespace osu.Game.Overlays.Dashboard
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
playingUsers.BindTo(spectatorStreaming.PlayingUsers);
|
||||
playingUsers.BindTo(spectatorClient.PlayingUsers);
|
||||
playingUsers.BindCollectionChanged(onUsersChanged, true);
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using osu.Framework.Allocation;
|
||||
@ -9,12 +10,18 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Overlays.News.Displays
|
||||
{
|
||||
public class FrontPageDisplay : CompositeDrawable
|
||||
/// <summary>
|
||||
/// Lists articles in a vertical flow for a specified year.
|
||||
/// </summary>
|
||||
public class ArticleListing : CompositeDrawable
|
||||
{
|
||||
public Action<APINewsSidebar> SidebarMetadataUpdated;
|
||||
|
||||
[Resolved]
|
||||
private IAPIProvider api { get; set; }
|
||||
|
||||
@ -24,6 +31,17 @@ namespace osu.Game.Overlays.News.Displays
|
||||
private GetNewsRequest request;
|
||||
private Cursor lastCursor;
|
||||
|
||||
private readonly int? year;
|
||||
|
||||
/// <summary>
|
||||
/// Instantiate a listing for the specified year.
|
||||
/// </summary>
|
||||
/// <param name="year">The year to load articles from. If null, will show the most recent articles.</param>
|
||||
public ArticleListing(int? year = null)
|
||||
{
|
||||
this.year = year;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
@ -74,7 +92,7 @@ namespace osu.Game.Overlays.News.Displays
|
||||
{
|
||||
request?.Cancel();
|
||||
|
||||
request = new GetNewsRequest(lastCursor);
|
||||
request = new GetNewsRequest(year, lastCursor);
|
||||
request.Success += response => Schedule(() => onSuccess(response));
|
||||
api.PerformAsync(request);
|
||||
}
|
||||
@ -85,22 +103,19 @@ namespace osu.Game.Overlays.News.Displays
|
||||
{
|
||||
cancellationToken?.Cancel();
|
||||
|
||||
// only needs to be updated on the initial load, as the content won't change during pagination.
|
||||
if (lastCursor == null)
|
||||
SidebarMetadataUpdated?.Invoke(response.SidebarMetadata);
|
||||
|
||||
// store cursor for next pagination request.
|
||||
lastCursor = response.Cursor;
|
||||
|
||||
var flow = new FillFlowContainer<NewsCard>
|
||||
LoadComponentsAsync(response.NewsPosts.Select(p => new NewsCard(p)).ToList(), loaded =>
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y,
|
||||
Direction = FillDirection.Vertical,
|
||||
Spacing = new Vector2(0, 10),
|
||||
Children = response.NewsPosts.Select(p => new NewsCard(p)).ToList()
|
||||
};
|
||||
content.AddRange(loaded);
|
||||
|
||||
LoadComponentAsync(flow, loaded =>
|
||||
{
|
||||
content.Add(loaded);
|
||||
showMore.IsLoading = false;
|
||||
showMore.Alpha = lastCursor == null ? 0 : 1;
|
||||
showMore.Alpha = response.Cursor != null ? 1 : 0;
|
||||
}, (cancellationToken = new CancellationTokenSource()).Token);
|
||||
}
|
||||
|
@ -19,13 +19,18 @@ namespace osu.Game.Overlays.News
|
||||
{
|
||||
TabControl.AddItem(front_page_string);
|
||||
|
||||
article.BindValueChanged(onArticleChanged, true);
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
Current.BindValueChanged(e =>
|
||||
{
|
||||
if (e.NewValue == front_page_string)
|
||||
ShowFrontPage?.Invoke();
|
||||
});
|
||||
|
||||
article.BindValueChanged(onArticleChanged, true);
|
||||
}
|
||||
|
||||
public void SetFrontPage() => article.Value = null;
|
||||
|
179
osu.Game/Overlays/News/Sidebar/MonthSection.cs
Normal file
179
osu.Game/Overlays/News/Sidebar/MonthSection.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
129
osu.Game/Overlays/News/Sidebar/NewsSidebar.cs
Normal file
129
osu.Game/Overlays/News/Sidebar/NewsSidebar.cs
Normal 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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
120
osu.Game/Overlays/News/Sidebar/YearsPanel.cs
Normal file
120
osu.Game/Overlays/News/Sidebar/YearsPanel.cs
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Overlays.News;
|
||||
using osu.Game.Overlays.News.Displays;
|
||||
using osu.Game.Overlays.News.Sidebar;
|
||||
|
||||
namespace osu.Game.Overlays
|
||||
{
|
||||
@ -13,9 +16,48 @@ namespace osu.Game.Overlays
|
||||
{
|
||||
private readonly Bindable<string> article = new Bindable<string>(null);
|
||||
|
||||
private readonly Container sidebarContainer;
|
||||
private readonly NewsSidebar sidebar;
|
||||
|
||||
private readonly Container content;
|
||||
|
||||
private CancellationTokenSource cancellationToken;
|
||||
|
||||
private bool displayUpdateRequired = true;
|
||||
|
||||
public NewsOverlay()
|
||||
: base(OverlayColourScheme.Purple, false)
|
||||
{
|
||||
Child = new GridContainer
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y,
|
||||
RowDimensions = new[]
|
||||
{
|
||||
new Dimension(GridSizeMode.AutoSize)
|
||||
},
|
||||
ColumnDimensions = new[]
|
||||
{
|
||||
new Dimension(GridSizeMode.AutoSize),
|
||||
new Dimension()
|
||||
},
|
||||
Content = new[]
|
||||
{
|
||||
new Drawable[]
|
||||
{
|
||||
sidebarContainer = new Container
|
||||
{
|
||||
AutoSizeAxes = Axes.X,
|
||||
Child = sidebar = new NewsSidebar()
|
||||
},
|
||||
content = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
@ -26,12 +68,7 @@ namespace osu.Game.Overlays
|
||||
article.BindValueChanged(onArticleChanged);
|
||||
}
|
||||
|
||||
protected override NewsHeader CreateHeader() => new NewsHeader
|
||||
{
|
||||
ShowFrontPage = ShowFrontPage
|
||||
};
|
||||
|
||||
private bool displayUpdateRequired = true;
|
||||
protected override NewsHeader CreateHeader() => new NewsHeader { ShowFrontPage = ShowFrontPage };
|
||||
|
||||
protected override void PopIn()
|
||||
{
|
||||
@ -56,38 +93,69 @@ namespace osu.Game.Overlays
|
||||
Show();
|
||||
}
|
||||
|
||||
public void ShowYear(int year)
|
||||
{
|
||||
loadFrontPage(year);
|
||||
Show();
|
||||
}
|
||||
|
||||
public void ShowArticle(string slug)
|
||||
{
|
||||
article.Value = slug;
|
||||
Show();
|
||||
}
|
||||
|
||||
private CancellationTokenSource cancellationToken;
|
||||
|
||||
private void onArticleChanged(ValueChangedEvent<string> e)
|
||||
{
|
||||
cancellationToken?.Cancel();
|
||||
Loading.Show();
|
||||
|
||||
if (e.NewValue == null)
|
||||
{
|
||||
Header.SetFrontPage();
|
||||
LoadDisplay(new FrontPageDisplay());
|
||||
return;
|
||||
}
|
||||
|
||||
Header.SetArticle(e.NewValue);
|
||||
LoadDisplay(Empty());
|
||||
}
|
||||
|
||||
protected void LoadDisplay(Drawable display)
|
||||
{
|
||||
ScrollFlow.ScrollToStart();
|
||||
LoadComponentAsync(display, loaded =>
|
||||
LoadComponentAsync(display, loaded => content.Child = loaded, (cancellationToken = new CancellationTokenSource()).Token);
|
||||
}
|
||||
|
||||
protected override void UpdateAfterChildren()
|
||||
{
|
||||
base.UpdateAfterChildren();
|
||||
sidebarContainer.Height = DrawHeight;
|
||||
sidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0));
|
||||
}
|
||||
|
||||
private void onArticleChanged(ValueChangedEvent<string> article)
|
||||
{
|
||||
if (article.NewValue == null)
|
||||
loadFrontPage();
|
||||
else
|
||||
loadArticle(article.NewValue);
|
||||
}
|
||||
|
||||
private void loadFrontPage(int? year = null)
|
||||
{
|
||||
beginLoading();
|
||||
|
||||
Header.SetFrontPage();
|
||||
|
||||
var page = new ArticleListing(year);
|
||||
page.SidebarMetadataUpdated += metadata => Schedule(() =>
|
||||
{
|
||||
Child = loaded;
|
||||
sidebar.Metadata.Value = metadata;
|
||||
Loading.Hide();
|
||||
}, (cancellationToken = new CancellationTokenSource()).Token);
|
||||
});
|
||||
LoadDisplay(page);
|
||||
}
|
||||
|
||||
private void loadArticle(string article)
|
||||
{
|
||||
beginLoading();
|
||||
|
||||
Header.SetArticle(article);
|
||||
|
||||
// Temporary, should be handled by ArticleDisplay later
|
||||
LoadDisplay(Empty());
|
||||
Loading.Hide();
|
||||
}
|
||||
|
||||
private void beginLoading()
|
||||
{
|
||||
cancellationToken?.Cancel();
|
||||
Loading.Show();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
|
@ -73,11 +73,6 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay
|
||||
LabelText = "Always play first combo break sound",
|
||||
Current = config.GetBindable<bool>(OsuSetting.AlwaysPlayFirstComboBreak)
|
||||
},
|
||||
new SettingsEnumDropdown<ScoreMeterType>
|
||||
{
|
||||
LabelText = "Score meter type",
|
||||
Current = config.GetBindable<ScoreMeterType>(OsuSetting.ScoreMeter)
|
||||
},
|
||||
new SettingsEnumDropdown<ScoringMode>
|
||||
{
|
||||
LabelText = "Score display mode",
|
||||
|
@ -9,12 +9,12 @@ using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Edit
|
||||
{
|
||||
public abstract class OverlaySelectionBlueprint : SelectionBlueprint<HitObject>
|
||||
public abstract class HitObjectSelectionBlueprint : SelectionBlueprint<HitObject>
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="DrawableHitObject"/> which this <see cref="OverlaySelectionBlueprint"/> applies to.
|
||||
/// The <see cref="DrawableHitObject"/> which this <see cref="HitObjectSelectionBlueprint"/> applies to.
|
||||
/// </summary>
|
||||
public readonly DrawableHitObject DrawableObject;
|
||||
public DrawableHitObject DrawableObject { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 OverlaySelectionBlueprint(DrawableHitObject drawableObject)
|
||||
: base(drawableObject.HitObject)
|
||||
protected HitObjectSelectionBlueprint(HitObject hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
DrawableObject = drawableObject;
|
||||
}
|
||||
|
||||
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 abstract class HitObjectSelectionBlueprint<T> : HitObjectSelectionBlueprint
|
||||
where T : HitObject
|
||||
{
|
||||
public T HitObject => (T)Item;
|
||||
|
||||
protected HitObjectSelectionBlueprint(T item)
|
||||
: base(item)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
@ -36,7 +36,8 @@ namespace osu.Game.Rulesets.Edit
|
||||
[Resolved(canBeNull: true)]
|
||||
protected EditorClock EditorClock { get; private set; }
|
||||
|
||||
private readonly IBindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>();
|
||||
[Resolved]
|
||||
private EditorBeatmap beatmap { get; set; }
|
||||
|
||||
private Bindable<double> startTimeBindable;
|
||||
|
||||
@ -58,10 +59,8 @@ namespace osu.Game.Rulesets.Edit
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(IBindable<WorkingBeatmap> beatmap)
|
||||
private void load()
|
||||
{
|
||||
this.beatmap.BindTo(beatmap);
|
||||
|
||||
startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy();
|
||||
startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true);
|
||||
}
|
||||
@ -113,7 +112,7 @@ namespace osu.Game.Rulesets.Edit
|
||||
/// Invokes <see cref="Objects.HitObject.ApplyDefaults(ControlPointInfo,BeatmapDifficulty, CancellationToken)"/>,
|
||||
/// refreshing <see cref="Objects.HitObject.NestedHitObjects"/> and parameters for the <see cref="HitObject"/>.
|
||||
/// </summary>
|
||||
protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.Value.Beatmap.ControlPointInfo, beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty);
|
||||
protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty);
|
||||
|
||||
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false;
|
||||
|
||||
|
@ -105,34 +105,34 @@ namespace osu.Game.Rulesets.Edit
|
||||
protected override bool ShouldBeConsideredForInput(Drawable child) => State == SelectionState.Selected;
|
||||
|
||||
/// <summary>
|
||||
/// Selects this <see cref="OverlaySelectionBlueprint"/>, causing it to become visible.
|
||||
/// Selects this <see cref="SelectionBlueprint{T}"/>, causing it to become visible.
|
||||
/// </summary>
|
||||
public void Select() => State = SelectionState.Selected;
|
||||
|
||||
/// <summary>
|
||||
/// Deselects this <see cref="OverlaySelectionBlueprint"/>, causing it to become invisible.
|
||||
/// Deselects this <see cref="HitObjectSelectionBlueprint"/>, causing it to become invisible.
|
||||
/// </summary>
|
||||
public void Deselect() => State = SelectionState.NotSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the selection state of this <see cref="OverlaySelectionBlueprint"/>.
|
||||
/// Toggles the selection state of this <see cref="HitObjectSelectionBlueprint"/>.
|
||||
/// </summary>
|
||||
public void ToggleSelection() => State = IsSelected ? SelectionState.NotSelected : SelectionState.Selected;
|
||||
|
||||
public bool IsSelected => State == SelectionState.Selected;
|
||||
|
||||
/// <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>
|
||||
public virtual MenuItem[] ContextMenuItems => Array.Empty<MenuItem>();
|
||||
|
||||
/// <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>
|
||||
public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre;
|
||||
|
||||
/// <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>
|
||||
public virtual Quad SelectionQuad => ScreenSpaceDrawQuad;
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using osu.Framework.Graphics.Performance;
|
||||
using osu.Framework.Graphics.Pooling;
|
||||
@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Objects.Pooling
|
||||
/// <summary>
|
||||
/// The entry holding essential state of this <see cref="PoolableDrawableWithLifetime{TEntry}"/>.
|
||||
/// </summary>
|
||||
protected TEntry? Entry { get; private set; }
|
||||
public TEntry? Entry { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
get => base.LifetimeStart;
|
||||
set => setLifetime(value, LifetimeEnd);
|
||||
get => Entry?.LifetimeStart ?? double.MinValue;
|
||||
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
|
||||
{
|
||||
get => base.LifetimeEnd;
|
||||
set => setLifetime(LifetimeStart, value);
|
||||
get => Entry?.LifetimeEnd ?? double.MaxValue;
|
||||
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;
|
||||
@ -64,11 +79,8 @@ namespace osu.Game.Rulesets.Objects.Pooling
|
||||
if (HasEntryApplied)
|
||||
free();
|
||||
|
||||
setLifetime(entry.LifetimeStart, entry.LifetimeEnd);
|
||||
Entry = entry;
|
||||
|
||||
OnApply(entry);
|
||||
|
||||
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()
|
||||
{
|
||||
Debug.Assert(Entry != null && HasEntryApplied);
|
||||
|
||||
OnFree(Entry);
|
||||
|
||||
Entry = null;
|
||||
setLifetime(double.MaxValue, double.MaxValue);
|
||||
|
||||
HasEntryApplied = false;
|
||||
}
|
||||
}
|
||||
|
@ -17,8 +17,18 @@ using osu.Game.Rulesets.Objects.Drawables;
|
||||
|
||||
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> AliveObjects => AliveInternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime);
|
||||
@ -60,8 +70,12 @@ namespace osu.Game.Rulesets.UI
|
||||
internal double FutureLifetimeExtension { get; set; }
|
||||
|
||||
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 HashSet<HitObjectLifetimeEntry> allEntries = new HashSet<HitObjectLifetimeEntry>();
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private IPooledHitObjectProvider pooledObjectProvider { get; set; }
|
||||
@ -72,6 +86,7 @@ namespace osu.Game.Rulesets.UI
|
||||
|
||||
lifetimeManager.EntryBecameAlive += entryBecameAlive;
|
||||
lifetimeManager.EntryBecameDead += entryBecameDead;
|
||||
lifetimeManager.EntryCrossedBoundary += entryCrossedBoundary;
|
||||
}
|
||||
|
||||
protected override void LoadAsyncComplete()
|
||||
@ -84,93 +99,113 @@ namespace osu.Game.Rulesets.UI
|
||||
|
||||
#region Pooling support
|
||||
|
||||
public void Add(HitObjectLifetimeEntry entry) => lifetimeManager.AddEntry(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)
|
||||
public void Add(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)
|
||||
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.OnRevertResult += onRevertResult;
|
||||
|
||||
bindStartTime(drawable);
|
||||
AddInternal(drawableMap[entry] = drawable, false);
|
||||
OnAdd(drawable);
|
||||
|
||||
HitObjectUsageBegan?.Invoke(entry.HitObject);
|
||||
AddInternal(drawable);
|
||||
}
|
||||
|
||||
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.OnRevertResult -= onRevertResult;
|
||||
|
||||
drawableMap.Remove(entry);
|
||||
|
||||
OnRemove(drawable);
|
||||
unbindStartTime(drawable);
|
||||
RemoveInternal(drawable);
|
||||
|
||||
HitObjectUsageFinished?.Invoke(entry.HitObject);
|
||||
RemoveInternal(drawable);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#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;
|
||||
hitObject.OnRevertResult += onRevertResult;
|
||||
|
||||
AddInternal(hitObject);
|
||||
OnAdd(hitObject);
|
||||
nonPooledDrawableMap.Add(drawable.Entry, drawable);
|
||||
addDrawable(drawable);
|
||||
Add(drawable.Entry);
|
||||
}
|
||||
|
||||
public virtual bool Remove(DrawableHitObject hitObject)
|
||||
public virtual bool Remove(DrawableHitObject drawable)
|
||||
{
|
||||
OnRemove(hitObject);
|
||||
if (!RemoveInternal(hitObject))
|
||||
if (drawable.Entry == null)
|
||||
return false;
|
||||
|
||||
hitObject.OnNewResult -= onNewResult;
|
||||
hitObject.OnRevertResult -= onRevertResult;
|
||||
|
||||
unbindStartTime(hitObject);
|
||||
|
||||
return true;
|
||||
return Remove(drawable.Entry);
|
||||
}
|
||||
|
||||
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))
|
||||
return;
|
||||
if (nonPooledDrawableMap.TryGetValue((HitObjectLifetimeEntry)entry, out var drawable))
|
||||
OnChildLifetimeBoundaryCrossed(new LifetimeBoundaryCrossedEvent(drawable, kind, direction));
|
||||
}
|
||||
|
||||
if ((e.Kind == LifetimeBoundaryKind.End && e.Direction == LifetimeBoundaryCrossingDirection.Forward)
|
||||
|| (e.Kind == LifetimeBoundaryKind.Start && e.Direction == LifetimeBoundaryCrossingDirection.Backward))
|
||||
{
|
||||
hitObject.OnKilled();
|
||||
}
|
||||
protected virtual void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e)
|
||||
{
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -195,12 +230,13 @@ namespace osu.Game.Rulesets.UI
|
||||
{
|
||||
}
|
||||
|
||||
public virtual void Clear(bool disposeChildren = true)
|
||||
public virtual void Clear()
|
||||
{
|
||||
lifetimeManager.ClearEntries();
|
||||
|
||||
ClearInternal(disposeChildren);
|
||||
unbindAllStartTimes();
|
||||
foreach (var drawable in nonPooledDrawableMap.Values)
|
||||
removeDrawable(drawable);
|
||||
nonPooledDrawableMap.Clear();
|
||||
Debug.Assert(InternalChildren.Count == 0 && startTimeMap.Count == 0 && aliveDrawableMap.Count == 0, "All hit objects should have been removed");
|
||||
}
|
||||
|
||||
protected override bool CheckChildrenLife()
|
||||
|
@ -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.
|
||||
// This is done before Apply() so that the state is updated once when the hitobject is applied.
|
||||
foreach (var m in mods.OfType<IApplicableToDrawableHitObjects>())
|
||||
m.ApplyToDrawableHitObjects(dho.Yield());
|
||||
if (mods != null)
|
||||
{
|
||||
foreach (var m in mods.OfType<IApplicableToDrawableHitObjects>())
|
||||
m.ApplyToDrawableHitObjects(dho.Yield());
|
||||
}
|
||||
}
|
||||
|
||||
if (!lifetimeEntryMap.TryGetValue(hitObject, out var entry))
|
||||
|
@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.UI
|
||||
public int RecordFrameRate = 60;
|
||||
|
||||
[Resolved(canBeNull: true)]
|
||||
private SpectatorStreamingClient spectatorStreaming { get; set; }
|
||||
private SpectatorClient spectatorClient { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private GameplayBeatmap gameplayBeatmap { get; set; }
|
||||
@ -49,13 +49,13 @@ namespace osu.Game.Rulesets.UI
|
||||
|
||||
inputManager = GetContainingInputManager();
|
||||
|
||||
spectatorStreaming?.BeginPlaying(gameplayBeatmap, target);
|
||||
spectatorClient?.BeginPlaying(gameplayBeatmap, target);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
spectatorStreaming?.EndPlaying();
|
||||
spectatorClient?.EndPlaying();
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.UI
|
||||
{
|
||||
target.Replay.Frames.Add(frame);
|
||||
|
||||
spectatorStreaming?.HandleFrame(frame);
|
||||
spectatorClient?.HandleFrame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,9 +50,9 @@ namespace osu.Game.Rulesets.UI.Scrolling
|
||||
timeRange.ValueChanged += _ => layoutCache.Invalidate();
|
||||
}
|
||||
|
||||
public override void Clear(bool disposeChildren = true)
|
||||
public override void Clear()
|
||||
{
|
||||
base.Clear(disposeChildren);
|
||||
base.Clear();
|
||||
|
||||
toComputeLifetime.Clear();
|
||||
layoutComputed.Clear();
|
||||
|
@ -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
|
||||
|
||||
#region Selection
|
||||
|
@ -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)
|
||||
{
|
||||
if (e.ControlPressed)
|
||||
@ -246,10 +254,10 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
if (drawable == 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)
|
||||
{
|
||||
|
@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
@ -22,6 +23,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
|
||||
protected readonly HitObjectComposer Composer;
|
||||
|
||||
private HitObjectUsageEventBuffer usageEventBuffer;
|
||||
|
||||
protected EditorBlueprintContainer(HitObjectComposer composer)
|
||||
{
|
||||
Composer = composer;
|
||||
@ -45,11 +48,19 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
foreach (var obj in Composer.HitObjects)
|
||||
AddBlueprintFor(obj.HitObject);
|
||||
|
||||
Composer.Playfield.HitObjectUsageBegan += AddBlueprintFor;
|
||||
Composer.Playfield.HitObjectUsageFinished += RemoveBlueprintFor;
|
||||
usageEventBuffer = new HitObjectUsageEventBuffer(Composer.Playfield);
|
||||
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)
|
||||
=> blueprints.OrderBy(b => b.Item.StartTime);
|
||||
|
||||
@ -80,6 +91,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
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()
|
||||
{
|
||||
base.DragOperationCompleted();
|
||||
@ -133,11 +153,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
Beatmap.HitObjectRemoved -= RemoveBlueprintFor;
|
||||
}
|
||||
|
||||
if (Composer != null)
|
||||
{
|
||||
Composer.Playfield.HitObjectUsageBegan -= AddBlueprintFor;
|
||||
Composer.Playfield.HitObjectUsageFinished -= RemoveBlueprintFor;
|
||||
}
|
||||
usageEventBuffer?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -168,13 +168,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
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")
|
||||
{
|
||||
Items = SelectionSampleStates.Select(kvp =>
|
||||
new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
|
||||
new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,7 @@ using osuTK;
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
/// <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>
|
||||
public class MoveSelectionEvent<T>
|
||||
{
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user