1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 18:47:27 +08:00

Merge branch 'master' into osu-diff-calc-max-combo

This commit is contained in:
Dean Herbert 2022-04-04 14:20:44 +09:00 committed by GitHub
commit ee8451c8ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1368 additions and 481 deletions

2
.git-blame-ignore-revs Normal file
View File

@ -0,0 +1,2 @@
# Normalize all the line endings
32a74f95a5c80a0ed18e693f13a47522099df5c3

View File

@ -9,7 +9,7 @@ body:
Important to note that your issue may have already been reported before. Please check:
- Pinned issues, at the top of https://github.com/ppy/osu/issues.
- Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0).
- And most importantly, search for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful.
- And most importantly, search for your issue both in the [issue listing](https://github.com/ppy/osu/issues) and the [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a). If you find that it already exists, respond with a reaction or add any further information that may be helpful.
- type: dropdown
attributes:
@ -48,20 +48,28 @@ body:
Attaching log files is required for every reported bug. See instructions below on how to find them.
**Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead.
### Desktop platforms
If the game has not yet been closed since you found the bug:
1. Head on to game settings and click on "Open osu! folder"
2. Then open the `logs` folder located there
**Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead.
The default places to find the logs are as follows:
The default places to find the logs on desktop platforms are as follows:
- `%AppData%/osu/logs` *on Windows*
- `~/.local/share/osu/logs` *on Linux & macOS*
- `Android/data/sh.ppy.osulazer/files/logs` *on Android*
- *On iOS*, they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
If you have selected a custom location for the game files, you can find the `logs` folder there.
### Mobile platforms
The places to find the logs on mobile platforms are as follows:
- *On Android*, navigate to `Android/data/sh.ppy.osulazer/files/logs` using a file browser app.
- *On iOS*, connect your device to a PC and copy the `logs` directory from the app's document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
---
After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below.
- type: textarea

View File

@ -6,18 +6,18 @@ using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Tests.Mods
{
public class TestSceneOsuModAimAssist : OsuModTestScene
public class TestSceneOsuModMagnetised : OsuModTestScene
{
[TestCase(0.1f)]
[TestCase(0.5f)]
[TestCase(1)]
public void TestAimAssist(float strength)
public void TestMagnetised(float strength)
{
CreateModTest(new ModTestData
{
Mod = new OsuModAimAssist
Mod = new OsuModMagnetised
{
AssistStrength = { Value = strength },
AttractionStrength = { Value = strength },
},
PassCondition = () => true,
Autoplay = false,

View File

@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Automation;
public override string Description => @"Automatic cursor movement - just follow the rhythm.";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModAimAssist) };
public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised) };
public bool PerformFail() => false;

View File

@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModAutoplay : ModAutoplay
{
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAimAssist), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });

View File

@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModCinema : ModCinema<OsuHitObject>
{
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAimAssist), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" });

View File

@ -16,20 +16,20 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Mods
{
internal class OsuModAimAssist : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>
internal class OsuModMagnetised : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>
{
public override string Name => "Aim Assist";
public override string Acronym => "AA";
public override IconUsage? Icon => FontAwesome.Solid.MousePointer;
public override string Name => "Magnetised";
public override string Acronym => "MG";
public override IconUsage? Icon => FontAwesome.Solid.Magnet;
public override ModType Type => ModType.Fun;
public override string Description => "No need to chase the circle the circle chases you!";
public override string Description => "No need to chase the circles your cursor is a magnet!";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) };
private IFrameStableClock gameplayClock;
[SettingSource("Assist strength", "How much this mod will assist you.", 0)]
public BindableFloat AssistStrength { get; } = new BindableFloat(0.5f)
[SettingSource("Attraction strength", "How strong the pull is.", 0)]
public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f)
{
Precision = 0.05f,
MinValue = 0.05f,
@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.Mods
private void easeTo(DrawableHitObject hitObject, Vector2 destination)
{
double dampLength = Interpolation.Lerp(3000, 40, AssistStrength.Value);
double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value);
float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime);
float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime);

View File

