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

Merge remote-tracking branch 'osumaster/master' into taikostatacc

This commit is contained in:
Natelytle 2023-07-28 11:36:59 -04:00
commit faddc4fa99
410 changed files with 6936 additions and 2364 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?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"> <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!" /> <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"> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security> <security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
@ -14,33 +15,10 @@
</trustInfo> </trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application> <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 --> <!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" /> <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 10 --> <!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application> </application>
</compatibility> </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> </asmv1:assembly>

View File

@ -8,13 +8,9 @@
<!-- NullabilityInfoContextSupport is disabled by default for Android --> <!-- NullabilityInfoContextSupport is disabled by default for Android -->
<NullabilityInfoContextSupport>true</NullabilityInfoContextSupport> <NullabilityInfoContextSupport>true</NullabilityInfoContextSupport>
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<AndroidManifestMerger>manifestmerger.jar</AndroidManifestMerger>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.625.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2023.724.0" />
</ItemGroup>
<ItemGroup>
<AndroidManifestOverlay Include="$(MSBuildThisFileDirectory)osu.Android\Properties\AndroidManifestOverlay.xml" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged. <!-- 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; 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); client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network);
config.BindWith(OsuSetting.DiscordRichPresence, privacyMode); config.BindWith(OsuSetting.DiscordRichPresence, privacyMode);
@ -187,7 +184,7 @@ namespace osu.Desktop
return edit.BeatmapInfo.ToString() ?? string.Empty; return edit.BeatmapInfo.ToString() ?? string.Empty;
case UserActivity.WatchingReplay watching: case UserActivity.WatchingReplay watching:
return watching.BeatmapInfo.ToString(); return watching.BeatmapInfo?.ToString() ?? string.Empty;
case UserActivity.InLobby lobby: case UserActivity.InLobby lobby:
return privacyMode.Value == DiscordRichPresenceMode.Limited ? string.Empty : lobby.Room.Name.Value; return privacyMode.Value == DiscordRichPresenceMode.Limited ? string.Empty : lobby.Room.Name.Value;

View File

@ -147,14 +147,12 @@ namespace osu.Desktop
{ {
base.SetHost(host); base.SetHost(host);
var desktopWindow = (SDL2DesktopWindow)host.Window;
var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico");
if (iconStream != null) if (iconStream != null)
desktopWindow.SetIconFromStream(iconStream); host.Window.SetIconFromStream(iconStream);
desktopWindow.CursorState |= CursorState.Hidden; host.Window.CursorState |= CursorState.Hidden;
desktopWindow.Title = Name; host.Window.Title = Name;
} }
protected override BatteryInfo CreateBatteryInfo() => new SDL2BatteryInfo(); 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> <Title>osu!</Title>
<Product>osu!(lazer)</Product> <Product>osu!(lazer)</Product>
<ApplicationIcon>lazer.ico</ApplicationIcon> <ApplicationIcon>lazer.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.0.0</Version> <Version>0.0.0</Version>
<FileVersion>0.0.0</FileVersion> <FileVersion>0.0.0</FileVersion>
</PropertyGroup> </PropertyGroup>
@ -27,7 +26,7 @@
<PackageReference Include="Clowd.Squirrel" Version="2.9.42" /> <PackageReference Include="Clowd.Squirrel" Version="2.9.42" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" /> <PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="System.IO.Packaging" Version="7.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>
<ItemGroup Label="Resources"> <ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" /> <EmbeddedResource Include="lazer.ico" />

View File

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

View File

@ -5,6 +5,7 @@ using System;
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Beatmaps;
namespace osu.Game.Rulesets.Catch.Tests 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.HalfTime, new[] { typeof(CatchModHalfTime) } },
new object[] { LegacyMods.Flashlight, new[] { typeof(CatchModFlashlight) } }, new object[] { LegacyMods.Flashlight, new[] { typeof(CatchModFlashlight) } },
new object[] { LegacyMods.Autoplay, new[] { typeof(CatchModAutoplay) } }, 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))] [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.Replays.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Rulesets.Catch namespace osu.Game.Rulesets.Catch
@ -91,6 +93,9 @@ namespace osu.Game.Rulesets.Catch
if (mods.HasFlagFast(LegacyMods.Relax)) if (mods.HasFlagFast(LegacyMods.Relax))
yield return new CatchModRelax(); yield return new CatchModRelax();
if (mods.HasFlagFast(LegacyMods.ScoreV2))
yield return new ModScoreV2();
} }
public override IEnumerable<Mod> GetModsFor(ModType type) public override IEnumerable<Mod> GetModsFor(ModType type)
@ -140,6 +145,12 @@ namespace osu.Game.Rulesets.Catch
new CatchModNoScope(), new CatchModNoScope(),
}; };
case ModType.System:
return new Mod[]
{
new ModScoreV2(),
};
default: default:
return Array.Empty<Mod>(); return Array.Empty<Mod>();
} }
@ -202,10 +213,24 @@ namespace osu.Game.Rulesets.Catch
public int LegacyID => 2; public int LegacyID => 2;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new CatchLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame(); public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame();
public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this); public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this);
public override IBeatmapVerifier CreateBeatmapVerifier() => new CatchBeatmapVerifier(); 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. // Todo: osu!catch should not output star rating in the 'aim' attribute.
yield return (ATTRIB_ID_AIM, StarRating); yield return (ATTRIB_ID_AIM, StarRating);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate); yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
} }
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo) 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]; StarRating = values[ATTRIB_ID_AIM];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE]; 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; public override int Version => 20220701;
private readonly IWorkingBeatmap workingBeatmap;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
{ {
workingBeatmap = beatmap;
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) 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 // 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; 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, StarRating = Math.Sqrt(skills[0].DifficultyValue()) * star_scaling_factor,
Mods = mods, Mods = mods,
ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0, 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)), 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) 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.Edit;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{ {
@ -24,7 +23,5 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
: base(new THitObject()) : base(new THitObject())
{ {
} }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
} }
} }

View File

