Reconcile 和解通用mod

This commit is contained in:
LA
2026-01-31 16:57:00 +08:00
parent 8b64a0dca1
commit 5bc1e02da5
8 changed files with 309 additions and 1 deletions

View File

@@ -118,6 +118,7 @@ namespace osu.Game.Rulesets.Catch
return new Mod[]
{
new ModNiceBPM(),
new ModReconcile(),
new UniversalLoopPlayClip(),
};

View File

@@ -262,6 +262,7 @@ namespace osu.Game.Rulesets.Mania
new ManiaModEz2Settings(),
new ManiaModCleanColumn(),
new ManiaModNiceBPM(),
new ManiaModReconcile(),
new ManiaModSpaceBody(),
new ManiaModLoopPlayClip(),
new ManiaModSRAdjust(),

View File

@@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Mania.Mods.LAsMods
// 基类中未包含的额外信息
// Seed 可能为 null
yield return ((LocalisableString)"Seed", Seed.Value.HasValue ? Seed.Value.Value.ToString() : "None");
yield return ((LocalisableString)"Seed", Seed.Value?.ToString() ?? "None");
yield return ((LocalisableString)"Randomize Columns", (Rand.Value ? "On" : "Off"));
yield return ((LocalisableString)"Mirror", (Mirror.Value ? "On" : "Off"));
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.LAsEzExtensions.Mods;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
public class ManiaModReconcile : ModReconcile
{
}
}

View File

@@ -166,6 +166,7 @@ namespace osu.Game.Rulesets.Osu
return new Mod[]
{
new ModNiceBPM(),
new ModReconcile(),
new UniversalLoopPlayClip(),
};

View File

@@ -136,6 +136,7 @@ namespace osu.Game.Rulesets.Taiko
return new Mod[]
{
new ModNiceBPM(),
new ModReconcile(),
new UniversalLoopPlayClip(),
};

View File

@@ -114,6 +114,42 @@ namespace osu.Game.LAsEzExtensions.Mods
public static readonly LocalisableString RateChangeOnMiss_Label = new EzLocalisableString("Miss时的速率变化", "Rate Change On Miss");
public static readonly LocalisableString RateChangeOnMiss_Description = new EzLocalisableString("达到Miss阈值时应用的速率倍数", "Rate multiplier applied when miss threshold is reached");
#endregion
#region Reconcile
public static readonly LocalisableString Reconcile_Description = new EzLocalisableString("满足条件时暂停,可选回溯到上一个目标位置。",
"Pause when conditions are met, optionally rewinding to the previous target position.");
public static readonly LocalisableString Reconcile_EnableMiss_Label = new EzLocalisableString("启用判定计数", "Enable judgement count");
public static readonly LocalisableString Reconcile_EnableMiss_Description = new EzLocalisableString("当指定判定累计到阈值时触发", "Trigger when the selected judgement reaches the threshold.");
public static readonly LocalisableString Reconcile_MissJudgement_Label = new EzLocalisableString("判定类型", "Judgement Type");
public static readonly LocalisableString Reconcile_MissJudgement_Description = new EzLocalisableString("选择要计数的判定类型", "Select the judgement to count.");
public static readonly LocalisableString Reconcile_MissCount_Label = new EzLocalisableString("判定计数阈值", "Judgement Count Threshold");
public static readonly LocalisableString Reconcile_MissCount_Description = new EzLocalisableString("达到该数量时触发暂停", "Trigger pause when this count is reached.");
public static readonly LocalisableString Reconcile_EnableAcc_Label = new EzLocalisableString("启用Acc条件", "Enable accuracy condition");
public static readonly LocalisableString Reconcile_EnableAcc_Description = new EzLocalisableString("当Acc低于阈值时触发", "Trigger when accuracy falls below the threshold.");
public static readonly LocalisableString Reconcile_AccThreshold_Label = new EzLocalisableString("Acc阈值(%)", "Accuracy Threshold (%)");
public static readonly LocalisableString Reconcile_AccThreshold_Description = new EzLocalisableString("低于此Acc触发暂停", "Trigger pause when accuracy is below this value.");
public static readonly LocalisableString Reconcile_EnableHealth_Label = new EzLocalisableString("启用血量条件", "Enable health condition");
public static readonly LocalisableString Reconcile_EnableHealth_Description = new EzLocalisableString("当血量低于阈值时触发", "Trigger when health falls below the threshold.");
public static readonly LocalisableString Reconcile_HealthThreshold_Label = new EzLocalisableString("血量阈值(%)", "Health Threshold (%)");
public static readonly LocalisableString Reconcile_HealthThreshold_Description = new EzLocalisableString("低于此血量触发暂停", "Trigger pause when health is below this value.");
public static readonly LocalisableString Reconcile_RewindEnabled_Label = new EzLocalisableString("启用回溯", "Enable rewind");
public static readonly LocalisableString Reconcile_RewindEnabled_Description = new EzLocalisableString(
"触发后回溯到目标位置再暂停。规则:"
+ "\n判定回溯到阈值的2/3处"
+ "\nAcc回溯到阈值+(100-阈值)/3"
+ "\n血量回溯到阈值+(100-阈值)*0.8。",
"Rewind to the target position before pausing. Rules: "
+ "\nJudgement rewinds to 2/3 of the threshold; "
+ "\nAcc rewinds to threshold+(100-threshold)/3; "
+ "\nHealth rewinds to threshold+(100-threshold)*0.8.");
#endregion
}
}

