mirror of
https://github.com/ppy/osu.git
synced 2024-11-11 16:27:26 +08:00
Merge branch 'master' into fix-multiplayer-unobserved
This commit is contained in:
commit
c540810943
20
.github/ISSUE_TEMPLATE/bug-issue.yml
vendored
20
.github/ISSUE_TEMPLATE/bug-issue.yml
vendored
@ -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
|
||||
|
@ -51,8 +51,8 @@
|
||||
<Reference Include="Java.Interop" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.325.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.404.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Transitive Dependencies">
|
||||
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
|
||||
|
@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Catch.Tests
|
||||
{
|
||||
protected override string ResourceAssembly => "osu.Game.Rulesets.Catch";
|
||||
|
||||
[TestCase(4.0505463516206195d, "diffcalc-test")]
|
||||
public void Test(double expected, string name)
|
||||
=> base.Test(expected, name);
|
||||
[TestCase(4.0505463516206195d, 127, "diffcalc-test")]
|
||||
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> base.Test(expectedStarRating, expectedMaxCombo, name);
|
||||
|
||||
[TestCase(5.1696411260785498d, "diffcalc-test")]
|
||||
public void TestClockRateAdjusted(double expected, string name)
|
||||
=> Test(expected, name, new CatchModDoubleTime());
|
||||
[TestCase(5.1696411260785498d, 127, "diffcalc-test")]
|
||||
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> Test(expectedStarRating, expectedMaxCombo, name, new CatchModDoubleTime());
|
||||
|
||||
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new CatchDifficultyCalculator(new CatchRuleset().RulesetInfo, beatmap);
|
||||
|
||||
|
@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
{
|
||||
protected override string ResourceAssembly => "osu.Game.Rulesets.Mania";
|
||||
|
||||
[TestCase(2.3449735700206298d, "diffcalc-test")]
|
||||
public void Test(double expected, string name)
|
||||
=> base.Test(expected, name);
|
||||
[TestCase(2.3449735700206298d, 151, "diffcalc-test")]
|
||||
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> base.Test(expectedStarRating, expectedMaxCombo, name);
|
||||
|
||||
[TestCase(2.7879104989252959d, "diffcalc-test")]
|
||||
public void TestClockRateAdjusted(double expected, string name)
|
||||
=> Test(expected, name, new ManiaModDoubleTime());
|
||||
[TestCase(2.7879104989252959d, 151, "diffcalc-test")]
|
||||
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> Test(expectedStarRating, expectedMaxCombo, name, new ManiaModDoubleTime());
|
||||
|
||||
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new ManiaDifficultyCalculator(new ManiaRuleset().RulesetInfo, beatmap);
|
||||
|
||||
|
@ -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,
|
@ -15,15 +15,20 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
{
|
||||
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
|
||||
|
||||
[TestCase(6.6972307565739273d, "diffcalc-test")]
|
||||
[TestCase(1.4484754139145539d, "zero-length-sliders")]
|
||||
public void Test(double expected, string name)
|
||||
=> base.Test(expected, name);
|
||||
[TestCase(6.6972307565739273d, 206, "diffcalc-test")]
|
||||
[TestCase(1.4484754139145539d, 45, "zero-length-sliders")]
|
||||
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> base.Test(expectedStarRating, expectedMaxCombo, name);
|
||||
|
||||
[TestCase(8.9382559208689809d, "diffcalc-test")]
|
||||
[TestCase(1.7548875851757628d, "zero-length-sliders")]
|
||||
public void TestClockRateAdjusted(double expected, string name)
|
||||
=> Test(expected, name, new OsuModDoubleTime());
|
||||
[TestCase(8.9382559208689809d, 206, "diffcalc-test")]
|
||||
[TestCase(1.7548875851757628d, 45, "zero-length-sliders")]
|
||||
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
|
||||
|
||||
[TestCase(6.6972307218715166d, 239, "diffcalc-test")]
|
||||
[TestCase(1.4484754139145537d, 54, "zero-length-sliders")]
|
||||
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
|
||||
|
||||
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap);
|
||||
|
||||
|
@ -61,10 +61,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
|
||||
|
||||
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
|
||||
double drainRate = beatmap.Difficulty.DrainRate;
|
||||
|
||||
int maxCombo = beatmap.HitObjects.Count;
|
||||
// Add the ticks + tail of the slider. 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above)
|
||||
maxCombo += beatmap.HitObjects.OfType<Slider>().Sum(s => s.NestedHitObjects.Count - 1);
|
||||
int maxCombo = beatmap.GetMaxCombo();
|
||||
|
||||
int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
|
||||
int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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" });
|
||||
|
@ -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" });
|
||||
|
@ -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);
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
|
@ -195,7 +195,7 @@ namespace osu.Game.Rulesets.Osu
|
||||
new OsuModApproachDifferent(),
|
||||
new OsuModMuted(),
|
||||
new OsuModNoScope(),
|
||||
new OsuModAimAssist(),
|
||||
new OsuModMagnetised(),
|
||||
new ModAdaptiveSpeed()
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -14,15 +14,15 @@ namespace osu.Game.Rulesets.Taiko.Tests
|
||||
{
|
||||
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
|
||||
|
||||
[TestCase(2.2420075288523802d, "diffcalc-test")]
|
||||
[TestCase(2.2420075288523802d, "diffcalc-test-strong")]
|
||||
public void Test(double expected, string name)
|
||||
=> base.Test(expected, name);
|
||||
[TestCase(2.2420075288523802d, 200, "diffcalc-test")]
|
||||
[TestCase(2.2420075288523802d, 200, "diffcalc-test-strong")]
|
||||
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> base.Test(expectedStarRating, expectedMaxCombo, name);
|
||||
|
||||
[TestCase(3.134084469440479d, "diffcalc-test")]
|
||||
[TestCase(3.134084469440479d, "diffcalc-test-strong")]
|
||||
public void TestClockRateAdjusted(double expected, string name)
|
||||
=> Test(expected, name, new TaikoModDoubleTime());
|
||||
[TestCase(3.134084469440479d, 200, "diffcalc-test")]
|
||||
[TestCase(3.134084469440479d, 200, "diffcalc-test-strong")]
|
||||
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
|
||||
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());
|
||||
|
||||
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new TaikoDifficultyCalculator(new TaikoRuleset().RulesetInfo, beatmap);
|
||||
|
||||
|
@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
|
||||
/// <summary>
|
||||
/// Default size of a drawable taiko hit object.
|
||||
/// </summary>
|
||||
public const float DEFAULT_SIZE = 0.45f;
|
||||
public const float DEFAULT_SIZE = 0.475f;
|
||||
|
||||
public override Judgement CreateJudgement() => new TaikoJudgement();
|
||||
|
||||
|
@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
|
||||
/// <summary>
|
||||
/// Scale multiplier for a strong drawable taiko hit object.
|
||||
/// </summary>
|
||||
public const float STRONG_SCALE = 1.4f;
|
||||
public const float STRONG_SCALE = 1 / 0.65f;
|
||||
|
||||
/// <summary>
|
||||
/// Default size of a strong drawable taiko hit object.
|
||||
|
@ -11,6 +11,7 @@ using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Backgrounds;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Skinning.Default
|
||||
@ -24,8 +25,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default
|
||||
/// </summary>
|
||||
public abstract class CirclePiece : BeatSyncedContainer, IHasAccentColour
|
||||
{
|
||||
public const float SYMBOL_SIZE = 0.45f;
|
||||
public const float SYMBOL_SIZE = TaikoHitObject.DEFAULT_SIZE;
|
||||
public const float SYMBOL_BORDER = 8;
|
||||
|
||||
private const double pre_beat_transition_time = 80;
|
||||
|
||||
private Color4 accentColour;
|
||||
|
@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
|
||||
new Sprite
|
||||
{
|
||||
Texture = skin.GetTexture("approachcircle"),
|
||||
Scale = new Vector2(0.73f),
|
||||
Scale = new Vector2(0.83f),
|
||||
Alpha = 0.47f, // eyeballed to match stable
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
|
||||
new Sprite
|
||||
{
|
||||
Texture = skin.GetTexture("taikobigcircle"),
|
||||
Scale = new Vector2(0.7f),
|
||||
Scale = new Vector2(0.8f),
|
||||
Alpha = 0.22f, // eyeballed to match stable
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ using osu.Framework.Testing;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osu.Game.Online.Multiplayer;
|
||||
using osu.Game.Online.Rooms;
|
||||
using osu.Game.Rulesets;
|
||||
@ -183,14 +184,41 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
assertItemInHistoryListStep(2, 0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestInsertedItemDoesNotRefreshAllOthers()
|
||||
{
|
||||
AddStep("change to round robin queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayersRoundRobin }).WaitSafely());
|
||||
|
||||
// Add a few items for the local user.
|
||||
addItemStep();
|
||||
addItemStep();
|
||||
addItemStep();
|
||||
addItemStep();
|
||||
addItemStep();
|
||||
|
||||
DrawableRoomPlaylistItem[] drawableItems = null;
|
||||
AddStep("get drawable items", () => drawableItems = this.ChildrenOfType<DrawableRoomPlaylistItem>().ToArray());
|
||||
|
||||
// Add 1 item for another user.
|
||||
AddStep("join second user", () => MultiplayerClient.AddUser(new APIUser { Id = 10 }));
|
||||
addItemStep(userId: 10);
|
||||
|
||||
// New item inserted towards the top of the list.
|
||||
assertItemInQueueListStep(7, 1);
|
||||
AddAssert("all previous playlist items remained", () => drawableItems.All(this.ChildrenOfType<DrawableRoomPlaylistItem>().Contains));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a step to create a new playlist item.
|
||||
/// </summary>
|
||||
private void addItemStep(bool expired = false) => AddStep("add item", () => MultiplayerClient.AddPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)
|
||||
private void addItemStep(bool expired = false, int? userId = null) => AddStep("add item", () =>
|
||||
{
|
||||
Expired = expired,
|
||||
PlayedAt = DateTimeOffset.Now
|
||||
})));
|
||||
MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)
|
||||
{
|
||||
Expired = expired,
|
||||
PlayedAt = DateTimeOffset.Now
|
||||
})).WaitSafely();
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the position of a given playlist item in the queue list.
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
122
osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
Normal file
122
osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs
Normal 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 });
|
||||
}
|
||||
}
|
@ -44,9 +44,6 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
|
||||
private BeatmapInfo beatmapInfo;
|
||||
|
||||
[Resolved]
|
||||
private RealmAccess realm { get; set; }
|
||||
|
||||
[Cached]
|
||||
private readonly DialogOverlay dialogOverlay;
|
||||
|
||||
@ -92,6 +89,12 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get<RulesetStore>(), () => beatmapManager, LocalStorage, Realm, Scheduler));
|
||||
Dependencies.Cache(Realm);
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load() => Schedule(() =>
|
||||
{
|
||||
var imported = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely();
|
||||
|
||||
imported?.PerformRead(s =>
|
||||
@ -115,26 +118,26 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
importedScores.Add(scoreManager.Import(score).Value);
|
||||
}
|
||||
});
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
[SetUp]
|
||||
public void Setup() => Schedule(() =>
|
||||
{
|
||||
realm.Run(r =>
|
||||
{
|
||||
// Due to soft deletions, we can re-use deleted scores between test runs
|
||||
scoreManager.Undelete(r.All<ScoreInfo>().Where(s => s.DeletePending).ToList());
|
||||
});
|
||||
|
||||
leaderboard.BeatmapInfo = beatmapInfo;
|
||||
leaderboard.RefetchScores(); // Required in the case that the beatmap hasn't changed
|
||||
});
|
||||
|
||||
[SetUpSteps]
|
||||
public void SetupSteps()
|
||||
{
|
||||
AddUntilStep("ensure scores imported", () => importedScores.Count == 50);
|
||||
AddStep("undelete scores", () =>
|
||||
{
|
||||
Realm.Run(r =>
|
||||
{
|
||||
// Due to soft deletions, we can re-use deleted scores between test runs
|
||||
scoreManager.Undelete(r.All<ScoreInfo>().Where(s => s.DeletePending).ToList());
|
||||
});
|
||||
});
|
||||
AddStep("set up leaderboard", () =>
|
||||
{
|
||||
leaderboard.BeatmapInfo = beatmapInfo;
|
||||
leaderboard.RefetchScores(); // Required in the case that the beatmap hasn't changed
|
||||
});
|
||||
|
||||
// Ensure the leaderboard items have finished showing up
|
||||
AddStep("finish transforms", () => leaderboard.FinishTransforms(true));
|
||||
AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType<LeaderboardScore>().Any());
|
||||
@ -169,11 +172,14 @@ namespace osu.Game.Tests.Visual.UserInterface
|
||||
AddStep("click delete button", () =>
|
||||
{
|
||||
InputManager.MoveMouseTo(dialogOverlay.ChildrenOfType<DialogButton>().First());
|
||||
InputManager.Click(MouseButton.Left);
|
||||
InputManager.PressButton(MouseButton.Left);
|
||||
});
|
||||
|
||||
AddUntilStep("wait for fetch", () => leaderboard.Scores != null);
|
||||
AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID));
|
||||
|
||||
// "Clean up"
|
||||
AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -5,6 +5,7 @@ using System.Collections.Generic;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Beatmaps.Timing;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
|
||||
namespace osu.Game.Beatmaps
|
||||
{
|
||||
@ -70,4 +71,27 @@ namespace osu.Game.Beatmaps
|
||||
/// </summary>
|
||||
new IReadOnlyList<T> HitObjects { get; }
|
||||
}
|
||||
|
||||
public static class BeatmapExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the maximum achievable combo by hitting all <see cref="HitObject"/>s in a beatmap.
|
||||
/// </summary>
|
||||
public static int GetMaxCombo(this IBeatmap beatmap)
|
||||
{
|
||||
int combo = 0;
|
||||
foreach (var h in beatmap.HitObjects)
|
||||
addCombo(h, ref combo);
|
||||
return combo;
|
||||
|
||||
static void addCombo(HitObject hitObject, ref int combo)
|
||||
{
|
||||
if (hitObject.CreateJudgement().MaxResult.AffectsCombo())
|
||||
combo++;
|
||||
|
||||
foreach (var nested in hitObject.NestedHitObjects)
|
||||
addCombo(nested, ref combo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -88,6 +88,7 @@ namespace osu.Game.Configuration
|
||||
throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})");
|
||||
|
||||
var control = (Drawable)Activator.CreateInstance(controlType);
|
||||
controlType.GetProperty(nameof(SettingsItem<object>.SettingSourceObject))?.SetValue(control, obj);
|
||||
controlType.GetProperty(nameof(SettingsItem<object>.LabelText))?.SetValue(control, attr.Label);
|
||||
controlType.GetProperty(nameof(SettingsItem<object>.TooltipText))?.SetValue(control, attr.Description);
|
||||
controlType.GetProperty(nameof(SettingsItem<object>.Current))?.SetValue(control, value);
|
||||
|
177
osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
Normal file
177
osu.Game/Graphics/UserInterface/ShearedToggleButton.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@ -11,6 +11,7 @@ using osu.Framework.Bindables;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests.Responses;
|
||||
using osu.Game.Utils;
|
||||
|
||||
namespace osu.Game.Online.Rooms
|
||||
{
|
||||
@ -84,6 +85,19 @@ namespace osu.Game.Online.Rooms
|
||||
Beatmap = beatmap;
|
||||
}
|
||||
|
||||
public PlaylistItem(MultiplayerPlaylistItem item)
|
||||
: this(new APIBeatmap { OnlineID = item.BeatmapID })
|
||||
{
|
||||
ID = item.ID;
|
||||
OwnerID = item.OwnerID;
|
||||
RulesetID = item.RulesetID;
|
||||
Expired = item.Expired;
|
||||
PlaylistOrder = item.PlaylistOrder;
|
||||
PlayedAt = item.PlayedAt;
|
||||
RequiredMods = item.RequiredMods.ToArray();
|
||||
AllowedMods = item.AllowedMods.ToArray();
|
||||
}
|
||||
|
||||
public void MarkInvalid() => valid.Value = false;
|
||||
|
||||
#region Newtonsoft.Json implicit ShouldSerialize() methods
|
||||
@ -101,13 +115,13 @@ namespace osu.Game.Online.Rooms
|
||||
|
||||
#endregion
|
||||
|
||||
public PlaylistItem With(IBeatmapInfo beatmap) => new PlaylistItem(beatmap)
|
||||
public PlaylistItem With(Optional<IBeatmapInfo> beatmap = default, Optional<ushort?> playlistOrder = default) => new PlaylistItem(beatmap.GetOr(Beatmap))
|
||||
{
|
||||
ID = ID,
|
||||
OwnerID = OwnerID,
|
||||
RulesetID = RulesetID,
|
||||
Expired = Expired,
|
||||
PlaylistOrder = PlaylistOrder,
|
||||
PlaylistOrder = playlistOrder.GetOr(PlaylistOrder),
|
||||
PlayedAt = PlayedAt,
|
||||
AllowedMods = AllowedMods,
|
||||
RequiredMods = RequiredMods,
|
||||
@ -119,6 +133,7 @@ namespace osu.Game.Online.Rooms
|
||||
&& Beatmap.OnlineID == other.Beatmap.OnlineID
|
||||
&& RulesetID == other.RulesetID
|
||||
&& Expired == other.Expired
|
||||
&& PlaylistOrder == other.PlaylistOrder
|
||||
&& AllowedMods.SequenceEqual(other.AllowedMods)
|
||||
&& RequiredMods.SequenceEqual(other.RequiredMods);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
163
osu.Game/Overlays/Chat/ChatTextBar.cs
Normal file
163
osu.Game/Overlays/Chat/ChatTextBar.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
38
osu.Game/Overlays/Chat/ChatTextBox.cs
Normal file
38
osu.Game/Overlays/Chat/ChatTextBox.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@ -17,7 +17,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
|
||||
HeaderText = @"Confirm deletion of";
|
||||
Buttons = new PopupDialogButton[]
|
||||
{
|
||||
new PopupDialogOkButton
|
||||
new PopupDialogDangerousButton
|
||||
{
|
||||
Text = @"Yes. Go for it.",
|
||||
Action = deleteAction
|
||||
|
@ -11,6 +11,7 @@ using osu.Framework.Graphics.Cursor;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.UserInterface;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Graphics.Containers;
|
||||
@ -24,6 +25,11 @@ namespace osu.Game.Overlays.Settings
|
||||
|
||||
protected Drawable Control { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The source component if this <see cref="SettingsItem{T}"/> was created via <see cref="SettingSourceAttribute"/>.
|
||||
/// </summary>
|
||||
public object SettingSourceObject { get; internal set; }
|
||||
|
||||
private IHasCurrentValue<T> controlWithCurrent => Control as IHasCurrentValue<T>;
|
||||
|
||||
protected override Container<Drawable> Content => FlowContent;
|
||||
|
@ -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),
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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()
|
||||
|
@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
|
||||
using osu.Game.Rulesets.Difficulty.Skills;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Utils;
|
||||
|
||||
namespace osu.Game.Rulesets.Difficulty
|
||||
{
|
||||
@ -119,15 +120,23 @@ namespace osu.Game.Rulesets.Difficulty
|
||||
/// <summary>
|
||||
/// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can only be used to compute difficulties for legacy mod combinations.
|
||||
/// </remarks>
|
||||
/// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
|
||||
public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
|
||||
public IEnumerable<DifficultyAttributes> CalculateAllLegacyCombinations(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rulesetInstance = ruleset.CreateInstance();
|
||||
|
||||
foreach (var combination in CreateDifficultyAdjustmentModCombinations())
|
||||
{
|
||||
if (combination is MultiMod multi)
|
||||
yield return Calculate(multi.Mods, cancellationToken);
|
||||
else
|
||||
yield return Calculate(combination.Yield(), cancellationToken);
|
||||
Mod classicMod = rulesetInstance.CreateAllMods().SingleOrDefault(m => m is ModClassic);
|
||||
|
||||
var finalCombination = ModUtils.FlattenMod(combination);
|
||||
if (classicMod != null)
|
||||
finalCombination = finalCombination.Append(classicMod);
|
||||
|
||||
yield return Calculate(finalCombination.ToArray(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,8 @@ using System;
|
||||
using System.Linq;
|
||||
using JetBrains.Annotations;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Audio;
|
||||
using osu.Framework.Audio.Sample;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Framework.Threading;
|
||||
using osu.Game.Graphics;
|
||||
@ -27,6 +29,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
|
||||
[CanBeNull]
|
||||
private MultiplayerRoom room => multiplayerClient.Room;
|
||||
|
||||
private Sample countdownTickSample;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(AudioManager audio)
|
||||
{
|
||||
countdownTickSample = audio.Samples.Get(@"Multiplayer/countdown-tick");
|
||||
// disabled for now pending further work on sound effect
|
||||
// countdownTickFinalSample = audio.Samples.Get(@"Multiplayer/countdown-tick-final");
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
@ -36,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
|
||||
}
|
||||
|
||||
private MultiplayerCountdown countdown;
|
||||
private DateTimeOffset countdownChangeTime;
|
||||
private double countdownChangeTime;
|
||||
private ScheduledDelegate countdownUpdateDelegate;
|
||||
|
||||
private void onRoomUpdated() => Scheduler.AddOnce(() =>
|
||||
@ -44,20 +56,55 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
|
||||
if (countdown != room?.Countdown)
|
||||
{
|
||||
countdown = room?.Countdown;
|
||||
countdownChangeTime = DateTimeOffset.Now;
|
||||
countdownChangeTime = Time.Current;
|
||||
}
|
||||
|
||||
scheduleNextCountdownUpdate();
|
||||
|
||||
updateButtonText();
|
||||
updateButtonColour();
|
||||
});
|
||||
|
||||
private void scheduleNextCountdownUpdate()
|
||||
{
|
||||
countdownUpdateDelegate?.Cancel();
|
||||
|
||||
if (countdown != null)
|
||||
countdownUpdateDelegate ??= Scheduler.AddDelayed(updateButtonText, 100, true);
|
||||
{
|
||||
// The remaining time on a countdown may be at a fractional portion between two seconds.
|
||||
// We want to align certain audio/visual cues to the point at which integer seconds change.
|
||||
// To do so, we schedule to the next whole second. Note that scheduler invocation isn't
|
||||
// guaranteed to be accurate, so this may still occur slightly late, but even in such a case
|
||||
// the next invocation will be roughly correct.
|
||||
double timeToNextSecond = countdownTimeRemaining.TotalMilliseconds % 1000;
|
||||
|
||||
countdownUpdateDelegate = Scheduler.AddDelayed(onCountdownTick, timeToNextSecond);
|
||||
}
|
||||
else
|
||||
{
|
||||
countdownUpdateDelegate?.Cancel();
|
||||
countdownUpdateDelegate = null;
|
||||
}
|
||||
|
||||
updateButtonText();
|
||||
updateButtonColour();
|
||||
});
|
||||
void onCountdownTick()
|
||||
{
|
||||
updateButtonText();
|
||||
|
||||
int secondsRemaining = countdownTimeRemaining.Seconds;
|
||||
|
||||
playTickSound(secondsRemaining);
|
||||
|
||||
if (secondsRemaining > 0)
|
||||
scheduleNextCountdownUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void playTickSound(int secondsRemaining)
|
||||
{
|
||||
if (secondsRemaining < 10) countdownTickSample?.Play();
|
||||
// disabled for now pending further work on sound effect
|
||||
// if (secondsRemaining <= 3) countdownTickFinalSample?.Play();
|
||||
}
|
||||
|
||||
private void updateButtonText()
|
||||
{
|
||||
@ -75,15 +122,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
|
||||
|
||||
if (countdown != null)
|
||||
{
|
||||
TimeSpan timeElapsed = DateTimeOffset.Now - countdownChangeTime;
|
||||
TimeSpan countdownRemaining;
|
||||
|
||||
if (timeElapsed > countdown.TimeRemaining)
|
||||
countdownRemaining = TimeSpan.Zero;
|
||||
else
|
||||
countdownRemaining = countdown.TimeRemaining - timeElapsed;
|
||||
|
||||
string countdownText = $"Starting in {countdownRemaining:mm\\:ss}";
|
||||
string countdownText = $"Starting in {countdownTimeRemaining:mm\\:ss}";
|
||||
|
||||
switch (localUser?.State)
|
||||
{
|
||||
@ -116,6 +155,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan countdownTimeRemaining
|
||||
{
|
||||
get
|
||||
{
|
||||
double timeElapsed = Time.Current - countdownChangeTime;
|
||||
TimeSpan remaining;
|
||||
|
||||
if (timeElapsed > countdown.TimeRemaining.TotalMilliseconds)
|
||||
remaining = TimeSpan.Zero;
|
||||
else
|
||||
remaining = countdown.TimeRemaining - TimeSpan.FromMilliseconds(timeElapsed);
|
||||
|
||||
return remaining;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateButtonColour()
|
||||
{
|
||||
if (room == null)
|
||||
|
@ -117,8 +117,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
|
||||
{
|
||||
base.PlaylistItemChanged(item);
|
||||
|
||||
removeItemFromLists(item.ID);
|
||||
addItemToLists(item);
|
||||
var newApiItem = Playlist.SingleOrDefault(i => i.ID == item.ID);
|
||||
var existingApiItemInQueue = queueList.Items.SingleOrDefault(i => i.ID == item.ID);
|
||||
|
||||
// Test if the only change between the two playlist items is the order.
|
||||
if (newApiItem != null && existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem))
|
||||
{
|
||||
// Set the new playlist order directly without refreshing the DrawablePlaylistItem.
|
||||
existingApiItemInQueue.PlaylistOrder = newApiItem.PlaylistOrder;
|
||||
|
||||
// The following isn't really required, but is here for safety and explicitness.
|
||||
// MultiplayerQueueList internally binds to changes in Playlist to invalidate its own layout, which is mutated on every playlist operation.
|
||||
queueList.Invalidate();
|
||||
}
|
||||
else
|
||||
{
|
||||
removeItemFromLists(item.ID);
|
||||
addItemToLists(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void addItemToLists(MultiplayerPlaylistItem item)
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ namespace osu.Game.Screens.Select
|
||||
HeaderText = @"Confirm deletion of";
|
||||
Buttons = new PopupDialogButton[]
|
||||
{
|
||||
new PopupDialogOkButton
|
||||
new PopupDialogDangerousButton
|
||||
{
|
||||
Text = @"Yes. Totally. Delete it.",
|
||||
Action = () => manager?.Delete(beatmap),
|
||||
|
@ -174,7 +174,7 @@ namespace osu.Game.Screens.Select
|
||||
|
||||
public virtual bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
|
||||
{
|
||||
if (e.Action == Hotkey)
|
||||
if (e.Action == Hotkey && !e.Repeat)
|
||||
{
|
||||
TriggerClick();
|
||||
return true;
|
||||
|
@ -38,7 +38,7 @@ namespace osu.Game.Screens.Select
|
||||
HeaderText = "Confirm deletion of local score";
|
||||
Buttons = new PopupDialogButton[]
|
||||
{
|
||||
new PopupDialogOkButton
|
||||
new PopupDialogDangerousButton
|
||||
{
|
||||
Text = "Yes. Please.",
|
||||
Action = () => scoreManager?.Delete(score)
|
||||
|
@ -8,6 +8,7 @@ using osu.Framework.Audio.Sample;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.OpenGL.Textures;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
@ -46,13 +47,13 @@ namespace osu.Game.Skinning
|
||||
this.resources = resources;
|
||||
}
|
||||
|
||||
public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null;
|
||||
public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT);
|
||||
|
||||
public override ISample GetSample(ISampleInfo sampleInfo)
|
||||
{
|
||||
foreach (string lookup in sampleInfo.LookupNames)
|
||||
{
|
||||
var sample = resources.AudioManager.Samples.Get(lookup);
|
||||
var sample = Samples?.Get(lookup) ?? resources.AudioManager.Samples.Get(lookup);
|
||||
if (sample != null)
|
||||
return sample;
|
||||
}
|
||||
@ -157,6 +158,16 @@ namespace osu.Game.Skinning
|
||||
break;
|
||||
}
|
||||
|
||||
switch (component.LookupName)
|
||||
{
|
||||
// Temporary until default skin has a valid hit lighting.
|
||||
case @"lighting":
|
||||
return Drawable.Empty();
|
||||
}
|
||||
|
||||
if (GetTexture(component.LookupName) is Texture t)
|
||||
return new Sprite { Texture = t };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,9 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
@ -11,6 +13,7 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.UserInterface;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Framework.Testing;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Graphics.Cursor;
|
||||
@ -22,7 +25,7 @@ using osu.Game.Screens.Edit.Components.Menus;
|
||||
namespace osu.Game.Skinning.Editor
|
||||
{
|
||||
[Cached(typeof(SkinEditor))]
|
||||
public class SkinEditor : VisibilityContainer
|
||||
public class SkinEditor : VisibilityContainer, ICanAcceptFiles
|
||||
{
|
||||
public const double TRANSITION_DURATION = 500;
|
||||
|
||||
@ -36,12 +39,18 @@ namespace osu.Game.Skinning.Editor
|
||||
|
||||
private Bindable<Skin> currentSkin;
|
||||
|
||||
[Resolved(canBeNull: true)]
|
||||
private OsuGame game { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private SkinManager skins { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private OsuColour colours { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private RealmAccess realm { get; set; }
|
||||
|
||||
[Resolved(canBeNull: true)]
|
||||
private SkinEditorOverlay skinEditorOverlay { get; set; }
|
||||
|
||||
@ -171,6 +180,8 @@ namespace osu.Game.Skinning.Editor
|
||||
|
||||
Show();
|
||||
|
||||
game?.RegisterImportHandler(this);
|
||||
|
||||
// as long as the skin editor is loaded, let's make sure we can modify the current skin.
|
||||
currentSkin = skins.CurrentSkin.GetBoundCopy();
|
||||
|
||||
@ -229,21 +240,29 @@ namespace osu.Game.Skinning.Editor
|
||||
}
|
||||
|
||||
private void placeComponent(Type type)
|
||||
{
|
||||
if (!(Activator.CreateInstance(type) is ISkinnableDrawable component))
|
||||
throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}.");
|
||||
|
||||
placeComponent(component);
|
||||
}
|
||||
|
||||
private void placeComponent(ISkinnableDrawable component, bool applyDefaults = true)
|
||||
{
|
||||
var targetContainer = getFirstTarget();
|
||||
|
||||
if (targetContainer == null)
|
||||
return;
|
||||
|
||||
if (!(Activator.CreateInstance(type) is ISkinnableDrawable component))
|
||||
throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}.");
|
||||
|
||||
var drawableComponent = (Drawable)component;
|
||||
|
||||
// give newly added components a sane starting location.
|
||||
drawableComponent.Origin = Anchor.TopCentre;
|
||||
drawableComponent.Anchor = Anchor.TopCentre;
|
||||
drawableComponent.Y = targetContainer.DrawSize.Y / 2;
|
||||
if (applyDefaults)
|
||||
{
|
||||
// give newly added components a sane starting location.
|
||||
drawableComponent.Origin = Anchor.TopCentre;
|
||||
drawableComponent.Anchor = Anchor.TopCentre;
|
||||
drawableComponent.Y = targetContainer.DrawSize.Y / 2;
|
||||
}
|
||||
|
||||
targetContainer.Add(component);
|
||||
|
||||
@ -313,5 +332,54 @@ namespace osu.Game.Skinning.Editor
|
||||
foreach (var item in items)
|
||||
availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item);
|
||||
}
|
||||
|
||||
#region Drag & drop import handling
|
||||
|
||||
public Task Import(params string[] paths)
|
||||
{
|
||||
Schedule(() =>
|
||||
{
|
||||
var file = new FileInfo(paths.First());
|
||||
|
||||
// import to skin
|
||||
currentSkin.Value.SkinInfo.PerformWrite(skinInfo =>
|
||||
{
|
||||
using (var contents = file.OpenRead())
|
||||
skins.AddFile(skinInfo, contents, file.Name);
|
||||
});
|
||||
|
||||
// Even though we are 100% on an update thread, we need to wait for realm callbacks to fire (to correctly invalidate caches in RealmBackedResourceStore).
|
||||
// See https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-2483573 for further discussion.
|
||||
// This is the best we can do for now.
|
||||
realm.Run(r => r.Refresh());
|
||||
|
||||
// place component
|
||||
var sprite = new SkinnableSprite
|
||||
{
|
||||
SpriteName = { Value = file.Name },
|
||||
Origin = Anchor.Centre,
|
||||
Position = getFirstTarget().ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position),
|
||||
};
|
||||
|
||||
placeComponent(sprite, false);
|
||||
|
||||
SkinSelectionHandler.ApplyClosestAnchor(sprite);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Import(params ImportTask[] tasks) => throw new NotImplementedException();
|
||||
|
||||
public IEnumerable<string> HandledExtensions => new[] { ".jpg", ".jpeg", ".png" };
|
||||
|
||||
#endregion
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
|
||||
game?.UnregisterImportHandler(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -157,13 +157,13 @@ namespace osu.Game.Skinning.Editor
|
||||
|
||||
if (item.UsesFixedAnchor) continue;
|
||||
|
||||
applyClosestAnchor(drawable);
|
||||
ApplyClosestAnchor(drawable);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void applyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable));
|
||||
public static void ApplyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable));
|
||||
|
||||
protected override void OnSelectionChanged()
|
||||
{
|
||||
@ -252,7 +252,7 @@ namespace osu.Game.Skinning.Editor
|
||||
|
||||
if (item.UsesFixedAnchor) continue;
|
||||
|
||||
applyClosestAnchor(drawable);
|
||||
ApplyClosestAnchor(drawable);
|
||||
}
|
||||
}
|
||||
|
||||
@ -279,7 +279,7 @@ namespace osu.Game.Skinning.Editor
|
||||
foreach (var item in SelectedItems)
|
||||
{
|
||||
item.UsesFixedAnchor = false;
|
||||
applyClosestAnchor((Drawable)item);
|
||||
ApplyClosestAnchor((Drawable)item);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ using osu.Framework.IO.Stores;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Beatmaps.Formats;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.Rulesets.Objects.Legacy;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
@ -37,11 +38,11 @@ namespace osu.Game.Skinning
|
||||
|
||||
private static IResourceStore<byte[]> createRealmBackedStore(BeatmapInfo beatmapInfo, IStorageResourceProvider? resources)
|
||||
{
|
||||
if (resources == null)
|
||||
if (resources == null || beatmapInfo.BeatmapSet == null)
|
||||
// should only ever be used in tests.
|
||||
return new ResourceStore<byte[]>();
|
||||
|
||||
return new RealmBackedResourceStore(beatmapInfo.BeatmapSet, resources.Files, new[] { @"ogg" });
|
||||
return new RealmBackedResourceStore<BeatmapSetInfo>(beatmapInfo.BeatmapSet.ToLive(resources.RealmAccess), resources.Files, resources.RealmAccess);
|
||||
}
|
||||
|
||||
public override Drawable? GetDrawableComponent(ISkinComponent component)
|
||||
|
@ -23,7 +23,7 @@ namespace osu.Game.Skinning
|
||||
/// The <see cref="ISkin"/> which is being transformed.
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
protected ISkin Skin { get; }
|
||||
protected internal ISkin Skin { get; }
|
||||
|
||||
protected LegacySkinTransformer([NotNull] ISkin skin)
|
||||
{
|
||||
|
@ -1,51 +1,77 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using osu.Framework.Extensions;
|
||||
using osu.Framework.IO.Stores;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Extensions;
|
||||
using Realms;
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
public class RealmBackedResourceStore : ResourceStore<byte[]>
|
||||
public class RealmBackedResourceStore<T> : ResourceStore<byte[]>
|
||||
where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey
|
||||
{
|
||||
private readonly Dictionary<string, string> fileToStoragePathMapping = new Dictionary<string, string>();
|
||||
private Lazy<Dictionary<string, string>> fileToStoragePathMapping;
|
||||
|
||||
public RealmBackedResourceStore(IHasRealmFiles source, IResourceStore<byte[]> underlyingStore, string[] extensions = null)
|
||||
private readonly Live<T> liveSource;
|
||||
private readonly IDisposable? realmSubscription;
|
||||
|
||||
public RealmBackedResourceStore(Live<T> source, IResourceStore<byte[]> underlyingStore, RealmAccess? realm)
|
||||
: base(underlyingStore)
|
||||
{
|
||||
// Must be initialised before the file cache.
|
||||
if (extensions != null)
|
||||
{
|
||||
foreach (string extension in extensions)
|
||||
AddExtension(extension);
|
||||
}
|
||||
liveSource = source;
|
||||
|
||||
initialiseFileCache(source);
|
||||
invalidateCache();
|
||||
Debug.Assert(fileToStoragePathMapping != null);
|
||||
|
||||
realmSubscription = realm?.RegisterForNotifications(r => r.All<T>().Where(s => s.ID == source.ID), skinChanged);
|
||||
}
|
||||
|
||||
private void initialiseFileCache(IHasRealmFiles source)
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
fileToStoragePathMapping.Clear();
|
||||
foreach (var f in source.Files)
|
||||
fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
|
||||
base.Dispose(disposing);
|
||||
realmSubscription?.Dispose();
|
||||
}
|
||||
|
||||
private void skinChanged(IRealmCollection<T> sender, ChangeSet changes, Exception error) => invalidateCache();
|
||||
|
||||
protected override IEnumerable<string> GetFilenames(string name)
|
||||
{
|
||||
foreach (string filename in base.GetFilenames(name))
|
||||
{
|
||||
string path = getPathForFile(filename.ToStandardisedPath());
|
||||
string? path = getPathForFile(filename.ToStandardisedPath());
|
||||
if (path != null)
|
||||
yield return path;
|
||||
}
|
||||
}
|
||||
|
||||
private string getPathForFile(string filename) =>
|
||||
fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null;
|
||||
private string? getPathForFile(string filename)
|
||||
{
|
||||
if (fileToStoragePathMapping.Value.TryGetValue(filename.ToLowerInvariant(), out string path))
|
||||
return path;
|
||||
|
||||
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Keys;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void invalidateCache() => fileToStoragePathMapping = new Lazy<Dictionary<string, string>>(initialiseFileCache);
|
||||
|
||||
private Dictionary<string, string> initialiseFileCache() => liveSource.PerformRead(source =>
|
||||
{
|
||||
var dictionary = new Dictionary<string, string>();
|
||||
dictionary.Clear();
|
||||
foreach (var f in source.Files)
|
||||
dictionary[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
|
||||
|
||||
return dictionary;
|
||||
});
|
||||
|
||||
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Value.Keys;
|
||||
}
|
||||
}
|
||||
|
@ -54,6 +54,8 @@ namespace osu.Game.Skinning
|
||||
where TLookup : notnull
|
||||
where TValue : notnull;
|
||||
|
||||
private readonly RealmBackedResourceStore<SkinInfo>? realmBackedStorage;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a new skin.
|
||||
/// </summary>
|
||||
@ -67,7 +69,9 @@ namespace osu.Game.Skinning
|
||||
{
|
||||
SkinInfo = skin.ToLive(resources.RealmAccess);
|
||||
|
||||
storage ??= new RealmBackedResourceStore(skin, resources.Files, new[] { @"ogg" });
|
||||
storage ??= realmBackedStorage = new RealmBackedResourceStore<SkinInfo>(SkinInfo, resources.Files, resources.RealmAccess);
|
||||
|
||||
(storage as ResourceStore<byte[]>)?.AddExtension("ogg");
|
||||
|
||||
var samples = resources.AudioManager?.GetSampleStore(storage);
|
||||
if (samples != null)
|
||||
@ -155,16 +159,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
|
||||
{
|
||||
@ -200,6 +195,8 @@ namespace osu.Game.Skinning
|
||||
|
||||
Textures?.Dispose();
|
||||
Samples?.Dispose();
|
||||
|
||||
realmBackedStorage?.Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading;
|
||||
@ -23,7 +24,9 @@ using osu.Game.Audio;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.IO.Archives;
|
||||
using osu.Game.Models;
|
||||
using osu.Game.Overlays.Notifications;
|
||||
using osu.Game.Utils;
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
@ -35,7 +38,7 @@ namespace osu.Game.Skinning
|
||||
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
|
||||
/// </remarks>
|
||||
[ExcludeFromDynamicCompile]
|
||||
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>
|
||||
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>, IModelManager<SkinInfo>, IModelFileManager<SkinInfo, RealmNamedFileUsage>
|
||||
{
|
||||
private readonly AudioManager audio;
|
||||
|
||||
@ -95,7 +98,10 @@ namespace osu.Game.Skinning
|
||||
}
|
||||
});
|
||||
|
||||
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
|
||||
CurrentSkinInfo.ValueChanged += skin =>
|
||||
{
|
||||
CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
|
||||
};
|
||||
|
||||
CurrentSkin.Value = DefaultSkin;
|
||||
CurrentSkin.ValueChanged += skin =>
|
||||
@ -144,20 +150,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;
|
||||
}
|
||||
});
|
||||
@ -306,5 +318,45 @@ namespace osu.Game.Skinning
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public bool Delete(SkinInfo item)
|
||||
{
|
||||
return skinModelManager.Delete(item);
|
||||
}
|
||||
|
||||
public void Delete(List<SkinInfo> items, bool silent = false)
|
||||
{
|
||||
skinModelManager.Delete(items, silent);
|
||||
}
|
||||
|
||||
public void Undelete(List<SkinInfo> items, bool silent = false)
|
||||
{
|
||||
skinModelManager.Undelete(items, silent);
|
||||
}
|
||||
|
||||
public void Undelete(SkinInfo item)
|
||||
{
|
||||
skinModelManager.Undelete(item);
|
||||
}
|
||||
|
||||
public bool IsAvailableLocally(SkinInfo model)
|
||||
{
|
||||
return skinModelManager.IsAvailableLocally(model);
|
||||
}
|
||||
|
||||
public void ReplaceFile(SkinInfo model, RealmNamedFileUsage file, Stream contents)
|
||||
{
|
||||
skinModelManager.ReplaceFile(model, file, contents);
|
||||
}
|
||||
|
||||
public void DeleteFile(SkinInfo model, RealmNamedFileUsage file)
|
||||
{
|
||||
skinModelManager.DeleteFile(model, file);
|
||||
}
|
||||
|
||||
public void AddFile(SkinInfo model, Stream contents, string filename)
|
||||
{
|
||||
skinModelManager.AddFile(model, contents, filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ namespace osu.Game.Skinning
|
||||
set => base.AutoSizeAxes = value;
|
||||
}
|
||||
|
||||
private readonly ISkinComponent component;
|
||||
protected readonly ISkinComponent Component;
|
||||
|
||||
private readonly ConfineMode confineMode;
|
||||
|
||||
@ -49,7 +49,7 @@ namespace osu.Game.Skinning
|
||||
|
||||
protected SkinnableDrawable(ISkinComponent component, ConfineMode confineMode = ConfineMode.NoScaling)
|
||||
{
|
||||
this.component = component;
|
||||
Component = component;
|
||||
this.confineMode = confineMode;
|
||||
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
@ -75,13 +75,13 @@ namespace osu.Game.Skinning
|
||||
|
||||
protected override void SkinChanged(ISkinSource skin)
|
||||
{
|
||||
Drawable = skin.GetDrawableComponent(component);
|
||||
Drawable = skin.GetDrawableComponent(Component);
|
||||
|
||||
isDefault = false;
|
||||
|
||||
if (Drawable == null)
|
||||
{
|
||||
Drawable = CreateDefault(component);
|
||||
Drawable = CreateDefault(Component);
|
||||
isDefault = true;
|
||||
}
|
||||
|
||||
|
@ -1,26 +1,56 @@
|
||||
// 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.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Configuration;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Overlays.Settings;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
{
|
||||
/// <summary>
|
||||
/// A skinnable element which uses a stable sprite and can therefore share implementation logic.
|
||||
/// A skinnable element which uses a single texture backing.
|
||||
/// </summary>
|
||||
public class SkinnableSprite : SkinnableDrawable
|
||||
public class SkinnableSprite : SkinnableDrawable, ISkinnableDrawable
|
||||
{
|
||||
protected override bool ApplySizeRestrictionsToDefault => true;
|
||||
|
||||
[Resolved]
|
||||
private TextureStore textures { get; set; }
|
||||
|
||||
[SettingSource("Sprite name", "The filename of the sprite", SettingControlType = typeof(SpriteSelectorControl))]
|
||||
public Bindable<string> SpriteName { get; } = new Bindable<string>(string.Empty);
|
||||
|
||||
[Resolved]
|
||||
private ISkinSource source { get; set; }
|
||||
|
||||
public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling)
|
||||
: base(new SpriteComponent(textureName), confineMode)
|
||||
{
|
||||
SpriteName.Value = textureName;
|
||||
}
|
||||
|
||||
public SkinnableSprite()
|
||||
: base(new SpriteComponent(string.Empty), ConfineMode.NoScaling)
|
||||
{
|
||||
RelativeSizeAxes = Axes.None;
|
||||
AutoSizeAxes = Axes.Both;
|
||||
|
||||
SpriteName.BindValueChanged(name =>
|
||||
{
|
||||
((SpriteComponent)Component).LookupName = name.NewValue ?? string.Empty;
|
||||
if (IsLoaded)
|
||||
SkinChanged(CurrentSkin);
|
||||
});
|
||||
}
|
||||
|
||||
protected override Drawable CreateDefault(ISkinComponent component)
|
||||
@ -28,19 +58,85 @@ namespace osu.Game.Skinning
|
||||
var texture = textures.Get(component.LookupName);
|
||||
|
||||
if (texture == null)
|
||||
return null;
|
||||
return new SpriteNotFound(component.LookupName);
|
||||
|
||||
return new Sprite { Texture = texture };
|
||||
}
|
||||
|
||||
public bool UsesFixedAnchor { get; set; }
|
||||
|
||||
private class SpriteComponent : ISkinComponent
|
||||
{
|
||||
public string LookupName { get; set; }
|
||||
|
||||
public SpriteComponent(string textureName)
|
||||
{
|
||||
LookupName = textureName;
|
||||
}
|
||||
}
|
||||
|
||||
public string LookupName { get; }
|
||||
public class SpriteSelectorControl : SettingsDropdown<string>
|
||||
{
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
// Round-about way of getting the user's skin to find available resources.
|
||||
// In the future we'll probably want to allow access to resources from the fallbacks, or potentially other skins
|
||||
// but that requires further thought.
|
||||
var highestPrioritySkin = getHighestPriorityUserSkin(((SkinnableSprite)SettingSourceObject).source.AllSources) as Skin;
|
||||
|
||||
string[] availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files
|
||||
.Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal)
|
||||
|| f.Filename.EndsWith(".jpg", StringComparison.Ordinal))
|
||||
.Select(f => f.Filename).Distinct()).ToArray();
|
||||
|
||||
if (availableFiles?.Length > 0)
|
||||
Items = availableFiles;
|
||||
|
||||
static ISkin getHighestPriorityUserSkin(IEnumerable<ISkin> skins)
|
||||
{
|
||||
foreach (var skin in skins)
|
||||
{
|
||||
if (skin is LegacySkinTransformer transformer && isUserSkin(transformer.Skin))
|
||||
return transformer.Skin;
|
||||
|
||||
if (isUserSkin(skin))
|
||||
return skin;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Temporarily used to exclude undesirable ISkin implementations
|
||||
static bool isUserSkin(ISkin skin)
|
||||
=> skin.GetType() == typeof(DefaultSkin)
|
||||
|| skin.GetType() == typeof(DefaultLegacySkin)
|
||||
|| skin.GetType() == typeof(LegacySkin);
|
||||
}
|
||||
}
|
||||
|
||||
public class SpriteNotFound : CompositeDrawable
|
||||
{
|
||||
public SpriteNotFound(string lookup)
|
||||
{
|
||||
AutoSizeAxes = Axes.Both;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new SpriteIcon
|
||||
{
|
||||
Size = new Vector2(50),
|
||||
Icon = FontAwesome.Solid.QuestionCircle
|
||||
},
|
||||
new OsuSpriteText
|
||||
{
|
||||
Position = new Vector2(25, 50),
|
||||
Text = $"missing: {lookup}",
|
||||
Origin = Anchor.TopCentre,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,10 +22,13 @@ namespace osu.Game.Tests.Beatmaps
|
||||
|
||||
protected abstract string ResourceAssembly { get; }
|
||||
|
||||
protected void Test(double expected, string name, params Mod[] mods)
|
||||
protected void Test(double expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods)
|
||||
{
|
||||
var attributes = CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods);
|
||||
|
||||
// Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences.
|
||||
Assert.That(CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods).StarRating, Is.EqualTo(expected).Within(0.00001));
|
||||
Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001));
|
||||
Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo));
|
||||
}
|
||||
|
||||
private IWorkingBeatmap getBeatmap(string name)
|
||||
|
@ -31,7 +31,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
public override IBindable<bool> IsConnected => isConnected;
|
||||
private readonly Bindable<bool> isConnected = new Bindable<bool>(true);
|
||||
|
||||
/// <summary>
|
||||
/// The local client's <see cref="Room"/>. This is not always equivalent to the server-side room.
|
||||
/// </summary>
|
||||
public new Room? APIRoom => base.APIRoom;
|
||||
|
||||
public Action<MultiplayerRoom>? RoomSetupAction;
|
||||
|
||||
public bool RoomJoined { get; private set; }
|
||||
@ -46,6 +50,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
/// </summary>
|
||||
private readonly List<MultiplayerPlaylistItem> serverSidePlaylist = new List<MultiplayerPlaylistItem>();
|
||||
|
||||
/// <summary>
|
||||
/// Guaranteed up-to-date API room.
|
||||
/// </summary>
|
||||
private Room? serverSideAPIRoom;
|
||||
|
||||
private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex];
|
||||
private int currentIndex;
|
||||
private long lastPlaylistItemId;
|
||||
@ -192,13 +201,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
protected override async Task<MultiplayerRoom> JoinRoom(long roomId, string? password = null)
|
||||
{
|
||||
var apiRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId);
|
||||
serverSideAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId);
|
||||
|
||||
if (password != apiRoom.Password.Value)
|
||||
if (password != serverSideAPIRoom.Password.Value)
|
||||
throw new InvalidOperationException("Invalid password.");
|
||||
|
||||
serverSidePlaylist.Clear();
|
||||
serverSidePlaylist.AddRange(apiRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)));
|
||||
serverSidePlaylist.AddRange(serverSideAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)));
|
||||
lastPlaylistItemId = serverSidePlaylist.Max(item => item.ID);
|
||||
|
||||
var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id)
|
||||
@ -210,11 +219,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
Settings =
|
||||
{
|
||||
Name = apiRoom.Name.Value,
|
||||
MatchType = apiRoom.Type.Value,
|
||||
Name = serverSideAPIRoom.Name.Value,
|
||||
MatchType = serverSideAPIRoom.Type.Value,
|
||||
Password = password,
|
||||
QueueMode = apiRoom.QueueMode.Value,
|
||||
AutoStartDuration = apiRoom.AutoStartDuration.Value
|
||||
QueueMode = serverSideAPIRoom.QueueMode.Value,
|
||||
AutoStartDuration = serverSideAPIRoom.AutoStartDuration.Value
|
||||
},
|
||||
Playlist = serverSidePlaylist.ToList(),
|
||||
Users = { localUser },
|
||||
@ -449,8 +458,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item)
|
||||
{
|
||||
Debug.Assert(Room != null);
|
||||
Debug.Assert(APIRoom != null);
|
||||
Debug.Assert(currentItem != null);
|
||||
Debug.Assert(serverSideAPIRoom != null);
|
||||
|
||||
item.OwnerID = userId;
|
||||
|
||||
@ -469,6 +478,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
item.PlaylistOrder = existingItem.PlaylistOrder;
|
||||
|
||||
serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item;
|
||||
serverSideAPIRoom.Playlist[serverSideAPIRoom.Playlist.IndexOf(serverSideAPIRoom.Playlist.Single(i => i.ID == item.ID))] = new PlaylistItem(item);
|
||||
|
||||
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
|
||||
}
|
||||
@ -479,6 +489,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
Debug.Assert(Room != null);
|
||||
Debug.Assert(APIRoom != null);
|
||||
Debug.Assert(serverSideAPIRoom != null);
|
||||
|
||||
var item = serverSidePlaylist.Find(i => i.ID == playlistItemId);
|
||||
|
||||
@ -495,6 +506,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
throw new InvalidOperationException("Attempted to remove an item which has already been played.");
|
||||
|
||||
serverSidePlaylist.Remove(item);
|
||||
serverSideAPIRoom.Playlist.RemoveAll(i => i.ID == item.ID);
|
||||
await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false);
|
||||
|
||||
await updateCurrentItem(Room).ConfigureAwait(false);
|
||||
@ -576,10 +588,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
private async Task addItem(MultiplayerPlaylistItem item)
|
||||
{
|
||||
Debug.Assert(Room != null);
|
||||
Debug.Assert(serverSideAPIRoom != null);
|
||||
|
||||
item.ID = ++lastPlaylistItemId;
|
||||
|
||||
serverSidePlaylist.Add(item);
|
||||
serverSideAPIRoom.Playlist.Add(new PlaylistItem(item));
|
||||
await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false);
|
||||
|
||||
await updatePlaylistOrder(Room).ConfigureAwait(false);
|
||||
@ -603,6 +617,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
private async Task updatePlaylistOrder(MultiplayerRoom room)
|
||||
{
|
||||
Debug.Assert(serverSideAPIRoom != null);
|
||||
|
||||
List<MultiplayerPlaylistItem> orderedActiveItems;
|
||||
|
||||
switch (room.Settings.QueueMode)
|
||||
@ -648,6 +664,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Also ensure that the API room's playlist is correct.
|
||||
foreach (var item in serverSideAPIRoom.Playlist)
|
||||
item.PlaylistOrder = serverSidePlaylist.Single(i => i.ID == item.ID).PlaylistOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,8 +36,8 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Realm" Version="10.10.0" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2022.325.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
|
||||
<PackageReference Include="Sentry" Version="3.14.1" />
|
||||
<PackageReference Include="SharpCompress" Version="0.30.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
|
@ -61,8 +61,8 @@
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Package References">
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.325.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.325.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.404.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.405.0" />
|
||||
</ItemGroup>
|
||||
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
|
||||
<PropertyGroup>
|
||||
@ -84,7 +84,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2022.325.0" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2022.404.0" />
|
||||
<PackageReference Include="SharpCompress" Version="0.30.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
||||
|
Loading…
Reference in New Issue
Block a user