@ -4,6 +4,7 @@
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Catch.UI;
using osu.Game.Screens.Play;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -59,7 +60,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
lastDisplayedCombo = combo; lastDisplayedCombo = combo;
if (Time.Elapsed < 0) if ((Clock as IGameplayClock)?.IsRewinding == true)
{ {
// needs more work to make rewind somehow look good. // 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). // 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.Catch.Replays;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Catch.UI namespace osu.Game.Rulesets.Catch.UI
@ -96,7 +97,7 @@ namespace osu.Game.Rulesets.Catch.UI
comboDisplay.X = Catcher.X; 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. // 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). // Setting `true` will prevent generation of false-positive after-images (with more false-negatives).

View File

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

View File

@ -5,6 +5,7 @@ using System;
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Beatmaps;
namespace osu.Game.Rulesets.Mania.Tests 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.Key3, new[] { typeof(ManiaModKey3) } },
new object[] { LegacyMods.Key2, new[] { typeof(ManiaModKey2) } }, new object[] { LegacyMods.Key2, new[] { typeof(ManiaModKey2) } },
new object[] { LegacyMods.Mirror, new[] { typeof(ManiaModMirror) } }, 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))] [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

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

View File

@ -31,9 +31,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty
public override int Version => 20220902; public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
{ {
workingBeatmap = beatmap;
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty; originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
} }
@ -46,15 +50,26 @@ namespace osu.Game.Rulesets.Mania.Difficulty
HitWindows hitWindows = new ManiaHitWindows(); HitWindows hitWindows = new ManiaHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
return new ManiaDifficultyAttributes ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{ {
StarRating = skills[0].DifficultyValue() * star_scaling_factor, StarRating = skills[0].DifficultyValue() * star_scaling_factor,
Mods = mods, Mods = mods,
// In osu-stable mania, rate-adjustment mods don't affect the hit window. // 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. // 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), 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) 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)) if (mods.HasFlagFast(LegacyMods.Mirror))
yield return new ManiaModMirror(); yield return new ManiaModMirror();
if (mods.HasFlagFast(LegacyMods.ScoreV2))
yield return new ModScoreV2();
} }
public override LegacyMods ConvertToLegacyMods(Mod[] mods) public override LegacyMods ConvertToLegacyMods(Mod[] mods)
@ -285,6 +288,12 @@ namespace osu.Game.Rulesets.Mania
new ModAdaptiveSpeed() new ModAdaptiveSpeed()
}; };
case ModType.System:
return new Mod[]
{
new ModScoreV2(),
};
default: default:
return Array.Empty<Mod>(); return Array.Empty<Mod>();
} }
@ -302,6 +311,8 @@ namespace osu.Game.Rulesets.Mania
public int LegacyID => 3; public int LegacyID => 3;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new ManiaLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame(); public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new ManiaRulesetConfigManager(settings, RulesetInfo); public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new ManiaRulesetConfigManager(settings, RulesetInfo);
@ -401,7 +412,7 @@ namespace osu.Game.Rulesets.Mania
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Height = 250 Height = 250
}, true), }, true),
new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[]
{ {
new AverageHitError(score.HitEvents), new AverageHitError(score.HitEvents),
new UnstableRate(score.HitEvents) new UnstableRate(score.HitEvents)
@ -414,6 +425,8 @@ namespace osu.Game.Rulesets.Mania
} }
public override RulesetSetupSection CreateEditorSetupSection() => new ManiaSetupSection(); public override RulesetSetupSection CreateEditorSetupSection() => new ManiaSetupSection();
public override DifficultySection CreateEditorDifficultySection() => new ManiaDifficultySection();
} }
public enum PlayfieldType public enum PlayfieldType

View File

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

View File

@ -2,7 +2,12 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.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) protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion)
{ {
return 10000 * comboProgress return 10000 * comboProgress
@ -25,5 +33,29 @@ namespace osu.Game.Rulesets.Mania.Scoring
protected override double GetComboScoreChange(JudgementResult result) 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)); => 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

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

View File

@ -25,6 +25,35 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{ {
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); 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] [Test]
public void TestContextMenuShownCorrectlyForSelectedSlider() public void TestContextMenuShownCorrectlyForSelectedSlider()
{ {

View File

@ -61,6 +61,20 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertControlPointType(0, PathType.Linear); 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] [Test]
public void TestPlaceNormalControlPoint() public void TestPlaceNormalControlPoint()
{ {

View File

@ -23,6 +23,7 @@ namespace osu.Game.Rulesets.Osu.Tests
[TestCase("uneven-repeat-slider")] [TestCase("uneven-repeat-slider")]
[TestCase("old-stacking")] [TestCase("old-stacking")]
[TestCase("multi-segment-slider")] [TestCase("multi-segment-slider")]
[TestCase("nan-slider")]
public void Test(string name) => base.Test(name); public void Test(string name) => base.Test(name);
protected override IEnumerable<ConvertValue> CreateConvertValue(HitObject hitObject) 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(6.7115569159190587d, 206, "diffcalc-test")]
[TestCase(1.4391311903612753d, 45, "zero-length-sliders")] [TestCase(1.4391311903612753d, 45, "zero-length-sliders")]
[TestCase(0.14102693012101306d, 1, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);

View File

@ -4,6 +4,7 @@
using System; using System;
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Tests.Beatmaps; 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.SpunOut, new[] { typeof(OsuModSpunOut) } },
new object[] { LegacyMods.Autopilot, new[] { typeof(OsuModAutopilot) } }, new object[] { LegacyMods.Autopilot, new[] { typeof(OsuModAutopilot) } },
new object[] { LegacyMods.Target, new[] { typeof(OsuModTargetPractice) } }, 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))] [TestCaseSource(nameof(osu_mod_mapping))]

View File

@ -49,6 +49,9 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep("Create tests", () => AddStep("Create tests", () =>
{ {
InputTrigger triggerLeft;
InputTrigger triggerRight;
Children = new Drawable[] Children = new Drawable[]
{ {
osuInputManager = new OsuInputManager(new OsuRuleset().RulesetInfo) osuInputManager = new OsuInputManager(new OsuRuleset().RulesetInfo)
@ -59,29 +62,39 @@ namespace osu.Game.Rulesets.Osu.Tests
Origin = Anchor.Centre, Origin = Anchor.Centre,
Children = new Drawable[] Children = new Drawable[]
{ {
leftKeyCounter = new DefaultKeyCounter(new TestActionKeyCounterTrigger(OsuAction.LeftButton))
{
Anchor = Anchor.Centre,
Origin = Anchor.CentreRight,
Depth = float.MinValue,
X = -100,
},
rightKeyCounter = new DefaultKeyCounter(new TestActionKeyCounterTrigger(OsuAction.RightButton))
{
Anchor = Anchor.Centre,
Origin = Anchor.CentreLeft,
Depth = float.MinValue,
X = 100,
},
new OsuCursorContainer new OsuCursorContainer
{ {
Depth = float.MinValue, Depth = float.MinValue,
},
triggerLeft = new TestActionKeyCounterTrigger(OsuAction.LeftButton)
{
Depth = float.MinValue
},
triggerRight = new TestActionKeyCounterTrigger(OsuAction.RightButton)
{
Depth = float.MinValue
} }
}, },
} },
}, },
new TouchVisualiser(), new TouchVisualiser(),
}; };
mainContent.AddRange(new[]
{
leftKeyCounter = new DefaultKeyCounter(triggerLeft)
{
Anchor = Anchor.Centre,
Origin = Anchor.CentreRight,
X = -100,
},
rightKeyCounter = new DefaultKeyCounter(triggerRight)
{
Anchor = Anchor.Centre,
Origin = Anchor.CentreLeft,
X = 100,
},
});
}); });
} }

View File

@ -214,17 +214,24 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
? currSlider.Position + currSlider.Path.PositionAt(1) ? currSlider.Position + currSlider.Path.PositionAt(1)
: currHitObject.Position; : 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) if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, currHitObject.Position) < stack_distance)
{ {
currHitObject.StackHeight++; currHitObject.StackHeight++;
startTime = beatmap.HitObjects[j].GetEndTime(); startTime = beatmap.HitObjects[j].StartTime;
} }
else if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, position2) < stack_distance) else if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, position2) < stack_distance)
{ {
// Case for sliders - bump notes down and right, rather than up and left. // Case for sliders - bump notes down and right, rather than up and left.
sliderStack++; sliderStack++;
beatmap.HitObjects[j].StackHeight -= 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_SPEED, SpeedDifficulty);
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty); yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate); yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_DIFFICULTY, StarRating);
if (ShouldSerializeFlashlightRating()) if (ShouldSerializeFlashlightRating())
@ -111,7 +110,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficulty = values[ATTRIB_ID_SPEED]; SpeedDifficulty = values[ATTRIB_ID_SPEED];
OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY]; OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE]; ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_DIFFICULTY]; StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];

View File

