mirror of
https://github.com/osukey/osukey.git
synced 2025-08-03 14:46:38 +09:00
Merge branch 'master' into editor-song-end
This commit is contained in:
@ -14,7 +14,7 @@ using osu.Framework.Graphics.Primitives;
|
||||
using osu.Framework.Input;
|
||||
using osu.Framework.Input.Bindings;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Framework.Timing;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
@ -25,52 +25,63 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
/// <summary>
|
||||
/// A container which provides a "blueprint" display of hitobjects.
|
||||
/// Includes selection and manipulation support via a <see cref="SelectionHandler"/>.
|
||||
/// Includes selection and manipulation support via a <see cref="Components.SelectionHandler"/>.
|
||||
/// </summary>
|
||||
public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler<PlatformAction>
|
||||
{
|
||||
public event Action<IEnumerable<HitObject>> SelectionChanged;
|
||||
|
||||
protected DragBox DragBox { get; private set; }
|
||||
|
||||
protected Container<SelectionBlueprint> SelectionBlueprints { get; private set; }
|
||||
public Container<SelectionBlueprint> SelectionBlueprints { get; private set; }
|
||||
|
||||
private SelectionHandler selectionHandler;
|
||||
protected SelectionHandler SelectionHandler { get; private set; }
|
||||
|
||||
protected readonly HitObjectComposer Composer;
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private IEditorChangeHandler changeHandler { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private IAdjustableClock adjustableClock { get; set; }
|
||||
protected EditorClock EditorClock { get; private set; }
|
||||
|
||||
[Resolved]
|
||||
private EditorBeatmap beatmap { get; set; }
|
||||
protected EditorBeatmap Beatmap { get; private set; }
|
||||
|
||||
private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
|
||||
private readonly Dictionary<HitObject, SelectionBlueprint> blueprintMap = new Dictionary<HitObject, SelectionBlueprint>();
|
||||
|
||||
[Resolved(canBeNull: true)]
|
||||
private IDistanceSnapProvider snapProvider { get; set; }
|
||||
private IPositionSnapProvider snapProvider { get; set; }
|
||||
|
||||
protected BlueprintContainer()
|
||||
protected BlueprintContainer(HitObjectComposer composer)
|
||||
{
|
||||
Composer = composer;
|
||||
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
selectionHandler = CreateSelectionHandler();
|
||||
selectionHandler.DeselectAll = deselectAll;
|
||||
SelectionHandler = CreateSelectionHandler();
|
||||
SelectionHandler.DeselectAll = deselectAll;
|
||||
|
||||
AddRangeInternal(new[]
|
||||
{
|
||||
DragBox = CreateDragBox(select),
|
||||
selectionHandler,
|
||||
DragBox = CreateDragBox(selectBlueprintsFromDragRectangle),
|
||||
SelectionHandler,
|
||||
SelectionBlueprints = CreateSelectionBlueprintContainer(),
|
||||
SelectionHandler.CreateProxy(),
|
||||
DragBox.CreateProxy().With(p => p.Depth = float.MinValue)
|
||||
});
|
||||
|
||||
foreach (var obj in beatmap.HitObjects)
|
||||
AddBlueprintFor(obj);
|
||||
// For non-pooled rulesets, hitobjects are already present in the playfield which allows the blueprints to be loaded in the async context.
|
||||
if (Composer != null)
|
||||
{
|
||||
foreach (var obj in Composer.HitObjects)
|
||||
addBlueprintFor(obj.HitObject);
|
||||
}
|
||||
|
||||
selectedHitObjects.BindTo(beatmap.SelectedHitObjects);
|
||||
selectedHitObjects.BindTo(Beatmap.SelectedHitObjects);
|
||||
selectedHitObjects.CollectionChanged += (selectedObjects, args) =>
|
||||
{
|
||||
switch (args.Action)
|
||||
@ -83,10 +94,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
foreach (var o in args.OldItems)
|
||||
SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Deselect();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
SelectionChanged?.Invoke(selectedHitObjects);
|
||||
};
|
||||
}
|
||||
|
||||
@ -94,15 +104,24 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
beatmap.HitObjectAdded += AddBlueprintFor;
|
||||
beatmap.HitObjectRemoved += removeBlueprintFor;
|
||||
Beatmap.HitObjectAdded += addBlueprintFor;
|
||||
Beatmap.HitObjectRemoved += removeBlueprintFor;
|
||||
|
||||
if (Composer != null)
|
||||
{
|
||||
// For pooled rulesets, blueprints must be added for hitobjects already "current" as they would've not been "current" during the async load addition process above.
|
||||
foreach (var obj in Composer.HitObjects)
|
||||
addBlueprintFor(obj.HitObject);
|
||||
|
||||
Composer.Playfield.HitObjectUsageBegan += addBlueprintFor;
|
||||
Composer.Playfield.HitObjectUsageFinished += removeBlueprintFor;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual Container<SelectionBlueprint> CreateSelectionBlueprintContainer() =>
|
||||
new Container<SelectionBlueprint> { RelativeSizeAxes = Axes.Both };
|
||||
protected virtual Container<SelectionBlueprint> CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="SelectionHandler"/> which outlines <see cref="DrawableHitObject"/>s and handles movement of selections.
|
||||
/// Creates a <see cref="Components.SelectionHandler"/> which outlines <see cref="DrawableHitObject"/>s and handles movement of selections.
|
||||
/// </summary>
|
||||
protected virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler();
|
||||
|
||||
@ -116,20 +135,26 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
|
||||
protected override bool OnMouseDown(MouseDownEvent e)
|
||||
{
|
||||
beginClickSelection(e);
|
||||
if (!beginClickSelection(e)) return true;
|
||||
|
||||
prepareSelectionMovement();
|
||||
|
||||
return e.Button == MouseButton.Left;
|
||||
}
|
||||
|
||||
private SelectionBlueprint clickedBlueprint;
|
||||
|
||||
protected override bool OnClick(ClickEvent e)
|
||||
{
|
||||
if (e.Button == MouseButton.Right)
|
||||
return false;
|
||||
|
||||
// store for double-click handling
|
||||
clickedBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered);
|
||||
|
||||
// Deselection should only occur if no selected blueprints are hovered
|
||||
// A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the hitobject and should not trigger deselection
|
||||
if (endClickSelection() || selectionHandler.SelectedBlueprints.Any(b => b.IsHovered))
|
||||
if (endClickSelection() || clickedBlueprint != null)
|
||||
return true;
|
||||
|
||||
deselectAll();
|
||||
@ -141,12 +166,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
if (e.Button == MouseButton.Right)
|
||||
return false;
|
||||
|
||||
SelectionBlueprint clickedBlueprint = selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered);
|
||||
|
||||
if (clickedBlueprint == null)
|
||||
// ensure the blueprint which was hovered for the first click is still the hovered blueprint.
|
||||
if (clickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint)
|
||||
return false;
|
||||
|
||||
adjustableClock?.Seek(clickedBlueprint.HitObject.StartTime);
|
||||
EditorClock?.SeekSmoothlyTo(clickedBlueprint.HitObject.StartTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -163,8 +187,12 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
if (e.Button == MouseButton.Right)
|
||||
return false;
|
||||
|
||||
if (movementBlueprint != null)
|
||||
if (movementBlueprints != null)
|
||||
{
|
||||
isDraggingBlueprint = true;
|
||||
changeHandler?.BeginChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DragBox.HandleDrag(e))
|
||||
{
|
||||
@ -191,11 +219,18 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
if (e.Button == MouseButton.Right)
|
||||
return;
|
||||
|
||||
if (DragBox.State == Visibility.Visible)
|
||||
if (isDraggingBlueprint)
|
||||
{
|
||||
DragBox.Hide();
|
||||
selectionHandler.UpdateVisibility();
|
||||
// handle positional change etc.
|
||||
foreach (var obj in selectedHitObjects)
|
||||
Beatmap.Update(obj);
|
||||
|
||||
changeHandler?.EndChange();
|
||||
isDraggingBlueprint = false;
|
||||
}
|
||||
|
||||
if (DragBox.State == Visibility.Visible)
|
||||
DragBox.Hide();
|
||||
}
|
||||
|
||||
protected override bool OnKeyDown(KeyDownEvent e)
|
||||
@ -203,7 +238,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
if (!selectionHandler.SelectedBlueprints.Any())
|
||||
if (!SelectionHandler.SelectedBlueprints.Any())
|
||||
return false;
|
||||
|
||||
deselectAll();
|
||||
@ -231,30 +266,59 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
|
||||
#region Blueprint Addition/Removal
|
||||
|
||||
private void removeBlueprintFor(HitObject hitObject)
|
||||
private void addBlueprintFor(HitObject hitObject)
|
||||
{
|
||||
var blueprint = SelectionBlueprints.SingleOrDefault(m => m.HitObject == hitObject);
|
||||
if (blueprint == null)
|
||||
if (blueprintMap.ContainsKey(hitObject))
|
||||
return;
|
||||
|
||||
blueprint.Deselect();
|
||||
|
||||
blueprint.Selected -= onBlueprintSelected;
|
||||
blueprint.Deselected -= onBlueprintDeselected;
|
||||
|
||||
SelectionBlueprints.Remove(blueprint);
|
||||
}
|
||||
|
||||
protected virtual void AddBlueprintFor(HitObject hitObject)
|
||||
{
|
||||
var blueprint = CreateBlueprintFor(hitObject);
|
||||
if (blueprint == null)
|
||||
return;
|
||||
|
||||
blueprintMap[hitObject] = blueprint;
|
||||
|
||||
blueprint.Selected += onBlueprintSelected;
|
||||
blueprint.Deselected += onBlueprintDeselected;
|
||||
|
||||
if (Beatmap.SelectedHitObjects.Contains(hitObject))
|
||||
blueprint.Select();
|
||||
|
||||
SelectionBlueprints.Add(blueprint);
|
||||
|
||||
OnBlueprintAdded(hitObject);
|
||||
}
|
||||
|
||||
private void removeBlueprintFor(HitObject hitObject)
|
||||
{
|
||||
if (!blueprintMap.Remove(hitObject, out var blueprint))
|
||||
return;
|
||||
|
||||
blueprint.Deselect();
|
||||
blueprint.Selected -= onBlueprintSelected;
|
||||
blueprint.Deselected -= onBlueprintDeselected;
|
||||
|
||||
SelectionBlueprints.Remove(blueprint);
|
||||
|
||||
if (movementBlueprints?.Contains(blueprint) == true)
|
||||
finishSelectionMovement();
|
||||
|
||||
OnBlueprintRemoved(hitObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after a <see cref="HitObject"/> blueprint has been added.
|
||||
/// </summary>
|
||||
/// <param name="hitObject">The <see cref="HitObject"/> for which the blueprint has been added.</param>
|
||||
protected virtual void OnBlueprintAdded(HitObject hitObject)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after a <see cref="HitObject"/> blueprint has been removed.
|
||||
/// </summary>
|
||||
/// <param name="hitObject">The <see cref="HitObject"/> for which the blueprint has been removed.</param>
|
||||
protected virtual void OnBlueprintRemoved(HitObject hitObject)
|
||||
{
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -270,26 +334,18 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// Attempts to select any hovered blueprints.
|
||||
/// </summary>
|
||||
/// <param name="e">The input event that triggered this selection.</param>
|
||||
private void beginClickSelection(MouseButtonEvent e)
|
||||
/// <returns>Whether a selection was performed.</returns>
|
||||
private bool beginClickSelection(MouseButtonEvent e)
|
||||
{
|
||||
Debug.Assert(!clickSelectionBegan);
|
||||
|
||||
// Deselections are only allowed for control + left clicks
|
||||
bool allowDeselection = e.ControlPressed && e.Button == MouseButton.Left;
|
||||
|
||||
// Todo: This is probably incorrectly disallowing multiple selections on stacked objects
|
||||
if (!allowDeselection && selectionHandler.SelectedBlueprints.Any(s => s.IsHovered))
|
||||
return;
|
||||
|
||||
foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren)
|
||||
// Iterate from the top of the input stack (blueprints closest to the front of the screen first).
|
||||
foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse())
|
||||
{
|
||||
if (blueprint.IsHovered)
|
||||
{
|
||||
selectionHandler.HandleSelectionRequested(blueprint, e.CurrentState);
|
||||
clickSelectionBegan = true;
|
||||
break;
|
||||
}
|
||||
if (!blueprint.IsHovered) continue;
|
||||
|
||||
return clickSelectionBegan = SelectionHandler.HandleSelectionRequested(blueprint, e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -309,14 +365,26 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// Select all masks in a given rectangle selection area.
|
||||
/// </summary>
|
||||
/// <param name="rect">The rectangle to perform a selection on in screen-space coordinates.</param>
|
||||
private void select(RectangleF rect)
|
||||
private void selectBlueprintsFromDragRectangle(RectangleF rect)
|
||||
{
|
||||
foreach (var blueprint in SelectionBlueprints)
|
||||
{
|
||||
if (blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.SelectionPoint))
|
||||
blueprint.Select();
|
||||
else
|
||||
blueprint.Deselect();
|
||||
// only run when utmost necessary to avoid unnecessary rect computations.
|
||||
bool isValidForSelection() => blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.ScreenSpaceSelectionPoint);
|
||||
|
||||
switch (blueprint.State)
|
||||
{
|
||||
case SelectionState.NotSelected:
|
||||
if (isValidForSelection())
|
||||
blueprint.Select();
|
||||
break;
|
||||
|
||||
case SelectionState.Selected:
|
||||
// if the editor is playing, we generally don't want to deselect objects even if outside the selection area.
|
||||
if (!EditorClock.IsRunning && !isValidForSelection())
|
||||
blueprint.Deselect();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,52 +393,57 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// </summary>
|
||||
private void selectAll()
|
||||
{
|
||||
SelectionBlueprints.ToList().ForEach(m => m.Select());
|
||||
selectionHandler.UpdateVisibility();
|
||||
Composer.Playfield.KeepAllAlive();
|
||||
|
||||
// Scheduled to allow the change in lifetime to take place.
|
||||
Schedule(() => SelectionBlueprints.ToList().ForEach(m => m.Select()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deselects all selected <see cref="SelectionBlueprint"/>s.
|
||||
/// </summary>
|
||||
private void deselectAll() => selectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect());
|
||||
private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect());
|
||||
|
||||
private void onBlueprintSelected(SelectionBlueprint blueprint)
|
||||
{
|
||||
selectionHandler.HandleSelected(blueprint);
|
||||
SelectionHandler.HandleSelected(blueprint);
|
||||
SelectionBlueprints.ChangeChildDepth(blueprint, 1);
|
||||
beatmap.SelectedHitObjects.Add(blueprint.HitObject);
|
||||
|
||||
Composer.Playfield.SetKeepAlive(blueprint.HitObject, true);
|
||||
}
|
||||
|
||||
private void onBlueprintDeselected(SelectionBlueprint blueprint)
|
||||
{
|
||||
selectionHandler.HandleDeselected(blueprint);
|
||||
SelectionHandler.HandleDeselected(blueprint);
|
||||
SelectionBlueprints.ChangeChildDepth(blueprint, 0);
|
||||
beatmap.SelectedHitObjects.Remove(blueprint.HitObject);
|
||||
|
||||
Composer.Playfield.SetKeepAlive(blueprint.HitObject, false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Selection Movement
|
||||
|
||||
private Vector2? movementBlueprintOriginalPosition;
|
||||
private SelectionBlueprint movementBlueprint;
|
||||
private Vector2[] movementBlueprintOriginalPositions;
|
||||
private SelectionBlueprint[] movementBlueprints;
|
||||
private bool isDraggingBlueprint;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to begin the movement of any selected blueprints.
|
||||
/// </summary>
|
||||
private void prepareSelectionMovement()
|
||||
{
|
||||
if (!selectionHandler.SelectedBlueprints.Any())
|
||||
if (!SelectionHandler.SelectedBlueprints.Any())
|
||||
return;
|
||||
|
||||
// Any selected blueprint that is hovered can begin the movement of the group, however only the earliest hitobject is used for movement
|
||||
// A special case is added for when a click selection occurred before the drag
|
||||
if (!clickSelectionBegan && !selectionHandler.SelectedBlueprints.Any(b => b.IsHovered))
|
||||
if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered))
|
||||
return;
|
||||
|
||||
// Movement is tracked from the blueprint of the earliest hitobject, since it only makes sense to distance snap from that hitobject
|
||||
movementBlueprint = selectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).First();
|
||||
movementBlueprintOriginalPosition = movementBlueprint.SelectionPoint; // todo: unsure if correct
|
||||
movementBlueprints = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).ToArray();
|
||||
movementBlueprintOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSelectionPoint).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -380,26 +453,50 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// <returns>Whether a movement was active.</returns>
|
||||
private bool moveCurrentSelection(DragEvent e)
|
||||
{
|
||||
if (movementBlueprint == null)
|
||||
if (movementBlueprints == null)
|
||||
return false;
|
||||
|
||||
Debug.Assert(movementBlueprintOriginalPosition != null);
|
||||
|
||||
HitObject draggedObject = movementBlueprint.HitObject;
|
||||
|
||||
// The final movement position, relative to screenSpaceMovementStartPosition
|
||||
Vector2 movePosition = movementBlueprintOriginalPosition.Value + e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition;
|
||||
|
||||
(Vector2 snappedPosition, double snappedTime) = snapProvider.GetSnappedPosition(ToLocalSpace(movePosition), draggedObject.StartTime);
|
||||
|
||||
// Move the hitobjects
|
||||
if (!selectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, ToScreenSpace(snappedPosition))))
|
||||
if (snapProvider == null)
|
||||
return true;
|
||||
|
||||
// Apply the start time at the newly snapped-to position
|
||||
double offset = snappedTime - draggedObject.StartTime;
|
||||
foreach (HitObject obj in selectionHandler.SelectedHitObjects)
|
||||
obj.StartTime += offset;
|
||||
Debug.Assert(movementBlueprintOriginalPositions != null);
|
||||
|
||||
Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition;
|
||||
|
||||
// check for positional snap for every object in selection (for things like object-object snapping)
|
||||
for (var i = 0; i < movementBlueprintOriginalPositions.Length; i++)
|
||||
{
|
||||
var testPosition = movementBlueprintOriginalPositions[i] + distanceTravelled;
|
||||
|
||||
var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition);
|
||||
|
||||
if (positionalResult.ScreenSpacePosition == testPosition) continue;
|
||||
|
||||
// attempt to move the objects, and abort any time based snapping if we can.
|
||||
if (SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints[i], positionalResult.ScreenSpacePosition)))
|
||||
return true;
|
||||
}
|
||||
|
||||
// if no positional snapping could be performed, try unrestricted snapping from the earliest
|
||||
// hitobject in the selection.
|
||||
|
||||
// The final movement position, relative to movementBlueprintOriginalPosition.
|
||||
Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled;
|
||||
|
||||
// Retrieve a snapped position.
|
||||
var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition);
|
||||
|
||||
// Move the hitobjects.
|
||||
if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), result.ScreenSpacePosition)))
|
||||
return true;
|
||||
|
||||
if (result.Time.HasValue)
|
||||
{
|
||||
// Apply the start time at the newly snapped-to position
|
||||
double offset = result.Time.Value - movementBlueprints.First().HitObject.StartTime;
|
||||
|
||||
Beatmap.PerformOnSelection(obj => obj.StartTime += offset);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -410,11 +507,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// <returns>Whether a movement was active.</returns>
|
||||
private bool finishSelectionMovement()
|
||||
{
|
||||
if (movementBlueprint == null)
|
||||
if (movementBlueprints == null)
|
||||
return false;
|
||||
|
||||
movementBlueprintOriginalPosition = null;
|
||||
movementBlueprint = null;
|
||||
movementBlueprintOriginalPositions = null;
|
||||
movementBlueprints = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -425,10 +522,16 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
|
||||
if (beatmap != null)
|
||||
if (Beatmap != null)
|
||||
{
|
||||
beatmap.HitObjectAdded -= AddBlueprintFor;
|
||||
beatmap.HitObjectRemoved -= removeBlueprintFor;
|
||||
Beatmap.HitObjectAdded -= addBlueprintFor;
|
||||
Beatmap.HitObjectRemoved -= removeBlueprintFor;
|
||||
}
|
||||
|
||||
if (Composer != null)
|
||||
{
|
||||
Composer.Playfield.HitObjectUsageBegan -= addBlueprintFor;
|
||||
Composer.Playfield.HitObjectUsageFinished -= removeBlueprintFor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,14 +3,21 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Humanizer;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Input;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Edit.Tools;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Screens.Edit.Components.TernaryButtons;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
@ -20,21 +27,16 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// </summary>
|
||||
public class ComposeBlueprintContainer : BlueprintContainer
|
||||
{
|
||||
[Resolved]
|
||||
private HitObjectComposer composer { get; set; }
|
||||
|
||||
private PlacementBlueprint currentPlacement;
|
||||
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
|
||||
|
||||
private readonly Container<PlacementBlueprint> placementBlueprintContainer;
|
||||
|
||||
private PlacementBlueprint currentPlacement;
|
||||
private InputManager inputManager;
|
||||
|
||||
private readonly IEnumerable<DrawableHitObject> drawableHitObjects;
|
||||
|
||||
public ComposeBlueprintContainer(IEnumerable<DrawableHitObject> drawableHitObjects)
|
||||
public ComposeBlueprintContainer(HitObjectComposer composer)
|
||||
: base(composer)
|
||||
{
|
||||
this.drawableHitObjects = drawableHitObjects;
|
||||
|
||||
placementBlueprintContainer = new Container<PlacementBlueprint>
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both
|
||||
@ -44,6 +46,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
TernaryStates = CreateTernaryButtons().ToArray();
|
||||
|
||||
AddInternal(placementBlueprintContainer);
|
||||
}
|
||||
|
||||
@ -52,6 +56,90 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
base.LoadComplete();
|
||||
|
||||
inputManager = GetContainingInputManager();
|
||||
|
||||
// updates to selected are handled for us by SelectionHandler.
|
||||
NewCombo.BindTo(SelectionHandler.SelectionNewComboState);
|
||||
|
||||
// we are responsible for current placement blueprint updated based on state changes.
|
||||
NewCombo.ValueChanged += _ => updatePlacementNewCombo();
|
||||
|
||||
// we own SelectionHandler so don't need to worry about making bindable copies (for simplicity)
|
||||
foreach (var kvp in SelectionHandler.SelectionSampleStates)
|
||||
{
|
||||
kvp.Value.BindValueChanged(_ => updatePlacementSamples());
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePlacementNewCombo()
|
||||
{
|
||||
if (currentPlacement?.HitObject is IHasComboInformation c)
|
||||
c.NewCombo = NewCombo.Value == TernaryState.True;
|
||||
}
|
||||
|
||||
private void updatePlacementSamples()
|
||||
{
|
||||
if (currentPlacement == null) return;
|
||||
|
||||
foreach (var kvp in SelectionHandler.SelectionSampleStates)
|
||||
sampleChanged(kvp.Key, kvp.Value.Value);
|
||||
}
|
||||
|
||||
private void sampleChanged(string sampleName, TernaryState state)
|
||||
{
|
||||
if (currentPlacement == null) return;
|
||||
|
||||
var samples = currentPlacement.HitObject.Samples;
|
||||
|
||||
var existingSample = samples.FirstOrDefault(s => s.Name == sampleName);
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case TernaryState.False:
|
||||
if (existingSample != null)
|
||||
samples.Remove(existingSample);
|
||||
break;
|
||||
|
||||
case TernaryState.True:
|
||||
if (existingSample == null)
|
||||
samples.Add(new HitSampleInfo(sampleName));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly Bindable<TernaryState> NewCombo = new Bindable<TernaryState> { Description = "New Combo" };
|
||||
|
||||
/// <summary>
|
||||
/// A collection of states which will be displayed to the user in the toolbox.
|
||||
/// </summary>
|
||||
public TernaryButton[] TernaryStates { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create all ternary states required to be displayed to the user.
|
||||
/// </summary>
|
||||
protected virtual IEnumerable<TernaryButton> CreateTernaryButtons()
|
||||
{
|
||||
//TODO: this should only be enabled (visible?) for rulesets that provide combo-supporting HitObjects.
|
||||
yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = FontAwesome.Regular.DotCircle });
|
||||
|
||||
foreach (var kvp in SelectionHandler.SelectionSampleStates)
|
||||
yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => getIconForSample(kvp.Key));
|
||||
}
|
||||
|
||||
private Drawable getIconForSample(string sampleName)
|
||||
{
|
||||
switch (sampleName)
|
||||
{
|
||||
case HitSampleInfo.HIT_CLAP:
|
||||
return new SpriteIcon { Icon = FontAwesome.Solid.Hands };
|
||||
|
||||
case HitSampleInfo.HIT_WHISTLE:
|
||||
return new SpriteIcon { Icon = FontAwesome.Solid.Bullhorn };
|
||||
|
||||
case HitSampleInfo.HIT_FINISH:
|
||||
return new SpriteIcon { Icon = FontAwesome.Solid.DrumSteelpan };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#region Placement
|
||||
@ -65,12 +153,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
createPlacement();
|
||||
}
|
||||
|
||||
private void updatePlacementPosition(Vector2 screenSpacePosition)
|
||||
private void updatePlacementPosition()
|
||||
{
|
||||
Vector2 snappedGridPosition = composer.GetSnappedPosition(ToLocalSpace(screenSpacePosition), 0).position;
|
||||
Vector2 snappedScreenSpacePosition = ToScreenSpace(snappedGridPosition);
|
||||
var snapResult = Composer.SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position);
|
||||
|
||||
currentPlacement.UpdatePosition(snappedScreenSpacePosition);
|
||||
// if no time was found from positional snapping, we should still quantize to the beat.
|
||||
snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null);
|
||||
|
||||
currentPlacement.UpdateTimeAndPosition(snapResult);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -79,18 +169,20 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
base.Update();
|
||||
|
||||
if (composer.CursorInPlacementArea)
|
||||
if (Composer.CursorInPlacementArea)
|
||||
createPlacement();
|
||||
else if (currentPlacement?.PlacementActive == false)
|
||||
removePlacement();
|
||||
|
||||
if (currentPlacement != null)
|
||||
updatePlacementPosition(inputManager.CurrentState.Mouse.Position);
|
||||
{
|
||||
updatePlacementPosition();
|
||||
}
|
||||
}
|
||||
|
||||
protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject hitObject)
|
||||
{
|
||||
var drawable = drawableHitObjects.FirstOrDefault(d => d.HitObject == hitObject);
|
||||
var drawable = Composer.HitObjects.FirstOrDefault(d => d.HitObject == hitObject);
|
||||
|
||||
if (drawable == null)
|
||||
return null;
|
||||
@ -100,10 +192,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
|
||||
public virtual OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null;
|
||||
|
||||
protected override void AddBlueprintFor(HitObject hitObject)
|
||||
protected override void OnBlueprintAdded(HitObject hitObject)
|
||||
{
|
||||
base.OnBlueprintAdded(hitObject);
|
||||
|
||||
refreshTool();
|
||||
base.AddBlueprintFor(hitObject);
|
||||
|
||||
// on successful placement, the new combo button should be reset as this is the most common user interaction.
|
||||
if (Beatmap.SelectedHitObjects.Count == 0)
|
||||
NewCombo.Value = TernaryState.False;
|
||||
}
|
||||
|
||||
private void createPlacement()
|
||||
@ -117,7 +214,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
placementBlueprintContainer.Child = currentPlacement = blueprint;
|
||||
|
||||
// Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame
|
||||
updatePlacementPosition(inputManager.CurrentState.Mouse.Position);
|
||||
updatePlacementPosition();
|
||||
|
||||
updatePlacementSamples();
|
||||
|
||||
updatePlacementNewCombo();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -43,7 +43,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
protected OsuColour Colours { get; private set; }
|
||||
|
||||
[Resolved]
|
||||
protected IDistanceSnapProvider SnapProvider { get; private set; }
|
||||
protected IPositionSnapProvider SnapProvider { get; private set; }
|
||||
|
||||
[Resolved]
|
||||
private EditorBeatmap beatmap { get; set; }
|
||||
|
@ -45,7 +45,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
Masking = true,
|
||||
BorderColour = Color4.White,
|
||||
BorderThickness = SelectionHandler.BORDER_RADIUS,
|
||||
BorderThickness = SelectionBox.BORDER_RADIUS,
|
||||
Child = new Box
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
@ -53,6 +53,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
}
|
||||
};
|
||||
|
||||
private RectangleF? dragRectangle;
|
||||
|
||||
/// <summary>
|
||||
/// Handle a forwarded mouse event.
|
||||
/// </summary>
|
||||
@ -66,15 +68,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
var dragQuad = new Quad(dragStartPosition.X, dragStartPosition.Y, dragPosition.X - dragStartPosition.X, dragPosition.Y - dragStartPosition.Y);
|
||||
|
||||
// We use AABBFloat instead of RectangleF since it handles negative sizes for us
|
||||
var dragRectangle = dragQuad.AABBFloat;
|
||||
var rec = dragQuad.AABBFloat;
|
||||
dragRectangle = rec;
|
||||
|
||||
var topLeft = ToLocalSpace(dragRectangle.TopLeft);
|
||||
var bottomRight = ToLocalSpace(dragRectangle.BottomRight);
|
||||
var topLeft = ToLocalSpace(rec.TopLeft);
|
||||
var bottomRight = ToLocalSpace(rec.BottomRight);
|
||||
|
||||
Box.Position = topLeft;
|
||||
Box.Size = bottomRight - topLeft;
|
||||
|
||||
PerformSelection?.Invoke(dragRectangle);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -93,7 +94,19 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
}
|
||||
}
|
||||
|
||||
public override void Hide() => State = Visibility.Hidden;
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
if (dragRectangle != null)
|
||||
PerformSelection?.Invoke(dragRectangle.Value);
|
||||
}
|
||||
|
||||
public override void Hide()
|
||||
{
|
||||
State = Visibility.Hidden;
|
||||
dragRectangle = null;
|
||||
}
|
||||
|
||||
public override void Show() => State = Visibility.Visible;
|
||||
|
||||
|
@ -1,32 +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 osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides a border around the playfield.
|
||||
/// </summary>
|
||||
public class EditorPlayfieldBorder : CompositeDrawable
|
||||
{
|
||||
public EditorPlayfieldBorder()
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
|
||||
Masking = true;
|
||||
BorderColour = Color4.White;
|
||||
BorderThickness = 2;
|
||||
|
||||
InternalChild = new Box
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Alpha = 0,
|
||||
AlwaysPresent = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
/// <summary>
|
||||
/// A container for <see cref="SelectionBlueprint"/> ordered by their <see cref="HitObject"/> start times.
|
||||
/// </summary>
|
||||
public sealed class HitObjectOrderedSelectionContainer : Container<SelectionBlueprint>
|
||||
{
|
||||
public override void Add(SelectionBlueprint drawable)
|
||||
{
|
||||
base.Add(drawable);
|
||||
bindStartTime(drawable);
|
||||
}
|
||||
|
||||
public override bool Remove(SelectionBlueprint drawable)
|
||||
{
|
||||
if (!base.Remove(drawable))
|
||||
return false;
|
||||
|
||||
unbindStartTime(drawable);
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void Clear(bool disposeChildren)
|
||||
{
|
||||
base.Clear(disposeChildren);
|
||||
unbindAllStartTimes();
|
||||
}
|
||||
|
||||
private readonly Dictionary<SelectionBlueprint, IBindable> startTimeMap = new Dictionary<SelectionBlueprint, IBindable>();
|
||||
|
||||
private void bindStartTime(SelectionBlueprint blueprint)
|
||||
{
|
||||
var bindable = blueprint.HitObject.StartTimeBindable.GetBoundCopy();
|
||||
|
||||
bindable.BindValueChanged(_ =>
|
||||
{
|
||||
if (LoadState >= LoadState.Ready)
|
||||
SortInternal();
|
||||
});
|
||||
|
||||
startTimeMap[blueprint] = bindable;
|
||||
}
|
||||
|
||||
private void unbindStartTime(SelectionBlueprint blueprint)
|
||||
{
|
||||
startTimeMap[blueprint].UnbindAll();
|
||||
startTimeMap.Remove(blueprint);
|
||||
}
|
||||
|
||||
private void unbindAllStartTimes()
|
||||
{
|
||||
foreach (var kvp in startTimeMap)
|
||||
kvp.Value.UnbindAll();
|
||||
startTimeMap.Clear();
|
||||
}
|
||||
|
||||
protected override int Compare(Drawable x, Drawable y)
|
||||
{
|
||||
var xObj = (SelectionBlueprint)x;
|
||||
var yObj = (SelectionBlueprint)y;
|
||||
|
||||
// Put earlier blueprints towards the end of the list, so they handle input first
|
||||
int i = yObj.HitObject.StartTime.CompareTo(xObj.HitObject.StartTime);
|
||||
return i == 0 ? CompareReverseChildID(x, y) : i;
|
||||
}
|
||||
}
|
||||
}
|
273
osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs
Normal file
273
osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs
Normal file
@ -0,0 +1,273 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Game.Graphics;
|
||||
using osuTK;
|
||||
using osuTK.Input;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
public class SelectionBox : CompositeDrawable
|
||||
{
|
||||
public Func<float, bool> OnRotation;
|
||||
public Func<Vector2, Anchor, bool> OnScale;
|
||||
public Func<Direction, bool> OnFlip;
|
||||
public Func<bool> OnReverse;
|
||||
|
||||
public Action OperationStarted;
|
||||
public Action OperationEnded;
|
||||
|
||||
private bool canReverse;
|
||||
|
||||
/// <summary>
|
||||
/// Whether pattern reversing support should be enabled.
|
||||
/// </summary>
|
||||
public bool CanReverse
|
||||
{
|
||||
get => canReverse;
|
||||
set
|
||||
{
|
||||
if (canReverse == value) return;
|
||||
|
||||
canReverse = value;
|
||||
recreate();
|
||||
}
|
||||
}
|
||||
|
||||
private bool canRotate;
|
||||
|
||||
/// <summary>
|
||||
/// Whether rotation support should be enabled.
|
||||
/// </summary>
|
||||
public bool CanRotate
|
||||
{
|
||||
get => canRotate;
|
||||
set
|
||||
{
|
||||
if (canRotate == value) return;
|
||||
|
||||
canRotate = value;
|
||||
recreate();
|
||||
}
|
||||
}
|
||||
|
||||
private bool canScaleX;
|
||||
|
||||
/// <summary>
|
||||
/// Whether vertical scale support should be enabled.
|
||||
/// </summary>
|
||||
public bool CanScaleX
|
||||
{
|
||||
get => canScaleX;
|
||||
set
|
||||
{
|
||||
if (canScaleX == value) return;
|
||||
|
||||
canScaleX = value;
|
||||
recreate();
|
||||
}
|
||||
}
|
||||
|
||||
private bool canScaleY;
|
||||
|
||||
/// <summary>
|
||||
/// Whether horizontal scale support should be enabled.
|
||||
/// </summary>
|
||||
public bool CanScaleY
|
||||
{
|
||||
get => canScaleY;
|
||||
set
|
||||
{
|
||||
if (canScaleY == value) return;
|
||||
|
||||
canScaleY = value;
|
||||
recreate();
|
||||
}
|
||||
}
|
||||
|
||||
private Container dragHandles;
|
||||
private FillFlowContainer buttons;
|
||||
|
||||
public const float BORDER_RADIUS = 3;
|
||||
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; }
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
|
||||
recreate();
|
||||
}
|
||||
|
||||
protected override bool OnKeyDown(KeyDownEvent e)
|
||||
{
|
||||
if (e.Repeat || !e.ControlPressed)
|
||||
return false;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.G:
|
||||
return CanReverse && OnReverse?.Invoke() == true;
|
||||
|
||||
case Key.H:
|
||||
return CanScaleX && OnFlip?.Invoke(Direction.Horizontal) == true;
|
||||
|
||||
case Key.J:
|
||||
return CanScaleY && OnFlip?.Invoke(Direction.Vertical) == true;
|
||||
}
|
||||
|
||||
return base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
private void recreate()
|
||||
{
|
||||
if (LoadState < LoadState.Loading)
|
||||
return;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Container
|
||||
{
|
||||
Masking = true,
|
||||
BorderThickness = BORDER_RADIUS,
|
||||
BorderColour = colours.YellowDark,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
|
||||
AlwaysPresent = true,
|
||||
Alpha = 0
|
||||
},
|
||||
}
|
||||
},
|
||||
dragHandles = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
// ensures that the centres of all drag handles line up with the middle of the selection box border.
|
||||
Padding = new MarginPadding(BORDER_RADIUS / 2)
|
||||
},
|
||||
buttons = new FillFlowContainer
|
||||
{
|
||||
Y = 20,
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Direction = FillDirection.Horizontal,
|
||||
Anchor = Anchor.BottomCentre,
|
||||
Origin = Anchor.Centre
|
||||
}
|
||||
};
|
||||
|
||||
if (CanScaleX) addXScaleComponents();
|
||||
if (CanScaleX && CanScaleY) addFullScaleComponents();
|
||||
if (CanScaleY) addYScaleComponents();
|
||||
if (CanRotate) addRotationComponents();
|
||||
if (CanReverse) addButton(FontAwesome.Solid.Backward, "Reverse pattern (Ctrl-G)", () => OnReverse?.Invoke());
|
||||
}
|
||||
|
||||
private void addRotationComponents()
|
||||
{
|
||||
const float separation = 40;
|
||||
|
||||
addButton(FontAwesome.Solid.Undo, "Rotate 90 degrees counter-clockwise", () => OnRotation?.Invoke(-90));
|
||||
addButton(FontAwesome.Solid.Redo, "Rotate 90 degrees clockwise", () => OnRotation?.Invoke(90));
|
||||
|
||||
AddRangeInternal(new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Depth = float.MaxValue,
|
||||
Colour = colours.YellowLight,
|
||||
Blending = BlendingParameters.Additive,
|
||||
Alpha = 0.3f,
|
||||
Size = new Vector2(BORDER_RADIUS, separation),
|
||||
Anchor = Anchor.TopCentre,
|
||||
Origin = Anchor.BottomCentre,
|
||||
},
|
||||
new SelectionBoxDragHandleButton(FontAwesome.Solid.Redo, "Free rotate")
|
||||
{
|
||||
Anchor = Anchor.TopCentre,
|
||||
Y = -separation,
|
||||
HandleDrag = e => OnRotation?.Invoke(convertDragEventToAngleOfRotation(e)),
|
||||
OperationStarted = operationStarted,
|
||||
OperationEnded = operationEnded
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void addYScaleComponents()
|
||||
{
|
||||
addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically (Ctrl-J)", () => OnFlip?.Invoke(Direction.Vertical));
|
||||
|
||||
addDragHandle(Anchor.TopCentre);
|
||||
addDragHandle(Anchor.BottomCentre);
|
||||
}
|
||||
|
||||
private void addFullScaleComponents()
|
||||
{
|
||||
addDragHandle(Anchor.TopLeft);
|
||||
addDragHandle(Anchor.TopRight);
|
||||
addDragHandle(Anchor.BottomLeft);
|
||||
addDragHandle(Anchor.BottomRight);
|
||||
}
|
||||
|
||||
private void addXScaleComponents()
|
||||
{
|
||||
addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally (Ctrl-H)", () => OnFlip?.Invoke(Direction.Horizontal));
|
||||
|
||||
addDragHandle(Anchor.CentreLeft);
|
||||
addDragHandle(Anchor.CentreRight);
|
||||
}
|
||||
|
||||
private void addButton(IconUsage icon, string tooltip, Action action)
|
||||
{
|
||||
buttons.Add(new SelectionBoxDragHandleButton(icon, tooltip)
|
||||
{
|
||||
OperationStarted = operationStarted,
|
||||
OperationEnded = operationEnded,
|
||||
Action = action
|
||||
});
|
||||
}
|
||||
|
||||
private void addDragHandle(Anchor anchor) => dragHandles.Add(new SelectionBoxDragHandle
|
||||
{
|
||||
Anchor = anchor,
|
||||
HandleDrag = e => OnScale?.Invoke(e.Delta, anchor),
|
||||
OperationStarted = operationStarted,
|
||||
OperationEnded = operationEnded
|
||||
});
|
||||
|
||||
private int activeOperations;
|
||||
|
||||
private float convertDragEventToAngleOfRotation(DragEvent e)
|
||||
{
|
||||
// Adjust coordinate system to the center of SelectionBox
|
||||
float startAngle = MathF.Atan2(e.LastMousePosition.Y - DrawHeight / 2, e.LastMousePosition.X - DrawWidth / 2);
|
||||
float endAngle = MathF.Atan2(e.MousePosition.Y - DrawHeight / 2, e.MousePosition.X - DrawWidth / 2);
|
||||
|
||||
return (endAngle - startAngle) * 180 / MathF.PI;
|
||||
}
|
||||
|
||||
private void operationEnded()
|
||||
{
|
||||
if (--activeOperations == 0)
|
||||
OperationEnded?.Invoke();
|
||||
}
|
||||
|
||||
private void operationStarted()
|
||||
{
|
||||
if (activeOperations++ == 0)
|
||||
OperationStarted?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Game.Graphics;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
public class SelectionBoxDragHandle : Container
|
||||
{
|
||||
public Action OperationStarted;
|
||||
public Action OperationEnded;
|
||||
|
||||
public Action<DragEvent> HandleDrag { get; set; }
|
||||
|
||||
private Circle circle;
|
||||
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; }
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
Size = new Vector2(10);
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
circle = new Circle
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
UpdateHoverState();
|
||||
}
|
||||
|
||||
protected override bool OnHover(HoverEvent e)
|
||||
{
|
||||
UpdateHoverState();
|
||||
return base.OnHover(e);
|
||||
}
|
||||
|
||||
protected override void OnHoverLost(HoverLostEvent e)
|
||||
{
|
||||
base.OnHoverLost(e);
|
||||
UpdateHoverState();
|
||||
}
|
||||
|
||||
protected bool HandlingMouse;
|
||||
|
||||
protected override bool OnMouseDown(MouseDownEvent e)
|
||||
{
|
||||
HandlingMouse = true;
|
||||
UpdateHoverState();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override bool OnDragStart(DragStartEvent e)
|
||||
{
|
||||
OperationStarted?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override void OnDrag(DragEvent e)
|
||||
{
|
||||
HandleDrag?.Invoke(e);
|
||||
base.OnDrag(e);
|
||||
}
|
||||
|
||||
protected override void OnDragEnd(DragEndEvent e)
|
||||
{
|
||||
HandlingMouse = false;
|
||||
OperationEnded?.Invoke();
|
||||
|
||||
UpdateHoverState();
|
||||
base.OnDragEnd(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseUpEvent e)
|
||||
{
|
||||
HandlingMouse = false;
|
||||
UpdateHoverState();
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected virtual void UpdateHoverState()
|
||||
{
|
||||
circle.Colour = HandlingMouse ? colours.GrayF : (IsHovered ? colours.Red : colours.YellowDark);
|
||||
this.ScaleTo(HandlingMouse || IsHovered ? 1.5f : 1, 100, Easing.OutQuint);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Cursor;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Input.Events;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
/// <summary>
|
||||
/// A drag "handle" which shares the visual appearance but behaves more like a clickable button.
|
||||
/// </summary>
|
||||
public sealed class SelectionBoxDragHandleButton : SelectionBoxDragHandle, IHasTooltip
|
||||
{
|
||||
private SpriteIcon icon;
|
||||
|
||||
private readonly IconUsage iconUsage;
|
||||
|
||||
public Action Action;
|
||||
|
||||
public SelectionBoxDragHandleButton(IconUsage iconUsage, string tooltip)
|
||||
{
|
||||
this.iconUsage = iconUsage;
|
||||
|
||||
TooltipText = tooltip;
|
||||
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
Size *= 2;
|
||||
AddInternal(icon = new SpriteIcon
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Size = new Vector2(0.5f),
|
||||
Icon = iconUsage,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
});
|
||||
}
|
||||
|
||||
protected override bool OnClick(ClickEvent e)
|
||||
{
|
||||
OperationStarted?.Invoke();
|
||||
Action?.Invoke();
|
||||
OperationEnded?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override void UpdateHoverState()
|
||||
{
|
||||
base.UpdateHoverState();
|
||||
icon.Colour = !HandlingMouse && IsHovered ? Color4.White : Color4.Black;
|
||||
}
|
||||
|
||||
public string TooltipText { get; }
|
||||
}
|
||||
}
|
@ -4,7 +4,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Humanizer;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Cursor;
|
||||
@ -12,14 +14,17 @@ using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Graphics.UserInterface;
|
||||
using osu.Framework.Input;
|
||||
using osu.Framework.Input.Bindings;
|
||||
using osu.Framework.Input.States;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osuTK;
|
||||
using osuTK.Input;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
@ -28,17 +33,22 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// </summary>
|
||||
public class SelectionHandler : CompositeDrawable, IKeyBindingHandler<PlatformAction>, IHasContextMenu
|
||||
{
|
||||
public const float BORDER_RADIUS = 2;
|
||||
|
||||
public IEnumerable<SelectionBlueprint> SelectedBlueprints => selectedBlueprints;
|
||||
private readonly List<SelectionBlueprint> selectedBlueprints;
|
||||
|
||||
public IEnumerable<HitObject> SelectedHitObjects => selectedBlueprints.Select(b => b.HitObject);
|
||||
public int SelectedCount => selectedBlueprints.Count;
|
||||
|
||||
private Drawable outline;
|
||||
private Drawable content;
|
||||
|
||||
private OsuSpriteText selectionDetailsText;
|
||||
|
||||
protected SelectionBox SelectionBox { get; private set; }
|
||||
|
||||
[Resolved]
|
||||
protected EditorBeatmap EditorBeatmap { get; private set; }
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private EditorBeatmap editorBeatmap { get; set; }
|
||||
protected IEditorChangeHandler ChangeHandler { get; private set; }
|
||||
|
||||
public SelectionHandler()
|
||||
{
|
||||
@ -52,29 +62,109 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OsuColour colours)
|
||||
{
|
||||
InternalChild = outline = new Container
|
||||
createStateBindables();
|
||||
|
||||
InternalChild = content = new Container
|
||||
{
|
||||
Masking = true,
|
||||
BorderThickness = BORDER_RADIUS,
|
||||
BorderColour = colours.Yellow,
|
||||
Child = new Box
|
||||
Children = new Drawable[]
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
AlwaysPresent = true,
|
||||
Alpha = 0
|
||||
// todo: should maybe be inside the SelectionBox?
|
||||
new Container
|
||||
{
|
||||
Name = "info text",
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = colours.YellowDark,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
selectionDetailsText = new OsuSpriteText
|
||||
{
|
||||
Padding = new MarginPadding(2),
|
||||
Colour = colours.Gray0,
|
||||
Font = OsuFont.Default.With(size: 11)
|
||||
}
|
||||
}
|
||||
},
|
||||
SelectionBox = CreateSelectionBox(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public SelectionBox CreateSelectionBox()
|
||||
=> new SelectionBox
|
||||
{
|
||||
OperationStarted = OnOperationBegan,
|
||||
OperationEnded = OnOperationEnded,
|
||||
|
||||
OnRotation = HandleRotation,
|
||||
OnScale = HandleScale,
|
||||
OnFlip = HandleFlip,
|
||||
OnReverse = HandleReverse,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a drag operation ends from the selection box.
|
||||
/// </summary>
|
||||
protected virtual void OnOperationBegan()
|
||||
{
|
||||
ChangeHandler?.BeginChange();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a drag operation begins from the selection box.
|
||||
/// </summary>
|
||||
protected virtual void OnOperationEnded()
|
||||
{
|
||||
ChangeHandler?.EndChange();
|
||||
}
|
||||
|
||||
#region User Input Handling
|
||||
|
||||
/// <summary>
|
||||
/// Handles the selected <see cref="DrawableHitObject"/>s being moved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Just returning true is enough to allow <see cref="HitObject.StartTime"/> updates to take place.
|
||||
/// Custom implementation is only required if other attributes are to be considered, like changing columns.
|
||||
/// </remarks>
|
||||
/// <param name="moveEvent">The move event.</param>
|
||||
/// <returns>Whether any <see cref="DrawableHitObject"/>s were moved.</returns>
|
||||
/// <returns>
|
||||
/// Whether any <see cref="DrawableHitObject"/>s could be moved.
|
||||
/// Returning true will also propagate StartTime changes provided by the closest <see cref="IPositionSnapProvider.SnapScreenSpacePositionToValidTime"/>.
|
||||
/// </returns>
|
||||
public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the selected <see cref="DrawableHitObject"/>s being rotated.
|
||||
/// </summary>
|
||||
/// <param name="angle">The delta angle to apply to the selection.</param>
|
||||
/// <returns>Whether any <see cref="DrawableHitObject"/>s could be rotated.</returns>
|
||||
public virtual bool HandleRotation(float angle) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the selected <see cref="DrawableHitObject"/>s being scaled.
|
||||
/// </summary>
|
||||
/// <param name="scale">The delta scale to apply, in playfield local coordinates.</param>
|
||||
/// <param name="anchor">The point of reference where the scale is originating from.</param>
|
||||
/// <returns>Whether any <see cref="DrawableHitObject"/>s could be scaled.</returns>
|
||||
public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the selected <see cref="DrawableHitObject"/>s being flipped.
|
||||
/// </summary>
|
||||
/// <param name="direction">The direction to flip</param>
|
||||
/// <returns>Whether any <see cref="DrawableHitObject"/>s could be flipped.</returns>
|
||||
public virtual bool HandleFlip(Direction direction) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the selected <see cref="DrawableHitObject"/>s being reversed pattern-wise.
|
||||
/// </summary>
|
||||
/// <returns>Whether any <see cref="DrawableHitObject"/>s could be reversed.</returns>
|
||||
public virtual bool HandleReverse() => false;
|
||||
|
||||
public bool OnPressed(PlatformAction action)
|
||||
{
|
||||
switch (action.ActionMethod)
|
||||
@ -107,9 +197,10 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
internal void HandleSelected(SelectionBlueprint blueprint)
|
||||
{
|
||||
selectedBlueprints.Add(blueprint);
|
||||
editorBeatmap.SelectedHitObjects.Add(blueprint.HitObject);
|
||||
|
||||
UpdateVisibility();
|
||||
// there are potentially multiple SelectionHandlers active, but we only want to add hitobjects to the selected list once.
|
||||
if (!EditorBeatmap.SelectedHitObjects.Contains(blueprint.HitObject))
|
||||
EditorBeatmap.SelectedHitObjects.Add(blueprint.HitObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -119,41 +210,55 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
internal void HandleDeselected(SelectionBlueprint blueprint)
|
||||
{
|
||||
selectedBlueprints.Remove(blueprint);
|
||||
editorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject);
|
||||
|
||||
// We don't want to update visibility if > 0, since we may be deselecting blueprints during drag-selection
|
||||
if (selectedBlueprints.Count == 0)
|
||||
UpdateVisibility();
|
||||
EditorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a blueprint requesting selection.
|
||||
/// </summary>
|
||||
/// <param name="blueprint">The blueprint.</param>
|
||||
/// <param name="state">The input state at the point of selection.</param>
|
||||
internal void HandleSelectionRequested(SelectionBlueprint blueprint, InputState state)
|
||||
/// <param name="e">The mouse event responsible for selection.</param>
|
||||
/// <returns>Whether a selection was performed.</returns>
|
||||
internal bool HandleSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e)
|
||||
{
|
||||
if (state.Keyboard.ControlPressed)
|
||||
if (e.ShiftPressed && e.Button == MouseButton.Right)
|
||||
{
|
||||
if (blueprint.IsSelected)
|
||||
blueprint.Deselect();
|
||||
else
|
||||
blueprint.Select();
|
||||
handleQuickDeletion(blueprint);
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (blueprint.IsSelected)
|
||||
return;
|
||||
|
||||
DeselectAll?.Invoke();
|
||||
blueprint.Select();
|
||||
}
|
||||
if (e.ControlPressed && e.Button == MouseButton.Left)
|
||||
blueprint.ToggleSelection();
|
||||
else
|
||||
ensureSelected(blueprint);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void handleQuickDeletion(SelectionBlueprint blueprint)
|
||||
{
|
||||
if (blueprint.HandleQuickDeletion())
|
||||
return;
|
||||
|
||||
if (!blueprint.IsSelected)
|
||||
EditorBeatmap.Remove(blueprint.HitObject);
|
||||
else
|
||||
deleteSelected();
|
||||
}
|
||||
|
||||
private void ensureSelected(SelectionBlueprint blueprint)
|
||||
{
|
||||
if (blueprint.IsSelected)
|
||||
return;
|
||||
|
||||
DeselectAll?.Invoke();
|
||||
blueprint.Select();
|
||||
}
|
||||
|
||||
private void deleteSelected()
|
||||
{
|
||||
foreach (var h in selectedBlueprints.ToList())
|
||||
editorBeatmap.Remove(h.HitObject);
|
||||
EditorBeatmap.RemoveRange(selectedBlueprints.Select(b => b.HitObject));
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -163,12 +268,22 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// <summary>
|
||||
/// Updates whether this <see cref="SelectionHandler"/> is visible.
|
||||
/// </summary>
|
||||
internal void UpdateVisibility()
|
||||
private void updateVisibility()
|
||||
{
|
||||
int count = selectedBlueprints.Count;
|
||||
|
||||
selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty;
|
||||
|
||||
this.FadeTo(count > 0 ? 1 : 0);
|
||||
OnSelectionChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggered whenever the set of selected objects changes.
|
||||
/// Should update the selection box's state to match supported operations.
|
||||
/// </summary>
|
||||
protected virtual void OnSelectionChanged()
|
||||
{
|
||||
if (selectedBlueprints.Count > 0)
|
||||
Show();
|
||||
else
|
||||
Hide();
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
@ -191,8 +306,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
topLeft -= new Vector2(5);
|
||||
bottomRight += new Vector2(5);
|
||||
|
||||
outline.Size = bottomRight - topLeft;
|
||||
outline.Position = topLeft;
|
||||
content.Size = bottomRight - topLeft;
|
||||
content.Position = topLeft;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -205,14 +320,32 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// <param name="sampleName">The name of the hit sample.</param>
|
||||
public void AddHitSample(string sampleName)
|
||||
{
|
||||
foreach (var h in SelectedHitObjects)
|
||||
EditorBeatmap.PerformOnSelection(h =>
|
||||
{
|
||||
// Make sure there isn't already an existing sample
|
||||
if (h.Samples.Any(s => s.Name == sampleName))
|
||||
continue;
|
||||
return;
|
||||
|
||||
h.Samples.Add(new HitSampleInfo { Name = sampleName });
|
||||
}
|
||||
h.Samples.Add(new HitSampleInfo(sampleName));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the new combo state of all selected <see cref="HitObject"/>s.
|
||||
/// </summary>
|
||||
/// <param name="state">Whether to set or unset.</param>
|
||||
/// <exception cref="InvalidOperationException">Throws if any selected object doesn't implement <see cref="IHasComboInformation"/></exception>
|
||||
public void SetNewCombo(bool state)
|
||||
{
|
||||
EditorBeatmap.PerformOnSelection(h =>
|
||||
{
|
||||
var comboInfo = h as IHasComboInformation;
|
||||
|
||||
if (comboInfo == null || comboInfo.NewCombo == state) return;
|
||||
|
||||
comboInfo.NewCombo = state;
|
||||
EditorBeatmap.Update(h);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -221,76 +354,144 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// <param name="sampleName">The name of the hit sample.</param>
|
||||
public void RemoveHitSample(string sampleName)
|
||||
{
|
||||
foreach (var h in SelectedHitObjects)
|
||||
h.SamplesBindable.RemoveAll(s => s.Name == sampleName);
|
||||
EditorBeatmap.PerformOnSelection(h => h.SamplesBindable.RemoveAll(s => s.Name == sampleName));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Selection State
|
||||
|
||||
/// <summary>
|
||||
/// The state of "new combo" for all selected hitobjects.
|
||||
/// </summary>
|
||||
public readonly Bindable<TernaryState> SelectionNewComboState = new Bindable<TernaryState>();
|
||||
|
||||
/// <summary>
|
||||
/// The state of each sample type for all selected hitobjects. Keys match with <see cref="HitSampleInfo"/> constant specifications.
|
||||
/// </summary>
|
||||
public readonly Dictionary<string, Bindable<TernaryState>> SelectionSampleStates = new Dictionary<string, Bindable<TernaryState>>();
|
||||
|
||||
/// <summary>
|
||||
/// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions)
|
||||
/// </summary>
|
||||
private void createStateBindables()
|
||||
{
|
||||
foreach (var sampleName in HitSampleInfo.AllAdditions)
|
||||
{
|
||||
var bindable = new Bindable<TernaryState>
|
||||
{
|
||||
Description = sampleName.Replace("hit", string.Empty).Titleize()
|
||||
};
|
||||
|
||||
bindable.ValueChanged += state =>
|
||||
{
|
||||
switch (state.NewValue)
|
||||
{
|
||||
case TernaryState.False:
|
||||
RemoveHitSample(sampleName);
|
||||
break;
|
||||
|
||||
case TernaryState.True:
|
||||
AddHitSample(sampleName);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
SelectionSampleStates[sampleName] = bindable;
|
||||
}
|
||||
|
||||
// new combo
|
||||
SelectionNewComboState.ValueChanged += state =>
|
||||
{
|
||||
switch (state.NewValue)
|
||||
{
|
||||
case TernaryState.False:
|
||||
SetNewCombo(false);
|
||||
break;
|
||||
|
||||
case TernaryState.True:
|
||||
SetNewCombo(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// bring in updates from selection changes
|
||||
EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates);
|
||||
EditorBeatmap.SelectedHitObjects.CollectionChanged += (sender, args) =>
|
||||
{
|
||||
Scheduler.AddOnce(updateVisibility);
|
||||
Scheduler.AddOnce(UpdateTernaryStates);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated).
|
||||
/// </summary>
|
||||
protected virtual void UpdateTernaryStates()
|
||||
{
|
||||
SelectionNewComboState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType<IHasComboInformation>(), h => h.NewCombo);
|
||||
|
||||
foreach (var (sampleName, bindable) in SelectionSampleStates)
|
||||
{
|
||||
bindable.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects, h => h.Samples.Any(s => s.Name == sampleName));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given a selection target and a function of truth, retrieve the correct ternary state for display.
|
||||
/// </summary>
|
||||
protected TernaryState GetStateFromSelection<T>(IEnumerable<T> selection, Func<T, bool> func)
|
||||
{
|
||||
if (selection.Any(func))
|
||||
return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate;
|
||||
|
||||
return TernaryState.False;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Context Menu
|
||||
|
||||
public virtual MenuItem[] ContextMenuItems
|
||||
public MenuItem[] ContextMenuItems
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!selectedBlueprints.Any(b => b.IsHovered))
|
||||
return Array.Empty<MenuItem>();
|
||||
|
||||
var items = new List<MenuItem>
|
||||
var items = new List<MenuItem>();
|
||||
|
||||
items.AddRange(GetContextMenuItemsForSelection(selectedBlueprints));
|
||||
|
||||
if (selectedBlueprints.All(b => b.HitObject is IHasComboInformation))
|
||||
{
|
||||
new OsuMenuItem("Sound")
|
||||
{
|
||||
Items = new[]
|
||||
{
|
||||
createHitSampleMenuItem("Whistle", HitSampleInfo.HIT_WHISTLE),
|
||||
createHitSampleMenuItem("Clap", HitSampleInfo.HIT_CLAP),
|
||||
createHitSampleMenuItem("Finish", HitSampleInfo.HIT_FINISH)
|
||||
}
|
||||
},
|
||||
new OsuMenuItem("Delete", MenuItemType.Destructive, deleteSelected),
|
||||
};
|
||||
items.Add(new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } });
|
||||
}
|
||||
|
||||
if (selectedBlueprints.Count == 1)
|
||||
items.AddRange(selectedBlueprints[0].ContextMenuItems);
|
||||
|
||||
items.AddRange(new[]
|
||||
{
|
||||
new OsuMenuItem("Sound")
|
||||
{
|
||||
Items = SelectionSampleStates.Select(kvp =>
|
||||
new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
|
||||
},
|
||||
new OsuMenuItem("Delete", MenuItemType.Destructive, deleteSelected),
|
||||
});
|
||||
|
||||
return items.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private MenuItem createHitSampleMenuItem(string name, string sampleName)
|
||||
{
|
||||
return new TernaryStateMenuItem(name, MenuItemType.Standard, setHitSampleState)
|
||||
{
|
||||
State = { Value = getHitSampleState() }
|
||||
};
|
||||
|
||||
void setHitSampleState(TernaryState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case TernaryState.False:
|
||||
RemoveHitSample(sampleName);
|
||||
break;
|
||||
|
||||
case TernaryState.True:
|
||||
AddHitSample(sampleName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
TernaryState getHitSampleState()
|
||||
{
|
||||
int countExisting = SelectedHitObjects.Count(h => h.Samples.Any(s => s.Name == sampleName));
|
||||
|
||||
if (countExisting == 0)
|
||||
return TernaryState.False;
|
||||
|
||||
if (countExisting < SelectedHitObjects.Count())
|
||||
return TernaryState.Indeterminate;
|
||||
|
||||
return TernaryState.True;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Provide context menu items relevant to current selection. Calling base is not required.
|
||||
/// </summary>
|
||||
/// <param name="selection">The current selection.</param>
|
||||
/// <returns>The relevant menu items.</returns>
|
||||
protected virtual IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint> selection)
|
||||
=> Enumerable.Empty<MenuItem>();
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
@ -12,14 +12,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
public class CentreMarker : CompositeDrawable
|
||||
{
|
||||
private const float triangle_width = 20;
|
||||
private const float triangle_width = 15;
|
||||
private const float triangle_height = 10;
|
||||
private const float bar_width = 2;
|
||||
|
||||
public CentreMarker()
|
||||
{
|
||||
RelativeSizeAxes = Axes.Y;
|
||||
Size = new Vector2(20, 1);
|
||||
Size = new Vector2(triangle_width, 1);
|
||||
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
@ -39,6 +39,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
Origin = Anchor.BottomCentre,
|
||||
Size = new Vector2(triangle_width, triangle_height),
|
||||
Scale = new Vector2(1, -1)
|
||||
},
|
||||
new Triangle
|
||||
{
|
||||
Anchor = Anchor.BottomCentre,
|
||||
Origin = Anchor.BottomCentre,
|
||||
Size = new Vector2(triangle_width, triangle_height),
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -46,7 +52,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OsuColour colours)
|
||||
{
|
||||
Colour = colours.Red;
|
||||
Colour = colours.RedDark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
public class DifficultyPointPiece : CompositeDrawable
|
||||
{
|
||||
private readonly DifficultyControlPoint difficultyPoint;
|
||||
|
||||
private OsuSpriteText speedMultiplierText;
|
||||
private readonly BindableNumber<double> speedMultiplier;
|
||||
|
||||
public DifficultyPointPiece(DifficultyControlPoint difficultyPoint)
|
||||
{
|
||||
this.difficultyPoint = difficultyPoint;
|
||||
speedMultiplier = difficultyPoint.SpeedMultiplierBindable.GetBoundCopy();
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OsuColour colours)
|
||||
{
|
||||
RelativeSizeAxes = Axes.Y;
|
||||
AutoSizeAxes = Axes.X;
|
||||
|
||||
Color4 colour = difficultyPoint.GetRepresentingColour(colours);
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = colour,
|
||||
Width = 2,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
},
|
||||
new Container
|
||||
{
|
||||
AutoSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Colour = colour,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
speedMultiplierText = new OsuSpriteText
|
||||
{
|
||||
Font = OsuFont.Default.With(weight: FontWeight.Bold),
|
||||
Colour = Color4.White,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
speedMultiplier.BindValueChanged(multiplier => speedMultiplierText.Text = $"{multiplier.NewValue:n2}x", true);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.Color4Extensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Colour;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
public class SamplePointPiece : CompositeDrawable
|
||||
{
|
||||
private readonly SampleControlPoint samplePoint;
|
||||
|
||||
private readonly Bindable<string> bank;
|
||||
private readonly BindableNumber<int> volume;
|
||||
|
||||
private OsuSpriteText text;
|
||||
private Box volumeBox;
|
||||
|
||||
public SamplePointPiece(SampleControlPoint samplePoint)
|
||||
{
|
||||
this.samplePoint = samplePoint;
|
||||
volume = samplePoint.SampleVolumeBindable.GetBoundCopy();
|
||||
bank = samplePoint.SampleBankBindable.GetBoundCopy();
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OsuColour colours)
|
||||
{
|
||||
Origin = Anchor.TopLeft;
|
||||
Anchor = Anchor.TopLeft;
|
||||
|
||||
AutoSizeAxes = Axes.X;
|
||||
RelativeSizeAxes = Axes.Y;
|
||||
|
||||
Color4 colour = samplePoint.GetRepresentingColour(colours);
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
Width = 20,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
volumeBox = new Box
|
||||
{
|
||||
X = 2,
|
||||
Anchor = Anchor.BottomLeft,
|
||||
Origin = Anchor.BottomLeft,
|
||||
Colour = ColourInfo.GradientVertical(colour, Color4.Black),
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
new Box
|
||||
{
|
||||
Colour = colour.Lighten(0.2f),
|
||||
Width = 2,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
},
|
||||
}
|
||||
},
|
||||
text = new OsuSpriteText
|
||||
{
|
||||
X = 2,
|
||||
Y = -5,
|
||||
Anchor = Anchor.BottomLeft,
|
||||
Alpha = 0.9f,
|
||||
Rotation = -90,
|
||||
Font = OsuFont.Default.With(weight: FontWeight.SemiBold)
|
||||
}
|
||||
};
|
||||
|
||||
volume.BindValueChanged(volume => volumeBox.Height = volume.NewValue / 100f, true);
|
||||
bank.BindValueChanged(bank => text.Text = bank.NewValue, true);
|
||||
}
|
||||
}
|
||||
}
|
@ -8,65 +8,30 @@ using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.Color4Extensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Audio;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Framework.Timing;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
[Cached(typeof(IDistanceSnapProvider))]
|
||||
[Cached(typeof(IPositionSnapProvider))]
|
||||
[Cached]
|
||||
public class Timeline : ZoomableScrollContainer, IDistanceSnapProvider
|
||||
public class Timeline : ZoomableScrollContainer, IPositionSnapProvider
|
||||
{
|
||||
public readonly Bindable<bool> WaveformVisible = new Bindable<bool>();
|
||||
|
||||
public readonly Bindable<bool> ControlPointsVisible = new Bindable<bool>();
|
||||
|
||||
public readonly Bindable<bool> TicksVisible = new Bindable<bool>();
|
||||
|
||||
public readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>();
|
||||
|
||||
[Resolved]
|
||||
private IAdjustableClock adjustableClock { get; set; }
|
||||
|
||||
public Timeline()
|
||||
{
|
||||
ZoomDuration = 200;
|
||||
ZoomEasing = Easing.OutQuint;
|
||||
ScrollbarVisible = false;
|
||||
}
|
||||
|
||||
private WaveformGraph waveform;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours)
|
||||
{
|
||||
Add(waveform = new WaveformGraph
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Colour = colours.Blue.Opacity(0.2f),
|
||||
LowColour = colours.BlueLighter,
|
||||
MidColour = colours.BlueDark,
|
||||
HighColour = colours.BlueDarker,
|
||||
Depth = float.MaxValue
|
||||
});
|
||||
|
||||
// We don't want the centre marker to scroll
|
||||
AddInternal(new CentreMarker());
|
||||
|
||||
WaveformVisible.ValueChanged += visible => waveform.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint);
|
||||
|
||||
Beatmap.BindTo(beatmap);
|
||||
Beatmap.BindValueChanged(b =>
|
||||
{
|
||||
waveform.Waveform = b.NewValue.Waveform;
|
||||
track = b.NewValue.Track;
|
||||
|
||||
MinZoom = getZoomLevelForVisibleMilliseconds(10000);
|
||||
MaxZoom = getZoomLevelForVisibleMilliseconds(500);
|
||||
Zoom = getZoomLevelForVisibleMilliseconds(2000);
|
||||
}, true);
|
||||
}
|
||||
|
||||
private float getZoomLevelForVisibleMilliseconds(double milliseconds) => (float)(track.Length / milliseconds);
|
||||
private EditorClock editorClock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The timeline's scroll position in the last frame.
|
||||
@ -90,6 +55,77 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
private Track track;
|
||||
|
||||
public Timeline()
|
||||
{
|
||||
ZoomDuration = 200;
|
||||
ZoomEasing = Easing.OutQuint;
|
||||
ScrollbarVisible = false;
|
||||
}
|
||||
|
||||
private WaveformGraph waveform;
|
||||
|
||||
private TimelineTickDisplay ticks;
|
||||
|
||||
private TimelineControlPointDisplay controlPoints;
|
||||
|
||||
private Bindable<float> waveformOpacity;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours, OsuConfigManager config)
|
||||
{
|
||||
AddRange(new Drawable[]
|
||||
{
|
||||
new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Depth = float.MaxValue,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
waveform = new WaveformGraph
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
BaseColour = colours.Blue.Opacity(0.2f),
|
||||
LowColour = colours.BlueLighter,
|
||||
MidColour = colours.BlueDark,
|
||||
HighColour = colours.BlueDarker,
|
||||
},
|
||||
ticks = new TimelineTickDisplay(),
|
||||
controlPoints = new TimelineControlPointDisplay(),
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// We don't want the centre marker to scroll
|
||||
AddInternal(new CentreMarker { Depth = float.MaxValue });
|
||||
|
||||
waveformOpacity = config.GetBindable<float>(OsuSetting.EditorWaveformOpacity);
|
||||
waveformOpacity.BindValueChanged(_ => updateWaveformOpacity(), true);
|
||||
|
||||
WaveformVisible.ValueChanged += _ => updateWaveformOpacity();
|
||||
ControlPointsVisible.ValueChanged += visible => controlPoints.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint);
|
||||
TicksVisible.ValueChanged += visible => ticks.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint);
|
||||
|
||||
Beatmap.BindTo(beatmap);
|
||||
Beatmap.BindValueChanged(b =>
|
||||
{
|
||||
waveform.Waveform = b.NewValue.Waveform;
|
||||
track = b.NewValue.Track;
|
||||
|
||||
// todo: i don't think this is safe, the track may not be loaded yet.
|
||||
if (track.Length > 0)
|
||||
{
|
||||
MaxZoom = getZoomLevelForVisibleMilliseconds(500);
|
||||
MinZoom = getZoomLevelForVisibleMilliseconds(10000);
|
||||
Zoom = getZoomLevelForVisibleMilliseconds(2000);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
private void updateWaveformOpacity() =>
|
||||
waveform.FadeTo(WaveformVisible.Value ? waveformOpacity.Value : 0, 200, Easing.OutQuint);
|
||||
|
||||
private float getZoomLevelForVisibleMilliseconds(double milliseconds) => Math.Max(1, (float)(track.Length / milliseconds));
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
@ -98,31 +134,42 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
Content.Margin = new MarginPadding { Horizontal = DrawWidth / 2 };
|
||||
|
||||
// This needs to happen after transforms are updated, but before the scroll position is updated in base.UpdateAfterChildren
|
||||
if (adjustableClock.IsRunning)
|
||||
if (editorClock.IsRunning)
|
||||
scrollToTrackTime();
|
||||
}
|
||||
|
||||
protected override bool OnScroll(ScrollEvent e)
|
||||
{
|
||||
// if this is not a precision scroll event, let the editor handle the seek itself (for snapping support)
|
||||
if (!e.AltPressed && !e.IsPrecise)
|
||||
return false;
|
||||
|
||||
return base.OnScroll(e);
|
||||
}
|
||||
|
||||
protected override void UpdateAfterChildren()
|
||||
{
|
||||
base.UpdateAfterChildren();
|
||||
|
||||
if (handlingDragInput)
|
||||
seekTrackToCurrent();
|
||||
else if (!adjustableClock.IsRunning)
|
||||
else if (!editorClock.IsRunning)
|
||||
{
|
||||
// The track isn't running. There are two cases we have to be wary of:
|
||||
// 1) The user flick-drags on this timeline: We want the track to follow us
|
||||
// 2) The user changes the track time through some other means (scrolling in the editor or overview timeline): We want to follow the track time
|
||||
// The track isn't running. There are three cases we have to be wary of:
|
||||
// 1) The user flick-drags on this timeline and we are applying an interpolated seek on the clock, until interrupted by 2 or 3.
|
||||
// 2) The user changes the track time through some other means (scrolling in the editor or overview timeline; clicking a hitobject etc.). We want the timeline to track the clock's time.
|
||||
// 3) An ongoing seek transform is running from an external seek. We want the timeline to track the clock's time.
|
||||
|
||||
// The simplest way to cover both cases is by checking whether the scroll position has changed and the audio hasn't been changed externally
|
||||
if (Current != lastScrollPosition && adjustableClock.CurrentTime == lastTrackTime)
|
||||
// The simplest way to cover the first two cases is by checking whether the scroll position has changed and the audio hasn't been changed externally
|
||||
// Checking IsSeeking covers the third case, where the transform may not have been applied yet.
|
||||
if (Current != lastScrollPosition && editorClock.CurrentTime == lastTrackTime && !editorClock.IsSeeking)
|
||||
seekTrackToCurrent();
|
||||
else
|
||||
scrollToTrackTime();
|
||||
}
|
||||
|
||||
lastScrollPosition = Current;
|
||||
lastTrackTime = adjustableClock.CurrentTime;
|
||||
lastTrackTime = editorClock.CurrentTime;
|
||||
}
|
||||
|
||||
private void seekTrackToCurrent()
|
||||
@ -131,16 +178,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
return;
|
||||
|
||||
double target = Current / Content.DrawWidth * track.Length;
|
||||
|
||||
adjustableClock.Seek(Math.Min(track.Length, target));
|
||||
editorClock.Seek(Math.Min(track.Length, target));
|
||||
}
|
||||
|
||||
private void scrollToTrackTime()
|
||||
{
|
||||
if (!track.IsLoaded)
|
||||
if (!track.IsLoaded || track.Length == 0)
|
||||
return;
|
||||
|
||||
ScrollTo((float)(adjustableClock.CurrentTime / track.Length) * Content.DrawWidth, false);
|
||||
// covers the case where the user starts playback after a drag is in progress.
|
||||
// we want to ensure the clock is always stopped during drags to avoid weird audio playback.
|
||||
if (handlingDragInput)
|
||||
editorClock.Stop();
|
||||
|
||||
ScrollTo((float)(editorClock.CurrentTime / track.Length) * Content.DrawWidth, false);
|
||||
}
|
||||
|
||||
protected override bool OnMouseDown(MouseDownEvent e)
|
||||
@ -163,15 +214,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
private void beginUserDrag()
|
||||
{
|
||||
handlingDragInput = true;
|
||||
trackWasPlaying = adjustableClock.IsRunning;
|
||||
adjustableClock.Stop();
|
||||
trackWasPlaying = editorClock.IsRunning;
|
||||
editorClock.Stop();
|
||||
}
|
||||
|
||||
private void endUserDrag()
|
||||
{
|
||||
handlingDragInput = false;
|
||||
if (trackWasPlaying)
|
||||
adjustableClock.Start();
|
||||
editorClock.Start();
|
||||
}
|
||||
|
||||
[Resolved]
|
||||
@ -180,11 +231,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
[Resolved]
|
||||
private IBeatSnapProvider beatSnapProvider { get; set; }
|
||||
|
||||
public double GetTimeFromScreenSpacePosition(Vector2 position)
|
||||
=> getTimeFromPosition(Content.ToLocalSpace(position));
|
||||
/// <summary>
|
||||
/// The total amount of time visible on the timeline.
|
||||
/// </summary>
|
||||
public double VisibleRange => track.Length / Zoom;
|
||||
|
||||
public (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time) =>
|
||||
(position, beatSnapProvider.SnapTime(getTimeFromPosition(position)));
|
||||
public SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) =>
|
||||
new SnapResult(screenSpacePosition, null);
|
||||
|
||||
public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) =>
|
||||
new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition))));
|
||||
|
||||
private double getTimeFromPosition(Vector2 localPosition) =>
|
||||
(localPosition.X / Content.DrawWidth) * track.Length;
|
||||
|
@ -14,9 +14,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
public class TimelineArea : Container
|
||||
{
|
||||
private readonly Timeline timeline = new Timeline { RelativeSizeAxes = Axes.Both };
|
||||
public readonly Timeline Timeline = new Timeline { RelativeSizeAxes = Axes.Both };
|
||||
|
||||
protected override Container<Drawable> Content => timeline;
|
||||
protected override Container<Drawable> Content => Timeline;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
@ -25,6 +25,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
CornerRadius = 5;
|
||||
|
||||
OsuCheckbox waveformCheckbox;
|
||||
OsuCheckbox controlPointsCheckbox;
|
||||
OsuCheckbox ticksCheckbox;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
@ -57,12 +59,26 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
Origin = Anchor.CentreLeft,
|
||||
AutoSizeAxes = Axes.Y,
|
||||
Width = 160,
|
||||
Padding = new MarginPadding { Horizontal = 15 },
|
||||
Padding = new MarginPadding { Horizontal = 10 },
|
||||
Direction = FillDirection.Vertical,
|
||||
Spacing = new Vector2(0, 4),
|
||||
Children = new[]
|
||||
{
|
||||
waveformCheckbox = new OsuCheckbox { LabelText = "Waveform" }
|
||||
waveformCheckbox = new OsuCheckbox
|
||||
{
|
||||
LabelText = "Waveform",
|
||||
Current = { Value = true },
|
||||
},
|
||||
controlPointsCheckbox = new OsuCheckbox
|
||||
{
|
||||
LabelText = "Control Points",
|
||||
Current = { Value = true },
|
||||
},
|
||||
ticksCheckbox = new OsuCheckbox
|
||||
{
|
||||
LabelText = "Ticks",
|
||||
Current = { Value = true },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -107,7 +123,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
}
|
||||
}
|
||||
},
|
||||
timeline
|
||||
Timeline
|
||||
},
|
||||
},
|
||||
ColumnDimensions = new[]
|
||||
@ -119,11 +135,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
}
|
||||
};
|
||||
|
||||
waveformCheckbox.Current.Value = true;
|
||||
|
||||
timeline.WaveformVisible.BindTo(waveformCheckbox.Current);
|
||||
Timeline.WaveformVisible.BindTo(waveformCheckbox.Current);
|
||||
Timeline.ControlPointsVisible.BindTo(controlPointsCheckbox.Current);
|
||||
Timeline.TicksVisible.BindTo(ticksCheckbox.Current);
|
||||
}
|
||||
|
||||
private void changeZoom(float change) => timeline.Zoom += change;
|
||||
private void changeZoom(float change) => Timeline.Zoom += change;
|
||||
}
|
||||
}
|
||||
|
@ -9,10 +9,11 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Primitives;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
@ -25,21 +26,25 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
[Resolved]
|
||||
private EditorBeatmap beatmap { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; }
|
||||
|
||||
private DragEvent lastDragEvent;
|
||||
|
||||
private Bindable<HitObject> placement;
|
||||
|
||||
private SelectionBlueprint placementBlueprint;
|
||||
|
||||
public TimelineBlueprintContainer()
|
||||
private readonly Box backgroundBox;
|
||||
|
||||
public TimelineBlueprintContainer(HitObjectComposer composer)
|
||||
: base(composer)
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
Height = 0.4f;
|
||||
Height = 0.6f;
|
||||
|
||||
AddInternal(new Box
|
||||
AddInternal(backgroundBox = new Box
|
||||
{
|
||||
Colour = Color4.Black,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
@ -78,6 +83,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
protected override Container<SelectionBlueprint> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both };
|
||||
|
||||
protected override bool OnHover(HoverEvent e)
|
||||
{
|
||||
backgroundBox.FadeColour(colours.BlueLighter, 120, Easing.OutQuint);
|
||||
return base.OnHover(e);
|
||||
}
|
||||
|
||||
protected override void OnHoverLost(HoverLostEvent e)
|
||||
{
|
||||
backgroundBox.FadeColour(Color4.Black, 600, Easing.OutQuint);
|
||||
base.OnHoverLost(e);
|
||||
}
|
||||
|
||||
protected override void OnDrag(DragEvent e)
|
||||
{
|
||||
handleScrollViaDrag(e);
|
||||
@ -97,6 +114,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
if (lastDragEvent != null)
|
||||
OnDrag(lastDragEvent);
|
||||
|
||||
if (Composer != null && timeline != null)
|
||||
{
|
||||
Composer.Playfield.PastLifetimeExtension = timeline.VisibleRange / 2;
|
||||
Composer.Playfield.FutureLifetimeExtension = timeline.VisibleRange / 2;
|
||||
}
|
||||
|
||||
base.Update();
|
||||
}
|
||||
|
||||
@ -137,8 +160,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
private class TimelineDragBox : DragBox
|
||||
{
|
||||
private Vector2 lastMouseDown;
|
||||
private float localMouseDown;
|
||||
// the following values hold the start and end X positions of the drag box in the timeline's local space,
|
||||
// but with zoom unapplied in order to be able to compensate for positional changes
|
||||
// while the timeline is being zoomed in/out.
|
||||
private float? selectionStart;
|
||||
private float selectionEnd;
|
||||
|
||||
[Resolved]
|
||||
private Timeline timeline { get; set; }
|
||||
|
||||
public TimelineDragBox(Action<RectangleF> performSelect)
|
||||
: base(performSelect)
|
||||
@ -153,21 +182,34 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
public override bool HandleDrag(MouseButtonEvent e)
|
||||
{
|
||||
// store the original position of the mouse down, as we may be scrolled during selection.
|
||||
if (lastMouseDown != e.ScreenSpaceMouseDownPosition)
|
||||
{
|
||||
lastMouseDown = e.ScreenSpaceMouseDownPosition;
|
||||
localMouseDown = e.MouseDownPosition.X;
|
||||
}
|
||||
selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom;
|
||||
|
||||
float selection1 = localMouseDown;
|
||||
float selection2 = e.MousePosition.X;
|
||||
// only calculate end when a transition is not in progress to avoid bouncing.
|
||||
if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom))
|
||||
selectionEnd = e.MousePosition.X / timeline.CurrentZoom;
|
||||
|
||||
Box.X = Math.Min(selection1, selection2);
|
||||
Box.Width = Math.Abs(selection1 - selection2);
|
||||
updateDragBoxPosition();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updateDragBoxPosition()
|
||||
{
|
||||
if (selectionStart == null)
|
||||
return;
|
||||
|
||||
float rescaledStart = selectionStart.Value * timeline.CurrentZoom;
|
||||
float rescaledEnd = selectionEnd * timeline.CurrentZoom;
|
||||
|
||||
Box.X = Math.Min(rescaledStart, rescaledEnd);
|
||||
Box.Width = Math.Abs(rescaledStart - rescaledEnd);
|
||||
|
||||
PerformSelection?.Invoke(Box.ScreenSpaceDrawQuad.AABBFloat);
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void Hide()
|
||||
{
|
||||
base.Hide();
|
||||
selectionStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,7 +219,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
public TimelineSelectionBlueprintContainer()
|
||||
{
|
||||
AddInternal(new TimelinePart<SelectionBlueprint>(Content = new Container<SelectionBlueprint> { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both });
|
||||
AddInternal(new TimelinePart<SelectionBlueprint>(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,57 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
/// <summary>
|
||||
/// The part of the timeline that displays the control points.
|
||||
/// </summary>
|
||||
public class TimelineControlPointDisplay : TimelinePart<TimelineControlPointGroup>
|
||||
{
|
||||
private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>();
|
||||
|
||||
public TimelineControlPointDisplay()
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
}
|
||||
|
||||
protected override void LoadBeatmap(EditorBeatmap beatmap)
|
||||
{
|
||||
base.LoadBeatmap(beatmap);
|
||||
|
||||
controlPointGroups.UnbindAll();
|
||||
controlPointGroups.BindTo(beatmap.ControlPointInfo.Groups);
|
||||
controlPointGroups.BindCollectionChanged((sender, args) =>
|
||||
{
|
||||
switch (args.Action)
|
||||
{
|
||||
case NotifyCollectionChangedAction.Reset:
|
||||
Clear();
|
||||
break;
|
||||
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
foreach (var group in args.NewItems.OfType<ControlPointGroup>())
|
||||
Add(new TimelineControlPointGroup(group));
|
||||
break;
|
||||
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
foreach (var group in args.OldItems.OfType<ControlPointGroup>())
|
||||
{
|
||||
var matching = Children.SingleOrDefault(gv => gv.Group == group);
|
||||
|
||||
matching?.Expire();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
// 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.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
public class TimelineControlPointGroup : CompositeDrawable
|
||||
{
|
||||
public readonly ControlPointGroup Group;
|
||||
|
||||
private readonly IBindableList<ControlPoint> controlPoints = new BindableList<ControlPoint>();
|
||||
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; }
|
||||
|
||||
public TimelineControlPointGroup(ControlPointGroup group)
|
||||
{
|
||||
Group = group;
|
||||
|
||||
RelativePositionAxes = Axes.X;
|
||||
RelativeSizeAxes = Axes.Y;
|
||||
AutoSizeAxes = Axes.X;
|
||||
|
||||
X = (float)group.Time;
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
controlPoints.BindTo(Group.ControlPoints);
|
||||
controlPoints.BindCollectionChanged((_, __) =>
|
||||
{
|
||||
ClearInternal();
|
||||
|
||||
foreach (var point in controlPoints)
|
||||
{
|
||||
switch (point)
|
||||
{
|
||||
case DifficultyControlPoint difficultyPoint:
|
||||
AddInternal(new DifficultyPointPiece(difficultyPoint) { Depth = -2 });
|
||||
break;
|
||||
|
||||
case TimingControlPoint timingPoint:
|
||||
AddInternal(new TimingPointPiece(timingPoint));
|
||||
break;
|
||||
|
||||
case SampleControlPoint samplePoint:
|
||||
AddInternal(new SamplePointPiece(samplePoint) { Depth = -1 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
}
|
@ -7,15 +7,20 @@ using JetBrains.Annotations;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Colour;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Effects;
|
||||
using osu.Framework.Graphics.Primitives;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
@ -23,22 +28,26 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
public class TimelineHitObjectBlueprint : SelectionBlueprint
|
||||
{
|
||||
private readonly Circle circle;
|
||||
private const float thickness = 5;
|
||||
private const float shadow_radius = 5;
|
||||
private const float circle_size = 34;
|
||||
|
||||
public Action<DragEvent> OnDragHandled;
|
||||
|
||||
[UsedImplicitly]
|
||||
private readonly Bindable<double> startTime;
|
||||
|
||||
public Action<DragEvent> OnDragHandled;
|
||||
private Bindable<int> indexInCurrentComboBindable;
|
||||
private Bindable<int> comboIndexBindable;
|
||||
|
||||
private readonly Circle circle;
|
||||
private readonly DragBar dragBar;
|
||||
|
||||
private readonly List<Container> shadowComponents = new List<Container>();
|
||||
private readonly Container mainComponents;
|
||||
private readonly OsuSpriteText comboIndexText;
|
||||
|
||||
private const float thickness = 5;
|
||||
|
||||
private const float shadow_radius = 5;
|
||||
|
||||
private const float circle_size = 16;
|
||||
[Resolved]
|
||||
private ISkinSource skin { get; set; }
|
||||
|
||||
public TimelineHitObjectBlueprint(HitObject hitObject)
|
||||
: base(hitObject)
|
||||
@ -54,14 +63,28 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
RelativeSizeAxes = Axes.X;
|
||||
AutoSizeAxes = Axes.Y;
|
||||
|
||||
AddRangeInternal(new Drawable[]
|
||||
{
|
||||
mainComponents = new Container
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
RelativeSizeAxes = Axes.X,
|
||||
AutoSizeAxes = Axes.Y,
|
||||
},
|
||||
comboIndexText = new OsuSpriteText
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.Centre,
|
||||
Font = OsuFont.Numeric.With(size: circle_size / 2, weight: FontWeight.Black),
|
||||
},
|
||||
});
|
||||
|
||||
circle = new Circle
|
||||
{
|
||||
Size = new Vector2(circle_size),
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.Centre,
|
||||
RelativePositionAxes = Axes.X,
|
||||
AlwaysPresent = true,
|
||||
Colour = Color4.White,
|
||||
EdgeEffect = new EdgeEffectParameters
|
||||
{
|
||||
Type = EdgeEffectType.Shadow,
|
||||
@ -72,12 +95,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
shadowComponents.Add(circle);
|
||||
|
||||
if (hitObject is IHasEndTime)
|
||||
if (hitObject is IHasDuration)
|
||||
{
|
||||
DragBar dragBarUnderlay;
|
||||
Container extensionBar;
|
||||
|
||||
AddRangeInternal(new Drawable[]
|
||||
mainComponents.AddRange(new Drawable[]
|
||||
{
|
||||
extensionBar = new Container
|
||||
{
|
||||
@ -117,18 +140,89 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
}
|
||||
else
|
||||
{
|
||||
AddInternal(circle);
|
||||
mainComponents.Add(circle);
|
||||
}
|
||||
|
||||
updateShadows();
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
if (HitObject is IHasComboInformation comboInfo)
|
||||
{
|
||||
indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy();
|
||||
indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true);
|
||||
|
||||
comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy();
|
||||
comboIndexBindable.BindValueChanged(_ => updateComboColour(), true);
|
||||
|
||||
skin.SourceChanged += updateComboColour;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString();
|
||||
|
||||
private void updateComboColour()
|
||||
{
|
||||
if (!(HitObject is IHasComboInformation combo))
|
||||
return;
|
||||
|
||||
var comboColours = skin.GetConfig<GlobalSkinColours, IReadOnlyList<Color4>>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty<Color4>();
|
||||
var comboColour = combo.GetComboColour(comboColours);
|
||||
|
||||
if (HitObject is IHasDuration)
|
||||
mainComponents.Colour = ColourInfo.GradientHorizontal(comboColour, Color4.White);
|
||||
else
|
||||
mainComponents.Colour = comboColour;
|
||||
|
||||
var col = mainComponents.Colour.TopLeft.Linear;
|
||||
float brightness = col.R + col.G + col.B;
|
||||
|
||||
// decide the combo index colour based on brightness?
|
||||
comboIndexText.Colour = brightness > 0.5f ? Color4.Black : Color4.White;
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
// no bindable so we perform this every update
|
||||
Width = (float)(HitObject.GetEndTime() - HitObject.StartTime);
|
||||
float duration = (float)(HitObject.GetEndTime() - HitObject.StartTime);
|
||||
|
||||
if (Width != duration)
|
||||
{
|
||||
Width = duration;
|
||||
|
||||
// kind of haphazard but yeah, no bindables.
|
||||
if (HitObject is IHasRepeats repeats)
|
||||
updateRepeats(repeats);
|
||||
}
|
||||
}
|
||||
|
||||
private Container repeatsContainer;
|
||||
|
||||
private void updateRepeats(IHasRepeats repeats)
|
||||
{
|
||||
repeatsContainer?.Expire();
|
||||
|
||||
mainComponents.Add(repeatsContainer = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
});
|
||||
|
||||
for (int i = 0; i < repeats.RepeatCount; i++)
|
||||
{
|
||||
repeatsContainer.Add(new Circle
|
||||
{
|
||||
Size = new Vector2(circle_size / 2),
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.Centre,
|
||||
RelativePositionAxes = Axes.X,
|
||||
X = (float)(i + 1) / (repeats.RepeatCount + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool ShouldBeConsideredForInput(Drawable child) => true;
|
||||
@ -186,7 +280,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
}
|
||||
}
|
||||
|
||||
public override Vector2 SelectionPoint => ScreenSpaceDrawQuad.TopLeft;
|
||||
public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft;
|
||||
|
||||
public class DragBar : Container
|
||||
{
|
||||
@ -254,46 +348,54 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
Colour = IsHovered || hasMouseDown ? Color4.OrangeRed : Color4.White;
|
||||
}
|
||||
|
||||
protected override bool OnDragStart(DragStartEvent e) => true;
|
||||
|
||||
[Resolved]
|
||||
private EditorBeatmap beatmap { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private IBeatSnapProvider beatSnapProvider { get; set; }
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private IEditorChangeHandler changeHandler { get; set; }
|
||||
|
||||
protected override bool OnDragStart(DragStartEvent e)
|
||||
{
|
||||
changeHandler?.BeginChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override void OnDrag(DragEvent e)
|
||||
{
|
||||
base.OnDrag(e);
|
||||
|
||||
OnDragHandled?.Invoke(e);
|
||||
|
||||
var time = timeline.GetTimeFromScreenSpacePosition(e.ScreenSpaceMousePosition);
|
||||
|
||||
switch (hitObject)
|
||||
if (timeline.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition).Time is double time)
|
||||
{
|
||||
case IHasRepeats repeatHitObject:
|
||||
// find the number of repeats which can fit in the requested time.
|
||||
var lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1);
|
||||
var proposedCount = Math.Max(0, (int)((time - hitObject.StartTime) / lengthOfOneRepeat) - 1);
|
||||
switch (hitObject)
|
||||
{
|
||||
case IHasRepeats repeatHitObject:
|
||||
// find the number of repeats which can fit in the requested time.
|
||||
var lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1);
|
||||
var proposedCount = Math.Max(0, (int)Math.Round((time - hitObject.StartTime) / lengthOfOneRepeat) - 1);
|
||||
|
||||
if (proposedCount == repeatHitObject.RepeatCount)
|
||||
return;
|
||||
if (proposedCount == repeatHitObject.RepeatCount)
|
||||
return;
|
||||
|
||||
repeatHitObject.RepeatCount = proposedCount;
|
||||
break;
|
||||
repeatHitObject.RepeatCount = proposedCount;
|
||||
beatmap.Update(hitObject);
|
||||
break;
|
||||
|
||||
case IHasEndTime endTimeHitObject:
|
||||
var snappedTime = Math.Max(hitObject.StartTime, beatSnapProvider.SnapTime(time));
|
||||
case IHasDuration endTimeHitObject:
|
||||
var snappedTime = Math.Max(hitObject.StartTime, beatSnapProvider.SnapTime(time));
|
||||
|
||||
if (endTimeHitObject.EndTime == snappedTime)
|
||||
return;
|
||||
if (endTimeHitObject.EndTime == snappedTime || Precision.AlmostEquals(snappedTime, hitObject.StartTime, beatmap.GetBeatLengthAtTime(snappedTime)))
|
||||
return;
|
||||
|
||||
endTimeHitObject.EndTime = snappedTime;
|
||||
break;
|
||||
endTimeHitObject.Duration = snappedTime - hitObject.StartTime;
|
||||
beatmap.Update(hitObject);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
beatmap.UpdateHitObject(hitObject);
|
||||
}
|
||||
|
||||
protected override void OnDragEnd(DragEndEvent e)
|
||||
@ -301,6 +403,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
base.OnDragEnd(e);
|
||||
|
||||
OnDragHandled?.Invoke(null);
|
||||
changeHandler?.EndChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Caching;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Graphics;
|
||||
@ -12,7 +14,7 @@ using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
public class TimelineTickDisplay : TimelinePart
|
||||
public class TimelineTickDisplay : TimelinePart<PointVisualisation>
|
||||
{
|
||||
[Resolved]
|
||||
private EditorBeatmap beatmap { get; set; }
|
||||
@ -23,6 +25,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
[Resolved]
|
||||
private BindableBeatDivisor beatDivisor { get; set; }
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private IEditorChangeHandler changeHandler { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; }
|
||||
|
||||
@ -31,15 +36,72 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
}
|
||||
|
||||
private readonly Cached tickCache = new Cached();
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
beatDivisor.BindValueChanged(_ => createLines(), true);
|
||||
beatDivisor.BindValueChanged(_ => invalidateTicks());
|
||||
|
||||
if (changeHandler != null)
|
||||
// currently this is the best way to handle any kind of timing changes.
|
||||
changeHandler.OnStateChange += invalidateTicks;
|
||||
}
|
||||
|
||||
private void createLines()
|
||||
private void invalidateTicks()
|
||||
{
|
||||
Clear();
|
||||
tickCache.Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The visible time/position range of the timeline.
|
||||
/// </summary>
|
||||
private (float min, float max) visibleRange = (float.MinValue, float.MaxValue);
|
||||
|
||||
/// <summary>
|
||||
/// The next time/position value to the left of the display when tick regeneration needs to be run.
|
||||
/// </summary>
|
||||
private float? nextMinTick;
|
||||
|
||||
/// <summary>
|
||||
/// The next time/position value to the right of the display when tick regeneration needs to be run.
|
||||
/// </summary>
|
||||
private float? nextMaxTick;
|
||||
|
||||
[Resolved(canBeNull: true)]
|
||||
private Timeline timeline { get; set; }
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
if (timeline != null)
|
||||
{
|
||||
var newRange = (
|
||||
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X,
|
||||
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X);
|
||||
|
||||
if (visibleRange != newRange)
|
||||
{
|
||||
visibleRange = newRange;
|
||||
|
||||
// actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries.
|
||||
if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick))
|
||||
tickCache.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
if (!tickCache.IsValid)
|
||||
createTicks();
|
||||
}
|
||||
|
||||
private void createTicks()
|
||||
{
|
||||
int drawableIndex = 0;
|
||||
int highestDivisor = BindableBeatDivisor.VALID_DIVISORS.Last();
|
||||
|
||||
nextMinTick = null;
|
||||
nextMaxTick = null;
|
||||
|
||||
for (var i = 0; i < beatmap.ControlPointInfo.TimingPoints.Count; i++)
|
||||
{
|
||||
@ -50,41 +112,78 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
for (double t = point.Time; t < until; t += point.BeatLength / beatDivisor.Value)
|
||||
{
|
||||
var indexInBeat = beat % beatDivisor.Value;
|
||||
float xPos = (float)t;
|
||||
|
||||
if (indexInBeat == 0)
|
||||
{
|
||||
Add(new PointVisualisation(t)
|
||||
{
|
||||
Colour = BindableBeatDivisor.GetColourFor(1, colours),
|
||||
Origin = Anchor.TopCentre,
|
||||
});
|
||||
}
|
||||
if (t < visibleRange.min)
|
||||
nextMinTick = xPos;
|
||||
else if (t > visibleRange.max)
|
||||
nextMaxTick ??= xPos;
|
||||
else
|
||||
{
|
||||
// if this is the first beat in the beatmap, there is no next min tick
|
||||
if (beat == 0 && i == 0)
|
||||
nextMinTick = float.MinValue;
|
||||
|
||||
var indexInBar = beat % ((int)point.TimeSignature * beatDivisor.Value);
|
||||
|
||||
var divisor = BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value);
|
||||
var colour = BindableBeatDivisor.GetColourFor(divisor, colours);
|
||||
var height = 0.1f - (float)divisor / BindableBeatDivisor.VALID_DIVISORS.Last() * 0.08f;
|
||||
|
||||
Add(new PointVisualisation(t)
|
||||
{
|
||||
Colour = colour,
|
||||
Height = height,
|
||||
Origin = Anchor.TopCentre,
|
||||
});
|
||||
// even though "bar lines" take up the full vertical space, we render them in two pieces because it allows for less anchor/origin churn.
|
||||
var height = indexInBar == 0 ? 0.5f : 0.1f - (float)divisor / highestDivisor * 0.08f;
|
||||
|
||||
Add(new PointVisualisation(t)
|
||||
{
|
||||
Colour = colour,
|
||||
Anchor = Anchor.BottomLeft,
|
||||
Origin = Anchor.BottomCentre,
|
||||
Height = height,
|
||||
});
|
||||
var topPoint = getNextUsablePoint();
|
||||
topPoint.X = xPos;
|
||||
topPoint.Colour = colour;
|
||||
topPoint.Height = height;
|
||||
topPoint.Anchor = Anchor.TopLeft;
|
||||
topPoint.Origin = Anchor.TopCentre;
|
||||
|
||||
var bottomPoint = getNextUsablePoint();
|
||||
bottomPoint.X = xPos;
|
||||
bottomPoint.Colour = colour;
|
||||
bottomPoint.Anchor = Anchor.BottomLeft;
|
||||
bottomPoint.Origin = Anchor.BottomCentre;
|
||||
bottomPoint.Height = height;
|
||||
}
|
||||
|
||||
beat++;
|
||||
}
|
||||
}
|
||||
|
||||
int usedDrawables = drawableIndex;
|
||||
|
||||
// save a few drawables beyond the currently used for edge cases.
|
||||
while (drawableIndex < Math.Min(usedDrawables + 16, Count))
|
||||
Children[drawableIndex++].Hide();
|
||||
|
||||
// expire any excess
|
||||
while (drawableIndex < Count)
|
||||
Children[drawableIndex++].Expire();
|
||||
|
||||
tickCache.Validate();
|
||||
|
||||
Drawable getNextUsablePoint()
|
||||
{
|
||||
PointVisualisation point;
|
||||
if (drawableIndex >= Count)
|
||||
Add(point = new PointVisualisation());
|
||||
else
|
||||
point = Children[drawableIndex];
|
||||
|
||||
drawableIndex++;
|
||||
point.Show();
|
||||
|
||||
return point;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
|
||||
if (changeHandler != null)
|
||||
changeHandler.OnStateChange -= invalidateTicks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,63 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.Color4Extensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Colour;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
{
|
||||
public class TimingPointPiece : CompositeDrawable
|
||||
{
|
||||
private readonly TimingControlPoint point;
|
||||
|
||||
private readonly BindableNumber<double> beatLength;
|
||||
private OsuSpriteText bpmText;
|
||||
|
||||
public TimingPointPiece(TimingControlPoint point)
|
||||
{
|
||||
this.point = point;
|
||||
beatLength = point.BeatLengthBindable.GetBoundCopy();
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(OsuColour colours)
|
||||
{
|
||||
Origin = Anchor.CentreLeft;
|
||||
Anchor = Anchor.CentreLeft;
|
||||
|
||||
AutoSizeAxes = Axes.Both;
|
||||
|
||||
Color4 colour = point.GetRepresentingColour(colours);
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Box
|
||||
{
|
||||
Alpha = 0.9f,
|
||||
Colour = ColourInfo.GradientHorizontal(colour, colour.Opacity(0.5f)),
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
},
|
||||
bpmText = new OsuSpriteText
|
||||
{
|
||||
Alpha = 0.9f,
|
||||
Padding = new MarginPadding(3),
|
||||
Font = OsuFont.Default.With(size: 40)
|
||||
}
|
||||
};
|
||||
|
||||
beatLength.BindValueChanged(beatLength =>
|
||||
{
|
||||
bpmText.Text = $"{60000 / beatLength.NewValue:n1} BPM";
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
}
|
@ -29,9 +29,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
private readonly Container zoomedContent;
|
||||
protected override Container<Drawable> Content => zoomedContent;
|
||||
|
||||
private float currentZoom = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The current zoom level of <see cref="ZoomableScrollContainer" />.
|
||||
/// It may differ from <see cref="Zoom" /> during transitions.
|
||||
/// </summary>
|
||||
public float CurrentZoom => currentZoom;
|
||||
|
||||
[Resolved(canBeNull: true)]
|
||||
private IFrameBasedClock editorClock { get; set; }
|
||||
|
||||
@ -108,19 +113,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
|
||||
|
||||
protected override bool OnScroll(ScrollEvent e)
|
||||
{
|
||||
if (e.IsPrecise)
|
||||
if (e.AltPressed)
|
||||
{
|
||||
// can't handle scroll correctly while playing.
|
||||
// the editor will handle this case for us.
|
||||
if (editorClock?.IsRunning == true)
|
||||
return false;
|
||||
|
||||
// for now, we don't support zoom when using a precision scroll device. this needs gesture support.
|
||||
return base.OnScroll(e);
|
||||
// zoom when holding alt.
|
||||
setZoomTarget(zoomTarget + e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X);
|
||||
return true;
|
||||
}
|
||||
|
||||
setZoomTarget(zoomTarget + e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X);
|
||||
return true;
|
||||
// can't handle scroll correctly while playing.
|
||||
// the editor will handle this case for us.
|
||||
if (editorClock?.IsRunning == true)
|
||||
return false;
|
||||
|
||||
return base.OnScroll(e);
|
||||
}
|
||||
|
||||
private void updateZoomedContentWidth() => zoomedContent.Width = DrawWidth * currentZoom;
|
||||
|
@ -1,8 +1,13 @@
|
||||
// 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.Diagnostics;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Screens.Edit.Compose.Components.Timeline;
|
||||
using osu.Game.Skinning;
|
||||
@ -11,27 +16,61 @@ namespace osu.Game.Screens.Edit.Compose
|
||||
{
|
||||
public class ComposeScreen : EditorScreenWithTimeline
|
||||
{
|
||||
[Resolved]
|
||||
private IBindable<WorkingBeatmap> beatmap { get; set; }
|
||||
|
||||
private HitObjectComposer composer;
|
||||
|
||||
public ComposeScreen()
|
||||
: base(EditorScreenMode.Compose)
|
||||
{
|
||||
}
|
||||
|
||||
private Ruleset ruleset;
|
||||
|
||||
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
|
||||
{
|
||||
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
|
||||
|
||||
ruleset = parent.Get<IBindable<WorkingBeatmap>>().Value.BeatmapInfo.Ruleset?.CreateInstance();
|
||||
composer = ruleset?.CreateHitObjectComposer();
|
||||
|
||||
// make the composer available to the timeline and other components in this screen.
|
||||
if (composer != null)
|
||||
dependencies.CacheAs(composer);
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
protected override Drawable CreateMainContent()
|
||||
{
|
||||
var ruleset = Beatmap.Value.BeatmapInfo.Ruleset?.CreateInstance();
|
||||
composer = ruleset?.CreateHitObjectComposer();
|
||||
|
||||
if (ruleset == null || composer == null)
|
||||
return new ScreenWhiteBox.UnderConstructionMessage(ruleset == null ? "This beatmap" : $"{ruleset.Description}'s composer");
|
||||
|
||||
var beatmapSkinProvider = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin);
|
||||
return wrapSkinnableContent(composer);
|
||||
}
|
||||
|
||||
protected override Drawable CreateTimelineContent()
|
||||
{
|
||||
if (ruleset == null || composer == null)
|
||||
return base.CreateTimelineContent();
|
||||
|
||||
return wrapSkinnableContent(new TimelineBlueprintContainer(composer));
|
||||
}
|
||||
|
||||
private Drawable wrapSkinnableContent(Drawable content)
|
||||
{
|
||||
Debug.Assert(ruleset != null);
|
||||
|
||||
var beatmapSkinProvider = new BeatmapSkinProvidingContainer(beatmap.Value.Skin);
|
||||
|
||||
// the beatmapSkinProvider is used as the fallback source here to allow the ruleset-specific skin implementation
|
||||
// full access to all skin sources.
|
||||
var rulesetSkinProvider = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider));
|
||||
var rulesetSkinProvider = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider, EditorBeatmap.PlayableBeatmap));
|
||||
|
||||
// load the skinning hierarchy first.
|
||||
// this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources.
|
||||
return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(composer));
|
||||
return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(content));
|
||||
}
|
||||
|
||||
protected override Drawable CreateTimelineContent() => composer == null ? base.CreateTimelineContent() : new TimelineBlueprintContainer();
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user