1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-15 06:13:03 +08:00

Merge pull request #25062 from smoogipoo/remove-hold-note-ticks

Remove osu!mania hold note ticks
This commit is contained in:
Dean Herbert 2023-10-12 13:55:50 +09:00 committed by GitHub
commit f26e3afd29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 327 additions and 295 deletions

View File

@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{ {
c.Add(hitExplosionPools[poolIndex].Get(e => c.Add(hitExplosionPools[poolIndex].Get(e =>
{ {
e.Apply(new JudgementResult(new HitObject(), runCount % 6 == 0 ? new HoldNoteTickJudgement() : new ManiaJudgement())); e.Apply(new JudgementResult(new HitObject(), new ManiaJudgement()));
e.Anchor = Anchor.Centre; e.Anchor = Anchor.Centre;
e.Origin = Anchor.Centre; e.Origin = Anchor.Centre;

View File

@ -54,7 +54,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
assertNoteJudgement(HitResult.IgnoreMiss); assertNoteJudgement(HitResult.IgnoreMiss);
} }
@ -73,7 +72,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Perfect); assertTailJudgement(HitResult.Perfect);
assertNoteJudgement(HitResult.IgnoreHit); assertNoteJudgement(HitResult.IgnoreHit);
} }
@ -92,7 +90,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
assertNoteJudgement(HitResult.IgnoreMiss); assertNoteJudgement(HitResult.IgnoreMiss);
} }
@ -111,7 +108,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -129,7 +125,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -149,7 +144,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -169,7 +163,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Perfect); assertTailJudgement(HitResult.Perfect);
} }
@ -188,10 +181,31 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
/// <summary>
/// -----[ ]-----
/// xox o
/// </summary>
[Test]
public void TestPressAtStartThenReleaseAndImmediatelyRepress()
{
performTest(new List<ReplayFrame>
{
new ManiaReplayFrame(time_head, ManiaAction.Key1),
new ManiaReplayFrame(time_head + 1),
new ManiaReplayFrame(time_head + 2, ManiaAction.Key1),
new ManiaReplayFrame(time_tail),
});
assertHeadJudgement(HitResult.Perfect);
assertComboAtJudgement(0, 1);
assertTailJudgement(HitResult.Meh);
assertComboAtJudgement(1, 0);
assertComboAtJudgement(2, 1);
}
/// <summary> /// <summary>
/// -----[ ]----- /// -----[ ]-----
/// xo x o /// xo x o
@ -208,7 +222,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -228,7 +241,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Meh); assertTailJudgement(HitResult.Meh);
} }
@ -246,7 +258,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -264,7 +275,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Meh); assertTailJudgement(HitResult.Meh);
} }
@ -358,7 +368,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}, beatmap); }, beatmap);
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
assertHitObjectJudgement(note, HitResult.Good); assertHitObjectJudgement(note, HitResult.Good);
@ -405,7 +414,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}, beatmap); }, beatmap);
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
assertHitObjectJudgement(note, HitResult.Great); assertHitObjectJudgement(note, HitResult.Great);
@ -425,7 +433,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Meh); assertTailJudgement(HitResult.Meh);
} }
@ -476,42 +483,6 @@ namespace osu.Game.Rulesets.Mania.Tests
.All(j => j.Type.IsHit())); .All(j => j.Type.IsHit()));
} }
[Test]
public void TestHitTailBeforeLastTick()
{
const int tick_rate = 8;
const double tick_spacing = TimingControlPoint.DEFAULT_BEAT_LENGTH / tick_rate;
const double time_last_tick = time_head + tick_spacing * (int)((time_tail - time_head) / tick_spacing - 1);
var beatmap = new Beatmap<ManiaHitObject>
{
HitObjects =
{
new HoldNote
{
StartTime = time_head,
Duration = time_tail - time_head,
Column = 0,
}
},
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty { SliderTickRate = tick_rate },
Ruleset = new ManiaRuleset().RulesetInfo
},
};
performTest(new List<ReplayFrame>
{
new ManiaReplayFrame(time_head, ManiaAction.Key1),
new ManiaReplayFrame(time_last_tick - 5)
}, beatmap);
assertHeadJudgement(HitResult.Perfect);
assertLastTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Ok);
}
[Test] [Test]
public void TestZeroLength() public void TestZeroLength()
{ {
@ -551,11 +522,8 @@ namespace osu.Game.Rulesets.Mania.Tests
private void assertNoteJudgement(HitResult result) private void assertNoteJudgement(HitResult result)
=> AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type, () => Is.EqualTo(result)); => AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type, () => Is.EqualTo(result));
private void assertTickJudgement(HitResult result) private void assertComboAtJudgement(int judgementIndex, int combo)
=> AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Select(j => j.Type), () => Does.Contain(result)); => AddAssert($"combo at judgement {judgementIndex} is {combo}", () => judgementResults.ElementAt(judgementIndex).ComboAfterJudgement, () => Is.EqualTo(combo));
private void assertLastTickJudgement(HitResult result)
=> AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type, () => Is.EqualTo(result));
private ScoreAccessibleReplayPlayer currentPlayer = null!; private ScoreAccessibleReplayPlayer currentPlayer = null!;