@ -26,9 +26,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
public override int Version => 20220902; public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
{ {
workingBeatmap = beatmap;
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) 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 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 preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double drainRate = beatmap.Difficulty.DrainRate; double drainRate = beatmap.Difficulty.DrainRate;
@ -86,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
return new OsuDifficultyAttributes OsuDifficultyAttributes attributes = new OsuDifficultyAttributes
{ {
StarRating = starRating, StarRating = starRating,
Mods = mods, Mods = mods,
@ -103,6 +108,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SliderCount = sliderCount, SliderCount = sliderCount,
SpinnerCount = spinnerCount, 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) 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 AlwaysShowWhenSelected => true;
protected override bool ShouldBeAlive => base.ShouldBeAlive 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) protected OsuSelectionBlueprint(T hitObject)
: base(hitObject) : base(hitObject)

View File

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

View File

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

View File

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

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

View File

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

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
@ -10,6 +11,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
@ -36,11 +38,12 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
() => Is.EqualTo(expectedResult)); () => 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", () => AddStep("load player", () =>
{ {
Beatmap.Value = CreateWorkingBeatmap(beatmap); Beatmap.Value = CreateWorkingBeatmap(beatmap);
SelectedMods.Value = mods ?? Array.Empty<Mod>();
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); 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); 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] [Test]
public void TestHitAllStrongDrumRollWithOneKey() public void TestHitAllStrongDrumRollWithOneKey()
{ {

View File

@ -4,10 +4,14 @@
using System.Collections.Generic; using System.Collections.Generic;
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Rulesets.Taiko.Scoring;
namespace osu.Game.Rulesets.Taiko.Tests.Judgements namespace osu.Game.Rulesets.Taiko.Tests.Judgements
{ {
@ -32,6 +36,28 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AssertResult<Hit>(0, HitResult.Great); 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] [Test]
public void TestHitRimHit() public void TestHitRimHit()
{ {
@ -157,5 +183,58 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AssertJudgementCount(1); AssertJudgementCount(1);
AssertResult<Hit>(0, HitResult.Ok); 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)); 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. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Tests.Mods namespace osu.Game.Rulesets.Taiko.Tests.Mods
{ {
public partial class TestSceneTaikoModHidden : TaikoModTestScene 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] [Test]
public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData
{ {
Mod = new TaikoModHidden(), Mod = new TaikoModHidden(),
Autoplay = true, Autoplay = true,
PassCondition = checkSomeAutoplayHits PassCondition = checkAllMaxResultJudgements(4),
}); });
private bool checkSomeAutoplayHits() [Test]
=> Player.ScoreProcessor.JudgedHits >= 4 public void TestHitTwoNotesWithinShortPeriod()
&& Player.Results.All(result => result.Type == result.Judgement.MaxResult); {
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 System;
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Tests.Beatmaps; 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.Flashlight, new[] { typeof(TaikoModFlashlight) } },
new object[] { LegacyMods.Autoplay, new[] { typeof(TaikoModAutoplay) } }, new object[] { LegacyMods.Autoplay, new[] { typeof(TaikoModAutoplay) } },
new object[] { LegacyMods.Random, new[] { typeof(TaikoModRandom) } }, 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))] [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>); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(200); seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
} }
[Test] [Test]
@ -100,13 +100,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
}); });
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(200); seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
} }
[Test] [Test]
@ -145,23 +145,23 @@ namespace osu.Game.Rulesets.Taiko.Tests
}); });
AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first)); AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
seekTo(120); seekTo(120);
AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first)); AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
seekTo(480); seekTo(480);
AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second)); AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(700); seekTo(700);
AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second)); AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
} }
[Test] [Test]
@ -174,8 +174,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
StartTime = 100, StartTime = 100,
Samples = new List<HitSampleInfo> Samples = new List<HitSampleInfo>
{ {
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong
} }
}; };
hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); 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>); AddAssert("most valid object is nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit.StrongNestedHit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(200); seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
} }
[Test] [Test]
@ -213,18 +213,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
}); });
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(600); seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(1200); seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
} }
[Test] [Test]
@ -247,18 +247,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
}); });
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(600); seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(1200); seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
} }
[Test] [Test]
@ -272,8 +272,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
EndTime = 1100, EndTime = 1100,
Samples = new List<HitSampleInfo> Samples = new List<HitSampleInfo>
{ {
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong
} }
}; };
drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); 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>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(600); seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(1200); seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSamples(HitType.Centre, true, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSamples(HitType.Rim, true, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
} }
[Test] [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. // 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. // But for sample playback purposes they can be ignored as noise.
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(600); seekTo(600);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(1200); seekTo(1200);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
} }
[Test] [Test]
@ -344,7 +344,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
EndTime = 1100, EndTime = 1100,
Samples = new List<HitSampleInfo> Samples = new List<HitSampleInfo>
{ {
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum") new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM)
} }
}; };
swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); 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. // 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. // But for sample playback purposes they can be ignored as noise.
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM);
seekTo(600); seekTo(600);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM);
seekTo(1200); seekTo(1200);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "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)); AddStep($"hit {hitType}", () => triggerSource.Play(hitType, strong));
AddAssert($"last played sample is {expectedName}", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Single().Name, () => Is.EqualTo(expectedName)); AddAssert($"last played sample is {expectedSamplesCsv}", () => string.Join(',', triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Select(s => s.Name)),
AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Single().Bank, () => Is.EqualTo(expectedBank)); () => 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)); 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) 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; DrawableDrumRollTick h;
DrawableRuleset.Playfield.Add(h = new DrawableDrumRollTick(tick) { JudgementType = hitType }); DrawableRuleset.Playfield.Add(h = new DrawableDrumRollTick(tick) { JudgementType = hitType });

View File

@ -3,15 +3,16 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.Taiko.UI;
namespace osu.Game.Rulesets.Taiko.Tests namespace osu.Game.Rulesets.Taiko.Tests
{ {
/// <summary> /// <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> /// </summary>
[HeadlessTest] [HeadlessTest]
public partial class TestSceneSampleOutput : TestSceneTaikoPlayer public partial class TestSceneSampleOutput : TestSceneTaikoPlayer
@ -26,10 +27,10 @@ namespace osu.Game.Rulesets.Taiko.Tests
string.Empty, string.Empty,
string.Empty, string.Empty,
string.Empty, string.Empty,
HitSampleInfo.HIT_FINISH, string.Empty,
HitSampleInfo.HIT_WHISTLE, string.Empty,
HitSampleInfo.HIT_WHISTLE, string.Empty,
HitSampleInfo.HIT_WHISTLE, string.Empty,
}; };
var actualSampleNames = new List<string>(); var actualSampleNames = new List<string>();
@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
AddUntilStep("all samples collected", () => actualSampleNames.Count == expectedSampleNames.Length); 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"); protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TaikoBeatmapConversionTest().GetBeatmap("sample-to-type-conversions");

View File

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

View File

@ -25,9 +25,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public override int Version => 20220902; public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
{ {
workingBeatmap = beatmap;
} }
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) 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 hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
return new TaikoDifficultyAttributes TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes
{ {
StarRating = starRating, StarRating = starRating,
Mods = mods, Mods = mods,
@ -96,6 +99,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate, OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate,
MaxCombo = beatmap.HitObjects.Count(h => h is Hit), 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> /// <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

@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
// TODO: The detection of rulesets is temporary until the leftover old skills have been reworked. // 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; double multiplier = 1.13;

View File

@ -62,6 +62,8 @@ namespace osu.Game.Rulesets.Taiko.Mods
hitObject.LifetimeEnd = state == ArmedState.Idle || !hitObject.AllJudged hitObject.LifetimeEnd = state == ArmedState.Idle || !hitObject.AllJudged
? hitObject.HitObject.GetEndTime() + hitObject.HitObject.HitWindows.WindowFor(HitResult.Miss) ? hitObject.HitObject.GetEndTime() + hitObject.HitObject.HitWindows.WindowFor(HitResult.Miss)
: hitObject.HitStateUpdateTime; : 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; 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); 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; 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); 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; public override bool OnPressed(KeyBindingPressEvent<TaikoAction> e) => false;
} }
} }

