1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 10:07:52 +08:00

Merge branch 'master' into gameplay-hud-redesign/counters

This commit is contained in:
Dean Herbert 2023-10-30 16:35:10 +09:00
commit caddbacfe8
No known key found for this signature in database
98 changed files with 1729 additions and 771 deletions

View File

@ -7,7 +7,7 @@ Templates for use when creating osu! dependent projects. Create a fully-testable
```bash
# install (or update) templates package.
# this only needs to be done once
dotnet new -i ppy.osu.Game.Templates
dotnet new install ppy.osu.Game.Templates
# create an empty freeform ruleset
dotnet new ruleset -n MyCoolRuleset

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
@ -179,5 +180,33 @@ namespace osu.Game.Rulesets.Catch.Edit
return null;
}
}
protected override void Update()
{
base.Update();
updateDistanceSnapGrid();
}
private void updateDistanceSnapGrid()
{
if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True)
{
distanceSnapGrid.Hide();
return;
}
var sourceHitObject = getDistanceSnapGridSourceHitObject();
if (sourceHitObject == null)
{
distanceSnapGrid.Hide();
return;
}
distanceSnapGrid.Show();
distanceSnapGrid.StartTime = sourceHitObject.GetEndTime();
distanceSnapGrid.StartX = sourceHitObject.EffectiveX;
}
}
}

View File

@ -108,7 +108,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
RelativeSizeAxes = Axes.X
},
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
slidingSample = new PausableSkinnableSound { Looping = true }
slidingSample = new PausableSkinnableSound
{
Looping = true,
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
}
});
maskedContents.AddRange(new[]

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -1,3 +1,4 @@
[General]
Version: latest
HitCircleOverlayAboveNumber: 0
HitCirclePrefix: display

View File

@ -0,0 +1,135 @@
// 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.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Tests
{
[TestFixture]
public class SpinnerSpinHistoryTest
{
private SpinnerSpinHistory history = null!;
[SetUp]
public void Setup()
{
history = new SpinnerSpinHistory();
}
[TestCase(0, 0)]
[TestCase(10, 10)]
[TestCase(180, 180)]
[TestCase(350, 350)]
[TestCase(360, 360)]
[TestCase(370, 370)]
[TestCase(540, 540)]
[TestCase(720, 720)]
// ---
[TestCase(-0, 0)]
[TestCase(-10, 10)]
[TestCase(-180, 180)]
[TestCase(-350, 350)]
[TestCase(-360, 360)]
[TestCase(-370, 370)]
[TestCase(-540, 540)]
[TestCase(-720, 720)]
public void TestSpinOneDirection(float spin, float expectedRotation)
{
history.ReportDelta(500, spin);
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
}
[TestCase(0, 0, 0, 0)]
// ---
[TestCase(10, -10, 0, 10)]
[TestCase(-10, 10, 0, 10)]
// ---
[TestCase(10, -20, 0, 10)]
[TestCase(-10, 20, 0, 10)]
// ---
[TestCase(20, -10, 0, 20)]
[TestCase(-20, 10, 0, 20)]
// ---
[TestCase(10, -360, 0, 350)]
[TestCase(-10, 360, 0, 350)]
// ---
[TestCase(360, -10, 0, 370)]
[TestCase(360, 10, 0, 370)]
[TestCase(-360, 10, 0, 370)]
[TestCase(-360, -10, 0, 370)]
// ---
[TestCase(10, 10, 10, 30)]
[TestCase(10, 10, -10, 20)]
[TestCase(10, -10, 10, 10)]
[TestCase(-10, -10, -10, 30)]
[TestCase(-10, -10, 10, 20)]
[TestCase(-10, 10, 10, 10)]
// ---
[TestCase(10, -20, -350, 360)]
[TestCase(10, -20, 350, 340)]
[TestCase(-10, 20, 350, 360)]
[TestCase(-10, 20, -350, 340)]
public void TestSpinMultipleDirections(float spin1, float spin2, float spin3, float expectedRotation)
{
history.ReportDelta(500, spin1);
history.ReportDelta(1000, spin2);
history.ReportDelta(1500, spin3);
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
}
// One spin
[TestCase(370, -50, 320)]
[TestCase(-370, 50, 320)]
// Two spins
[TestCase(740, -420, 320)]
[TestCase(-740, 420, 320)]
public void TestRemoveAndCrossFullSpin(float deltaToAdd, float deltaToRemove, float expectedRotation)
{
history.ReportDelta(1000, deltaToAdd);
history.ReportDelta(500, deltaToRemove);
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
}
// One spin + partial
[TestCase(400, -30, -50, 320)]
[TestCase(-400, 30, 50, 320)]
// Two spins + partial
[TestCase(800, -430, -50, 320)]
[TestCase(-800, 430, 50, 320)]
public void TestRemoveAndCrossFullAndPartialSpins(float deltaToAdd1, float deltaToAdd2, float deltaToRemove, float expectedRotation)
{
history.ReportDelta(1000, deltaToAdd1);
history.ReportDelta(1500, deltaToAdd2);
history.ReportDelta(500, deltaToRemove);
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
}
[Test]
public void TestRewindMultipleFullSpins()
{
history.ReportDelta(500, 360);
history.ReportDelta(1000, 720);
Assert.That(history.TotalRotation, Is.EqualTo(1080));
history.ReportDelta(250, -900);
Assert.That(history.TotalRotation, Is.EqualTo(180));
}
[Test]
public void TestRewindOverDirectionChange()
{
history.ReportDelta(1000, 40); // max is now CW 40 degrees
Assert.That(history.TotalRotation, Is.EqualTo(40));
history.ReportDelta(1100, -90); // max is now CCW 50 degrees
Assert.That(history.TotalRotation, Is.EqualTo(50));
history.ReportDelta(1200, 110); // max is now CW 60 degrees
Assert.That(history.TotalRotation, Is.EqualTo(60));
history.ReportDelta(1000, -20);
Assert.That(history.TotalRotation, Is.EqualTo(40));
}
}
}

View File

@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
RelativeSizeAxes = Axes.Both,
Size = new Vector2(0.8f),
Child = new MovingCursorInputManager { Child = createContent?.Invoke() }
Child = new MovingCursorInputManager { Child = createContent() }
});
});

View File

@ -34,6 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep("Hit Big Stream", () => SetContents(_ => testStream(2, true)));
AddStep("Hit Medium Stream", () => SetContents(_ => testStream(5, true)));
AddStep("Hit Small Stream", () => SetContents(_ => testStream(7, true)));
AddStep("High combo index", () => SetContents(_ => testSingle(2, true, comboIndex: 15)));
}
[Test]
@ -66,12 +67,12 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep("Hit Big Single", () => SetContents(_ => testSingle(2, true)));
}
private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null)
private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null, int comboIndex = 0)
{
var playfield = new TestOsuPlayfield();
for (double t = timeOffset; t < timeOffset + 60000; t += 2000)
playfield.Add(createSingle(circleSize, auto, t, positionOffset));
playfield.Add(createSingle(circleSize, auto, t, positionOffset, comboIndex: comboIndex));
return playfield;
}
@ -84,14 +85,14 @@ namespace osu.Game.Rulesets.Osu.Tests
for (int i = 0; i <= 1000; i += 100)
{
playfield.Add(createSingle(circleSize, auto, i, pos, hitOffset));
playfield.Add(createSingle(circleSize, auto, i, pos, hitOffset, i / 100 - 1));
pos.X += 50;
}
return playfield;
}
private TestDrawableHitCircle createSingle(float circleSize, bool auto, double timeOffset, Vector2? positionOffset, double hitOffset = 0)
private TestDrawableHitCircle createSingle(float circleSize, bool auto, double timeOffset, Vector2? positionOffset, double hitOffset = 0, int comboIndex = 0)
{
positionOffset ??= Vector2.Zero;
@ -99,6 +100,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
StartTime = Time.Current + 1000 + timeOffset,
Position = OsuPlayfield.BASE_SIZE / 4 + positionOffset.Value,
IndexInCurrentCombo = comboIndex,
};
circle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize });

View File

@ -38,6 +38,42 @@ namespace osu.Game.Rulesets.Osu.Tests
private readonly List<JudgementResult> judgementResults = new List<JudgementResult>();
[TestCase(30, 0)]
[TestCase(30, 1)]
[TestCase(40, 0)]
[TestCase(40, 1)]
[TestCase(50, 1)]
[TestCase(60, 1)]
[TestCase(70, 1)]
[TestCase(80, 1)]
[TestCase(80, 0)]
[TestCase(80, 10)]
[TestCase(90, 1)]
[Ignore("headless test doesn't run at high enough precision for this to always enter a tracking state in time.")]
public void TestVeryShortSliderMissHead(float sliderLength, int repeatCount)
{
performTest(new List<ReplayFrame>
{
new OsuReplayFrame { Position = new Vector2(50, 0), Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start - 10 },
new OsuReplayFrame { Position = new Vector2(50, 0), Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start + 2000 },
}, new Slider
{
StartTime = time_slider_start,
Position = new Vector2(0, 0),
SliderVelocityMultiplier = 10f,
RepeatCount = repeatCount,
Path = new SliderPath(PathType.Linear, new[]
{
Vector2.Zero,
new Vector2(sliderLength, 0),
}),
}, 240, 1);
AddAssert("Head judgement is first", () => judgementResults[0].HitObject is SliderHeadCircle);
AddAssert("Tail judgement is second last", () => judgementResults[^2].HitObject is SliderTailCircle);
AddAssert("Slider judgement is last", () => judgementResults[^1].HitObject is Slider);
}
// Making these too short causes breakage from frames not being processed fast enough.
// To keep things simple, these tests are crafted to always be >16ms length.
// If sliders shorter than this are ever used in gameplay it will probably break things and we can revisit.
@ -76,6 +112,8 @@ namespace osu.Game.Rulesets.Osu.Tests
assertAllMaxJudgements();
AddAssert("Head judgement is first", () => judgementResults.First().HitObject is SliderHeadCircle);
// Even if the last tick is hit early, the slider should always execute its final judgement at its endtime.
// If not, hitsounds will not play on time.
AddAssert("Judgement offset is zero", () => judgementResults.Last().TimeOffset == 0);
@ -121,6 +159,8 @@ namespace osu.Game.Rulesets.Osu.Tests
else
AddAssert("Tracking dropped", assertMidSliderJudgementFail);
AddAssert("Head judgement is first", () => judgementResults.First().HitObject is SliderHeadCircle);
// Even if the last tick is hit early, the slider should always execute its final judgement at its endtime.
// If not, hitsounds will not play on time.
AddAssert("Judgement offset is zero", () => judgementResults.Last().TimeOffset == 0);

View File

@ -53,7 +53,6 @@ namespace osu.Game.Rulesets.Osu.Tests
/// While off-centre, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms.
/// </summary>
[Test]
[Ignore("An upcoming implementation will fix this case")]
public void TestVibrateWithoutSpinningOffCentre()
{
List<ReplayFrame> frames = new List<ReplayFrame>();
@ -81,7 +80,6 @@ namespace osu.Game.Rulesets.Osu.Tests
/// While centred on the slider, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms.
/// </summary>
[Test]
[Ignore("An upcoming implementation will fix this case")]
public void TestVibrateWithoutSpinningOnCentre()
{
List<ReplayFrame> frames = new List<ReplayFrame>();
@ -130,7 +128,6 @@ namespace osu.Game.Rulesets.Osu.Tests
/// No ticks should be hit since the total rotation is -0.5 (0.5 CW + 1 CCW = 0.5 CCW).
/// </summary>
[Test]
[Ignore("An upcoming implementation will fix this case")]
public void TestSpinHalfBothDirections()
{
performTest(new SpinFramesGenerator(time_spinner_start)
@ -149,7 +146,6 @@ namespace osu.Game.Rulesets.Osu.Tests
[TestCase(-180, 540, 1)]
[TestCase(180, -900, 2)]
[TestCase(-180, 900, 2)]
[Ignore("An upcoming implementation will fix this case")]
public void TestSpinOneDirectionThenChangeDirection(float direction1, float direction2, int expectedTicks)
{
performTest(new SpinFramesGenerator(time_spinner_start)
@ -162,18 +158,28 @@ namespace osu.Game.Rulesets.Osu.Tests
}
[Test]
[Ignore("An upcoming implementation will fix this case")]
public void TestRewind()
{
AddStep("set manual clock", () => manualClock = new ManualClock { Rate = 1 });
AddStep("set manual clock", () => manualClock = new ManualClock
{
// Avoids interpolation trying to run ahead during testing.
Rate = 0
});
List<ReplayFrame> frames = new SpinFramesGenerator(time_spinner_start)
.Spin(360, 500) // 2000ms -> 1 full CW spin
.Spin(-180, 500) // 2500ms -> 0.5 CCW spins
.Spin(90, 500) // 3000ms -> 0.25 CW spins
.Spin(450, 500) // 3500ms -> 1 full CW spin
.Spin(180, 500) // 4000ms -> 0.5 CW spins
.Build();
List<ReplayFrame> frames =
new SpinFramesGenerator(time_spinner_start)
// 1500ms start
.Spin(360, 500)
// 2000ms -> 1 full CW spin
.Spin(-180, 500)
// 2500ms -> 1 full CW spin + 0.5 CCW spins
.Spin(90, 500)
// 3000ms -> 1 full CW spin + 0.25 CCW spins
.Spin(450, 500)
// 3500ms -> 2 full CW spins
.Spin(180, 500)
// 4000ms -> 2 full CW spins + 0.5 CW spins
.Build();
loadPlayer(frames);
@ -190,15 +196,35 @@ namespace osu.Game.Rulesets.Osu.Tests
DrawableSpinner drawableSpinner = null!;
AddUntilStep("get spinner", () => (drawableSpinner = currentPlayer.ChildrenOfType<DrawableSpinner>().Single()) != null);
assertTotalRotation(4000, 900);
assertFinalRotationCorrect();
assertTotalRotation(3750, 810);
assertTotalRotation(3500, 720);
assertTotalRotation(3250, 530);
assertTotalRotation(3000, 540);
assertTotalRotation(3000, 450);
assertTotalRotation(2750, 540);
assertTotalRotation(2500, 540);
assertTotalRotation(2250, 360);
assertTotalRotation(2000, 180);
assertTotalRotation(2250, 450);
assertTotalRotation(2000, 360);
assertTotalRotation(1500, 0);
// same thing but always returning to final time to check.
assertFinalRotationCorrect();
assertTotalRotation(3750, 810);
assertFinalRotationCorrect();
assertTotalRotation(3500, 720);
assertFinalRotationCorrect();
assertTotalRotation(3250, 530);
assertFinalRotationCorrect();
assertTotalRotation(3000, 450);
assertFinalRotationCorrect();
assertTotalRotation(2750, 540);
assertFinalRotationCorrect();
assertTotalRotation(2500, 540);
assertFinalRotationCorrect();
assertTotalRotation(2250, 450);
assertFinalRotationCorrect();
assertTotalRotation(2000, 360);
assertFinalRotationCorrect();
assertTotalRotation(1500, 0);
void assertTotalRotation(double time, float expected)
@ -211,8 +237,11 @@ namespace osu.Game.Rulesets.Osu.Tests
void addSeekStep(double time)
{
AddStep($"seek to {time}", () => clock.Seek(time));
// Lenience is required due to interpolation running slightly ahead on a stalled clock.
AddUntilStep("wait for seek to finish", () => drawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time));
}
void assertFinalRotationCorrect() => assertTotalRotation(4000, 900);
}
private void assertTicksHit(int count)

View File

@ -4,6 +4,7 @@
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Judgements
{
@ -15,28 +16,15 @@ namespace osu.Game.Rulesets.Osu.Judgements
public Spinner Spinner => (Spinner)HitObject;
/// <summary>
/// The total rotation performed on the spinner disc, disregarding the spin direction,
/// adjusted for the track's playback rate.
/// The total amount that the spinner was rotated.
/// </summary>
/// <remarks>
/// <para>
/// This value is always non-negative and is monotonically increasing with time
/// (i.e. will only increase if time is passing forward, but can decrease during rewind).
/// </para>
/// <para>
/// The rotation from each frame is multiplied by the clock's current playback rate.
/// The reason this is done is to ensure that spinners give the same score and require the same number of spins
/// regardless of whether speed-modifying mods are applied.
/// </para>
/// </remarks>
/// <example>
/// Assuming no speed-modifying mods are active,
/// if the spinner is spun 360 degrees clockwise and then 360 degrees counter-clockwise,
/// this property will return the value of 720 (as opposed to 0).
/// If Double Time is active instead (with a speed multiplier of 1.5x),
/// in the same scenario the property will return 720 * 1.5 = 1080.
/// </example>
public float TotalRotation;
public float TotalRotation => History.TotalRotation;
/// <summary>
/// Stores the spinning history of the spinner.<br />
/// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
/// </summary>
public readonly SpinnerSpinHistory History = new SpinnerSpinHistory();
/// <summary>
/// Time instant at which the spin was started (the first user input which caused an increase in spin).

View File

@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override LocalisableString Description => "Burn the notes into your memory.";
//Alters the transforms of the approach circles, breaking the effects of these mods.
public override Type[] IncompatibleMods => new[] { typeof(OsuModApproachDifferent) };
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModApproachDifferent), typeof(OsuModTransform) }).ToArray();
public override ModType Type => ModType.Fun;

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "Everything rotates. EVERYTHING.";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised), typeof(OsuModRepel) };
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModFreezeFrame) }).ToArray();
private float theta;

View File

@ -40,6 +40,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
HeadCircle,
TailCircle,
repeatContainer,
Body,
};
@ -107,7 +108,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
headContainer = new Container<DrawableSliderHead> { RelativeSizeAxes = Axes.Both },
OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, },
Ball,
slidingSample = new PausableSkinnableSound { Looping = true }
slidingSample = new PausableSkinnableSound
{
Looping = true,
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
}
});
PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition);

View File

@ -4,7 +4,6 @@
#nullable disable
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Scoring;
@ -15,9 +14,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public new SliderHeadCircle HitObject => (SliderHeadCircle)base.HitObject;
[CanBeNull]
public Slider Slider => DrawableSlider?.HitObject;
public DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject;
public override bool DisplayResult => HitObject?.JudgeAsNormalHitCircle ?? base.DisplayResult;

View File

@ -17,7 +17,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public partial class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking
public partial class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking, IRequireTracking
{
public new SliderRepeat HitObject => (SliderRepeat)base.HitObject;
@ -36,6 +36,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public override bool DisplayResult => false;
public bool Tracking { get; set; }
public DrawableSliderRepeat()
: base(null)
{
@ -85,8 +87,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (HitObject.StartTime <= Time.Current)
ApplyResult(r => r.Type = DrawableSlider.Tracking.Value ? r.Judgement.MaxResult : r.Judgement.MinResult);
// shared implementation with DrawableSliderTick.
if (timeOffset >= 0)
{
// Attempt to preserve correct ordering of judgements as best we can by forcing
// an un-judged head to be missed when the user has clearly skipped it.
//
// This check is applied to all nested slider objects apart from the head (ticks, repeats, tail).
if (Tracking && !DrawableSlider.HeadCircle.Judged)
DrawableSlider.HeadCircle.MissForcefully();
ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult);
}
}
protected override void UpdateInitialTransforms()

View File

@ -4,6 +4,7 @@
#nullable disable
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -129,16 +130,27 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
if (userTriggered)
return;
// Ensure the tail can only activate after all previous ticks already have.
// Ensure the tail can only activate after all previous ticks/repeats already have.
//
// This covers the edge case where the lenience may allow the tail to activate before
// the last tick, changing ordering of score/combo awarding.
if (DrawableSlider.NestedHitObjects.Count > 1 && !DrawableSlider.NestedHitObjects[^2].Judged)
var lastTick = DrawableSlider.NestedHitObjects.LastOrDefault(o => o.HitObject is SliderTick || o.HitObject is SliderRepeat);
if (lastTick?.Judged == false)
return;
if (timeOffset < SliderEventGenerator.TAIL_LENIENCY)
return;
// Attempt to preserve correct ordering of judgements as best we can by forcing
// an un-judged head to be missed when the user has clearly skipped it.
//
// This check is applied to all nested slider objects apart from the head (ticks, repeats, tail).
if (Tracking && !DrawableSlider.HeadCircle.Judged)
DrawableSlider.HeadCircle.MissForcefully();
// The player needs to have engaged in tracking at any point after the tail leniency cutoff.
// An actual tick miss should only occur if reaching the tick itself.
if (timeOffset >= SliderEventGenerator.TAIL_LENIENCY && Tracking)
if (Tracking)
ApplyResult(r => r.Type = r.Judgement.MaxResult);
else if (timeOffset > 0)
ApplyResult(r => r.Type = r.Judgement.MinResult);

View File

@ -75,8 +75,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
// shared implementation with DrawableSliderRepeat.
if (timeOffset >= 0)
{
// Attempt to preserve correct ordering of judgements as best we can by forcing
// an un-judged head to be missed when the user has clearly skipped it.
//
// This check is applied to all nested slider objects apart from the head (ticks, repeats, tail).
if (Tracking && !DrawableSlider.HeadCircle.Judged)
DrawableSlider.HeadCircle.MissForcefully();
ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult);
}
}
protected override void UpdateInitialTransforms()

View File

@ -106,6 +106,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
spinningSample = new PausableSkinnableSound
{
Volume = { Value = 0 },
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
Looping = true,
Frequency = { Value = spinning_sample_initial_frequency }
}

View File

@ -25,6 +25,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Origin = Anchor.Centre;
}
protected override void OnApply()
{
base.OnApply();
// the tick can be theoretically judged at any point in the spinner's duration,
// so it must be alive throughout the spinner's entire lifetime.
// this mostly matters for correct sample playback.
LifetimeStart = DrawableSpinner.HitObject.StartTime;
}
/// <summary>
/// Apply a judgement result.
/// </summary>

View File

@ -0,0 +1,146 @@
// 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.Diagnostics;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
/// <summary>
/// Stores the spinning history of a single spinner.<br />
/// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
/// </summary>
/// <remarks>
/// A single, full rotation of the spinner is defined as a 360-degree rotation of the spinner, starting from 0, going in a single direction.<br />
/// </remarks>
/// <example>
/// If the player spins 90-degrees clockwise, then changes direction, they need to spin 90-degrees counter-clockwise to return to 0
/// and then continue rotating the spinner for another 360-degrees in the same direction.
/// </example>
public class SpinnerSpinHistory
{
/// <summary>
/// The sum of all complete spins and any current partial spin, in degrees.
/// </summary>
/// <remarks>
/// This is the final scoring value.
/// </remarks>
public float TotalRotation => 360 * completedSpins.Count + currentSpinMaxRotation;
private readonly Stack<CompletedSpin> completedSpins = new Stack<CompletedSpin>();
/// <summary>
/// The total accumulated (absolute) rotation.
/// </summary>
private float totalAccumulatedRotation;
private float totalAccumulatedRotationAtLastCompletion;
/// <summary>
/// For the current spin, represents the maximum absolute rotation (from 0..360) achieved by the user.
/// </summary>
/// <remarks>
/// This is used to report <see cref="TotalRotation"/> in the case a user spins backwards.
/// Basically it allows us to not reduce the total rotation in such a case.
///
/// This also stops spinner "cheese" where a user may rapidly change directions and cause an increase
/// in rotations.
/// </remarks>
private float currentSpinMaxRotation;
/// <summary>
/// The current spin, from -360..360.
/// </summary>
private float currentSpinRotation => totalAccumulatedRotation - totalAccumulatedRotationAtLastCompletion;
private double lastReportTime = double.NegativeInfinity;
/// <summary>
/// Report a delta update based on user input.
/// </summary>
/// <param name="currentTime">The current time.</param>
/// <param name="delta">The delta of the angle moved through since the last report.</param>
public void ReportDelta(double currentTime, float delta)
{
if (delta == 0)
return;
// Importantly, outside of tests the max delta entering here is 180 degrees.
// If it wasn't for tests, we could add this line:
//
// Debug.Assert(Math.Abs(delta) < 180);
//
// For this to be 101% correct, we need to add the ability for important frames to be
// created based on gameplay intrinsics (ie. there should be one frame for any spinner delta 90 < n < 180 degrees).
//
// But this can come later.
totalAccumulatedRotation += delta;
if (currentTime >= lastReportTime)
{
currentSpinMaxRotation = Math.Max(currentSpinMaxRotation, Math.Abs(currentSpinRotation));
// Handle the case where the user has completed another spin.
// Note that this does could be an `if` rather than `while` if the above assertion held true.
// It is a `while` loop to handle tests which throw larger values at this method.
while (currentSpinMaxRotation >= 360)
{
int direction = Math.Sign(currentSpinRotation);
completedSpins.Push(new CompletedSpin(currentTime, direction));
// Incrementing the last completion point will cause `currentSpinRotation` to
// hold the remaining spin that needs to be considered.
totalAccumulatedRotationAtLastCompletion += direction * 360;
// Reset the current max as we are entering a new spin.
// Importantly, carry over the remainder (which is now stored in `currentSpinRotation`).
currentSpinMaxRotation = Math.Abs(currentSpinRotation);
}
}
else
{
// When rewinding, the main thing we care about is getting `totalAbsoluteRotationsAtLastCompletion`
// to the correct value. We can used the stored history for this.
while (completedSpins.TryPeek(out var segment) && segment.CompletionTime > currentTime)
{
completedSpins.Pop();
totalAccumulatedRotationAtLastCompletion -= segment.Direction * 360;
}
// This is a best effort. We may not have enough data to match this 1:1, but that's okay.
// We know that the player is somewhere in a spin.
// In the worst case, this will be lower than expected, and recover in forward playback.
currentSpinMaxRotation = Math.Abs(currentSpinRotation);
}
lastReportTime = currentTime;
}
/// <summary>
/// Represents a single completed spin.
/// </summary>
private class CompletedSpin
{
/// <summary>
/// The time at which this spin completion occurred.
/// </summary>
public readonly double CompletionTime;
/// <summary>
/// The direction this spin completed in.
/// </summary>
public readonly int Direction;
public CompletedSpin(double completionTime, int direction)
{
Debug.Assert(direction == -1 || direction == 1);
CompletionTime = completionTime;
Direction = direction;
}
}
}
}

View File

@ -101,15 +101,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
rotationTransferred = true;
}
currentRotation += delta;
double rate = gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate;
delta = (float)(delta * Math.Abs(rate));
Debug.Assert(Math.Abs(delta) <= 180);
// rate has to be applied each frame, because it's not guaranteed to be constant throughout playback
// (see: ModTimeRamp)
drawableSpinner.Result.TotalRotation += (float)(Math.Abs(delta) * rate);
currentRotation += delta;
drawableSpinner.Result.History.ReportDelta(Time.Current, delta);
}
private void resetState(DrawableHitObject obj)

View File

@ -1,35 +1,39 @@
// 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.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
public partial class LegacySliderBall : CompositeDrawable
{
private readonly Drawable animationContent;
private readonly ISkin skin;
[Resolved(canBeNull: true)]
private DrawableHitObject? parentObject { get; set; }
public Color4 BallColour => animationContent.Colour;
private Sprite layerNd = null!;
private Sprite layerSpec = null!;
public LegacySliderBall(Drawable animationContent, ISkin skin)
private TextureAnimation ballAnimation = null!;
private Texture[] ballTextures = null!;
public Color4 BallColour => ballAnimation.Colour;
public LegacySliderBall(ISkin skin)
{
this.animationContent = animationContent;
this.skin = skin;
AutoSizeAxes = Axes.Both;
@ -38,30 +42,39 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
[BackgroundDependencyLoader]
private void load()
{
var ballColour = skin.GetConfig<OsuSkinColour, Color4>(OsuSkinColour.SliderBall)?.Value ?? Color4.White;
Vector2 maxSize = OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE;
InternalChildren = new[]
var ballColour = skin.GetConfig<OsuSkinColour, Color4>(OsuSkinColour.SliderBall)?.Value ?? Color4.White;
ballTextures = skin.GetTextures("sliderb", default, default, true, "", maxSize, out _);
InternalChildren = new Drawable[]
{
layerNd = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE),
Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(maxSize),
Colour = new Color4(5, 5, 5, 255),
},
LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d =>
ballAnimation = new LegacySkinExtensions.SkinnableTextureAnimation
{
d.Anchor = Anchor.Centre;
d.Origin = Anchor.Centre;
}), ballColour),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Colour = ballColour,
},
layerSpec = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE),
Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(maxSize),
Blending = BlendingParameters.Additive,
},
};
if (parentObject != null)
parentObject.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(parentObject);
}
private readonly IBindable<Color4> accentColour = new Bindable<Color4>();
@ -78,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
if (skin.GetConfig<SkinConfiguration.LegacySetting, bool>(SkinConfiguration.LegacySetting.AllowSliderBallTint)?.Value == true)
{
accentColour.BindTo(parentObject.AccentColour);
accentColour.BindValueChanged(a => animationContent.Colour = a.NewValue, true);
accentColour.BindValueChanged(a => ballAnimation.Colour = a.NewValue, true);
}
}
}
@ -94,6 +107,26 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
layerSpec.Rotation = -appliedRotation;
}
private void onHitObjectApplied(DrawableHitObject? drawableObject = null)
{
double frameDelay;
if (drawableObject?.HitObject != null)
{
DrawableSlider drawableSlider = (DrawableSlider)drawableObject;
frameDelay = Math.Max(
0.15 / drawableSlider.HitObject.Velocity * LegacySkinExtensions.SIXTY_FRAME_TIME,
LegacySkinExtensions.SIXTY_FRAME_TIME);
}
else
frameDelay = LegacySkinExtensions.SIXTY_FRAME_TIME;
ballAnimation.ClearFrames();
foreach (var texture in ballTextures)
ballAnimation.AddFrame(texture, frameDelay);
}
private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState _)
{
// Gets called by slider ticks, tails, etc., leading to duplicated
@ -114,7 +147,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
base.Dispose(isDisposing);
if (parentObject != null)
{
parentObject.HitObjectApplied -= onHitObjectApplied;
parentObject.ApplyCustomUpdateState -= updateStateTransforms;
}
}
}
}

View File

@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Origin = Anchor.Centre,
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 299,
}.With(s => s.Font = s.Font.With(fixedWidth: false)),
},
spmBackground = new Sprite
{
Anchor = Anchor.TopCentre,
@ -102,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Origin = Anchor.TopRight,
Scale = new Vector2(SPRITE_SCALE * 0.9f),
Position = new Vector2(80, 448 + spm_hide_offset),
}.With(s => s.Font = s.Font.With(fixedWidth: false)),
},
}
});
}

View File

@ -59,13 +59,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
return null;
case OsuSkinComponents.SliderBall:
var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "", maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
// todo: slider ball has a custom frame delay based on velocity
// Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME);
if (sliderBallContent != null)
return new LegacySliderBall(sliderBallContent, this);
if (GetTexture("sliderb") != null || GetTexture("sliderb0") != null)
return new LegacySliderBall(this);
return null;
@ -150,10 +145,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
return null;
const float hitcircle_text_scale = 0.8f;
return new LegacySpriteText(LegacyFont.HitCircle, OsuHitObject.OBJECT_DIMENSIONS * 2 / hitcircle_text_scale)
return new LegacySpriteText(LegacyFont.HitCircle)
{
// stable applies a blanket 0.8x scale to hitcircle fonts
Scale = new Vector2(hitcircle_text_scale),
MaxSizePerGlyph = OsuHitObject.OBJECT_DIMENSIONS * 2 / hitcircle_text_scale,
};
case OsuSkinComponents.SpinnerBody:

View File

@ -4,6 +4,7 @@
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Objects.Drawables;
@ -14,53 +15,54 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
{
public partial class ArgonBarLine : CompositeDrawable
{
private Container majorEdgeContainer = null!;
private Bindable<bool> major = null!;
private Box mainLine = null!;
private Drawable topAnchor = null!;
private Drawable bottomAnchor = null!;
[BackgroundDependencyLoader]
private void load(DrawableHitObject drawableHitObject)
{
RelativeSizeAxes = Axes.Both;
const float line_offset = 8;
var majorPieceSize = new Vector2(6, 20);
// Avoid flickering due to no anti-aliasing of boxes by default.
var edgeSmoothness = new Vector2(0.3f);
InternalChildren = new Drawable[]
AddInternal(mainLine = new Box
{
line = new Box
{
RelativeSizeAxes = Axes.Both,
EdgeSmoothness = new Vector2(0.5f, 0),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
majorEdgeContainer = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Children = new[]
{
new Circle
{
Name = "Top line",
Anchor = Anchor.TopCentre,
Origin = Anchor.BottomCentre,
Size = majorPieceSize,
Y = -line_offset,
},
new Circle
{
Name = "Bottom line",
Anchor = Anchor.BottomCentre,
Origin = Anchor.TopCentre,
Size = majorPieceSize,
Y = line_offset,
},
}
}
};
Name = "Bar line",
EdgeSmoothness = edgeSmoothness,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
});
const float major_extension = 10;
AddInternal(topAnchor = new Box
{
Name = "Top anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.TopCentre,
Origin = Anchor.BottomCentre,
Height = major_extension,
RelativeSizeAxes = Axes.X,
Colour = ColourInfo.GradientVertical(Colour4.Transparent, Colour4.White),
});
AddInternal(bottomAnchor = new Box
{
Name = "Bottom anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.BottomCentre,
Origin = Anchor.TopCentre,
Height = major_extension,
RelativeSizeAxes = Axes.X,
Colour = ColourInfo.GradientVertical(Colour4.White, Colour4.Transparent),
});
major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy();
}
@ -71,13 +73,10 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
major.BindValueChanged(updateMajor, true);
}
private Box line = null!;
private void updateMajor(ValueChangedEvent<bool> major)
{
line.Alpha = major.NewValue ? 1f : 0.5f;
line.Width = major.NewValue ? 1 : 0.5f;
majorEdgeContainer.Alpha = major.NewValue ? 1 : 0;
mainLine.Alpha = major.NewValue ? 1f : 0.5f;
topAnchor.Alpha = bottomAnchor.Alpha = major.NewValue ? mainLine.Alpha * 0.3f : 0;
}
}
}

View File

@ -87,6 +87,34 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
[Test]
public void TestDecodeLegacyOnlineID()
{
var decoder = new TestLegacyScoreDecoder();
using (var resourceStream = TestResources.OpenResource("Replays/taiko-replay-with-legacy-online-id.osr"))
{
var score = decoder.Parse(resourceStream);
Assert.That(score.ScoreInfo.OnlineID, Is.EqualTo(-1));
Assert.That(score.ScoreInfo.LegacyOnlineID, Is.EqualTo(255));
}
}
[Test]
public void TestDecodeNewOnlineID()
{
var decoder = new TestLegacyScoreDecoder();
using (var resourceStream = TestResources.OpenResource("Replays/taiko-replay-with-new-online-id.osr"))
{
var score = decoder.Parse(resourceStream);
Assert.That(score.ScoreInfo.OnlineID, Is.EqualTo(258));
Assert.That(score.ScoreInfo.LegacyOnlineID, Is.EqualTo(-1));
}
}
[TestCase(3, true)]
[TestCase(6, false)]
[TestCase(LegacyBeatmapDecoder.LATEST_VERSION, false)]

View File

@ -287,5 +287,26 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.That(manyTimes.EndTime, Is.EqualTo(9000 + loop_duration));
}
}
[Test]
public void TestVideoAndBackgroundEventsDoNotAffectStoryboardBounds()
{
var decoder = new LegacyStoryboardDecoder();
using var resStream = TestResources.OpenResource("video-background-events-ignored.osb");
using var stream = new LineBufferedReader(resStream);
var storyboard = decoder.Decode(stream);
Assert.Multiple(() =>
{
Assert.That(storyboard.GetLayer(@"Video").Elements, Has.Count.EqualTo(1));
Assert.That(storyboard.GetLayer(@"Video").Elements.Single(), Is.InstanceOf<StoryboardVideo>());
Assert.That(storyboard.GetLayer(@"Video").Elements.Single().StartTime, Is.EqualTo(-5678));
Assert.That(storyboard.EarliestEventTime, Is.Null);
Assert.That(storyboard.LatestEventTime, Is.Null);
});
}
}
}

View File

@ -108,6 +108,28 @@ namespace osu.Game.Tests.Gameplay
AddAssert("gameplay clock time = 10000", () => gameplayClockContainer.CurrentTime, () => Is.EqualTo(10000).Within(10f));
}
[Test]
public void TestStopUsingBeatmapClock()
{
ClockBackedTestWorkingBeatmap working = null;
MasterGameplayClockContainer gameplayClockContainer = null;
BindableDouble frequencyAdjustment = new BindableDouble(2);
AddStep("create container", () =>
{
working = new ClockBackedTestWorkingBeatmap(new OsuRuleset().RulesetInfo, new FramedClock(new ManualClock()), Audio);
Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0);
gameplayClockContainer.Reset(startClock: true);
});
AddStep("apply frequency adjustment", () => gameplayClockContainer.AdjustmentsFromMods.AddAdjustment(AdjustableProperty.Frequency, frequencyAdjustment));
AddAssert("track frequency changed", () => working.Track.AggregateFrequency.Value, () => Is.EqualTo(2));
AddStep("stop using beatmap clock", () => gameplayClockContainer.StopUsingBeatmapClock());
AddAssert("frequency adjustment unapplied", () => working.Track.AggregateFrequency.Value, () => Is.EqualTo(1));
}
protected override void Dispose(bool isDisposing)
{
localConfig?.Dispose();

View File

@ -2,10 +2,12 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Filter;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Carousel;
using osu.Game.Screens.Select.Filter;
namespace osu.Game.Tests.NonVisual.Filtering
@ -382,6 +384,57 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("unrecognised=keyword", filterCriteria.SearchText.Trim());
}
[TestCase("[1]", new[] { 0 })]
[TestCase("[1", new[] { 0 })]
[TestCase("My[Favourite", new[] { 2 })]
[TestCase("My[Favourite]", new[] { 2 })]
[TestCase("My[Favourite]Song", new[] { 2 })]
[TestCase("Favourite]", new[] { 2 })]
[TestCase("[Diff", new[] { 0, 1, 3, 4, 6 })]
[TestCase("[Diff]", new[] { 0, 1, 3, 4, 6 })]
[TestCase("[Favourite]", new[] { 3 })]
[TestCase("Title1 [Diff]", new[] { 0, 1 })]
[TestCase("Title1[Diff]", new int[] { })]
[TestCase("[diff ]with]", new[] { 4 })]
[TestCase("[diff ]with [[ brackets]]]]", new[] { 4 })]
[TestCase("[Diff in title]", new int[] { })]
[TestCase("[Diff in diff]", new[] { 6 })]
[TestCase("diff=Diff", new[] { 0, 1, 3, 4, 6 })]
[TestCase("diff=Diff1", new[] { 0 })]
[TestCase("diff=\"Diff\"", new[] { 3, 4, 6 })]
[TestCase("diff=!\"Diff\"", new int[] { })]
public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes)
{
var carouselBeatmaps = (((string title, string difficultyName)[])new[]
{
("Title1", "Diff1"),
("Title1", "Diff2"),
("My[Favourite]Song", "Expert"),
("Title", "My Favourite Diff"),
("Another One", "diff ]with [[ brackets]]]"),
("Diff in title", "a"),
("a", "Diff in diff"),
}).Select(info => new CarouselBeatmap(new BeatmapInfo
{
Metadata = new BeatmapMetadata
{
Title = info.title
},
DifficultyName = info.difficultyName
})).ToList();
var criteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(criteria, query);
carouselBeatmaps.ForEach(b => b.Filter(criteria));
int[] visibleBeatmaps = carouselBeatmaps
.Where(b => !b.Filtered.Value)
.Select(b => carouselBeatmaps.IndexOf(b)).ToArray();
Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes));
}
private class CustomFilterCriteria : IRulesetFilterCriteria
{
public string? CustomValue { get; set; }

View File

@ -0,0 +1,5 @@
osu file format v14
[Events]
0,-1234,"BG.jpg",0,0
Video,-5678,"Video.avi",0,0

View File

@ -138,7 +138,7 @@ namespace osu.Game.Tests.Visual.Editing
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveBeforeGameplayTestDialog);
AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveRequiredPopupDialog);
AddStep("dismiss prompt", () =>
{
@ -165,7 +165,7 @@ namespace osu.Game.Tests.Visual.Editing
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveBeforeGameplayTestDialog);
AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveRequiredPopupDialog);
AddStep("save changes", () => DialogOverlay.CurrentDialog.PerformOkAction());

View File

@ -213,7 +213,7 @@ namespace osu.Game.Tests.Visual.Gameplay
OnlineID = hasOnlineId ? online_score_id : 0,
Ruleset = new OsuRuleset().RulesetInfo,
BeatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(),
Hash = replayAvailable ? "online" : string.Empty,
HasOnlineReplay = replayAvailable,
User = new APIUser
{
Id = 39828,

View File

@ -7,6 +7,7 @@ using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Configuration;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -16,6 +17,7 @@ using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.Leaderboards;
@ -34,6 +36,7 @@ using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Carousel;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Screens.Select.Options;
using osu.Game.Tests.Beatmaps.IO;
@ -165,6 +168,41 @@ namespace osu.Game.Tests.Visual.Navigation
ConfirmAtMainMenu();
}
[Test]
public void TestSongSelectScrollHandling()
{
TestPlaySongSelect songSelect = null;
double scrollPosition = 0;
AddStep("set game volume to max", () => Game.Dependencies.Get<FrameworkConfigManager>().SetValue(FrameworkSetting.VolumeUniversal, 1d));
AddUntilStep("wait for volume overlay to hide", () => Game.ChildrenOfType<VolumeOverlay>().Single().State.Value, () => Is.EqualTo(Visibility.Hidden));
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded);
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("store scroll position", () => scrollPosition = getCarouselScrollPosition());
AddStep("move to left side", () => InputManager.MoveMouseTo(
songSelect.ChildrenOfType<Screens.Select.SongSelect.LeftSideInteractionContainer>().Single().ScreenSpaceDrawQuad.TopLeft + new Vector2(1)));
AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1));
AddAssert("carousel didn't move", getCarouselScrollPosition, () => Is.EqualTo(scrollPosition));
AddRepeatStep("alt-scroll down", () =>
{
InputManager.PressKey(Key.AltLeft);
InputManager.ScrollVerticalBy(-1);
InputManager.ReleaseKey(Key.AltLeft);
}, 5);
AddAssert("game volume decreased", () => Game.Dependencies.Get<FrameworkConfigManager>().Get<double>(FrameworkSetting.VolumeUniversal), () => Is.LessThan(1));
AddStep("move to carousel", () => InputManager.MoveMouseTo(songSelect.ChildrenOfType<BeatmapCarousel>().Single()));
AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1));
AddAssert("carousel moved", getCarouselScrollPosition, () => Is.Not.EqualTo(scrollPosition));
double getCarouselScrollPosition() => Game.ChildrenOfType<UserTrackingScrollContainer<DrawableCarouselItem>>().Single().Current;
}
/// <summary>
/// This tests that the F1 key will open the mod select overlay, and not be handled / blocked by the music controller (which has the same default binding
/// but should be handled *after* song select).
@ -209,6 +247,7 @@ namespace osu.Game.Tests.Visual.Navigation
AddStep("end spectator before retry", () => Game.SpectatorClient.EndPlaying(player.GameplayState));
AddStep("attempt to retry", () => player.ChildrenOfType<HotkeyRetryOverlay>().First().Action());
AddAssert("old player score marked failed", () => player.Score.ScoreInfo.Rank, () => Is.EqualTo(ScoreRank.F));
AddUntilStep("wait for old player gone", () => Game.ScreenStack.CurrentScreen != player);
AddUntilStep("get new player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null);
@ -221,6 +260,7 @@ namespace osu.Game.Tests.Visual.Navigation
var getOriginalPlayer = playToCompletion();
AddStep("attempt to retry", () => getOriginalPlayer().ChildrenOfType<HotkeyRetryOverlay>().First().Action());
AddAssert("original play isn't failed", () => getOriginalPlayer().Score.ScoreInfo.Rank, () => Is.Not.EqualTo(ScoreRank.F));
AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != getOriginalPlayer() && Game.ScreenStack.CurrentScreen is Player);
}

View File

@ -5,6 +5,7 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
@ -18,6 +19,7 @@ using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osu.Game.Skinning;
using osu.Game.Tests.Beatmaps.IO;
using osuTK;
using osuTK.Input;
@ -31,7 +33,7 @@ namespace osu.Game.Tests.Visual.Navigation
private SkinEditor skinEditor => Game.ChildrenOfType<SkinEditor>().FirstOrDefault();
[Test]
public void TestEditComponentDuringGameplay()
public void TestEditComponentFromGameplayScene()
{
advanceToSongSelect();
openSkinEditor();
@ -69,6 +71,28 @@ namespace osu.Game.Tests.Visual.Navigation
AddAssert("value is less than default", () => hitErrorMeter.JudgementLineThickness.Value < hitErrorMeter.JudgementLineThickness.Default);
}
[Test]
public void TestMutateProtectedSkinDuringGameplay()
{
advanceToSongSelect();
AddStep("set default skin", () => Game.Dependencies.Get<SkinManager>().CurrentSkinInfo.SetDefault());
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("enable NF", () => Game.SelectedMods.Value = new[] { new OsuModNoFail() });
AddStep("enter gameplay", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for player", () =>
{
DismissAnyNotifications();
return Game.ScreenStack.CurrentScreen is Player;
});
openSkinEditor();
AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get<SkinManager>().CurrentSkin.Value.SkinInfo.Value.Protected);
}
[Test]
public void TestComponentsDeselectedOnSkinEditorHide()
{

View File

@ -64,7 +64,8 @@ namespace osu.Game.Tests.Visual.Online
new[] { "Plain", "This is plain comment" },
new[] { "Pinned", "This is pinned comment" },
new[] { "Link", "Please visit https://osu.ppy.sh" },
new[] { "Big Image", "![](Backgrounds/bg1)" },
new[] { "Small Image", "![](Cursor/cursortrail)" },
new[]
{
"Heading", @"# Heading 1

View File

@ -362,7 +362,7 @@ namespace osu.Game.Tests.Visual.Ranking
{
var score = TestResources.CreateTestScoreInfo();
score.TotalScore += 10 - i;
score.Hash = $"test{i}";
score.HasOnlineReplay = true;
scores.Add(score);
}

View File

@ -18,26 +18,24 @@ namespace osu.Game.Tests.Visual.UserInterface
[Cached]
private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Purple);
private readonly BindableDouble current = new BindableDouble(5)
{
Precision = 0.1f,
MinValue = 0,
MaxValue = 15
};
private RoundedSliderBar<double> slider = null!;
[BackgroundDependencyLoader]
private void load()
[SetUpSteps]
public void SetUpSteps()
{
Child = slider = new RoundedSliderBar<double>
AddStep("create slider", () => Child = slider = new RoundedSliderBar<double>
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Current = current,
Current = new BindableDouble(5)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 15
},
RelativeSizeAxes = Axes.X,
Width = 0.4f
};
});
}
[Test]
@ -55,5 +53,22 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert("slider is default", () => slider.Current.IsDefault);
}
[Test]
public void TestNubDoubleClickOnDisabledSliderDoesNothing()
{
AddStep("set slider to 1", () => slider.Current.Value = 1);
AddStep("disable slider", () => slider.Current.Disabled = true);
AddStep("move mouse to nub", () => InputManager.MoveMouseTo(slider.ChildrenOfType<Nub>().Single()));
AddStep("double click nub", () =>
{
InputManager.Click(MouseButton.Left);
InputManager.Click(MouseButton.Left);
});
AddAssert("slider is still at 1", () => slider.Current.Value, () => Is.EqualTo(1));
}
}
}

View File

@ -18,26 +18,24 @@ namespace osu.Game.Tests.Visual.UserInterface
[Cached]
private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Purple);
private readonly BindableDouble current = new BindableDouble(5)
{
Precision = 0.1f,
MinValue = 0,
MaxValue = 15
};
private ShearedSliderBar<double> slider = null!;
[BackgroundDependencyLoader]
private void load()
[SetUpSteps]
public void SetUpSteps()
{
Child = slider = new ShearedSliderBar<double>
AddStep("create slider", () => Child = slider = new ShearedSliderBar<double>
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Current = current,
Current = new BindableDouble(5)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 15
},
RelativeSizeAxes = Axes.X,
Width = 0.4f
};
});
}
[Test]
@ -55,5 +53,22 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert("slider is default", () => slider.Current.IsDefault);
}
[Test]
public void TestNubDoubleClickOnDisabledSliderDoesNothing()
{
AddStep("set slider to 1", () => slider.Current.Value = 1);
AddStep("disable slider", () => slider.Current.Disabled = true);
AddStep("move mouse to nub", () => InputManager.MoveMouseTo(slider.ChildrenOfType<ShearedNub>().Single()));
AddStep("double click nub", () =>
{
InputManager.Click(MouseButton.Left);
InputManager.Click(MouseButton.Left);
});
AddAssert("slider is still at 1", () => slider.Current.Value, () => Is.EqualTo(1));
}
}
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Globalization;
using osu.Framework.Bindables;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings;
@ -10,7 +11,7 @@ namespace osu.Game.Tournament.Components
{
public partial class DateTextBox : SettingsTextBox
{
private readonly BindableWithCurrent<DateTimeOffset> current = new BindableWithCurrent<DateTimeOffset>();
private readonly BindableWithCurrent<DateTimeOffset> current = new BindableWithCurrent<DateTimeOffset>(DateTimeOffset.Now);
public new Bindable<DateTimeOffset>? Current
{
@ -23,13 +24,13 @@ namespace osu.Game.Tournament.Components
base.Current = new Bindable<string>(string.Empty);
current.BindValueChanged(dto =>
base.Current.Value = dto.NewValue.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"), true);
base.Current.Value = dto.NewValue.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", DateTimeFormatInfo.InvariantInfo), true);
((OsuTextBox)Control).OnCommit += (sender, _) =>
{
try
{
current.Value = DateTimeOffset.Parse(sender.Text);
current.Value = DateTimeOffset.Parse(sender.Text, DateTimeFormatInfo.InvariantInfo);
}
catch
{

View File

@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Users;
namespace osu.Game.Tournament
@ -519,9 +518,6 @@ namespace osu.Game.Tournament
case CountryCode.KE:
return "KEN";
case CountryCode.SS:
return "SSD";
case CountryCode.SR:
return "SUR";
@ -763,7 +759,7 @@ namespace osu.Game.Tournament
return "MOZ";
default:
throw new ArgumentOutOfRangeException(nameof(country));
return country.ToString();
}
}
}

View File

@ -10,13 +10,15 @@ namespace osu.Game.Database
/// <summary>
/// A model that contains a list of files it is responsible for.
/// </summary>
public interface IHasRealmFiles
public interface IHasRealmFiles : IHasNamedFiles
{
/// <summary>
/// Available files in this model, with locally filenames.
/// When performing lookups, consider using <see cref="BeatmapSetInfoExtensions.GetFile"/> or <see cref="BeatmapSetInfoExtensions.GetPathForFile"/> to do case-insensitive lookups.
/// </summary>
IList<RealmNamedFileUsage> Files { get; }
new IList<RealmNamedFileUsage> Files { get; }
IEnumerable<INamedFileUsage> IHasNamedFiles.Files => Files;
/// <summary>
/// A combined hash representing the model, based on the files it contains.

View File

@ -21,5 +21,11 @@ namespace osu.Game.Database
/// Whether this import should use hard links rather than file copy operations if available.
/// </summary>
public bool PreferHardLinks { get; set; }
/// <summary>
/// If set to <see langword="true"/>, this import will not respect <see cref="RealmArchiveModelImporter{TModel}.PauseImports"/>.
/// This is useful for cases where an import <em>must</em> complete even if gameplay is in progress.
/// </summary>
public bool ImportImmediately { get; set; }
}
}

View File

@ -87,8 +87,9 @@ namespace osu.Game.Database
/// 33 2023-08-16 Reset default chat toggle key binding to avoid conflict with newly added leaderboard toggle key binding.
/// 34 2023-08-21 Add BackgroundReprocessingFailed flag to ScoreInfo to track upgrade failures.
/// 35 2023-10-16 Clear key combinations of keybindings that are assigned to more than one action in a given settings section.
/// 36 2023-10-26 Add LegacyOnlineID to ScoreInfo. Move osu_scores_*_high IDs stored in OnlineID to LegacyOnlineID. Reset anomalous OnlineIDs.
/// </summary>
private const int schema_version = 35;
private const int schema_version = 36;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -1075,6 +1076,24 @@ namespace osu.Game.Database
break;
}
case 36:
{
foreach (var score in migration.NewRealm.All<ScoreInfo>())
{
if (score.OnlineID > 0)
{
score.LegacyOnlineID = score.OnlineID;
score.OnlineID = -1;
}
else
{
score.LegacyOnlineID = score.OnlineID = -1;
}
}
break;
}
}
Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");

View File

@ -261,7 +261,7 @@ namespace osu.Game.Database
/// <param name="cancellationToken">An optional cancellation token.</param>
public virtual Live<TModel>? ImportModel(TModel item, ArchiveReader? archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => Realm.Run(realm =>
{
pauseIfNecessary(cancellationToken);
pauseIfNecessary(parameters, cancellationToken);
TModel? existing;
@ -560,9 +560,9 @@ namespace osu.Game.Database
/// <returns>Whether to perform deletion.</returns>
protected virtual bool ShouldDeleteArchive(string path) => false;
private void pauseIfNecessary(CancellationToken cancellationToken)
private void pauseIfNecessary(ImportParameters importParameters, CancellationToken cancellationToken)
{
if (!PauseImports)
if (!PauseImports || importParameters.ImportImmediately)
return;
Logger.Log($@"{GetType().Name} is being paused.");

View File

@ -114,8 +114,24 @@ namespace osu.Game.Extensions
/// </summary>
/// <param name="instance">The instance to compare.</param>
/// <param name="other">The other instance to compare against.</param>
/// <returns>Whether online IDs match. If either instance is missing an online ID, this will return false.</returns>
public static bool MatchesOnlineID(this IScoreInfo? instance, IScoreInfo? other) => matchesOnlineID(instance, other);
/// <returns>
/// Whether online IDs match.
/// Both <see cref="IHasOnlineID{T}.OnlineID"/> and <see cref="IScoreInfo.LegacyOnlineID"/> are checked, in that order.
/// If either instance is missing an online ID, this will return false.
/// </returns>
public static bool MatchesOnlineID(this IScoreInfo? instance, IScoreInfo? other)
{
if (matchesOnlineID(instance, other))
return true;
if (instance == null || other == null)
return false;
if (instance.LegacyOnlineID < 0 || other.LegacyOnlineID < 0)
return false;
return instance.LegacyOnlineID.Equals(other.LegacyOnlineID);
}
private static bool matchesOnlineID(this IHasOnlineID<long>? instance, IHasOnlineID<long>? other)
{

View File

@ -98,7 +98,11 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.TopCentre,
RelativePositionAxes = Axes.X,
Current = { Value = true },
OnDoubleClicked = () => Current.SetDefault(),
OnDoubleClicked = () =>
{
if (!Current.Disabled)
Current.SetDefault();
},
},
},
hoverClickSounds = new HoverClickSounds()

View File

@ -101,7 +101,11 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.TopCentre,
RelativePositionAxes = Axes.X,
Current = { Value = true },
OnDoubleClicked = () => Current.SetDefault(),
OnDoubleClicked = () =>
{
if (!Current.Disabled)
Current.SetDefault();
},
},
},
hoverClickSounds = new HoverClickSounds()

View File

@ -149,6 +149,7 @@ namespace osu.Game.Input.Bindings
new KeyBinding(InputKey.Space, GlobalAction.SkipCutscene),
new KeyBinding(InputKey.ExtraMouseButton2, GlobalAction.SkipCutscene),
new KeyBinding(InputKey.Tilde, GlobalAction.QuickRetry),
new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.QuickRetry),
new KeyBinding(new[] { InputKey.Control, InputKey.Tilde }, GlobalAction.QuickExit),
new KeyBinding(new[] { InputKey.F3 }, GlobalAction.DecreaseScrollSpeed),
new KeyBinding(new[] { InputKey.F4 }, GlobalAction.IncreaseScrollSpeed),

View File

@ -7,16 +7,16 @@ using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Online.API.Requests.Responses
{
[Serializable]
public class SoloScoreInfo : IHasOnlineID<long>
public class SoloScoreInfo : IScoreInfo
{
[JsonProperty("beatmap_id")]
public int BeatmapID { get; set; }
@ -138,6 +138,18 @@ namespace osu.Game.Online.API.Requests.Responses
#endregion
#region IScoreInfo
public long OnlineID => (long?)ID ?? -1;
IUser IScoreInfo.User => User!;
DateTimeOffset IScoreInfo.Date => EndedAt;
long IScoreInfo.LegacyOnlineID => (long?)LegacyScoreId ?? -1;
IBeatmapInfo IScoreInfo.Beatmap => Beatmap!;
IRulesetInfo IScoreInfo.Ruleset => Beatmap!.Ruleset;
#endregion
public override string ToString() => $"score_id: {ID} user_id: {UserID}";
/// <summary>
@ -178,6 +190,7 @@ namespace osu.Game.Online.API.Requests.Responses
var score = new ScoreInfo
{
OnlineID = OnlineID,
LegacyOnlineID = (long?)LegacyScoreId ?? -1,
User = User ?? new APIUser { Id = UserID },
BeatmapInfo = new BeatmapInfo { OnlineID = BeatmapID },
Ruleset = new RulesetInfo { OnlineID = RulesetID },
@ -189,7 +202,7 @@ namespace osu.Game.Online.API.Requests.Responses
Statistics = Statistics,
MaximumStatistics = MaximumStatistics,
Date = EndedAt,
Hash = HasReplay ? "online" : string.Empty, // TODO: temporary?
HasOnlineReplay = HasReplay,
Mods = mods,
PP = PP,
};
@ -223,7 +236,5 @@ namespace osu.Game.Online.API.Requests.Responses
Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
};
public long OnlineID => (long?)ID ?? -1;
}
}

View File

@ -58,6 +58,9 @@ namespace osu.Game.Online.Rooms
[JsonProperty("position")]
public int? Position { get; set; }
[JsonProperty("has_replay")]
public bool HasReplay { get; set; }
/// <summary>
/// Any scores in the room around this score.
/// </summary>
@ -84,7 +87,7 @@ namespace osu.Game.Online.Rooms
User = User,
Accuracy = Accuracy,
Date = EndedAt,
Hash = string.Empty, // todo: temporary?
HasOnlineReplay = HasReplay,
Rank = Rank,
Mods = Mods?.Select(m => m.ToMod(rulesetInstance)).ToArray() ?? Array.Empty<Mod>(),
Position = Position,

View File

@ -39,7 +39,8 @@ namespace osu.Game.Online
var scoreInfo = new ScoreInfo
{
ID = TrackedItem.ID,
OnlineID = TrackedItem.OnlineID
OnlineID = TrackedItem.OnlineID,
LegacyOnlineID = TrackedItem.LegacyOnlineID
};
Downloader.DownloadBegan += downloadBegan;
@ -47,6 +48,7 @@ namespace osu.Game.Online
realmSubscription = realm.RegisterForNotifications(r => r.All<ScoreInfo>().Where(s =>
((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID)
|| (s.LegacyOnlineID > 0 && s.LegacyOnlineID == TrackedItem.LegacyOnlineID)
|| (!string.IsNullOrEmpty(s.Hash) && s.Hash == TrackedItem.Hash))
&& !s.DeletePending), (items, _) =>
{

View File

@ -678,6 +678,9 @@ namespace osu.Game
if (score.OnlineID > 0)
databasedScoreInfo = ScoreManager.Query(s => s.OnlineID == score.OnlineID);
if (score.LegacyOnlineID > 0)
databasedScoreInfo ??= ScoreManager.Query(s => s.LegacyOnlineID == score.LegacyOnlineID);
if (score is ScoreInfo scoreInfo)
databasedScoreInfo ??= ScoreManager.Query(s => s.Hash == scoreInfo.Hash);

View File

@ -2,8 +2,14 @@
// See the LICENCE file in the repository root for full licence text.
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Graphics.Containers.Markdown;
using osuTK;
namespace osu.Game.Overlays.Comments
{
@ -16,6 +22,8 @@ namespace osu.Game.Overlays.Comments
protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock);
public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer();
private partial class CommentMarkdownHeading : OsuMarkdownHeading
{
public CommentMarkdownHeading(HeadingBlock headingBlock)
@ -40,5 +48,64 @@ namespace osu.Game.Overlays.Comments
}
}
}
private partial class CommentMarkdownTextFlowContainer : MarkdownTextFlowContainer
{
protected override void AddImage(LinkInline linkInline) => AddDrawable(new CommentMarkdownImage(linkInline.Url));
private partial class CommentMarkdownImage : MarkdownImage
{
public CommentMarkdownImage(string url)
: base(url)
{
}
private DelayedLoadWrapper wrapper = null!;
protected override Drawable CreateContent(string url) => wrapper = new DelayedLoadWrapper(CreateImageContainer(url));
protected override ImageContainer CreateImageContainer(string url)
{
var container = new CommentImageContainer(url);
container.OnLoadComplete += d =>
{
// The size of DelayedLoadWrapper depends on AutoSizeAxes of it's content.
// But since it's set to None, we need to specify the size here manually.
wrapper.Size = container.Size;
d.FadeInFromZero(300, Easing.OutQuint);
};
return container;
}
private partial class CommentImageContainer : ImageContainer
{
// https://github.com/ppy/osu-web/blob/3bd0f406dc78d60b356d955cd4201f8c3e1cca09/resources/css/bem/osu-md.less#L36
// Web version defines max height in em units (6 em), which assuming default font size as 14 results in 84 px,
// which also seems to match observations upon inspecting the web element.
private const float max_height = 84f;
public CommentImageContainer(string url)
: base(url)
{
AutoSizeAxes = Axes.None;
}
protected override Sprite CreateImageSprite() => new Sprite
{
RelativeSizeAxes = Axes.Both
};
protected override Texture GetImageTexture(TextureStore textures, string url)
{
Texture t = base.GetImageTexture(textures, url);
if (t != null)
Size = t.Height > max_height ? new Vector2(max_height / t.Height * t.Width, max_height) : t.Size;
return t!;
}
}
}
}
}
}

View File

@ -98,12 +98,6 @@ namespace osu.Game.Rulesets.Edit
}
});
if (DistanceSpacingMultiplier.Disabled)
{
distanceSpacingSlider.Hide();
return;
}
DistanceSpacingMultiplier.Value = editorBeatmap.BeatmapInfo.DistanceSpacing;
DistanceSpacingMultiplier.BindValueChanged(multiplier =>
{
@ -116,6 +110,8 @@ namespace osu.Game.Rulesets.Edit
editorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue;
}, true);
DistanceSpacingMultiplier.BindDisabledChanged(disabled => distanceSpacingSlider.Alpha = disabled ? 0 : 1, true);
// Manual binding to handle enabling distance spacing when the slider is interacted with.
distanceSpacingSlider.Current.BindValueChanged(spacing =>
{

View File

@ -159,6 +159,26 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// </summary>
internal bool IsInitialized;
/// <summary>
/// The minimum allowable volume for sample playback.
/// <see cref="Samples"/> quieter than that will be forcibly played at this volume instead.
/// </summary>
/// <remarks>
/// <para>
/// Drawable hitobjects adding their own custom samples, or other sample playback sources
/// (i.e. <see cref="GameplaySampleTriggerSource"/>) must enforce this themselves.
/// </para>
/// <para>
/// This sample volume floor is present in stable, although it is set at 8% rather than 5%.
/// See: https://github.com/peppy/osu-stable-reference/blob/3ea48705eb67172c430371dcfc8a16a002ed0d3d/osu!/Audio/AudioEngine.cs#L1070,
/// https://github.com/peppy/osu-stable-reference/blob/3ea48705eb67172c430371dcfc8a16a002ed0d3d/osu!/Audio/AudioEngine.cs#L1404-L1405.
/// The reason why it is 5% here is that the 8% cap was enforced in a silent manner
/// (i.e. the minimum selectable volume in the editor was 5%, but it would be played at 8% anyways),
/// which is confusing and arbitrary, so we're just doing 5% here at the cost of sacrificing strict parity.
/// </para>
/// </remarks>
public const int MINIMUM_SAMPLE_VOLUME = 5;
/// <summary>
/// Creates a new <see cref="DrawableHitObject"/>.
/// </summary>
@ -181,7 +201,10 @@ namespace osu.Game.Rulesets.Objects.Drawables
comboColourBrightness.BindTo(gameplaySettings.ComboColourNormalisationAmount);
// Explicit non-virtual function call in case a DrawableHitObject overrides AddInternal.
base.AddInternal(Samples = new PausableSkinnableSound());
base.AddInternal(Samples = new PausableSkinnableSound
{
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME
});
CurrentSkin = skinSource;
CurrentSkin.SourceChanged += skinSourceChanged;

View File

@ -7,6 +7,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
@ -45,7 +46,10 @@ namespace osu.Game.Rulesets.UI
Child = hitSounds = new Container<SkinnableSound>
{
Name = "concurrent sample pool",
ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new PausableSkinnableSound())
ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new PausableSkinnableSound
{
MinimumSampleVolume = DrawableHitObject.MINIMUM_SAMPLE_VOLUME
})
}
};
}

View File

@ -9,7 +9,7 @@ using osu.Game.Users;
namespace osu.Game.Scoring
{
public interface IScoreInfo : IHasOnlineID<long>, IHasNamedFiles
public interface IScoreInfo : IHasOnlineID<long>
{
IUser User { get; }
@ -22,7 +22,7 @@ namespace osu.Game.Scoring
double Accuracy { get; }
bool HasReplay { get; }
long LegacyOnlineID { get; }
DateTimeOffset Date { get; }

View File

@ -19,6 +19,13 @@ namespace osu.Game.Scoring.Legacy
[JsonObject(MemberSerialization.OptIn)]
public class LegacyReplaySoloScoreInfo
{
/// <remarks>
/// The value of this property should correspond to <see cref="ScoreInfo.OnlineID"/>
/// (i.e. come from the `solo_scores` ID scheme).
/// </remarks>
[JsonProperty("online_id")]
public long OnlineID { get; set; } = -1;
[JsonProperty("mods")]
public APIMod[] Mods { get; set; } = Array.Empty<APIMod>();
@ -30,6 +37,7 @@ namespace osu.Game.Scoring.Legacy
public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo
{
OnlineID = score.OnlineID,
Mods = score.APIMods,
Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value),

View File

@ -101,9 +101,9 @@ namespace osu.Game.Scoring.Legacy
byte[] compressedReplay = sr.ReadByteArray();
if (version >= 20140721)
scoreInfo.OnlineID = sr.ReadInt64();
scoreInfo.LegacyOnlineID = sr.ReadInt64();
else if (version >= 20121008)
scoreInfo.OnlineID = sr.ReadInt32();
scoreInfo.LegacyOnlineID = sr.ReadInt32();
byte[] compressedScoreInfo = null;
@ -121,6 +121,7 @@ namespace osu.Game.Scoring.Legacy
Debug.Assert(readScore != null);
score.ScoreInfo.OnlineID = readScore.OnlineID;
score.ScoreInfo.Statistics = readScore.Statistics;
score.ScoreInfo.MaximumStatistics = readScore.MaximumStatistics;
score.ScoreInfo.Mods = readScore.Mods.Select(m => m.ToMod(currentRuleset)).ToArray();

View File

@ -84,7 +84,7 @@ namespace osu.Game.Scoring.Legacy
sw.Write(getHpGraphFormatted());
sw.Write(score.ScoreInfo.Date.DateTime);
sw.WriteByteArray(createReplayData());
sw.Write((long)0);
sw.Write(score.ScoreInfo.LegacyOnlineID);
writeModSpecificData(score.ScoreInfo, sw);
sw.WriteByteArray(createScoreInfoData());
}

View File

@ -94,15 +94,32 @@ namespace osu.Game.Scoring
public double Accuracy { get; set; }
public bool HasReplay => !string.IsNullOrEmpty(Hash);
[Ignored]
public bool HasOnlineReplay { get; set; }
public DateTimeOffset Date { get; set; }
public double? PP { get; set; }
/// <summary>
/// The online ID of this score.
/// </summary>
/// <remarks>
/// In the osu-web database, this ID (if present) comes from the new <c>solo_scores</c> table.
/// </remarks>
[Indexed]
public long OnlineID { get; set; } = -1;
/// <summary>
/// The legacy online ID of this score.
/// </summary>
/// <remarks>
/// In the osu-web database, this ID (if present) comes from the legacy <c>osu_scores_*_high</c> tables.
/// This ID is also stored to replays set on osu!stable.
/// </remarks>
[Indexed]
public long LegacyOnlineID { get; set; } = -1;
[MapTo("User")]
public RealmUser RealmUser { get; set; } = null!;
@ -168,7 +185,6 @@ namespace osu.Game.Scoring
IRulesetInfo IScoreInfo.Ruleset => Ruleset;
IBeatmapInfo? IScoreInfo.Beatmap => BeatmapInfo;
IUser IScoreInfo.User => User;
IEnumerable<INamedFileUsage> IHasNamedFiles.Files => Files;
#region Properties required to make things work with existing usages

View File

@ -150,7 +150,11 @@ namespace osu.Game.Scoring
public Task Import(ImportTask[] imports, ImportParameters parameters = default) => scoreImporter.Import(imports, parameters);
public override bool IsAvailableLocally(ScoreInfo model) => Realm.Run(realm => realm.All<ScoreInfo>().Any(s => s.OnlineID == model.OnlineID));
public override bool IsAvailableLocally(ScoreInfo model)
=> Realm.Run(realm => realm.All<ScoreInfo>()
// this basically inlines `ModelExtension.MatchesOnlineID(IScoreInfo, IScoreInfo)`,
// because that method can't be used here, as realm can't translate it to its query language.
.Any(s => s.OnlineID == model.OnlineID || s.LegacyOnlineID == model.LegacyOnlineID));
public IEnumerable<string> HandledExtensions => scoreImporter.HandledExtensions;

View File

@ -16,6 +16,7 @@ using osu.Game.Audio;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Screens.Edit.Timing;
using osuTK;
using osuTK.Graphics;
@ -101,7 +102,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
},
volume = new IndeterminateSliderWithTextBoxInput<int>("Volume", new BindableInt(100)
{
MinValue = 0,
MinValue = DrawableHitObject.MINIMUM_SAMPLE_VOLUME,
MaxValue = 100,
})
}

View File

@ -425,7 +425,7 @@ namespace osu.Game.Screens.Edit
{
if (HasUnsavedChanges)
{
dialogOverlay.Push(new SaveBeforeGameplayTestDialog(() =>
dialogOverlay.Push(new SaveRequiredPopupDialog("The beatmap will be saved in order to test it.", () =>
{
if (!Save()) return;
@ -1018,25 +1018,36 @@ namespace osu.Game.Screens.Edit
{
var exportItems = new List<MenuItem>
{
new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, exportBeatmap) { Action = { Disabled = !RuntimeInfo.IsDesktop } },
new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, exportLegacyBeatmap) { Action = { Disabled = !RuntimeInfo.IsDesktop } },
new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, () => exportBeatmap(false)) { Action = { Disabled = !RuntimeInfo.IsDesktop } },
new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, () => exportBeatmap(true)) { Action = { Disabled = !RuntimeInfo.IsDesktop } },
};
return new EditorMenuItem(CommonStrings.Export) { Items = exportItems };
}
private void exportBeatmap()
private void exportBeatmap(bool legacy)
{
if (!Save()) return;
if (HasUnsavedChanges)
{
dialogOverlay.Push(new SaveRequiredPopupDialog("The beatmap will be saved in order to export it.", () =>
{
if (!Save()) return;
beatmapManager.Export(Beatmap.Value.BeatmapSetInfo);
}
runExport();
}));
}
else
{
runExport();
}
private void exportLegacyBeatmap()
{
if (!Save()) return;
beatmapManager.ExportLegacy(Beatmap.Value.BeatmapSetInfo);
void runExport()
{
if (legacy)
beatmapManager.ExportLegacy(Beatmap.Value.BeatmapSetInfo);
else
beatmapManager.Export(Beatmap.Value.BeatmapSetInfo);
}
}
/// <summary>

View File

@ -1,17 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Graphics.Sprites;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Screens.Edit.GameplayTest
namespace osu.Game.Screens.Edit
{
public partial class SaveBeforeGameplayTestDialog : PopupDialog
public partial class SaveRequiredPopupDialog : PopupDialog
{
public SaveBeforeGameplayTestDialog(Action saveAndPreview)
public SaveRequiredPopupDialog(string headerText, Action saveAndAction)
{
HeaderText = "The beatmap will be saved in order to test it.";
HeaderText = headerText;
Icon = FontAwesome.Regular.Save;
@ -20,7 +20,7 @@ namespace osu.Game.Screens.Edit.GameplayTest
new PopupDialogOkButton
{
Text = "Sounds good, let's go!",
Action = saveAndPreview
Action = saveAndAction
},
new PopupDialogCancelButton
{

View File

@ -10,7 +10,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
public partial class MultiplayerResultsScreen : PlaylistsResultsScreen
{
public MultiplayerResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem)
: base(score, roomId, playlistItem, false, false)
: base(score, roomId, playlistItem, false)
{
}
}

View File

@ -41,7 +41,7 @@ namespace osu.Game.Screens.Play
private readonly WorkingBeatmap beatmap;
private readonly Track track;
private Track track;
private readonly double skipTargetTime;
@ -145,7 +145,7 @@ namespace osu.Game.Screens.Play
protected override void StartGameplayClock()
{
addSourceClockAdjustments();
addAdjustmentsToTrack();
base.StartGameplayClock();
@ -186,20 +186,20 @@ namespace osu.Game.Screens.Play
/// </summary>
public void StopUsingBeatmapClock()
{
removeSourceClockAdjustments();
removeAdjustmentsFromTrack();
var virtualTrack = new TrackVirtual(beatmap.Track.Length);
virtualTrack.Seek(CurrentTime);
track = new TrackVirtual(beatmap.Track.Length);
track.Seek(CurrentTime);
if (IsRunning)
virtualTrack.Start();
ChangeSource(virtualTrack);
track.Start();
ChangeSource(track);
addSourceClockAdjustments();
addAdjustmentsToTrack();
}
private bool speedAdjustmentsApplied;
private void addSourceClockAdjustments()
private void addAdjustmentsToTrack()
{
if (speedAdjustmentsApplied)
return;
@ -213,7 +213,7 @@ namespace osu.Game.Screens.Play
speedAdjustmentsApplied = true;
}
private void removeSourceClockAdjustments()
private void removeAdjustmentsFromTrack()
{
if (!speedAdjustmentsApplied)
return;
@ -228,7 +228,7 @@ namespace osu.Game.Screens.Play
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
removeSourceClockAdjustments();
removeAdjustmentsFromTrack();
}
ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.Beatmap.ControlPointInfo;

View File

@ -1110,13 +1110,14 @@ namespace osu.Game.Screens.Play
failAnimationContainer?.Stop();
PauseOverlay?.StopAllSamples();
if (LoadedBeatmapSuccessfully)
if (LoadedBeatmapSuccessfully && !GameplayState.HasPassed)
{
if (!GameplayState.HasPassed && !GameplayState.HasFailed)
Debug.Assert(resultsDisplayDelegate == null);
if (!GameplayState.HasFailed)
GameplayState.HasQuit = true;
// if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap.
if (prepareScoreForDisplayTask == null && DrawableRuleset.ReplayScore == null)
if (DrawableRuleset.ReplayScore == null)
ScoreProcessor.FailScore(Score.ScoreInfo);
}
@ -1166,13 +1167,6 @@ namespace osu.Game.Screens.Play
// the import process will re-attach managed beatmap/rulesets to this score. we don't want this for now, so create a temporary copy to import.
var importableScore = score.ScoreInfo.DeepClone();
// For the time being, online ID responses are not really useful for anything.
// In addition, the IDs provided via new (lazer) endpoints are based on a different autoincrement from legacy (stable) scores.
//
// Until we better define the server-side logic behind this, let's not store the online ID to avoid potential unique constraint
// conflicts across various systems (ie. solo and multiplayer).
importableScore.OnlineID = -1;
var imported = scoreManager.Import(importableScore, replayReader);
imported.PerformRead(s =>

View File

@ -37,7 +37,7 @@ namespace osu.Game.Screens.Ranking
if (State.Value == DownloadState.LocallyAvailable)
return ReplayAvailability.Local;
if (Score.Value?.HasReplay == true)
if (Score.Value?.HasOnlineReplay == true)
return ReplayAvailability.Online;
return ReplayAvailability.NotAvailable;

View File

@ -7,6 +7,7 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Solo;
@ -67,7 +68,7 @@ namespace osu.Game.Screens.Ranking
return null;
getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset);
getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineID != Score.OnlineID).Select(s => s.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo)));
getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => !s.MatchesOnlineID(Score)).Select(s => s.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo)));
return getScoreRequest;
}

View File

@ -1197,6 +1197,8 @@ namespace osu.Game.Screens.Select
{
private bool rightMouseScrollBlocked;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
public CarouselScrollContainer()
{
// size is determined by the carousel itself, due to not all content necessarily being loaded.

View File

@ -59,7 +59,7 @@ namespace osu.Game.Screens.Select.Carousel
criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode);
match &= !criteria.Title.HasFilter || criteria.Title.Matches(BeatmapInfo.Metadata.Title) ||
criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode);
match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName);
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
if (!match) return false;

View File

@ -38,6 +38,7 @@ namespace osu.Game.Screens.Select
public OptionalTextFilter Creator;
public OptionalTextFilter Artist;
public OptionalTextFilter Title;
public OptionalTextFilter DifficultyName;
public OptionalRange<double> UserStarDifficulty = new OptionalRange<double>
{
@ -68,8 +69,23 @@ namespace osu.Game.Screens.Select
string remainingText = value;
// Match either an open difficulty tag to the end of string,
// or match a closed one with a whitespace after it.
//
// To keep things simple, the closing ']' may be included in the match group,
// and is trimmed post-match.
foreach (Match quotedSegment in Regex.Matches(value, "(^|\\s)\\[(.*)(\\]\\s|$)"))
{
DifficultyName = new OptionalTextFilter
{
SearchTerm = quotedSegment.Groups[2].Value.Trim(']')
};
remainingText = remainingText.Replace(quotedSegment.Value, string.Empty);
}
// First handle quoted segments to ensure we keep inline spaces in exact matches.
foreach (Match quotedSegment in Regex.Matches(searchText, "(\"[^\"]+\"[!]?)"))
foreach (Match quotedSegment in Regex.Matches(value, "(\"[^\"]+\"[!]?)"))
{
terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value });
remainingText = remainingText.Replace(quotedSegment.Value, string.Empty);

View File

@ -76,6 +76,9 @@ namespace osu.Game.Screens.Select
case "title":
return TryUpdateCriteriaText(ref criteria.Title, op, value);
case "diff":
return TryUpdateCriteriaText(ref criteria.DifficultyName, op, value);
default:
return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false;
}

View File

@ -171,11 +171,6 @@ namespace osu.Game.Screens.Select
AddRangeInternal(new Drawable[]
{
new ResetScrollContainer(() => Carousel.ScrollToSelected())
{
RelativeSizeAxes = Axes.Y,
Width = 250,
},
new VerticalMaskingContainer
{
Children = new Drawable[]
@ -243,6 +238,10 @@ namespace osu.Game.Screens.Select
Padding = new MarginPadding { Top = left_area_padding },
Children = new Drawable[]
{
new LeftSideInteractionContainer(() => Carousel.ScrollToSelected())
{
RelativeSizeAxes = Axes.Both,
},
beatmapInfoWedge = new BeatmapInfoWedge
{
Height = WEDGE_HEIGHT,
@ -1017,18 +1016,28 @@ namespace osu.Game.Screens.Select
}
}
private partial class ResetScrollContainer : Container
/// <summary>
/// Handles mouse interactions required when moving away from the carousel.
/// </summary>
internal partial class LeftSideInteractionContainer : Container
{
private readonly Action? onHoverAction;
private readonly Action? resetCarouselPosition;
public ResetScrollContainer(Action onHoverAction)
public LeftSideInteractionContainer(Action resetCarouselPosition)
{
this.onHoverAction = onHoverAction;
this.resetCarouselPosition = resetCarouselPosition;
}
// we want to block plain scrolls on the left side so that they don't scroll the carousel,
// but also we *don't* want to handle scrolls when they're combined with keyboard modifiers
// as those will usually correspond to other interactions like adjusting volume.
protected override bool OnScroll(ScrollEvent e) => !e.ControlPressed && !e.AltPressed && !e.ShiftPressed && !e.SuperPressed;
protected override bool OnMouseDown(MouseDownEvent e) => true;
protected override bool OnHover(HoverEvent e)
{
onHoverAction?.Invoke();
resetCarouselPosition?.Invoke();
return base.OnHover(e);
}
}

View File

@ -25,6 +25,7 @@ namespace osu.Game.Skinning
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
FixedWidth = true,
};
}
}

View File

@ -28,6 +28,7 @@ namespace osu.Game.Skinning
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
FixedWidth = true,
};
}
}

View File

@ -200,7 +200,11 @@ namespace osu.Game.Skinning
}
}
private const double default_frame_time = 1000 / 60d;
/// <summary>
/// The frame length of each frame at a 60 FPS rate.
/// Default frame rate for legacy skin animations.
/// </summary>
public const double SIXTY_FRAME_TIME = 1000 / 60d;
private static double getFrameLength(ISkin source, bool applyConfigFrameRate, Texture[] textures)
{
@ -214,7 +218,7 @@ namespace osu.Game.Skinning
return 1000f / textures.Length;
}
return default_frame_time;
return SIXTY_FRAME_TIME;
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Sprites;
@ -12,8 +13,10 @@ namespace osu.Game.Skinning
{
public sealed partial class LegacySpriteText : OsuSpriteText
{
public Vector2? MaxSizePerGlyph { get; init; }
public bool FixedWidth { get; init; }
private readonly LegacyFont font;
private readonly Vector2? maxSizePerGlyph;
private LegacyGlyphStore glyphStore = null!;
@ -21,10 +24,18 @@ namespace osu.Game.Skinning
protected override char[] FixedWidthExcludeCharacters => new[] { ',', '.', '%', 'x' };
public LegacySpriteText(LegacyFont font, Vector2? maxSizePerGlyph = null)
// ReSharper disable once UnusedMember.Global
// being unused is the point here
public new FontUsage Font
{
get => base.Font;
set => throw new InvalidOperationException(@"Attempting to use this setter will not work correctly. "
+ $@"Use specific init-only properties exposed by {nameof(LegacySpriteText)} instead.");
}
public LegacySpriteText(LegacyFont font)
{
this.font = font;
this.maxSizePerGlyph = maxSizePerGlyph;
Shadow = false;
UseFullGlyphHeight = false;
@ -33,10 +44,10 @@ namespace osu.Game.Skinning
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
Font = new FontUsage(skin.GetFontPrefix(font), 1, fixedWidth: true);
base.Font = new FontUsage(skin.GetFontPrefix(font), 1, fixedWidth: FixedWidth);
Spacing = new Vector2(-skin.GetFontOverlap(font), 0);
glyphStore = new LegacyGlyphStore(skin, maxSizePerGlyph);
glyphStore = new LegacyGlyphStore(skin, MaxSizePerGlyph);
}
protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore);

View File

@ -14,7 +14,7 @@ namespace osu.Game.Skinning
{
[MapTo("Skin")]
[JsonObject(MemberSerialization.OptIn)]
public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable<SkinInfo>, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles
public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable<SkinInfo>, IHasGuidPrimaryKey, ISoftDelete
{
internal static readonly Guid TRIANGLES_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD");
internal static readonly Guid ARGON_SKIN = new Guid("CFFA69DE-B3E3-4DEE-8563-3C4F425C05D0");

View File

@ -182,7 +182,10 @@ namespace osu.Game.Skinning
Name = NamingUtils.GetNextBestName(existingSkinNames, $@"{s.Name} (modified)")
};
var result = skinImporter.ImportModel(skinInfo);
var result = skinImporter.ImportModel(skinInfo, parameters: new ImportParameters
{
ImportImmediately = true // to avoid possible deadlocks when editing skin during gameplay.
});
if (result != null)
{

View File

@ -20,6 +20,12 @@ namespace osu.Game.Skinning
/// </summary>
public partial class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent
{
/// <summary>
/// The minimum allowable volume for <see cref="Samples"/>.
/// <see cref="Samples"/> that specify a lower <see cref="ISampleInfo.Volume"/> will be forcibly pulled up to this volume.
/// </summary>
public int MinimumSampleVolume { get; set; }
public override bool RemoveWhenNotAlive => false;
public override bool RemoveCompletedTransforms => false;
@ -156,7 +162,7 @@ namespace osu.Game.Skinning
{
var sample = samplePool?.GetPooledSample(s) ?? new PoolableSkinnableSample(s);
sample.Looping = Looping;
sample.Volume.Value = s.Volume / 100.0;
sample.Volume.Value = Math.Max(s.Volume, MinimumSampleVolume) / 100.0;
samplesContainer.Add(sample);
}

View File

@ -30,8 +30,12 @@ namespace osu.Game.Storyboards
/// </summary>
/// <remarks>
/// This iterates all elements and as such should be used sparingly or stored locally.
/// Sample events use their start time as "end time" during this calculation.
/// Video and background events are not included to match stable.
/// </remarks>
public double? EarliestEventTime => Layers.SelectMany(l => l.Elements).MinBy(e => e.StartTime)?.StartTime;
public double? EarliestEventTime => Layers.SelectMany(l => l.Elements)
.Where(e => e is not StoryboardVideo)
.MinBy(e => e.StartTime)?.StartTime;
/// <summary>
/// Across all layers, find the latest point in time that a storyboard element ends at.
@ -39,9 +43,12 @@ namespace osu.Game.Storyboards
/// </summary>
/// <remarks>
/// This iterates all elements and as such should be used sparingly or stored locally.
/// Videos and samples return StartTime as their EndTIme.
/// Sample events use their start time as "end time" during this calculation.
/// Video and background events are not included to match stable.
/// </remarks>
public double? LatestEventTime => Layers.SelectMany(l => l.Elements).MaxBy(e => e.GetEndTime())?.GetEndTime();
public double? LatestEventTime => Layers.SelectMany(l => l.Elements)
.Where(e => e is not StoryboardVideo)
.MaxBy(e => e.GetEndTime())?.GetEndTime();
/// <summary>
/// Depth of the currently front-most storyboard layer, excluding the overlay layer.

View File

@ -432,6 +432,11 @@ namespace osu.Game.Tests.Visual
private bool running;
public override double Rate => base.Rate
// This is mainly to allow some tests to override the rate to zero
// and avoid interpolation.
* referenceClock.Rate;
public TrackVirtualManual(IFrameBasedClock referenceClock, string name = "virtual")
: base(name)
{

File diff suppressed because it is too large Load Diff

View File

@ -37,9 +37,10 @@
</PackageReference>
<PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2023.1012.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.1014.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.1023.0" />
<PackageReference Include="Sentry" Version="3.40.0" />
<PackageReference Include="SharpCompress" Version="0.34.1" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
<PackageReference Include="SharpCompress" Version="0.33.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.6" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />