diff --git a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java index 33662a6..49dea46 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java +++ b/src/main/java/net/tfminecraft/vehicleframework/weapons/shooter/DefaultShooter.java @@ -2,12 +2,14 @@ import java.util.ArrayList; import java.util.List; +import org.bukkit.FluidCollisionMode; import org.bukkit.Location; import org.bukkit.Particle; import org.bukkit.World; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.util.RayTraceResult; import org.bukkit.util.Vector; import net.tfminecraft.vehicleframework.VehicleFramework; @@ -23,6 +25,13 @@ public class DefaultShooter implements Shooter { //Regular shooting method for things like cannonballs, explode on impact + // Vanilla armour stand physics per tick, so bomblets fall as they did under vanilla movement. + private static final double CLUSTER_GRAVITY = 0.08; + private static final double CLUSTER_VERTICAL_DRAG = 0.98; + private static final double CLUSTER_HORIZONTAL_DRAG = 0.91; + // Safety stop for a bomblet that never reaches anything. + private static final int CLUSTER_MAX_TICKS = 200; + private HitChecker checker = new HitChecker(); private ProjectileShooter shooter; @@ -108,29 +117,60 @@ private void sendCluster(Location original, ClusterBomb a, List players, Entity armorStand = a.getClusterData().spawn(original); projectiles.add(armorStand); Cache.projectiles.add(armorStand); + // The task below moves it; vanilla gravity would move it a second time near players. + armorStand.setGravity(false); Vector velocity = new Vector( (Math.random() - 0.5) * a.getSpread(), -3.5, // downward velocity (Math.random() - 0.5) * a.getSpread() ); - armorStand.setVelocity(velocity); // Handle explosion on ground impact new BukkitRunnable() { int i = 0; + boolean landed = false; + Location current = armorStand.getLocation(); public void run() { - if (i > 5 && (checker.hasHit(armorStand, projectiles) || armorStand.isOnGround() || armorStand.isDead())) { + if (i > 5 && (landed || checker.hasHit(armorStand, projectiles) || armorStand.isDead())) { shooter.triggerExplosion(players, armorStand.getLocation(), a.getClusterData(), w); - armorStand.remove(); - projectiles.remove(armorStand); - Cache.projectiles.remove(armorStand); - cancel(); + removeBomblet(); + return; + } + if (i >= CLUSTER_MAX_TICKS) { + removeBomblet(); return; } + if (!landed) { + landed = move(); + } a.getData().fx(players, armorStand.getLocation(), 1f, i); i++; } + + // Not left to vanilla physics: entities stop moving outside simulation distance. + private boolean move() { + RayTraceResult hit = world.rayTraceBlocks(current, velocity.clone().normalize(), velocity.length(), FluidCollisionMode.ALWAYS, false); + if (hit != null) { + // Stop on the surface instead of passing through thin roofs or into the ground. + current = hit.getHitPosition().toLocation(world, current.getYaw(), current.getPitch()); + armorStand.teleport(current); + return true; + } + current.add(velocity); + armorStand.teleport(current); + velocity.setX(velocity.getX() * CLUSTER_HORIZONTAL_DRAG); + velocity.setY((velocity.getY() - CLUSTER_GRAVITY) * CLUSTER_VERTICAL_DRAG); + velocity.setZ(velocity.getZ() * CLUSTER_HORIZONTAL_DRAG); + return false; + } + + private void removeBomblet() { + armorStand.remove(); + projectiles.remove(armorStand); + Cache.projectiles.remove(armorStand); + cancel(); + } }.runTaskTimer(VehicleFramework.plugin, 0L, 1L); } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java index 9f046bb..1d0e075 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/weapons/WeaponsCoverageTest.java @@ -971,13 +971,31 @@ void clusterFuseCreatesConfiguredChildrenAndCleansThemOnImpact() throws Exceptio verify(shell).remove(); assertEquals(3, r.spawned.size()); assertEquals(2, Cache.projectiles.size()); - for (Entity child : r.spawned.subList(1, 3)) { - assertEquals(new Vector(0, -3.5, 0), child.getVelocity()); - when(child.isOnGround()).thenReturn(true); + List children = r.spawned.subList(1, 3); + Map startY = new HashMap<>(); + for (Entity child : children) { + verify(child).setGravity(false); + verify(child, never()).setVelocity(any()); + startY.put(child, child.getLocation().getY()); } - r.ticks(7); + r.tick(); + for (Entity child : children) + assertEquals(startY.get(child) - 3.5, child.getLocation().getY(), 1e-9); + when(r.world.rayTraceBlocks( + any(Location.class), + any(Vector.class), + anyDouble(), + eq(FluidCollisionMode.ALWAYS), + eq(false))) + .thenReturn(new RayTraceResult(new Vector(0, 40, 0))); + r.ticks(5); + assertEquals(2, Cache.projectiles.size(), "Landed bomblets still wait out the arming delay"); + r.tick(); assertTrue(Cache.projectiles.isEmpty()); - for (Entity child : r.spawned.subList(1, 3)) verify(child).remove(); + for (Entity child : children) { + verify(child).remove(); + assertEquals(40, child.getLocation().getY(), 1e-9); + } verify(player) .spawnParticle( eq(Particle.EXPLOSION_EMITTER), @@ -1016,6 +1034,82 @@ void clusterImpactAtFuseBoundaryDetonatesOnlyOnce() throws Exception { } } + @Test + void clusterBombletsFallWithArmourStandDragWithoutVanillaTicks() throws Exception { + Entity child = fireCluster(7.5); + Location start = child.getLocation(); + r.tick(); + Location first = child.getLocation(); + r.tick(); + Location second = child.getLocation(); + assertEquals(start.getY() - 3.5, first.getY(), 1e-9); + assertEquals(first.getY() - 3.58 * .98, second.getY(), 1e-9); + double firstStepX = first.getX() - start.getX(); + double secondStepX = second.getX() - first.getX(); + double firstStepZ = first.getZ() - start.getZ(); + double secondStepZ = second.getZ() - first.getZ(); + assertEquals(firstStepX * .91, secondStepX, 1e-9); + assertEquals(firstStepZ * .91, secondStepZ, 1e-9); + verify(child, never()).isOnGround(); + } + + @Test + void clusterBombletsDetonateNextToBlocksTheyDidNotRayTrace() throws Exception { + try (var explosions = mockStatic(ExplosionCreator.class)) { + Entity child = fireCluster(0); + r.ticks(6); + verify(child, never()).remove(); + Location at = child.getLocation(); + r.block(at.getBlockX(), at.getBlockY() - 1, at.getBlockZ(), Material.STONE); + r.tick(); + verify(child).remove(); + assertTrue(Cache.projectiles.isEmpty()); + explosions.verify( + () -> + ExplosionCreator.triggerExplosion( + eq(at), anyDouble(), anyDouble(), anyDouble(), anyString(), eq(false))); + } + } + + @Test + void clusterBombletsThatNeverLandAreRemovedWithoutExploding() throws Exception { + try (var explosions = mockStatic(ExplosionCreator.class)) { + Entity child = fireCluster(0); + r.ticks(200); + verify(child, never()).remove(); + r.tick(); + verify(child).remove(); + assertTrue(Cache.projectiles.isEmpty()); + assertEquals(0, r.pending()); + explosions.verifyNoInteractions(); + } + } + + private Entity fireCluster(double spread) throws Exception { + ClusterBomb ammo = + (ClusterBomb) + ammo( + "CLUSTER", + "fuse: 1\n" + + "amount: 1\n" + + "spread: " + spread + "\n" + + "explosive: false\n" + + "cluster:\n" + + " explosive: true\n" + + " model: {type: item, material: STONE}\n"); + new ProjectileShooter() + .shoot( + List.of(), + vehicle.getEntity(), + new Location(r.world, 0, 1000, 0), + new Vector(0, 0, 1), + ammo, + null); + r.tick(); + assertEquals(2, r.spawned.size()); + return r.spawned.getLast(); + } + @Test void torpedoesLevelAccelerateInWaterAndExpireAfterTheirFuse() throws Exception { for (boolean waterlogged : List.of(false, true)) {