View File

@ -4,14 +4,12 @@
#nullable disable #nullable disable
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using JetBrains.Annotations; using JetBrains.Annotations;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Rulesets.Taiko.Skinning.Default;
@ -37,7 +35,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
private bool validActionPressed; private bool validActionPressed;
private bool pressHandledThisFrame; private double? lastPressHandleTime;
private readonly Bindable<HitType> type = new Bindable<HitType>(); private readonly Bindable<HitType> type = new Bindable<HitType>();
@ -78,7 +76,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
HitActions = null; HitActions = null;
HitAction = null; HitAction = null;
validActionPressed = pressHandledThisFrame = false; validActionPressed = false;
lastPressHandleTime = null;
} }
private void updateActionsFromType() 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.CentreHit), _ => new CentreHitCirclePiece(), confineMode: ConfineMode.ScaleToFit)
: new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.RimHit), _ => new RimHitCirclePiece(), 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) protected override void CheckForResult(bool userTriggered, double timeOffset)
{ {
Debug.Assert(HitObject.HitWindows != null); Debug.Assert(HitObject.HitWindows != null);
@ -150,7 +115,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public override bool OnPressed(KeyBindingPressEvent<TaikoAction> e) public override bool OnPressed(KeyBindingPressEvent<TaikoAction> e)
{ {
if (pressHandledThisFrame) if (lastPressHandleTime == Time.Current)
return true; return true;
if (Judged) if (Judged)
return false; 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 // 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 // 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; return result;
} }
@ -175,15 +140,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
base.OnReleased(e); 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) protected override void UpdateHitStateTransforms(ArmedState state)
{ {
Debug.Assert(HitObject.HitWindows != null); Debug.Assert(HitObject.HitWindows != null);
@ -231,7 +187,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
/// The lenience for the second key press. /// The lenience for the second key press.
/// This does not adjust by map difficulty in ScoreV2 yet. /// This does not adjust by map difficulty in ScoreV2 yet.
/// </summary> /// </summary>
private const double second_hit_window = 30; public const double SECOND_HIT_WINDOW = 30;
public StrongNestedHit() public StrongNestedHit()
: this(null) : this(null)
@ -259,12 +215,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
if (!userTriggered) 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); ApplyResult(r => r.Type = r.Judgement.MinResult);
return; 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); 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. // 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. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Judgements;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables namespace osu.Game.Rulesets.Taiko.Objects.Drawables
@ -16,5 +17,17 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
: base(nestedHit) : 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) if (Time.Current < HitObject.StartTime)
return false; return false;
if (AllJudged)
return false;
bool isCentre = e.Action == TaikoAction.LeftCentre || e.Action == TaikoAction.RightCentre; bool isCentre = e.Action == TaikoAction.LeftCentre || e.Action == TaikoAction.RightCentre;
// Ensure alternating centre and rim hits // Ensure alternating centre and rim hits

View File

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

View File

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

View File

