mirror of
https://github.com/osukey/osukey.git
synced 2025-08-06 16:13:57 +09:00
Limit distance snap between two adjacent hit objects (#6740)
Limit distance snap between two adjacent hit objects
This commit is contained in:
@ -42,11 +42,19 @@ namespace osu.Game.Rulesets.Osu.Tests
|
|||||||
[Cached(typeof(IDistanceSnapProvider))]
|
[Cached(typeof(IDistanceSnapProvider))]
|
||||||
private readonly SnapProvider snapProvider = new SnapProvider();
|
private readonly SnapProvider snapProvider = new SnapProvider();
|
||||||
|
|
||||||
private readonly TestOsuDistanceSnapGrid grid;
|
private TestOsuDistanceSnapGrid grid;
|
||||||
|
|
||||||
public TestSceneOsuDistanceSnapGrid()
|
public TestSceneOsuDistanceSnapGrid()
|
||||||
{
|
{
|
||||||
editorBeatmap = new EditorBeatmap<OsuHitObject>(new OsuBeatmap());
|
editorBeatmap = new EditorBeatmap<OsuHitObject>(new OsuBeatmap());
|
||||||
|
}
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Setup() => Schedule(() =>
|
||||||
|
{
|
||||||
|
editorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1;
|
||||||
|
editorBeatmap.ControlPointInfo.Clear();
|
||||||
|
editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length });
|
||||||
|
|
||||||
Children = new Drawable[]
|
Children = new Drawable[]
|
||||||
{
|
{
|
||||||
@ -58,14 +66,6 @@ namespace osu.Game.Rulesets.Osu.Tests
|
|||||||
grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }),
|
grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }),
|
||||||
new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }
|
new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
[SetUp]
|
|
||||||
public void Setup() => Schedule(() =>
|
|
||||||
{
|
|
||||||
editorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1;
|
|
||||||
editorBeatmap.ControlPointInfo.Clear();
|
|
||||||
editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
[TestCase(1)]
|
[TestCase(1)]
|
||||||
@ -102,6 +102,27 @@ namespace osu.Game.Rulesets.Osu.Tests
|
|||||||
assertSnappedDistance((float)beat_length * 2);
|
assertSnappedDistance((float)beat_length * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestLimitedDistance()
|
||||||
|
{
|
||||||
|
AddStep("create limited grid", () =>
|
||||||
|
{
|
||||||
|
Children = new Drawable[]
|
||||||
|
{
|
||||||
|
new Box
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Colour = Color4.SlateGray
|
||||||
|
},
|
||||||
|
grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }, new HitCircle { StartTime = 200 }),
|
||||||
|
new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
AddStep("move mouse outside grid", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 3f)));
|
||||||
|
assertSnappedDistance((float)beat_length * 2);
|
||||||
|
}
|
||||||
|
|
||||||
private void assertSnappedDistance(float expectedDistance) => AddAssert($"snap distance = {expectedDistance}", () =>
|
private void assertSnappedDistance(float expectedDistance) => AddAssert($"snap distance = {expectedDistance}", () =>
|
||||||
{
|
{
|
||||||
Vector2 snappedPosition = grid.GetSnappedPosition(grid.ToLocalSpace(InputManager.CurrentState.Mouse.Position)).position;
|
Vector2 snappedPosition = grid.GetSnappedPosition(grid.ToLocalSpace(InputManager.CurrentState.Mouse.Position)).position;
|
||||||
@ -152,8 +173,8 @@ namespace osu.Game.Rulesets.Osu.Tests
|
|||||||
{
|
{
|
||||||
public new float DistanceSpacing => base.DistanceSpacing;
|
public new float DistanceSpacing => base.DistanceSpacing;
|
||||||
|
|
||||||
public TestOsuDistanceSnapGrid(OsuHitObject hitObject)
|
public TestOsuDistanceSnapGrid(OsuHitObject hitObject, OsuHitObject nextHitObject = null)
|
||||||
: base(hitObject)
|
: base(hitObject, nextHitObject)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,9 +185,9 @@ namespace osu.Game.Rulesets.Osu.Tests
|
|||||||
|
|
||||||
public float GetBeatSnapDistanceAt(double referenceTime) => (float)beat_length;
|
public float GetBeatSnapDistanceAt(double referenceTime) => (float)beat_length;
|
||||||
|
|
||||||
public float DurationToDistance(double referenceTime, double duration) => 0;
|
public float DurationToDistance(double referenceTime, double duration) => (float)duration;
|
||||||
|
|
||||||
public double DistanceToDuration(double referenceTime, float distance) => 0;
|
public double DistanceToDuration(double referenceTime, float distance) => distance;
|
||||||
|
|
||||||
public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0;
|
public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0;
|
||||||
|
|
||||||
|
@ -8,8 +8,8 @@ namespace osu.Game.Rulesets.Osu.Edit
|
|||||||
{
|
{
|
||||||
public class OsuDistanceSnapGrid : CircularDistanceSnapGrid
|
public class OsuDistanceSnapGrid : CircularDistanceSnapGrid
|
||||||
{
|
{
|
||||||
public OsuDistanceSnapGrid(OsuHitObject hitObject)
|
public OsuDistanceSnapGrid(OsuHitObject hitObject, OsuHitObject nextHitObject)
|
||||||
: base(hitObject, hitObject.StackedEndPosition)
|
: base(hitObject, nextHitObject, hitObject.StackedEndPosition)
|
||||||
{
|
{
|
||||||
Masking = true;
|
Masking = true;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
// See the LICENCE file in the repository root for full licence text.
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using osu.Game.Beatmaps;
|
using osu.Game.Beatmaps;
|
||||||
@ -60,25 +61,40 @@ namespace osu.Game.Rulesets.Osu.Edit
|
|||||||
var objects = selectedHitObjects.ToList();
|
var objects = selectedHitObjects.ToList();
|
||||||
|
|
||||||
if (objects.Count == 0)
|
if (objects.Count == 0)
|
||||||
|
return createGrid(h => h.StartTime <= EditorClock.CurrentTime);
|
||||||
|
|
||||||
|
double minTime = objects.Min(h => h.StartTime);
|
||||||
|
return createGrid(h => h.StartTime < minTime, objects.Count + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a grid from the last <see cref="HitObject"/> matching a predicate to a target <see cref="HitObject"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceSelector">A predicate that matches <see cref="HitObject"/>s where the grid can start from.
|
||||||
|
/// Only the last <see cref="HitObject"/> matching the predicate is used.</param>
|
||||||
|
/// <param name="targetOffset">An offset from the <see cref="HitObject"/> selected via <paramref name="sourceSelector"/> at which the grid should stop.</param>
|
||||||
|
/// <returns>The <see cref="OsuDistanceSnapGrid"/> from a selected <see cref="HitObject"/> to a target <see cref="HitObject"/>.</returns>
|
||||||
|
private OsuDistanceSnapGrid createGrid(Func<HitObject, bool> sourceSelector, int targetOffset = 1)
|
||||||
|
{
|
||||||
|
if (targetOffset < 1) throw new ArgumentOutOfRangeException(nameof(targetOffset));
|
||||||
|
|
||||||
|
int sourceIndex = -1;
|
||||||
|
|
||||||
|
for (int i = 0; i < EditorBeatmap.HitObjects.Count; i++)
|
||||||
{
|
{
|
||||||
var lastObject = EditorBeatmap.HitObjects.LastOrDefault(h => h.StartTime <= EditorClock.CurrentTime);
|
if (!sourceSelector(EditorBeatmap.HitObjects[i]))
|
||||||
|
break;
|
||||||
|
|
||||||
if (lastObject == null)
|
sourceIndex = i;
|
||||||
return null;
|
|
||||||
|
|
||||||
return new OsuDistanceSnapGrid(lastObject);
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
double minTime = objects.Min(h => h.StartTime);
|
|
||||||
|
|
||||||
var lastObject = EditorBeatmap.HitObjects.LastOrDefault(h => h.StartTime < minTime);
|
if (sourceIndex == -1)
|
||||||
|
return null;
|
||||||
|
|
||||||
if (lastObject == null)
|
OsuHitObject sourceObject = EditorBeatmap.HitObjects[sourceIndex];
|
||||||
return null;
|
OsuHitObject targetObject = sourceIndex + targetOffset < EditorBeatmap.HitObjects.Count ? EditorBeatmap.HitObjects[sourceIndex + targetOffset] : null;
|
||||||
|
|
||||||
return new OsuDistanceSnapGrid(lastObject);
|
return new OsuDistanceSnapGrid(sourceObject, targetObject);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,11 @@ namespace osu.Game.Tests.Visual.Editor
|
|||||||
{
|
{
|
||||||
editorBeatmap = new EditorBeatmap<OsuHitObject>(new OsuBeatmap());
|
editorBeatmap = new EditorBeatmap<OsuHitObject>(new OsuBeatmap());
|
||||||
editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length });
|
editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length });
|
||||||
|
}
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Setup() => Schedule(() =>
|
||||||
|
{
|
||||||
Children = new Drawable[]
|
Children = new Drawable[]
|
||||||
{
|
{
|
||||||
new Box
|
new Box
|
||||||
@ -42,7 +46,7 @@ namespace osu.Game.Tests.Visual.Editor
|
|||||||
},
|
},
|
||||||
new TestDistanceSnapGrid(new HitObject(), grid_position)
|
new TestDistanceSnapGrid(new HitObject(), grid_position)
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
[TestCase(1)]
|
[TestCase(1)]
|
||||||
[TestCase(2)]
|
[TestCase(2)]
|
||||||
@ -57,12 +61,29 @@ namespace osu.Game.Tests.Visual.Editor
|
|||||||
AddStep($"set beat divisor = {divisor}", () => BeatDivisor.Value = divisor);
|
AddStep($"set beat divisor = {divisor}", () => BeatDivisor.Value = divisor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestLimitedDistance()
|
||||||
|
{
|
||||||
|
AddStep("create limited grid", () =>
|
||||||
|
{
|
||||||
|
Children = new Drawable[]
|
||||||
|
{
|
||||||
|
new Box
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Colour = Color4.SlateGray
|
||||||
|
},
|
||||||
|
new TestDistanceSnapGrid(new HitObject(), grid_position, new HitObject { StartTime = 100 })
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private class TestDistanceSnapGrid : DistanceSnapGrid
|
private class TestDistanceSnapGrid : DistanceSnapGrid
|
||||||
{
|
{
|
||||||
public new float DistanceSpacing => base.DistanceSpacing;
|
public new float DistanceSpacing => base.DistanceSpacing;
|
||||||
|
|
||||||
public TestDistanceSnapGrid(HitObject hitObject, Vector2 centrePosition)
|
public TestDistanceSnapGrid(HitObject hitObject, Vector2 centrePosition, HitObject nextHitObject = null)
|
||||||
: base(hitObject, centrePosition)
|
: base(hitObject, nextHitObject, centrePosition)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +98,7 @@ namespace osu.Game.Tests.Visual.Editor
|
|||||||
|
|
||||||
int beatIndex = 0;
|
int beatIndex = 0;
|
||||||
|
|
||||||
for (float s = centrePosition.X + DistanceSpacing; s <= DrawWidth; s += DistanceSpacing, beatIndex++)
|
for (float s = centrePosition.X + DistanceSpacing; s <= DrawWidth && beatIndex < MaxIntervals; s += DistanceSpacing, beatIndex++)
|
||||||
{
|
{
|
||||||
AddInternal(new Circle
|
AddInternal(new Circle
|
||||||
{
|
{
|
||||||
@ -90,7 +111,7 @@ namespace osu.Game.Tests.Visual.Editor
|
|||||||
|
|
||||||
beatIndex = 0;
|
beatIndex = 0;
|
||||||
|
|
||||||
for (float s = centrePosition.X - DistanceSpacing; s >= 0; s -= DistanceSpacing, beatIndex++)
|
for (float s = centrePosition.X - DistanceSpacing; s >= 0 && beatIndex < MaxIntervals; s -= DistanceSpacing, beatIndex++)
|
||||||
{
|
{
|
||||||
AddInternal(new Circle
|
AddInternal(new Circle
|
||||||
{
|
{
|
||||||
@ -103,7 +124,7 @@ namespace osu.Game.Tests.Visual.Editor
|
|||||||
|
|
||||||
beatIndex = 0;
|
beatIndex = 0;
|
||||||
|
|
||||||
for (float s = centrePosition.Y + DistanceSpacing; s <= DrawHeight; s += DistanceSpacing, beatIndex++)
|
for (float s = centrePosition.Y + DistanceSpacing; s <= DrawHeight && beatIndex < MaxIntervals; s += DistanceSpacing, beatIndex++)
|
||||||
{
|
{
|
||||||
AddInternal(new Circle
|
AddInternal(new Circle
|
||||||
{
|
{
|
||||||
@ -116,7 +137,7 @@ namespace osu.Game.Tests.Visual.Editor
|
|||||||
|
|
||||||
beatIndex = 0;
|
beatIndex = 0;
|
||||||
|
|
||||||
for (float s = centrePosition.Y - DistanceSpacing; s >= 0; s -= DistanceSpacing, beatIndex++)
|
for (float s = centrePosition.Y - DistanceSpacing; s >= 0 && beatIndex < MaxIntervals; s -= DistanceSpacing, beatIndex++)
|
||||||
{
|
{
|
||||||
AddInternal(new Circle
|
AddInternal(new Circle
|
||||||
{
|
{
|
||||||
@ -138,9 +159,9 @@ namespace osu.Game.Tests.Visual.Editor
|
|||||||
|
|
||||||
public float GetBeatSnapDistanceAt(double referenceTime) => 10;
|
public float GetBeatSnapDistanceAt(double referenceTime) => 10;
|
||||||
|
|
||||||
public float DurationToDistance(double referenceTime, double duration) => 0;
|
public float DurationToDistance(double referenceTime, double duration) => (float)duration;
|
||||||
|
|
||||||
public double DistanceToDuration(double referenceTime, float distance) => 0;
|
public double DistanceToDuration(double referenceTime, float distance) => distance;
|
||||||
|
|
||||||
public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0;
|
public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0;
|
||||||
|
|
||||||
|
@ -12,8 +12,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
|||||||
{
|
{
|
||||||
public abstract class CircularDistanceSnapGrid : DistanceSnapGrid
|
public abstract class CircularDistanceSnapGrid : DistanceSnapGrid
|
||||||
{
|
{
|
||||||
protected CircularDistanceSnapGrid(HitObject hitObject, Vector2 centrePosition)
|
protected CircularDistanceSnapGrid(HitObject hitObject, HitObject nextHitObject, Vector2 centrePosition)
|
||||||
: base(hitObject, centrePosition)
|
: base(hitObject, nextHitObject, centrePosition)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
|||||||
float dx = Math.Max(centrePosition.X, DrawWidth - centrePosition.X);
|
float dx = Math.Max(centrePosition.X, DrawWidth - centrePosition.X);
|
||||||
float dy = Math.Max(centrePosition.Y, DrawHeight - centrePosition.Y);
|
float dy = Math.Max(centrePosition.Y, DrawHeight - centrePosition.Y);
|
||||||
float maxDistance = new Vector2(dx, dy).Length;
|
float maxDistance = new Vector2(dx, dy).Length;
|
||||||
int requiredCircles = (int)(maxDistance / DistanceSpacing);
|
int requiredCircles = Math.Min(MaxIntervals, (int)(maxDistance / DistanceSpacing));
|
||||||
|
|
||||||
for (int i = 0; i < requiredCircles; i++)
|
for (int i = 0; i < requiredCircles; i++)
|
||||||
{
|
{
|
||||||
@ -65,15 +65,17 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
|||||||
|
|
||||||
public override (Vector2 position, double time) GetSnappedPosition(Vector2 position)
|
public override (Vector2 position, double time) GetSnappedPosition(Vector2 position)
|
||||||
{
|
{
|
||||||
Vector2 direction = position - CentrePosition;
|
if (MaxIntervals == 0)
|
||||||
|
return (CentrePosition, StartTime);
|
||||||
|
|
||||||
|
Vector2 direction = position - CentrePosition;
|
||||||
if (direction == Vector2.Zero)
|
if (direction == Vector2.Zero)
|
||||||
direction = new Vector2(0.001f, 0.001f);
|
direction = new Vector2(0.001f, 0.001f);
|
||||||
|
|
||||||
float distance = direction.Length;
|
float distance = direction.Length;
|
||||||
|
|
||||||
float radius = DistanceSpacing;
|
float radius = DistanceSpacing;
|
||||||
int radialCount = Math.Max(1, (int)Math.Round(distance / radius));
|
int radialCount = MathHelper.Clamp((int)Math.Round(distance / radius), 1, MaxIntervals);
|
||||||
|
|
||||||
Vector2 normalisedDirection = direction * new Vector2(1f / distance);
|
Vector2 normalisedDirection = direction * new Vector2(1f / distance);
|
||||||
Vector2 snappedPosition = CentrePosition + normalisedDirection * radialCount * radius;
|
Vector2 snappedPosition = CentrePosition + normalisedDirection * radialCount * radius;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
// See the LICENCE file in the repository root for full licence text.
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using JetBrains.Annotations;
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Caching;
|
using osu.Framework.Caching;
|
||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
@ -29,6 +30,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
protected double StartTime { get; private set; }
|
protected double StartTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The maximum number of distance snapping intervals allowed.
|
||||||
|
/// </summary>
|
||||||
|
protected int MaxIntervals { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The position which the grid is centred on.
|
/// The position which the grid is centred on.
|
||||||
/// The first beat snapping tick is located at <see cref="CentrePosition"/> + <see cref="DistanceSpacing"/> in the desired direction.
|
/// The first beat snapping tick is located at <see cref="CentrePosition"/> + <see cref="DistanceSpacing"/> in the desired direction.
|
||||||
@ -49,12 +55,15 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
|||||||
|
|
||||||
private readonly Cached gridCache = new Cached();
|
private readonly Cached gridCache = new Cached();
|
||||||
private readonly HitObject hitObject;
|
private readonly HitObject hitObject;
|
||||||
|
private readonly HitObject nextHitObject;
|
||||||
|
|
||||||
protected DistanceSnapGrid(HitObject hitObject, Vector2 centrePosition)
|
protected DistanceSnapGrid(HitObject hitObject, [CanBeNull] HitObject nextHitObject, Vector2 centrePosition)
|
||||||
{
|
{
|
||||||
this.hitObject = hitObject;
|
this.hitObject = hitObject;
|
||||||
|
this.nextHitObject = nextHitObject;
|
||||||
|
|
||||||
CentrePosition = centrePosition;
|
CentrePosition = centrePosition;
|
||||||
|
|
||||||
RelativeSizeAxes = Axes.Both;
|
RelativeSizeAxes = Axes.Both;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +83,16 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
|||||||
private void updateSpacing()
|
private void updateSpacing()
|
||||||
{
|
{
|
||||||
DistanceSpacing = SnapProvider.GetBeatSnapDistanceAt(StartTime);
|
DistanceSpacing = SnapProvider.GetBeatSnapDistanceAt(StartTime);
|
||||||
|
|
||||||
|
if (nextHitObject == null)
|
||||||
|
MaxIntervals = int.MaxValue;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// +1 is added since a snapped hitobject may have its start time slightly less than the snapped time due to floating point errors
|
||||||
|
double maxDuration = nextHitObject.StartTime - StartTime + 1;
|
||||||
|
MaxIntervals = (int)(maxDuration / SnapProvider.DistanceToDuration(StartTime, DistanceSpacing));
|
||||||
|
}
|
||||||
|
|
||||||
gridCache.Invalidate();
|
gridCache.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user