Merge branch 'master' into visible-playfield-boundary

This commit is contained in:
Dean Herbert
2020-10-19 18:05:28 +09:00
committed by GitHub
689 changed files with 17731 additions and 4426 deletions

View File

@ -0,0 +1,27 @@
// 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 System.Linq;
using Newtonsoft.Json;
using osu.Game.IO.Serialization;
using osu.Game.IO.Serialization.Converters;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit
{
public class ClipboardContent : IJsonSerializable
{
[JsonConverter(typeof(TypedListConverter<HitObject>))]
public IList<HitObject> HitObjects;
public ClipboardContent()
{
}
public ClipboardContent(EditorBeatmap editorBeatmap)
{
HitObjects = editorBeatmap.SelectedHitObjects.ToList();
}
}
}

View File

@ -18,7 +18,8 @@ namespace osu.Game.Screens.Edit.Components
private const float contents_padding = 15;
protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>();
protected Track Track => Beatmap.Value.Track;
protected readonly IBindable<Track> Track = new Bindable<Track>();
private readonly Drawable background;
private readonly Container content;
@ -42,9 +43,11 @@ namespace osu.Game.Screens.Edit.Components
}
[BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours)
private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours, EditorClock clock)
{
Beatmap.BindTo(beatmap);
Track.BindTo(clock.Track);
background.Colour = colours.Gray1;
}
}

View File

@ -32,8 +32,6 @@ namespace osu.Game.Screens.Edit.Components.Menus
Height = 1,
Colour = Color4.White.Opacity(0.2f),
});
Current.Value = EditorScreenMode.Compose;
}
[BackgroundDependencyLoader]

View File

@ -62,12 +62,12 @@ namespace osu.Game.Screens.Edit.Components
}
};
Track?.AddAdjustment(AdjustableProperty.Tempo, tempo);
Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Tempo, tempo), true);
}
protected override void Dispose(bool isDisposing)
{
Track?.RemoveAdjustment(AdjustableProperty.Tempo, tempo);
Track.Value?.RemoveAdjustment(AdjustableProperty.Tempo, tempo);
base.Dispose(isDisposing);
}

View File

@ -5,7 +5,6 @@ using System;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
@ -29,7 +28,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
private Color4 selectedBackgroundColour;
private Color4 selectedBubbleColour;
private readonly Drawable bubble;
private Drawable icon;
private readonly RadioButton button;
public DrawableRadioButton(RadioButton button)
@ -40,19 +39,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
Action = button.Select;
RelativeSizeAxes = Axes.X;
bubble = new CircularContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fit,
Scale = new Vector2(0.5f),
X = 10,
Masking = true,
Blending = BlendingParameters.Additive,
Child = new Box { RelativeSizeAxes = Axes.Both }
};
}
[BackgroundDependencyLoader]
@ -73,7 +59,14 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
Colour = Color4.Black.Opacity(0.5f)
};
Add(bubble);
Add(icon = (button.CreateIcon?.Invoke() ?? new Circle()).With(b =>
{
b.Blending = BlendingParameters.Additive;
b.Anchor = Anchor.CentreLeft;
b.Origin = Anchor.CentreLeft;
b.Size = new Vector2(20);
b.X = 10;
}));
}
protected override void LoadComplete()
@ -96,7 +89,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
return;
BackgroundColour = button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour;
bubble.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour;
icon.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour;
}
protected override SpriteText CreateText() => new OsuSpriteText

View File

@ -3,6 +3,7 @@
using System;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
namespace osu.Game.Screens.Edit.Components.RadioButtons
{
@ -19,11 +20,17 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
/// </summary>
public object Item;
/// <summary>
/// A function which creates a drawable icon to represent this item. If null, a sane default should be used.
/// </summary>
public readonly Func<Drawable> CreateIcon;
private readonly Action action;
public RadioButton(object item, Action action)
public RadioButton(object item, Action action, Func<Drawable> createIcon = null)
{
Item = item;
CreateIcon = createIcon;
this.action = action;
Selected = new BindableBool();
}

View File

@ -0,0 +1,112 @@
// 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.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Components.TernaryButtons
{
internal class DrawableTernaryButton : TriangleButton
{
private Color4 defaultBackgroundColour;
private Color4 defaultBubbleColour;
private Color4 selectedBackgroundColour;
private Color4 selectedBubbleColour;
private Drawable icon;
public readonly TernaryButton Button;
public DrawableTernaryButton(TernaryButton button)
{
Button = button;
Text = button.Description;
RelativeSizeAxes = Axes.X;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
defaultBackgroundColour = colours.Gray3;
defaultBubbleColour = defaultBackgroundColour.Darken(0.5f);
selectedBackgroundColour = colours.BlueDark;
selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f);
Triangles.Alpha = 0;
Content.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Radius = 2,
Offset = new Vector2(0, 1),
Colour = Color4.Black.Opacity(0.5f)
};
Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b =>
{
b.Blending = BlendingParameters.Additive;
b.Anchor = Anchor.CentreLeft;
b.Origin = Anchor.CentreLeft;
b.Size = new Vector2(20);
b.X = 10;
}));
}
protected override void LoadComplete()
{
base.LoadComplete();
Button.Bindable.BindValueChanged(selected => updateSelectionState(), true);
Action = onAction;
}
private void onAction()
{
Button.Toggle();
}
private void updateSelectionState()
{
if (!IsLoaded)
return;
switch (Button.Bindable.Value)
{
case TernaryState.Indeterminate:
icon.Colour = selectedBubbleColour.Darken(0.5f);
BackgroundColour = selectedBackgroundColour.Darken(0.5f);
break;
case TernaryState.False:
icon.Colour = defaultBubbleColour;
BackgroundColour = defaultBackgroundColour;
break;
case TernaryState.True:
icon.Colour = selectedBubbleColour;
BackgroundColour = selectedBackgroundColour;
break;
}
}
protected override SpriteText CreateText() => new OsuSpriteText
{
Depth = -1,
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
X = 40f
};
}
}

View File

@ -0,0 +1,44 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Screens.Edit.Components.TernaryButtons
{
public class TernaryButton
{
public readonly Bindable<TernaryState> Bindable;
public readonly string Description;
/// <summary>
/// A function which creates a drawable icon to represent this item. If null, a sane default should be used.
/// </summary>
public readonly Func<Drawable> CreateIcon;
public TernaryButton(Bindable<TernaryState> bindable, string description, Func<Drawable> createIcon = null)
{
Bindable = bindable;
Description = description;
CreateIcon = createIcon;
}
public void Toggle()
{
switch (Bindable.Value)
{
case TernaryState.False:
case TernaryState.Indeterminate:
Bindable.Value = TernaryState.True;
break;
case TernaryState.True:
Bindable.Value = TernaryState.False;
break;
}
}
}
}

View File

@ -3,8 +3,8 @@
using osu.Framework.Graphics;
using osu.Game.Graphics.Sprites;
using System;
using osu.Framework.Allocation;
using osu.Game.Extensions;
using osu.Game.Graphics;
namespace osu.Game.Screens.Edit.Components
@ -22,10 +22,12 @@ namespace osu.Game.Screens.Edit.Components
{
trackTimer = new OsuSpriteText
{
Origin = Anchor.BottomLeft,
RelativePositionAxes = Axes.Y,
Font = OsuFont.GetFont(size: 22, fixedWidth: true),
Y = 0.5f,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
// intentionally fudged centre to avoid movement of the number portion when
// going negative.
X = -35,
Font = OsuFont.GetFont(size: 25, fixedWidth: true),
}
};
}
@ -33,8 +35,7 @@ namespace osu.Game.Screens.Edit.Components
protected override void Update()
{
base.Update();
trackTimer.Text = TimeSpan.FromMilliseconds(editorClock.CurrentTime).ToString(@"mm\:ss\:fff");
trackTimer.Text = editorClock.CurrentTime.ToEditorFormattedString();
}
}
}

View File

@ -1,70 +1,50 @@
// 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.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations;
namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
{
/// <summary>
/// The part of the timeline that displays the control points.
/// </summary>
public class ControlPointPart : TimelinePart
public class ControlPointPart : TimelinePart<GroupVisualisation>
{
private IBindableList<ControlPointGroup> controlPointGroups;
protected override void LoadBeatmap(WorkingBeatmap beatmap)
{
base.LoadBeatmap(beatmap);
ControlPointInfo cpi = beatmap.Beatmap.ControlPointInfo;
cpi.TimingPoints.ForEach(addTimingPoint);
// Consider all non-timing points as the same type
cpi.SamplePoints.Select(c => (ControlPoint)c)
.Concat(cpi.EffectPoints)
.Concat(cpi.DifficultyPoints)
.Distinct()
// Non-timing points should not be added where there are timing points
.Where(c => cpi.TimingPointAt(c.Time).Time != c.Time)
.ForEach(addNonTimingPoint);
}
private void addTimingPoint(ControlPoint controlPoint) => Add(new TimingPointVisualisation(controlPoint));
private void addNonTimingPoint(ControlPoint controlPoint) => Add(new NonTimingPointVisualisation(controlPoint));
private class TimingPointVisualisation : ControlPointVisualisation
{
public TimingPointVisualisation(ControlPoint controlPoint)
: base(controlPoint)
controlPointGroups = beatmap.Beatmap.ControlPointInfo.Groups.GetBoundCopy();
controlPointGroups.BindCollectionChanged((sender, args) =>
{
}
switch (args.Action)
{
case NotifyCollectionChangedAction.Reset:
Clear();
break;
[BackgroundDependencyLoader]
private void load(OsuColour colours) => Colour = colours.YellowDark;
}
case NotifyCollectionChangedAction.Add:
foreach (var group in args.NewItems.OfType<ControlPointGroup>())
Add(new GroupVisualisation(group));
break;
private class NonTimingPointVisualisation : ControlPointVisualisation
{
public NonTimingPointVisualisation(ControlPoint controlPoint)
: base(controlPoint)
{
}
case NotifyCollectionChangedAction.Remove:
foreach (var group in args.OldItems.OfType<ControlPointGroup>())
{
var matching = Children.SingleOrDefault(gv => gv.Group == group);
[BackgroundDependencyLoader]
private void load(OsuColour colours) => Colour = colours.Green;
}
matching?.Expire();
}
private abstract class ControlPointVisualisation : PointVisualisation
{
protected ControlPointVisualisation(ControlPoint controlPoint)
: base(controlPoint.Time)
{
}
break;
}
}, true);
}
}
}

View File