@ -9,6 +9,8 @@ namespace osu.Game.Rulesets.Taiko.Objects
{ {
public class DrumRollTick : TaikoStrongableHitObject public class DrumRollTick : TaikoStrongableHitObject
{ {
public readonly DrumRoll Parent;
/// <summary> /// <summary>
/// Whether this is the first (initial) tick of the slider. /// Whether this is the first (initial) tick of the slider.
/// </summary> /// </summary>
@ -25,6 +27,11 @@ namespace osu.Game.Rulesets.Taiko.Objects
/// </summary> /// </summary>
public double HitWindow => TickSpacing / 2; public double HitWindow => TickSpacing / 2;
public DrumRollTick(DrumRoll parent)
{
Parent = parent;
}
public override Judgement CreateJudgement() => new TaikoDrumRollTickJudgement(); public override Judgement CreateJudgement() => new TaikoDrumRollTickJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty; 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. // the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield.
return Drawable.Empty().With(d => d.Expire()); return Drawable.Empty().With(d => d.Expire());
case TaikoSkinComponents.DrumSamplePlayer:
return new ArgonDrumSamplePlayer();
case TaikoSkinComponents.TaikoExplosionGreat: case TaikoSkinComponents.TaikoExplosionGreat:
case TaikoSkinComponents.TaikoExplosionMiss: case TaikoSkinComponents.TaikoExplosionMiss:
case TaikoSkinComponents.TaikoExplosionOk: 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. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -8,6 +9,7 @@ using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Primitives;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Objects; 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 Bindable<int> currentCombo { get; } = new BindableInt();
private int animationFrame; private int animationFrame;
private double beatLength;
// required for editor blueprints (not sure why these circle pieces are zero size). // required for editor blueprints (not sure why these circle pieces are zero size).
public override Quad ScreenSpaceDrawQuad => backgroundLayer.ScreenSpaceDrawQuad; public override Quad ScreenSpaceDrawQuad => backgroundLayer.ScreenSpaceDrawQuad;
private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT;
public LegacyCirclePiece() public LegacyCirclePiece()
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
@ -39,11 +42,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private GameplayState? gameplayState { get; set; } private GameplayState? gameplayState { get; set; }
[Resolved(canBeNull: true)]
private IBeatSyncProvider? beatSyncProvider { get; set; }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(ISkinSource skin, DrawableHitObject drawableHitObject) private void load(ISkinSource skin, DrawableHitObject drawableHitObject, IBeatSyncProvider? beatSyncProvider)
{ {
Drawable? getDrawableFor(string lookup) Drawable? getDrawableFor(string lookup)
{ {
@ -64,6 +64,11 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
if (foregroundLayer != null) if (foregroundLayer != null)
AddInternal(foregroundLayer); 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). // 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. // For now just stop at first frame for sanity.
foreach (var c in InternalChildren) foreach (var c in InternalChildren)
@ -115,14 +120,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
return; return;
} }
if (beatSyncProvider?.ControlPoints != null) animationFrame = Math.Abs(Time.Current - timingPoint.Time) % ((timingPoint.BeatLength * 2) / multiplier) >= timingPoint.BeatLength / multiplier ? 0 : 1;
{ animatableForegroundLayer.GotoFrame(animationFrame);
beatLength = beatSyncProvider.ControlPoints.TimingPointAt(Time.Current).BeatLength;
animationFrame = Time.Current % ((beatLength * 2) / multiplier) >= beatLength / multiplier ? 0 : 1;
animatableForegroundLayer.GotoFrame(animationFrame);
}
} }
private Color4 accentColour; private Color4 accentColour;

View File

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

View File

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

View File

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

View File

@ -1,57 +1,151 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
namespace osu.Game.Rulesets.Taiko.UI namespace osu.Game.Rulesets.Taiko.UI
{ {
internal partial class DrumSamplePlayer : CompositeDrawable, IKeyBindingHandler<TaikoAction> internal partial class DrumSamplePlayer : CompositeDrawable, IKeyBindingHandler<TaikoAction>
{ {
private readonly DrumSampleTriggerSource leftRimSampleTriggerSource; private DrumSampleTriggerSource leftCentreTrigger = null!;
private readonly DrumSampleTriggerSource leftCentreSampleTriggerSource; private DrumSampleTriggerSource rightCentreTrigger = null!;
private readonly DrumSampleTriggerSource rightCentreSampleTriggerSource; private DrumSampleTriggerSource leftRimTrigger = null!;
private readonly DrumSampleTriggerSource rightRimSampleTriggerSource; 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[] InternalChildren = new Drawable[]
{ {
leftRimSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), leftCentreTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Left),
leftCentreSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), rightCentreTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Right),
rightCentreSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), leftRimTrigger = CreateTriggerSource(hitObjectContainer, SampleBalance.Left),
rightRimSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), 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) 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) switch (e.Action)
{ {
case TaikoAction.LeftRim:
leftRimSampleTriggerSource.Play(HitType.Rim);
break;
case TaikoAction.LeftCentre: case TaikoAction.LeftCentre:
leftCentreSampleTriggerSource.Play(HitType.Centre); hitType = HitType.Centre;
triggerSource = strong ? strongCentreTrigger : leftCentreTrigger;
break; break;
case TaikoAction.RightCentre: 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; break;
case TaikoAction.RightRim: case TaikoAction.RightRim:
rightRimSampleTriggerSource.Play(HitType.Rim); hitType = HitType.Rim;
triggerSource = strong ? strongRimTrigger : rightRimTrigger;
break; 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; 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) public void OnReleased(KeyBindingReleaseEvent<TaikoAction> e)
{ {
} }

View File

@ -2,30 +2,74 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Linq; using osu.Framework.Utils;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.UI namespace osu.Game.Rulesets.Taiko.UI
{ {
public partial class DrumSampleTriggerSource : GameplaySampleTriggerSource 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) : 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; 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"); 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, RelativeSizeAxes = Axes.Both,
}, },
drumRollHitContainer.CreateProxy(), 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. // 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. // but is proxied below everything to not cover visual effects such as hit explosions.
inputDrum, inputDrum,

View File

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

View File

@ -64,7 +64,7 @@ namespace osu.Game.Tests.Beatmaps
[Test] [Test]
public void TestCachedRetrievalWithFiles() => AddStep("run 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)); Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0));
@ -90,7 +90,7 @@ namespace osu.Game.Tests.Beatmaps
[Test] [Test]
public void TestForcedRefetchRetrievalWithFiles() => AddStep("run 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)); Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0));
@ -102,7 +102,7 @@ namespace osu.Game.Tests.Beatmaps
[Test] [Test]
public void TestSavePreservesCollections() => AddStep("run 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); 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" 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("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(5, result.Links.Count); Assert.AreEqual(4, result.Links.Count);
Link f = result.Links.Find(l => l.Url == "https://dev.ppy.sh/wiki/wiki links"); Link f = result.Links.Find(l => l.Url == "https://dev.ppy.sh/wiki/wiki links");
Assert.That(f, Is.Not.Null); Assert.That(f, Is.Not.Null);
@ -500,27 +500,22 @@ namespace osu.Game.Tests.Chat
Assert.That(f, Is.Not.Null); Assert.That(f, Is.Not.Null);
Assert.AreEqual(78, f.Index); Assert.AreEqual(78, f.Index);
Assert.AreEqual(18, f.Length); 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] [Test]
public void TestEmoji() 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" }); 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\0\0\0<--This is an emoji,There are more:\0\0\0\0\0\0,\0\0\0", result.DisplayContent); 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, 4); Assert.AreEqual(result.Links.Count, 0);
Assert.AreEqual(result.Links[0].Index, 11); }
Assert.AreEqual(result.Links[1].Index, 49);
Assert.AreEqual(result.Links[2].Index, 52); [Test]
Assert.AreEqual(result.Links[3].Index, 56); public void TestEmojiWithSuccessiveParens()
Assert.AreEqual(result.Links[0].Url, "\uD83D\uDE12"); {
Assert.AreEqual(result.Links[1].Url, "\uD83D\uDE10"); Message result = MessageFormatter.FormatMessage(new Message { Content = "\uD83D\uDE10(let's hope this doesn't accidentally turn into a link)" });
Assert.AreEqual(result.Links[2].Url, "\uD83D\uDE00"); Assert.AreEqual("[emoji](let's hope this doesn't accidentally turn into a link)", result.DisplayContent);
Assert.AreEqual(result.Links[3].Url, "\uD83D\uDE20"); Assert.AreEqual(result.Links.Count, 0);
} }
[Test] [Test]

View File

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

View File