@ -4,19 +4,14 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Osu.Utils;
using osuTK;
namespace osu.Game.Rulesets.Osu.Mods
{
@ -28,12 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override string Description => "It never gets boring!";
private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast;
private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
/// <summary>
/// Number of previous hitobjects to be shifted together when another object is being moved.
/// </summary>
private const int preceding_hitobjects_to_shift = 10;
private Random? rng;
@ -42,330 +31,33 @@ namespace osu.Game.Rulesets.Osu.Mods
if (!(beatmap is OsuBeatmap osuBeatmap))
return;
var hitObjects = osuBeatmap.HitObjects;
Seed.Value ??= RNG.Next();
rng = new Random((int)Seed.Value);
var randomObjects = randomiseObjects(hitObjects);
var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects);
applyRandomisation(hitObjects, randomObjects);
}
/// <summary>
/// Randomise the position of each hit object and return a list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.
/// </summary>
/// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to have their positions randomised.</param>
/// <returns>A list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.</returns>
private List<RandomObjectInfo> randomiseObjects(IEnumerable<OsuHitObject> hitObjects)
{
Debug.Assert(rng != null, $"{nameof(ApplyToBeatmap)} was not called before randomising objects");
var randomObjects = new List<RandomObjectInfo>();
RandomObjectInfo? previous = null;
float rateOfChangeMultiplier = 0;
foreach (OsuHitObject hitObject in hitObjects)
foreach (var positionInfo in positionInfos)
{
var current = new RandomObjectInfo(hitObject);
randomObjects.Add(current);
// rateOfChangeMultiplier only changes every 5 iterations in a combo
// to prevent shaky-line-shaped streams
if (hitObject.IndexInCurrentCombo % 5 == 0)
if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0)
rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
if (previous == null)
if (positionInfo == positionInfos.First())
{
current.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2);
current.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2);
positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
}
else
{
current.DistanceFromPrevious = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal);
// The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object)
// is proportional to the distance between the last and the current hit object
// to allow jumps and prevent too sharp turns during streams.
// Allow maximum jump angle when jump distance is more than half of playfield diagonal length
current.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, current.DistanceFromPrevious / (playfield_diagonal * 0.5f));
positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f));
}
previous = current;
}
return randomObjects;
}
/// <summary>
/// Reposition the hit objects according to the information in <paramref name="randomObjects"/>.
/// </summary>
/// <param name="hitObjects">The hit objects to be repositioned.</param>
/// <param name="randomObjects">A list of <see cref="RandomObjectInfo"/> describing how each hit object should be placed.</param>
private void applyRandomisation(IReadOnlyList<OsuHitObject> hitObjects, IReadOnlyList<RandomObjectInfo> randomObjects)
{
RandomObjectInfo? previous = null;
for (int i = 0; i < hitObjects.Count; i++)
{
var hitObject = hitObjects[i];
var current = randomObjects[i];
if (hitObject is Spinner)
{
previous = null;
continue;
}
computeRandomisedPosition(current, previous, i > 1 ? randomObjects[i - 2] : null);
// Move hit objects back into the playfield if they are outside of it
Vector2 shift = Vector2.Zero;
switch (hitObject)
{
case HitCircle circle:
shift = clampHitCircleToPlayfield(circle, current);
break;
case Slider slider:
shift = clampSliderToPlayfield(slider, current);
break;
}
if (shift != Vector2.Zero)
{
var toBeShifted = new List<OsuHitObject>();
for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
{
// only shift hit circles
if (!(hitObjects[j] is HitCircle)) break;
toBeShifted.Add(hitObjects[j]);
}
if (toBeShifted.Count > 0)
applyDecreasingShift(toBeShifted, shift);
}
previous = current;
}
}
/// <summary>
/// Compute the randomised position of a hit object while attempting to keep it inside the playfield.
/// </summary>
/// <param name="current">The <see cref="RandomObjectInfo"/> representing the hit object to have the randomised position computed for.</param>
/// <param name="previous">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the current one.</param>
/// <param name="beforePrevious">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
private void computeRandomisedPosition(RandomObjectInfo current, RandomObjectInfo? previous, RandomObjectInfo? beforePrevious)
{
float previousAbsoluteAngle = 0f;
if (previous != null)
{
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
}
float absoluteAngle = previousAbsoluteAngle + current.RelativeAngle;
var posRelativeToPrev = new Vector2(
current.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
);
Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre;
posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
current.PositionRandomised = lastEndPosition + posRelativeToPrev;
}
/// <summary>
/// Move the randomised position of a hit circle so that it fits inside the playfield.
/// </summary>
/// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
private Vector2 clampHitCircleToPlayfield(HitCircle circle, RandomObjectInfo objectInfo)
{
var previousPosition = objectInfo.PositionRandomised;
objectInfo.EndPositionRandomised = objectInfo.PositionRandomised = clampToPlayfieldWithPadding(
objectInfo.PositionRandomised,
(float)circle.Radius
);
circle.Position = objectInfo.PositionRandomised;
return objectInfo.PositionRandomised - previousPosition;
}
/// <summary>
/// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
/// </summary>
/// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo)
{
var possibleMovementBounds = calculatePossibleMovementBounds(slider);
var previousPosition = objectInfo.PositionRandomised;
// Clamp slider position to the placement area
// If the slider is larger than the playfield, force it to stay at the original position
float newX = possibleMovementBounds.Width < 0
? objectInfo.PositionOriginal.X
: Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
float newY = possibleMovementBounds.Height < 0
? objectInfo.PositionOriginal.Y
: Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY);
objectInfo.EndPositionRandomised = slider.EndPosition;
shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal);
return objectInfo.PositionRandomised - previousPosition;
}
/// <summary>
/// Decreasingly shift a list of <see cref="OsuHitObject"/>s by a specified amount.
/// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount.
/// </summary>
/// <param name="hitObjects">The list of hit objects to be shifted.</param>
/// <param name="shift">The amount to be shifted.</param>
private void applyDecreasingShift(IList<OsuHitObject> hitObjects, Vector2 shift)
{
for (int i = 0; i < hitObjects.Count; i++)
{
var hitObject = hitObjects[i];
// The first object is shifted by a vector slightly smaller than shift
// The last object is shifted by a vector slightly larger than zero
Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1));
hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius);
}
}
/// <summary>
/// Calculates a <see cref="RectangleF"/> which contains all of the possible movements of the slider (in relative X/Y coordinates)
/// such that the entire slider is inside the playfield.
/// </summary>
/// <remarks>
/// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
/// </remarks>
private RectangleF calculatePossibleMovementBounds(Slider slider)
{
var pathPositions = new List<Vector2>();
slider.Path.GetPathToProgress(pathPositions, 0, 1);
float minX = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
float minY = float.PositiveInfinity;
float maxY = float.NegativeInfinity;
// Compute the bounding box of the slider.
foreach (var pos in pathPositions)
{
minX = MathF.Min(minX, pos.X);
maxX = MathF.Max(maxX, pos.X);
minY = MathF.Min(minY, pos.Y);
maxY = MathF.Max(maxY, pos.Y);
}
// Take the circle radius into account.
float radius = (float)slider.Radius;
minX -= radius;
minY -= radius;
maxX += radius;
maxY += radius;
// Given the bounding box of the slider (via min/max X/Y),
// the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right),
// and the amount that it can move to the right is WIDTH - maxX.
// Same calculation applies for the Y axis.
float left = -minX;
float right = OsuPlayfield.BASE_SIZE.X - maxX;
float top = -minY;
float bottom = OsuPlayfield.BASE_SIZE.Y - maxY;
return new RectangleF(left, top, right - left, bottom - top);
}
/// <summary>
/// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
/// </summary>
/// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
/// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
private void shiftNestedObjects(Slider slider, Vector2 shift)
{
foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
{
if (!(hitObject is OsuHitObject osuHitObject))
continue;
osuHitObject.Position += shift;
}
}
/// <summary>
/// Clamp a position to playfield, keeping a specified distance from the edges.
/// </summary>
/// <param name="position">The position to be clamped.</param>
/// <param name="padding">The minimum distance allowed from playfield edges.</param>
/// <returns>The clamped position.</returns>
private Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
{
return new Vector2(
Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding),
Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding)
);
}
private class RandomObjectInfo
{
/// <summary>
/// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
/// </summary>
/// <remarks>
/// <see cref="RelativeAngle"/> of the first hit object in a beatmap represents the absolute angle from playfield center to the object.
/// </remarks>
/// <example>
/// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
/// the previous object to reach this one.
/// </example>
public float RelativeAngle { get; set; }
/// <summary>
/// The jump distance from the previous hit object to this one.
/// </summary>
/// <remarks>
/// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
/// </remarks>
public float DistanceFromPrevious { get; set; }
public Vector2 PositionOriginal { get; }
public Vector2 PositionRandomised { get; set; }
public Vector2 EndPositionOriginal { get; }
public Vector2 EndPositionRandomised { get; set; }
public OsuHitObject HitObject { get; }
public RandomObjectInfo(OsuHitObject hitObject)
{
PositionRandomised = PositionOriginal = hitObject.Position;
EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition;
HitObject = hitObject;
}
osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos);
}
}
}

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToPlayer
{
public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things.";
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModAimAssist) }).ToArray();
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised) }).ToArray();
/// <summary>
/// How early before a hitobject's start time to trigger a hit.

View File

@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Fun;
public override string Description => "Everything rotates. EVERYTHING.";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModAimAssist) };
public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised) };
private float theta;

View File

@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Fun;
public override string Description => "They just won't stay still...";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModAimAssist) };
public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised) };
private const int wiggle_duration = 90; // (ms) Higher = fewer wiggles
private const int wiggle_strength = 10; // Higher = stronger wiggles

View File

@ -195,7 +195,7 @@ namespace osu.Game.Rulesets.Osu
new OsuModApproachDifferent(),
new OsuModMuted(),
new OsuModNoScope(),
new OsuModAimAssist(),
new OsuModMagnetised(),
new ModAdaptiveSpeed()
};

View File