@ -0,0 +1,46 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations;
using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
{
public class GroupVisualisation : PointVisualisation
{
public readonly ControlPointGroup Group;
private BindableList<ControlPoint> controlPoints;
[Resolved]
private OsuColour colours { get; set; }
public GroupVisualisation(ControlPointGroup group)
: base(group.Time)
{
Group = group;
}
protected override void LoadComplete()
{
base.LoadComplete();
controlPoints = (BindableList<ControlPoint>)Group.ControlPoints.GetBoundCopy();
controlPoints.BindCollectionChanged((_, __) =>
{
if (controlPoints.Count == 0)
{
Colour = Color4.Transparent;
return;
}
Colour = controlPoints.Any(c => c is TimingControlPoint) ? colours.YellowDark : colours.Green;
}, true);
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osuTK;
using osu.Framework.Graphics;
@ -22,6 +23,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
{
protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>();
protected readonly IBindable<Track> Track = new Bindable<Track>();
private readonly Container<T> content;
protected override Container<T> Content => content;
@ -35,12 +38,15 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
updateRelativeChildSize();
LoadBeatmap(b.NewValue);
};
Track.ValueChanged += _ => updateRelativeChildSize();
}
[BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap)
private void load(IBindable<WorkingBeatmap> beatmap, EditorClock clock)
{
Beatmap.BindTo(beatmap);
Track.BindTo(clock.Track);
}
private void updateRelativeChildSize()

View File

@ -1,9 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osuTK;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osuTK;
namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations
{
@ -12,16 +12,23 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations
/// </summary>
public class PointVisualisation : Box
{
public const float WIDTH = 1;
public PointVisualisation(double startTime)
: this()
{
X = (float)startTime;
}
public PointVisualisation()
{
Origin = Anchor.TopCentre;
RelativeSizeAxes = Axes.Y;
Width = 1;
EdgeSmoothness = new Vector2(1, 0);
RelativePositionAxes = Axes.X;
X = (float)startTime;
RelativeSizeAxes = Axes.Y;
Width = WIDTH;
EdgeSmoothness = new Vector2(WIDTH, 0);
}
}
}

View File

@ -24,15 +24,15 @@ 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>
{
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; }
[Resolved(CanBeNull = true)]
private IEditorChangeHandler changeHandler { get; set; }
@ -41,7 +41,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
private EditorClock editorClock { get; set; }
[Resolved]
private EditorBeatmap beatmap { get; set; }
protected EditorBeatmap Beatmap { get; private set; }
private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
@ -56,22 +56,22 @@ namespace osu.Game.Screens.Edit.Compose.Components
[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(),
SelectionHandler.CreateProxy(),
DragBox.CreateProxy().With(p => p.Depth = float.MinValue)
});
foreach (var obj in beatmap.HitObjects)
foreach (var obj in Beatmap.HitObjects)
AddBlueprintFor(obj);
selectedHitObjects.BindTo(beatmap.SelectedHitObjects);
selectedHitObjects.BindTo(Beatmap.SelectedHitObjects);
selectedHitObjects.CollectionChanged += (selectedObjects, args) =>
{
switch (args.Action)
@ -94,15 +94,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
base.LoadComplete();
beatmap.HitObjectAdded += AddBlueprintFor;
beatmap.HitObjectRemoved += removeBlueprintFor;
Beatmap.HitObjectAdded += AddBlueprintFor;
Beatmap.HitObjectRemoved += removeBlueprintFor;
}
protected virtual Container<SelectionBlueprint> CreateSelectionBlueprintContainer() =>
new Container<SelectionBlueprint> { 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();
@ -130,7 +130,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
return false;
// store for double-click handling
clickedBlueprint = selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered);
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
@ -147,7 +147,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
return false;
// 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)
if (clickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint)
return false;
editorClock?.SeekTo(clickedBlueprint.HitObject.StartTime);
@ -201,6 +201,10 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (isDraggingBlueprint)
{
// handle positional change etc.
foreach (var obj in selectedHitObjects)
Beatmap.Update(obj);
changeHandler?.EndChange();
isDraggingBlueprint = false;
}
@ -208,7 +212,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (DragBox.State == Visibility.Visible)
{
DragBox.Hide();
selectionHandler.UpdateVisibility();
SelectionHandler.UpdateVisibility();
}
}
@ -217,7 +221,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();
@ -271,6 +275,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
blueprint.Selected += onBlueprintSelected;
blueprint.Deselected += onBlueprintDeselected;
if (Beatmap.SelectedHitObjects.Contains(hitObject))
blueprint.Select();
SelectionBlueprints.Add(blueprint);
}
@ -295,14 +302,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
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))
if (!allowDeselection && SelectionHandler.SelectedBlueprints.Any(s => s.IsHovered))
return;
foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren)
{
if (blueprint.IsHovered)
{
selectionHandler.HandleSelectionRequested(blueprint, e.CurrentState);
SelectionHandler.HandleSelectionRequested(blueprint, e.CurrentState);
clickSelectionBegan = true;
break;
}
@ -326,7 +333,7 @@ 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)
{
@ -355,26 +362,24 @@ namespace osu.Game.Screens.Edit.Compose.Components
private void selectAll()
{
SelectionBlueprints.ToList().ForEach(m => m.Select());
selectionHandler.UpdateVisibility();
SelectionHandler.UpdateVisibility();
}
/// <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);
}
private void onBlueprintDeselected(SelectionBlueprint blueprint)
{
selectionHandler.HandleDeselected(blueprint);
SelectionHandler.HandleDeselected(blueprint);
SelectionBlueprints.ChangeChildDepth(blueprint, 0);
beatmap.SelectedHitObjects.Remove(blueprint.HitObject);
}
#endregion
@ -390,16 +395,16 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// </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();
movementBlueprint = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).First();
movementBlueprintOriginalPosition = movementBlueprint.ScreenSpaceSelectionPoint; // todo: unsure if correct
}
@ -424,15 +429,19 @@ namespace osu.Game.Screens.Edit.Compose.Components
var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition);
// Move the hitobjects.
if (!selectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, result.ScreenSpacePosition)))
if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, result.ScreenSpacePosition)))
return true;
if (result.Time.HasValue)
{
// Apply the start time at the newly snapped-to position
double offset = result.Time.Value - draggedObject.StartTime;
foreach (HitObject obj in selectionHandler.SelectedHitObjects)
foreach (HitObject obj in Beatmap.SelectedHitObjects)
{
obj.StartTime += offset;
Beatmap.Update(obj);
}
}
return true;
@ -459,10 +468,10 @@ 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;
}
}
}

View File

@ -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
@ -46,6 +53,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
[BackgroundDependencyLoader]
private void load()
{
TernaryStates = CreateTernaryButtons().ToArray();
AddInternal(placementBlueprintContainer);
}
@ -54,6 +63,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 { Name = 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
@ -86,7 +179,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
removePlacement();
if (currentPlacement != null)
{
updatePlacementPosition();
}
}
protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject hitObject)
@ -104,7 +199,12 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected override void AddBlueprintFor(HitObject 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()
@ -115,10 +215,17 @@ namespace osu.Game.Screens.Edit.Compose.Components
if (blueprint != null)
{
// doing this post-creations as adding the default hit sample should be the case regardless of the ruleset.
blueprint.HitObject.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_NORMAL });
placementBlueprintContainer.Child = currentPlacement = blueprint;
// Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame
updatePlacementPosition();
updatePlacementSamples();
updatePlacementNewCombo();
}
}

View File

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

View File

@ -0,0 +1,235 @@
// 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.Game.Graphics;
using osuTK;
namespace osu.Game.Screens.Edit.Compose.Components
{
public class SelectionBox : CompositeDrawable
{
public Action<float> OnRotation;
public Action<Vector2, Anchor> OnScale;
public Action<Direction> OnFlip;
public Action 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 FillFlowContainer buttons;
public const float BORDER_RADIUS = 3;
[Resolved]
private OsuColour colours { get; set; }
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Both;
recreate();
}
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
},
}
},
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", () => 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(e.Delta.X),
OperationStarted = operationStarted,
OperationEnded = operationEnded
}
});
}
private void addYScaleComponents()
{
addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically", () => 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", () => 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) => AddInternal(new SelectionBoxDragHandle
{
Anchor = anchor,
HandleDrag = e => OnScale?.Invoke(e.Delta, anchor),
OperationStarted = operationStarted,
OperationEnded = operationEnded
});
private int activeOperations;
private void operationEnded()
{
if (--activeOperations == 0)
OperationEnded?.Invoke();
}
private void operationStarted()
{
if (activeOperations++ == 0)
OperationStarted?.Invoke();
}
}
}

View File

@ -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);
}
}
}

View File

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

View File

@ -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;
@ -20,6 +22,7 @@ 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;
namespace osu.Game.Screens.Edit.Compose.Components
@ -29,22 +32,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 content;
private OsuSpriteText selectionDetailsText;
[Resolved(CanBeNull = true)]
protected SelectionBox SelectionBox { get; private set; }
[Resolved]
protected EditorBeatmap EditorBeatmap { get; private set; }
[Resolved(CanBeNull = true)]
private IEditorChangeHandler changeHandler { get; set; }
protected IEditorChangeHandler ChangeHandler { get; private set; }
public SelectionHandler()
{
@ -58,23 +61,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
createStateBindables();
InternalChild = content = new Container
{
Children = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
BorderThickness = BORDER_RADIUS,
BorderColour = colours.YellowDark,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
AlwaysPresent = true,
Alpha = 0
}
},
// todo: should maybe be inside the SelectionBox?
new Container
{
Name = "info text",
@ -93,11 +86,40 @@ namespace osu.Game.Screens.Edit.Compose.Components
Font = OsuFont.Default.With(size: 11)
}
}
}
},
SelectionBox = CreateSelectionBox(),
}
};
}
public SelectionBox CreateSelectionBox()
=> new SelectionBox
{
OperationStarted = OnOperationBegan,
OperationEnded = OnOperationEnded,
OnRotation = angle => HandleRotation(angle),
OnScale = (amount, anchor) => HandleScale(amount, anchor),
OnFlip = direction => HandleFlip(direction),
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>
@ -112,7 +134,35 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// 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) => true;
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)
{
@ -146,7 +196,10 @@ namespace osu.Game.Screens.Edit.Compose.Components
internal void HandleSelected(SelectionBlueprint blueprint)
{
selectedBlueprints.Add(blueprint);
EditorBeatmap.SelectedHitObjects.Add(blueprint.HitObject);
// 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);
UpdateVisibility();
}
@ -158,6 +211,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
internal void HandleDeselected(SelectionBlueprint blueprint)
{
selectedBlueprints.Remove(blueprint);
EditorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject);
UpdateVisibility();
@ -189,12 +243,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
private void deleteSelected()
{
changeHandler?.BeginChange();
foreach (var h in selectedBlueprints.ToList())
EditorBeatmap?.Remove(h.HitObject);
changeHandler?.EndChange();
EditorBeatmap.RemoveRange(selectedBlueprints.Select(b => b.HitObject));
}
#endregion
@ -211,11 +260,22 @@ namespace osu.Game.Screens.Edit.Compose.Components
selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty;
if (count > 0)
{
Show();
OnSelectionChanged();
}
else
Hide();
}
/// <summary>
/// Triggered whenever more than one object is selected, on each change.
/// Should update the selection box's state to match supported operations.
/// </summary>
protected virtual void OnSelectionChanged()
{
}
protected override void Update()
{
base.Update();
@ -250,9 +310,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <param name="sampleName">The name of the hit sample.</param>
public void AddHitSample(string sampleName)
{
changeHandler?.BeginChange();
EditorBeatmap.BeginChange();
foreach (var h in SelectedHitObjects)
foreach (var h in EditorBeatmap.SelectedHitObjects)
{
// Make sure there isn't already an existing sample
if (h.Samples.Any(s => s.Name == sampleName))
@ -261,7 +321,29 @@ namespace osu.Game.Screens.Edit.Compose.Components
h.Samples.Add(new HitSampleInfo { Name = sampleName });
}
changeHandler?.EndChange();
EditorBeatmap.EndChange();
}
/// <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.BeginChange();
foreach (var h in EditorBeatmap.SelectedHitObjects)
{
var comboInfo = h as IHasComboInformation;
if (comboInfo == null || comboInfo.NewCombo == state) continue;
comboInfo.NewCombo = state;
EditorBeatmap.Update(h);
}
EditorBeatmap.EndChange();
}
/// <summary>
@ -270,12 +352,99 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <param name="sampleName">The name of the hit sample.</param>
public void RemoveHitSample(string sampleName)
{
changeHandler?.BeginChange();
EditorBeatmap.BeginChange();
foreach (var h in SelectedHitObjects)
foreach (var h in EditorBeatmap.SelectedHitObjects)
h.SamplesBindable.RemoveAll(s => s.Name == sampleName);
changeHandler?.EndChange();
EditorBeatmap.EndChange();
}
#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 += _ => UpdateTernaryStates();
EditorBeatmap.SelectedHitObjects.CollectionChanged += (sender, args) => 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
@ -293,6 +462,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
items.AddRange(GetContextMenuItemsForSelection(selectedBlueprints));
if (selectedBlueprints.All(b => b.HitObject is IHasComboInformation))
{
items.Add(new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } });
}
if (selectedBlueprints.Count == 1)
items.AddRange(selectedBlueprints[0].ContextMenuItems);
@ -300,12 +474,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
new OsuMenuItem("Sound")
{
Items = new[]
{
createHitSampleMenuItem("Whistle", HitSampleInfo.HIT_WHISTLE),
createHitSampleMenuItem("Clap", HitSampleInfo.HIT_CLAP),
createHitSampleMenuItem("Finish", HitSampleInfo.HIT_FINISH)
}
Items = SelectionSampleStates.Select(kvp =>
new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
},
new OsuMenuItem("Delete", MenuItemType.Destructive, deleteSelected),
});
@ -322,41 +492,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected virtual IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint> selection)
=> Enumerable.Empty<MenuItem>();
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;
}
}
#endregion
}
}

