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

Merge branch 'master' into LNFixHotfix

This commit is contained in:
Dan Balasescu 2023-08-15 13:45:06 +02:00 committed by GitHub
commit 7d8cef83e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
460 changed files with 7826 additions and 2933 deletions

View File

@ -21,7 +21,7 @@
]
},
"ppy.localisationanalyser.tools": {
"version": "2022.809.0",
"version": "2023.712.0",
"commands": [
"localisation"
]

View File

@ -9,6 +9,9 @@ indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[g_*.cs]
generated_code = true
[*.cs]
end_of_line = crlf
insert_final_newline = true

1
.gitignore vendored
View File

@ -339,6 +339,5 @@ inspectcode
# Fody (pulled in by Realm) - schema file
FodyWeavers.xsd
**/FodyWeavers.xml
.idea/.idea.osu.Desktop/.idea/misc.xml

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<asmv1:assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv1="urn:schemas-microsoft-com:asm.v1" xmlns:asmv2="urn:schemas-microsoft-com:asm.v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity version="1.0.0.0" name="osu!" />
<SquirrelAwareVersion xmlns="urn:schema-squirrel-com:asm.v1">1</SquirrelAwareVersion>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
@ -14,33 +15,10 @@
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows Vista -->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 10 -->
<!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<asmv3:application>
<asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
<dpiAware>true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</asmv1:assembly>

View File

@ -8,13 +8,9 @@
<!-- NullabilityInfoContextSupport is disabled by default for Android -->
<NullabilityInfoContextSupport>true</NullabilityInfoContextSupport>
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<AndroidManifestMerger>manifestmerger.jar</AndroidManifestMerger>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.625.0" />
</ItemGroup>
<ItemGroup>
<AndroidManifestOverlay Include="$(MSBuildThisFileDirectory)osu.Android\Properties\AndroidManifestOverlay.xml" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.815.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mailto" />
</intent>
</queries>
</manifest>

View File

@ -54,9 +54,6 @@ namespace osu.Desktop
client.OnReady += onReady;
// safety measure for now, until we performance test / improve backoff for failed connections.
client.OnConnectionFailed += (_, _) => client.Deinitialize();
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network);
config.BindWith(OsuSetting.DiscordRichPresence, privacyMode);
@ -187,7 +184,7 @@ namespace osu.Desktop
return edit.BeatmapInfo.ToString() ?? string.Empty;
case UserActivity.WatchingReplay watching:
return watching.BeatmapInfo.ToString();
return watching.BeatmapInfo?.ToString() ?? string.Empty;
case UserActivity.InLobby lobby:
return privacyMode.Value == DiscordRichPresenceMode.Limited ? string.Empty : lobby.Room.Name.Value;

View File

@ -75,7 +75,7 @@ namespace osu.Desktop.LegacyIpc
case LegacyIpcDifficultyCalculationRequest req:
try
{
WorkingBeatmap beatmap = new FlatFileWorkingBeatmap(req.BeatmapFile);
WorkingBeatmap beatmap = new FlatWorkingBeatmap(req.BeatmapFile);
var ruleset = beatmap.BeatmapInfo.Ruleset.CreateInstance();
Mod[] mods = ruleset.ConvertFromLegacyMods((LegacyMods)req.Mods).ToArray();

View File

@ -147,14 +147,12 @@ namespace osu.Desktop
{
base.SetHost(host);
var desktopWindow = (SDL2DesktopWindow)host.Window;
var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico");
if (iconStream != null)
desktopWindow.SetIconFromStream(iconStream);
host.Window.SetIconFromStream(iconStream);
desktopWindow.CursorState |= CursorState.Hidden;
desktopWindow.Title = Name;
host.Window.CursorState |= CursorState.Hidden;
host.Window.Title = Name;
}
protected override BatteryInfo CreateBatteryInfo() => new SDL2BatteryInfo();

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<asmv1:assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv1="urn:schemas-microsoft-com:asm.v1" xmlns:asmv2="urn:schemas-microsoft-com:asm.v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity version="1.0.0.0" name="osu!" />
<SquirrelAwareVersion xmlns="urn:schema-squirrel-com:asm.v1">1</SquirrelAwareVersion>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
<applicationRequestMinimum>
<defaultAssemblyRequest permissionSetReference="Custom" />
<PermissionSet class="System.Security.PermissionSet" version="1" Unrestricted="true" ID="Custom" SameSite="site" />
</applicationRequestMinimum>
</security>
</trustInfo>
<asmv3:application>
<asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
<dpiAware>true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
</asmv1:assembly>

View File

@ -8,7 +8,6 @@
<Title>osu!</Title>
<Product>osu!(lazer)</Product>
<ApplicationIcon>lazer.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.0.0</Version>
<FileVersion>0.0.0</FileVersion>
</PropertyGroup>
@ -27,7 +26,7 @@
<PackageReference Include="Clowd.Squirrel" Version="2.9.42" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="System.IO.Packaging" Version="7.0.0" />
<PackageReference Include="DiscordRichPresence" Version="1.1.3.18" />
<PackageReference Include="DiscordRichPresence" Version="1.1.4.20" />
</ItemGroup>
<ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" />

View File

@ -5,7 +5,7 @@
<key>CFBundleName</key>
<string>osu.Game.Rulesets.Catch.Tests.iOS</string>
<key>CFBundleIdentifier</key>
<string>ppy.osu-Game-Rulesets-Catch-Tests-iOS</string>
<string>sh.ppy.catch-ruleset-tests</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>

View File

@ -5,6 +5,7 @@ using System;
using NUnit.Framework;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Rulesets.Catch.Tests
@ -24,7 +25,8 @@ namespace osu.Game.Rulesets.Catch.Tests
new object[] { LegacyMods.HalfTime, new[] { typeof(CatchModHalfTime) } },
new object[] { LegacyMods.Flashlight, new[] { typeof(CatchModFlashlight) } },
new object[] { LegacyMods.Autoplay, new[] { typeof(CatchModAutoplay) } },
new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(CatchModHardRock), typeof(CatchModDoubleTime) } }
new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(CatchModHardRock), typeof(CatchModDoubleTime) } },
new object[] { LegacyMods.ScoreV2, new[] { typeof(ModScoreV2) } },
};
[TestCaseSource(nameof(catch_mod_mapping))]

View File

@ -26,6 +26,8 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Catch
@ -91,6 +93,9 @@ namespace osu.Game.Rulesets.Catch
if (mods.HasFlagFast(LegacyMods.Relax))
yield return new CatchModRelax();
if (mods.HasFlagFast(LegacyMods.ScoreV2))
yield return new ModScoreV2();
}
public override IEnumerable<Mod> GetModsFor(ModType type)
@ -140,6 +145,12 @@ namespace osu.Game.Rulesets.Catch
new CatchModNoScope(),
};
case ModType.System:
return new Mod[]
{
new ModScoreV2(),
};
default:
return Array.Empty<Mod>();
}
@ -202,10 +213,24 @@ namespace osu.Game.Rulesets.Catch
public int LegacyID => 2;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new CatchLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame();
public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this);
public override IBeatmapVerifier CreateBeatmapVerifier() => new CatchBeatmapVerifier();
public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
{
return new[]
{
new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}),
};
}
}
}

View File

@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// Todo: osu!catch should not output star rating in the 'aim' attribute.
yield return (ATTRIB_ID_AIM, StarRating);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@ -36,7 +35,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
StarRating = values[ATTRIB_ID_AIM];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
}
}
}

View File

@ -25,9 +25,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty
public override int Version => 20220701;
private readonly IWorkingBeatmap workingBeatmap;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@ -38,13 +41,24 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// this is the same as osu!, so there's potential to share the implementation... maybe
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
return new CatchDifficultyAttributes
CatchDifficultyAttributes attributes = new CatchDifficultyAttributes
{
StarRating = Math.Sqrt(skills[0].DifficultyValue()) * star_scaling_factor,
Mods = mods,
ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0,
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
};
if (ComputeLegacyScoringValues)
{
CatchLegacyScoreSimulator sv1Simulator = new CatchLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)

View File

@ -0,0 +1,142 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Catch.Difficulty
{
internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int combo;
private double scoreMultiplier;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
int countNormal = 0;
int countSlider = 0;
int countSpinner = 0;
foreach (HitObject obj in baseBeatmap.HitObjects)
{
switch (obj)
{
case IHasPath:
countSlider++;
break;
case IHasDuration:
countSpinner++;
break;
default:
countNormal++;
break;
}
}
int objectCount = countNormal + countSlider + countSpinner;
int drainLength = 0;
if (baseBeatmap.HitObjects.Count > 0)
{
int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000;
}
int difficultyPeppyStars = (int)Math.Round(
(baseBeatmap.Difficulty.DrainRate
+ baseBeatmap.Difficulty.OverallDifficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
}
private void simulateHit(HitObject hitObject)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
bool isBonus = false;
HitResult bonusResult = HitResult.None;
int scoreIncrease = 0;
switch (hitObject)
{
case TinyDroplet:
scoreIncrease = 10;
increaseCombo = false;
break;
case Droplet:
scoreIncrease = 100;
break;
case Fruit:
scoreIncrease = 300;
addScoreComboMultiplier = true;
increaseCombo = true;
break;
case Banana:
scoreIncrease = 1100;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.LargeBonus;
break;
case JuiceStream:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
return;
case BananaShower:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
return;
}
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
}
}

View File

@ -6,7 +6,6 @@ using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
@ -24,7 +23,5 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
: base(new THitObject())
{
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
}
}

View File

@ -4,6 +4,7 @@
using osu.Framework.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
@ -59,7 +60,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
lastDisplayedCombo = combo;
if (Time.Elapsed < 0)
if ((Clock as IGameplayClock)?.IsRewinding == true)
{
// needs more work to make rewind somehow look good.
// basically we want the previous increment to play... or turning off RemoveCompletedTransforms (not feasible from a performance angle).

View File

@ -10,6 +10,7 @@ using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osuTK;
namespace osu.Game.Rulesets.Catch.UI
@ -96,7 +97,7 @@ namespace osu.Game.Rulesets.Catch.UI
comboDisplay.X = Catcher.X;
if (Time.Elapsed <= 0)
if ((Clock as IGameplayClock)?.IsRewinding == true)
{
// This is probably a wrong value, but currently the true value is not recorded.
// Setting `true` will prevent generation of false-positive after-images (with more false-negatives).

View File

@ -5,7 +5,7 @@
<key>CFBundleName</key>
<string>osu.Game.Rulesets.Mania.Tests.iOS</string>
<key>CFBundleIdentifier</key>
<string>ppy.osu-Game-Rulesets-Mania-Tests-iOS</string>
<string>sh.ppy.mania-ruleset-tests</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>

View File

@ -5,6 +5,7 @@ using System;
using NUnit.Framework;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Rulesets.Mania.Tests
@ -36,7 +37,8 @@ namespace osu.Game.Rulesets.Mania.Tests
new object[] { LegacyMods.Key3, new[] { typeof(ManiaModKey3) } },
new object[] { LegacyMods.Key2, new[] { typeof(ManiaModKey2) } },
new object[] { LegacyMods.Mirror, new[] { typeof(ManiaModMirror) } },
new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(ManiaModHardRock), typeof(ManiaModDoubleTime) } }
new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(ManiaModHardRock), typeof(ManiaModDoubleTime) } },
new object[] { LegacyMods.ScoreV2, new[] { typeof(ModScoreV2) } },
};
[TestCaseSource(nameof(mania_mod_mapping))]

View File