@ -11,7 +11,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Utils
{
public static class OsuHitObjectGenerationUtils
public static partial class OsuHitObjectGenerationUtils
{
// The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle.
// The closer the hit objects draw to the border, the sharper the turn

View File

@ -0,0 +1,340 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics.Primitives;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osuTK;
#nullable enable
namespace osu.Game.Rulesets.Osu.Utils
{
public static partial class OsuHitObjectGenerationUtils
{
/// <summary>
/// Number of previous hitobjects to be shifted together when an object is being moved.
/// </summary>
private const int preceding_hitobjects_to_shift = 10;
private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
/// <summary>
/// Generate a list of <see cref="ObjectPositionInfo"/>s containing information for how the given list of
/// <see cref="OsuHitObject"/>s are positioned.
/// </summary>
/// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to process.</param>
/// <returns>A list of <see cref="ObjectPositionInfo"/>s describing how each hit object is positioned relative to the previous one.</returns>
public static List<ObjectPositionInfo> GeneratePositionInfos(IEnumerable<OsuHitObject> hitObjects)
{
var positionInfos = new List<ObjectPositionInfo>();
Vector2 previousPosition = playfield_centre;
float previousAngle = 0;
foreach (OsuHitObject hitObject in hitObjects)
{
Vector2 relativePosition = hitObject.Position - previousPosition;
float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
float relativeAngle = absoluteAngle - previousAngle;
positionInfos.Add(new ObjectPositionInfo(hitObject)
{
RelativeAngle = relativeAngle,
DistanceFromPrevious = relativePosition.Length
});
previousPosition = hitObject.EndPosition;
previousAngle = absoluteAngle;
}
return positionInfos;
}
/// <summary>
/// Reposition the hit objects according to the information in <paramref name="objectPositionInfos"/>.
/// </summary>
/// <param name="objectPositionInfos">Position information for each hit object.</param>
/// <returns>The repositioned hit objects.</returns>
public static List<OsuHitObject> RepositionHitObjects(IEnumerable<ObjectPositionInfo> objectPositionInfos)
{
List<WorkingObject> workingObjects = objectPositionInfos.Select(o => new WorkingObject(o)).ToList();
WorkingObject? previous = null;
for (int i = 0; i < workingObjects.Count; i++)
{
var current = workingObjects[i];
var hitObject = current.HitObject;
if (hitObject is Spinner)
{
previous = null;
continue;
}
computeModifiedPosition(current, previous, i > 1 ? workingObjects[i - 2] : null);
// Move hit objects back into the playfield if they are outside of it
Vector2 shift = Vector2.Zero;
switch (hitObject)
{
case HitCircle _:
shift = clampHitCircleToPlayfield(current);
break;
case Slider _:
shift = clampSliderToPlayfield(current);
break;
}
if (shift != Vector2.Zero)
{
var toBeShifted = new List<OsuHitObject>();
for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
{
// only shift hit circles
if (!(workingObjects[j].HitObject is HitCircle)) break;
toBeShifted.Add(workingObjects[j].HitObject);
}
if (toBeShifted.Count > 0)
applyDecreasingShift(toBeShifted, shift);
}
previous = current;
}
return workingObjects.Select(p => p.HitObject).ToList();
}
/// <summary>
/// Compute the modified position of a hit object while attempting to keep it inside the playfield.
/// </summary>
/// <param name="current">The <see cref="WorkingObject"/> representing the hit object to have the modified position computed for.</param>
/// <param name="previous">The <see cref="WorkingObject"/> representing the hit object immediately preceding the current one.</param>
/// <param name="beforePrevious">The <see cref="WorkingObject"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
private static void computeModifiedPosition(WorkingObject current, WorkingObject? previous, WorkingObject? beforePrevious)
{
float previousAbsoluteAngle = 0f;
if (previous != null)
{
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
}
float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle;
var posRelativeToPrev = new Vector2(
current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
);
Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre;
posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
current.PositionModified = lastEndPosition + posRelativeToPrev;
}
/// <summary>
/// Move the modified position of a <see cref="HitCircle"/> so that it fits inside the playfield.
/// </summary>
/// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
private static Vector2 clampHitCircleToPlayfield(WorkingObject workingObject)
{
var previousPosition = workingObject.PositionModified;
workingObject.EndPositionModified = workingObject.PositionModified = clampToPlayfieldWithPadding(
workingObject.PositionModified,
(float)workingObject.HitObject.Radius
);
workingObject.HitObject.Position = workingObject.PositionModified;
return workingObject.PositionModified - previousPosition;
}
/// <summary>
/// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
/// </summary>
/// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
private static Vector2 clampSliderToPlayfield(WorkingObject workingObject)
{
var slider = (Slider)workingObject.HitObject;
var possibleMovementBounds = calculatePossibleMovementBounds(slider);
var previousPosition = workingObject.PositionModified;
// Clamp slider position to the placement area
// If the slider is larger than the playfield, force it to stay at the original position
float newX = possibleMovementBounds.Width < 0
? workingObject.PositionOriginal.X
: Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
float newY = possibleMovementBounds.Height < 0
? workingObject.PositionOriginal.Y
: Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
slider.Position = workingObject.PositionModified = new Vector2(newX, newY);
workingObject.EndPositionModified = slider.EndPosition;
shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal);
return workingObject.PositionModified - previousPosition;
}
/// <summary>
/// Decreasingly shift a list of <see cref="OsuHitObject"/>s by a specified amount.
/// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount.
/// </summary>
/// <param name="hitObjects">The list of hit objects to be shifted.</param>
/// <param name="shift">The amount to be shifted.</param>
private static void applyDecreasingShift(IList<OsuHitObject> hitObjects, Vector2 shift)
{
for (int i = 0; i < hitObjects.Count; i++)
{
var hitObject = hitObjects[i];
// The first object is shifted by a vector slightly smaller than shift
// The last object is shifted by a vector slightly larger than zero
Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1));
hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius);
}
}
/// <summary>
/// Calculates a <see cref="RectangleF"/> which contains all of the possible movements of the slider (in relative X/Y coordinates)
/// such that the entire slider is inside the playfield.
/// </summary>
/// <remarks>
/// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
/// </remarks>
private static RectangleF calculatePossibleMovementBounds(Slider slider)
{
var pathPositions = new List<Vector2>();
slider.Path.GetPathToProgress(pathPositions, 0, 1);
float minX = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
float minY = float.PositiveInfinity;
float maxY = float.NegativeInfinity;
// Compute the bounding box of the slider.
foreach (var pos in pathPositions)
{
minX = MathF.Min(minX, pos.X);
maxX = MathF.Max(maxX, pos.X);
minY = MathF.Min(minY, pos.Y);
maxY = MathF.Max(maxY, pos.Y);
}
// Take the circle radius into account.
float radius = (float)slider.Radius;
minX -= radius;
minY -= radius;
maxX += radius;
maxY += radius;
// Given the bounding box of the slider (via min/max X/Y),
// the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right),
// and the amount that it can move to the right is WIDTH - maxX.
// Same calculation applies for the Y axis.
float left = -minX;
float right = OsuPlayfield.BASE_SIZE.X - maxX;
float top = -minY;
float bottom = OsuPlayfield.BASE_SIZE.Y - maxY;
return new RectangleF(left, top, right - left, bottom - top);
}
/// <summary>
/// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
/// </summary>
/// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
/// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
private static void shiftNestedObjects(Slider slider, Vector2 shift)
{
foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
{
if (!(hitObject is OsuHitObject osuHitObject))
continue;
osuHitObject.Position += shift;
}
}
/// <summary>
/// Clamp a position to playfield, keeping a specified distance from the edges.
/// </summary>
/// <param name="position">The position to be clamped.</param>
/// <param name="padding">The minimum distance allowed from playfield edges.</param>
/// <returns>The clamped position.</returns>
private static Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
{
return new Vector2(
Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding),
Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding)
);
}
public class ObjectPositionInfo
{
/// <summary>
/// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
/// </summary>
/// <remarks>
/// <see cref="RelativeAngle"/> of the first hit object in a beatmap represents the absolute angle from playfield center to the object.
/// </remarks>
/// <example>
/// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
/// the previous object to reach this one.
/// </example>
public float RelativeAngle { get; set; }
/// <summary>
/// The jump distance from the previous hit object to this one.
/// </summary>
/// <remarks>
/// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
/// </remarks>
public float DistanceFromPrevious { get; set; }
/// <summary>
/// The hit object associated with this <see cref="ObjectPositionInfo"/>.
/// </summary>
public OsuHitObject HitObject { get; }
public ObjectPositionInfo(OsuHitObject hitObject)
{
HitObject = hitObject;
}
}
private class WorkingObject
{
public Vector2 PositionOriginal { get; }
public Vector2 PositionModified { get; set; }
public Vector2 EndPositionModified { get; set; }
public ObjectPositionInfo PositionInfo { get; }
public OsuHitObject HitObject => PositionInfo.HitObject;
public WorkingObject(ObjectPositionInfo positionInfo)
{
PositionInfo = positionInfo;
PositionModified = PositionOriginal = HitObject.Position;
EndPositionModified = HitObject.EndPosition;
}
}
}
}