View File

@ -0,0 +1,67 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using 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);
}
}
}

View File

@ -0,0 +1,85 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using 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);
}
}
}

View File

@ -8,6 +8,7 @@ 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.Game.Beatmaps;
using osu.Game.Graphics;
@ -21,6 +22,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
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]
@ -57,23 +63,41 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private WaveformGraph waveform;
private TimelineTickDisplay ticks;
private TimelineControlPointDisplay controlPoints;
[BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours)
{
Add(waveform = new WaveformGraph
AddRange(new Drawable[]
{
RelativeSizeAxes = Axes.Both,
Colour = colours.Blue.Opacity(0.2f),
LowColour = colours.BlueLighter,
MidColour = colours.BlueDark,
HighColour = colours.BlueDarker,
Depth = float.MaxValue
new Container
{
RelativeSizeAxes = Axes.Both,
Depth = float.MaxValue,
Children = new Drawable[]
{
waveform = new WaveformGraph
{
RelativeSizeAxes = Axes.Both,
Colour = 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 });
WaveformVisible.ValueChanged += visible => waveform.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint);
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 =>

View File

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

View File

@ -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;
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 IBindableList<ControlPointGroup> controlPointGroups;
public TimelineControlPointDisplay()
{
RelativeSizeAxes = Axes.Both;
}
protected override void LoadBeatmap(WorkingBeatmap beatmap)
{
base.LoadBeatmap(beatmap);
controlPointGroups = beatmap.Beatmap.ControlPointInfo.Groups.GetBoundCopy();
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);
}
}
}

View File

@ -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 BindableList<ControlPoint> controlPoints;
[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 = (BindableList<ControlPoint>)Group.ControlPoints.GetBoundCopy();
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);
}
}
}

View File

@ -3,18 +3,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
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.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.Graphics;
@ -34,11 +39,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private readonly List<Container> shadowComponents = new List<Container>();
private DrawableHitObject drawableHitObject;
private Bindable<Color4> comboColour;
private readonly Container mainComponents;
private readonly OsuSpriteText comboIndexText;
private Bindable<int> comboIndex;
private const float thickness = 5;
private const float shadow_radius = 5;
private const float circle_size = 16;
private const float circle_size = 24;
public TimelineHitObjectBlueprint(HitObject hitObject)
: base(hitObject)
@ -54,14 +69,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,
@ -77,7 +106,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
DragBar dragBarUnderlay;
Container extensionBar;
AddRangeInternal(new Drawable[]
mainComponents.AddRange(new Drawable[]
{
extensionBar = new Container
{
@ -117,18 +146,93 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
}
else
{
AddInternal(circle);
mainComponents.Add(circle);
}
updateShadows();
}
[BackgroundDependencyLoader(true)]
private void load(HitObjectComposer composer)
{
if (composer != null)
{
// best effort to get the drawable representation for grabbing colour and what not.
drawableHitObject = composer.HitObjects.FirstOrDefault(d => d.HitObject == HitObject);
}
}
protected override void LoadComplete()
{
base.LoadComplete();
if (HitObject is IHasComboInformation comboInfo)
{
comboIndex = comboInfo.IndexInCurrentComboBindable.GetBoundCopy();
comboIndex.BindValueChanged(combo =>
{
comboIndexText.Text = (combo.NewValue + 1).ToString();
}, true);
}
if (drawableHitObject != null)
{
comboColour = drawableHitObject.AccentColour.GetBoundCopy();
comboColour.BindValueChanged(colour =>
{
if (HitObject is IHasDuration)
mainComponents.Colour = ColourInfo.GradientHorizontal(drawableHitObject.AccentColour.Value, Color4.White);
else
mainComponents.Colour = drawableHitObject.AccentColour.Value;
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;
}, true);
}
}
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;
@ -288,6 +392,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
return;
repeatHitObject.RepeatCount = proposedCount;
beatmap.Update(hitObject);
break;
case IHasDuration endTimeHitObject:
@ -297,10 +402,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
return;
endTimeHitObject.Duration = snappedTime - hitObject.StartTime;
beatmap.Update(hitObject);
break;
}
beatmap.UpdateHitObject(hitObject);
}
}

View File