@ -0,0 +1,147 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests
{
public partial class TestSceneMaximumScore : RateAdjustedBeatmapTestScene
{
private ScoreAccessibleReplayPlayer currentPlayer = null!;
private List<JudgementResult> judgementResults = new List<JudgementResult>();
[Test]
public void TestSimultaneousTickAndNote()
{
performTest(
new List<ManiaHitObject>
{
new HoldNote
{
StartTime = 1000,
Duration = 2000,
Column = 0,
},
new Note
{
StartTime = 2000,
Column = 1
}
},
new List<ReplayFrame>
{
new ManiaReplayFrame(1000, ManiaAction.Key1),
new ManiaReplayFrame(2000, ManiaAction.Key1, ManiaAction.Key2),
new ManiaReplayFrame(2001, ManiaAction.Key1),
new ManiaReplayFrame(3000)
});
AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000));
}
[Test]
public void TestSimultaneousLongNotes()
{
performTest(
new List<ManiaHitObject>
{
new HoldNote
{
StartTime = 1000,
Duration = 2000,
Column = 0,
},
new HoldNote
{
StartTime = 2000,
Duration = 2000,
Column = 1
}
},
new List<ReplayFrame>
{
new ManiaReplayFrame(1000, ManiaAction.Key1),
new ManiaReplayFrame(2000, ManiaAction.Key1, ManiaAction.Key2),
new ManiaReplayFrame(3000, ManiaAction.Key2),
new ManiaReplayFrame(4000)
});
AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000));
}
private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames)
{
var beatmap = new Beatmap<ManiaHitObject>
{
HitObjects = hitObjects,
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty { SliderTickRate = 4 },
Ruleset = new ManiaRuleset().RulesetInfo
},
};
beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f });
AddStep("load player", () =>
{
Beatmap.Value = CreateWorkingBeatmap(beatmap);
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
p.OnLoadComplete += _ =>
{
p.ScoreProcessor.NewJudgement += result =>
{
if (currentPlayer == p) judgementResults.Add(result);
};
};
LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>();
});
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
}
private partial class ScoreAccessibleReplayPlayer : ReplayPlayer
{
public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;
protected override bool PauseOnFocusLost => false;
public ScoreAccessibleReplayPlayer(Score score)
: base(score, new PlayerConfiguration
{
AllowPause = false,
ShowResults = false,
})
{
}
}
}
}

View File

@ -117,18 +117,16 @@ namespace osu.Game.Rulesets.Mania.Tests
private void createBarLine(bool major)
{
foreach (var stage in stages)
var obj = new BarLine
{
var obj = new BarLine
{
StartTime = Time.Current + 2000,
Major = major,
};
StartTime = Time.Current + 2000,
Major = major,
};
obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
foreach (var stage in stages)
stage.Add(obj);
}
}
private ScrollingTestContainer createStage(ScrollingDirection direction, ManiaAction action)

View File

@ -24,7 +24,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
foreach (var v in base.ToDatabaseAttributes())
yield return v;
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
}
@ -33,7 +32,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
base.FromDatabaseAttributes(values, onlineInfo);
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
}

View File

@ -31,9 +31,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
}
@ -46,15 +50,26 @@ namespace osu.Game.Rulesets.Mania.Difficulty
HitWindows hitWindows = new ManiaHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
return new ManiaDifficultyAttributes
ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{
StarRating = skills[0].DifficultyValue() * star_scaling_factor,
Mods = mods,
// In osu-stable mania, rate-adjustment mods don't affect the hit window.
// This is done the way it is to introduce fractional differences in order to match osu-stable for the time being.
GreatHitWindow = Math.Ceiling((int)(getHitWindow300(mods) * clockRate) / clockRate),
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject)
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
};
if (ComputeLegacyScoringValues)
{
ManiaLegacyScoreSimulator sv1Simulator = new ManiaLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
private static int maxComboForObject(HitObject hitObject)

View File

@ -0,0 +1,28 @@
// 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.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Difficulty
{
internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore => 0;
public int ComboScore { get; private set; }
public double BonusScoreRatio => 0;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{
double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn))
.Select(m => m.ScoreMultiplier)
.Aggregate(1.0, (c, n) => c * n);
ComboScore = (int)(1000000 * multiplier);
}
}
}

View File

@ -0,0 +1,22 @@
// 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.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Screens.Edit.Setup;
namespace osu.Game.Rulesets.Mania.Edit.Setup
{
public partial class ManiaDifficultySection : DifficultySection
{
[BackgroundDependencyLoader]
private void load()
{
CircleSizeSlider.Label = BeatmapsetsStrings.ShowStatsCsMania;
CircleSizeSlider.Description = "The number of columns in the beatmap";
if (CircleSizeSlider.Current is BindableNumber<float> circleSizeFloat)
circleSizeFloat.Precision = 1;
}
}
}

View File

@ -157,6 +157,9 @@ namespace osu.Game.Rulesets.Mania
if (mods.HasFlagFast(LegacyMods.Mirror))
yield return new ManiaModMirror();
if (mods.HasFlagFast(LegacyMods.ScoreV2))
yield return new ModScoreV2();
}
public override LegacyMods ConvertToLegacyMods(Mod[] mods)
@ -285,6 +288,12 @@ namespace osu.Game.Rulesets.Mania
new ModAdaptiveSpeed()
};
case ModType.System:
return new Mod[]
{
new ModScoreV2(),
};
default:
return Array.Empty<Mod>();
}
@ -302,6 +311,8 @@ namespace osu.Game.Rulesets.Mania
public int LegacyID => 3;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new ManiaLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new ManiaRulesetConfigManager(settings, RulesetInfo);
@ -401,7 +412,7 @@ namespace osu.Game.Rulesets.Mania
RelativeSizeAxes = Axes.X,
Height = 250
}, true),
new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[]
{
new AverageHitError(score.HitEvents),
new UnstableRate(score.HitEvents)
@ -414,6 +425,8 @@ namespace osu.Game.Rulesets.Mania
}
public override RulesetSetupSection CreateEditorSetupSection() => new ManiaSetupSection();
public override DifficultySection CreateEditorDifficultySection() => new ManiaDifficultySection();
}
public enum PlayfieldType

View File

@ -33,5 +33,6 @@ namespace osu.Game.Rulesets.Mania
HitExplosion,
StageBackground,
StageForeground,
BarLine
}
}

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 osu.Framework.Bindables;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
@ -8,7 +9,15 @@ namespace osu.Game.Rulesets.Mania.Objects
{
public class BarLine : ManiaHitObject, IBarLine
{
public bool Major { get; set; }
private HitObjectProperty<bool> major;
public Bindable<bool> MajorBindable => major.Bindable;
public bool Major
{
get => major.Value;
set => major.Value = value;
}
public override Judgement CreateJudgement() => new IgnoreJudgement();
}

View File

@ -1,9 +1,11 @@
// 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.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osuTK;
using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
@ -13,45 +15,41 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
/// </summary>
public partial class DrawableBarLine : DrawableManiaHitObject<BarLine>
{
public readonly Bindable<bool> Major = new Bindable<bool>();
public DrawableBarLine()
: this(null!)
{
}
public DrawableBarLine(BarLine barLine)
: base(barLine)
{
RelativeSizeAxes = Axes.X;
Height = barLine.Major ? 1.7f : 1.2f;
}
AddInternal(new Box
[BackgroundDependencyLoader]
private void load()
{
AddInternal(new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.BarLine), _ => new DefaultBarLine())
{
Name = "Bar line",
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
Alpha = barLine.Major ? 0.5f : 0.2f
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
if (barLine.Major)
{
Vector2 size = new Vector2(22, 6);
const float line_offset = 4;
Major.BindValueChanged(major => Height = major.NewValue ? 1.7f : 1.2f, true);
}
AddInternal(new Circle
{
Name = "Left line",
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreRight,
protected override void OnApply()
{
base.OnApply();
Major.BindTo(HitObject.MajorBindable);
}
Size = size,
X = -line_offset,
});
AddInternal(new Circle
{
Name = "Right line",
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreLeft,
Size = size,
X = line_offset,
});
}
protected override void OnFree()
{
base.OnFree();
Major.UnbindFrom(HitObject.MajorBindable);
}
protected override void UpdateStartTimeStateTransforms() => this.FadeOut(150);

View File

@ -16,6 +16,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
using osuTK;
@ -298,7 +299,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
return false;
// do not run any of this logic when rewinding, as it inverts order of presses/releases.
if (Time.Elapsed < 0)
if ((Clock as IGameplayClock)?.IsRewinding == true)
return false;
if (CheckHittable?.Invoke(this, Time.Current) == false)
@ -337,7 +338,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
return;
// do not run any of this logic when rewinding, as it inverts order of presses/releases.
if (Time.Elapsed < 0)
if ((Clock as IGameplayClock)?.IsRewinding == true)
return;
Tail.UpdateResult();

View File

@ -2,7 +2,12 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Scoring
@ -16,6 +21,9 @@ namespace osu.Game.Rulesets.Mania.Scoring
{
}
protected override IEnumerable<HitObject> EnumerateHitObjects(IBeatmap beatmap)
=> base.EnumerateHitObjects(beatmap).OrderBy(ho => ho, JudgementOrderComparer.DEFAULT);
protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion)
{
return 10000 * comboProgress
@ -25,5 +33,29 @@ namespace osu.Game.Rulesets.Mania.Scoring
protected override double GetComboScoreChange(JudgementResult result)
=> Judgement.ToNumericResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(400, combo_base));
private class JudgementOrderComparer : IComparer<HitObject>
{
public static readonly JudgementOrderComparer DEFAULT = new JudgementOrderComparer();
public int Compare(HitObject? x, HitObject? y)
{
if (ReferenceEquals(x, y)) return 0;
if (ReferenceEquals(x, null)) return -1;
if (ReferenceEquals(y, null)) return 1;
int result = x.GetEndTime().CompareTo(y.GetEndTime());
if (result != 0)
return result;
// due to the way input is handled in mania, notes take precedence over ticks in judging order.
if (x is Note && y is not Note) return -1;
if (x is not Note && y is Note) return 1;
return x is ManiaHitObject maniaX && y is ManiaHitObject maniaY
? maniaX.Column.CompareTo(maniaY.Column)
: 0;
}
}
}
}

View File

@ -0,0 +1,72 @@
// 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.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK;
namespace osu.Game.Rulesets.Mania.Skinning.Default
{
public partial class DefaultBarLine : CompositeDrawable
{
private Bindable<bool> major = null!;
private Drawable mainLine = null!;
private Drawable leftAnchor = null!;
private Drawable rightAnchor = null!;
[BackgroundDependencyLoader]
private void load(DrawableHitObject drawableHitObject)
{
RelativeSizeAxes = Axes.Both;
AddInternal(mainLine = new Box
{
Name = "Bar line",
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
});
Vector2 size = new Vector2(22, 6);
const float line_offset = 4;
AddInternal(leftAnchor = new Circle
{
Name = "Left anchor",
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreRight,
Size = size,
X = -line_offset,
});
AddInternal(rightAnchor = new Circle
{
Name = "Right anchor",
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreLeft,
Size = size,
X = line_offset,
});
major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy();
}
protected override void LoadComplete()
{
base.LoadComplete();
major.BindValueChanged(updateMajor, true);
}
private void updateMajor(ValueChangedEvent<bool> major)
{
mainLine.Alpha = major.NewValue ? 0.5f : 0.2f;
leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? 1 : 0;
}
}
}

View File

@ -119,6 +119,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
case ManiaSkinComponents.StageForeground:
return new LegacyStageForeground();
case ManiaSkinComponents.BarLine:
return null; // Not yet implemented.
default:
throw new UnsupportedSkinComponentException(lookup);
}

