diff --git a/patches/net/minecraft/world/entity/ai/attributes/Attribute.java.patch b/patches/net/minecraft/world/entity/ai/attributes/Attribute.java.patch new file mode 100644 index 0000000000..3863a37e98 --- /dev/null +++ b/patches/net/minecraft/world/entity/ai/attributes/Attribute.java.patch @@ -0,0 +1,33 @@ +--- a/net/minecraft/world/entity/ai/attributes/Attribute.java ++++ b/net/minecraft/world/entity/ai/attributes/Attribute.java +@@ -9,7 +_,7 @@ + import net.minecraft.network.codec.ByteBufCodecs; + import net.minecraft.network.codec.StreamCodec; + +-public class Attribute { ++public class Attribute implements net.neoforged.neoforge.common.extensions.IAttributeExtension { + public static final Codec> CODEC = BuiltInRegistries.ATTRIBUTE.holderByNameCodec(); + public static final StreamCodec> STREAM_CODEC = ByteBufCodecs.holderRegistry(Registries.ATTRIBUTE); + private final double defaultValue; +@@ -50,6 +_,21 @@ + + public ChatFormatting getStyle(boolean p_347715_) { + return this.sentiment.getStyle(p_347715_); ++ } ++ ++ // Neo: Patch in the default implementation of IAttributeExtension#getMergedStyle since we need access to Attribute#sentiment ++ ++ protected static final net.minecraft.network.chat.TextColor MERGED_RED = net.minecraft.network.chat.TextColor.fromRgb(0xF93131); ++ protected static final net.minecraft.network.chat.TextColor MERGED_BLUE = net.minecraft.network.chat.TextColor.fromRgb(0x7A7AF9); ++ protected static final net.minecraft.network.chat.TextColor MERGED_GRAY = net.minecraft.network.chat.TextColor.fromRgb(0xCCCCCC); ++ ++ @Override ++ public net.minecraft.network.chat.TextColor getMergedStyle(boolean isPositive) { ++ return switch (this.sentiment) { ++ case POSITIVE -> isPositive ? MERGED_BLUE : MERGED_RED; ++ case NEGATIVE -> isPositive ? MERGED_RED : MERGED_BLUE; ++ case NEUTRAL -> MERGED_GRAY; ++ }; + } + + public static enum Sentiment { diff --git a/patches/net/minecraft/world/entity/ai/attributes/Attributes.java.patch b/patches/net/minecraft/world/entity/ai/attributes/Attributes.java.patch new file mode 100644 index 0000000000..ebf3e8fe30 --- /dev/null +++ b/patches/net/minecraft/world/entity/ai/attributes/Attributes.java.patch @@ -0,0 +1,22 @@ +--- a/net/minecraft/world/entity/ai/attributes/Attributes.java ++++ b/net/minecraft/world/entity/ai/attributes/Attributes.java +@@ -54,7 +_,8 @@ + "generic.jump_strength", new RangedAttribute("attribute.name.generic.jump_strength", 0.42F, 0.0, 32.0).setSyncable(true) + ); + public static final Holder KNOCKBACK_RESISTANCE = register( +- "generic.knockback_resistance", new RangedAttribute("attribute.name.generic.knockback_resistance", 0.0, 0.0, 1.0) ++ // Neo: Convert Knockback Resistance to percent-based for more appropriate display using IAttributeExtension. ++ "generic.knockback_resistance", new net.neoforged.neoforge.common.PercentageAttribute("attribute.name.generic.knockback_resistance", 0.0, 0.0, 1.0) + ); + public static final Holder LUCK = register( + "generic.luck", new RangedAttribute("attribute.name.generic.luck", 0.0, -1024.0, 1024.0).setSyncable(true) +@@ -72,7 +_,8 @@ + "generic.movement_efficiency", new RangedAttribute("attribute.name.generic.movement_efficiency", 0.0, 0.0, 1.0).setSyncable(true) + ); + public static final Holder MOVEMENT_SPEED = register( +- "generic.movement_speed", new RangedAttribute("attribute.name.generic.movement_speed", 0.7, 0.0, 1024.0).setSyncable(true) ++ // Neo: Convert Movement Speed to percent-based for more appropriate display using IAttributeExtension. Use a scale factor of 1000 since movement speed has 0.001 units. ++ "generic.movement_speed", new net.neoforged.neoforge.common.PercentageAttribute("attribute.name.generic.movement_speed", 0.7, 0.0, 1024.0, 1000).setSyncable(true) + ); + public static final Holder OXYGEN_BONUS = register( + "generic.oxygen_bonus", new RangedAttribute("attribute.name.generic.oxygen_bonus", 0.0, 0.0, 1024.0).setSyncable(true) diff --git a/patches/net/minecraft/world/item/ItemStack.java.patch b/patches/net/minecraft/world/item/ItemStack.java.patch index 4c1f77b59b..7f753b99da 100644 --- a/patches/net/minecraft/world/item/ItemStack.java.patch +++ b/patches/net/minecraft/world/item/ItemStack.java.patch @@ -148,7 +148,18 @@ if (this.has(DataComponents.CUSTOM_NAME)) { mutablecomponent.withStyle(ChatFormatting.ITALIC); } -@@ -784,12 +_,14 @@ +@@ -752,7 +_,9 @@ + this.addToTooltip(DataComponents.ENCHANTMENTS, p_339637_, consumer, p_41653_); + this.addToTooltip(DataComponents.DYED_COLOR, p_339637_, consumer, p_41653_); + this.addToTooltip(DataComponents.LORE, p_339637_, consumer, p_41653_); +- this.addAttributeTooltips(consumer, p_41652_); ++ // Neo: Replace attribute tooltips with custom handling ++ net.neoforged.neoforge.common.util.AttributeUtil.addAttributeTooltips(this, consumer, ++ net.neoforged.neoforge.common.util.AttributeTooltipContext.of(p_41652_, p_339637_, p_41653_)); + this.addToTooltip(DataComponents.UNBREAKABLE, p_339637_, consumer, p_41653_); + AdventureModePredicate adventuremodepredicate = this.get(DataComponents.CAN_BREAK); + if (adventuremodepredicate != null && adventuremodepredicate.showInTooltip()) { +@@ -784,10 +_,15 @@ list.add(DISABLED_ITEM_TOOLTIP); } @@ -157,12 +168,13 @@ } } ++ /** ++ * @deprecated Neo: Use {@link net.neoforged.neoforge.client.util.TooltipUtil#addAttributeTooltips} ++ */ ++ @Deprecated private void addAttributeTooltips(Consumer p_330796_, @Nullable Player p_330530_) { ItemAttributeModifiers itemattributemodifiers = this.getOrDefault(DataComponents.ATTRIBUTE_MODIFIERS, ItemAttributeModifiers.EMPTY); -+ // Neo: We don't need to call IItemStackExtension#getAttributeModifiers here, since it will be done in forEachModifier. if (itemattributemodifiers.showInTooltip()) { - for (EquipmentSlotGroup equipmentslotgroup : EquipmentSlotGroup.values()) { - MutableBoolean mutableboolean = new MutableBoolean(true); @@ -897,6 +_,17 @@ return !this.getOrDefault(DataComponents.ENCHANTMENTS, ItemEnchantments.EMPTY).isEmpty(); } diff --git a/patches/net/minecraft/world/item/alchemy/PotionContents.java.patch b/patches/net/minecraft/world/item/alchemy/PotionContents.java.patch new file mode 100644 index 0000000000..90c51e7d35 --- /dev/null +++ b/patches/net/minecraft/world/item/alchemy/PotionContents.java.patch @@ -0,0 +1,13 @@ +--- a/net/minecraft/world/item/alchemy/PotionContents.java ++++ b/net/minecraft/world/item/alchemy/PotionContents.java +@@ -173,6 +_,10 @@ + p_331296_.accept(CommonComponents.EMPTY); + p_331296_.accept(Component.translatable("potion.whenDrank").withStyle(ChatFormatting.DARK_PURPLE)); + ++ // Neo: Override handling of potion attribute tooltips to support IAttributeExtension ++ net.neoforged.neoforge.common.util.AttributeUtil.addPotionTooltip(list, p_331296_); ++ if (true) return; ++ + for (Pair, AttributeModifier> pair : list) { + AttributeModifier attributemodifier = pair.getSecond(); + double d1 = attributemodifier.amount(); diff --git a/src/main/java/net/neoforged/neoforge/client/event/AddAttributeTooltipsEvent.java b/src/main/java/net/neoforged/neoforge/client/event/AddAttributeTooltipsEvent.java new file mode 100644 index 0000000000..b39ffb1fe5 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/client/event/AddAttributeTooltipsEvent.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.event; + +import java.util.function.Consumer; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.component.ItemAttributeModifiers; +import net.neoforged.bus.api.Event; +import net.neoforged.neoforge.common.util.AttributeTooltipContext; +import net.neoforged.neoforge.common.util.AttributeUtil; + +/** + * This event is fired after attribute tooltip lines have been added to an item stack's tooltip in {@link AttributeUtil#addAttributeTooltips}. + *

+ * It can be used to add additional tooltip lines adjacent to the attribute lines without having to manually locate the inject point. + *

+ * This event may be fired on both the logical client and logical server. + */ +public class AddAttributeTooltipsEvent extends Event { + protected final ItemStack stack; + protected final Consumer tooltip; + protected final AttributeTooltipContext ctx; + + public AddAttributeTooltipsEvent(ItemStack stack, Consumer tooltip, AttributeTooltipContext ctx) { + this.stack = stack; + this.tooltip = tooltip; + this.ctx = ctx; + } + + /** + * The current tooltip context. + */ + public AttributeTooltipContext getContext() { + return this.ctx; + } + + /** + * The {@link ItemStack} with the tooltip. + */ + public ItemStack getStack() { + return this.stack; + } + + /** + * Adds one or more {@link Component}s to the tooltip. + */ + public void addTooltipLines(Component... comps) { + for (Component comp : comps) { + this.tooltip.accept(comp); + } + } + + /** + * Checks if the attribute tooltips should be shown on the current item stack. + *

+ * This event is fired even if the component would prevent the normal tooltip lines from showing. + */ + public boolean shouldShow() { + return this.stack.getOrDefault(DataComponents.ATTRIBUTE_MODIFIERS, ItemAttributeModifiers.EMPTY).showInTooltip(); + } +} diff --git a/src/main/java/net/neoforged/neoforge/client/event/GatherSkippedAttributeTooltipsEvent.java b/src/main/java/net/neoforged/neoforge/client/event/GatherSkippedAttributeTooltipsEvent.java new file mode 100644 index 0000000000..851439e16d --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/client/event/GatherSkippedAttributeTooltipsEvent.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.event; + +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.EquipmentSlotGroup; +import net.minecraft.world.item.ItemStack; +import net.neoforged.bus.api.Event; +import net.neoforged.neoforge.common.util.AttributeTooltipContext; +import org.jetbrains.annotations.Nullable; + +/** + * This event is used to collect the IDs of attribute modifiers that will not be displayed in item tooltips. + *

+ * It allows hiding some (or all) of the modifiers, potentially for displaying them in an alternative way (or for hiding information from the player). + *

+ * This event may be fired on both the logical client and logical server. + */ +public class GatherSkippedAttributeTooltipsEvent extends Event { + protected final ItemStack stack; + protected final AttributeTooltipContext ctx; + + @Nullable + private Set skippedIds = null; + + @Nullable + private Set skippedGroups = null; + + private boolean skipAll = false; + + public GatherSkippedAttributeTooltipsEvent(ItemStack stack, AttributeTooltipContext ctx) { + this.stack = stack; + this.ctx = ctx; + // Skip sets are lazily initialized by the getter functions to avoid memory churn + } + + /** + * The current tooltip context. + */ + public AttributeTooltipContext getContext() { + return this.ctx; + } + + /** + * The {@link ItemStack} with the tooltip. + */ + public ItemStack getStack() { + return this.stack; + } + + /** + * Marks the id of a specific attribute modifier as skipped, causing it to not be displayed in the tooltip. + */ + public void skipId(ResourceLocation id) { + this.getSkippedIds().add(id); + } + + /** + * Marks an entire {@link EquipmentSlotGroup} as skipped, preventing all modifiers for that group from showing. + */ + public void skipGroup(EquipmentSlotGroup group) { + this.getSkippedGroups().add(group); + } + + /** + * Checks if a given id is skipped or not. If all modifiers are skipped, this method always returns true. + */ + public boolean isSkipped(ResourceLocation id) { + return this.skipAll || (this.skippedIds != null && this.skippedIds.contains(id)); + } + + /** + * Checks if a given group is skipped or not. If all modifiers are skipped, this method always returns true. + */ + public boolean isSkipped(EquipmentSlotGroup group) { + return this.skipAll || (this.skippedGroups != null && this.skippedGroups.contains(group)); + } + + /** + * Sets if the event should skip displaying all attribute modifiers. + */ + public void setSkipAll(boolean skip) { + this.skipAll = skip; + } + + /** + * Checks if the event will cause all attribute modifiers to be skipped. + */ + public boolean isSkippingAll() { + return this.skipAll; + } + + /** + * Initializes {@link #skippedIds} if necessary, and returns it. + */ + protected Set getSkippedIds() { + if (this.skippedIds == null) { + this.skippedIds = new HashSet<>(); + } + return this.skippedIds; + } + + /** + * Initializes {@link #skippedGroups} if necessary, and returns it. + */ + protected Set getSkippedGroups() { + if (this.skippedGroups == null) { + this.skippedGroups = EnumSet.noneOf(EquipmentSlotGroup.class); + } + return this.skippedGroups; + } +} diff --git a/src/main/java/net/neoforged/neoforge/common/BooleanAttribute.java b/src/main/java/net/neoforged/neoforge/common/BooleanAttribute.java index 453cb3ba31..faf7277198 100644 --- a/src/main/java/net/neoforged/neoforge/common/BooleanAttribute.java +++ b/src/main/java/net/neoforged/neoforge/common/BooleanAttribute.java @@ -5,8 +5,14 @@ package net.neoforged.neoforge.common; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; import net.minecraft.world.entity.ai.attributes.Attribute; import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation; +import net.minecraft.world.item.TooltipFlag; +import org.jetbrains.annotations.Nullable; /** * A boolean attribute only has two states, on or off, represented by a value of 0 (false) or 1 (true). @@ -34,4 +40,27 @@ public double sanitizeValue(double value) { } return value > 0 ? 1 : 0; } + + @Override + public MutableComponent toValueComponent(@Nullable Operation op, double value, TooltipFlag flag) { + if (op == null) { + return Component.translatable("neoforge.value.boolean." + (value > 0 ? "enabled" : "disabled")); + } else if (op == Operation.ADD_VALUE && value > 0) { + return Component.translatable("neoforge.value.boolean.enable"); + } else if (op == Operation.ADD_MULTIPLIED_TOTAL && (int) value == -1) { + return Component.translatable("neoforge.value.boolean.disable"); + } else { + return Component.translatable("neoforge.value.boolean.invalid"); + } + } + + @Override + public MutableComponent toComponent(AttributeModifier modif, TooltipFlag flag) { + double value = modif.amount(); + + ChatFormatting color = this.getStyle(value > 0); + MutableComponent comp = Component.translatable("neoforge.modifier.bool", this.toValueComponent(modif.operation(), value, flag), Component.translatable(this.getDescriptionId())).withStyle(color); + + return comp.append(this.getDebugInfo(modif, flag)); + } } diff --git a/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java b/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java index 3c0ca35bb5..46b0696bf4 100644 --- a/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java +++ b/src/main/java/net/neoforged/neoforge/common/NeoForgeMod.java @@ -47,6 +47,7 @@ import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.MobCategory; import net.minecraft.world.entity.ai.attributes.Attribute; +import net.minecraft.world.entity.ai.attributes.Attribute.Sentiment; import net.minecraft.world.entity.ai.attributes.RangedAttribute; import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.item.Items; @@ -194,8 +195,8 @@ public class NeoForgeMod { private static final DeferredHolder, SingletonArgumentInfo> MODID_COMMAND_ARGUMENT_TYPE = COMMAND_ARGUMENT_TYPES.register("modid", () -> ArgumentTypeInfos.registerByClass(ModIdArgument.class, SingletonArgumentInfo.contextFree(ModIdArgument::modIdArgument))); - public static final Holder SWIM_SPEED = ATTRIBUTES.register("swim_speed", () -> new RangedAttribute("neoforge.swim_speed", 1.0D, 0.0D, 1024.0D).setSyncable(true)); - public static final Holder NAMETAG_DISTANCE = ATTRIBUTES.register("nametag_distance", () -> new RangedAttribute("neoforge.name_tag_distance", 64.0D, 0.0D, 64.0).setSyncable(true)); + public static final Holder SWIM_SPEED = ATTRIBUTES.register("swim_speed", () -> new PercentageAttribute("neoforge.swim_speed", 1.0D, 0.0D, 1024.0D).setSyncable(true)); + public static final Holder NAMETAG_DISTANCE = ATTRIBUTES.register("nametag_distance", () -> new RangedAttribute("neoforge.name_tag_distance", 64.0D, 0.0D, 64.0).setSyncable(true).setSentiment(Sentiment.NEUTRAL)); /** * This attribute controls if the player may use creative flight when not in creative mode. @@ -471,6 +472,8 @@ public void setItemMovement(ItemEntity entity) { private static boolean enableProperFilenameValidation = false; private static boolean enableMilkFluid = false; + private static boolean enableMergedAttributeTooltips = false; + public static final DeferredHolder BUCKET_EMPTY_MILK = DeferredHolder.create(Registries.SOUND_EVENT, ResourceLocation.withDefaultNamespace("item.bucket.empty_milk")); public static final DeferredHolder BUCKET_FILL_MILK = DeferredHolder.create(Registries.SOUND_EVENT, ResourceLocation.withDefaultNamespace("item.bucket.fill_milk")); public static final DeferredHolder MILK_TYPE = DeferredHolder.create(NeoForgeRegistries.Keys.FLUID_TYPES, ResourceLocation.withDefaultNamespace("milk")); @@ -493,6 +496,13 @@ public static void enableMilkFluid() { enableMilkFluid = true; } + /** + * Run this during mod construction to enable merged attribute tooltip functionality. + */ + public static void enableMergedAttributeTooltips() { + enableMergedAttributeTooltips = true; + } + /** * Run this method during mod constructor to enable {@link net.minecraft.FileUtil#RESERVED_WINDOWS_FILENAMES_NEOFORGE} regex being used for filepath validation. * Fixes MC-268617 at cost of vanilla incompat edge cases with files generated with this activated and them migrated to vanilla instance - See PR #767 @@ -505,6 +515,10 @@ public static boolean getProperFilenameValidation() { return enableProperFilenameValidation; } + public static boolean shouldMergeAttributeTooltips() { + return enableMergedAttributeTooltips; + } + public NeoForgeMod(IEventBus modEventBus, Dist dist, ModContainer container) { LOGGER.info(NEOFORGEMOD, "NeoForge mod loading, version {}, for MC {}", NeoForgeVersion.getVersion(), DetectedVersion.BUILT_IN.getName()); ForgeSnapshotsMod.logStartupWarning(); diff --git a/src/main/java/net/neoforged/neoforge/common/PercentageAttribute.java b/src/main/java/net/neoforged/neoforge/common/PercentageAttribute.java new file mode 100644 index 0000000000..75a845fea8 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/common/PercentageAttribute.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.common; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation; +import net.minecraft.world.entity.ai.attributes.RangedAttribute; +import net.minecraft.world.item.TooltipFlag; + +/** + * A Percentage Attribute is one which always displays modifiers as percentages, including for {@link Operation#ADD_VALUE}. + *

+ * This is used for attributes that would not make sense being displayed as flat additions (ex: +0.05 Swim Speed). + */ +public class PercentageAttribute extends RangedAttribute { + protected final double scaleFactor; + + /** + * Creates a new PercentageAttribute with the given description, value information, and scale factor. + *

+ * If your attribute's "real" value correlates 1 == 100%, you would use a scale factor of 100 to convert to 1 to 100%. + * + * @param pDescriptionId The description id used for generating the attribute's lang key. + * @param pDefaultValue The default value of the attribute + * @param pMin The minimum value + * @param pMax The maximum value + * @param scaleFactor The scale factor, used to convert the literal value to a percentage value. + */ + public PercentageAttribute(String pDescriptionId, double pDefaultValue, double pMin, double pMax, double scaleFactor) { + super(pDescriptionId, pDefaultValue, pMin, pMax); + this.scaleFactor = scaleFactor; + } + + /** + * Creates a new PercentageAttribute with the default scale factor of 100. + * + * @see #PercentageAttribute(String, double, double, double, double) + */ + public PercentageAttribute(String pDescriptionId, double pDefaultValue, double pMin, double pMax) { + this(pDescriptionId, pDefaultValue, pMin, pMax, 100); + } + + @Override + public MutableComponent toValueComponent(Operation op, double value, TooltipFlag flag) { + return Component.translatable("neoforge.value.percent", FORMAT.format(value * this.scaleFactor)); + } +} diff --git a/src/main/java/net/neoforged/neoforge/common/extensions/IAttributeExtension.java b/src/main/java/net/neoforged/neoforge/common/extensions/IAttributeExtension.java new file mode 100644 index 0000000000..6a5c51564d --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/common/extensions/IAttributeExtension.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.common.extensions; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; +import net.minecraft.ChatFormatting; +import net.minecraft.Util; +import net.minecraft.core.Holder; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.TextColor; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.ai.attributes.Attribute; +import net.minecraft.world.entity.ai.attributes.Attribute.Sentiment; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.item.TooltipFlag; +import net.neoforged.neoforge.common.NeoForgeMod; +import net.neoforged.neoforge.common.util.AttributeUtil; +import org.jetbrains.annotations.Nullable; + +public interface IAttributeExtension { + public static final DecimalFormat FORMAT = Util.make(new DecimalFormat("#.##"), fmt -> fmt.setDecimalFormatSymbols(DecimalFormatSymbols.getInstance(Locale.ROOT)));; + + /** + * Converts the value of an attribute modifier to the value that will be displayed. + *

+ * For multiplicative modifiers, this method is responsible for converting the value to percentage form. + * + * @param op The operation of the modifier. Null if we are just displaying the raw value and not a modifier. + * @param value The value to convert. Either the current attribute value (if null operation) or the attribute modifier's amount. + * @param flag The tooltip flag. + * @return The component form of the formatted value. + */ + default MutableComponent toValueComponent(@Nullable Operation op, double value, TooltipFlag flag) { + if (isNullOrAddition(op)) { + return Component.translatable("neoforge.value.flat", FORMAT.format(value)); + } + + return Component.translatable("neoforge.value.percent", FORMAT.format(value * 100)); + } + + /** + * Converts an attribute modifier into its tooltip representation. + *

+ * This method does not handle formatting of "base" modifiers, such as Attack Damage or Attack Speed. + *

+ * The returned component may append additional debug information based on the tooltip flag. + * + * @param modif The attribute modifier being converted to a component. + * @param flag The tooltip flag. + * @return The component representation of the passed attribute modifier, with debug info appended if enabled. + */ + default MutableComponent toComponent(AttributeModifier modif, TooltipFlag flag) { + Attribute attr = self(); + double value = modif.amount(); + String key = value > 0 ? "neoforge.modifier.plus" : "neoforge.modifier.take"; + ChatFormatting color = attr.getStyle(value > 0); + + Component attrDesc = Component.translatable(attr.getDescriptionId()); + Component valueComp = this.toValueComponent(modif.operation(), value, flag); + MutableComponent comp = Component.translatable(key, valueComp, attrDesc).withStyle(color); + + return comp.append(this.getDebugInfo(modif, flag)); + } + + /** + * Computes the additional debug information for a given attribute modifier, if the flag {@linkplain TooltipFlag#isAdvanced() is advanced}. + * + * @param modif The attribute modifier being converted to a component. + * @param flag The tooltip flag. + * @return The debug component, or {@link CommonComponents#EMPTY} if disabled. + * @apiNote This information is automatically appended to {@link #toComponent(AttributeModifier, TooltipFlag)}. + */ + default Component getDebugInfo(AttributeModifier modif, TooltipFlag flag) { + Component debugInfo = CommonComponents.EMPTY; + + if (flag.isAdvanced()) { + // Advanced Tooltips show the underlying operation and the "true" value. We offset MULTIPLY_TOTAL by 1 due to how the operation is calculated. + double advValue = (modif.operation() == Operation.ADD_MULTIPLIED_TOTAL ? 1 : 0) + modif.amount(); + String valueStr = FORMAT.format(advValue); + String txt = switch (modif.operation()) { + case ADD_VALUE -> String.format(Locale.ROOT, advValue > 0 ? "[+%s]" : "[%s]", valueStr); + case ADD_MULTIPLIED_BASE -> String.format(Locale.ROOT, advValue > 0 ? "[+%sx]" : "[%sx]", valueStr); + case ADD_MULTIPLIED_TOTAL -> String.format(Locale.ROOT, "[x%s]", valueStr); + }; + debugInfo = Component.literal(" ").append(Component.literal(txt).withStyle(ChatFormatting.GRAY)); + } + return debugInfo; + } + + /** + * Gets the specific ID that represents a "base" (green) modifier for this attribute. + * + * @return The ID of the "base" modifier, or null, if no such modifier may exist. + */ + @Nullable + default ResourceLocation getBaseId() { + if (this == Attributes.ATTACK_DAMAGE.value()) return AttributeUtil.BASE_ATTACK_DAMAGE_ID; + else if (this == Attributes.ATTACK_SPEED.value()) return AttributeUtil.BASE_ATTACK_SPEED_ID; + else if (this == Attributes.ENTITY_INTERACTION_RANGE.value()) return AttributeUtil.BASE_ENTITY_REACH_ID; + return null; + } + + /** + * Converts a "base" attribute modifier (as dictated by {@link #getBaseId()}) into a text component. + *

+ * Similar to {@link #toComponent}, this method is responsible for adding debug information when the tooltip flag {@linkplain TooltipFlag#isAdvanced() is advanced}. + * + * @param value The value to be shown (after having been added to the entity's base value) + * @param entityBase The entity's base value for this attribute from {@link LivingEntity#getAttributeBaseValue(Holder)}. + * @param merged If we are displaying a merged base component (which will have a non-merged base component as a child). + * @param flag The tooltip flag. + * @return The component representation of the passed attribute modifier. + */ + default MutableComponent toBaseComponent(double value, double entityBase, boolean merged, TooltipFlag flag) { + Attribute attr = self(); + MutableComponent comp = Component.translatable("attribute.modifier.equals.0", FORMAT.format(value), Component.translatable(attr.getDescriptionId())); + + // Emit both the value of the modifier, and the entity's base value as debug information, since both are flattened into the modifier. + // Skip showing debug information here when displaying a merged modifier, since it will be shown if the user holds shift to display the un-merged modifier. + if (flag.isAdvanced() && !merged) { + Component debugInfo = Component.literal(" ").append(Component.translatable("neoforge.attribute.debug.base", FORMAT.format(entityBase), FORMAT.format(value - entityBase)).withStyle(ChatFormatting.GRAY)); + comp.append(debugInfo); + } + + return comp; + } + + /** + * Returns the color used by merged attribute modifiers. Only used when {@link NeoForgeMod#enableMergedAttributeTooltips()} is active. + *

+ * Similarly to {@link Attribute#getStyle(boolean)}, this method should return a color based on the attribute's {@link Sentiment}. + * The returned color should be distinguishable from the color used by {@link Attribute#getStyle(boolean)}. + * + * @param positive If the attribute modifier value is positive or not. + */ + TextColor getMergedStyle(boolean isPositive); + + public static boolean isNullOrAddition(@Nullable Operation op) { + return op == null || op == Operation.ADD_VALUE; + } + + private Attribute self() { + return (Attribute) this; + } +} diff --git a/src/main/java/net/neoforged/neoforge/common/util/AttributeTooltipContext.java b/src/main/java/net/neoforged/neoforge/common/util/AttributeTooltipContext.java new file mode 100644 index 0000000000..e21ee1ff52 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/common/util/AttributeTooltipContext.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.common.util; + +import net.minecraft.core.HolderLookup.Provider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.Item.TooltipContext; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.saveddata.maps.MapId; +import net.minecraft.world.level.saveddata.maps.MapItemSavedData; +import org.jetbrains.annotations.Nullable; + +/** + * Extended {@link TooltipContext} used when generating attribute tooltips. + */ +public interface AttributeTooltipContext extends Item.TooltipContext { + /** + * {@return the player for whom tooltips are being generated for, if known} + */ + @Nullable + Player player(); + + /** + * {@return the current tooltip flag} + */ + TooltipFlag flag(); + + public static AttributeTooltipContext of(@Nullable Player player, Item.TooltipContext itemCtx, TooltipFlag flag) { + return new AttributeTooltipContext() { + @Override + public Provider registries() { + return itemCtx.registries(); + } + + @Override + public float tickRate() { + return itemCtx.tickRate(); + } + + @Override + public MapItemSavedData mapData(MapId id) { + return itemCtx.mapData(id); + } + + @Override + public Level level() { + return itemCtx.level(); + } + + @Nullable + @Override + public Player player() { + return player; + } + + @Override + public TooltipFlag flag() { + return flag; + } + }; + } +} diff --git a/src/main/java/net/neoforged/neoforge/common/util/AttributeUtil.java b/src/main/java/net/neoforged/neoforge/common/util/AttributeUtil.java new file mode 100644 index 0000000000..160626a669 --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/common/util/AttributeUtil.java @@ -0,0 +1,356 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.common.util; + +import com.google.common.collect.Multimap; +import com.google.common.collect.TreeMultimap; +import com.mojang.datafixers.util.Pair; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.IdentityHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Consumer; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.core.Holder; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.TextColor; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.effect.MobEffect; +import net.minecraft.world.entity.EquipmentSlotGroup; +import net.minecraft.world.entity.ai.attributes.Attribute; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.alchemy.PotionContents; +import net.minecraft.world.item.component.ItemAttributeModifiers; +import net.neoforged.fml.loading.FMLEnvironment; +import net.neoforged.neoforge.client.event.AddAttributeTooltipsEvent; +import net.neoforged.neoforge.client.event.GatherSkippedAttributeTooltipsEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.common.NeoForgeMod; +import net.neoforged.neoforge.common.extensions.IAttributeExtension; +import net.neoforged.neoforge.event.ItemAttributeModifierEvent; +import net.neoforged.neoforge.internal.versions.neoforge.NeoForgeVersion; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Utility code to support {@link IAttributeExtension}. + */ +public class AttributeUtil { + /** + * ID of the base modifier for Attack Damage + */ + public static final ResourceLocation BASE_ATTACK_DAMAGE_ID = Item.BASE_ATTACK_DAMAGE_ID; + + /** + * ID of the base modifier for Attack Speed + */ + public static final ResourceLocation BASE_ATTACK_SPEED_ID = Item.BASE_ATTACK_SPEED_ID; + + /** + * ID of the base modifier for Attack Range + */ + public static final ResourceLocation BASE_ENTITY_REACH_ID = ResourceLocation.withDefaultNamespace("base_entity_reach"); + + /** + * ID used for attribute modifiers used to hold merged values when {@link NeoForgeMod#enableMergedAttributeTooltips()} is active. + *

+ * Should not be used by any real attribute modifiers for gameplay purposes. + */ + public static final ResourceLocation FAKE_MERGED_ID = ResourceLocation.fromNamespaceAndPath(NeoForgeVersion.MOD_ID, "fake_merged_modifier"); + + /** + * Comparator for {@link AttributeModifier}. First compares by operation, then amount, then the ID. + */ + public static final Comparator ATTRIBUTE_MODIFIER_COMPARATOR = Comparator.comparing(AttributeModifier::operation) + .thenComparingDouble(a -> -Math.abs(a.amount())) // Sort most impactful modifiers first + .thenComparing(AttributeModifier::id); + + private static final Logger LOGGER = LogManager.getLogger(); + + /** + * Checks if attribute modifier tooltips should show, and if they should, adds tooltips for all attribute modifiers present on an item stack to the stack's tooltip lines. + *

+ * After the tooltip lines have been added, fires the {@link AddAttributeTooltipsEvent} to allow mods to add additional attribute-related lines. + * + * @param tooltip A consumer to add the tooltip lines to. + * @param ctx The tooltip context. + */ + public static void addAttributeTooltips(ItemStack stack, Consumer tooltip, AttributeTooltipContext ctx) { + ItemAttributeModifiers modifiers = stack.getOrDefault(DataComponents.ATTRIBUTE_MODIFIERS, ItemAttributeModifiers.EMPTY); + if (modifiers.showInTooltip()) { + applyModifierTooltips(stack, tooltip, ctx); + } + NeoForge.EVENT_BUS.post(new AddAttributeTooltipsEvent(stack, tooltip, ctx)); + } + + /** + * Applies the attribute modifier tooltips for all attribute modifiers present on the item stack. + *

+ * Before application, this method posts the {@link GatherSkippedAttributeTooltipsEvent} to determine which tooltips should be skipped. + *

+ * This method is also responsible for adding the modifier group category labels. + * + * @param tooltip A consumer to add the tooltip lines to. + * @param ctx The tooltip context. + */ + public static void applyModifierTooltips(ItemStack stack, Consumer tooltip, AttributeTooltipContext ctx) { + var event = NeoForge.EVENT_BUS.post(new GatherSkippedAttributeTooltipsEvent(stack, ctx)); + if (event.isSkippingAll()) { + return; + } + + for (EquipmentSlotGroup group : EquipmentSlotGroup.values()) { + if (event.isSkipped(group)) { + continue; + } + + Multimap, AttributeModifier> modifiers = getSortedModifiers(stack, group); + + // Remove any skipped modifiers before doing any logic + modifiers.values().removeIf(m -> event.isSkipped(m.id())); + + if (modifiers.isEmpty()) { + continue; + } + + // Add an empty line, then the name of the group, then the modifiers. + tooltip.accept(Component.empty()); + tooltip.accept(Component.translatable("item.modifiers." + group.getSerializedName()).withStyle(ChatFormatting.GRAY)); + + applyTextFor(stack, tooltip, modifiers, ctx); + } + } + + /** + * Applies the text for the provided attribute modifiers to the tooltip for a given item stack. + *

+ * This method will attempt to merge multiple modifiers for a single attribute into a single modifier if {@linkplain NeoForgeMod#enableMergedAttributeTooltips()} was called. + * + * @param stack The item stack that owns the modifiers. + * @param tooltip The consumer to append tooltip components to. + * @param modifierMap A mutable map of modifiers to convert into tooltip lines. + * @param ctx The tooltip context. + */ + public static void applyTextFor(ItemStack stack, Consumer tooltip, Multimap, AttributeModifier> modifierMap, AttributeTooltipContext ctx) { + // Don't add anything if there is nothing in the group + if (modifierMap.isEmpty()) { + return; + } + + // Collect all the base modifiers + Map, BaseModifier> baseModifs = new IdentityHashMap<>(); + + var it = modifierMap.entries().iterator(); + while (it.hasNext()) { + Entry, AttributeModifier> entry = it.next(); + Holder attr = entry.getKey(); + AttributeModifier modif = entry.getValue(); + if (modif.id().equals(attr.value().getBaseId())) { + baseModifs.put(attr, new BaseModifier(modif, new ArrayList<>())); + // Remove base modifiers from the main map after collection so we don't need to check for them later. + it.remove(); + } + } + + // Collect children of all base modifiers for merging logic + for (Map.Entry, AttributeModifier> entry : modifierMap.entries()) { + BaseModifier base = baseModifs.get(entry.getKey()); + if (base != null) { + base.children.add(entry.getValue()); + } + } + + // Add tooltip lines for base modifiers + for (Map.Entry, BaseModifier> entry : baseModifs.entrySet()) { + Holder attr = entry.getKey(); + BaseModifier baseModif = entry.getValue(); + double entityBase = ctx.player() == null ? 0 : ctx.player().getAttributeBaseValue(attr); + double base = baseModif.base.amount() + entityBase; + final double rawBase = base; + double amt = base; + + // Compute the base value including merged modifiers if merging is enabled + if (NeoForgeMod.shouldMergeAttributeTooltips()) { + for (AttributeModifier modif : baseModif.children) { + switch (modif.operation()) { + case ADD_VALUE: + base = amt = amt + modif.amount(); + break; + case ADD_MULTIPLIED_BASE: + amt += modif.amount() * base; + break; + case ADD_MULTIPLIED_TOTAL: + amt *= 1 + modif.amount(); + break; + } + } + } + + boolean isMerged = NeoForgeMod.shouldMergeAttributeTooltips() && !baseModif.children.isEmpty(); + MutableComponent text = attr.value().toBaseComponent(amt, entityBase, isMerged, ctx.flag()); + tooltip.accept(Component.literal(" ").append(text).withStyle(isMerged ? ChatFormatting.GOLD : ChatFormatting.DARK_GREEN)); + if (ctx.flag().hasShiftDown() && isMerged) { + // Display the raw base value, and then all children modifiers. + text = attr.value().toBaseComponent(rawBase, entityBase, false, ctx.flag()); + tooltip.accept(listHeader().append(text.withStyle(ChatFormatting.DARK_GREEN))); + for (AttributeModifier modifier : baseModif.children) { + tooltip.accept(listHeader().append(attr.value().toComponent(modifier, ctx.flag()))); + } + } + } + + for (Holder attr : modifierMap.keySet()) { + // Skip attributes who have already been processed during the base modifier stage + if (NeoForgeMod.shouldMergeAttributeTooltips() && baseModifs.containsKey(attr)) { + continue; + } + + Collection modifs = modifierMap.get(attr); + // Initiate merged-tooltip logic if we have more than one modifier for a given attribute. + if (NeoForgeMod.shouldMergeAttributeTooltips() && modifs.size() > 1) { + Map mergeData = new EnumMap<>(Operation.class); + + for (AttributeModifier modifier : modifs) { + if (modifier.amount() == 0) { + continue; + } + + MergedModifierData data = mergeData.computeIfAbsent(modifier.operation(), op -> new MergedModifierData()); + if (data.sum != 0) { + // If the sum for this operation is non-zero, we've already consumed one modifier. Consuming a second means we've merged. + data.isMerged = true; + } + data.sum += modifier.amount(); + data.children.add(modifier); + } + + for (Operation op : Operation.values()) { + MergedModifierData data = mergeData.get(op); + + // If the merged value comes out to 0, just ignore the whole stack + if (data == null || data.sum == 0) { + continue; + } + + // Handle merged modifier stacks by creating a "fake" merged modifier with the underlying value. + if (data.isMerged) { + TextColor color = attr.value().getMergedStyle(data.sum > 0); + var fakeModif = new AttributeModifier(FAKE_MERGED_ID, data.sum, op); + MutableComponent comp = attr.value().toComponent(fakeModif, ctx.flag()); + tooltip.accept(comp.withStyle(comp.getStyle().withColor(color))); + if (ctx.flag().hasShiftDown()) { + data.children.forEach(modif -> tooltip.accept(listHeader().append(attr.value().toComponent(modif, ctx.flag())))); + } + } else { + var fakeModif = new AttributeModifier(FAKE_MERGED_ID, data.sum, op); + tooltip.accept(attr.value().toComponent(fakeModif, ctx.flag())); + } + } + } else { + for (AttributeModifier m : modifs) { + if (m.amount() != 0) { + tooltip.accept(attr.value().toComponent(m, ctx.flag())); + } + } + } + } + } + + /** + * Adds tooltip lines for the attribute modifiers contained in a {@link PotionContents}. + * + * @param list The list of attribute modifiers generated by calling {@link MobEffect#createModifiers} for each mob effect instance on the potion. + * @param tooltips The tooltip consumer to add lines to. + */ + public static void addPotionTooltip(List, AttributeModifier>> list, Consumer tooltips) { + for (Pair, AttributeModifier> pair : list) { + tooltips.accept(pair.getFirst().value().toComponent(pair.getSecond(), getTooltipFlag())); + } + } + + /** + * Creates a sorted {@link TreeMultimap} used to ensure a stable iteration order of item attribute modifiers. + */ + public static Multimap, AttributeModifier> sortedMap() { + return TreeMultimap.create(Comparator.comparing(Holder::getKey), ATTRIBUTE_MODIFIER_COMPARATOR); + } + + /** + * Returns a sorted, mutable {@link Multimap} containing all the attribute modifiers on an item stack for the given group. + *

+ * This includes attribute modifiers from components (or default modifiers, if not present), enchantments, and the {@link ItemAttributeModifierEvent}. + * + * @param stack The stack to query modifiers for. + * @param slot The slot group to query modifiers for. + */ + public static Multimap, AttributeModifier> getSortedModifiers(ItemStack stack, EquipmentSlotGroup slot) { + Multimap, AttributeModifier> map = sortedMap(); + stack.forEachModifier(slot, (attr, modif) -> { + if (attr != null && modif != null) { + map.put(attr, modif); + } else { + LOGGER.debug("Detected broken attribute modifier entry on item {}. Attr={}, Modif={}", stack, attr, modif); + } + }); + return map; + } + + /** + * Creates a mutable component starting with the char used to represent a drop-down list. + */ + private static MutableComponent listHeader() { + return Component.literal(" \u2507 ").withStyle(ChatFormatting.GRAY); + } + + /** + * Gets the current global tooltip flag. Used by {@link AttributeUtil#addPotionTooltip} since one isn't available locally. + * + * @return If called on the client, the current tooltip flag, otherwise {@link TooltipFlag#NORMAL} + */ + private static TooltipFlag getTooltipFlag() { + if (FMLEnvironment.dist.isClient()) { + return ClientAccess.getTooltipFlag(); + } + return TooltipFlag.NORMAL; + } + + /** + * Stores a single base modifier (determined by {@link IAttributeExtension#getBaseId()}) and any other children non-base modifiers for the same attribute. + *

+ * Used during attribute merging logic within {@link AttributeUtil#applyTextFor}. + */ + private static record BaseModifier(AttributeModifier base, List children) {} + + /** + * State-tracking object used to merge attribute modifier tooltips in {@link AttributeUtil#applyTextFor}. + */ + private static class MergedModifierData { + double sum = 0; + boolean isMerged = false; + private List children = new LinkedList<>(); + } + + /** + * Client bouncer class to avoid class loading issues. Access to this class still needs a dist check. + */ + private static class ClientAccess { + static TooltipFlag getTooltipFlag() { + return Minecraft.getInstance().options.advancedItemTooltips ? TooltipFlag.ADVANCED : TooltipFlag.NORMAL; + } + } +} diff --git a/src/main/resources/assets/neoforge/lang/en_us.json b/src/main/resources/assets/neoforge/lang/en_us.json index 0d78311974..484a2b29f3 100644 --- a/src/main/resources/assets/neoforge/lang/en_us.json +++ b/src/main/resources/assets/neoforge/lang/en_us.json @@ -263,5 +263,20 @@ "neoforge.network.extensible_enums.no_vanilla_server": "This client does not support vanilla servers as it has extended enums used in serverbound networking", "neoforge.network.extensible_enums.enum_set_mismatch": "The set of extensible enums on the client and server do not match. Make sure you are using the same NeoForge version as the server", - "neoforge.network.extensible_enums.enum_entry_mismatch": "The set of values added to extensible enums on the client and server do not match. Make sure you are using the same mod and NeoForge versions as the server. See the log for more details" + "neoforge.network.extensible_enums.enum_entry_mismatch": "The set of values added to extensible enums on the client and server do not match. Make sure you are using the same mod and NeoForge versions as the server. See the log for more details", + + "neoforge.attribute.debug.base": "[Entity: %s | Item: %s]", + + "neoforge.value.flat": "%s", + "neoforge.value.percent": "%s%%", + + "neoforge.value.boolean.enabled": "Enabled", + "neoforge.value.boolean.disabled": "Disabled", + "neoforge.value.boolean.enable": "Enables", + "neoforge.value.boolean.disable": "Disables", + "neoforge.value.boolean.invalid": "Invalid", + + "neoforge.modifier.plus": "+%s %s", + "neoforge.modifier.take": "%s %s", + "neoforge.modifier.bool": "%s %s" }