View File

@ -2,10 +2,13 @@
// See the LICENCE file in the repository root for full licence text.
using NUnit.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.Framework.Timing;
using osu.Game.Configuration;
using osu.Game.Overlays.Toolbar;
using osuTK;
using osuTK.Graphics;
@ -15,7 +18,10 @@ namespace osu.Game.Tests.Visual.Menus
[TestFixture]
public class TestSceneToolbarClock : OsuManualInputManagerTestScene
{
private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
private readonly Container mainContainer;
private readonly ToolbarClock toolbarClock;
public TestSceneToolbarClock()
{
@ -49,7 +55,7 @@ namespace osu.Game.Tests.Visual.Menus
RelativeSizeAxes = Axes.Y,
Width = 2,
},
new ToolbarClock(),
toolbarClock = new ToolbarClock(),
new Box
{
Colour = Color4.DarkRed,
@ -65,6 +71,12 @@ namespace osu.Game.Tests.Visual.Menus
AddSliderStep("scale", 0.5, 4, 1, scale => mainContainer.Scale = new Vector2((float)scale));
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
}
[Test]
public void TestRealGameTime()
{
@ -76,5 +88,20 @@ namespace osu.Game.Tests.Visual.Menus
{
AddStep("Set game time long", () => mainContainer.Clock = new FramedOffsetClock(Clock, false) { Offset = 3600.0 * 24 * 1000 * 98 });
}
[Test]
public void TestDisplayModeChange()
{
AddStep("Set clock display mode", () => clockDisplayMode.Value = ToolbarClockDisplayMode.Full);
AddStep("Trigger click", () => toolbarClock.TriggerClick());
AddAssert("State is digital with runtime", () => clockDisplayMode.Value == ToolbarClockDisplayMode.DigitalWithRuntime);
AddStep("Trigger click", () => toolbarClock.TriggerClick());
AddAssert("State is digital", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Digital);
AddStep("Trigger click", () => toolbarClock.TriggerClick());
AddAssert("State is analog", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Analog);
AddStep("Trigger click", () => toolbarClock.TriggerClick());
AddAssert("State is full", () => clockDisplayMode.Value == ToolbarClockDisplayMode.Full);
}
}
}

View File

@ -5,9 +5,11 @@ using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
@ -29,6 +31,14 @@ namespace osu.Game.Tests.Visual.Online
private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType<BeatmapListingSearchControl>().Single();
private OsuConfigManager localConfig;
[BackgroundDependencyLoader]
private void load()
{
Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage));
}
[SetUpSteps]
public void SetUpSteps()
{
@ -61,6 +71,8 @@ namespace osu.Game.Tests.Visual.Online
Id = API.LocalUser.Value.Id + 1,
};
});
AddStep("reset size", () => localConfig.SetValue(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal));
}
[Test]
@ -121,23 +133,23 @@ namespace osu.Game.Tests.Visual.Online
}
[Test]
public void TestCardSizeSwitching()
public void TestCardSizeSwitching([Values] bool viaConfig)
{
AddAssert("is visible", () => overlay.State.Value == Visibility.Visible);
AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray()));
assertAllCardsOfType<BeatmapCardNormal>(100);
setCardSize(BeatmapCardSize.Extra);
setCardSize(BeatmapCardSize.Extra, viaConfig);
assertAllCardsOfType<BeatmapCardExtra>(100);
setCardSize(BeatmapCardSize.Normal);
setCardSize(BeatmapCardSize.Normal, viaConfig);
assertAllCardsOfType<BeatmapCardNormal>(100);
AddStep("fetch for 0 beatmaps", () => fetchFor());
AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
setCardSize(BeatmapCardSize.Extra);
setCardSize(BeatmapCardSize.Extra, viaConfig);
AddAssert("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
}
@ -361,7 +373,13 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("\"no maps found\" placeholder not shown", () => !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any(d => d.IsPresent));
}
private void setCardSize(BeatmapCardSize cardSize) => AddStep($"set card size to {cardSize}", () => overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize);
private void setCardSize(BeatmapCardSize cardSize, bool viaConfig) => AddStep($"set card size to {cardSize}", () =>
{
if (viaConfig)
localConfig.SetValue(OsuSetting.BeatmapListingCardSize, cardSize);
else
overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize;
});
private void assertAllCardsOfType<T>(int expectedCount)
where T : BeatmapCard =>
@ -370,5 +388,11 @@ namespace osu.Game.Tests.Visual.Online
int loadedCorrectCount = this.ChildrenOfType<BeatmapCard>().Count(card => card.IsLoaded && card.GetType() == typeof(T));
return loadedCorrectCount > 0 && loadedCorrectCount == expectedCount;
});
protected override void Dispose(bool isDisposing)
{
localConfig?.Dispose();
base.Dispose(isDisposing);
}
}
}

View File

@ -71,7 +71,9 @@ namespace osu.Game.Tests.Visual.Online
{
Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(),
Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(),
}
},
PassCount = RNG.Next(0, 999),
PlayCount = RNG.Next(1000, 1999),
};
}

View File

@ -0,0 +1,122 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Overlays;
using osu.Game.Overlays.Chat;
namespace osu.Game.Tests.Visual.Online
{
[TestFixture]
public class TestSceneChatTextBox : OsuTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink);
[Cached]
private readonly Bindable<Channel> currentChannel = new Bindable<Channel>();
private OsuSpriteText commitText;
private OsuSpriteText searchText;
private ChatTextBar bar;
[SetUp]
public void SetUp()
{
Schedule(() =>
{
Child = new GridContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
RowDimensions = new[]
{
new Dimension(GridSizeMode.Absolute, 30),
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(),
},
Content = new[]
{
new Drawable[]
{
commitText = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Font = OsuFont.Default.With(size: 20),
},
searchText = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Font = OsuFont.Default.With(size: 20),
},
},
},
},
},
new Drawable[]
{
bar = new ChatTextBar
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Width = 0.99f,
},
},
},
};
bar.OnChatMessageCommitted += text =>
{
commitText.Text = $"{nameof(bar.OnChatMessageCommitted)}: {text}";
commitText.FadeOutFromOne(1000, Easing.InQuint);
};
bar.OnSearchTermsChanged += text =>
{
searchText.Text = $"{nameof(bar.OnSearchTermsChanged)}: {text}";
};
});
}
[Test]
public void TestVisual()
{
AddStep("Public Channel", () => currentChannel.Value = createPublicChannel("#osu"));
AddStep("Public Channel Long Name", () => currentChannel.Value = createPublicChannel("#public-channel-long-name"));
AddStep("Private Channel", () => currentChannel.Value = createPrivateChannel("peppy", 2));
AddStep("Private Long Name", () => currentChannel.Value = createPrivateChannel("test user long name", 3));
AddStep("Chat Mode Channel", () => bar.ShowSearch.Value = false);
AddStep("Chat Mode Search", () => bar.ShowSearch.Value = true);
}
private static Channel createPublicChannel(string name)
=> new Channel { Name = name, Type = ChannelType.Public, Id = 1234 };
private static Channel createPrivateChannel(string username, int id)
=> new Channel(new APIUser { Id = id, Username = username });
}
}