View File

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Primitives;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
@ -25,6 +26,22 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly List<Stage> stages = new List<Stage>();
public override Quad SkinnableComponentScreenSpaceDrawQuad
{
get
{
RectangleF totalArea = RectangleF.Empty;
for (int i = 0; i < Stages.Count; ++i)
{
var stageArea = Stages[i].ScreenSpaceDrawQuad.AABBFloat;
totalArea = i == 0 ? stageArea : RectangleF.Union(totalArea, stageArea);
}
return totalArea;
}
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => stages.Any(s => s.ReceivePositionalInputAt(screenSpacePos));
public ManiaPlayfield(List<StageDefinition> stageDefinitions)

View File

@ -136,6 +136,8 @@ namespace osu.Game.Rulesets.Mania.UI
columnFlow.SetContentForColumn(i, column);
AddNested(column);
}
RegisterPool<BarLine, DrawableBarLine>(50, 200);
}
private ISkinSource currentSkin;
@ -186,7 +188,7 @@ namespace osu.Game.Rulesets.Mania.UI
public override bool Remove(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Remove(h);
public void Add(BarLine barLine) => base.Add(new DrawableBarLine(barLine));
public void Add(BarLine barLine) => base.Add(barLine);
internal void OnNewResult(DrawableHitObject judgedObject, JudgementResult result)
{

View File

@ -5,7 +5,7 @@
<key>CFBundleName</key>
<string>osu.Game.Rulesets.Osu.Tests.iOS</string>
<key>CFBundleIdentifier</key>
<string>ppy.osu-Game-Rulesets-Osu-Tests-iOS</string>
<string>sh.ppy.osu-ruleset-tests</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>

View File

@ -25,6 +25,35 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
[Test]
public void TestSelectAfterFadedOut()
{
var slider = new Slider
{
StartTime = 0,
Position = new Vector2(100, 100),
Path = new SliderPath
{
ControlPoints =
{
new PathControlPoint(),
new PathControlPoint(new Vector2(100))
}
}
};
AddStep("add slider", () => EditorBeatmap.Add(slider));
moveMouseToObject(() => slider);
AddStep("seek after end", () => EditorClock.Seek(750));
AddStep("left click", () => InputManager.Click(MouseButton.Left));
AddAssert("slider not selected", () => EditorBeatmap.SelectedHitObjects.Count == 0);
AddStep("seek to visible", () => EditorClock.Seek(650));
AddStep("left click", () => InputManager.Click(MouseButton.Left));
AddUntilStep("slider selected", () => EditorBeatmap.SelectedHitObjects.Single() == slider);
}
[Test]
public void TestContextMenuShownCorrectlyForSelectedSlider()
{

View File

@ -61,6 +61,20 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertControlPointType(0, PathType.Linear);
}
[Test]
public void TestPlaceWithMouseMovementOutsidePlayfield()
{
addMovementStep(new Vector2(200));
addClickStep(MouseButton.Left);
AddStep("move mouse out of screen", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + Vector2.One));
addClickStep(MouseButton.Right);
assertPlaced(true);
assertControlPointCount(2);
assertControlPointType(0, PathType.Linear);
}
[Test]
public void TestPlaceNormalControlPoint()
{

View File

@ -23,6 +23,7 @@ namespace osu.Game.Rulesets.Osu.Tests
[TestCase("uneven-repeat-slider")]
[TestCase("old-stacking")]
[TestCase("multi-segment-slider")]
[TestCase("nan-slider")]
public void Test(string name) => base.Test(name);
protected override IEnumerable<ConvertValue> CreateConvertValue(HitObject hitObject)

View File

@ -17,6 +17,7 @@ namespace osu.Game.Rulesets.Osu.Tests
[TestCase(6.7115569159190587d, 206, "diffcalc-test")]
[TestCase(1.4391311903612753d, 45, "zero-length-sliders")]
[TestCase(0.14102693012101306d, 1, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);

View File

@ -4,6 +4,7 @@
using System;
using NUnit.Framework;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Tests.Beatmaps;
@ -28,7 +29,8 @@ namespace osu.Game.Rulesets.Osu.Tests
new object[] { LegacyMods.SpunOut, new[] { typeof(OsuModSpunOut) } },
new object[] { LegacyMods.Autopilot, new[] { typeof(OsuModAutopilot) } },
new object[] { LegacyMods.Target, new[] { typeof(OsuModTargetPractice) } },
new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(OsuModHardRock), typeof(OsuModDoubleTime) } }
new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(OsuModHardRock), typeof(OsuModDoubleTime) } },
new object[] { LegacyMods.ScoreV2, new[] { typeof(ModScoreV2) } },
};
[TestCaseSource(nameof(osu_mod_mapping))]

View File

@ -0,0 +1,147 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
public partial class TestSceneSpinnerJudgement : RateAdjustedBeatmapTestScene
{
private const double time_spinner_start = 2000;
private const double time_spinner_end = 4000;
private List<JudgementResult> judgementResults = new List<JudgementResult>();
private ScoreAccessibleReplayPlayer currentPlayer = null!;
[Test]
public void TestHitNothing()
{
performTest(new List<ReplayFrame>());
AddAssert("all min judgements", () => judgementResults.All(result => result.Type == result.Judgement.MinResult));
}
[TestCase(1)]
[TestCase(2)]
[TestCase(5)]
public void TestNumberOfSpins(int spins)
{
performTest(generateReplay(spins));
for (int i = 0; i < spins; ++i)
assertResult<SpinnerTick>(i, HitResult.SmallBonus);
assertResult<SpinnerTick>(spins, HitResult.IgnoreMiss);
}
[Test]
public void TestHitEverything()
{
performTest(generateReplay(20));
AddAssert("all max judgements", () => judgementResults.All(result => result.Type == result.Judgement.MaxResult));
}
private static List<ReplayFrame> generateReplay(int spins)
{
var replayFrames = new List<ReplayFrame>();
const int frames_per_spin = 30;
for (int i = 0; i < spins * frames_per_spin; ++i)
{
float totalProgress = i / (float)(spins * frames_per_spin);
float spinProgress = (i % frames_per_spin) / (float)frames_per_spin;
double time = time_spinner_start + (time_spinner_end - time_spinner_start) * totalProgress;
float posX = MathF.Cos(2 * MathF.PI * spinProgress);
float posY = MathF.Sin(2 * MathF.PI * spinProgress);
Vector2 finalPos = OsuPlayfield.BASE_SIZE / 2 + new Vector2(posX, posY) * 50;
replayFrames.Add(new OsuReplayFrame(time, finalPos, OsuAction.LeftButton));
}
return replayFrames;
}
private void performTest(List<ReplayFrame> frames)
{
AddStep("load player", () =>
{
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new Spinner
{
StartTime = time_spinner_start,
EndTime = time_spinner_end,
Position = OsuPlayfield.BASE_SIZE / 2
}
},
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty(),
Ruleset = new OsuRuleset().RulesetInfo
},
});
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
p.OnLoadComplete += _ =>
{
p.ScoreProcessor.NewJudgement += result =>
{
if (currentPlayer == p) judgementResults.Add(result);
};
};
LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>();
});
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
}
private void assertResult<T>(int index, HitResult expectedResult)
{
AddAssert($"{typeof(T).ReadableName()} ({index}) judged as {expectedResult}",
() => judgementResults.Where(j => j.HitObject is T).OrderBy(j => j.HitObject.StartTime).ElementAt(index).Type,
() => Is.EqualTo(expectedResult));
}
private partial class ScoreAccessibleReplayPlayer : ReplayPlayer
{
public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;
protected override bool PauseOnFocusLost => false;
public ScoreAccessibleReplayPlayer(Score score)
: base(score, new PlayerConfiguration
{
AllowPause = false,
ShowResults = false,
})
{
}
}
}
}

View File

@ -214,17 +214,24 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
? currSlider.Position + currSlider.Path.PositionAt(1)
: currHitObject.Position;
// Note the use of `StartTime` in the code below doesn't match stable's use of `EndTime`.
// This is because in the stable implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j)
// and therefore it does not have a correct `EndTime`, but instead the default of `EndTime = StartTime`.
//
// Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
// if we use `EndTime` here it would result in unexpected stacking.
if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, currHitObject.Position) < stack_distance)
{
currHitObject.StackHeight++;
startTime = beatmap.HitObjects[j].GetEndTime();
startTime = beatmap.HitObjects[j].StartTime;
}
else if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, position2) < stack_distance)
{
// Case for sliders - bump notes down and right, rather than up and left.
sliderStack++;
beatmap.HitObjects[j].StackHeight -= sliderStack;
startTime = beatmap.HitObjects[j].GetEndTime();
startTime = beatmap.HitObjects[j].StartTime;
}
}
}

View File

@ -93,7 +93,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_SPEED, SpeedDifficulty);
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
if (ShouldSerializeFlashlightRating())
@ -111,7 +110,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficulty = values[ATTRIB_ID_SPEED];
OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];

View File

@ -26,9 +26,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@ -71,7 +74,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1
);
double starRating = basePerformance > 0.00001 ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0;
double starRating = basePerformance > 0.00001
? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4)
: 0;
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double drainRate = beatmap.Difficulty.DrainRate;
@ -86,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
return new OsuDifficultyAttributes
OsuDifficultyAttributes attributes = new OsuDifficultyAttributes
{
StarRating = starRating,
Mods = mods,
@ -103,6 +108,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SliderCount = sliderCount,
SpinnerCount = spinnerCount,
};
if (ComputeLegacyScoringValues)
{
OsuLegacyScoreSimulator sv1Simulator = new OsuLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)

View File

@ -0,0 +1,177 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty
{
internal class OsuLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int combo;
private double scoreMultiplier;
private IBeatmap playableBeatmap = null!;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{
this.playableBeatmap = playableBeatmap;
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
int countNormal = 0;
int countSlider = 0;
int countSpinner = 0;
foreach (HitObject obj in workingBeatmap.Beatmap.HitObjects)
{
switch (obj)
{
case IHasPath:
countSlider++;
break;
case IHasDuration:
countSpinner++;
break;
default:
countNormal++;
break;
}
}
int objectCount = countNormal + countSlider + countSpinner;
int drainLength = 0;
if (baseBeatmap.HitObjects.Count > 0)
{
int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000;
}
int difficultyPeppyStars = (int)Math.Round(
(baseBeatmap.Difficulty.DrainRate
+ baseBeatmap.Difficulty.OverallDifficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
}
private void simulateHit(HitObject hitObject)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
bool isBonus = false;
HitResult bonusResult = HitResult.None;
int scoreIncrease = 0;
switch (hitObject)
{
case SliderHeadCircle:
case SliderTailCircle:
case SliderRepeat:
scoreIncrease = 30;
break;
case SliderTick:
scoreIncrease = 10;
break;
case SpinnerBonusTick:
scoreIncrease = 1100;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.LargeBonus;
break;
case SpinnerTick:
scoreIncrease = 100;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.SmallBonus;
break;
case HitCircle:
scoreIncrease = 300;
addScoreComboMultiplier = true;
break;
case Slider:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
scoreIncrease = 300;
increaseCombo = false;
addScoreComboMultiplier = true;
break;
case Spinner spinner:
// The spinner object applies a lenience because gameplay mechanics differ from osu-stable.
// We'll redo the calculations to match osu-stable here...
const double maximum_rotations_per_second = 477.0 / 60;
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
double secondsDuration = spinner.Duration / 1000;
// The total amount of half spins possible for the entire spinner.
int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2);
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
// To be able to receive bonus points, the spinner must be rotated another 1.5 times.
int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3;
for (int i = 0; i <= totalHalfSpinsPossible; i++)
{
if (i > halfSpinsRequiredBeforeBonus && (i - halfSpinsRequiredBeforeBonus) % 2 == 0)
simulateHit(new SpinnerBonusTick());
else if (i > 1 && i % 2 == 0)
simulateHit(new SpinnerTick());
}
scoreIncrease = 300;
addScoreComboMultiplier = true;
break;
}
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
}
}

View File

@ -22,7 +22,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
protected override bool AlwaysShowWhenSelected => true;
protected override bool ShouldBeAlive => base.ShouldBeAlive
|| (DrawableObject is not DrawableSpinner && ShowHitMarkers.Value && editorClock.CurrentTime >= Item.StartTime && editorClock.CurrentTime - Item.GetEndTime() < HitCircleOverlapMarker.FADE_OUT_EXTENSION);
|| (DrawableObject is not DrawableSpinner && ShowHitMarkers.Value && editorClock.CurrentTime >= Item.StartTime
&& editorClock.CurrentTime - Item.GetEndTime() < HitCircleOverlapMarker.FADE_OUT_EXTENSION);
public override bool IsSelectable =>
// Bypass fade out extension from hit markers for selection purposes.
// This is to match stable, where even when the afterimage hit markers are still visible, objects are not selectable.
base.ShouldBeAlive;
protected OsuSelectionBlueprint(T hitObject)
: base(hitObject)

View File