@ -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; }
@ -31,15 +33,63 @@ 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(_ => tickCache.Invalidate());
}
private void createLines()
/// <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()
{
Clear();
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 +100,70 @@ 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;
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -1,8 +1,12 @@
// 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;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Screens.Edit.Compose.Components.Timeline;
using osu.Game.Skinning;
@ -13,11 +17,28 @@ namespace osu.Game.Screens.Edit.Compose
{
private HitObjectComposer composer;
protected override Drawable CreateMainContent()
public ComposeScreen()
: base(EditorScreenMode.Compose)
{
var ruleset = Beatmap.Value.BeatmapInfo.Ruleset?.CreateInstance();
}
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.
dependencies.CacheAs(composer);
return dependencies;
}
protected override Drawable CreateMainContent()
{
if (ruleset == null || composer == null)
return new ScreenWhiteBox.UnderConstructionMessage(ruleset == null ? "This beatmap" : $"{ruleset.Description}'s composer");

View File

@ -6,6 +6,7 @@ namespace osu.Game.Screens.Edit.Design
public class DesignScreen : EditorScreen
{
public DesignScreen()
: base(EditorScreenMode.Design)
{
Child = new ScreenWhiteBox.UnderConstructionMessage("Design mode");
}

View File

@ -2,42 +2,48 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using osuTK.Graphics;
using osu.Framework.Screens;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Screens.Edit.Components.Timelines.Summary;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Platform;
using osu.Framework.Timing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Screens.Edit.Design;
using osuTK.Input;
using System.Collections.Generic;
using osu.Framework;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.IO.Serialization;
using osu.Game.Online.API;
using osu.Game.Overlays;
using osu.Game.Rulesets.Edit;
using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Screens.Edit.Components.Timelines.Summary;
using osu.Game.Screens.Edit.Compose;
using osu.Game.Screens.Edit.Design;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Play;
using osu.Game.Users;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Screens.Edit
{
[Cached(typeof(IBeatSnapProvider))]
[Cached]
public class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler<GlobalAction>, IKeyBindingHandler<PlatformAction>, IBeatSnapProvider
{
public override float BackgroundParallaxAmount => 0.1f;
@ -50,11 +56,20 @@ namespace osu.Game.Screens.Edit
public override bool AllowRateAdjustments => false;
protected bool HasUnsavedChanges => lastSavedHash != changeHandler.CurrentStateHash;
[Resolved]
private BeatmapManager beatmapManager { get; set; }
[Resolved(canBeNull: true)]
private DialogOverlay dialogOverlay { get; set; }
private bool exitConfirmed;
private string lastSavedHash;
private Box bottomBackground;
private Container screenContainer;
private Container<EditorScreen> screenContainer;
private EditorScreen currentScreen;
@ -65,13 +80,23 @@ namespace osu.Game.Screens.Edit
private EditorBeatmap editorBeatmap;
private EditorChangeHandler changeHandler;
private EditorMenuBar menuBar;
private DependencyContainer dependencies;
private bool isNewBeatmap;
protected override UserActivity InitialActivity => new UserActivity.Editing(Beatmap.Value.BeatmapInfo);
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
=> dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
private MusicController music { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours, GameHost host)
{
@ -79,16 +104,23 @@ namespace osu.Game.Screens.Edit
beatDivisor.BindValueChanged(divisor => Beatmap.Value.BeatmapInfo.BeatDivisor = divisor.NewValue);
// Todo: should probably be done at a DrawableRuleset level to share logic with Player.
var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock();
clock = new EditorClock(Beatmap.Value, beatDivisor) { IsCoupled = false };
clock.ChangeSource(sourceClock);
UpdateClockSource();
dependencies.CacheAs(clock);
dependencies.CacheAs<ISamplePlaybackDisabler>(clock);
AddInternal(clock);
// todo: remove caching of this and consume via editorBeatmap?
dependencies.Cache(beatDivisor);
if (Beatmap.Value is DummyWorkingBeatmap)
{
isNewBeatmap = true;
Beatmap.Value = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value);
}
try
{
playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset);
@ -101,19 +133,23 @@ namespace osu.Game.Screens.Edit
return;
}
AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap));
AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, Beatmap.Value.Skin));
dependencies.CacheAs(editorBeatmap);
changeHandler = new EditorChangeHandler(editorBeatmap);
dependencies.CacheAs<IEditorChangeHandler>(changeHandler);
EditorMenuBar menuBar;
updateLastSavedHash();
OsuMenuItem undoMenuItem;
OsuMenuItem redoMenuItem;
EditorMenuItem cutMenuItem;
EditorMenuItem copyMenuItem;
EditorMenuItem pasteMenuItem;
var fileMenuItems = new List<MenuItem>
{
new EditorMenuItem("Save", MenuItemType.Standard, saveBeatmap)
new EditorMenuItem("Save", MenuItemType.Standard, Save)
};
if (RuntimeInfo.IsDesktop)
@ -132,7 +168,7 @@ namespace osu.Game.Screens.Edit
Name = "Screen container",
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Top = 40, Bottom = 60 },
Child = screenContainer = new Container
Child = screenContainer = new Container<EditorScreen>
{
RelativeSizeAxes = Axes.Both,
Masking = true
@ -148,6 +184,7 @@ namespace osu.Game.Screens.Edit
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.Both,
Mode = { Value = isNewBeatmap ? EditorScreenMode.SongSetup : EditorScreenMode.Compose },
Items = new[]
{
new MenuItem("File")
@ -159,7 +196,11 @@ namespace osu.Game.Screens.Edit
Items = new[]
{
undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, Undo),
redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo)
redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo),
new EditorMenuItemSpacer(),
cutMenuItem = new EditorMenuItem("Cut", MenuItemType.Standard, Cut),
copyMenuItem = new EditorMenuItem("Copy", MenuItemType.Standard, Copy),
pasteMenuItem = new EditorMenuItem("Paste", MenuItemType.Standard, Paste),
}
}
}
@ -220,11 +261,44 @@ namespace osu.Game.Screens.Edit
changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true);
changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true);
editorBeatmap.SelectedHitObjects.BindCollectionChanged((_, __) =>
{
var hasObjects = editorBeatmap.SelectedHitObjects.Count > 0;
cutMenuItem.Action.Disabled = !hasObjects;
copyMenuItem.Action.Disabled = !hasObjects;
}, true);
clipboard.BindValueChanged(content => pasteMenuItem.Action.Disabled = string.IsNullOrEmpty(content.NewValue));
menuBar.Mode.ValueChanged += onModeChanged;
bottomBackground.Colour = colours.Gray2;
}
/// <summary>
/// If the beatmap's track has changed, this method must be called to keep the editor in a valid state.
/// </summary>
public void UpdateClockSource()
{
var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock();
clock.ChangeSource(sourceClock);
}
protected void Save()
{
// no longer new after first user-triggered save.
isNewBeatmap = false;
// apply any set-level metadata changes.
beatmapManager.Update(playableBeatmap.BeatmapInfo.BeatmapSet);
// save the loaded beatmap's data stream.
beatmapManager.Save(playableBeatmap.BeatmapInfo, editorBeatmap, editorBeatmap.BeatmapSkin);
updateLastSavedHash();
}
protected override void Update()
{
base.Update();
@ -235,6 +309,18 @@ namespace osu.Game.Screens.Edit
{
switch (action.ActionType)
{
case PlatformActionType.Cut:
Cut();
return true;
case PlatformActionType.Copy:
Copy();
return true;
case PlatformActionType.Paste:
Paste();
return true;
case PlatformActionType.Undo:
Undo();
return true;
@ -244,7 +330,7 @@ namespace osu.Game.Screens.Edit
return true;
case PlatformActionType.Save:
saveBeatmap();
Save();
return true;
}
@ -305,14 +391,32 @@ namespace osu.Game.Screens.Edit
public bool OnPressed(GlobalAction action)
{
if (action == GlobalAction.Back)
switch (action)
{
// as we don't want to display the back button, manual handling of exit action is required.
this.Exit();
return true;
}
case GlobalAction.Back:
// as we don't want to display the back button, manual handling of exit action is required.
this.Exit();
return true;
return false;
case GlobalAction.EditorComposeMode:
menuBar.Mode.Value = EditorScreenMode.Compose;
return true;
case GlobalAction.EditorDesignMode:
menuBar.Mode.Value = EditorScreenMode.Design;
return true;
case GlobalAction.EditorTimingMode:
menuBar.Mode.Value = EditorScreenMode.Timing;
return true;
case GlobalAction.EditorSetupMode:
menuBar.Mode.Value = EditorScreenMode.SongSetup;
return true;
default:
return false;
}
}
public void OnReleased(GlobalAction action)
@ -334,12 +438,94 @@ namespace osu.Game.Screens.Edit
public override bool OnExiting(IScreen next)
{
if (!exitConfirmed)
{
// if the confirm dialog is already showing (or we can't show it, ie. in tests) exit without save.
if (dialogOverlay == null || dialogOverlay.CurrentDialog is PromptForSaveDialog)
{
confirmExit();
return true;
}
if (isNewBeatmap || HasUnsavedChanges)
{
dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave));
return true;
}
}
Background.FadeColour(Color4.White, 500);
resetTrack();
return base.OnExiting(next);
}
private void confirmExitWithSave()
{
exitConfirmed = true;
Save();
this.Exit();
}
private void confirmExit()
{
// stop the track if playing to allow the parent screen to choose a suitable playback mode.
Beatmap.Value.Track.Stop();
if (isNewBeatmap)
{
// confirming exit without save means we should delete the new beatmap completely.
beatmapManager.Delete(playableBeatmap.BeatmapInfo.BeatmapSet);
// in theory this shouldn't be required but due to EF core not sharing instance states 100%
// MusicController is unaware of the changed DeletePending state.
Beatmap.SetDefault();
}
exitConfirmed = true;
this.Exit();
}
private readonly Bindable<string> clipboard = new Bindable<string>();
protected void Cut()
{
Copy();
editorBeatmap.RemoveRange(editorBeatmap.SelectedHitObjects.ToArray());
}
protected void Copy()
{
if (editorBeatmap.SelectedHitObjects.Count == 0)
return;
clipboard.Value = new ClipboardContent(editorBeatmap).Serialize();
}
protected void Paste()
{
if (string.IsNullOrEmpty(clipboard.Value))
return;
var objects = clipboard.Value.Deserialize<ClipboardContent>().HitObjects;
Debug.Assert(objects.Any());
double timeOffset = clock.CurrentTime - objects.Min(o => o.StartTime);
foreach (var h in objects)
h.StartTime += timeOffset;
editorBeatmap.BeginChange();
editorBeatmap.SelectedHitObjects.Clear();
editorBeatmap.AddRange(objects);
editorBeatmap.SelectedHitObjects.AddRange(objects);
editorBeatmap.EndChange();
}
protected void Undo() => changeHandler.RestoreState(-1);
protected void Redo() => changeHandler.RestoreState(1);
@ -365,7 +551,21 @@ namespace osu.Game.Screens.Edit
private void onModeChanged(ValueChangedEvent<EditorScreenMode> e)
{
currentScreen?.Exit();
var lastScreen = currentScreen;
lastScreen?
.ScaleTo(0.98f, 200, Easing.OutQuint)
.FadeOut(200, Easing.OutQuint);
if ((currentScreen = screenContainer.SingleOrDefault(s => s.Type == e.NewValue)) != null)
{
screenContainer.ChangeChildDepth(currentScreen, lastScreen?.Depth + 1 ?? 0);
currentScreen
.ScaleTo(1, 200, Easing.OutQuint)
.FadeIn(200, Easing.OutQuint);
return;
}
switch (e.NewValue)
{
@ -386,27 +586,44 @@ namespace osu.Game.Screens.Edit
break;
}
LoadComponentAsync(currentScreen, screenContainer.Add);
LoadComponentAsync(currentScreen, newScreen =>
{
if (newScreen == currentScreen)
screenContainer.Add(newScreen);
});
}
private void seek(UIEvent e, int direction)
{
double amount = e.ShiftPressed ? 2 : 1;
double amount = e.ShiftPressed ? 4 : 1;
bool trackPlaying = clock.IsRunning;
if (trackPlaying)
{
// generally users are not looking to perform tiny seeks when the track is playing,
// so seeks should always be by one full beat, bypassing the beatDivisor.
// this multiplication undoes the division that will be applied in the underlying seek operation.
amount *= beatDivisor.Value;
}
if (direction < 1)
clock.SeekBackward(!clock.IsRunning, amount);
clock.SeekBackward(!trackPlaying, amount);
else
clock.SeekForward(!clock.IsRunning, amount);
clock.SeekForward(!trackPlaying, amount);
}
private void saveBeatmap() => beatmapManager.Save(playableBeatmap.BeatmapInfo, editorBeatmap);
private void exportBeatmap()
{
saveBeatmap();
Save();
beatmapManager.Export(Beatmap.Value.BeatmapSetInfo);
}
private void updateLastSavedHash()
{
lastSavedHash = changeHandler.CurrentStateHash;
}
public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime);
public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime);

View File