View File

@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Judgements
{
public class HoldNoteBodyJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.IgnoreHit;
public override HitResult MinResult => HitResult.ComboBreak;
}
}

View File

@ -1,12 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Judgements
{
public class HoldNoteTickJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.LargeTickHit;
}
}

View File

@ -35,10 +35,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public DrawableHoldNoteHead Head => headContainer.Child; public DrawableHoldNoteHead Head => headContainer.Child;
public DrawableHoldNoteTail Tail => tailContainer.Child; public DrawableHoldNoteTail Tail => tailContainer.Child;
public DrawableHoldNoteBody Body => bodyContainer.Child;
private Container<DrawableHoldNoteHead> headContainer; private Container<DrawableHoldNoteHead> headContainer;
private Container<DrawableHoldNoteTail> tailContainer; private Container<DrawableHoldNoteTail> tailContainer;
private Container<DrawableHoldNoteTick> tickContainer; private Container<DrawableHoldNoteBody> bodyContainer;
private PausableSkinnableSound slidingSample; private PausableSkinnableSound slidingSample;
@ -60,12 +61,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public double? HoldStartTime { get; private set; } public double? HoldStartTime { get; private set; }
/// <summary> /// <summary>
/// Time at which the hold note has been broken, i.e. released too early, resulting in a reduced score. /// Used to decide whether to visually clamp the hold note to the judgement line.
/// </summary>
public double? HoldBrokenTime { get; private set; }
/// <summary>
/// Whether the hold note has been released potentially without having caused a break.
/// </summary> /// </summary>
private double? releaseTime; private double? releaseTime;
@ -103,6 +99,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both } headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both }
} }
}, },
bodyContainer = new Container<DrawableHoldNoteBody> { RelativeSizeAxes = Axes.Both },
bodyPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece bodyPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
@ -110,7 +107,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
RelativeSizeAxes = Axes.X RelativeSizeAxes = Axes.X
}, },
tickContainer = new Container<DrawableHoldNoteTick> { RelativeSizeAxes = Axes.Both },
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both }, tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
slidingSample = new PausableSkinnableSound { Looping = true } slidingSample = new PausableSkinnableSound { Looping = true }
}); });
@ -118,7 +114,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
maskedContents.AddRange(new[] maskedContents.AddRange(new[]
{ {
bodyPiece.CreateProxy(), bodyPiece.CreateProxy(),
tickContainer.CreateProxy(),
tailContainer.CreateProxy(), tailContainer.CreateProxy(),
}); });
} }
@ -136,7 +131,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
sizingContainer.Size = Vector2.One; sizingContainer.Size = Vector2.One;
HoldStartTime = null; HoldStartTime = null;
HoldBrokenTime = null;
releaseTime = null; releaseTime = null;
} }
@ -154,8 +148,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
tailContainer.Child = tail; tailContainer.Child = tail;
break; break;
case DrawableHoldNoteTick tick: case DrawableHoldNoteBody body:
tickContainer.Add(tick); bodyContainer.Child = body;
break; break;
} }
} }
@ -165,7 +159,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
base.ClearNestedHitObjects(); base.ClearNestedHitObjects();
headContainer.Clear(false); headContainer.Clear(false);
tailContainer.Clear(false); tailContainer.Clear(false);
tickContainer.Clear(false); bodyContainer.Clear(false);
} }
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
@ -178,8 +172,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
case HeadNote head: case HeadNote head:
return new DrawableHoldNoteHead(head); return new DrawableHoldNoteHead(head);
case HoldNoteTick tick: case HoldNoteBody body:
return new DrawableHoldNoteTick(tick); return new DrawableHoldNoteBody(body);
} }
return base.CreateNestedHitObject(hitObject); return base.CreateNestedHitObject(hitObject);
@ -266,20 +260,15 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
if (Tail.AllJudged) if (Tail.AllJudged)
{ {
foreach (var tick in tickContainer)
{
if (!tick.Judged)
tick.MissForcefully();
}
if (Tail.IsHit) if (Tail.IsHit)
ApplyResult(r => r.Type = r.Judgement.MaxResult); ApplyResult(r => r.Type = r.Judgement.MaxResult);
else else
MissForcefully(); MissForcefully();
} }
if (Tail.Judged && !Tail.IsHit) // Make sure that the hold note is fully judged by giving the body a judgement.
HoldBrokenTime = Time.Current; if (Tail.AllJudged && !Body.AllJudged)
Body.TriggerResult(Tail.IsHit);
} }
public override void MissForcefully() public override void MissForcefully()
@ -333,22 +322,22 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (e.Action != Action.Value) if (e.Action != Action.Value)
return; return;
// Make sure a hold was started
if (HoldStartTime == null)
return;
// do not run any of this logic when rewinding, as it inverts order of presses/releases. // do not run any of this logic when rewinding, as it inverts order of presses/releases.
if ((Clock as IGameplayClock)?.IsRewinding == true) if ((Clock as IGameplayClock)?.IsRewinding == true)
return; return;
Tail.UpdateResult(); // When our action is released and we are in the middle of a hold, there's a chance that
endHold(); // the user has released too early (before the tail).
//
// In such a case, we want to record this against the DrawableHoldNoteBody.
if (HoldStartTime != null)
{
Tail.UpdateResult();
Body.TriggerResult(Tail.IsHit);
// If the key has been released too early, the user should not receive full score for the release endHold();
if (!Tail.IsHit) releaseTime = Time.Current;
HoldBrokenTime = Time.Current; }
releaseTime = Time.Current;
} }
private void endHold() private void endHold()