View File

@ -1,16 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings.Sections;
using osu.Game.Overlays.Settings.Sections.Input;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Settings
{
[TestFixture]
public class TestSceneSettingsPanel : OsuTestScene
public class TestSceneSettingsPanel : OsuManualInputManagerTestScene
{
private SettingsPanel settings;
private DialogOverlay dialogOverlay;
@ -33,7 +38,55 @@ namespace osu.Game.Tests.Visual.Settings
public void ToggleVisibility()
{
AddWaitStep("wait some", 5);
AddToggleStep("toggle editor visibility", visible => settings.ToggleVisibility());
AddToggleStep("toggle visibility", visible => settings.ToggleVisibility());
}
[Test]
public void TestTextboxFocusAfterNestedPanelBackButton()
{
AddUntilStep("sections loaded", () => settings.SectionsContainer.Children.Count > 0);
AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType<FocusedTextBox>().FirstOrDefault()?.HasFocus == true);
AddStep("open key binding subpanel", () =>
{
settings.SectionsContainer
.ChildrenOfType<InputSection>().FirstOrDefault()?
.ChildrenOfType<OsuButton>().FirstOrDefault()?
.TriggerClick();
});
AddUntilStep("binding panel textbox focused", () => settings
.ChildrenOfType<KeyBindingPanel>().FirstOrDefault()?
.ChildrenOfType<FocusedTextBox>().FirstOrDefault()?.HasFocus == true);
AddStep("Press back", () => settings
.ChildrenOfType<KeyBindingPanel>().FirstOrDefault()?
.ChildrenOfType<SettingsSubPanel.BackButton>().FirstOrDefault()?.TriggerClick());
AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType<FocusedTextBox>().FirstOrDefault()?.HasFocus == true);
}
[Test]
public void TestTextboxFocusAfterNestedPanelEscape()
{
AddUntilStep("sections loaded", () => settings.SectionsContainer.Children.Count > 0);
AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType<FocusedTextBox>().FirstOrDefault()?.HasFocus == true);
AddStep("open key binding subpanel", () =>
{
settings.SectionsContainer
.ChildrenOfType<InputSection>().FirstOrDefault()?
.ChildrenOfType<OsuButton>().FirstOrDefault()?
.TriggerClick();
});
AddUntilStep("binding panel textbox focused", () => settings
.ChildrenOfType<KeyBindingPanel>().FirstOrDefault()?
.ChildrenOfType<FocusedTextBox>().FirstOrDefault()?.HasFocus == true);
AddStep("Escape", () => InputManager.Key(Key.Escape));
AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType<FocusedTextBox>().FirstOrDefault()?.HasFocus == true);
}
[BackgroundDependencyLoader]

View File

@ -0,0 +1,108 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osuTK.Input;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public class TestSceneShearedToggleButton : OsuManualInputManagerTestScene
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
[Test]
public void TestShearedToggleButton()
{
ShearedToggleButton button = null;
AddStep("create button", () =>
{
Child = button = new ShearedToggleButton(200)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Toggle me",
};
});
AddToggleStep("toggle button", active => button.Active.Value = active);
AddToggleStep("toggle disabled", disabled => button.Active.Disabled = disabled);
}
[Test]
public void TestSizing()
{
ShearedToggleButton toggleButton = null;
AddStep("create fixed width button", () => Child = toggleButton = new ShearedToggleButton(200)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Fixed width"
});
AddStep("change text", () => toggleButton.Text = "New text");
AddStep("create auto-sizing button", () => Child = toggleButton = new ShearedToggleButton
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "This button autosizes to its text!"
});
AddStep("change text", () => toggleButton.Text = "New text");
}
[Test]
public void TestDisabledState()
{
ShearedToggleButton button = null;
AddStep("create button", () =>
{
Child = button = new ShearedToggleButton(200)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Toggle me",
};
});
clickToggle();
assertToggleState(true);
clickToggle();
assertToggleState(false);
setToggleDisabledState(true);
assertToggleState(false);
clickToggle();
assertToggleState(false);
setToggleDisabledState(false);
assertToggleState(false);
clickToggle();
assertToggleState(true);
setToggleDisabledState(true);
assertToggleState(true);
clickToggle();
assertToggleState(true);
void clickToggle() => AddStep("click toggle", () =>
{
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
void assertToggleState(bool active) => AddAssert($"toggle is {(active ? "" : "not ")}active", () => button.Active.Value == active);
void setToggleDisabledState(bool disabled) => AddStep($"{(disabled ? "disable" : "enable")} toggle", () => button.Active.Disabled = disabled);
}
}
}

View File