@ -18,6 +18,7 @@ using osu.Game.Extensions;
using osu.Game.Models; using osu.Game.Models;
using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Tests.Resources; using osu.Game.Tests.Resources;
using Realms; using Realms;
using SharpCompress.Archives; using SharpCompress.Archives;
@ -416,6 +417,108 @@ 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()
{
RunTestWithRealmAsync(async (realm, storage) =>
{
var importer = new BeatmapImporter(storage, realm);
using var store = new RealmRulesetStore(realm, storage);
string? temp = TestResources.GetTestBeatmapForImport();
var imported = await LoadOszIntoStore(importer, realm.Realm);
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(r =>
{
BeatmapInfo beatmap = imported.Beatmaps.First();
beatmap.Hash = "new_hash";
beatmap.ResetOnlineInfo();
beatmap.UpdateLocalScores(r);
});
Assert.That(!imported.Beatmaps.First().Scores.Any());
var importedSecondTime = await importer.Import(new ImportTask(temp));
EnsureLoaded(realm.Realm);
// check the newly "imported" beatmap is not the original.
Assert.NotNull(importedSecondTime);
Debug.Assert(importedSecondTime != null);
Assert.That(imported.ID != importedSecondTime.ID);
var importedFirstTimeBeatmap = imported.Beatmaps.First();
var importedSecondTimeBeatmap = importedSecondTime.PerformRead(s => s.Beatmaps.First());
Assert.That(importedFirstTimeBeatmap.ID != importedSecondTimeBeatmap.ID);
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));
});
}
[Test] [Test]
public void TestImportThenImportWithChangedFile() public void TestImportThenImportWithChangedFile()
{ {
@ -1074,18 +1177,16 @@ namespace osu.Game.Tests.Database
Assert.IsTrue(realm.All<BeatmapSetInfo>().First(_ => true).DeletePending); Assert.IsTrue(realm.All<BeatmapSetInfo>().First(_ => true).DeletePending);
} }
private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) =>
{ realm.WriteAsync(() =>
// TODO: reimplement when we have score support in realm. {
// return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo realm.Add(new ScoreInfo
// { {
// OnlineID = 2, OnlineID = 2,
// Beatmap = beatmap, BeatmapInfo = beatmap,
// BeatmapInfoID = beatmap.ID BeatmapHash = beatmap.Hash
// }, new ImportScoreTest.TestArchiveReader()); });
});
return Task.CompletedTask;
}
private static void checkBeatmapSetCount(Realm realm, int expected, bool includeDeletePending = false) private static void checkBeatmapSetCount(Realm realm, int expected, bool includeDeletePending = false)
{ {

View File

@ -323,7 +323,7 @@ namespace osu.Game.Tests.Database
var beatmapInfo = s.Beatmaps.First(b => b.File?.Filename != removedFilename); var beatmapInfo = s.Beatmaps.First(b => b.File?.Filename != removedFilename);
scoreTargetBeatmapHash = beatmapInfo.Hash; scoreTargetBeatmapHash = beatmapInfo.Hash;
s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser())); s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser()));
}); });
realm.Run(r => r.Refresh()); realm.Run(r => r.Refresh());
@ -347,6 +347,73 @@ namespace osu.Game.Tests.Database
}); });
} }
[Test]
public void TestDanglingScoreTransferred()
{
RunTestWithRealmAsync(async (realm, storage) =>
{
var importer = new BeatmapImporter(storage, realm);
using var rulesets = new RealmRulesetStore(realm, storage);
using var __ = getBeatmapArchive(out string pathOriginal);
using var _ = getBeatmapArchive(out string pathOnlineCopy);
var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal));
Assert.That(importBeforeUpdate, Is.Not.Null);
Debug.Assert(importBeforeUpdate != null);
string scoreTargetBeatmapHash = string.Empty;
// set a score on the beatmap
importBeforeUpdate.PerformWrite(s =>
{
var beatmapInfo = s.Beatmaps.First();
scoreTargetBeatmapHash = beatmapInfo.Hash;
s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser()));
});
// locally modify beatmap
const string new_beatmap_hash = "new_hash";
importBeforeUpdate.PerformWrite(s =>
{
var beatmapInfo = s.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash);
beatmapInfo.Hash = new_beatmap_hash;
beatmapInfo.ResetOnlineInfo();
beatmapInfo.UpdateLocalScores(s.Realm!);
});
realm.Run(r => r.Refresh());
// making changes to a beatmap doesn't remove the score from realm, but should disassociate the beatmap.
checkCount<ScoreInfo>(realm, 1);
Assert.That(realm.Run(r => r.All<ScoreInfo>().First().BeatmapInfo), Is.Null);
// reimport the original beatmap before local modifications
var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOnlineCopy), importBeforeUpdate.Value);
Assert.That(importAfterUpdate, Is.Not.Null);
Debug.Assert(importAfterUpdate != null);
realm.Run(r => r.Refresh());
// both original and locally modified versions present
checkCount<BeatmapInfo>(realm, count_beatmaps + 1);
checkCount<BeatmapMetadata>(realm, count_beatmaps + 1);
checkCount<BeatmapSetInfo>(realm, 2);
// score is preserved
checkCount<ScoreInfo>(realm, 1);
// score is transferred to new beatmap
Assert.That(importBeforeUpdate.Value.Beatmaps.First(b => b.Hash == new_beatmap_hash).Scores, Has.Count.EqualTo(0));
Assert.That(importAfterUpdate.Value.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash).Scores, Has.Count.EqualTo(1));
});
}
[Test] [Test]
public void TestScoreLostOnModification() public void TestScoreLostOnModification()
{ {
@ -368,7 +435,7 @@ namespace osu.Game.Tests.Database
{ {
var beatmapInfo = s.Beatmaps.Last(); var beatmapInfo = s.Beatmaps.Last();
scoreTargetFilename = beatmapInfo.File?.Filename; scoreTargetFilename = beatmapInfo.File?.Filename;
s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser())); s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser()));
}); });
realm.Run(r => r.Refresh()); realm.Run(r => r.Refresh());
@ -461,7 +528,7 @@ namespace osu.Game.Tests.Database
importBeforeUpdate.PerformWrite(s => importBeforeUpdate.PerformWrite(s =>
{ {
var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection")); var beatmapCollection = s.Realm!.Add(new BeatmapCollection("test collection"));
beatmapsToAddToCollection = s.Beatmaps.Count - (allOriginalBeatmapsInCollection ? 0 : 1); beatmapsToAddToCollection = s.Beatmaps.Count - (allOriginalBeatmapsInCollection ? 0 : 1);
for (int i = 0; i < beatmapsToAddToCollection; i++) for (int i = 0; i < beatmapsToAddToCollection; i++)
@ -476,7 +543,7 @@ namespace osu.Game.Tests.Database
importAfterUpdate.PerformRead(updated => importAfterUpdate.PerformRead(updated =>
{ {
updated.Realm.Refresh(); updated.Realm!.Refresh();
string[] hashes = updated.Realm.All<BeatmapCollection>().Single().BeatmapMD5Hashes.ToArray(); string[] hashes = updated.Realm.All<BeatmapCollection>().Single().BeatmapMD5Hashes.ToArray();
@ -526,7 +593,7 @@ namespace osu.Game.Tests.Database
importBeforeUpdate.PerformWrite(s => importBeforeUpdate.PerformWrite(s =>
{ {
var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection")); var beatmapCollection = s.Realm!.Add(new BeatmapCollection("test collection"));
originalHash = s.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash; originalHash = s.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash;
beatmapCollection.BeatmapMD5Hashes.Add(originalHash); beatmapCollection.BeatmapMD5Hashes.Add(originalHash);
@ -540,7 +607,7 @@ namespace osu.Game.Tests.Database
importAfterUpdate.PerformRead(updated => importAfterUpdate.PerformRead(updated =>
{ {
updated.Realm.Refresh(); updated.Realm!.Refresh();
string[] hashes = updated.Realm.All<BeatmapCollection>().Single().BeatmapMD5Hashes.ToArray(); string[] hashes = updated.Realm.All<BeatmapCollection>().Single().BeatmapMD5Hashes.ToArray();
string updatedHash = updated.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash; string updatedHash = updated.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash;

View File

@ -128,7 +128,7 @@ namespace osu.Game.Tests.Database
realm.RegisterCustomSubscription(r => realm.RegisterCustomSubscription(r =>
{ {
var subscription = r.All<BeatmapInfo>().QueryAsyncWithNotifications((_, _, _) => var subscription = r.All<BeatmapInfo>().QueryAsyncWithNotifications((_, _) =>
{ {
realm.Run(_ => realm.Run(_ =>
{ {

View File

@ -355,7 +355,7 @@ namespace osu.Game.Tests.Database
return null; return null;
}); });
void gotChange(IRealmCollection<BeatmapInfo> sender, ChangeSet changes, Exception error) void gotChange(IRealmCollection<BeatmapInfo> sender, ChangeSet? changes)
{ {
changesTriggered++; changesTriggered++;
} }

View File

@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@ -54,7 +53,7 @@ namespace osu.Game.Tests.Database
registration.Dispose(); registration.Dispose();
}); });
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error) void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes)
{ {
lastChanges = changes; lastChanges = changes;
@ -92,7 +91,7 @@ namespace osu.Game.Tests.Database
registration.Dispose(); registration.Dispose();
}); });
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error) => lastChanges = changes; void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes) => lastChanges = changes;
} }
[Test] [Test]
@ -185,7 +184,7 @@ namespace osu.Game.Tests.Database
} }
}); });
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error) void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes)
{ {
if (changes == null) if (changes == null)
resolvedItems = sender; resolvedItems = sender;

View File

@ -76,12 +76,12 @@ namespace osu.Game.Tests.Database
Available = true, Available = true,
})); }));
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore // Availability is updated on construction of a RealmRulesetStore
var _ = new RealmRulesetStore(realm, storage); var _ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.False); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
}); });
} }
@ -101,18 +101,18 @@ namespace osu.Game.Tests.Database
Available = true, Available = true,
})); }));
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore // Availability is updated on construction of a RealmRulesetStore
var _ = new RealmRulesetStore(realm, storage); var _ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.False); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
// Simulate the ruleset getting updated // Simulate the ruleset getting updated
LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION;
var __ = new RealmRulesetStore(realm, storage); var __ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
}); });
} }