View File

@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
public partial class DrawableHoldNoteBody : DrawableManiaHitObject<HoldNoteBody>
{
public bool HasHoldBreak => AllJudged && !IsHit;
public override bool DisplayResult => false;
public DrawableHoldNoteBody()
: this(null)
{
}
public DrawableHoldNoteBody(HoldNoteBody hitObject)
: base(hitObject)
{
}
internal void TriggerResult(bool hit)
{
if (AllJudged) return;
ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult);
}
}
}

View File

@ -55,7 +55,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
ApplyResult(r => ApplyResult(r =>
{ {
// If the head wasn't hit or the hold note was broken, cap the max score to Meh. // If the head wasn't hit or the hold note was broken, cap the max score to Meh.
if (result > HitResult.Meh && (!HoldNote.Head.IsHit || HoldNote.HoldBrokenTime != null)) bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak;
if (result > HitResult.Meh && hasComboBreak)
result = HitResult.Meh; result = HitResult.Meh;
r.Type = result; r.Type = result;

View File

@ -1,110 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
/// <summary>
/// Visualises a <see cref="HoldNoteTick"/> hit object.
/// </summary>
public partial class DrawableHoldNoteTick : DrawableManiaHitObject<HoldNoteTick>
{
/// <summary>
/// References the time at which the user started holding the hold note.
/// </summary>
private Func<double?> holdStartTime;
private Container glowContainer;
public DrawableHoldNoteTick()
: this(null)
{
}
public DrawableHoldNoteTick(HoldNoteTick hitObject)
: base(hitObject)
{
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
RelativeSizeAxes = Axes.X;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(glowContainer = new CircularContainer
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
RelativeSizeAxes = Axes.Both,
Masking = true,
Children = new[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true
}
}
});
}
protected override void LoadComplete()
{
base.LoadComplete();
AccentColour.BindValueChanged(colour =>
{
glowContainer.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Radius = 2f,
Roundness = 15f,
Colour = colour.NewValue.Opacity(0.3f)
};
}, true);
}
protected override void OnApply()
{
base.OnApply();
Debug.Assert(ParentHitObject != null);
var holdNote = (DrawableHoldNote)ParentHitObject;
holdStartTime = () => holdNote.HoldStartTime;
}
protected override void OnFree()
{
base.OnFree();
holdStartTime = null;
}
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (Time.Current < HitObject.StartTime)
return;
double? startTime = holdStartTime?.Invoke();
if (startTime == null || startTime > HitObject.StartTime)
ApplyResult(r => r.Type = r.Judgement.MinResult);
else
ApplyResult(r => r.Type = r.Judgement.MaxResult);
}
}
}