@ -1,9 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.IO;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Platform;
using osu.Game.Rulesets;
using osu.Game.Tests;
@ -12,6 +16,45 @@ namespace osu.Game.Tournament.Tests.NonVisual
{
public class DataLoadTest : TournamentHostTest
{
[Test]
public void TestRulesetGetsValidOnlineID()
{
using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
var osu = new TestTournament(runOnLoadComplete: () =>
{
// ReSharper disable once AccessToDisposedClosure
var storage = host.Storage.GetStorageForDirectory(Path.Combine("tournaments", "default"));
using (var stream = storage.GetStream("bracket.json", FileAccess.Write, FileMode.Create))
using (var writer = new StreamWriter(stream))
{
writer.Write(@"{
""Ruleset"": {
""ShortName"": ""taiko"",
""OnlineID"": -1,
""Name"": ""osu!taiko"",
""InstantiationInfo"": ""osu.Game.Rulesets.OsuTaiko.TaikoRuleset, osu.Game.Rulesets.Taiko"",
""Available"": true
} }");
}
});
LoadTournament(host, osu);
osu.BracketLoadTask.WaitSafely();
Assert.That(osu.Dependencies.Get<IBindable<RulesetInfo>>().Value.OnlineID, Is.EqualTo(1));
}
finally
{
host.Exit();
}
}
}
[Test]
public void TestUnavailableRuleset()
{
@ -19,7 +62,7 @@ namespace osu.Game.Tournament.Tests.NonVisual
{
try
{
var osu = new TestTournament();
var osu = new TestTournament(true);
LoadTournament(host, osu);
var storage = osu.Dependencies.Get<Storage>();
@ -35,10 +78,23 @@ namespace osu.Game.Tournament.Tests.NonVisual
public class TestTournament : TournamentGameBase
{
private readonly bool resetRuleset;
private readonly Action runOnLoadComplete;
public new Task BracketLoadTask => base.BracketLoadTask;
public TestTournament(bool resetRuleset = false, Action runOnLoadComplete = null)
{
this.resetRuleset = resetRuleset;
this.runOnLoadComplete = runOnLoadComplete;
}
protected override void LoadComplete()
{
runOnLoadComplete?.Invoke();
base.LoadComplete();
Ruleset.Value = new RulesetInfo(); // not available
if (resetRuleset)
Ruleset.Value = new RulesetInfo(); // not available
}
}
}

View File

@ -64,6 +64,16 @@ namespace osu.Game.Tournament
Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage)));
dependencies.CacheAs(new StableInfo(storage));
}
protected override void LoadComplete()
{
MenuCursorContainer.Cursor.AlwaysPresent = true; // required for tooltip display
// we don't want to show the menu cursor as it would appear on stream output.
MenuCursorContainer.Cursor.Alpha = 0;
base.LoadComplete();
Task.Run(readBracket);
}
@ -81,10 +91,14 @@ namespace osu.Game.Tournament
ladder ??= new LadderInfo();
ladder.Ruleset.Value = ladder.Ruleset.Value != null
var resolvedRuleset = ladder.Ruleset.Value != null
? RulesetStore.GetRuleset(ladder.Ruleset.Value.ShortName)
: RulesetStore.AvailableRulesets.First();
// Must set to null initially to avoid the following re-fetch hitting `ShortName` based equality check.
ladder.Ruleset.Value = null;
ladder.Ruleset.Value = resolvedRuleset;
bool addedInfo = false;
// assign teams
@ -282,16 +296,6 @@ namespace osu.Game.Tournament
}
}
protected override void LoadComplete()
{
MenuCursorContainer.Cursor.AlwaysPresent = true; // required for tooltip display
// we don't want to show the menu cursor as it would appear on stream output.
MenuCursorContainer.Cursor.Alpha = 0;
base.LoadComplete();
}
protected virtual void SaveChanges()
{
if (!bracketLoadTaskCompletionSource.Task.IsCompletedSuccessfully)

View File

@ -7,8 +7,10 @@ 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.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Tournament.Components;
using osu.Game.Tournament.Screens;
using osu.Game.Tournament.Screens.Drawings;
@ -23,6 +25,7 @@ using osu.Game.Tournament.Screens.TeamIntro;
using osu.Game.Tournament.Screens.TeamWin;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Tournament
{
@ -123,16 +126,16 @@ namespace osu.Game.Tournament
new ScreenButton(typeof(RoundEditorScreen)) { Text = "Rounds Editor", RequestSelection = SetScreen },
new ScreenButton(typeof(LadderEditorScreen)) { Text = "Bracket Editor", RequestSelection = SetScreen },
new Separator(),
new ScreenButton(typeof(ScheduleScreen)) { Text = "Schedule", RequestSelection = SetScreen },
new ScreenButton(typeof(LadderScreen)) { Text = "Bracket", RequestSelection = SetScreen },
new ScreenButton(typeof(ScheduleScreen), Key.S) { Text = "Schedule", RequestSelection = SetScreen },
new ScreenButton(typeof(LadderScreen), Key.B) { Text = "Bracket", RequestSelection = SetScreen },
new Separator(),
new ScreenButton(typeof(TeamIntroScreen)) { Text = "Team Intro", RequestSelection = SetScreen },
new ScreenButton(typeof(SeedingScreen)) { Text = "Seeding", RequestSelection = SetScreen },
new ScreenButton(typeof(TeamIntroScreen), Key.I) { Text = "Team Intro", RequestSelection = SetScreen },
new ScreenButton(typeof(SeedingScreen), Key.D) { Text = "Seeding", RequestSelection = SetScreen },
new Separator(),
new ScreenButton(typeof(MapPoolScreen)) { Text = "Map Pool", RequestSelection = SetScreen },
new ScreenButton(typeof(GameplayScreen)) { Text = "Gameplay", RequestSelection = SetScreen },
new ScreenButton(typeof(MapPoolScreen), Key.M) { Text = "Map Pool", RequestSelection = SetScreen },
new ScreenButton(typeof(GameplayScreen), Key.G) { Text = "Gameplay", RequestSelection = SetScreen },
new Separator(),
new ScreenButton(typeof(TeamWinScreen)) { Text = "Win", RequestSelection = SetScreen },
new ScreenButton(typeof(TeamWinScreen), Key.W) { Text = "Win", RequestSelection = SetScreen },
new Separator(),
new ScreenButton(typeof(DrawingsScreen)) { Text = "Drawings", RequestSelection = SetScreen },
new ScreenButton(typeof(ShowcaseScreen)) { Text = "Showcase", RequestSelection = SetScreen },
@ -231,13 +234,60 @@ namespace osu.Game.Tournament
{
public readonly Type Type;
public ScreenButton(Type type)
private readonly Key? shortcutKey;
public ScreenButton(Type type, Key? shortcutKey = null)
{
this.shortcutKey = shortcutKey;
Type = type;
BackgroundColour = OsuColour.Gray(0.2f);
Action = () => RequestSelection?.Invoke(type);
RelativeSizeAxes = Axes.X;
if (shortcutKey != null)
{
Add(new Container
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(24),
Margin = new MarginPadding(5),
Masking = true,
CornerRadius = 4,
Alpha = 0.5f,
Blending = BlendingParameters.Additive,
Children = new Drawable[]
{
new Box
{
Colour = OsuColour.Gray(0.1f),
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Font = OsuFont.Default.With(size: 24),
Y = -2,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = shortcutKey.ToString(),
}
}
});
}
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Key == shortcutKey)
{
TriggerClick();
return true;
}
return base.OnKeyDown(e);
}
private bool isSelected;

View File

@ -10,6 +10,7 @@ using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
@ -44,6 +45,8 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f);
SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal);
SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full);
// Online settings
@ -297,6 +300,7 @@ namespace osu.Game.Configuration
RandomSelectAlgorithm,
ShowFpsDisplay,
ChatDisplayHeight,
BeatmapListingCardSize,
ToolbarClockDisplayMode,
Version,
ShowConvertedBeatmaps,

View File

@ -28,7 +28,7 @@ namespace osu.Game.Graphics.UserInterface
if (!allowImmediateFocus)
return;
Scheduler.Add(() => GetContainingInputManager().ChangeFocus(this), false);
Scheduler.Add(() => GetContainingInputManager().ChangeFocus(this));
}
public new void KillFocus() => base.KillFocus();

View File

@ -0,0 +1,177 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using System;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Graphics.UserInterface
{
public class ShearedToggleButton : OsuClickableContainer
{
public BindableBool Active { get; } = new BindableBool();
public LocalisableString Text
{
get => text.Text;
set => text.Text = value;
}
private readonly Box background;
private readonly OsuSpriteText text;
private Sample? sampleOff;
private Sample? sampleOn;
private const float shear = 0.2f;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
/// <summary>
/// Creates a new <see cref="ShearedToggleButton"/>
/// </summary>
/// <param name="width">
/// The width of the button.
/// <list type="bullet">
/// <item>If a non-<see langword="null"/> value is provided, this button will have a fixed width equal to the provided value.</item>
/// <item>If a <see langword="null"/> value is provided (or the argument is omitted entirely), the button will autosize in width to fit the text.</item>
/// </list>
/// </param>
public ShearedToggleButton(float? width = null)
{
Height = 50;
Padding = new MarginPadding { Horizontal = shear * 50 };
Content.CornerRadius = 7;
Content.Shear = new Vector2(shear, 0);
Content.Masking = true;
Content.BorderThickness = 2;
Content.Anchor = Content.Origin = Anchor.Centre;
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both
},
text = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.TorusAlternate.With(size: 17),
Shear = new Vector2(-shear, 0)
}
};
if (width != null)
{
Width = width.Value;
}
else
{
AutoSizeAxes = Axes.X;
text.Margin = new MarginPadding { Horizontal = 15 };
}
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sampleOn = audio.Samples.Get(@"UI/check-on");
sampleOff = audio.Samples.Get(@"UI/check-off");
}
protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet);
protected override void LoadComplete()
{
base.LoadComplete();
Active.BindValueChanged(_ =>
{
updateState();
playSample();
});
Active.BindDisabledChanged(disabled =>
{
updateState();
Action = disabled ? (Action?)null : Active.Toggle;
}, true);
FinishTransforms(true);
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
base.OnHoverLost(e);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
Content.ScaleTo(0.8f, 2000, Easing.OutQuint);
return base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseUpEvent e)
{
Content.ScaleTo(1, 1000, Easing.OutElastic);
base.OnMouseUp(e);
}
private void updateState()
{
var darkerColour = Active.Value ? colourProvider.Highlight1 : colourProvider.Background3;
var lighterColour = Active.Value ? colourProvider.Colour0 : colourProvider.Background1;
if (Active.Disabled)
{
darkerColour = darkerColour.Darken(0.3f);
lighterColour = lighterColour.Darken(0.3f);
}
else if (IsHovered)
{
darkerColour = darkerColour.Lighten(0.3f);
lighterColour = lighterColour.Lighten(0.3f);
}
background.FadeColour(darkerColour, 150, Easing.OutQuint);
Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(darkerColour, lighterColour), 150, Easing.OutQuint);
var textColour = Active.Value ? colourProvider.Background6 : colourProvider.Content1;
if (Active.Disabled)
textColour = textColour.Opacity(0.6f);
text.FadeColour(textColour, 150, Easing.OutQuint);
}
private void playSample()
{
if (Active.Value)
sampleOn?.Play();
else
sampleOff?.Play();
}
}
}