View File

@ -104,7 +104,7 @@ namespace osu.Game.Tests.Database
realm.Run(innerRealm => realm.Run(innerRealm =>
{ {
var binding = innerRealm.ResolveReference(tsr); var binding = innerRealm.ResolveReference(tsr)!;
innerRealm.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace)); innerRealm.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace));
}); });

View File

@ -0,0 +1,183 @@
// 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.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Editing.Checks
{
public class CheckBreaksTest
{
private CheckBreaks check = null!;
[SetUp]
public void Setup()
{
check = new CheckBreaks();
}
[Test]
public void TestBreakTooShort()
{
var beatmap = new Beatmap<HitObject>
{
Breaks = new List<BreakPeriod>
{
new BreakPeriod(0, 649)
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckBreaks.IssueTemplateTooShort);
}
[Test]
public void TestBreakStartsEarly()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 1_200 }
},
Breaks = new List<BreakPeriod>
{
new BreakPeriod(100, 751)
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckBreaks.IssueTemplateEarlyStart);
}
[Test]
public void TestBreakEndsLate()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 1_298 }
},
Breaks = new List<BreakPeriod>
{
new BreakPeriod(200, 850)
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckBreaks.IssueTemplateLateEnd);
}
[Test]
public void TestBreakAfterLastObjectStartsEarly()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 1200 }
},
Breaks = new List<BreakPeriod>
{
new BreakPeriod(1398, 2300)
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckBreaks.IssueTemplateEarlyStart);
}
[Test]
public void TestBreakBeforeFirstObjectEndsLate()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 1100 },
new HitCircle { StartTime = 1500 }
},
Breaks = new List<BreakPeriod>
{
new BreakPeriod(0, 652)
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckBreaks.IssueTemplateLateEnd);
}
[Test]
public void TestBreakMultipleObjectsEarly()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 1_297 },
new HitCircle { StartTime = 1_298 }
},
Breaks = new List<BreakPeriod>
{
new BreakPeriod(200, 850)
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckBreaks.IssueTemplateLateEnd);
}
[Test]
public void TestBreaksCorrect()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 1_300 }
},
Breaks = new List<BreakPeriod>
{
new BreakPeriod(200, 850)
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Is.Empty);
}
}
}

View File

@ -0,0 +1,85 @@
// 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.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Editing.Checks
{
public class CheckDrainLengthTest
{
private CheckDrainLength check = null!;
[SetUp]
public void Setup()
{
check = new CheckDrainLength();
}
[Test]
public void TestDrainTimeShort()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 29_999 }
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckDrainLength.IssueTemplateTooShort);
}
[Test]
public void TestDrainTimeBreak()
{
var beatmap = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 40_000 }
},
Breaks = new List<BreakPeriod>
{
new BreakPeriod(10_000, 21_000)
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckDrainLength.IssueTemplateTooShort);
}
[Test]
public void TestDrainTimeCorrect()
{
var hitObjects = new List<HitObject>();
for (int i = 0; i <= 30; ++i)
hitObjects.Add(new HitCircle { StartTime = 1000 * i });
var beatmap = new Beatmap<HitObject> { HitObjects = hitObjects };
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
var issues = check.Run(context).ToList();
Assert.That(issues, Is.Empty);
}
}
}

View File

@ -3,6 +3,7 @@
#nullable disable #nullable disable
using System;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing; using osu.Framework.Testing;
@ -80,7 +81,9 @@ namespace osu.Game.Tests.Gameplay
{ {
TestLifetimeEntry entry = null; TestLifetimeEntry entry = null;
AddStep("Create entry", () => entry = new TestLifetimeEntry(new HitObject()) { LifetimeStart = 1 }); AddStep("Create entry", () => entry = new TestLifetimeEntry(new HitObject()) { LifetimeStart = 1 });
assertJudged(() => entry, false);
AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty())); AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()));
assertJudged(() => entry, false);
AddAssert("Lifetime is updated", () => entry.LifetimeStart == -TestLifetimeEntry.INITIAL_LIFETIME_OFFSET); AddAssert("Lifetime is updated", () => entry.LifetimeStart == -TestLifetimeEntry.INITIAL_LIFETIME_OFFSET);
TestDrawableHitObject dho = null; TestDrawableHitObject dho = null;
@ -91,6 +94,7 @@ namespace osu.Game.Tests.Gameplay
}); });
AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty())); AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()));
AddAssert("Lifetime is correct", () => dho.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY && entry.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY); AddAssert("Lifetime is correct", () => dho.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY && entry.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY);
assertJudged(() => entry, false);
} }
[Test] [Test]
@ -138,6 +142,29 @@ namespace osu.Game.Tests.Gameplay
AddAssert("DHO state is correct", () => dho.State.Value == ArmedState.Miss); AddAssert("DHO state is correct", () => dho.State.Value == ArmedState.Miss);
} }
[Test]
public void TestJudgedStateThroughLifetime()
{
TestDrawableHitObject dho = null;
HitObjectLifetimeEntry lifetimeEntry = null;
AddStep("Create lifetime entry", () => lifetimeEntry = new HitObjectLifetimeEntry(new HitObject { StartTime = Time.Current }));
assertJudged(() => lifetimeEntry, false);
AddStep("Create DHO and apply entry", () =>
{
Child = dho = new TestDrawableHitObject();
dho.Apply(lifetimeEntry);
});
assertJudged(() => lifetimeEntry, false);
AddStep("Apply result", () => dho.MissForcefully());
assertJudged(() => lifetimeEntry, true);
}
[Test] [Test]
public void TestResultSetBeforeLoadComplete() public void TestResultSetBeforeLoadComplete()
{ {
@ -154,15 +181,20 @@ namespace osu.Game.Tests.Gameplay
} }
}; };
}); });
assertJudged(() => lifetimeEntry, true);
AddStep("Create DHO and apply entry", () => AddStep("Create DHO and apply entry", () =>
{ {
dho = new TestDrawableHitObject(); dho = new TestDrawableHitObject();
dho.Apply(lifetimeEntry); dho.Apply(lifetimeEntry);
Child = dho; Child = dho;
}); });
assertJudged(() => lifetimeEntry, true);
AddAssert("DHO state is correct", () => dho.State.Value, () => Is.EqualTo(ArmedState.Hit)); AddAssert("DHO state is correct", () => dho.State.Value, () => Is.EqualTo(ArmedState.Hit));
} }
private void assertJudged(Func<HitObjectLifetimeEntry> entry, bool val) =>
AddAssert(val ? "Is judged" : "Not judged", () => entry().Judged, () => Is.EqualTo(val));
private partial class TestDrawableHitObject : DrawableHitObject private partial class TestDrawableHitObject : DrawableHitObject
{ {
public const double INITIAL_LIFETIME_OFFSET = 100; public const double INITIAL_LIFETIME_OFFSET = 100;

View File

@ -87,10 +87,10 @@ namespace osu.Game.Tests.Models
var mock = new Mock<IScoreInfo>(); var mock = new Mock<IScoreInfo>();
mock.Setup(m => m.User).Returns(new APIUser { Username = "user" }); // TODO: temporary. mock.Setup(m => m.User).Returns(new APIUser { Username = "user" }); // TODO: temporary.
mock.Setup(m => m.Beatmap.Metadata.Artist).Returns("artist"); mock.Setup(m => m.Beatmap!.Metadata.Artist).Returns("artist");
mock.Setup(m => m.Beatmap.Metadata.Title).Returns("title"); mock.Setup(m => m.Beatmap!.Metadata.Title).Returns("title");
mock.Setup(m => m.Beatmap.Metadata.Author.Username).Returns("author"); mock.Setup(m => m.Beatmap!.Metadata.Author.Username).Returns("author");
mock.Setup(m => m.Beatmap.DifficultyName).Returns("difficulty"); mock.Setup(m => m.Beatmap!.DifficultyName).Returns("difficulty");
Assert.That(mock.Object.GetDisplayString(), Is.EqualTo("user playing artist - title (author) [difficulty]")); Assert.That(mock.Object.GetDisplayString(), Is.EqualTo("user playing artist - title (author) [difficulty]"));
} }