View File

@ -3,6 +3,9 @@
namespace osu.Game.Rulesets.Mania.Objects namespace osu.Game.Rulesets.Mania.Objects
{ {
/// <summary>
/// The head note of a <see cref="HoldNote"/>.
/// </summary>
public class HeadNote : Note public class HeadNote : Note
{ {
} }

View File

@ -6,8 +6,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
@ -81,27 +79,18 @@ namespace osu.Game.Rulesets.Mania.Objects
/// </summary> /// </summary>
public TailNote Tail { get; private set; } public TailNote Tail { get; private set; }
public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset;
/// <summary> /// <summary>
/// The time between ticks of this hold. /// The body of the hold.
/// This is an invisible and silent object that tracks the holding state of the <see cref="HoldNote"/>.
/// </summary> /// </summary>
private double tickSpacing = 50; public HoldNoteBody Body { get; private set; }
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset;
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime);
tickSpacing = timingPoint.BeatLength / difficulty.SliderTickRate;
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken) protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{ {
base.CreateNestedHitObjects(cancellationToken); base.CreateNestedHitObjects(cancellationToken);
createTicks(cancellationToken);
AddNested(Head = new HeadNote AddNested(Head = new HeadNote
{ {
StartTime = StartTime, StartTime = StartTime,
@ -115,23 +104,12 @@ namespace osu.Game.Rulesets.Mania.Objects
Column = Column, Column = Column,
Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1), Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1),
}); });
}
private void createTicks(CancellationToken cancellationToken) AddNested(Body = new HoldNoteBody
{
if (tickSpacing == 0)
return;
for (double t = StartTime + tickSpacing; t <= EndTime - tickSpacing; t += tickSpacing)
{ {
cancellationToken.ThrowIfCancellationRequested(); StartTime = StartTime,
Column = Column
AddNested(new HoldNoteTick });
{
StartTime = t,
Column = Column
});
}
} }
public override Judgement CreateJudgement() => new IgnoreJudgement(); public override Judgement CreateJudgement() => new IgnoreJudgement();

View File

@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects
{
/// <summary>
/// The body of a <see cref="HoldNote"/>.
/// Mostly a dummy hitobject that provides the judgement for the "holding" state.<br />
/// On hit - the hold note was held correctly for the full duration.<br />
/// On miss - the hold note was released at some point during its judgement period.
/// </summary>
public class HoldNoteBody : ManiaHitObject
{
public override Judgement CreateJudgement() => new HoldNoteBodyJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
}
}

View File

@ -1,19 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects
{
/// <summary>
/// A scoring tick of a hold note.
/// </summary>
public class HoldNoteTick : ManiaHitObject
{
public override Judgement CreateJudgement() => new HoldNoteTickJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
}
}

View File

@ -6,6 +6,9 @@ using osu.Game.Rulesets.Mania.Judgements;
namespace osu.Game.Rulesets.Mania.Objects namespace osu.Game.Rulesets.Mania.Objects
{ {
/// <summary>
/// The tail note of a <see cref="HoldNote"/>.
/// </summary>
public class TailNote : Note public class TailNote : Note
{ {
/// <summary> /// <summary>

View File

@ -209,7 +209,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
missFadeTime.Value ??= holdNote.HoldBrokenTime;
if (holdNote.Body.HasHoldBreak)
missFadeTime.Value = holdNote.Body.Result.TimeAbsolute;
int scaleDirection = (direction.Value == ScrollingDirection.Down ? 1 : -1); int scaleDirection = (direction.Value == ScrollingDirection.Down ? 1 : -1);

View File

@ -7,7 +7,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Animations;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning; using osu.Game.Skinning;
@ -69,9 +68,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
public void Animate(JudgementResult result) public void Animate(JudgementResult result)
{ {
if (result.Judgement is HoldNoteTickJudgement)
return;
(explosion as IFramedAnimation)?.GotoFrame(0); (explosion as IFramedAnimation)?.GotoFrame(0);
explosion?.FadeInFromZero(FADE_IN_DURATION) explosion?.FadeInFromZero(FADE_IN_DURATION)

View File

@ -112,7 +112,7 @@ namespace osu.Game.Rulesets.Mania.UI
RegisterPool<HoldNote, DrawableHoldNote>(10, 50); RegisterPool<HoldNote, DrawableHoldNote>(10, 50);
RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50); RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50);
RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50); RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);
RegisterPool<HoldNoteTick, DrawableHoldNoteTick>(50, 250); RegisterPool<HoldNoteBody, DrawableHoldNoteBody>(10, 50);
} }
private void onSourceChanged() private void onSourceChanged()

