// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Reflection; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Rulesets.UI; using osu.Game.Utils; namespace osu.Game.Rulesets.Mods { /// /// The base class for gameplay modifiers. /// [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public abstract class Mod : IMod, IEquatable, IDeepCloneable { [JsonIgnore] public abstract string Name { get; } public abstract string Acronym { get; } [JsonIgnore] public virtual string ExtendedIconInformation => string.Empty; [JsonIgnore] public virtual IconUsage? Icon => null; [JsonIgnore] public virtual ModType Type => ModType.Fun; [JsonIgnore] public abstract LocalisableString Description { get; } /// /// The tooltip to display for this mod when used in a . /// /// /// Differs from , as the value of attributes (AR, CS, etc) changeable via the mod /// are displayed in the tooltip. /// [JsonIgnore] public string IconTooltip { get { string description = SettingDescription; return string.IsNullOrEmpty(description) ? Name : $"{Name} ({description})"; } } /// /// The description of editable settings of a mod to use in the . /// /// /// Parentheses are added to the tooltip, surrounding the value of this property. If this property is string.Empty, /// the tooltip will not have parentheses. /// public virtual string SettingDescription { get { var tooltipTexts = new List(); foreach ((SettingSourceAttribute attr, PropertyInfo property) in this.GetOrderedSettingsSourceProperties()) { var bindable = (IBindable)property.GetValue(this)!; string valueText; switch (bindable) { case Bindable b: valueText = b.Value ? "on" : "off"; break; default: valueText = bindable.ToString() ?? string.Empty; break; } if (!bindable.IsDefault) tooltipTexts.Add($"{attr.Label}: {valueText}"); } return string.Join(", ", tooltipTexts.Where(s => !string.IsNullOrEmpty(s))); } } /// /// The score multiplier of this mod. /// [JsonIgnore] public abstract double ScoreMultiplier { get; } /// /// Returns true if this mod is implemented (and playable). /// [JsonIgnore] public virtual bool HasImplementation => this is IApplicableMod; /// /// Whether this mod can be played by a real human user. /// Non-user-playable mods are not viable for single-player score submission. /// /// /// /// is user-playable. /// is not user-playable. /// /// [JsonIgnore] public virtual bool UserPlayable => true; /// /// Whether this mod can be specified as a "required" mod in a multiplayer context. /// /// /// /// is valid for multiplayer. /// /// is valid for multiplayer as long as it is a required mod, /// as that ensures the same duration of gameplay for all users in the room. /// /// /// is not valid for multiplayer, as it leads to varying /// gameplay duration depending on how the users in the room play. /// /// is not valid for multiplayer. /// /// [JsonIgnore] public virtual bool ValidForMultiplayer => true; /// /// Whether this mod can be specified as a "free" or "allowed" mod in a multiplayer context. /// /// /// /// is valid for multiplayer as a free mod. /// /// is not valid for multiplayer as a free mod, /// as it could to varying gameplay duration between users in the room depending on whether they picked it. /// /// is not valid for multiplayer as a free mod. /// /// [JsonIgnore] public virtual bool ValidForMultiplayerAsFreeMod => true; /// [JsonIgnore] public virtual bool AlwaysValidForSubmission => false; /// /// Whether this mod requires configuration to apply changes to the game. /// [JsonIgnore] public virtual bool RequiresConfiguration => false; /// /// Whether scores with this mod active can give performance points. /// [JsonIgnore] public virtual bool Ranked => false; /// /// The mods this mod cannot be enabled with. /// [JsonIgnore] public virtual Type[] IncompatibleMods => Array.Empty(); private IReadOnlyDictionary? settingsBacking; /// /// All settings within this mod. /// /// /// The settings are returned in ascending key order as per . /// The ordering is intentionally enforced manually, as ordering of is unspecified. /// internal IEnumerable SettingsBindables => SettingsMap.OrderBy(pair => pair.Key).Select(pair => pair.Value); /// /// Provides mapping of names to s of all settings within this mod. /// internal IReadOnlyDictionary SettingsMap => settingsBacking ??= this.GetSettingsSourceProperties() .Select(p => p.Item2) .ToDictionary(property => property.Name.ToSnakeCase(), property => (IBindable)property.GetValue(this)!); /// /// Whether all settings in this mod are set to their default state. /// public virtual bool UsesDefaultConfiguration => SettingsBindables.All(s => s.IsDefault); /// /// Creates a copy of this initialised to a default state. /// public virtual Mod DeepClone() { var result = (Mod)Activator.CreateInstance(GetType())!; result.CopyFrom(this); return result; } /// /// Copies mod setting values from into this instance, overwriting all existing settings. /// /// The mod to copy properties from. public void CopyFrom(Mod source) { if (source.GetType() != GetType()) throw new ArgumentException($"Expected mod of type {GetType()}, got {source.GetType()}.", nameof(source)); foreach (var (_, property) in this.GetSettingsSourceProperties()) { var targetBindable = (IBindable)property.GetValue(this)!; var sourceBindable = (IBindable)property.GetValue(source)!; CopyAdjustedSetting(targetBindable, sourceBindable); } } /// /// This method copies the values of all settings from that share the same names with this mod instance. /// The most frequent use of this is when switching rulesets, in order to preserve values of common settings during the switch. /// /// /// The values are copied directly, without adjusting for possibly different allowed ranges of values. /// If the value of a setting is not valid for this instance due to not falling inside of the allowed range, it will be clamped accordingly. /// /// The mod to extract settings from. public void CopyCommonSettingsFrom(Mod source) { if (source.UsesDefaultConfiguration) return; foreach (var (name, targetSetting) in SettingsMap) { if (!source.SettingsMap.TryGetValue(name, out IBindable? sourceSetting)) continue; if (sourceSetting.IsDefault) continue; var targetBindableType = targetSetting.GetType(); var sourceBindableType = sourceSetting.GetType(); // if either the target is assignable to the source or the source is assignable to the target, // then we presume that the data types contained in both bindables are compatible and we can proceed with the copy. // this handles cases like `Bindable` and `BindableInt`. if (!targetBindableType.IsAssignableFrom(sourceBindableType) && !sourceBindableType.IsAssignableFrom(targetBindableType)) continue; // TODO: special case for handling number types PropertyInfo property = targetSetting.GetType().GetProperty(nameof(Bindable.Value))!; property.SetValue(targetSetting, property.GetValue(sourceSetting)); } } /// /// When creating copies or clones of a Mod, this method will be called /// to copy explicitly adjusted user settings from . /// The base implementation will transfer the value via /// or by binding and unbinding (if is an ) /// and should be called unless replaced with custom logic. /// /// The target bindable to apply the adjustment to. /// The adjustment to apply. internal virtual void CopyAdjustedSetting(IBindable target, object source) { if (source is IBindable sourceBindable) { // copy including transfer of default values. target.BindTo(sourceBindable); target.UnbindFrom(sourceBindable); } else { if (!(target is IParseable parseable)) throw new InvalidOperationException($"Bindable type {target.GetType().ReadableName()} is not {nameof(IParseable)}."); parseable.Parse(source, CultureInfo.InvariantCulture); } } public bool Equals(IMod? other) => other is Mod them && Equals(them); public bool Equals(Mod? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; return GetType() == other.GetType() && SettingsBindables.SequenceEqual(other.SettingsBindables, ModSettingsEqualityComparer.Default); } public override int GetHashCode() { var hashCode = new HashCode(); hashCode.Add(GetType()); foreach (var setting in SettingsBindables) hashCode.Add(setting.GetUnderlyingSettingValue()); return hashCode.ToHashCode(); } /// /// Reset all custom settings for this mod back to their defaults. /// public virtual void ResetSettingsToDefaults() => CopyFrom((Mod)Activator.CreateInstance(GetType())!); private class ModSettingsEqualityComparer : IEqualityComparer { public static ModSettingsEqualityComparer Default { get; } = new ModSettingsEqualityComparer(); public bool Equals(IBindable? x, IBindable? y) { object? xValue = x?.GetUnderlyingSettingValue(); object? yValue = y?.GetUnderlyingSettingValue(); return EqualityComparer.Default.Equals(xValue, yValue); } public int GetHashCode(IBindable obj) => obj.GetUnderlyingSettingValue().GetHashCode(); } } }