@ -62,12 +62,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private void load()
{
// Give a bit of breathing room around the playfield content.
PlayfieldContentContainer.Padding = new MarginPadding
{
Vertical = 10,
Left = TOOLBOX_CONTRACTED_SIZE_LEFT + 10,
Right = TOOLBOX_CONTRACTED_SIZE_RIGHT + 10,
};
PlayfieldContentContainer.Padding = new MarginPadding(10);
LayerBelowRuleset.AddRange(new Drawable[]
{

View File

@ -17,6 +17,7 @@ using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Utils;
using osuTK;
using osuTK.Input;
@ -27,11 +28,6 @@ namespace osu.Game.Rulesets.Osu.Edit
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider? snapProvider { get; set; }
/// <summary>
/// During a transform, the initial origin is stored so it can be used throughout the operation.
/// </summary>
private Vector2? referenceOrigin;
/// <summary>
/// During a transform, the initial path types of a single selected slider are stored so they
/// can be maintained throughout the operation.
@ -42,9 +38,8 @@ namespace osu.Game.Rulesets.Osu.Edit
{
base.OnSelectionChanged();
Quad quad = selectedMovableObjects.Length > 0 ? getSurroundingQuad(selectedMovableObjects) : new Quad();
Quad quad = selectedMovableObjects.Length > 0 ? GeometryUtils.GetSurroundingQuad(selectedMovableObjects) : new Quad();
SelectionBox.CanRotate = quad.Width > 0 || quad.Height > 0;
SelectionBox.CanFlipX = SelectionBox.CanScaleX = quad.Width > 0;
SelectionBox.CanFlipY = SelectionBox.CanScaleY = quad.Height > 0;
SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider);
@ -53,7 +48,6 @@ namespace osu.Game.Rulesets.Osu.Edit
protected override void OnOperationEnded()
{
base.OnOperationEnded();
referenceOrigin = null;
referencePathTypes = null;
}
@ -109,13 +103,13 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var hitObjects = selectedMovableObjects;
var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : getSurroundingQuad(hitObjects);
var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : GeometryUtils.GetSurroundingQuad(hitObjects);
bool didFlip = false;
foreach (var h in hitObjects)
{
var flippedPosition = GetFlippedPosition(direction, flipQuad, h.Position);
var flippedPosition = GeometryUtils.GetFlippedPosition(direction, flipQuad, h.Position);
if (!Precision.AlmostEquals(flippedPosition, h.Position))
{
@ -169,34 +163,13 @@ namespace osu.Game.Rulesets.Osu.Edit
if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y;
}
public override bool HandleRotation(float delta)
{
var hitObjects = selectedMovableObjects;
Quad quad = getSurroundingQuad(hitObjects);
referenceOrigin ??= quad.Centre;
foreach (var h in hitObjects)
{
h.Position = RotatePointAroundOrigin(h.Position, referenceOrigin.Value, delta);
if (h is IHasPath path)
{
foreach (PathControlPoint cp in path.Path.ControlPoints)
cp.Position = RotatePointAroundOrigin(cp.Position, Vector2.Zero, delta);
}
}
// this isn't always the case but let's be lenient for now.
return true;
}
public override SelectionRotationHandler CreateRotationHandler() => new OsuSelectionRotationHandler();
private void scaleSlider(Slider slider, Vector2 scale)
{
referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type).ToList();
Quad sliderQuad = GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position));
Quad sliderQuad = GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position));
// Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0.
scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size;
@ -222,7 +195,7 @@ namespace osu.Game.Rulesets.Osu.Edit
slider.SnapTo(snapProvider);
//if sliderhead or sliderend end up outside playfield, revert scaling.
Quad scaledQuad = getSurroundingQuad(new OsuHitObject[] { slider });
Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider });
(bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad);
if (xInBounds && yInBounds && slider.Path.HasValidLength)
@ -238,10 +211,10 @@ namespace osu.Game.Rulesets.Osu.Edit
private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale)
{
scale = getClampedScale(hitObjects, reference, scale);
Quad selectionQuad = getSurroundingQuad(hitObjects);
Quad selectionQuad = GeometryUtils.GetSurroundingQuad(hitObjects);
foreach (var h in hitObjects)
h.Position = GetScaledPosition(reference, scale, selectionQuad, h.Position);
h.Position = GeometryUtils.GetScaledPosition(reference, scale, selectionQuad, h.Position);
}
private (bool X, bool Y) isQuadInBounds(Quad quad)
@ -256,7 +229,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var hitObjects = selectedMovableObjects;
Quad quad = getSurroundingQuad(hitObjects);
Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects);
Vector2 delta = Vector2.Zero;
@ -286,7 +259,7 @@ namespace osu.Game.Rulesets.Osu.Edit
float xOffset = ((reference & Anchor.x0) > 0) ? -scale.X : 0;
float yOffset = ((reference & Anchor.y0) > 0) ? -scale.Y : 0;
Quad selectionQuad = getSurroundingQuad(hitObjects);
Quad selectionQuad = GeometryUtils.GetSurroundingQuad(hitObjects);
//todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead.
Quad scaledQuad = new Quad(selectionQuad.TopLeft.X + xOffset, selectionQuad.TopLeft.Y + yOffset, selectionQuad.Width + scale.X, selectionQuad.Height + scale.Y);
@ -311,26 +284,6 @@ namespace osu.Game.Rulesets.Osu.Edit
return scale;
}
/// <summary>
/// Returns a gamefield-space quad surrounding the provided hit objects.
/// </summary>
/// <param name="hitObjects">The hit objects to calculate a quad for.</param>
private Quad getSurroundingQuad(OsuHitObject[] hitObjects) =>
GetSurroundingQuad(hitObjects.SelectMany(h =>
{
if (h is IHasPath path)
{
return new[]
{
h.Position,
// can't use EndPosition for reverse slider cases.
h.Position + path.Path.PositionAt(1)
};
}
return new[] { h.Position };
}));
/// <summary>
/// All osu! hitobjects which can be moved/rotated/scaled.
/// </summary>

View File

@ -0,0 +1,107 @@
// 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;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuSelectionRotationHandler : SelectionRotationHandler
{
[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }
private BindableList<HitObject> selectedItems { get; } = new BindableList<HitObject>();
[BackgroundDependencyLoader]
private void load(EditorBeatmap editorBeatmap)
{
selectedItems.BindTo(editorBeatmap.SelectedHitObjects);
}
protected override void LoadComplete()
{
base.LoadComplete();
selectedItems.CollectionChanged += (_, __) => updateState();
updateState();
}
private void updateState()
{
var quad = GeometryUtils.GetSurroundingQuad(selectedMovableObjects);
CanRotate.Value = quad.Width > 0 || quad.Height > 0;
}
private OsuHitObject[]? objectsInRotation;
private Vector2? defaultOrigin;
private Dictionary<OsuHitObject, Vector2>? originalPositions;
private Dictionary<IHasPath, Vector2[]>? originalPathControlPointPositions;
public override void Begin()
{
if (objectsInRotation != null)
throw new InvalidOperationException($"Cannot {nameof(Begin)} a rotate operation while another is in progress!");
changeHandler?.BeginChange();
objectsInRotation = selectedMovableObjects.ToArray();
defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation).Centre;
originalPositions = objectsInRotation.ToDictionary(obj => obj, obj => obj.Position);
originalPathControlPointPositions = objectsInRotation.OfType<IHasPath>().ToDictionary(
obj => obj,
obj => obj.Path.ControlPoints.Select(point => point.Position).ToArray());
}
public override void Update(float rotation, Vector2? origin = null)
{
if (objectsInRotation == null)
throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!");
Debug.Assert(originalPositions != null && originalPathControlPointPositions != null && defaultOrigin != null);
Vector2 actualOrigin = origin ?? defaultOrigin.Value;
foreach (var ho in objectsInRotation)
{
ho.Position = GeometryUtils.RotatePointAroundOrigin(originalPositions[ho], actualOrigin, rotation);
if (ho is IHasPath withPath)
{
var originalPath = originalPathControlPointPositions[withPath];
for (int i = 0; i < withPath.Path.ControlPoints.Count; ++i)
withPath.Path.ControlPoints[i].Position = GeometryUtils.RotatePointAroundOrigin(originalPath[i], Vector2.Zero, rotation);
}
}
}
public override void Commit()
{
if (objectsInRotation == null)
throw new InvalidOperationException($"Cannot {nameof(Commit)} a rotate operation without calling {nameof(Begin)} first!");
changeHandler?.EndChange();
objectsInRotation = null;
originalPositions = null;
originalPathControlPointPositions = null;
defaultOrigin = null;
}
private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()
.Where(h => h is not Spinner);
}
}

View File

@ -14,6 +14,7 @@ using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
using osuTK;
@ -179,16 +180,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
private Vector2? lastPosition;
private bool rewinding;
public void UpdateProgress(double completionProgress)
{
Position = drawableSlider.HitObject.CurvePositionAt(completionProgress);
var diff = lastPosition.HasValue ? lastPosition.Value - Position : Position - drawableSlider.HitObject.CurvePositionAt(completionProgress + 0.01f);
if (Clock.ElapsedFrameTime != 0)
rewinding = Clock.ElapsedFrameTime < 0;
bool rewinding = (Clock as IGameplayClock)?.IsRewinding == true;
// Ensure the value is substantially high enough to allow for Atan2 to get a valid angle.
if (diff.LengthFast < 0.01f)

View File

@ -113,6 +113,9 @@ namespace osu.Game.Rulesets.Osu
if (mods.HasFlagFast(LegacyMods.TouchDevice))
yield return new OsuModTouchDevice();
if (mods.HasFlagFast(LegacyMods.ScoreV2))
yield return new ModScoreV2();
}
public override LegacyMods ConvertToLegacyMods(Mod[] mods)
@ -212,6 +215,7 @@ namespace osu.Game.Rulesets.Osu
return new Mod[]
{
new OsuModTouchDevice(),
new ModScoreV2(),
};
default:
@ -253,6 +257,8 @@ namespace osu.Game.Rulesets.Osu
public int LegacyID => 0;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new OsuLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new OsuRulesetConfigManager(settings, RulesetInfo);
@ -313,7 +319,7 @@ namespace osu.Game.Rulesets.Osu
RelativeSizeAxes = Axes.X,
Height = 250
}, true),
new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[]
{
new AverageHitError(timedHitEvents),
new UnstableRate(timedHitEvents)

View File

@ -0,0 +1 @@
{"Mappings":[{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336}]}]}

View File

@ -0,0 +1,18 @@
osu file format v14
[Difficulty]
HPDrainRate:5.8
CircleSize:4
OverallDifficulty:9.6
ApproachRate:10
SliderMultiplier:2
SliderTickRate:1
[TimingPoints]
77211,-100,4,3,50,70,0,0
77497,8.40402703648439,4,3,51,70,1,8
77497,NaN,4,3,51,70,0,8
77498,285.714285714286,4,3,51,70,1,0
[HitObjects]
298,290,77497,6,0,B|234:298|192:279|192:279|180:299|180:299|205:311|238:318|238:318|230:347|217:371|217:371|137:370|80:340|80:340|65:259|73:143|102:68|102:68|149:49|199:34|199:34|213:54|213:54|267:38|324:40|324:40|332:18|332:18|385:20|435:27|435:27|480:93|517:204|521:286|521:286|474:329|396:350|396:350|377:329|363:302|363:302|393:287|415:271|415:271|398:254|398:254|362:282|299:290,1,1723.66345596313,10|0,1:0|3:0,3:0:0:0:

View File

@ -72,9 +72,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
lastAngle = thisAngle;
IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation / 2 - Rotation) > 5f;
IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f;
Rotation = (float)Interpolation.Damp(Rotation, currentRotation / 2, 0.99, Math.Abs(Time.Elapsed));
Rotation = (float)Interpolation.Damp(Rotation, currentRotation, 0.99, Math.Abs(Time.Elapsed));
}
/// <summary>

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Scoring;
@ -120,18 +121,22 @@ namespace osu.Game.Rulesets.Osu.Statistics
new OsuSpriteText
{
Text = "Overshoot",
Font = OsuFont.GetFont(size: 12),
Anchor = Anchor.Centre,
Origin = Anchor.BottomCentre,
Padding = new MarginPadding(3),
Origin = Anchor.BottomLeft,
Padding = new MarginPadding(2),
Rotation = -rotation,
RelativePositionAxes = Axes.Both,
Y = -(inner_portion + line_extension) / 2,
},
new OsuSpriteText
{
Text = "Undershoot",
Font = OsuFont.GetFont(size: 12),
Anchor = Anchor.Centre,
Origin = Anchor.TopCentre,
Padding = new MarginPadding(3),
Origin = Anchor.TopRight,
Rotation = -rotation,
Padding = new MarginPadding(2),
RelativePositionAxes = Axes.Both,
Y = (inner_portion + line_extension) / 2,
},

View File