View File

@ -1061,6 +1061,12 @@ namespace osu.Game
return true;
case GlobalAction.RandomSkin:
// Don't allow random skin selection while in the skin editor.
// This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path.
// If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow.
if (skinEditor.State.Value == Visibility.Visible)
return false;
SkinManager.SelectRandomSkin();
return true;
}

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Configuration;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
@ -53,7 +54,9 @@ namespace osu.Game.Overlays.BeatmapListing
/// <summary>
/// The currently selected <see cref="BeatmapCardSize"/>.
/// </summary>
public IBindable<BeatmapCardSize> CardSize { get; } = new Bindable<BeatmapCardSize>();
public IBindable<BeatmapCardSize> CardSize => cardSize;
private readonly Bindable<BeatmapCardSize> cardSize = new Bindable<BeatmapCardSize>();
private readonly BeatmapListingSearchControl searchControl;
private readonly BeatmapListingSortTabControl sortControl;
@ -128,6 +131,9 @@ namespace osu.Game.Overlays.BeatmapListing
};
}
[Resolved]
private OsuConfigManager config { get; set; }
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, IAPIProvider api)
{
@ -141,6 +147,8 @@ namespace osu.Game.Overlays.BeatmapListing
{
base.LoadComplete();
config.BindWith(OsuSetting.BeatmapListingCardSize, cardSize);
var sortCriteria = sortControl.Current;
var sortDirection = sortControl.SortDirection;

View File

@ -5,6 +5,8 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@ -19,7 +21,7 @@ namespace osu.Game.Overlays.BeatmapSet
protected readonly FailRetryGraph Graph;
private readonly FillFlowContainer header;
private readonly OsuSpriteText successPercent;
private readonly SuccessRatePercentage successPercent;
private readonly Bar successRate;
private readonly Container percentContainer;
@ -45,6 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet
float rate = playCount != 0 ? (float)passCount / playCount : 0;
successPercent.Text = rate.ToLocalisableString(@"0.#%");
successPercent.TooltipText = $"{passCount} / {playCount}";
successRate.Length = rate;
percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic);
@ -80,7 +83,7 @@ namespace osu.Game.Overlays.BeatmapSet
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Width = 0f,
Child = successPercent = new OsuSpriteText
Child = successPercent = new SuccessRatePercentage
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopCentre,
@ -121,5 +124,10 @@ namespace osu.Game.Overlays.BeatmapSet
Graph.Padding = new MarginPadding { Top = header.DrawHeight };
}
private class SuccessRatePercentage : OsuSpriteText, IHasTooltip
{
public LocalisableString TooltipText { get; set; }
}
}
}

View File

@ -0,0 +1,163 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.Containers;
using osu.Game.Online.Chat;
using osuTK;
namespace osu.Game.Overlays.Chat
{
public class ChatTextBar : Container
{
public readonly BindableBool ShowSearch = new BindableBool();
public event Action<string>? OnChatMessageCommitted;
public event Action<string>? OnSearchTermsChanged;
[Resolved]
private Bindable<Channel> currentChannel { get; set; } = null!;
private OsuTextFlowContainer chattingTextContainer = null!;
private Container searchIconContainer = null!;
private ChatTextBox chatTextBox = null!;
private const float chatting_text_width = 180;
private const float search_icon_width = 40;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
RelativeSizeAxes = Axes.X;
Height = 60;
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5,
},
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(),
},
Content = new[]
{
new Drawable[]
{
chattingTextContainer = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 20))
{
Masking = true,
Width = chatting_text_width,
Padding = new MarginPadding { Left = 10 },
RelativeSizeAxes = Axes.Y,
TextAnchor = Anchor.CentreRight,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Colour = colourProvider.Background1,
},
searchIconContainer = new Container
{
RelativeSizeAxes = Axes.Y,
Width = search_icon_width,
Child = new SpriteIcon
{
Icon = FontAwesome.Solid.Search,
Origin = Anchor.CentreRight,
Anchor = Anchor.CentreRight,
Size = new Vector2(20),
Margin = new MarginPadding { Right = 2 },
},
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = 5 },
Child = chatTextBox = new ChatTextBox
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.X,
ShowSearch = { BindTarget = ShowSearch },
HoldFocus = true,
ReleaseFocusOnCommit = false,
},
},
},
},
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
chatTextBox.Current.ValueChanged += chatTextBoxChange;
chatTextBox.OnCommit += chatTextBoxCommit;
ShowSearch.BindValueChanged(change =>
{
bool showSearch = change.NewValue;
chattingTextContainer.FadeTo(showSearch ? 0 : 1);
searchIconContainer.FadeTo(showSearch ? 1 : 0);
// Clear search terms if any exist when switching back to chat mode
if (!showSearch)
OnSearchTermsChanged?.Invoke(string.Empty);
}, true);
currentChannel.BindValueChanged(change =>
{
Channel newChannel = change.NewValue;
switch (newChannel?.Type)
{
case ChannelType.Public:
chattingTextContainer.Text = $"chatting in {newChannel.Name}";
break;
case ChannelType.PM:
chattingTextContainer.Text = $"chatting with {newChannel.Name}";
break;
default:
chattingTextContainer.Text = string.Empty;
break;
}
}, true);
}
private void chatTextBoxChange(ValueChangedEvent<string> change)
{
if (ShowSearch.Value)
OnSearchTermsChanged?.Invoke(change.NewValue);
}
private void chatTextBoxCommit(TextBox sender, bool newText)
{
if (ShowSearch.Value)
return;
OnChatMessageCommitted?.Invoke(sender.Text);
sender.Text = string.Empty;
}
}
}

View File

@ -0,0 +1,38 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using osu.Framework.Bindables;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Chat
{
public class ChatTextBox : FocusedTextBox
{
public readonly BindableBool ShowSearch = new BindableBool();
public override bool HandleLeftRightArrows => !ShowSearch.Value;
protected override void LoadComplete()
{
base.LoadComplete();
ShowSearch.BindValueChanged(change =>
{
bool showSearch = change.NewValue;
PlaceholderText = showSearch ? "type here to search" : "type here";
Text = string.Empty;
}, true);
}
protected override void Commit()
{
if (ShowSearch.Value)
return;
base.Commit();
}
}
}

View File