@ -8,17 +8,16 @@ using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Skinning;
namespace osu.Game.Screens.Edit
{
public class EditorBeatmap : Component, IBeatmap, IBeatSnapProvider
public class EditorBeatmap : TransactionalCommitComponent, IBeatmap, IBeatSnapProvider
{
/// <summary>
/// Invoked when a <see cref="HitObject"/> is added to this <see cref="EditorBeatmap"/>.
@ -47,6 +46,8 @@ namespace osu.Game.Screens.Edit
public readonly IBeatmap PlayableBeatmap;
public readonly ISkin BeatmapSkin;
[Resolved]
private BindableBeatDivisor beatDivisor { get; set; }
@ -54,9 +55,10 @@ namespace osu.Game.Screens.Edit
private readonly Dictionary<HitObject, Bindable<double>> startTimeBindables = new Dictionary<HitObject, Bindable<double>>();
public EditorBeatmap(IBeatmap playableBeatmap)
public EditorBeatmap(IBeatmap playableBeatmap, ISkin beatmapSkin = null)
{
PlayableBeatmap = playableBeatmap;
BeatmapSkin = beatmapSkin;
beatmapProcessor = playableBeatmap.BeatmapInfo.Ruleset?.CreateInstance().CreateBeatmapProcessor(PlayableBeatmap);
@ -64,41 +66,6 @@ namespace osu.Game.Screens.Edit
trackStartTime(obj);
}
private readonly HashSet<HitObject> pendingUpdates = new HashSet<HitObject>();
private ScheduledDelegate scheduledUpdate;
/// <summary>
/// Updates a <see cref="HitObject"/>, invoking <see cref="HitObject.ApplyDefaults"/> and re-processing the beatmap.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> to update.</param>
public void UpdateHitObject([NotNull] HitObject hitObject) => updateHitObject(hitObject, false);
private void updateHitObject([CanBeNull] HitObject hitObject, bool silent)
{
scheduledUpdate?.Cancel();
if (hitObject != null)
pendingUpdates.Add(hitObject);
scheduledUpdate = Schedule(() =>
{
beatmapProcessor?.PreProcess();
foreach (var obj in pendingUpdates)
obj.ApplyDefaults(ControlPointInfo, BeatmapInfo.BaseDifficulty);
beatmapProcessor?.PostProcess();
if (!silent)
{
foreach (var obj in pendingUpdates)
HitObjectUpdated?.Invoke(obj);
}
pendingUpdates.Clear();
});
}
public BeatmapInfo BeatmapInfo
{
get => PlayableBeatmap.BeatmapInfo;
@ -121,14 +88,22 @@ namespace osu.Game.Screens.Edit
private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects;
private readonly List<HitObject> batchPendingInserts = new List<HitObject>();
private readonly List<HitObject> batchPendingDeletes = new List<HitObject>();
private readonly HashSet<HitObject> batchPendingUpdates = new HashSet<HitObject>();
/// <summary>
/// Adds a collection of <see cref="HitObject"/>s to this <see cref="EditorBeatmap"/>.
/// </summary>
/// <param name="hitObjects">The <see cref="HitObject"/>s to add.</param>
public void AddRange(IEnumerable<HitObject> hitObjects)
{
BeginChange();
foreach (var h in hitObjects)
Add(h);
EndChange();
}
/// <summary>
@ -156,14 +131,34 @@ namespace osu.Game.Screens.Edit
mutableHitObjects.Insert(index, hitObject);
HitObjectAdded?.Invoke(hitObject);
updateHitObject(hitObject, true);
BeginChange();
batchPendingInserts.Add(hitObject);
EndChange();
}
/// <summary>
/// Updates a <see cref="HitObject"/>, invoking <see cref="HitObject.ApplyDefaults"/> and re-processing the beatmap.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> to update.</param>
public void Update([NotNull] HitObject hitObject)
{
// updates are debounced regardless of whether a batch is active.
batchPendingUpdates.Add(hitObject);
}
/// <summary>
/// Update all hit objects with potentially changed difficulty or control point data.
/// </summary>
public void UpdateAllHitObjects()
{
foreach (var h in HitObjects)
batchPendingUpdates.Add(h);
}
/// <summary>
/// Removes a <see cref="HitObject"/> from this <see cref="EditorBeatmap"/>.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> to add.</param>
/// <param name="hitObject">The <see cref="HitObject"/> to remove.</param>
/// <returns>True if the <see cref="HitObject"/> has been removed, false otherwise.</returns>
public bool Remove(HitObject hitObject)
{
@ -176,6 +171,18 @@ namespace osu.Game.Screens.Edit
return true;
}
/// <summary>
/// Removes a collection of <see cref="HitObject"/>s to this <see cref="EditorBeatmap"/>.
/// </summary>
/// <param name="hitObjects">The <see cref="HitObject"/>s to remove.</param>
public void RemoveRange(IEnumerable<HitObject> hitObjects)
{
BeginChange();
foreach (var h in hitObjects)
Remove(h);
EndChange();
}
/// <summary>
/// Finds the index of a <see cref="HitObject"/> in this <see cref="EditorBeatmap"/>.
/// </summary>
@ -195,31 +202,55 @@ namespace osu.Game.Screens.Edit
var bindable = startTimeBindables[hitObject];
bindable.UnbindAll();
startTimeBindables.Remove(hitObject);
HitObjectRemoved?.Invoke(hitObject);
updateHitObject(null, true);
BeginChange();
batchPendingDeletes.Add(hitObject);
EndChange();
}
protected override void Update()
{
base.Update();
if (batchPendingUpdates.Count > 0)
UpdateState();
}
protected override void UpdateState()
{
if (batchPendingUpdates.Count == 0 && batchPendingDeletes.Count == 0 && batchPendingInserts.Count == 0)
return;
beatmapProcessor?.PreProcess();
foreach (var h in batchPendingDeletes) processHitObject(h);
foreach (var h in batchPendingInserts) processHitObject(h);
foreach (var h in batchPendingUpdates) processHitObject(h);
beatmapProcessor?.PostProcess();
// callbacks may modify the lists so let's be safe about it
var deletes = batchPendingDeletes.ToArray();
batchPendingDeletes.Clear();
var inserts = batchPendingInserts.ToArray();
batchPendingInserts.Clear();
var updates = batchPendingUpdates.ToArray();
batchPendingUpdates.Clear();
foreach (var h in deletes) HitObjectRemoved?.Invoke(h);
foreach (var h in inserts) HitObjectAdded?.Invoke(h);
foreach (var h in updates) HitObjectUpdated?.Invoke(h);
}
/// <summary>
/// Clears all <see cref="HitObjects"/> from this <see cref="EditorBeatmap"/>.
/// </summary>
public void Clear()
{
var removed = HitObjects.ToList();
public void Clear() => RemoveRange(HitObjects.ToArray());
mutableHitObjects.Clear();
foreach (var b in startTimeBindables)
b.Value.UnbindAll();
startTimeBindables.Clear();
foreach (var h in removed)
HitObjectRemoved?.Invoke(h);
updateHitObject(null, true);
}
private void processHitObject(HitObject hitObject) => hitObject.ApplyDefaults(ControlPointInfo, BeatmapInfo.BaseDifficulty);
private void trackStartTime(HitObject hitObject)
{
@ -232,7 +263,7 @@ namespace osu.Game.Screens.Edit
var insertionIndex = findInsertionIndex(PlayableBeatmap.HitObjects, hitObject.StartTime);
mutableHitObjects.Insert(insertionIndex + 1, hitObject);
UpdateHitObject(hitObject);
Update(hitObject);
};
}

View File

@ -4,8 +4,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Game.Beatmaps.Formats;
using osu.Game.Rulesets.Objects;
@ -14,18 +16,31 @@ namespace osu.Game.Screens.Edit
/// <summary>
/// Tracks changes to the <see cref="Editor"/>.
/// </summary>
public class EditorChangeHandler : IEditorChangeHandler
public class EditorChangeHandler : TransactionalCommitComponent, IEditorChangeHandler
{
public readonly Bindable<bool> CanUndo = new Bindable<bool>();
public readonly Bindable<bool> CanRedo = new Bindable<bool>();
public event Action OnStateChange;
private readonly LegacyEditorBeatmapPatcher patcher;
private readonly List<byte[]> savedStates = new List<byte[]>();
private int currentState = -1;
/// <summary>
/// A SHA-2 hash representing the current visible editor state.
/// </summary>
public string CurrentStateHash
{
get
{
using (var stream = new MemoryStream(savedStates[currentState]))
return stream.ComputeSHA2Hash();
}
}
private readonly EditorBeatmap editorBeatmap;
private int bulkChangesStarted;
private bool isRestoring;
public const int MAX_SAVED_STATES = 50;
@ -38,9 +53,9 @@ namespace osu.Game.Screens.Edit
{
this.editorBeatmap = editorBeatmap;
editorBeatmap.HitObjectAdded += hitObjectAdded;
editorBeatmap.HitObjectRemoved += hitObjectRemoved;
editorBeatmap.HitObjectUpdated += hitObjectUpdated;
editorBeatmap.TransactionBegan += BeginChange;
editorBeatmap.TransactionEnded += EndChange;
editorBeatmap.SaveStateTriggered += SaveState;
patcher = new LegacyEditorBeatmapPatcher(editorBeatmap);
@ -48,51 +63,34 @@ namespace osu.Game.Screens.Edit
SaveState();
}
private void hitObjectAdded(HitObject obj) => SaveState();
private void hitObjectRemoved(HitObject obj) => SaveState();
private void hitObjectUpdated(HitObject obj) => SaveState();
public void BeginChange() => bulkChangesStarted++;
public void EndChange()
protected override void UpdateState()
{
if (bulkChangesStarted == 0)
throw new InvalidOperationException($"Cannot call {nameof(EndChange)} without a previous call to {nameof(BeginChange)}.");
if (--bulkChangesStarted == 0)
SaveState();
}
/// <summary>
/// Saves the current <see cref="Editor"/> state.
/// </summary>
public void SaveState()
{
if (bulkChangesStarted > 0)
return;
if (isRestoring)
return;
if (currentState < savedStates.Count - 1)
savedStates.RemoveRange(currentState + 1, savedStates.Count - currentState - 1);
if (savedStates.Count > MAX_SAVED_STATES)
savedStates.RemoveAt(0);
using (var stream = new MemoryStream())
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(editorBeatmap).Encode(sw);
new LegacyBeatmapEncoder(editorBeatmap, editorBeatmap.BeatmapSkin).Encode(sw);
savedStates.Add(stream.ToArray());
var newState = stream.ToArray();
// if the previous state is binary equal we don't need to push a new one, unless this is the initial state.
if (savedStates.Count > 0 && newState.SequenceEqual(savedStates.Last())) return;
if (currentState < savedStates.Count - 1)
savedStates.RemoveRange(currentState + 1, savedStates.Count - currentState - 1);
if (savedStates.Count > MAX_SAVED_STATES)
savedStates.RemoveAt(0);
savedStates.Add(newState);
currentState = savedStates.Count - 1;
OnStateChange?.Invoke();
updateBindables();
}
currentState = savedStates.Count - 1;
updateBindables();
}
/// <summary>
@ -101,7 +99,7 @@ namespace osu.Game.Screens.Edit
/// <param name="direction">The direction to restore in. If less than 0, an older state will be used. If greater than 0, a newer state will be used.</param>
public void RestoreState(int direction)
{
if (bulkChangesStarted > 0)
if (TransactionActive)
return;
if (savedStates.Count == 0)
@ -118,6 +116,7 @@ namespace osu.Game.Screens.Edit
isRestoring = false;
OnStateChange?.Invoke();
updateBindables();
}

View File