View File

@ -33,7 +33,7 @@ namespace osu.Game.Tests.NonVisual.Filtering
Artist = "The Artist", Artist = "The Artist",
ArtistUnicode = "check unicode too", ArtistUnicode = "check unicode too",
Title = "Title goes here", Title = "Title goes here",
TitleUnicode = "Title goes here", TitleUnicode = "TitleUnicode goes here",
Author = { Username = "The Author" }, Author = { Username = "The Author" },
Source = "unit tests", Source = "unit tests",
Tags = "look for tags too", Tags = "look for tags too",
@ -159,6 +159,34 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(filtered, carouselItem.Filtered.Value); Assert.AreEqual(filtered, carouselItem.Filtered.Value);
} }
[Test]
[TestCase("\"artist\"", false)]
[TestCase("\"arti\"", true)]
[TestCase("\"artist title author\"", true)]
[TestCase("\"artist\" \"title\" \"author\"", false)]
[TestCase("\"an artist\"", true)]
[TestCase("\"tags too\"", false)]
[TestCase("\"tags to\"", true)]
[TestCase("\"version\"", false)]
[TestCase("\"an auteur\"", true)]
[TestCase("\"Artist\"!", true)]
[TestCase("\"The Artist\"!", false)]
[TestCase("\"the artist\"!", false)]
[TestCase("\"\\\"", true)] // nasty case, covers properly escaping user input in underlying regex.
public void TestCriteriaMatchingExactTerms(string terms, bool filtered)
{
var exampleBeatmapInfo = getExampleBeatmap();
var criteria = new FilterCriteria
{
Ruleset = new RulesetInfo { OnlineID = 6 },
AllowConvertedBeatmaps = true,
SearchText = terms
};
var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
carouselItem.Filter(criteria);
Assert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test] [Test]
[TestCase("", false)] [TestCase("", false)]
[TestCase("The", false)] [TestCase("The", false)]
@ -179,6 +207,27 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(filtered, carouselItem.Filtered.Value); Assert.AreEqual(filtered, carouselItem.Filtered.Value);
} }
[Test]
[TestCase("", false)]
[TestCase("Goes", false)]
[TestCase("GOES", false)]
[TestCase("goes", false)]
[TestCase("title goes", false)]
[TestCase("title goes AND then something else", true)]
[TestCase("titleunicode", false)]
[TestCase("unknown", true)]
public void TestCriteriaMatchingTitle(string titleName, bool filtered)
{
var exampleBeatmapInfo = getExampleBeatmap();
var criteria = new FilterCriteria
{
Title = new FilterCriteria.OptionalTextFilter { SearchTerm = titleName }
};
var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
carouselItem.Filter(criteria);
Assert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test] [Test]
[TestCase("", false)] [TestCase("", false)]
[TestCase("The", false)] [TestCase("The", false)]
@ -188,6 +237,9 @@ namespace osu.Game.Tests.NonVisual.Filtering
[TestCase("the artist AND then something else", true)] [TestCase("the artist AND then something else", true)]
[TestCase("unicode too", false)] [TestCase("unicode too", false)]
[TestCase("unknown", true)] [TestCase("unknown", true)]
[TestCase("\"Artist\"!", true)]
[TestCase("\"The Artist\"!", false)]
[TestCase("\"the artist\"!", false)]
public void TestCriteriaMatchingArtist(string artistName, bool filtered) public void TestCriteriaMatchingArtist(string artistName, bool filtered)
{ {
var exampleBeatmapInfo = getExampleBeatmap(); var exampleBeatmapInfo = getExampleBeatmap();

View File

@ -23,6 +23,63 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(4, filterCriteria.SearchTerms.Length); Assert.AreEqual(4, filterCriteria.SearchTerms.Length);
} }
[Test]
public void TestApplyQueriesBareWordsWithExactMatch()
{
const string query = "looking for \"a beatmap\"! like \"this\"";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("looking for \"a beatmap\"! like \"this\"", filterCriteria.SearchText);
Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("a beatmap"));
Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
Assert.That(filterCriteria.SearchTerms[1].SearchTerm, Is.EqualTo("this"));
Assert.That(filterCriteria.SearchTerms[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
Assert.That(filterCriteria.SearchTerms[2].SearchTerm, Is.EqualTo("looking"));
Assert.That(filterCriteria.SearchTerms[2].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
Assert.That(filterCriteria.SearchTerms[3].SearchTerm, Is.EqualTo("for"));
Assert.That(filterCriteria.SearchTerms[3].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
Assert.That(filterCriteria.SearchTerms[4].SearchTerm, Is.EqualTo("like"));
Assert.That(filterCriteria.SearchTerms[4].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
}
[Test]
public void TestApplyFullPhraseQueryWithExclamationPointInTerm()
{
const string query = "looking for \"circles!\"!";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("looking for \"circles!\"!", filterCriteria.SearchText);
Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("circles!"));
Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
Assert.That(filterCriteria.SearchTerms[1].SearchTerm, Is.EqualTo("looking"));
Assert.That(filterCriteria.SearchTerms[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
Assert.That(filterCriteria.SearchTerms[2].SearchTerm, Is.EqualTo("for"));
Assert.That(filterCriteria.SearchTerms[2].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
}
[Test]
public void TestApplyBrokenFullPhraseQuery()
{
const string query = "\"!";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("\"!", filterCriteria.SearchText);
Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("!"));
Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
}
/* /*
* The following tests have been written a bit strangely (they don't check exact * The following tests have been written a bit strangely (they don't check exact
* bound equality with what the filter says). * bound equality with what the filter says).
@ -226,6 +283,18 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm); Assert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm);
} }
[Test]
public void TestApplyTitleQueries()
{
const string query = "find me songs with title=\"a certain title\" please";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim());
Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
Assert.AreEqual("a certain title", filterCriteria.Title.SearchTerm);
Assert.That(filterCriteria.Title.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
}
[Test] [Test]
public void TestApplyArtistQueries() public void TestApplyArtistQueries()
{ {
@ -235,6 +304,7 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim()); Assert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim());
Assert.AreEqual(5, filterCriteria.SearchTerms.Length); Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm); Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm);
Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
} }
[Test] [Test]
@ -246,6 +316,19 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("really like yes", filterCriteria.SearchText.Trim()); Assert.AreEqual("really like yes", filterCriteria.SearchText.Trim());
Assert.AreEqual(3, filterCriteria.SearchTerms.Length); Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm); Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm);
Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
}
[Test]
public void TestApplyArtistQueriesWithSpacesFullPhrase()
{
const string query = "artist=\"The Only One\"!";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.That(filterCriteria.SearchText.Trim(), Is.Empty);
Assert.AreEqual(0, filterCriteria.SearchTerms.Length);
Assert.AreEqual("The Only One", filterCriteria.Artist.SearchTerm);
Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
} }
[Test] [Test]

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