View File

@ -11,7 +11,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Effects;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK; using osuTK;
@ -150,9 +149,6 @@ namespace osu.Game.Rulesets.Mania.UI
// scale roughly in-line with visual appearance of notes // scale roughly in-line with visual appearance of notes
Vector2 scale = new Vector2(1, 0.6f); Vector2 scale = new Vector2(1, 0.6f);
if (result.Judgement is HoldNoteTickJudgement)
scale *= 0.5f;
this.ScaleTo(scale); this.ScaleTo(scale);
largeFaint largeFaint

View File

@ -195,10 +195,6 @@ namespace osu.Game.Rulesets.Mania.UI
if (!judgedObject.DisplayResult || !DisplayJudgements.Value) if (!judgedObject.DisplayResult || !DisplayJudgements.Value)
return; return;
// Tick judgements should not display text.
if (judgedObject is DrawableHoldNoteTick)
return;
judgements.Clear(false); judgements.Clear(false);
judgements.Add(judgementPool.Get(j => judgements.Add(judgementPool.Get(j =>
{ {

View File

@ -0,0 +1,36 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Tests.Rulesets.Scoring
{
[TestFixture]
public class HitResultTest
{
[TestCase(new[] { HitResult.Perfect, HitResult.Great, HitResult.Good, HitResult.Ok, HitResult.Meh }, new[] { HitResult.Miss })]
[TestCase(new[] { HitResult.LargeTickHit }, new[] { HitResult.LargeTickMiss })]
[TestCase(new[] { HitResult.SmallTickHit }, new[] { HitResult.SmallTickMiss })]
[TestCase(new[] { HitResult.LargeBonus, HitResult.SmallBonus }, new[] { HitResult.IgnoreMiss })]
[TestCase(new[] { HitResult.IgnoreHit }, new[] { HitResult.IgnoreMiss, HitResult.ComboBreak })]
public void TestValidResultPairs(HitResult[] maxResults, HitResult[] minResults)
{
HitResult[] unsupportedResults = HitResultExtensions.ALL_TYPES.Where(t => !minResults.Contains(t)).ToArray();
Assert.Multiple(() =>
{
foreach (var max in maxResults)
{
foreach (var min in minResults)
Assert.DoesNotThrow(() => HitResultExtensions.ValidateHitResultPair(max, min), $"{max} + {min} should be supported.");
foreach (var unsupported in unsupportedResults)
Assert.Throws<ArgumentOutOfRangeException>(() => HitResultExtensions.ValidateHitResultPair(max, unsupported), $"{max} + {unsupported} should not be supported.");
}
});
}
}
}

View File

@ -107,7 +107,7 @@ namespace osu.Game.Tests.Rulesets.Scoring
for (int i = 0; i < 4; i++) for (int i = 0; i < 4; i++)
{ {
var judgementResult = new JudgementResult(fourObjectBeatmap.HitObjects[i], new TestJudgement(maxResult)) var judgementResult = new JudgementResult(fourObjectBeatmap.HitObjects[i], fourObjectBeatmap.HitObjects[i].CreateJudgement())
{ {
Type = i == 2 ? minResult : hitResult Type = i == 2 ? minResult : hitResult
}; };
@ -259,6 +259,41 @@ namespace osu.Game.Tests.Rulesets.Scoring
} }
#pragma warning restore CS0618 #pragma warning restore CS0618
[Test]
public void TestComboBreak()
{
Assert.That(HitResult.ComboBreak.IncreasesCombo(), Is.False);
Assert.That(HitResult.ComboBreak.BreaksCombo(), Is.True);
Assert.That(HitResult.ComboBreak.AffectsCombo(), Is.True);
Assert.That(HitResult.ComboBreak.AffectsAccuracy(), Is.False);
Assert.That(HitResult.ComboBreak.IsBasic(), Is.False);
Assert.That(HitResult.ComboBreak.IsTick(), Is.False);
Assert.That(HitResult.ComboBreak.IsBonus(), Is.False);
Assert.That(HitResult.ComboBreak.IsHit(), Is.False);
Assert.That(HitResult.ComboBreak.IsScorable(), Is.True);
Assert.That(HitResultExtensions.ALL_TYPES, Does.Contain(HitResult.ComboBreak));
beatmap = new TestBeatmap(new RulesetInfo())
{
HitObjects = new List<HitObject>
{
new TestHitObject(HitResult.Great),
new TestHitObject(HitResult.IgnoreHit, HitResult.ComboBreak),
}
};
scoreProcessor = new TestScoreProcessor();
scoreProcessor.ApplyBeatmap(beatmap);
scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], beatmap.HitObjects[0].CreateJudgement()) { Type = HitResult.Great });
Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(1));
Assert.That(scoreProcessor.Accuracy.Value, Is.EqualTo(1));
scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[1], beatmap.HitObjects[1].CreateJudgement()) { Type = HitResult.ComboBreak });
Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0));
Assert.That(scoreProcessor.Accuracy.Value, Is.EqualTo(1));
}
[Test] [Test]
public void TestAccuracyWhenNearPerfect() public void TestAccuracyWhenNearPerfect()
{ {
@ -275,7 +310,7 @@ namespace osu.Game.Tests.Rulesets.Scoring
for (int i = 0; i < beatmap.HitObjects.Count; i++) for (int i = 0; i < beatmap.HitObjects.Count; i++)
{ {
scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[i], new TestJudgement(HitResult.Great)) scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[i], beatmap.HitObjects[i].CreateJudgement())
{ {
Type = i == 0 ? HitResult.Miss : HitResult.Great Type = i == 0 ? HitResult.Miss : HitResult.Great
}); });
@ -293,24 +328,31 @@ namespace osu.Game.Tests.Rulesets.Scoring
{ {
public override HitResult MaxResult { get; } public override HitResult MaxResult { get; }
public TestJudgement(HitResult maxResult) public override HitResult MinResult => minResult ?? base.MinResult;
private readonly HitResult? minResult;
public TestJudgement(HitResult maxResult, HitResult? minResult = null)
{ {
MaxResult = maxResult; MaxResult = maxResult;
this.minResult = minResult;
} }
} }
private class TestHitObject : HitObject private class TestHitObject : HitObject
{ {
private readonly HitResult maxResult; private readonly HitResult maxResult;
private readonly HitResult? minResult;
public override Judgement CreateJudgement() public override Judgement CreateJudgement()
{ {
return new TestJudgement(maxResult); return new TestJudgement(maxResult, minResult);
} }
public TestHitObject(HitResult maxResult) public TestHitObject(HitResult maxResult, HitResult? minResult = null)
{ {
this.maxResult = maxResult; this.maxResult = maxResult;
this.minResult = minResult;
} }
} }