@ -5,7 +5,7 @@
<key>CFBundleName</key>
<string>osu.Game.Rulesets.Taiko.Tests.iOS</string>
<key>CFBundleIdentifier</key>
<string>ppy.osu-Game-Rulesets-Taiko-Tests-iOS</string>
<string>sh.ppy.taiko-ruleset-tests</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>

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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
@ -10,6 +11,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects;
@ -36,11 +38,12 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
() => Is.EqualTo(expectedResult));
}
protected void PerformTest(List<ReplayFrame> frames, Beatmap<TaikoHitObject>? beatmap = null)
protected void PerformTest(List<ReplayFrame> frames, Beatmap<TaikoHitObject>? beatmap = null, Mod[]? mods = null)
{
AddStep("load player", () =>
{
Beatmap.Value = CreateWorkingBeatmap(beatmap);
SelectedMods.Value = mods ?? Array.Empty<Mod>();
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });

View File

@ -75,6 +75,25 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AssertResult<DrumRoll>(0, HitResult.IgnoreHit);
}
[Test]
public void TestHitNoneStrongDrumRoll()
{
PerformTest(new List<ReplayFrame>
{
new TaikoReplayFrame(0),
}, CreateBeatmap(createDrumRoll(true)));
AssertJudgementCount(12);
for (int i = 0; i < 5; ++i)
{
AssertResult<DrumRollTick>(i, HitResult.IgnoreMiss);
AssertResult<DrumRollTick.StrongNestedHit>(i, HitResult.IgnoreMiss);
}
AssertResult<DrumRoll>(0, HitResult.IgnoreHit);
}
[Test]
public void TestHitAllStrongDrumRollWithOneKey()
{

View File

@ -4,10 +4,14 @@
using System.Collections.Generic;
using NUnit.Framework;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Rulesets.Taiko.Scoring;
namespace osu.Game.Rulesets.Taiko.Tests.Judgements
{
@ -32,6 +36,28 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AssertResult<Hit>(0, HitResult.Great);
}
[Test]
public void TestHitWithBothKeysOnSameFrameDoesNotFallThroughToNextObject()
{
PerformTest(new List<ReplayFrame>
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(1000, TaikoAction.LeftCentre, TaikoAction.RightCentre),
}, CreateBeatmap(new Hit
{
Type = HitType.Centre,
StartTime = 1000,
}, new Hit
{
Type = HitType.Centre,
StartTime = 1020
}));
AssertJudgementCount(2);
AssertResult<Hit>(0, HitResult.Great);
AssertResult<Hit>(1, HitResult.Miss);
}
[Test]
public void TestHitRimHit()
{
@ -157,5 +183,58 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AssertJudgementCount(1);
AssertResult<Hit>(0, HitResult.Ok);
}
[Test]
public void TestStrongHitOneKeyWithHidden()
{
const double hit_time = 1000;
var beatmap = CreateBeatmap(new Hit
{
Type = HitType.Centre,
StartTime = hit_time,
IsStrong = true
});
var hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
PerformTest(new List<ReplayFrame>
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(hit_time + hitWindows.WindowFor(HitResult.Ok) - 1, TaikoAction.LeftCentre),
}, beatmap, new Mod[] { new TaikoModHidden() });
AssertJudgementCount(2);
AssertResult<Hit>(0, HitResult.Ok);
AssertResult<Hit.StrongNestedHit>(0, HitResult.IgnoreMiss);
}
[Test]
public void TestStrongHitTwoKeysWithHidden()
{
const double hit_time = 1000;
var beatmap = CreateBeatmap(new Hit
{
Type = HitType.Centre,
StartTime = hit_time,
IsStrong = true
});
var hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
PerformTest(new List<ReplayFrame>
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(hit_time + hitWindows.WindowFor(HitResult.Ok) - 1, TaikoAction.LeftCentre),
new TaikoReplayFrame(hit_time + hitWindows.WindowFor(HitResult.Ok) + DrawableHit.StrongNestedHit.SECOND_HIT_WINDOW - 2, TaikoAction.LeftCentre, TaikoAction.RightCentre),
}, beatmap, new Mod[] { new TaikoModHidden() });
AssertJudgementCount(2);
AssertResult<Hit>(0, HitResult.Ok);
AssertResult<Hit.StrongNestedHit>(0, HitResult.LargeBonus);
}
}
}

View File

@ -114,5 +114,75 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AddAssert("all tick offsets are 0", () => JudgementResults.Where(r => r.HitObject is SwellTick).All(r => r.TimeOffset == 0));
}
/// <summary>
/// Ensure input is correctly sent to subsequent hits if a swell is fully completed.
/// </summary>
[Test]
public void TestHitSwellThenHitHit()
{
const double swell_time = 1000;
const double hit_time = 1150;
Swell swell = new Swell
{
StartTime = swell_time,
Duration = 100,
RequiredHits = 1
};
Hit hit = new Hit
{
StartTime = hit_time
};
List<ReplayFrame> frames = new List<ReplayFrame>
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(swell_time, TaikoAction.LeftRim),
new TaikoReplayFrame(hit_time, TaikoAction.RightCentre),
};
PerformTest(frames, CreateBeatmap(swell, hit));
AssertJudgementCount(3);
AssertResult<SwellTick>(0, HitResult.IgnoreHit);
AssertResult<Swell>(0, HitResult.LargeBonus);
AssertResult<Hit>(0, HitResult.Great);
}
[Test]
public void TestMissSwellThenHitHit()
{
const double swell_time = 1000;
const double hit_time = 1150;
Swell swell = new Swell
{
StartTime = swell_time,
Duration = 100,
RequiredHits = 1
};
Hit hit = new Hit
{
StartTime = hit_time
};
List<ReplayFrame> frames = new List<ReplayFrame>
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(hit_time, TaikoAction.RightCentre),
};
PerformTest(frames, CreateBeatmap(swell, hit));
AssertJudgementCount(3);
AssertResult<SwellTick>(0, HitResult.IgnoreMiss);
AssertResult<Swell>(0, HitResult.IgnoreMiss);
AssertResult<Hit>(0, HitResult.Great);
}
}
}

View File

@ -1,24 +1,71 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Tests.Mods
{
public partial class TestSceneTaikoModHidden : TaikoModTestScene
{
private Func<bool> checkAllMaxResultJudgements(int count) => ()
=> Player.ScoreProcessor.JudgedHits >= count
&& Player.Results.All(result => result.Type == result.Judgement.MaxResult);
[Test]
public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData
{
Mod = new TaikoModHidden(),
Autoplay = true,
PassCondition = checkSomeAutoplayHits
PassCondition = checkAllMaxResultJudgements(4),
});
private bool checkSomeAutoplayHits()
=> Player.ScoreProcessor.JudgedHits >= 4
&& Player.Results.All(result => result.Type == result.Judgement.MaxResult);
[Test]
public void TestHitTwoNotesWithinShortPeriod()
{
const double hit_time = 1;
var beatmap = new Beatmap<TaikoHitObject>
{
HitObjects = new List<TaikoHitObject>
{
new Hit
{
Type = HitType.Rim,
StartTime = hit_time,
},
new Hit
{
Type = HitType.Centre,
StartTime = hit_time * 2,
},
},
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty
{
SliderTickRate = 4,
OverallDifficulty = 0,
},
Ruleset = new TaikoRuleset().RulesetInfo
},
};
beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f });
CreateModTest(new ModTestData
{
Mod = new TaikoModHidden(),
Autoplay = true,
PassCondition = checkAllMaxResultJudgements(2),
Beatmap = beatmap,
});
}
}
}

View File

@ -4,6 +4,7 @@
using System;
using NUnit.Framework;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Tests.Beatmaps;
@ -25,7 +26,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
new object[] { LegacyMods.Flashlight, new[] { typeof(TaikoModFlashlight) } },
new object[] { LegacyMods.Autoplay, new[] { typeof(TaikoModAutoplay) } },
new object[] { LegacyMods.Random, new[] { typeof(TaikoModRandom) } },
new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(TaikoModHardRock), typeof(TaikoModDoubleTime) } }
new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(TaikoModHardRock), typeof(TaikoModDoubleTime) } },
new object[] { LegacyMods.ScoreV2, new[] { typeof(ModScoreV2) } },
};
[TestCaseSource(nameof(taiko_mod_mapping))]

View File

@ -72,13 +72,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
}
[Test]
@ -100,13 +100,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
}
[Test]
@ -145,23 +145,23 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
seekTo(120);
AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
seekTo(480);
AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(700);
AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
}
[Test]
@ -174,8 +174,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
StartTime = 100,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong
}
};
hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@ -184,13 +184,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit.StrongNestedHit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
}
[Test]
@ -213,18 +213,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
}
[Test]
@ -247,18 +247,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
}
[Test]
@ -272,8 +272,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
EndTime = 1100,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong
}
};
drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@ -282,18 +282,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
}
[Test]
@ -319,18 +319,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
// This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits.
// But for sample playback purposes they can be ignored as noise.
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(600);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(1200);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
}
[Test]
@ -344,7 +344,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
EndTime = 1100,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum")
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM)
}
};
swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@ -356,25 +356,26 @@ namespace osu.Game.Rulesets.Taiko.Tests
// This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits.
// But for sample playback purposes they can be ignored as noise.
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM);
seekTo(600);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM);
seekTo(1200);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM);
}
private void checkSound(HitType hitType, string expectedName, string expectedBank)
private void checkSamples(HitType hitType, bool strong, string expectedSamplesCsv, string expectedBank)
{
AddStep($"hit {hitType}", () => triggerSource.Play(hitType));
AddAssert($"last played sample is {expectedName}", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Single().Name, () => Is.EqualTo(expectedName));
AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Single().Bank, () => Is.EqualTo(expectedBank));
AddStep($"hit {hitType}", () => triggerSource.Play(hitType, strong));
AddAssert($"last played sample is {expectedSamplesCsv}", () => string.Join(',', triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Select(s => s.Name)),
() => Is.EqualTo(expectedSamplesCsv));
AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().First().Bank, () => Is.EqualTo(expectedBank));
}
private void seekTo(double time) => AddStep($"seek to {time}", () => gameplayClock.Seek(time));

View File

@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
private void addFlyingHit(HitType hitType)
{
var tick = new DrumRollTick { HitWindows = HitWindows.Empty, StartTime = DrawableRuleset.Playfield.Time.Current };
var tick = new DrumRollTick(new DrumRoll()) { HitWindows = HitWindows.Empty, StartTime = DrawableRuleset.Playfield.Time.Current };
DrawableDrumRollTick h;
DrawableRuleset.Playfield.Add(h = new DrawableDrumRollTick(tick) { JudgementType = hitType });

View File

@ -3,15 +3,16 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.Taiko.UI;
namespace osu.Game.Rulesets.Taiko.Tests
{
/// <summary>
/// Taiko has some interesting rules for legacy mappings.
/// Taiko doesn't output any samples. They are all handled externally by <see cref="DrumSamplePlayer"/>.
/// </summary>
[HeadlessTest]
public partial class TestSceneSampleOutput : TestSceneTaikoPlayer
@ -26,10 +27,10 @@ namespace osu.Game.Rulesets.Taiko.Tests
string.Empty,
string.Empty,
string.Empty,
HitSampleInfo.HIT_FINISH,
HitSampleInfo.HIT_WHISTLE,
HitSampleInfo.HIT_WHISTLE,
HitSampleInfo.HIT_WHISTLE,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
};
var actualSampleNames = new List<string>();
@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
AddUntilStep("all samples collected", () => actualSampleNames.Count == expectedSampleNames.Length);
AddAssert("samples are correct", () => actualSampleNames.SequenceEqual(expectedSampleNames));
AddAssert("samples are correct", () => actualSampleNames, () => Is.EqualTo(expectedSampleNames));
}
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TaikoBeatmapConversionTest().GetBeatmap("sample-to-type-conversions");

View File

@ -48,7 +48,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
foreach (var v in base.ToDatabaseAttributes())
yield return v;
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
}
@ -57,7 +56,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
base.FromDatabaseAttributes(values, onlineInfo);
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
}

View File