@ -3,21 +3,28 @@
using System;
using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Transforms;
using osu.Framework.Utils;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Screens.Play;
namespace osu.Game.Screens.Edit
{
/// <summary>
/// A decoupled clock which adds editor-specific functionality, such as snapping to a user-defined beat divisor.
/// </summary>
public class EditorClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock
public class EditorClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock, ISamplePlaybackDisabler
{
public readonly double TrackLength;
public IBindable<Track> Track => track;
private readonly Bindable<Track> track = new Bindable<Track>();
public double TrackLength => track.Value?.Length ?? 60000;
public ControlPointInfo ControlPointInfo;
@ -25,6 +32,10 @@ namespace osu.Game.Screens.Edit
private readonly DecoupleableInterpolatingFramedClock underlyingClock;
public IBindable<bool> SamplePlaybackDisabled => samplePlaybackDisabled;
private readonly Bindable<bool> samplePlaybackDisabled = new Bindable<bool>();
public EditorClock(WorkingBeatmap beatmap, BindableBeatDivisor beatDivisor)
: this(beatmap.Beatmap.ControlPointInfo, beatmap.Track.Length, beatDivisor)
{
@ -35,7 +46,6 @@ namespace osu.Game.Screens.Edit
this.beatDivisor = beatDivisor;
ControlPointInfo = controlPointInfo;
TrackLength = trackLength;
underlyingClock = new DecoupleableInterpolatingFramedClock();
}
@ -76,7 +86,7 @@ namespace osu.Game.Screens.Edit
/// </summary>
/// <param name="snapped">Whether to snap to the closest beat after seeking.</param>
/// <param name="amount">The relative amount (magnitude) which should be seeked.</param>
public void SeekBackward(bool snapped = false, double amount = 1) => seek(-1, snapped, amount);
public void SeekBackward(bool snapped = false, double amount = 1) => seek(-1, snapped, amount + (IsRunning ? 1.5 : 0));
/// <summary>
/// Seeks forwards by one beat length.
@ -161,11 +171,14 @@ namespace osu.Game.Screens.Edit
public void Stop()
{
samplePlaybackDisabled.Value = true;
underlyingClock.Stop();
}
public bool Seek(double position)
{
samplePlaybackDisabled.Value = true;
ClearTransforms();
return underlyingClock.Seek(position);
}
@ -190,7 +203,11 @@ namespace osu.Game.Screens.Edit
public FrameTimeInfo TimeInfo => underlyingClock.TimeInfo;
public void ChangeSource(IClock source) => underlyingClock.ChangeSource(source);
public void ChangeSource(IClock source)
{
track.Value = source as Track;
underlyingClock.ChangeSource(source);
}
public IClock Source => underlyingClock.Source;
@ -202,8 +219,35 @@ namespace osu.Game.Screens.Edit
private const double transform_time = 300;
protected override void Update()
{
base.Update();
updateSeekingState();
}
private void updateSeekingState()
{
if (samplePlaybackDisabled.Value)
{
if (track.Value?.IsRunning != true)
{
// seeking in the editor can happen while the track isn't running.
// in this case we always want to expose ourselves as seeking (to avoid sample playback).
return;
}
// we are either running a seek tween or doing an immediate seek.
// in the case of an immediate seek the seeking bool will be set to false after one update.
// this allows for silencing hit sounds and the likes.
samplePlaybackDisabled.Value = Transforms.Any();
}
}
public void SeekTo(double seekDestination)
{
samplePlaybackDisabled.Value = true;
if (IsRunning)
Seek(seekDestination);
else

View File

@ -23,8 +23,12 @@ namespace osu.Game.Screens.Edit
protected override Container<Drawable> Content => content;
private readonly Container content;
protected EditorScreen()
public readonly EditorScreenMode Type;
protected EditorScreen(EditorScreenMode type)
{
Type = type;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
RelativeSizeAxes = Axes.Both;
@ -40,10 +44,5 @@ namespace osu.Game.Screens.Edit
.Then()
.FadeTo(1f, 250, Easing.OutQuint);
}
public void Exit()
{
this.FadeOut(250).Expire();
}
}
}

View File

@ -25,6 +25,11 @@ namespace osu.Game.Screens.Edit
private Container timelineContainer;
protected EditorScreenWithTimeline(EditorScreenMode type)
: base(type)
{
}
[BackgroundDependencyLoader(true)]
private void load([CanBeNull] BindableBeatDivisor beatDivisor)
{
@ -94,6 +99,7 @@ namespace osu.Game.Screens.Edit
}
},
};
LoadComponentAsync(CreateMainContent(), content =>
{
spinner.State.Value = Visibility.Hidden;
@ -106,13 +112,20 @@ namespace osu.Game.Screens.Edit
RelativeSizeAxes = Axes.Both,
Children = new[]
{
new TimelineTickDisplay(),
CreateTimelineContent(),
}
}, timelineContainer.Add);
}, t =>
{
timelineContainer.Add(t);
OnTimelineLoaded(t);
});
});
}
protected virtual void OnTimelineLoaded(TimelineArea timelineArea)
{
}
protected abstract Drawable CreateMainContent();
protected virtual Drawable CreateTimelineContent() => new Container();

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit
@ -10,6 +11,11 @@ namespace osu.Game.Screens.Edit
/// </summary>
public interface IEditorChangeHandler
{
/// <summary>
/// Fired whenever a state change occurs.
/// </summary>
event Action OnStateChange;
/// <summary>
/// Begins a bulk state change event. <see cref="EndChange"/> should be invoked soon after.
/// </summary>
@ -29,5 +35,11 @@ namespace osu.Game.Screens.Edit
/// This should be invoked as soon as possible after <see cref="BeginChange"/> to cause a state change.
/// </remarks>
void EndChange();
/// <summary>
/// Immediately saves the current <see cref="Editor"/> state.
/// Note that this will be a no-op if there is a change in progress via <see cref="BeginChange"/>.
/// </summary>
void SaveState();
}
}

View File

@ -68,6 +68,8 @@ namespace osu.Game.Screens.Edit
toRemove.Sort();
toAdd.Sort();
editorBeatmap.BeginChange();
// Apply the changes.
for (int i = toRemove.Count - 1; i >= 0; i--)
editorBeatmap.RemoveAt(toRemove[i]);
@ -78,6 +80,8 @@ namespace osu.Game.Screens.Edit
foreach (var i in toAdd)
editorBeatmap.Insert(i, newBeatmap.HitObjects[i]);
}
editorBeatmap.EndChange();
}
private string readString(byte[] state) => Encoding.UTF8.GetString(state);

View File

@ -0,0 +1,37 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Graphics.Sprites;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Screens.Edit
{
public class PromptForSaveDialog : PopupDialog
{
public PromptForSaveDialog(Action exit, Action saveAndExit)
{
HeaderText = "Did you want to save your changes?";
Icon = FontAwesome.Regular.Save;
Buttons = new PopupDialogButton[]
{
new PopupDialogCancelButton
{
Text = @"Save my masterpiece!",
Action = saveAndExit
},
new PopupDialogOkButton
{
Text = @"Forget all changes",
Action = exit
},
new PopupDialogCancelButton
{
Text = @"Oops, continue editing",
},
};
}
}
}

View File

@ -0,0 +1,99 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterfaceV2;
namespace osu.Game.Screens.Edit.Setup
{
internal class DifficultySection : SetupSection
{
[Resolved]
private EditorBeatmap editorBeatmap { get; set; }
private LabelledSliderBar<float> circleSizeSlider;
private LabelledSliderBar<float> healthDrainSlider;
private LabelledSliderBar<float> approachRateSlider;
private LabelledSliderBar<float> overallDifficultySlider;
[BackgroundDependencyLoader]
private void load()
{
Children = new Drawable[]
{
new OsuSpriteText
{
Text = "Difficulty settings"
},
circleSizeSlider = new LabelledSliderBar<float>
{
Label = "Object Size",
Description = "The size of all hit objects",
Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 2,
MaxValue = 7,
Precision = 0.1f,
}
},
healthDrainSlider = new LabelledSliderBar<float>
{
Label = "Health Drain",
Description = "The rate of passive health drain throughout playable time",
Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.DrainRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
approachRateSlider = new LabelledSliderBar<float>
{
Label = "Approach Rate",
Description = "The speed at which objects are presented to the player",
Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.ApproachRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
overallDifficultySlider = new LabelledSliderBar<float>
{
Label = "Overall Difficulty",
Description = "The harshness of hit windows and difficulty of special objects (ie. spinners)",
Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.OverallDifficulty)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
};
foreach (var item in Children.OfType<LabelledSliderBar<float>>())
item.Current.ValueChanged += onValueChanged;
}
private void onValueChanged(ValueChangedEvent<float> args)
{
// for now, update these on commit rather than making BeatmapMetadata bindables.
// after switching database engines we can reconsider if switching to bindables is a good direction.
Beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize = circleSizeSlider.Current.Value;
Beatmap.Value.BeatmapInfo.BaseDifficulty.DrainRate = healthDrainSlider.Current.Value;
Beatmap.Value.BeatmapInfo.BaseDifficulty.ApproachRate = approachRateSlider.Current.Value;
Beatmap.Value.BeatmapInfo.BaseDifficulty.OverallDifficulty = overallDifficultySlider.Current.Value;
editorBeatmap.UpdateAllHitObjects();
}
}
}

View File

@ -0,0 +1,73 @@
// 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.IO;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
namespace osu.Game.Screens.Edit.Setup
{
/// <summary>
/// A labelled textbox which reveals an inline file chooser when clicked.
/// </summary>
internal class FileChooserLabelledTextBox : LabelledTextBox
{
public Container Target;
private readonly IBindable<FileInfo> currentFile = new Bindable<FileInfo>();
public FileChooserLabelledTextBox()
{
currentFile.BindValueChanged(onFileSelected);
}
private void onFileSelected(ValueChangedEvent<FileInfo> file)
{
if (file.NewValue == null)
return;
Target.Clear();
Current.Value = file.NewValue.FullName;
}
protected override OsuTextBox CreateTextBox() =>
new FileChooserOsuTextBox
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
CornerRadius = CORNER_RADIUS,
OnFocused = DisplayFileChooser
};
public void DisplayFileChooser()
{
Target.Child = new FileSelector(validFileExtensions: ResourcesSection.AudioExtensions)
{
RelativeSizeAxes = Axes.X,
Height = 400,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
CurrentFile = { BindTarget = currentFile }
};
}
internal class FileChooserOsuTextBox : OsuTextBox
{
public Action OnFocused;
protected override void OnFocus(FocusEvent e)
{
OnFocused?.Invoke();
base.OnFocus(e);
GetContainingInputManager().TriggerFocusContention(this);
}
}
}
}

View File

@ -0,0 +1,71 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterfaceV2;
namespace osu.Game.Screens.Edit.Setup
{
internal class MetadataSection : SetupSection
{
private LabelledTextBox artistTextBox;
private LabelledTextBox titleTextBox;
private LabelledTextBox creatorTextBox;
private LabelledTextBox difficultyTextBox;
[BackgroundDependencyLoader]
private void load()
{
Children = new Drawable[]
{
new OsuSpriteText
{
Text = "Beatmap metadata"
},
artistTextBox = new LabelledTextBox
{
Label = "Artist",
Current = { Value = Beatmap.Value.Metadata.Artist },
TabbableContentContainer = this
},
titleTextBox = new LabelledTextBox
{
Label = "Title",
Current = { Value = Beatmap.Value.Metadata.Title },
TabbableContentContainer = this
},
creatorTextBox = new LabelledTextBox
{
Label = "Creator",
Current = { Value = Beatmap.Value.Metadata.AuthorString },
TabbableContentContainer = this
},
difficultyTextBox = new LabelledTextBox
{
Label = "Difficulty Name",
Current = { Value = Beatmap.Value.BeatmapInfo.Version },
TabbableContentContainer = this
},
};
foreach (var item in Children.OfType<LabelledTextBox>())
item.OnCommit += onCommit;
}
private void onCommit(TextBox sender, bool newText)
{
if (!newText) return;
// for now, update these on commit rather than making BeatmapMetadata bindables.
// after switching database engines we can reconsider if switching to bindables is a good direction.
Beatmap.Value.Metadata.Artist = artistTextBox.Current.Value;
Beatmap.Value.Metadata.Title = titleTextBox.Current.Value;
Beatmap.Value.Metadata.AuthorString = creatorTextBox.Current.Value;
Beatmap.Value.BeatmapInfo.Version = difficultyTextBox.Current.Value;
}
}
}

View File