@ -32,7 +32,7 @@ namespace osu.Game.Overlays
protected override bool DimMainContent => false; // dimming is handled by main overlay
private class BackButton : SidebarButton
public class BackButton : SidebarButton
{
private Container content;

View File

@ -6,7 +6,6 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Overlays.Toolbar
{
@ -42,7 +41,7 @@ namespace osu.Game.Overlays.Toolbar
{
Y = 14,
Colour = colours.PinkLight,
Scale = new Vector2(0.6f)
Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold),
}
};

View File

@ -190,7 +190,7 @@ namespace osu.Game.Overlays.Toolbar
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (e.Action == Hotkey)
if (e.Action == Hotkey && !e.Repeat)
{
TriggerClick();
return true;

View File

@ -3,27 +3,35 @@
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Overlays.Toolbar
{
public class ToolbarClock : CompositeDrawable
public class ToolbarClock : OsuClickableContainer
{
private Bindable<ToolbarClockDisplayMode> clockDisplayMode;
private Box hoverBackground;
private Box flashBackground;
private DigitalClockDisplay digital;
private AnalogClockDisplay analog;
public ToolbarClock()
: base(HoverSampleSet.Toolbar)
{
RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X;
Padding = new MarginPadding(10);
}
[BackgroundDependencyLoader]
@ -31,23 +39,41 @@ namespace osu.Game.Overlays.Toolbar
{
clockDisplayMode = config.GetBindable<ToolbarClockDisplayMode>(OsuSetting.ToolbarClockDisplayMode);
InternalChild = new FillFlowContainer
Children = new Drawable[]
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Children = new Drawable[]
hoverBackground = new Box
{
analog = new AnalogClockDisplay
RelativeSizeAxes = Axes.Both,
Colour = OsuColour.Gray(80).Opacity(180),
Blending = BlendingParameters.Additive,
Alpha = 0,
},
flashBackground = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Colour = Color4.White.Opacity(100),
Blending = BlendingParameters.Additive,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Padding = new MarginPadding(10),
Children = new Drawable[]
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
digital = new DigitalClockDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
analog = new AnalogClockDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
digital = new DigitalClockDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
}
}
};
@ -72,8 +98,25 @@ namespace osu.Game.Overlays.Toolbar
protected override bool OnClick(ClickEvent e)
{
flashBackground.FadeOutFromOne(800, Easing.OutQuint);
cycleDisplayMode();
return true;
return base.OnClick(e);
}
protected override bool OnHover(HoverEvent e)
{
hoverBackground.FadeIn(200);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
hoverBackground.FadeOut(200);
base.OnHoverLost(e);
}
private void cycleDisplayMode()

View File

@ -1,19 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
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 TimingSection : Section<TimingControlPoint>
{
private SettingsSlider<double> bpmSlider;
private LabelledTimeSignature timeSignature;
private BPMTextBox bpmTextEntry;
@ -23,7 +20,6 @@ namespace osu.Game.Screens.Edit.Timing
Flow.AddRange(new Drawable[]
{
bpmTextEntry = new BPMTextBox(),
bpmSlider = new BPMSlider(),
timeSignature = new LabelledTimeSignature
{
Label = "Time Signature"
@ -35,11 +31,8 @@ namespace osu.Game.Screens.Edit.Timing
{
if (point.NewValue != null)
{
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
bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
timeSignature.Current = point.NewValue.TimeSignatureBindable;
timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
@ -102,51 +95,6 @@ namespace osu.Game.Screens.Edit.Timing
}
}
private class BPMSlider : SettingsSlider<double>
{
private const double sane_minimum = 60;
private const double sane_maximum = 240;
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 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

@ -37,21 +37,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
base.UserJoined(user);
userJoinedSample?.Play();
Scheduler.AddOnce(() => userJoinedSample?.Play());
}
protected override void UserLeft(MultiplayerRoomUser user)
{
base.UserLeft(user);
userLeftSample?.Play();
Scheduler.AddOnce(() => userLeftSample?.Play());
}
protected override void UserKicked(MultiplayerRoomUser user)
{
base.UserKicked(user);
userKickedSample?.Play();
Scheduler.AddOnce(() => userKickedSample?.Play());
}
private void hostChanged(ValueChangedEvent<APIUser> value)
@ -59,7 +59,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
// only play sound when the host changes from an already-existing host.
if (value.OldValue == null) return;
hostChangedSample?.Play();
Scheduler.AddOnce(() => hostChangedSample?.Play());
}
}
}

View File

@ -227,15 +227,15 @@ namespace osu.Game.Screens.Play.HUD
public int? Team => (User.MatchState as TeamVersusUserState)?.TeamID;
private readonly RulesetInfo ruleset;
private readonly ScoreInfo scoreInfo;
public TrackedUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor)
{
this.ruleset = ruleset;
User = user;
ScoreProcessor = scoreProcessor;
scoreInfo = new ScoreInfo { Ruleset = ruleset };
ScoringMode.BindValueChanged(_ => UpdateScore());
}
@ -253,12 +253,10 @@ namespace osu.Game.Screens.Play.HUD
{
var header = frame.Header;
Score.Value = ScoreProcessor.ComputePartialScore(ScoringMode.Value, new ScoreInfo
{
Ruleset = ruleset,
MaxCombo = header.MaxCombo,
Statistics = header.Statistics
});
scoreInfo.MaxCombo = header.MaxCombo;
scoreInfo.Statistics = header.Statistics;
Score.Value = ScoreProcessor.ComputePartialScore(ScoringMode.Value, scoreInfo);
Accuracy.Value = header.Accuracy;
CurrentCombo.Value = header.Combo;

View File

@ -9,6 +9,7 @@ using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Skinning;
@ -84,9 +85,17 @@ namespace osu.Game.Screens.Play.HUD
/// <returns>The new instance.</returns>
public Drawable CreateInstance()
{
Drawable d = (Drawable)Activator.CreateInstance(Type);
d.ApplySkinnableInfo(this);
return d;
try
{
Drawable d = (Drawable)Activator.CreateInstance(Type);
d.ApplySkinnableInfo(this);
return d;
}
catch (Exception e)
{
Logger.Error(e, $"Unable to create skin component {Type.Name}");
return Drawable.Empty();
}
}
}
}

View File

@ -155,16 +155,7 @@ namespace osu.Game.Skinning
var components = new List<Drawable>();
foreach (var i in skinnableInfo)
{
try
{
components.Add(i.CreateInstance());
}
catch (Exception e)
{
Logger.Error(e, $"Unable to create skin component {i.Type.Name}");
}
}
components.Add(i.CreateInstance());
return new SkinnableTargetComponentsContainer
{

View File

@ -24,6 +24,7 @@ using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Overlays.Notifications;
using osu.Game.Utils;
namespace osu.Game.Skinning
{
@ -144,20 +145,26 @@ namespace osu.Game.Skinning
if (!s.Protected)
return;
string[] existingSkinNames = realm.Run(r => r.All<SkinInfo>()
.Where(skin => !skin.DeletePending)
.AsEnumerable()
.Select(skin => skin.Name).ToArray());
// if the user is attempting to save one of the default skin implementations, create a copy first.
var result = skinModelManager.Import(new SkinInfo
var skinInfo = new SkinInfo
{
Name = s.Name + @" (modified)",
Creator = s.Creator,
InstantiationInfo = s.InstantiationInfo,
});
Name = NamingUtils.GetNextBestName(existingSkinNames, $"{s.Name} (modified)")
};
var result = skinModelManager.Import(skinInfo);
if (result != null)
{
// save once to ensure the required json content is populated.
// currently this only happens on save.
result.PerformRead(skin => Save(skin.CreateInstance(this)));
CurrentSkinInfo.Value = result;
}
});