@ -25,9 +25,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
@ -84,7 +87,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
HitWindows hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
return new TaikoDifficultyAttributes
TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes
{
StarRating = starRating,
Mods = mods,
@ -95,6 +98,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
};
if (ComputeLegacyScoringValues)
{
TaikoLegacyScoreSimulator sv1Simulator = new TaikoLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
/// <summary>

View File

@ -0,0 +1,202 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
internal class TaikoLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int combo;
private double modMultiplier;
private int difficultyPeppyStars;
private IBeatmap playableBeatmap = null!;
private IReadOnlyList<Mod> mods = null!;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{
this.playableBeatmap = playableBeatmap;
this.mods = mods;
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
int countNormal = 0;
int countSlider = 0;
int countSpinner = 0;
foreach (HitObject obj in baseBeatmap.HitObjects)
{
switch (obj)
{
case IHasPath:
countSlider++;
break;
case IHasDuration:
countSpinner++;
break;
default:
countNormal++;
break;
}
}
int objectCount = countNormal + countSlider + countSpinner;
int drainLength = 0;
if (baseBeatmap.HitObjects.Count > 0)
{
int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000;
}
difficultyPeppyStars = (int)Math.Round(
(baseBeatmap.Difficulty.DrainRate
+ baseBeatmap.Difficulty.OverallDifficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
modMultiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
}
private void simulateHit(HitObject hitObject)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
bool isBonus = false;
HitResult bonusResult = HitResult.None;
int scoreIncrease = 0;
switch (hitObject)
{
case SwellTick:
scoreIncrease = 300;
increaseCombo = false;
break;
case DrumRollTick:
scoreIncrease = 300;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.SmallBonus;
break;
case Swell swell:
// The taiko swell generally does not match the osu-stable implementation in any way.
// We'll redo the calculations to match osu-stable here...
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
double secondsDuration = swell.Duration / 1000;
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
halfSpinsRequiredForCompletion = (int)Math.Max(1, halfSpinsRequiredForCompletion * 1.65f);
if (mods.Any(m => m is ModDoubleTime))
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 0.75f));
if (mods.Any(m => m is ModHalfTime))
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f));
for (int i = 0; i <= halfSpinsRequiredForCompletion; i++)
simulateHit(new SwellTick());
scoreIncrease = 300;
addScoreComboMultiplier = true;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.LargeBonus;
break;
case Hit:
scoreIncrease = 300;
addScoreComboMultiplier = true;
break;
case DrumRoll:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
return;
}
if (hitObject is DrumRollTick tick)
{
if (playableBeatmap.ControlPointInfo.EffectPointAt(tick.Parent.StartTime).KiaiMode)
scoreIncrease = (int)(scoreIncrease * 1.2f);
if (tick.IsStrong)
scoreIncrease += scoreIncrease / 5;
}
// The score increase directly contributed to by the combo-multiplied portion.
int comboScoreIncrease = 0;
if (addScoreComboMultiplier)
{
int oldScoreIncrease = scoreIncrease;
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
scoreIncrease += (int)(scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * modMultiplier) * (Math.Min(100, combo) / 10);
if (hitObject is Swell)
{
if (playableBeatmap.ControlPointInfo.EffectPointAt(hitObject.GetEndTime()).KiaiMode)
scoreIncrease = (int)(scoreIncrease * 1.2f);
}
else
{
if (playableBeatmap.ControlPointInfo.EffectPointAt(hitObject.StartTime).KiaiMode)
scoreIncrease = (int)(scoreIncrease * 1.2f);
}
comboScoreIncrease = scoreIncrease - oldScoreIncrease;
}
if (hitObject is Swell || (hitObject is TaikoStrongableHitObject strongable && strongable.IsStrong))
{
scoreIncrease *= 2;
comboScoreIncrease *= 2;
}
scoreIncrease -= comboScoreIncrease;
if (addScoreComboMultiplier)
ComboScore += comboScoreIncrease;
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
}
}

View File

@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
// TODO: The detection of rulesets is temporary until the leftover old skills have been reworked.
bool isConvert = score.BeatmapInfo.Ruleset.OnlineID != 1;
bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1;
double multiplier = 1.13;

View File

@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
public partial class TaikoHitObjectComposer : HitObjectComposer<TaikoHitObject>
{
protected override bool ApplyHorizontalCentering => false;
public TaikoHitObjectComposer(TaikoRuleset ruleset)
: base(ruleset)
{

View File

@ -62,6 +62,8 @@ namespace osu.Game.Rulesets.Taiko.Mods
hitObject.LifetimeEnd = state == ArmedState.Idle || !hitObject.AllJudged
? hitObject.HitObject.GetEndTime() + hitObject.HitObject.HitWindows.WindowFor(HitResult.Miss)
: hitObject.HitStateUpdateTime;
// extend the lifetime end of the object in order to allow its nested strong hit (if any) to be judged.
hitObject.LifetimeEnd += DrawableHit.StrongNestedHit.SECOND_HIT_WINDOW;
}
break;

View File

@ -195,14 +195,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
ApplyResult(r => r.Type = ParentHitObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult);
}
public override void OnKilled()
{
base.OnKilled();
if (Time.Current > ParentHitObject.HitObject.GetEndTime() && !Judged)
ApplyResult(r => r.Type = r.Judgement.MinResult);
}
public override bool OnPressed(KeyBindingPressEvent<TaikoAction> e) => false;
}
}

View File

@ -108,14 +108,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
ApplyResult(r => r.Type = ParentHitObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult);
}
public override void OnKilled()
{
base.OnKilled();
if (Time.Current > ParentHitObject.HitObject.GetEndTime() && !Judged)
ApplyResult(r => r.Type = r.Judgement.MinResult);
}
public override bool OnPressed(KeyBindingPressEvent<TaikoAction> e) => false;
}
}

View File

@ -4,14 +4,12 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Skinning.Default;
@ -37,7 +35,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
private bool validActionPressed;
private bool pressHandledThisFrame;
private double? lastPressHandleTime;
private readonly Bindable<HitType> type = new Bindable<HitType>();
@ -78,7 +76,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
HitActions = null;
HitAction = null;
validActionPressed = pressHandledThisFrame = false;
validActionPressed = false;
lastPressHandleTime = null;
}
private void updateActionsFromType()
@ -93,40 +92,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
? new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.CentreHit), _ => new CentreHitCirclePiece(), confineMode: ConfineMode.ScaleToFit)
: new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.RimHit), _ => new RimHitCirclePiece(), confineMode: ConfineMode.ScaleToFit);
public override IEnumerable<HitSampleInfo> GetSamples()
{
// normal and claps are always handled by the drum (see DrumSampleMapping).
// in addition, whistles are excluded as they are an alternative rim marker.
var samples = HitObject.Samples.Where(s =>
s.Name != HitSampleInfo.HIT_NORMAL
&& s.Name != HitSampleInfo.HIT_CLAP
&& s.Name != HitSampleInfo.HIT_WHISTLE);
if (HitObject.Type == HitType.Rim && HitObject.IsStrong)
{
// strong + rim always maps to whistle.
// TODO: this should really be in the legacy decoder, but can't be because legacy encoding parity would be broken.
// when we add a taiko editor, this is probably not going to play nice.
var corrected = samples.ToList();
for (int i = 0; i < corrected.Count; i++)
{
var s = corrected[i];
if (s.Name != HitSampleInfo.HIT_FINISH)
continue;
corrected[i] = s.With(HitSampleInfo.HIT_WHISTLE);
}
return corrected;
}
return samples;
}
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
Debug.Assert(HitObject.HitWindows != null);
@ -150,7 +115,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public override bool OnPressed(KeyBindingPressEvent<TaikoAction> e)
{
if (pressHandledThisFrame)
if (lastPressHandleTime == Time.Current)
return true;
if (Judged)
return false;
@ -164,7 +129,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
// Regardless of whether we've hit or not, any secondary key presses in the same frame should be discarded
// E.g. hitting a non-strong centre as a strong should not fall through and perform a hit on the next note
pressHandledThisFrame = true;
lastPressHandleTime = Time.Current;
return result;
}
@ -175,15 +140,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
base.OnReleased(e);
}
protected override void Update()
{
base.Update();
// The input manager processes all input prior to us updating, so this is the perfect time
// for us to remove the extra press blocking, before input is handled in the next frame
pressHandledThisFrame = false;
}
protected override void UpdateHitStateTransforms(ArmedState state)
{
Debug.Assert(HitObject.HitWindows != null);
@ -231,7 +187,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
/// The lenience for the second key press.
/// This does not adjust by map difficulty in ScoreV2 yet.
/// </summary>
private const double second_hit_window = 30;
public const double SECOND_HIT_WINDOW = 30;
public StrongNestedHit()
: this(null)
@ -259,12 +215,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
if (!userTriggered)
{
if (timeOffset - ParentHitObject.Result.TimeOffset > second_hit_window)
if (timeOffset - ParentHitObject.Result.TimeOffset > SECOND_HIT_WINDOW)
ApplyResult(r => r.Type = r.Judgement.MinResult);
return;
}
if (Math.Abs(timeOffset - ParentHitObject.Result.TimeOffset) <= second_hit_window)
if (Math.Abs(timeOffset - ParentHitObject.Result.TimeOffset) <= SECOND_HIT_WINDOW)
ApplyResult(r => r.Type = r.Judgement.MaxResult);
}

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 osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Judgements;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
@ -16,5 +17,17 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
: base(nestedHit)
{
}
public override void OnKilled()
{
base.OnKilled();
// usually, the strong nested hit isn't judged itself, it is judged by its parent object.
// however, in rare cases (see: drum rolls, hits with hidden active),
// it can happen that the hit window of the nested strong hit extends past the lifetime of the parent object.
// this is a safety to prevent such cases from causing the nested hit to never be judged and as such prevent gameplay from completing.
if (!Judged && Time.Current > ParentHitObject?.HitObject.GetEndTime())
ApplyResult(r => r.Type = r.Judgement.MinResult);
}
}
}

View File

@ -276,6 +276,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
if (Time.Current < HitObject.StartTime)
return false;
if (AllJudged)
return false;
bool isCentre = e.Action == TaikoAction.LeftCentre || e.Action == TaikoAction.RightCentre;
// Ensure alternating centre and rim hits

View File

@ -119,8 +119,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public override bool RemoveWhenNotAlive => false;
}
// Most osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource).
public override IEnumerable<HitSampleInfo> GetSamples() => Enumerable.Empty<HitSampleInfo>();
// osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource).
public sealed override IEnumerable<HitSampleInfo> GetSamples() => Enumerable.Empty<HitSampleInfo>();
}
public abstract partial class DrawableTaikoHitObject<TObject> : DrawableTaikoHitObject

View File