@ -0,0 +1,211 @@
// 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 System.IO;
using System.Linq;
using System.Threading.Tasks;
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;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
namespace osu.Game.Screens.Edit.Setup
{
internal class ResourcesSection : SetupSection, ICanAcceptFiles
{
private LabelledTextBox audioTrackTextBox;
private Container backgroundSpriteContainer;
public IEnumerable<string> HandledExtensions => ImageExtensions.Concat(AudioExtensions);
public static string[] ImageExtensions { get; } = { ".jpg", ".jpeg", ".png" };
public static string[] AudioExtensions { get; } = { ".mp3", ".ogg" };
[Resolved]
private OsuGameBase game { get; set; }
[Resolved]
private MusicController music { get; set; }
[Resolved]
private BeatmapManager beatmaps { get; set; }
[Resolved(canBeNull: true)]
private Editor editor { get; set; }
[BackgroundDependencyLoader]
private void load()
{
Container audioTrackFileChooserContainer = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
};
Children = new Drawable[]
{
backgroundSpriteContainer = new Container
{
RelativeSizeAxes = Axes.X,
Height = 250,
Masking = true,
CornerRadius = 10,
},
new OsuSpriteText
{
Text = "Resources"
},
audioTrackTextBox = new FileChooserLabelledTextBox
{
Label = "Audio Track",
Current = { Value = Beatmap.Value.Metadata.AudioFile ?? "Click to select a track" },
Target = audioTrackFileChooserContainer,
TabbableContentContainer = this
},
audioTrackFileChooserContainer,
};
updateBackgroundSprite();
audioTrackTextBox.Current.BindValueChanged(audioTrackChanged);
}
Task ICanAcceptFiles.Import(params string[] paths)
{
Schedule(() =>
{
var firstFile = new FileInfo(paths.First());
if (ImageExtensions.Contains(firstFile.Extension))
{
ChangeBackgroundImage(firstFile.FullName);
}
else if (AudioExtensions.Contains(firstFile.Extension))
{
audioTrackTextBox.Text = firstFile.FullName;
}
});
return Task.CompletedTask;
}
protected override void LoadComplete()
{
base.LoadComplete();
game.RegisterImportHandler(this);
}
public bool ChangeBackgroundImage(string path)
{
var info = new FileInfo(path);
if (!info.Exists)
return false;
var set = Beatmap.Value.BeatmapSetInfo;
// remove the previous background for now.
// in the future we probably want to check if this is being used elsewhere (other difficulties?)
var oldFile = set.Files.FirstOrDefault(f => f.Filename == Beatmap.Value.Metadata.BackgroundFile);
using (var stream = info.OpenRead())
{
if (oldFile != null)
beatmaps.ReplaceFile(set, oldFile, stream, info.Name);
else
beatmaps.AddFile(set, stream, info.Name);
}
Beatmap.Value.Metadata.BackgroundFile = info.Name;
updateBackgroundSprite();
return true;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
game?.UnregisterImportHandler(this);
}
public bool ChangeAudioTrack(string path)
{
var info = new FileInfo(path);
if (!info.Exists)
return false;
var set = Beatmap.Value.BeatmapSetInfo;
// remove the previous audio track for now.
// in the future we probably want to check if this is being used elsewhere (other difficulties?)
var oldFile = set.Files.FirstOrDefault(f => f.Filename == Beatmap.Value.Metadata.AudioFile);
using (var stream = info.OpenRead())
{
if (oldFile != null)
beatmaps.ReplaceFile(set, oldFile, stream, info.Name);
else
beatmaps.AddFile(set, stream, info.Name);
}
Beatmap.Value.Metadata.AudioFile = info.Name;
music.ReloadCurrentTrack();
editor?.UpdateClockSource();
return true;
}
private void audioTrackChanged(ValueChangedEvent<string> filePath)
{
if (!ChangeAudioTrack(filePath.NewValue))
audioTrackTextBox.Current.Value = filePath.OldValue;
}
private void updateBackgroundSprite()
{
LoadComponentAsync(new BeatmapBackgroundSprite(Beatmap.Value)
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
FillMode = FillMode.Fill,
}, background =>
{
if (background.Texture != null)
backgroundSpriteContainer.Child = background;
else
{
backgroundSpriteContainer.Children = new Drawable[]
{
new Box
{
Colour = Colours.GreySeafoamDarker,
RelativeSizeAxes = Axes.Both,
},
new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 24))
{
Text = "Drag image here to set beatmap background!",
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.X,
}
};
}
background.FadeInFromZero(500);
});
}
}
}

View File

@ -1,13 +1,78 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays;
namespace osu.Game.Screens.Edit.Setup
{
public class SetupScreen : EditorScreen
{
[Resolved]
private OsuColour colours { get; set; }
[Cached]
protected readonly OverlayColourProvider ColourProvider;
public SetupScreen()
: base(EditorScreenMode.SongSetup)
{
Child = new ScreenWhiteBox.UnderConstructionMessage("Setup mode");
ColourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
}
[BackgroundDependencyLoader]
private void load()
{
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(50),
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 10,
Children = new Drawable[]
{
new Box
{
Colour = colours.GreySeafoamDark,
RelativeSizeAxes = Axes.Both,
},
new SectionsContainer<SetupSection>
{
FixedHeader = new SetupScreenHeader(),
RelativeSizeAxes = Axes.Both,
Children = new SetupSection[]
{
new ResourcesSection(),
new MetadataSection(),
new DifficultySection(),
}
},
}
}
};
}
}
internal class SetupScreenHeader : OverlayHeader
{
protected override OverlayTitle CreateTitle() => new SetupScreenTitle();
private class SetupScreenTitle : OverlayTitle
{
public SetupScreenTitle()
{
Title = "beatmap setup";
Description = "change general settings of your beatmap";
IconTexture = "Icons/Hexacons/social";
}
}
}
}

View File

@ -0,0 +1,42 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osuTK;
namespace osu.Game.Screens.Edit.Setup
{
internal class SetupSection : Container
{
private readonly FillFlowContainer flow;
[Resolved]
protected OsuColour Colours { get; private set; }
[Resolved]
protected IBindable<WorkingBeatmap> Beatmap { get; private set; }
protected override Container<Drawable> Content => flow;
public SetupSection()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Padding = new MarginPadding(10);
InternalChild = flow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(20),
Direction = FillDirection.Vertical,
};
}
}
}

View File

@ -41,6 +41,7 @@ namespace osu.Game.Screens.Edit.Timing
private IReadOnlyList<Drawable> createSections() => new Drawable[]
{
new GroupSection(),
new TimingSection(),
new DifficultySection(),
new SampleSection(),

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
@ -89,7 +90,7 @@ namespace osu.Game.Screens.Edit.Timing
},
new OsuSpriteText
{
Text = $"{group.Time:n0}ms",
Text = group.Time.ToEditorFormattedString(),
Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold)
},
new ControlGroupAttributes(group),
@ -112,11 +113,23 @@ namespace osu.Game.Screens.Edit.Timing
};
controlPoints = group.ControlPoints.GetBoundCopy();
controlPoints.CollectionChanged += (_, __) => createChildren();
}
[Resolved]
private OsuColour colours { get; set; }
[BackgroundDependencyLoader]
private void load()
{
createChildren();
}
protected override void LoadComplete()
{
base.LoadComplete();
controlPoints.CollectionChanged += (_, __) => createChildren();
}
private void createChildren()
{
fill.ChildrenEnumerable = controlPoints.Select(createAttribute).Where(c => c != null);
@ -124,20 +137,22 @@ namespace osu.Game.Screens.Edit.Timing
private Drawable createAttribute(ControlPoint controlPoint)
{
Color4 colour = controlPoint.GetRepresentingColour(colours);
switch (controlPoint)
{
case TimingControlPoint timing:
return new RowAttribute("timing", () => $"{60000 / timing.BeatLength:n1}bpm {timing.TimeSignature}");
return new RowAttribute("timing", () => $"{60000 / timing.BeatLength:n1}bpm {timing.TimeSignature}", colour);
case DifficultyControlPoint difficulty:
return new RowAttribute("difficulty", () => $"{difficulty.SpeedMultiplier:n2}x");
return new RowAttribute("difficulty", () => $"{difficulty.SpeedMultiplier:n2}x", colour);
case EffectControlPoint effect:
return new RowAttribute("effect", () => $"{(effect.KiaiMode ? "Kiai " : "")}{(effect.OmitFirstBarLine ? "NoBarLine " : "")}");
return new RowAttribute("effect", () => $"{(effect.KiaiMode ? "Kiai " : "")}{(effect.OmitFirstBarLine ? "NoBarLine " : "")}", colour);
case SampleControlPoint sample:
return new RowAttribute("sample", () => $"{sample.SampleBank} {sample.SampleVolume}%");
return new RowAttribute("sample", () => $"{sample.SampleBank} {sample.SampleVolume}%", colour);
}
return null;

View File

@ -2,27 +2,23 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Bindables;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Overlays.Settings;
namespace osu.Game.Screens.Edit.Timing
{
internal class DifficultySection : Section<DifficultyControlPoint>
{
private SettingsSlider<double> multiplier;
private SliderWithTextBoxInput<double> multiplierSlider;
[BackgroundDependencyLoader]
private void load()
{
Flow.AddRange(new[]
{
multiplier = new SettingsSlider<double>
multiplierSlider = new SliderWithTextBoxInput<double>("Speed Multiplier")
{
LabelText = "Speed Multiplier",
Bindable = new DifficultyControlPoint().SpeedMultiplierBindable,
RelativeSizeAxes = Axes.X,
Current = new DifficultyControlPoint().SpeedMultiplierBindable
}
});
}
@ -31,7 +27,8 @@ namespace osu.Game.Screens.Edit.Timing
{
if (point.NewValue != null)
{
multiplier.Bindable = point.NewValue.SpeedMultiplierBindable;
multiplierSlider.Current = point.NewValue.SpeedMultiplierBindable;
multiplierSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
}
}

View File

@ -28,7 +28,10 @@ namespace osu.Game.Screens.Edit.Timing
if (point.NewValue != null)
{
kiai.Current = point.NewValue.KiaiModeBindable;
kiai.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable;
omitBarLine.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
}
}

View File

@ -0,0 +1,120 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osuTK;
namespace osu.Game.Screens.Edit.Timing
{
internal class GroupSection : CompositeDrawable
{
private LabelledTextBox textBox;
private TriangleButton button;
[Resolved]
protected Bindable<ControlPointGroup> SelectedGroup { get; private set; }
[Resolved]
protected IBindable<WorkingBeatmap> Beatmap { get; private set; }
[Resolved]
private EditorClock clock { get; set; }
[Resolved(canBeNull: true)]
private IEditorChangeHandler changeHandler { get; set; }
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Padding = new MarginPadding(10);
InternalChildren = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(10),
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
textBox = new LabelledTextBox
{
Label = "Time"
},
button = new TriangleButton
{
Text = "Use current time",
RelativeSizeAxes = Axes.X,
Action = () => changeSelectedGroupTime(clock.CurrentTime)
}
}
},
};
textBox.OnCommit += (sender, isNew) =>
{
if (!isNew)
return;
if (double.TryParse(sender.Text, out var newTime))
{
changeSelectedGroupTime(newTime);
}
else
{
SelectedGroup.TriggerChange();
}
};
SelectedGroup.BindValueChanged(group =>
{
if (group.NewValue == null)
{
textBox.Text = string.Empty;
textBox.Current.Disabled = true;
button.Enabled.Value = false;
return;
}
textBox.Current.Disabled = false;
button.Enabled.Value = true;
textBox.Text = $"{group.NewValue.Time:n0}";
}, true);
}
private void changeSelectedGroupTime(in double time)
{
if (SelectedGroup.Value == null || time == SelectedGroup.Value.Time)
return;
changeHandler?.BeginChange();
var currentGroupItems = SelectedGroup.Value.ControlPoints.ToArray();
Beatmap.Value.Beatmap.ControlPointInfo.RemoveGroup(SelectedGroup.Value);
foreach (var cp in currentGroupItems)
Beatmap.Value.Beatmap.ControlPointInfo.Add(time, cp);
// the control point might not necessarily exist yet, if currentGroupItems was empty.
SelectedGroup.Value = Beatmap.Value.Beatmap.ControlPointInfo.GroupAt(time, true);
changeHandler?.EndChange();
}
}
}

