diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackService.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackService.java index bae8afab..b4135a9c 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackService.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackService.java @@ -1,21 +1,20 @@ package com.eternalcode.combat.fight.knockback; import com.eternalcode.combat.config.implementation.PluginConfig; -import com.eternalcode.combat.region.Point; import com.eternalcode.combat.region.Region; import com.eternalcode.combat.region.RegionProvider; import com.eternalcode.commons.bukkit.scheduler.MinecraftScheduler; +import com.eternalcode.commons.scheduler.Task; import io.papermc.lib.PaperLib; -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause; import org.bukkit.util.Vector; +import java.time.Duration; +import java.util.*; + public final class KnockbackService { private final PluginConfig config; @@ -23,6 +22,7 @@ public final class KnockbackService { private final RegionProvider regionProvider; private final Map insideRegion = new HashMap<>(); + private final Set fallbackActive = new HashSet<>(); public KnockbackService(PluginConfig config, MinecraftScheduler scheduler, RegionProvider regionProvider) { this.config = config; @@ -31,57 +31,257 @@ public KnockbackService(PluginConfig config, MinecraftScheduler scheduler, Regio } public void knockbackLater(Region region, Player player, Duration duration) { - this.scheduler.runLater(() -> this.knockback(region, player), duration); + scheduler.runLater(() -> this.knockback(region, player), duration); + } + + public void knockback(Region region, Player player) { + + if (player.isInsideVehicle()) { + player.leaveVehicle(); + } + + Location loc = player.getLocation(); + Vector direction = getDirectionToEdge(region, loc); + + if (config.knockback.dampenVelocity) { + player.setVelocity(player.getVelocity().multiply(config.knockback.dampenFactor)); + } + + boolean onGround = Math.abs(player.getVelocity().getY()) < 0.08; + + double y = onGround + ? config.knockback.vertical + : Math.min(player.getVelocity().getY(), config.knockback.maxAirVertical); + + Vector velocity = direction.multiply(config.knockback.multiplier).setY(y); + + player.setFallDistance(0); + player.setVelocity(velocity); + + + if (config.knockback.useTeleport) { + scheduleSmartFallback(player, region); + } } public void forceKnockbackLater(Player player, Region region) { - if (insideRegion.containsKey(player.getUniqueId())) { + UUID uuid = player.getUniqueId(); + + if (insideRegion.containsKey(uuid)) { return; } - insideRegion.put(player.getUniqueId(), region); + insideRegion.put(uuid, region); scheduler.runLater(player.getLocation(), () -> { - insideRegion.remove(player.getUniqueId()); - Location playerLocation = player.getLocation(); - if (!region.contains(playerLocation) && !regionProvider.isInRegion(playerLocation)) { + insideRegion.remove(uuid); + + Location loc = player.getLocation(); + double velocity = player.getVelocity().lengthSquared(); + + if (velocity > 0.02) { return; } - if (player.isInsideVehicle()) { - player.leaveVehicle(); + if (!region.contains(loc)) { + return; + } + + double distanceToEdge = getDistanceToEdge(region, loc); + + if (distanceToEdge > 1.5) { + return; + } + + if (fallbackActive.contains(uuid)) { + return; + } + + Location generated = generate( + player.getLocation(), + Point2D.from(region.getMin()), + Point2D.from(region.getMax()), + 0 + ); + + Location safe = makeSafe(generated); + if (safe == null || safe.getWorld() == null) { + return; } - Location location = generate(playerLocation, Point2D.from(region.getMin()), Point2D.from(region.getMax())); + PaperLib.teleportAsync(player, safe.clone(), TeleportCause.PLUGIN); - PaperLib.teleportAsync(player, location, TeleportCause.PLUGIN); - }, this.config.knockback.forceDelay); + }, config.knockback.forceDelay); } - private Location generate(Location playerLocation, Point2D minX, Point2D maxX) { + private void scheduleSmartFallback(Player player, Region region) { + UUID uuid = player.getUniqueId(); + + if (fallbackActive.contains(uuid)) { + return; + } + + fallbackActive.add(uuid); + + final Task[] taskRef = new Task[1]; + + taskRef[0] = scheduler.timer(() -> { + + Location check = player.getLocation(); + double velocity = player.getVelocity().lengthSquared(); + + if (!region.contains(check)) { + fallbackActive.remove(uuid); + taskRef[0].cancel(); + return; + } + + if (velocity > 0.02) { + return; + } + + Location generated = generate( + player.getLocation(), + Point2D.from(region.getMin()), + Point2D.from(region.getMax()), + 0 + ); + + Location safe = makeSafe(generated); + if (safe == null || safe.getWorld() == null) { + fallbackActive.remove(uuid); + taskRef[0].cancel(); + return; + } + + PaperLib.teleportAsync(player, safe.clone(), TeleportCause.PLUGIN); + + fallbackActive.remove(uuid); + taskRef[0].cancel(); + + }, + Duration.ofMillis(100), + Duration.ofMillis(100)); + } + + private Location makeSafe(Location loc) { + if (loc == null || loc.getWorld() == null) return loc; + + return config.knockback.safeGroundCheck + ? findSafeGround(loc) + : loc.getWorld().getHighestBlockAt(loc).getLocation().add(0, config.knockback.groundOffset, 0); + } + + private Location findSafeGround(Location loc) { + + if (loc.getWorld() == null) return loc; + + Location check = loc.clone(); + int minY = loc.getWorld().getMinHeight(); + + for (int y = check.getBlockY(); y > minY; y--) { + check.setY(y); + + Material type = check.getBlock().getType(); + Material above = check.clone().add(0, 1, 0).getBlock().getType(); + Material above2 = check.clone().add(0, 2, 0).getBlock().getType(); + + if (type.isSolid() + && !config.knockback.unsafeGroundBlocks.contains(type) + && above.isAir() + && above2.isAir()) { + + return check.clone().add(0, config.knockback.groundOffset, 0); + } + } + + return getSafeHighest(loc); + } + + private Location getSafeHighest(Location loc) { + if (loc == null || loc.getWorld() == null) return loc; + + if (!config.knockback.safeHighestFallback) { + return loc.getWorld() + .getHighestBlockAt(loc) + .getLocation() + .add(0, config.knockback.groundOffset, 0); + } + + Location highest = loc.getWorld().getHighestBlockAt(loc).getLocation(); + int minY = loc.getWorld().getMinHeight(); + + int startY = highest.getBlockY(); + int maxScan = config.knockback.safeHighestMaxScan; + + int endY = (maxScan < 0) + ? minY + : Math.max(minY, startY - maxScan); + + for (int y = startY; y > endY; y--) { + highest.setY(y); + + Material type = highest.getBlock().getType(); + Material above = highest.clone().add(0, 1, 0).getBlock().getType(); + Material above2 = highest.clone().add(0, 2, 0).getBlock().getType(); + + if (type.isSolid() + && !config.knockback.unsafeGroundBlocks.contains(type) + && above.isAir() + && above2.isAir()) { + + return highest.clone().add(0, config.knockback.groundOffset, 0); + } + } + + return config.knockback.cancelIfNoSafeGround ? null : loc; + } + + private Location generate(Location playerLocation, Point2D minX, Point2D maxX, int attempts) { + if (attempts >= config.knockback.maxAttempts) { + return playerLocation; + } + Location location = KnockbackOutsideRegionGenerator.generate(minX, maxX, playerLocation); + Optional otherRegion = regionProvider.getRegion(location); if (otherRegion.isPresent()) { + Region region = otherRegion.get(); - return generate(playerLocation, minX.min(region.getMin()), maxX.max(region.getMax())); + + return generate( + playerLocation, + minX.min(region.getMin()), + maxX.max(region.getMax()), + attempts + 1 + ); } return location; } - public void knockback(Region region, Player player) { - if (player.isInsideVehicle()) { - player.leaveVehicle(); - } + private Vector getDirectionToEdge(Region region, Location loc) { + double dxMin = loc.getX() - region.getMin().getX(); + double dxMax = region.getMax().getX() - loc.getX(); + double dzMin = loc.getZ() - region.getMin().getZ(); + double dzMax = region.getMax().getZ() - loc.getZ(); + + double min = Math.min(Math.min(dxMin, dxMax), Math.min(dzMin, dzMax)); - Point point = region.getCenter(); - Location subtract = player.getLocation().subtract(point.x(), 0, point.z()); + if (Math.abs(min - dxMin) < 1e-6) return new Vector(-1, 0, 0); + if (Math.abs(min - dxMax) < 1e-6) return new Vector(1, 0, 0); + if (Math.abs(min - dzMin) < 1e-6) return new Vector(0, 0, -1); + + return new Vector(0, 0, 1); + } - Vector knockbackVector = new Vector(subtract.getX(), 0, subtract.getZ()).normalize(); - double multiplier = this.config.knockback.multiplier; - Vector configuredVector = new Vector(multiplier, 0.5, multiplier); + private double getDistanceToEdge(Region region, Location loc) { + double dxMin = loc.getX() - region.getMin().getX(); + double dxMax = region.getMax().getX() - loc.getX(); + double dzMin = loc.getZ() - region.getMin().getZ(); + double dzMax = region.getMax().getZ() - loc.getZ(); - player.setVelocity(knockbackVector.multiply(configuredVector)); + return Math.min(Math.min(dxMin, dxMax), Math.min(dzMin, dzMax)); } } diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackSettings.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackSettings.java index 8b722435..48d08248 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackSettings.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/fight/knockback/KnockbackSettings.java @@ -2,18 +2,147 @@ import eu.okaeri.configs.OkaeriConfig; import eu.okaeri.configs.annotation.Comment; +import org.bukkit.Material; + import java.time.Duration; +import java.util.Set; public class KnockbackSettings extends OkaeriConfig { @Comment({ - "# Adjust the knockback multiplier for restricted regions.", - "# Higher values increase the knockback distance. Avoid using negative values.", - "# A value of 1.0 typically knocks players 2-4 blocks away." + "# Horizontal knockback strength multiplier.", + "# This controls how far the player is pushed horizontally.", + "#", + "# Applied in: direction.multiply(multiplier)", + "# Example: 1.0 ≈ 2–4 blocks push" + }) + public double multiplier = 1.0; + + @Comment({ + "# Vertical velocity applied when the player is considered on ground.", + "#", + "# Used when Y velocity is near zero (< 0.08).", + "# Higher values = more upward knockback.", + "# Recommended: 0.15 - 0.3" + }) + public double vertical = 0.2; + + @Comment({ + "# Maximum vertical velocity when player is already airborne.", + "#", + "# Prevents stacking vertical velocity or launching too high.", + "# Used as: min(currentY, maxAirVertical)" }) - public double multiplier = 1; + public double maxAirVertical = 0.2; - @Comment({ "# Time after which the player will be force knocked back outside the safe zone" }) + @Comment({ + "# Delay before force teleport is applied after entering a region.", + "#", + "# Used in forceKnockbackLater().", + "# Prevents instant teleport when crossing region borders." + }) public Duration forceDelay = Duration.ofSeconds(1); + @Comment({ + "# Enables teleport fallback after knockback.", + "#", + "# If knockback does not push the player outside the region,", + "# a safe location will be generated and player will be teleported." + }) + public boolean useTeleport = false; + + @Comment({ + "# Blocks that are considered unsafe to stand on when searching for ground.", + "# These blocks will be ignored during safe ground detection.", + "#", + "# Used in findSafeGround():", + "# - Skips these blocks even if they are solid", + "# - Prevents teleporting onto dangerous or invalid blocks", + "#", + "# Examples:", + "# - BARRIER (invisible collision)", + "# - LAVA / WATER (damage / movement issues)", + "# - CACTUS / MAGMA (damage blocks)", + }) + public Set unsafeGroundBlocks = Set.of( + Material.BARRIER, + Material.LAVA, + Material.WATER, + Material.CACTUS, + Material.MAGMA_BLOCK, + Material.FIRE, + Material.SOUL_FIRE + ); + + @Comment({ + "# Use custom safe ground detection instead of highest block. (recommended)", + "#", + "# true -> scans downward for safe landing", + "# false -> uses Bukkit getHighestBlockAt()", + "#", + "# Safe mode prevents:", + "# - Landing on roofs", + "# - Landing on barriers", + "# - Unsafe teleport positions" + }) + public boolean safeGroundCheck = true; + + @Comment({ + "# Enables safe fallback scanning instead of using Bukkit highest block directly.", + "#", + "# true -> scans downward from highest block to find safe ground", + "# false -> uses Bukkit getHighestBlockAt (faster but unsafe)" + }) + public boolean safeHighestFallback = true; + + @Comment({ + "# Maximum vertical scan distance for safe highest fallback.", + "#", + "# Prevents excessive scanning in very tall worlds.", + "# Set to -1 to scan all the way down to min world height." + }) + public int safeHighestMaxScan = -1; + + @Comment({ + "# If true, prevents teleport if no safe ground is found at all.", + "#", + "# true -> cancel teleport", + "# false -> fallback to original location" + }) + public boolean cancelIfNoSafeGround = false; + + @Comment({ + "# Y offset added after finding ground.", + "#", + "# Usually 1.0 = player stands exactly on block.", + "# Can be increased slightly to prevent clipping issues." + }) + public double groundOffset = 1.0; + + @Comment({ + "# Reduces player's current velocity BEFORE applying knockback.", + "#", + "# Helps create smoother and more consistent knockback.", + "# Prevents stacking velocity from previous movement." + }) + public boolean dampenVelocity = true; + + @Comment({ + "# Multiplier applied when dampening velocity.", + "#", + "# 1.0 = no change", + "# 0.0 = completely stop player", + "# Recommended: 0.6 - 0.9" + }) + public double dampenFactor = 0.8; + + @Comment({ + "# Maximum recursive attempts when generating a safe location.", + "#", + "# Used in generate() method.", + "# Prevents infinite loops when regions overlap or chain.", + "#", + "# If exceeded -> fallback to player current location." + }) + public int maxAttempts = 5; } diff --git a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/region/worldguard/WorldGuardRegion.java b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/region/worldguard/WorldGuardRegion.java index 4d0a5a8b..0ffc17de 100644 --- a/eternalcombat-plugin/src/main/java/com/eternalcode/combat/region/worldguard/WorldGuardRegion.java +++ b/eternalcombat-plugin/src/main/java/com/eternalcode/combat/region/worldguard/WorldGuardRegion.java @@ -8,13 +8,14 @@ import org.bukkit.World; record WorldGuardRegion(World world, ProtectedRegion region) implements Region { + @Override public Point getCenter() { BlockVector3 min = this.region.getMinimumPoint(); BlockVector3 max = this.region.getMaximumPoint(); - double x = (double) (min.getX() + max.getX()) / 2; - double z = (double) (min.getZ() + max.getZ()) / 2; + double x = (min.x() + max.x()) / 2.0; + double z = (min.z() + max.z()) / 2.0; return new Point(this.world, x, z); } @@ -22,12 +23,13 @@ public Point getCenter() { @Override public Location getMin() { BlockVector3 min = this.region.getMinimumPoint(); - return new Location(this.world, min.getX(), min.getY(), min.getZ()); + return new Location(this.world, min.x(), min.y(), min.z()); } @Override public Location getMax() { BlockVector3 max = this.region.getMaximumPoint(); - return new Location(this.world, max.getX(), max.getY(), max.getZ()); + return new Location(this.world, max.x(), max.y(), max.z()); } + }