Protect owned pets from lethal damage on Forge

This commit is contained in:
Frederik Spirgi
2026-10-12 09:35:41 +02:00
parent b82489f9d3
commit 59ccc9e106
10 changed files with 286 additions and 12 deletions
+1
View File
@@ -16,6 +16,7 @@ out
# gradle # gradle
build build
.gradle .gradle
.gradle-user-home
# other # other
eclipse eclipse
+3 -1
View File
@@ -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. 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.
+1
View File
@@ -5,6 +5,7 @@ All notable changes since version 1.0.0 are documented in this file.
## Unreleased ## Unreleased
### Added ### 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 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 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. - Added separate configuration for mob execution time, executor movement, and knocked-player movement.
@@ -37,26 +37,27 @@ public class HelpUpHandler {
return helpUpAttempts.values().stream().anyMatch(attempt -> attempt.helperId.equals(helperId)); return helpUpAttempts.values().stream().anyMatch(attempt -> attempt.helperId.equals(helperId));
} }
public static void startHelpUp(ServerPlayer helper) { public static boolean startHelpUp(ServerPlayer helper) {
if (!Config.COMMON.helpUpEnabled.get()) return; if (!Config.COMMON.helpUpEnabled.get()) return false;
if (KnockedManager.isKnocked(helper)) { if (KnockedManager.isKnocked(helper)) {
helper.sendSystemMessage(Component.translatable("message.knockedback.cannot_help_while_knocked")); helper.sendSystemMessage(Component.translatable("message.knockedback.cannot_help_while_knocked"));
return; return false;
} }
if (PlayerExecutionHandler.isExecuting(helper.getUUID())) return; if (PlayerExecutionHandler.isExecuting(helper.getUUID())) return false;
if (isHelping(helper.getUUID())) return; if (isHelping(helper.getUUID()) || PetKnockdownHandler.isHelping(helper.getUUID())) return false;
Player target = findNearestKnocked(helper); Player target = findNearestKnocked(helper);
if (target == null) return; if (target == null) return false;
UUID knockedId = target.getUUID(); UUID knockedId = target.getUUID();
if (CarryManager.isBeingCarried(knockedId)) { if (CarryManager.isBeingCarried(knockedId)) {
helper.sendSystemMessage(Component.translatable("message.knockedback.cannot_help_carried_player")); helper.sendSystemMessage(Component.translatable("message.knockedback.cannot_help_carried_player"));
return; return false;
} }
if (helpUpAttempts.containsKey(knockedId)) return; if (helpUpAttempts.containsKey(knockedId)) return false;
if (NpcHelpUpHandler.isBeingHelped(knockedId)) return; if (NpcHelpUpHandler.isBeingHelped(knockedId)) return false;
helpUpAttempts.put(knockedId, new HelpUpAttempt(helper.getUUID())); helpUpAttempts.put(knockedId, new HelpUpAttempt(helper.getUUID()));
helper.sendSystemMessage(Component.translatable("message.knockedback.help_up_started", target.getDisplayName())); helper.sendSystemMessage(Component.translatable("message.knockedback.help_up_started", target.getDisplayName()));
target.sendSystemMessage(Component.translatable("message.knockedback.being_helped", helper.getDisplayName())); target.sendSystemMessage(Component.translatable("message.knockedback.being_helped", helper.getDisplayName()));
return true;
} }
public static void cancelHelpUp(UUID knockedId) { public static void cancelHelpUp(UUID knockedId) {
@@ -26,9 +26,12 @@ public class HelpUpKnockedPacket {
ServerPlayer helper = ctx.get().getSender(); ServerPlayer helper = ctx.get().getSender();
if (helper == null) return; if (helper == null) return;
if (msg.helping) { if (msg.helping) {
HelpUpHandler.startHelpUp(helper); if (!HelpUpHandler.startHelpUp(helper)) {
PetKnockdownHandler.startHelpUp(helper);
}
} else { } else {
HelpUpHandler.cancelHelpUpByHelper(helper.getUUID()); HelpUpHandler.cancelHelpUpByHelper(helper.getUUID());
PetKnockdownHandler.cancelHelpUpByHelper(helper.getUUID());
} }
}); });
ctx.get().setPacketHandled(true); ctx.get().setPacketHandled(true);
@@ -121,7 +121,8 @@ public final class NpcHelpUpHandler {
} }
private static boolean isValidHelper(Mob mob, ServerPlayer player) { 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()); ResourceLocation id = ForgeRegistries.ENTITY_TYPES.getKey(mob.getType());
if (id == null || !Config.COMMON.npcHelpUpAllowlist.get().contains(id.toString())) return false; if (id == null || !Config.COMMON.npcHelpUpAllowlist.get().contains(id.toString())) return false;
if (mob instanceof TamableAnimal pet) return pet.isTame() && pet.isOwnedBy(player); if (mob instanceof TamableAnimal pet) return pet.isTame() && pet.isOwnedBy(player);
@@ -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<UUID, HelpAttempt> 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<UUID, HelpAttempt> 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<UUID, HelpAttempt> 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));
}
}
@@ -34,6 +34,11 @@ public class Config {
public final ForgeConfigSpec.DoubleValue npcHelpUpSearchRange; public final ForgeConfigSpec.DoubleValue npcHelpUpSearchRange;
public final ForgeConfigSpec.DoubleValue npcThreatRange; public final ForgeConfigSpec.DoubleValue npcThreatRange;
public final ForgeConfigSpec.ConfigValue<List<? extends String>> npcHelpUpAllowlist; public final ForgeConfigSpec.ConfigValue<List<? extends String>> 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<List<? extends String>> protectedEntityTypes;
public Common(ForgeConfigSpec.Builder builder) { public Common(ForgeConfigSpec.Builder builder) {
builder.push("general"); builder.push("general");
@@ -124,6 +129,30 @@ public class Config {
.comment("If true, Totems of Undying prevent entering the knocked state. Default: true.") .comment("If true, Totems of Undying prevent entering the knocked state. Default: true.")
.define("totemPreventsKnockdown", 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(); builder.pop();
} }
} }
@@ -24,6 +24,8 @@
"message.knockedback.been_dropped": "Du wurdest von %1$s abgesetzt", "message.knockedback.been_dropped": "Du wurdest von %1$s abgesetzt",
"message.knockedback.you_executed": "Du hast %1$s hingerichtet!", "message.knockedback.you_executed": "Du hast %1$s hingerichtet!",
"message.knockedback.you_were_executed": "Du wurdest von %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.execution_progress": "Hinrichten",
"overlay.knockedback.help_up_progress": "Aufhelfen", "overlay.knockedback.help_up_progress": "Aufhelfen",
"overlay.knockedback.executing_target": "%s hinrichten...", "overlay.knockedback.executing_target": "%s hinrichten...",
@@ -24,6 +24,8 @@
"message.knockedback.you_were_helped_up": "You were helped up by %s!", "message.knockedback.you_were_helped_up": "You were helped up by %s!",
"message.knockedback.you_executed": "You executed %s!", "message.knockedback.you_executed": "You executed %s!",
"message.knockedback.you_were_executed": "You were executed by %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.execution_progress": "Executing",
"overlay.knockedback.help_up_progress": "Helping up", "overlay.knockedback.help_up_progress": "Helping up",
"overlay.knockedback.executing_target": "Executing %s...", "overlay.knockedback.executing_target": "Executing %s...",