View File

@ -9,6 +9,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Timing
{
@ -16,11 +17,13 @@ namespace osu.Game.Screens.Edit.Timing
{
private readonly string header;
private readonly Func<string> content;
private readonly Color4 colour;
public RowAttribute(string header, Func<string> content)
public RowAttribute(string header, Func<string> content, Color4 colour)
{
this.header = header;
this.content = content;
this.colour = colour;
}
[BackgroundDependencyLoader]
@ -40,7 +43,7 @@ namespace osu.Game.Screens.Edit.Timing
{
new Box
{
Colour = colours.Yellow,
Colour = colour,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
@ -50,7 +53,7 @@ namespace osu.Game.Screens.Edit.Timing
Origin = Anchor.Centre,
Font = OsuFont.Default.With(weight: FontWeight.SemiBold, size: 12),
Text = header,
Colour = colours.Gray3
Colour = colours.Gray0
},
};
}

View File

@ -2,18 +2,17 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays.Settings;
namespace osu.Game.Screens.Edit.Timing
{
internal class SampleSection : Section<SampleControlPoint>
{
private LabelledTextBox bank;
private SettingsSlider<int> volume;
private SliderWithTextBoxInput<int> volume;
[BackgroundDependencyLoader]
private void load()
@ -24,10 +23,9 @@ namespace osu.Game.Screens.Edit.Timing
{
Label = "Bank Name",
},
volume = new SettingsSlider<int>
volume = new SliderWithTextBoxInput<int>("Volume")
{
Bindable = new SampleControlPoint().SampleVolumeBindable,
LabelText = "Volume",
Current = new SampleControlPoint().SampleVolumeBindable,
}
});
}
@ -37,7 +35,10 @@ namespace osu.Game.Screens.Edit.Timing
if (point.NewValue != null)
{
bank.Current = point.NewValue.SampleBankBindable;
volume.Bindable = point.NewValue.SampleVolumeBindable;
bank.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
volume.Current = point.NewValue.SampleVolumeBindable;
volume.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
}
}

View File

@ -32,6 +32,9 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved]
protected Bindable<ControlPointGroup> SelectedGroup { get; private set; }
[Resolved(canBeNull: true)]
protected IEditorChangeHandler ChangeHandler { get; private set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{

View File

@ -0,0 +1,78 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays.Settings;
namespace osu.Game.Screens.Edit.Timing
{
public class SliderWithTextBoxInput<T> : CompositeDrawable, IHasCurrentValue<T>
where T : struct, IEquatable<T>, IComparable<T>, IConvertible
{
private readonly SettingsSlider<T> slider;
public SliderWithTextBoxInput(string labelText)
{
LabelledTextBox textbox;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChildren = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
textbox = new LabelledTextBox
{
Label = labelText,
},
slider = new SettingsSlider<T>
{
TransferValueOnCommit = true,
RelativeSizeAxes = Axes.X,
}
}
},
};
textbox.OnCommit += (t, isNew) =>
{
if (!isNew) return;
try
{
slider.Current.Parse(t.Text);
}
catch
{
// TriggerChange below will restore the previous text value on failure.
}
// This is run regardless of parsing success as the parsed number may not actually trigger a change
// due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state.
Current.TriggerChange();
};
Current.BindValueChanged(val =>
{
textbox.Text = val.NewValue.ToString();
}, true);
}
public Bindable<T> Current
{
get => slider.Current;
set => slider.Current = value;
}
}
}

View File

@ -12,6 +12,7 @@ using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Edit.Compose.Components.Timeline;
using osuTK;
namespace osu.Game.Screens.Edit.Timing
@ -24,6 +25,11 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved]
private EditorClock clock { get; set; }
public TimingScreen()
: base(EditorScreenMode.Timing)
{
}
protected override Drawable CreateMainContent() => new GridContainer
{
RelativeSizeAxes = Axes.Both,
@ -53,6 +59,12 @@ namespace osu.Game.Screens.Edit.Timing
});
}
protected override void OnTimelineLoaded(TimelineArea timelineArea)
{
base.OnTimelineLoaded(timelineArea);
timelineArea.Timeline.Zoom = timelineArea.Timeline.MinZoom;
}
public class ControlPointList : CompositeDrawable
{
private OsuButton deleteButton;
@ -69,6 +81,9 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved]
private Bindable<ControlPointGroup> selectedGroup { get; set; }
[Resolved(canBeNull: true)]
private IEditorChangeHandler changeHandler { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
@ -124,11 +139,13 @@ namespace osu.Game.Screens.Edit.Timing
selectedGroup.BindValueChanged(selected => { deleteButton.Enabled.Value = selected.NewValue != null; }, true);
controlGroups = Beatmap.Value.Beatmap.ControlPointInfo.Groups.GetBoundCopy();
controlGroups.CollectionChanged += (sender, args) => createContent();
createContent();
}
private void createContent() => table.ControlGroups = controlGroups;
controlGroups.BindCollectionChanged((sender, args) =>
{
table.ControlGroups = controlGroups;
changeHandler.SaveState();
}, true);
}
private void delete()
{

View File

@ -1,30 +1,30 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays.Settings;
namespace osu.Game.Screens.Edit.Timing
{
internal class TimingSection : Section<TimingControlPoint>
{
private SettingsSlider<double> bpm;
private SettingsSlider<double> bpmSlider;
private SettingsEnumDropdown<TimeSignatures> timeSignature;
private BPMTextBox bpmTextEntry;
[BackgroundDependencyLoader]
private void load()
{
Flow.AddRange(new Drawable[]
{
bpm = new BPMSlider
{
Bindable = new TimingControlPoint().BeatLengthBindable,
LabelText = "BPM",
},
bpmTextEntry = new BPMTextBox(),
bpmSlider = new BPMSlider(),
timeSignature = new SettingsEnumDropdown<TimeSignatures>
{
LabelText = "Time Signature"
@ -36,8 +36,14 @@ namespace osu.Game.Screens.Edit.Timing
{
if (point.NewValue != null)
{
bpm.Bindable = point.NewValue.BeatLengthBindable;
timeSignature.Bindable = point.NewValue.TimeSignatureBindable;
bpmSlider.Current = point.NewValue.BeatLengthBindable;
bpmSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable;
// no need to hook change handler here as it's the same bindable as above
timeSignature.Current = point.NewValue.TimeSignatureBindable;
timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
}
}
@ -52,34 +58,96 @@ namespace osu.Game.Screens.Edit.Timing
};
}
private class BPMSlider : SettingsSlider<double>
private class BPMTextBox : LabelledTextBox
{
private readonly BindableDouble beatLengthBindable = new BindableDouble();
private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable;
private BindableDouble bpmBindable;
public override Bindable<double> Bindable
public BPMTextBox()
{
get => base.Bindable;
Label = "BPM";
OnCommit += (val, isNew) =>
{
if (!isNew) return;
try
{
if (double.TryParse(Current.Value, out double doubleVal) && doubleVal > 0)
beatLengthBindable.Value = beatLengthToBpm(doubleVal);
}
catch
{
// TriggerChange below will restore the previous text value on failure.
}
// This is run regardless of parsing success as the parsed number may not actually trigger a change
// due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state.
beatLengthBindable.TriggerChange();
};
beatLengthBindable.BindValueChanged(val =>
{
Current.Value = beatLengthToBpm(val.NewValue).ToString("N2");
}, true);
}
public Bindable<double> Bindable
{
get => beatLengthBindable;
set
{
// incoming will be beatlength
// incoming will be beat length, not bpm
beatLengthBindable.UnbindBindings();
beatLengthBindable.BindTo(value);
}
}
}
base.Bindable = bpmBindable = new BindableDouble(beatLengthToBpm(beatLengthBindable.Value))
{
MinValue = beatLengthToBpm(beatLengthBindable.MaxValue),
MaxValue = beatLengthToBpm(beatLengthBindable.MinValue),
Default = beatLengthToBpm(beatLengthBindable.Default),
};
private class BPMSlider : SettingsSlider<double>
{
private const double sane_minimum = 60;
private const double sane_maximum = 240;
bpmBindable.BindValueChanged(bpm => beatLengthBindable.Value = beatLengthToBpm(bpm.NewValue));
private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable;
private readonly BindableDouble bpmBindable = new BindableDouble(60000 / TimingControlPoint.DEFAULT_BEAT_LENGTH)
{
MinValue = sane_minimum,
MaxValue = sane_maximum,
};
public BPMSlider()
{
beatLengthBindable.BindValueChanged(beatLength => updateCurrent(beatLengthToBpm(beatLength.NewValue)), true);
bpmBindable.BindValueChanged(bpm => beatLengthBindable.Value = beatLengthToBpm(bpm.NewValue));
base.Current = bpmBindable;
TransferValueOnCommit = true;
}
public override Bindable<double> Current
{
get => base.Current;
set
{
// incoming will be beat length, not bpm
beatLengthBindable.UnbindBindings();
beatLengthBindable.BindTo(value);
}
}
private double beatLengthToBpm(double beatLength) => 60000 / beatLength;
private void updateCurrent(double newValue)
{
// we use a more sane range for the slider display unless overridden by the user.
// if a value comes in outside our range, we should expand temporarily.
bpmBindable.MinValue = Math.Min(newValue, sane_minimum);
bpmBindable.MaxValue = Math.Max(newValue, sane_maximum);
bpmBindable.Value = newValue;
}
}
private static double beatLengthToBpm(double beatLength) => 60000 / beatLength;
}
}

View File

@ -0,0 +1,73 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Graphics;
namespace osu.Game.Screens.Edit
{
/// <summary>
/// A component that tracks a batch change, only applying after all active changes are completed.
/// </summary>
public abstract class TransactionalCommitComponent : Component
{
/// <summary>
/// Fires whenever a transaction begins. Will not fire on nested transactions.
/// </summary>
public event Action TransactionBegan;
/// <summary>
/// Fires when the last transaction completes.
/// </summary>
public event Action TransactionEnded;
/// <summary>
/// Fires when <see cref="SaveState"/> is called and results in a non-transactional state save.
/// </summary>
public event Action SaveStateTriggered;
public bool TransactionActive => bulkChangesStarted > 0;
private int bulkChangesStarted;
/// <summary>
/// Signal the beginning of a change.
/// </summary>
public void BeginChange()
{
if (bulkChangesStarted++ == 0)
TransactionBegan?.Invoke();
}
/// <summary>
/// Signal the end of a change.
/// </summary>
/// <exception cref="InvalidOperationException">Throws if <see cref="BeginChange"/> was not first called.</exception>
public void EndChange()
{
if (bulkChangesStarted == 0)
throw new InvalidOperationException($"Cannot call {nameof(EndChange)} without a previous call to {nameof(BeginChange)}.");
if (--bulkChangesStarted == 0)
{
UpdateState();
TransactionEnded?.Invoke();
}
}
/// <summary>
/// Force an update of the state with no attached transaction.
/// This is a no-op if a transaction is already active. Should generally be used as a safety measure to ensure granular changes are not left outside a transaction.
/// </summary>
public void SaveState()
{
if (bulkChangesStarted > 0)
return;
SaveStateTriggered?.Invoke();
UpdateState();
}
protected abstract void UpdateState();
}
}