View File

@ -78,6 +78,7 @@ namespace osu.Game.Graphics
case HitResult.SmallTickMiss: case HitResult.SmallTickMiss:
case HitResult.LargeTickMiss: case HitResult.LargeTickMiss:
case HitResult.Miss: case HitResult.Miss:
case HitResult.ComboBreak:
return Red; return Red;
case HitResult.Meh: case HitResult.Meh:

View File

@ -35,7 +35,40 @@ namespace osu.Game.Rulesets.Judgements
/// <summary> /// <summary>
/// The minimum <see cref="HitResult"/> that can be achieved - the inverse of <see cref="MaxResult"/>. /// The minimum <see cref="HitResult"/> that can be achieved - the inverse of <see cref="MaxResult"/>.
/// </summary> /// </summary>
public HitResult MinResult /// <remarks>
/// Defaults to a sane value for the given <see cref="MaxResult"/>. May be overridden to provide a supported custom value:
/// <list type="table">
/// <listheader>
/// <term><see cref="MaxResult"/>s</term>
/// <description>Valid <see cref="MinResult"/>s</description>
/// </listheader>
/// <item>
/// <term><see cref="HitResult.Perfect"/>, <see cref="HitResult.Great"/>, <see cref="HitResult.Good"/>, <see cref="HitResult.Ok"/>, <see cref="HitResult.Meh"/></term>
/// <description><see cref="HitResult.Miss"/></description>
/// </item>
/// <item>
/// <term><see cref="HitResult.LargeBonus"/></term>
/// <description><see cref="HitResult.IgnoreMiss"/></description>
/// </item>
/// <item>
/// <term><see cref="HitResult.SmallBonus"/></term>
/// <description><see cref="HitResult.IgnoreMiss"/></description>
/// </item>
/// <item>
/// <term><see cref="HitResult.SmallTickHit"/></term>
/// <description><see cref="HitResult.SmallTickMiss"/></description>
/// </item>
/// <item>
/// <term><see cref="HitResult.LargeTickHit"/></term>
/// <description><see cref="HitResult.LargeTickMiss"/></description>
/// </item>
/// <item>
/// <term><see cref="HitResult.IgnoreHit"/></term>
/// <description><see cref="HitResult.IgnoreMiss"/>, <see cref="HitResult.ComboBreak"/></description>
/// </item>
/// </list>
/// </remarks>
public virtual HitResult MinResult
{ {
get get
{ {

View File

@ -672,6 +672,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (!Result.HasResult) if (!Result.HasResult)
throw new InvalidOperationException($"{GetType().ReadableName()} applied a {nameof(JudgementResult)} but did not update {nameof(JudgementResult.Type)}."); throw new InvalidOperationException($"{GetType().ReadableName()} applied a {nameof(JudgementResult)} but did not update {nameof(JudgementResult.Type)}.");
HitResultExtensions.ValidateHitResultPair(Result.Judgement.MaxResult, Result.Judgement.MinResult);
if (!Result.Type.IsValidHitResult(Result.Judgement.MinResult, Result.Judgement.MaxResult)) if (!Result.Type.IsValidHitResult(Result.Judgement.MinResult, Result.Judgement.MaxResult))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(

View File

@ -120,6 +120,16 @@ namespace osu.Game.Rulesets.Scoring
[Order(12)] [Order(12)]
IgnoreHit, IgnoreHit,
/// <summary>
/// Indicates that a combo break should occur, but does not otherwise affect score.
/// </summary>
/// <remarks>
/// May be paired with <see cref="IgnoreHit"/>.
/// </remarks>
[EnumMember(Value = "combo_break")]
[Order(15)]
ComboBreak,
/// <summary> /// <summary>
/// A special result used as a padding value for legacy rulesets. It is a hit type and affects combo, but does not affect the base score (does not affect accuracy). /// A special result used as a padding value for legacy rulesets. It is a hit type and affects combo, but does not affect the base score (does not affect accuracy).
/// </summary> /// </summary>
@ -165,6 +175,7 @@ namespace osu.Game.Rulesets.Scoring
case HitResult.LargeTickHit: case HitResult.LargeTickHit:
case HitResult.LargeTickMiss: case HitResult.LargeTickMiss:
case HitResult.LegacyComboIncrease: case HitResult.LegacyComboIncrease:
case HitResult.ComboBreak:
return true; return true;
default: default:
@ -177,11 +188,19 @@ namespace osu.Game.Rulesets.Scoring
/// </summary> /// </summary>
public static bool AffectsAccuracy(this HitResult result) public static bool AffectsAccuracy(this HitResult result)
{ {
// LegacyComboIncrease is a special type which is neither a basic, tick, bonus, or accuracy-affecting result. switch (result)
if (result == HitResult.LegacyComboIncrease) {
return false; // LegacyComboIncrease is a special non-gameplay type which is neither a basic, tick, bonus, or accuracy-affecting result.
case HitResult.LegacyComboIncrease:
return false;
return IsScorable(result) && !IsBonus(result); // ComboBreak is a special type that only affects combo. It cannot be considered as basic, tick, bonus, or accuracy-affecting.
case HitResult.ComboBreak:
return false;
default:
return IsScorable(result) && !IsBonus(result);
}
} }
/// <summary> /// <summary>
@ -189,11 +208,19 @@ namespace osu.Game.Rulesets.Scoring
/// </summary> /// </summary>
public static bool IsBasic(this HitResult result) public static bool IsBasic(this HitResult result)
{ {
// LegacyComboIncrease is a special type which is neither a basic, tick, bonus, or accuracy-affecting result. switch (result)
if (result == HitResult.LegacyComboIncrease) {
return false; // LegacyComboIncrease is a special non-gameplay type which is neither a basic, tick, bonus, or accuracy-affecting result.
case HitResult.LegacyComboIncrease:
return false;
return IsScorable(result) && !IsTick(result) && !IsBonus(result); // ComboBreak is a special type that only affects combo. It cannot be considered as basic, tick, bonus, or accuracy-affecting.
case HitResult.ComboBreak:
return false;
default:
return IsScorable(result) && !IsTick(result) && !IsBonus(result);
}
} }
/// <summary> /// <summary>
@ -242,6 +269,7 @@ namespace osu.Game.Rulesets.Scoring
case HitResult.Miss: case HitResult.Miss:
case HitResult.SmallTickMiss: case HitResult.SmallTickMiss:
case HitResult.LargeTickMiss: case HitResult.LargeTickMiss:
case HitResult.ComboBreak:
return false; return false;
default: default:
@ -254,11 +282,20 @@ namespace osu.Game.Rulesets.Scoring
/// </summary> /// </summary>
public static bool IsScorable(this HitResult result) public static bool IsScorable(this HitResult result)
{ {
// LegacyComboIncrease is not actually scorable (in terms of usable by rulesets for that purpose), but needs to be defined as such to be correctly included in statistics output. switch (result)
if (result == HitResult.LegacyComboIncrease) {
return true; // LegacyComboIncrease is not actually scorable (in terms of usable by rulesets for that purpose), but needs to be defined as such to be correctly included in statistics output.
case HitResult.LegacyComboIncrease:
return true;
return result >= HitResult.Miss && result < HitResult.IgnoreMiss; // ComboBreak is its own type that affects score via combo.
case HitResult.ComboBreak:
return true;
default:
// Note that IgnoreHit and IgnoreMiss are excluded as they do not affect score.
return result >= HitResult.Miss && result < HitResult.IgnoreMiss;
}
} }
/// <summary> /// <summary>
@ -291,6 +328,30 @@ namespace osu.Game.Rulesets.Scoring
/// <param name="result">The <see cref="HitResult"/> to get the index of.</param> /// <param name="result">The <see cref="HitResult"/> to get the index of.</param>
/// <returns>The index of <paramref name="result"/>.</returns> /// <returns>The index of <paramref name="result"/>.</returns>
public static int GetIndexForOrderedDisplay(this HitResult result) => order.IndexOf(result); public static int GetIndexForOrderedDisplay(this HitResult result) => order.IndexOf(result);
public static void ValidateHitResultPair(HitResult maxResult, HitResult minResult)
{
if (maxResult == HitResult.None || !IsHit(maxResult))
throw new ArgumentOutOfRangeException(nameof(maxResult), $"{maxResult} is not a valid maximum judgement result.");
if (minResult == HitResult.None || IsHit(minResult))
throw new ArgumentOutOfRangeException(nameof(minResult), $"{minResult} is not a valid minimum judgement result.");
if (maxResult == HitResult.IgnoreHit && minResult is not (HitResult.IgnoreMiss or HitResult.ComboBreak))
throw new ArgumentOutOfRangeException(nameof(minResult), $"{minResult} is not a valid minimum result for a {maxResult} judgement.");
if (maxResult.IsBonus() && minResult != HitResult.IgnoreMiss)
throw new ArgumentOutOfRangeException(nameof(minResult), $"{HitResult.IgnoreMiss} is the only valid minimum result for a {maxResult} judgement.");
if (maxResult == HitResult.LargeTickHit && minResult != HitResult.LargeTickMiss)
throw new ArgumentOutOfRangeException(nameof(minResult), $"{HitResult.LargeTickMiss} is the only valid minimum result for a {maxResult} judgement.");
if (maxResult == HitResult.SmallTickHit && minResult != HitResult.SmallTickMiss)
throw new ArgumentOutOfRangeException(nameof(minResult), $"{HitResult.SmallTickMiss} is the only valid minimum result for a {maxResult} judgement.");
if (maxResult.IsBasic() && minResult != HitResult.Miss)
throw new ArgumentOutOfRangeException(nameof(minResult), $"{HitResult.Miss} is the only valid minimum result for a {maxResult} judgement.");
}
} }
#pragma warning restore CS0618 #pragma warning restore CS0618
} }