View File

@@ -0,0 +1,257 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
namespace osu.Game.LAsEzExtensions.Mods
{
public class ModReconcile : Mod, IApplicableToPlayer, IApplicableToScoreProcessor, IApplicableToHealthProcessor, IUpdatableByPlayfield
{
public override string Name => "Reconcile";
public override string Acronym => "RC";
public override LocalisableString Description => EzModStrings.Reconcile_Description;
public override ModType Type => ModType.LA_Mod;
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
public override IconUsage? Icon => FontAwesome.Solid.Handshake;
[SettingSource(typeof(EzModStrings), nameof(EzModStrings.Reconcile_EnableMiss_Label), nameof(EzModStrings.Reconcile_EnableMiss_Description))]
public BindableBool EnableMissCondition { get; } = new BindableBool(false);
[SettingSource(typeof(EzModStrings), nameof(EzModStrings.Reconcile_MissJudgement_Label), nameof(EzModStrings.Reconcile_MissJudgement_Description), SettingControlType = typeof(SettingsEnumDropdown<HitResult>))]
public Bindable<HitResult> MissJudgement { get; } = new Bindable<HitResult>(HitResult.Miss);
[SettingSource(typeof(EzModStrings), nameof(EzModStrings.Reconcile_MissCount_Label), nameof(EzModStrings.Reconcile_MissCount_Description))]
public BindableNumber<int> MissCountThreshold { get; } = new BindableInt(3)
{
MinValue = 1,
MaxValue = 20,
Precision = 1
};
[SettingSource(typeof(EzModStrings), nameof(EzModStrings.Reconcile_EnableAcc_Label), nameof(EzModStrings.Reconcile_EnableAcc_Description))]
public BindableBool EnableAccCondition { get; } = new BindableBool(false);
[SettingSource(typeof(EzModStrings), nameof(EzModStrings.Reconcile_AccThreshold_Label), nameof(EzModStrings.Reconcile_AccThreshold_Description))]
public BindableNumber<double> AccThresholdPercent { get; } = new BindableDouble(94)
{
MinValue = 50,
MaxValue = 100,
Precision = 0.1
};
[SettingSource(typeof(EzModStrings), nameof(EzModStrings.Reconcile_EnableHealth_Label), nameof(EzModStrings.Reconcile_EnableHealth_Description))]
public BindableBool EnableHealthCondition { get; } = new BindableBool(true);
[SettingSource(typeof(EzModStrings), nameof(EzModStrings.Reconcile_HealthThreshold_Label), nameof(EzModStrings.Reconcile_HealthThreshold_Description))]
public BindableNumber<int> HealthThresholdPercent { get; } = new BindableInt(30)
{
MinValue = 10,
MaxValue = 90,
};
[SettingSource(typeof(EzModStrings), nameof(EzModStrings.Reconcile_RewindEnabled_Label), nameof(EzModStrings.Reconcile_RewindEnabled_Description))]
public BindableBool RewindEnabled { get; } = new BindableBool(false);
private Player? player;
private ScoreProcessor? scoreProcessor;
private HealthProcessor? healthProcessor;
private int currentJudgementCount;
private double? lastJudgementTargetTime;
private double? lastAccTargetTime;
private double? lastHealthTargetTime;
private double? cooldownUntilTime;
private const double pause_cooldown_ms = 5000;
public void ApplyToPlayer(Player player)
{
this.player = player;
player.GameplayState.PlayingState.BindValueChanged(_ =>
{
if (player.GameplayState.PlayingState.Value == LocalUserPlayingState.NotPlaying)
resetCounts();
});
}
public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
this.scoreProcessor = scoreProcessor;
scoreProcessor.NewJudgement += onNewJudgement;
scoreProcessor.JudgementReverted += onJudgementReverted;
scoreProcessor.OnResetFromReplayFrame += resetCounts;
MissJudgement.BindValueChanged(_ => resetJudgementTracking(), true);
AccThresholdPercent.BindValueChanged(_ => lastAccTargetTime = null);
}
public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank;
public void ApplyToHealthProcessor(HealthProcessor healthProcessor)
{
this.healthProcessor = healthProcessor;
}
public void Update(Playfield playfield)
{
if (player == null || scoreProcessor == null || healthProcessor == null)
return;
if (player.GameplayState.HasCompleted)
return;
if (player.GameplayState.PlayingState.Value != LocalUserPlayingState.Playing)
return;
if (!playfield.Clock.IsRunning)
return;
double currentTime = playfield.Clock.CurrentTime;
if (cooldownUntilTime is { } cooldownUntil && currentTime < cooldownUntil)
return;
var triggerState = getTriggerState(scoreProcessor, healthProcessor);
if (triggerState.ShouldTrigger)
handleTrigger(playfield, currentTime, triggerState);
}
private TriggerState getTriggerState(ScoreProcessor scoreProcessor, HealthProcessor healthProcessor)
{
bool missTriggered = EnableMissCondition.Value && MissCountThreshold.Value > 0 && currentJudgementCount >= MissCountThreshold.Value;
bool accTriggered = EnableAccCondition.Value && scoreProcessor.Accuracy.Value * 100 < AccThresholdPercent.Value;
bool healthTriggered = EnableHealthCondition.Value && healthProcessor.Health.Value * 100 < HealthThresholdPercent.Value;
return new TriggerState(missTriggered, accTriggered, healthTriggered);
}
private void handleTrigger(Playfield playfield, double currentTime, TriggerState triggerState)
{
if (player == null)
return;
if (RewindEnabled.Value)
{
double? targetTime = getRewindTargetTime(triggerState);
if (targetTime is { } target && target <= currentTime)
player.Seek(target);
}
if (!player.Pause())
return;
resetCounts();
cooldownUntilTime = currentTime + pause_cooldown_ms;
}
private void onNewJudgement(JudgementResult result)
{
if (!result.IsFinal)
return;
if (result.Type != HitResult.None && result.Type == MissJudgement.Value)
{
currentJudgementCount++;
int relaxedCount = getRelaxedMissCount();
if (currentJudgementCount == relaxedCount)
lastJudgementTargetTime = result.TimeAbsolute;
}
if (scoreProcessor != null)
{
if (scoreProcessor.Accuracy.Value * 100 >= getRelaxedAccThreshold())
lastAccTargetTime = result.TimeAbsolute;
}
if (healthProcessor != null)
{
if (healthProcessor.Health.Value * 100 >= getRelaxedHealthThreshold())
lastHealthTargetTime = result.TimeAbsolute;
}
}
private void onJudgementReverted(JudgementResult result)
{
if (!result.IsFinal)
return;
if (result.Type != HitResult.None && result.Type == MissJudgement.Value)
{
currentJudgementCount = currentJudgementCount > 0 ? currentJudgementCount - 1 : 0;
if (currentJudgementCount < getRelaxedMissCount())
lastJudgementTargetTime = null;
}
}
private void resetCounts()
{
currentJudgementCount = 0;
}
private void resetJudgementTracking()
{
currentJudgementCount = 0;
lastJudgementTargetTime = null;
}
private int getRelaxedMissCount()
{
int threshold = MissCountThreshold.Value;
return (int)System.Math.Ceiling(threshold * (2d / 3d));
}
private double getRelaxedAccThreshold()
{
double threshold = AccThresholdPercent.Value;
return threshold + (100 - threshold) / 3d;
}
private double getRelaxedHealthThreshold()
{
double threshold = HealthThresholdPercent.Value;
return threshold + (100 - threshold) * 0.8d;
}
private double? getRewindTargetTime(TriggerState triggerState)
{
double? targetTime = null;
if (triggerState.MissTriggered && lastJudgementTargetTime is { } missTarget)
targetTime = selectEarlier(targetTime, missTarget);
if (triggerState.AccTriggered && lastAccTargetTime is { } accTarget)
targetTime = selectEarlier(targetTime, accTarget);
if (triggerState.HealthTriggered && lastHealthTargetTime is { } healthTarget)
targetTime = selectEarlier(targetTime, healthTarget);
return targetTime;
static double selectEarlier(double? current, double candidate) => current.HasValue ? System.Math.Min(current.Value, candidate) : candidate;
}
private readonly record struct TriggerState(bool MissTriggered, bool AccTriggered, bool HealthTriggered)
{
public bool ShouldTrigger => MissTriggered || AccTriggered || HealthTriggered;
}
}
}