@ -90,7 +90,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
{
cancellationToken.ThrowIfCancellationRequested();
AddNested(new DrumRollTick
AddNested(new DrumRollTick(this)
{
FirstTick = first,
TickSpacing = tickSpacing,

View File

@ -9,6 +9,8 @@ namespace osu.Game.Rulesets.Taiko.Objects
{
public class DrumRollTick : TaikoStrongableHitObject
{
public readonly DrumRoll Parent;
/// <summary>
/// Whether this is the first (initial) tick of the slider.
/// </summary>
@ -25,6 +27,11 @@ namespace osu.Game.Rulesets.Taiko.Objects
/// </summary>
public double HitWindow => TickSpacing / 2;
public DrumRollTick(DrumRoll parent)
{
Parent = parent;
}
public override Judgement CreateJudgement() => new TaikoDrumRollTickJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;

View File

@ -0,0 +1,42 @@
// 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.Framework.Allocation;
using osu.Game.Audio;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Rulesets.UI;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.Skinning.Argon
{
internal partial class ArgonDrumSamplePlayer : DrumSamplePlayer
{
private ArgonFlourishTriggerSource argonFlourishTrigger = null!;
[BackgroundDependencyLoader]
private void load(Playfield playfield, IPooledSampleProvider sampleProvider)
{
var hitObjectContainer = playfield.HitObjectContainer;
// Warm up pools for non-standard samples.
sampleProvider.GetPooledSample(new VolumeAwareHitSampleInfo(new HitSampleInfo(HitSampleInfo.HIT_NORMAL), true));
sampleProvider.GetPooledSample(new VolumeAwareHitSampleInfo(new HitSampleInfo(HitSampleInfo.HIT_CLAP), true));
sampleProvider.GetPooledSample(new VolumeAwareHitSampleInfo(new HitSampleInfo(HitSampleInfo.HIT_FLOURISH), true));
// We want to play back flourishes in an isolated source as to not have them cancelled.
AddInternal(argonFlourishTrigger = new ArgonFlourishTriggerSource(hitObjectContainer));
}
protected override DrumSampleTriggerSource CreateTriggerSource(HitObjectContainer hitObjectContainer, SampleBalance balance) =>
new ArgonDrumSampleTriggerSource(hitObjectContainer, balance);
protected override void Play(DrumSampleTriggerSource triggerSource, HitType hitType, bool strong)
{
base.Play(triggerSource, hitType, strong);
// This won't always play something, but the logic for flourish playback is contained within.
argonFlourishTrigger.Play(hitType, strong);
}
}
}

View File

@ -0,0 +1,46 @@
// 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.Collections.Generic;
using osu.Framework.Allocation;
using osu.Game.Audio;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Rulesets.UI;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.Skinning.Argon
{
public partial class ArgonDrumSampleTriggerSource : DrumSampleTriggerSource
{
[Resolved]
private ISkinSource skinSource { get; set; } = null!;
public ArgonDrumSampleTriggerSource(HitObjectContainer hitObjectContainer, SampleBalance balance)
: base(hitObjectContainer, balance)
{
}
public override void Play(HitType hitType, bool strong)
{
TaikoHitObject? hitObject = GetMostValidObject() as TaikoHitObject;
if (hitObject == null)
return;
var originalSample = hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL);
// If the sample is provided by a legacy skin, we should not try and do anything special.
if (skinSource.FindProvider(s => s.GetSample(originalSample) != null) is LegacySkinTransformer)
{
base.Play(hitType, strong);
return;
}
// let the magic begin...
var samplesToPlay = new List<ISampleInfo> { new VolumeAwareHitSampleInfo(originalSample, strong) };
PlaySamples(samplesToPlay.ToArray());
}
}
}

View File

@ -0,0 +1,79 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Game.Audio;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Rulesets.UI;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.Skinning.Argon
{
internal partial class ArgonFlourishTriggerSource : DrumSampleTriggerSource
{
private readonly HitObjectContainer hitObjectContainer;
[Resolved]
private ISkinSource skinSource { get; set; } = null!;
/// <summary>
/// The minimum time to leave between flourishes that are added to strong rim hits.
/// </summary>
private const double time_between_flourishes = 2000;
public ArgonFlourishTriggerSource(HitObjectContainer hitObjectContainer)
: base(hitObjectContainer)
{
this.hitObjectContainer = hitObjectContainer;
}
public override void Play(HitType hitType, bool strong)
{
TaikoHitObject? hitObject = GetMostValidObject() as TaikoHitObject;
if (hitObject == null)
return;
var originalSample = hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL);
// If the sample is provided by a legacy skin, we should not try and do anything special.
if (skinSource.FindProvider(s => s.GetSample(originalSample) != null) is LegacySkinTransformer)
return;
if (strong && hitType == HitType.Rim && canPlayFlourish(hitObject))
PlaySamples(new ISampleInfo[] { new VolumeAwareHitSampleInfo(hitObject.CreateHitSampleInfo(HitSampleInfo.HIT_FLOURISH), true) });
}
private bool canPlayFlourish(TaikoHitObject hitObject)
{
double? lastFlourish = null;
var hitObjects = hitObjectContainer.AliveObjects
.Reverse()
.Select(d => d.HitObject)
.OfType<Hit>()
.Where(h => h.IsStrong && h.Type == HitType.Rim);
// Add an additional 'flourish' sample to strong rim hits (that are at least `time_between_flourishes` apart).
// This is applied to hitobjects in reverse order, as to sound more musically coherent by biasing towards to
// end of groups/combos of strong rim hits instead of the start.
foreach (var h in hitObjects)
{
bool canFlourish = lastFlourish == null || lastFlourish - h.StartTime >= time_between_flourishes;
if (canFlourish)
lastFlourish = h.StartTime;
// hitObject can be either the strong hit itself (if hit late), or its nested strong object (if hit early)
// due to `GetMostValidObject()` idiosyncrasies.
// whichever it is, if we encounter it during iteration, stop looking.
if (h == hitObject || h.NestedHitObjects.Contains(hitObject))
return canFlourish;
}
return false;
}
}
}

View File

@ -60,6 +60,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
// the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield.
return Drawable.Empty().With(d => d.Expire());
case TaikoSkinComponents.DrumSamplePlayer:
return new ArgonDrumSamplePlayer();
case TaikoSkinComponents.TaikoExplosionGreat:
case TaikoSkinComponents.TaikoExplosionMiss:
case TaikoSkinComponents.TaikoExplosionOk:

View File

@ -0,0 +1,52 @@
// 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.Collections.Generic;
using osu.Game.Audio;
namespace osu.Game.Rulesets.Taiko.Skinning.Argon
{
public class VolumeAwareHitSampleInfo : HitSampleInfo
{
public const int SAMPLE_VOLUME_THRESHOLD_HARD = 90;
public const int SAMPLE_VOLUME_THRESHOLD_MEDIUM = 60;
public VolumeAwareHitSampleInfo(HitSampleInfo sampleInfo, bool isStrong = false)
: base(sampleInfo.Name, isStrong ? BANK_STRONG : getBank(sampleInfo.Bank, sampleInfo.Name, sampleInfo.Volume), sampleInfo.Suffix, sampleInfo.Volume)
{
}
public override IEnumerable<string> LookupNames
{
get
{
foreach (string name in base.LookupNames)
yield return name.Insert(name.LastIndexOf('/') + 1, "Argon/taiko-");
}
}
private static string getBank(string originalBank, string sampleName, int volume)
{
// So basically we're overwriting mapper's bank intentions here.
// The rationale is that most taiko beatmaps only use a single bank, but regularly adjust volume.
switch (sampleName)
{
case HIT_NORMAL:
case HIT_CLAP:
{
if (volume >= SAMPLE_VOLUME_THRESHOLD_HARD)
return BANK_DRUM;
if (volume >= SAMPLE_VOLUME_THRESHOLD_MEDIUM)
return BANK_NORMAL;
return BANK_SOFT;
}
default:
return originalBank;
}
}
}
}

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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -8,6 +9,7 @@ using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Objects;
@ -26,11 +28,12 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
private Bindable<int> currentCombo { get; } = new BindableInt();
private int animationFrame;
private double beatLength;
// required for editor blueprints (not sure why these circle pieces are zero size).
public override Quad ScreenSpaceDrawQuad => backgroundLayer.ScreenSpaceDrawQuad;
private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT;
public LegacyCirclePiece()
{
RelativeSizeAxes = Axes.Both;
@ -39,11 +42,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
[Resolved(canBeNull: true)]
private GameplayState? gameplayState { get; set; }
[Resolved(canBeNull: true)]
private IBeatSyncProvider? beatSyncProvider { get; set; }
[BackgroundDependencyLoader]
private void load(ISkinSource skin, DrawableHitObject drawableHitObject)
private void load(ISkinSource skin, DrawableHitObject drawableHitObject, IBeatSyncProvider? beatSyncProvider)
{
Drawable? getDrawableFor(string lookup)
{
@ -64,6 +64,11 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
if (foregroundLayer != null)
AddInternal(foregroundLayer);
drawableHitObject.StartTimeBindable.BindValueChanged(startTime =>
{
timingPoint = beatSyncProvider?.ControlPoints?.TimingPointAt(startTime.NewValue) ?? TimingControlPoint.DEFAULT;
}, true);
// Animations in taiko skins are used in a custom way (>150 combo and animating in time with beat).
// For now just stop at first frame for sanity.
foreach (var c in InternalChildren)
@ -115,14 +120,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
return;
}
if (beatSyncProvider?.ControlPoints != null)
{
beatLength = beatSyncProvider.ControlPoints.TimingPointAt(Time.Current).BeatLength;
animationFrame = Time.Current % ((beatLength * 2) / multiplier) >= beatLength / multiplier ? 0 : 1;
animatableForegroundLayer.GotoFrame(animationFrame);
}
animationFrame = Math.Abs(Time.Current - timingPoint.Time) % ((timingPoint.BeatLength * 2) / multiplier) >= timingPoint.BeatLength / multiplier ? 0 : 1;
animatableForegroundLayer.GotoFrame(animationFrame);
}
private Color4 accentColour;

View File

@ -52,6 +52,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
return null;
case TaikoSkinComponents.DrumSamplePlayer:
return null;
case TaikoSkinComponents.CentreHit:
case TaikoSkinComponents.RimHit:
if (hasHitCircle)

View File

@ -116,6 +116,9 @@ namespace osu.Game.Rulesets.Taiko
if (mods.HasFlagFast(LegacyMods.Random))
yield return new TaikoModRandom();
if (mods.HasFlagFast(LegacyMods.ScoreV2))
yield return new ModScoreV2();
}
public override LegacyMods ConvertToLegacyMods(Mod[] mods)
@ -176,6 +179,12 @@ namespace osu.Game.Rulesets.Taiko
new ModAdaptiveSpeed()
};
case ModType.System:
return new Mod[]
{
new ModScoreV2(),
};
default:
return Array.Empty<Mod>();
}
@ -197,6 +206,8 @@ namespace osu.Game.Rulesets.Taiko
public int LegacyID => 1;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new TaikoLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new TaikoRulesetConfigManager(settings, RulesetInfo);
@ -245,7 +256,7 @@ namespace osu.Game.Rulesets.Taiko
RelativeSizeAxes = Axes.X,
Height = 250
}, true),
new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[]
{
new AverageHitError(timedHitEvents),
new UnstableRate(timedHitEvents)

View File

@ -21,6 +21,7 @@ namespace osu.Game.Rulesets.Taiko
TaikoExplosionKiai,
Scroller,
Mascot,
KiaiGlow
KiaiGlow,
DrumSamplePlayer
}
}

View File

@ -1,57 +1,151 @@
// 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.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
namespace osu.Game.Rulesets.Taiko.UI
{
internal partial class DrumSamplePlayer : CompositeDrawable, IKeyBindingHandler<TaikoAction>
{
private readonly DrumSampleTriggerSource leftRimSampleTriggerSource;
private readonly DrumSampleTriggerSource leftCentreSampleTriggerSource;
private readonly DrumSampleTriggerSource rightCentreSampleTriggerSource;
private readonly DrumSampleTriggerSource rightRimSampleTriggerSource;
private DrumSampleTriggerSource leftCentreTrigger = null!;
private DrumSampleTriggerSource rightCentreTrigger = null!;
private DrumSampleTriggerSource leftRimTrigger = null!;
private DrumSampleTriggerSource rightRimTrigger = null!;
private DrumSampleTriggerSource strongCentreTrigger = null!;
private DrumSampleTriggerSource strongRimTrigger = null!;
public DrumSamplePlayer(HitObjectContainer hitObjectContainer)
private double lastHitTime;
private TaikoAction? lastAction;
[BackgroundDependencyLoader]
private void load(Playfield playfield)
{
var hitObjectContainer = playfield.HitObjectContainer;
InternalChildren = new Drawable[]
{
leftRimSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer),
leftCentreSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer),
rightCentreSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer),
rightRimSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer),
leftCentreTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Left),
rightCentreTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Right),
leftRimTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Left),
rightRimTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Right),
strongCentreTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Centre),
strongRimTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Centre)
};
}
protected virtual DrumSampleTriggerSource CreateTriggerSource(HitObjectContainer hitObjectContainer, SampleBalance balance)
=> new DrumSampleTriggerSource(hitObjectContainer);
public bool OnPressed(KeyBindingPressEvent<TaikoAction> e)
{
if ((Clock as IGameplayClock)?.IsRewinding == true)
return false;
HitType hitType;
DrumSampleTriggerSource triggerSource;
bool strong = checkStrongValidity(e.Action, lastAction, Time.Current - lastHitTime);
switch (e.Action)
{
case TaikoAction.LeftRim:
leftRimSampleTriggerSource.Play(HitType.Rim);
break;
case TaikoAction.LeftCentre:
leftCentreSampleTriggerSource.Play(HitType.Centre);
hitType = HitType.Centre;
triggerSource = strong ? strongCentreTrigger : leftCentreTrigger;
break;
case TaikoAction.RightCentre:
rightCentreSampleTriggerSource.Play(HitType.Centre);
hitType = HitType.Centre;
triggerSource = strong ? strongCentreTrigger : rightCentreTrigger;
break;
case TaikoAction.LeftRim:
hitType = HitType.Rim;
triggerSource = strong ? strongRimTrigger : leftRimTrigger;
break;
case TaikoAction.RightRim:
rightRimSampleTriggerSource.Play(HitType.Rim);
hitType = HitType.Rim;
triggerSource = strong ? strongRimTrigger : rightRimTrigger;
break;
default:
return false;
}
if (strong)
{
switch (hitType)
{
case HitType.Centre:
flushCenterTriggerSources();
break;
case HitType.Rim:
flushRimTriggerSources();
break;
}
}
Play(triggerSource, hitType, strong);
lastHitTime = Time.Current;
lastAction = e.Action;
return false;
}
protected virtual void Play(DrumSampleTriggerSource triggerSource, HitType hitType, bool strong) =>
triggerSource.Play(hitType, strong);
private bool checkStrongValidity(TaikoAction newAction, TaikoAction? lastAction, double timeBetweenActions)
{
if (lastAction == null)
return false;
if (timeBetweenActions < 0 || timeBetweenActions > DrawableHit.StrongNestedHit.SECOND_HIT_WINDOW)
return false;
switch (newAction)
{
case TaikoAction.LeftCentre:
return lastAction == TaikoAction.RightCentre;
case TaikoAction.RightCentre:
return lastAction == TaikoAction.LeftCentre;
case TaikoAction.LeftRim:
return lastAction == TaikoAction.RightRim;
case TaikoAction.RightRim:
return lastAction == TaikoAction.LeftRim;
default:
return false;
}
}
private void flushCenterTriggerSources()
{
leftCentreTrigger.StopAllPlayback();
rightCentreTrigger.StopAllPlayback();
strongCentreTrigger.StopAllPlayback();
}
private void flushRimTriggerSources()
{
leftRimTrigger.StopAllPlayback();
rightRimTrigger.StopAllPlayback();
strongRimTrigger.StopAllPlayback();
}
public void OnReleased(KeyBindingReleaseEvent<TaikoAction> e)
{
}

