From 59ccc9e106447e8d077b2b51621262c407ab75b0 Mon Sep 17 00:00:00 2001 From: Frederik Spirgi Date: Mon, 12 Oct 2026 09:35:41 +0200 Subject: [PATCH] Protect owned pets from lethal damage on Forge --- .gitignore | 1 + README.md | 4 +- changelog.md | 1 + .../net/fretux/knockedback/HelpUpHandler.java | 19 +- .../knockedback/HelpUpKnockedPacket.java | 5 +- .../fretux/knockedback/NpcHelpUpHandler.java | 3 +- .../knockedback/PetKnockdownHandler.java | 232 ++++++++++++++++++ .../net/fretux/knockedback/config/Config.java | 29 +++ .../assets/knockedback/lang/de_de.json | 2 + .../assets/knockedback/lang/en_us.json | 2 + 10 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 src/main/java/net/fretux/knockedback/PetKnockdownHandler.java diff --git a/.gitignore b/.gitignore index 12f8644..27da90e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ out # gradle build .gradle +.gradle-user-home # other eclipse diff --git a/README.md b/README.md index 0508495..5d2fa79 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,6 @@ Mobs cannot execute players who are currently being carried. Press V near a knoc Fire, lava, fall, and explosion deaths bypass the knocked state and kill normally. -Animations and other stuff like config files and custom items for reviving will come in future updates. +Owned pets also survive lethal combat damage at 1 HP. They cannot take ordinary damage while knocked, and recover with four health after 20 seconds. Hold H within two blocks to help a knocked pet recover sooner. Their owner receives a chat notice. The pet's recovery timer is saved with the entity, so it resumes after a world reload. Tamed animals and modded followers that implement Minecraft's `OwnableEntity` interface are protected automatically. + +For modded followers without vanilla ownership data, add their entity type IDs to `pets.protectedEntityTypes` in the Forge config file (`config/knockedback-common.toml`). Every mob of a listed type is protected, so list only the follower types you want to save. Pet protection, recovery time, recovery health, and player help are configurable in the same section. diff --git a/changelog.md b/changelog.md index bf88efc..6f4b593 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ All notable changes since version 1.0.0 are documented in this file. ## Unreleased ### Added +- Owned pets and configured modded followers now enter a saved knocked state on lethal combat damage; players can help them recover with H. - Added the help-up progress bar when a friendly NPC is reviving the player. - Added achievements for being rescued by an NPC, owned pet, friendly villager, or iron golem. - Added separate configuration for mob execution time, executor movement, and knocked-player movement. diff --git a/src/main/java/net/fretux/knockedback/HelpUpHandler.java b/src/main/java/net/fretux/knockedback/HelpUpHandler.java index 20f26d0..920b777 100644 --- a/src/main/java/net/fretux/knockedback/HelpUpHandler.java +++ b/src/main/java/net/fretux/knockedback/HelpUpHandler.java @@ -37,26 +37,27 @@ public class HelpUpHandler { return helpUpAttempts.values().stream().anyMatch(attempt -> attempt.helperId.equals(helperId)); } - public static void startHelpUp(ServerPlayer helper) { - if (!Config.COMMON.helpUpEnabled.get()) return; + public static boolean startHelpUp(ServerPlayer helper) { + if (!Config.COMMON.helpUpEnabled.get()) return false; if (KnockedManager.isKnocked(helper)) { helper.sendSystemMessage(Component.translatable("message.knockedback.cannot_help_while_knocked")); - return; + return false; } - if (PlayerExecutionHandler.isExecuting(helper.getUUID())) return; - if (isHelping(helper.getUUID())) return; + if (PlayerExecutionHandler.isExecuting(helper.getUUID())) return false; + if (isHelping(helper.getUUID()) || PetKnockdownHandler.isHelping(helper.getUUID())) return false; Player target = findNearestKnocked(helper); - if (target == null) return; + if (target == null) return false; UUID knockedId = target.getUUID(); if (CarryManager.isBeingCarried(knockedId)) { helper.sendSystemMessage(Component.translatable("message.knockedback.cannot_help_carried_player")); - return; + return false; } - if (helpUpAttempts.containsKey(knockedId)) return; - if (NpcHelpUpHandler.isBeingHelped(knockedId)) return; + if (helpUpAttempts.containsKey(knockedId)) return false; + if (NpcHelpUpHandler.isBeingHelped(knockedId)) return false; helpUpAttempts.put(knockedId, new HelpUpAttempt(helper.getUUID())); helper.sendSystemMessage(Component.translatable("message.knockedback.help_up_started", target.getDisplayName())); target.sendSystemMessage(Component.translatable("message.knockedback.being_helped", helper.getDisplayName())); + return true; } public static void cancelHelpUp(UUID knockedId) { diff --git a/src/main/java/net/fretux/knockedback/HelpUpKnockedPacket.java b/src/main/java/net/fretux/knockedback/HelpUpKnockedPacket.java index bccf5cb..a249a1f 100644 --- a/src/main/java/net/fretux/knockedback/HelpUpKnockedPacket.java +++ b/src/main/java/net/fretux/knockedback/HelpUpKnockedPacket.java @@ -26,9 +26,12 @@ public class HelpUpKnockedPacket { ServerPlayer helper = ctx.get().getSender(); if (helper == null) return; if (msg.helping) { - HelpUpHandler.startHelpUp(helper); + if (!HelpUpHandler.startHelpUp(helper)) { + PetKnockdownHandler.startHelpUp(helper); + } } else { HelpUpHandler.cancelHelpUpByHelper(helper.getUUID()); + PetKnockdownHandler.cancelHelpUpByHelper(helper.getUUID()); } }); ctx.get().setPacketHandled(true); diff --git a/src/main/java/net/fretux/knockedback/NpcHelpUpHandler.java b/src/main/java/net/fretux/knockedback/NpcHelpUpHandler.java index d6aab9d..d7b2350 100644 --- a/src/main/java/net/fretux/knockedback/NpcHelpUpHandler.java +++ b/src/main/java/net/fretux/knockedback/NpcHelpUpHandler.java @@ -121,7 +121,8 @@ public final class NpcHelpUpHandler { } private static boolean isValidHelper(Mob mob, ServerPlayer player) { - if (mob == null || !mob.isAlive() || mob.level() != player.level()) return false; + if (mob == null || !mob.isAlive() || mob.level() != player.level() + || PetKnockdownHandler.isKnocked(mob)) return false; ResourceLocation id = ForgeRegistries.ENTITY_TYPES.getKey(mob.getType()); if (id == null || !Config.COMMON.npcHelpUpAllowlist.get().contains(id.toString())) return false; if (mob instanceof TamableAnimal pet) return pet.isTame() && pet.isOwnedBy(player); diff --git a/src/main/java/net/fretux/knockedback/PetKnockdownHandler.java b/src/main/java/net/fretux/knockedback/PetKnockdownHandler.java new file mode 100644 index 0000000..20e9f5f --- /dev/null +++ b/src/main/java/net/fretux/knockedback/PetKnockdownHandler.java @@ -0,0 +1,232 @@ +package net.fretux.knockedback; + +import net.fretux.knockedback.config.Config; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.OwnableEntity; +import net.minecraft.world.entity.TamableAnimal; +import net.minecraft.world.phys.AABB; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.living.LivingDeathEvent; +import net.minecraftforge.event.entity.living.LivingEvent; +import net.minecraftforge.event.entity.living.LivingHurtEvent; +import net.minecraftforge.event.entity.player.AttackEntityEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.network.PacketDistributor; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.server.ServerLifecycleHooks; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Mod.EventBusSubscriber(modid = KnockedBack.MOD_ID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public final class PetKnockdownHandler { + private static final String TIMER_KEY = "knockedback.pet_remaining"; + private static final double HELP_RANGE = 2.0D; + private static final Map HELP_ATTEMPTS = new HashMap<>(); + + private PetKnockdownHandler() {} + + private static final class HelpAttempt { + final UUID helperId; + int remaining; + + HelpAttempt(UUID helperId) { + this.helperId = helperId; + this.remaining = Config.COMMON.helpUpTime.get(); + } + } + + public static boolean isKnocked(LivingEntity entity) { + return entity instanceof Mob && entity.getPersistentData().getInt(TIMER_KEY) > 0; + } + + private static boolean isProtectedPet(LivingEntity entity) { + if (!Config.COMMON.petKnockdownEnabled.get() || !(entity instanceof Mob)) return false; + if (entity instanceof TamableAnimal pet && pet.isTame() && pet.getOwnerUUID() != null) return true; + if (entity instanceof OwnableEntity owned && owned.getOwnerUUID() != null) return true; + var id = ForgeRegistries.ENTITY_TYPES.getKey(entity.getType()); + return id != null && Config.COMMON.protectedEntityTypes.get().contains(id.toString()); + } + + @SubscribeEvent + public static void onDeath(LivingDeathEvent event) { + LivingEntity entity = event.getEntity(); + if (entity.level().isClientSide || event.getSource().is(KnockedBackDamageTags.BYPASS_KNOCKDOWN) + || !isProtectedPet(entity)) return; + entity.setHealth(1.0F); + entity.getPersistentData().putInt(TIMER_KEY, Config.COMMON.petKnockdownDuration.get()); + if (entity instanceof Mob mob) { + mob.setTarget(null); + mob.getNavigation().stop(); + } + notifyOwner(entity, "message.knockedback.pet_knocked"); + event.setCanceled(true); + } + + @SubscribeEvent + public static void onHurt(LivingHurtEvent event) { + LivingEntity entity = event.getEntity(); + if (entity.level().isClientSide || !isKnocked(entity)) return; + if (event.getSource().is(KnockedBackDamageTags.BYPASS_KNOCKDOWN)) { + clearKnockdown(entity); + return; + } + entity.getPersistentData().putInt(TIMER_KEY, Config.COMMON.petKnockdownDuration.get()); + event.setCanceled(true); + } + + @SubscribeEvent + public static void onLivingTick(LivingEvent.LivingTickEvent event) { + LivingEntity entity = event.getEntity(); + if (entity.level().isClientSide || !isKnocked(entity)) return; + if (entity instanceof Mob mob) { + mob.setTarget(null); + mob.getNavigation().stop(); + mob.setDeltaMovement(0, mob.getDeltaMovement().y(), 0); + } + if (HELP_ATTEMPTS.containsKey(entity.getUUID())) return; + CompoundTag data = entity.getPersistentData(); + int remaining = data.getInt(TIMER_KEY) - 1; + if (remaining > 0) { + data.putInt(TIMER_KEY, remaining); + } else { + clearKnockdown(entity); + entity.setHealth(Math.min(entity.getMaxHealth(), Config.COMMON.petRecoveryHealth.get().floatValue())); + notifyOwner(entity, "message.knockedback.pet_recovered"); + } + } + + private static void clearKnockdown(LivingEntity entity) { + entity.getPersistentData().remove(TIMER_KEY); + cancelHelpUp(entity.getUUID()); + } + + private static void notifyOwner(LivingEntity entity, String messageKey) { + if (!(entity instanceof OwnableEntity owned) || owned.getOwnerUUID() == null) return; + MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); + if (server == null) return; + ServerPlayer owner = server.getPlayerList().getPlayer(owned.getOwnerUUID()); + if (owner != null) owner.sendSystemMessage(Component.translatable(messageKey, entity.getDisplayName())); + } + + public static boolean isHelping(UUID helperId) { + return HELP_ATTEMPTS.values().stream().anyMatch(attempt -> attempt.helperId.equals(helperId)); + } + + public static void startHelpUp(ServerPlayer helper) { + if (!Config.COMMON.helpUpEnabled.get() || !Config.COMMON.petHelpUpEnabled.get() + || KnockedManager.isKnocked(helper) || HelpUpHandler.isHelping(helper.getUUID()) + || PlayerExecutionHandler.isExecuting(helper.getUUID()) || isHelping(helper.getUUID())) return; + AABB box = helper.getBoundingBox().inflate(HELP_RANGE); + Mob pet = helper.level().getEntitiesOfClass(Mob.class, box).stream() + .filter(entity -> isKnocked(entity) && !HELP_ATTEMPTS.containsKey(entity.getUUID()) + && helper.distanceToSqr(entity) <= HELP_RANGE * HELP_RANGE) + .min(Comparator.comparingDouble(helper::distanceToSqr)).orElse(null); + if (pet == null) return; + HELP_ATTEMPTS.put(pet.getUUID(), new HelpAttempt(helper.getUUID())); + helper.sendSystemMessage(Component.translatable("message.knockedback.help_up_started", pet.getDisplayName())); + } + + public static void cancelHelpUpByHelper(UUID helperId) { + for (Map.Entry entry : new ArrayList<>(HELP_ATTEMPTS.entrySet())) { + if (entry.getValue().helperId.equals(helperId)) cancelHelpUp(entry.getKey()); + } + } + + private static void cancelHelpUp(UUID petId) { + HelpAttempt attempt = HELP_ATTEMPTS.remove(petId); + if (attempt == null) return; + ServerPlayer helper = findPlayer(attempt.helperId); + if (helper != null) clearProgress(helper); + } + + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (event.phase != TickEvent.Phase.END || !event.side.isServer()) return; + for (Map.Entry entry : new ArrayList<>(HELP_ATTEMPTS.entrySet())) { + UUID petId = entry.getKey(); + HelpAttempt attempt = entry.getValue(); + Mob pet = findMob(petId); + ServerPlayer helper = findPlayer(attempt.helperId); + if (pet == null || helper == null || !isKnocked(pet) || KnockedManager.isKnocked(helper) + || pet.level() != helper.level() || helper.distanceToSqr(pet) > HELP_RANGE * HELP_RANGE) { + cancelHelpUp(petId); + continue; + } + attempt.remaining--; + NetworkHandler.CHANNEL.send(PacketDistributor.PLAYER.with(() -> helper), + new ExecutionProgressPacket(attempt.remaining, Config.COMMON.helpUpTime.get(), + ExecutionProgressPacket.Action.HELP_UP, pet.getDisplayName().getString(), null)); + if (attempt.remaining <= 0) { + HELP_ATTEMPTS.remove(petId); + pet.getPersistentData().remove(TIMER_KEY); + pet.setHealth(Math.min(pet.getMaxHealth(), pet.getHealth() + Config.COMMON.helpUpHealAmount.get().floatValue())); + clearProgress(helper); + KnockedbackAdvancements.recordHelpUp(helper); + helper.sendSystemMessage(Component.translatable("message.knockedback.you_helped_up", pet.getDisplayName())); + notifyOwner(pet, "message.knockedback.pet_recovered"); + } + } + } + + @SubscribeEvent + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + if (event.side.isServer() && event.phase == TickEvent.Phase.END + && event.player instanceof ServerPlayer helper && isHelping(helper.getUUID())) { + helper.setDeltaMovement(0, 0, 0); + helper.setSprinting(false); + } + } + + @SubscribeEvent + public static void onAttack(AttackEntityEvent event) { + if (event.getEntity() instanceof ServerPlayer helper && isHelping(helper.getUUID())) event.setCanceled(true); + } + + @SubscribeEvent + public static void onHelperHurt(LivingHurtEvent event) { + if (event.getEntity() instanceof ServerPlayer helper) cancelHelpUpByHelper(helper.getUUID()); + } + + @SubscribeEvent + public static void onLogout(PlayerEvent.PlayerLoggedOutEvent event) { + cancelHelpUpByHelper(event.getEntity().getUUID()); + } + + @SubscribeEvent + public static void onRespawn(PlayerEvent.PlayerRespawnEvent event) { + cancelHelpUpByHelper(event.getEntity().getUUID()); + } + + private static Mob findMob(UUID id) { + MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); + if (server == null) return null; + for (ServerLevel level : server.getAllLevels()) { + Entity entity = level.getEntity(id); + if (entity instanceof Mob mob) return mob; + } + return null; + } + + private static ServerPlayer findPlayer(UUID id) { + MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); + return server == null ? null : server.getPlayerList().getPlayer(id); + } + + private static void clearProgress(ServerPlayer player) { + NetworkHandler.CHANNEL.send(PacketDistributor.PLAYER.with(() -> player), + new ExecutionProgressPacket(0, 1, ExecutionProgressPacket.Action.NONE, null)); + } +} diff --git a/src/main/java/net/fretux/knockedback/config/Config.java b/src/main/java/net/fretux/knockedback/config/Config.java index 745eefd..7b84533 100644 --- a/src/main/java/net/fretux/knockedback/config/Config.java +++ b/src/main/java/net/fretux/knockedback/config/Config.java @@ -34,6 +34,11 @@ public class Config { public final ForgeConfigSpec.DoubleValue npcHelpUpSearchRange; public final ForgeConfigSpec.DoubleValue npcThreatRange; public final ForgeConfigSpec.ConfigValue> npcHelpUpAllowlist; + public final ForgeConfigSpec.BooleanValue petKnockdownEnabled; + public final ForgeConfigSpec.IntValue petKnockdownDuration; + public final ForgeConfigSpec.DoubleValue petRecoveryHealth; + public final ForgeConfigSpec.BooleanValue petHelpUpEnabled; + public final ForgeConfigSpec.ConfigValue> protectedEntityTypes; public Common(ForgeConfigSpec.Builder builder) { builder.push("general"); @@ -124,6 +129,30 @@ public class Config { .comment("If true, Totems of Undying prevent entering the knocked state. Default: true.") .define("totemPreventsKnockdown", true); + builder.pop().push("pets"); + + petKnockdownEnabled = builder + .comment("If true, owned pets and listed follower types survive lethal combat damage. Default: true.") + .define("petKnockdownEnabled", true); + + petKnockdownDuration = builder + .comment("How long (in ticks) a pet remains knocked before recovering. Default: 400 (20 seconds).") + .defineInRange("petKnockdownDuration", 400, 20, 1200); + + petRecoveryHealth = builder + .comment("Health restored to a pet after automatic recovery. Default: 4.0 (2 hearts).") + .defineInRange("petRecoveryHealth", 4.0D, 1.0D, 100.0D); + + petHelpUpEnabled = builder + .comment("If true, a player can hold the help-up key near a knocked pet to rescue it early. Default: true.") + .define("petHelpUpEnabled", true); + + protectedEntityTypes = builder + .comment("Additional entity type ids to protect, for modded followers without vanilla ownership data.", + "Every mob of a listed type is protected, so list only intended follower types.", + "Example: [\"modid:follower\"]") + .defineListAllowEmpty("protectedEntityTypes", List.of(), o -> o instanceof String); + builder.pop(); } } diff --git a/src/main/resources/assets/knockedback/lang/de_de.json b/src/main/resources/assets/knockedback/lang/de_de.json index 06d7429..b3c8755 100644 --- a/src/main/resources/assets/knockedback/lang/de_de.json +++ b/src/main/resources/assets/knockedback/lang/de_de.json @@ -24,6 +24,8 @@ "message.knockedback.been_dropped": "Du wurdest von %1$s abgesetzt", "message.knockedback.you_executed": "Du hast %1$s hingerichtet!", "message.knockedback.you_were_executed": "Du wurdest von %1$s hingerichtet!", + "message.knockedback.pet_knocked": "%s wurde niedergeschlagen. Halte H in der N\u00e4he gedr\u00fcckt, um zu helfen.", + "message.knockedback.pet_recovered": "%s hat sich erholt.", "overlay.knockedback.execution_progress": "Hinrichten", "overlay.knockedback.help_up_progress": "Aufhelfen", "overlay.knockedback.executing_target": "%s hinrichten...", diff --git a/src/main/resources/assets/knockedback/lang/en_us.json b/src/main/resources/assets/knockedback/lang/en_us.json index 9560731..d39b7f6 100644 --- a/src/main/resources/assets/knockedback/lang/en_us.json +++ b/src/main/resources/assets/knockedback/lang/en_us.json @@ -24,6 +24,8 @@ "message.knockedback.you_were_helped_up": "You were helped up by %s!", "message.knockedback.you_executed": "You executed %s!", "message.knockedback.you_were_executed": "You were executed by %s!", + "message.knockedback.pet_knocked": "%s was knocked down. Hold H nearby to help them recover.", + "message.knockedback.pet_recovered": "%s recovered from knockdown.", "overlay.knockedback.execution_progress": "Executing", "overlay.knockedback.help_up_progress": "Helping up", "overlay.knockedback.executing_target": "Executing %s...",