View File

@ -2,30 +2,74 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.UI
{
public partial class DrumSampleTriggerSource : GameplaySampleTriggerSource
{
public DrumSampleTriggerSource(HitObjectContainer hitObjectContainer)
private const double stereo_separation = 0.2;
public DrumSampleTriggerSource(HitObjectContainer hitObjectContainer, SampleBalance balance = SampleBalance.Centre)
: base(hitObjectContainer)
{
switch (balance)
{
case SampleBalance.Left:
AudioContainer.Balance.Value = -stereo_separation;
break;
case SampleBalance.Centre:
AudioContainer.Balance.Value = 0;
break;
case SampleBalance.Right:
AudioContainer.Balance.Value = stereo_separation;
break;
}
}
public void Play(HitType hitType)
public virtual void Play(HitType hitType, bool strong)
{
var hitSample = GetMostValidObject()?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL);
TaikoHitObject? hitObject = GetMostValidObject() as TaikoHitObject;
if (hitSample == null)
if (hitObject == null)
return;
PlaySamples(new ISampleInfo[] { new HitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL, hitSample.Bank, volume: hitSample.Volume) });
var baseSample = hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL);
if (strong)
{
PlaySamples(new ISampleInfo[]
{
baseSample,
hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_WHISTLE : HitSampleInfo.HIT_FINISH)
});
}
else
{
PlaySamples(new ISampleInfo[] { baseSample });
}
}
public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead");
protected override void ApplySampleInfo(SkinnableSound hitSound, ISampleInfo[] samples)
{
base.ApplySampleInfo(hitSound, samples);
hitSound.Balance.Value = -0.05 + RNG.NextDouble(0.1);
}
}
public enum SampleBalance
{
Left,
Centre,
Right
}
}

View File

@ -170,7 +170,10 @@ namespace osu.Game.Rulesets.Taiko.UI
RelativeSizeAxes = Axes.Both,
},
drumRollHitContainer.CreateProxy(),
new DrumSamplePlayer(HitObjectContainer),
new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumSamplePlayer), _ => new DrumSamplePlayer())
{
RelativeSizeAxes = Axes.Both,
},
// this is added at the end of the hierarchy to receive input before taiko objects.
// but is proxied below everything to not cover visual effects such as hit explosions.
inputDrum,

View File

@ -5,7 +5,7 @@
<key>CFBundleName</key>
<string>osu.Game.Tests.iOS</string>
<key>CFBundleIdentifier</key>
<string>ppy.osu-Game-Tests-iOS</string>
<string>sh.ppy.osu-tests</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>

View File

@ -64,7 +64,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestCachedRetrievalWithFiles() => AddStep("run test", () =>
{
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID).Detach());
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID)!.Detach());
Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0));
@ -90,7 +90,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestForcedRefetchRetrievalWithFiles() => AddStep("run test", () =>
{
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID).Detach());
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID)!.Detach());
Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0));
@ -102,7 +102,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestSavePreservesCollections() => AddStep("run test", () =>
{
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID).Detach());
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID)!.Detach());
var working = beatmaps.GetWorkingBeatmap(beatmap);

View File

@ -478,8 +478,8 @@ namespace osu.Game.Tests.Chat
Content = "This is a [http://www.simple-test.com simple test] with some [traps] and [[wiki links]]. Don't forget to visit https://osu.ppy.sh (now!)[http://google.com]\uD83D\uDE12"
});
Assert.AreEqual("This is a simple test with some [traps] and wiki links. Don't forget to visit https://osu.ppy.sh now!\0\0\0", result.DisplayContent);
Assert.AreEqual(5, result.Links.Count);
Assert.AreEqual("This is a simple test with some [traps] and wiki links. Don't forget to visit https://osu.ppy.sh now![emoji]", result.DisplayContent);
Assert.AreEqual(4, result.Links.Count);
Link f = result.Links.Find(l => l.Url == "https://dev.ppy.sh/wiki/wiki links");
Assert.That(f, Is.Not.Null);
@ -500,27 +500,22 @@ namespace osu.Game.Tests.Chat
Assert.That(f, Is.Not.Null);
Assert.AreEqual(78, f.Index);
Assert.AreEqual(18, f.Length);
f = result.Links.Find(l => l.Url == "\uD83D\uDE12");
Assert.That(f, Is.Not.Null);
Assert.AreEqual(101, f.Index);
Assert.AreEqual(3, f.Length);
}
[Test]
public void TestEmoji()
{
Message result = MessageFormatter.FormatMessage(new Message { Content = "Hello world\uD83D\uDE12<--This is an emoji,There are more:\uD83D\uDE10\uD83D\uDE00,\uD83D\uDE20" });
Assert.AreEqual("Hello world\0\0\0<--This is an emoji,There are more:\0\0\0\0\0\0,\0\0\0", result.DisplayContent);
Assert.AreEqual(result.Links.Count, 4);
Assert.AreEqual(result.Links[0].Index, 11);
Assert.AreEqual(result.Links[1].Index, 49);
Assert.AreEqual(result.Links[2].Index, 52);
Assert.AreEqual(result.Links[3].Index, 56);
Assert.AreEqual(result.Links[0].Url, "\uD83D\uDE12");
Assert.AreEqual(result.Links[1].Url, "\uD83D\uDE10");
Assert.AreEqual(result.Links[2].Url, "\uD83D\uDE00");
Assert.AreEqual(result.Links[3].Url, "\uD83D\uDE20");
Message result = MessageFormatter.FormatMessage(new Message { Content = "Hello world\uD83D\uDE12<--This is an emoji,There are more emojis among us:\uD83D\uDE10\uD83D\uDE00,\uD83D\uDE20" });
Assert.AreEqual("Hello world[emoji]<--This is an emoji,There are more emojis among us:[emoji][emoji],[emoji]", result.DisplayContent);
Assert.AreEqual(result.Links.Count, 0);
}
[Test]
public void TestEmojiWithSuccessiveParens()
{
Message result = MessageFormatter.FormatMessage(new Message { Content = "\uD83D\uDE10(let's hope this doesn't accidentally turn into a link)" });
Assert.AreEqual("[emoji](let's hope this doesn't accidentally turn into a link)", result.DisplayContent);
Assert.AreEqual(result.Links.Count, 0);
}
[Test]

View File

@ -42,7 +42,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0);
});
});
@ -51,7 +51,7 @@ namespace osu.Game.Tests.Database
{
Realm.Write(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
foreach (var b in beatmapSetInfo.Beatmaps)
b.StarRating = -1;
});
@ -66,7 +66,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0);
});
});
@ -79,7 +79,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0);
});
});
@ -90,7 +90,7 @@ namespace osu.Game.Tests.Database
{
Realm.Write(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
foreach (var b in beatmapSetInfo.Beatmaps)
b.StarRating = -1;
});
@ -107,7 +107,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating == -1);
});
});
@ -118,7 +118,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0);
});
});

View File

@ -417,6 +417,60 @@ namespace osu.Game.Tests.Database
});
}
[Test]
public void TestImport_Modify_Revert()
{
RunTestWithRealmAsync(async (realm, storage) =>
{
var importer = new BeatmapImporter(storage, realm);
using var store = new RealmRulesetStore(realm, storage);
var imported = await LoadOszIntoStore(importer, realm.Realm);
await createScoreForBeatmap(realm.Realm, imported.Beatmaps.First());
var score = realm.Run(r => r.All<ScoreInfo>().Single());
string originalHash = imported.Beatmaps.First().Hash;
const string modified_hash = "new_hash";
Assert.That(imported.Beatmaps.First().Scores.Single(), Is.EqualTo(score));
Assert.That(score.BeatmapHash, Is.EqualTo(originalHash));
Assert.That(score.BeatmapInfo, Is.EqualTo(imported.Beatmaps.First()));
// imitate making local changes via editor
// ReSharper disable once MethodHasAsyncOverload
realm.Write(r =>
{
BeatmapInfo beatmap = imported.Beatmaps.First();
beatmap.Hash = modified_hash;
beatmap.ResetOnlineInfo();
beatmap.UpdateLocalScores(r);
});
Assert.That(!imported.Beatmaps.First().Scores.Any());
Assert.That(score.BeatmapInfo, Is.Null);
Assert.That(score.BeatmapHash, Is.EqualTo(originalHash));
// imitate reverting the local changes made above
// ReSharper disable once MethodHasAsyncOverload
realm.Write(r =>
{
BeatmapInfo beatmap = imported.Beatmaps.First();
beatmap.Hash = originalHash;
beatmap.ResetOnlineInfo();
beatmap.UpdateLocalScores(r);
});
Assert.That(imported.Beatmaps.First().Scores.Single(), Is.EqualTo(score));
Assert.That(score.BeatmapHash, Is.EqualTo(originalHash));
Assert.That(score.BeatmapInfo, Is.EqualTo(imported.Beatmaps.First()));
});
}
[Test]
public void TestImport_ThenModifyMapWithScore_ThenImport()
{
@ -431,19 +485,19 @@ namespace osu.Game.Tests.Database
await createScoreForBeatmap(realm.Realm, imported.Beatmaps.First());
Assert.That(imported.Beatmaps.First().Scores.Any());
// imitate making local changes via editor
// ReSharper disable once MethodHasAsyncOverload
realm.Write(_ =>
realm.Write(r =>
{
BeatmapInfo beatmap = imported.Beatmaps.First();
beatmap.Hash = "new_hash";
beatmap.ResetOnlineInfo();
beatmap.UpdateLocalScores(r);
});
// for now, making changes to a beatmap doesn't remove the backlink from the score to the beatmap.
// the logic of ensuring that scores match the beatmap is upheld via comparing the hash in usages (see: https://github.com/ppy/osu/pull/22539).
// TODO: revisit when fixing https://github.com/ppy/osu/issues/24069.
Assert.That(imported.Beatmaps.First().Scores.Any());
Assert.That(!imported.Beatmaps.First().Scores.Any());
var importedSecondTime = await importer.Import(new ImportTask(temp));
@ -461,6 +515,7 @@ namespace osu.Game.Tests.Database
Assert.That(importedFirstTimeBeatmap.Hash != importedSecondTimeBeatmap.Hash);
Assert.That(!importedFirstTimeBeatmap.Scores.Any());
Assert.That(importedSecondTimeBeatmap.Scores.Count() == 1);
Assert.That(importedSecondTimeBeatmap.Scores.Single().BeatmapInfo, Is.EqualTo(importedSecondTimeBeatmap));
});
}

Some files were not shown because too many files have changed in this diff Show More