diff --git a/src/main/java/net/minestom/server/entity/Entity.java b/src/main/java/net/minestom/server/entity/Entity.java index 9d6f9d23c..0f64fba0c 100644 --- a/src/main/java/net/minestom/server/entity/Entity.java +++ b/src/main/java/net/minestom/server/entity/Entity.java @@ -91,7 +91,8 @@ public abstract class Entity implements Viewable, EventHandler, DataContainer, P protected Entity vehicle; // Velocity - protected Vector velocity = new Vector(); // Movement in block per second + protected Vector velocity = new Vector(); // Movement in block per second + protected boolean hasPhysics = true; protected double gravityDragPerTick; protected double gravityAcceleration; @@ -483,7 +484,11 @@ public abstract class Entity implements Viewable, EventHandler, DataContainer, P getVelocity().getZ() / tps ); - this.onGround = CollisionUtils.handlePhysics(this, deltaPos, newPosition, newVelocityOut); + if (this.hasPhysics) { + this.onGround = CollisionUtils.handlePhysics(this, deltaPos, newPosition, newVelocityOut); + } else { + newVelocityOut = deltaPos; + } // World border collision final Position finalVelocityPosition = CollisionUtils.applyWorldBorder(instance, position, newPosition); @@ -862,6 +867,16 @@ public abstract class Entity implements Viewable, EventHandler, DataContainer, P return getPosition().getDistance(entity.getPosition()); } + /** + * Gets the distance squared between two entities. + * + * @param entity the entity to get the distance from + * @return the distance squared between this and {@code entity} + */ + public double getDistanceSquared(@NotNull Entity entity) { + return getPosition().getDistanceSquared(entity.getPosition()); + } + /** * Gets the entity vehicle or null. * diff --git a/src/main/java/net/minestom/server/entity/LivingEntity.java b/src/main/java/net/minestom/server/entity/LivingEntity.java index 0759d732d..9f6d49e8c 100644 --- a/src/main/java/net/minestom/server/entity/LivingEntity.java +++ b/src/main/java/net/minestom/server/entity/LivingEntity.java @@ -22,6 +22,7 @@ import net.minestom.server.sound.Sound; import net.minestom.server.sound.SoundCategory; import net.minestom.server.utils.BlockPosition; import net.minestom.server.utils.Position; +import net.minestom.server.utils.Vector; import net.minestom.server.utils.block.BlockIterator; import net.minestom.server.utils.time.CooldownUtils; import net.minestom.server.utils.time.TimeUnit; @@ -590,6 +591,30 @@ public abstract class LivingEntity extends Entity implements EquipmentHandler { return blocks; } + /** + * Checks whether the current entity has line of sight to the given one. + * If so, it doesn't mean that the given entity is IN line of sight of the current, + * but the current one can rotate so that it will be true. + * + * @param entity the entity to be checked. + * @return if the current entity has line of sight to the given one. + */ + public boolean hasLineOfSight(Entity entity) { + Vector start = getPosition().toVector().add(0D, getEyeHeight(), 0D); + Vector end = entity.getPosition().toVector().add(0D, getEyeHeight(), 0D); + Vector direction = end.subtract(start); + int maxDistance = (int) Math.ceil(direction.length()); + + Iterator it = new BlockIterator(start, direction.normalize(), 0D, maxDistance); + while (it.hasNext()) { + Block block = Block.fromStateId(getInstance().getBlockStateId(it.next())); + if (!block.isAir() && !block.isLiquid()) { + return false; + } + } + return true; + } + /** * Gets the target (not-air) {@link BlockPosition} of the entity. * diff --git a/src/main/java/net/minestom/server/entity/ai/goal/RangedAttackGoal.java b/src/main/java/net/minestom/server/entity/ai/goal/RangedAttackGoal.java new file mode 100644 index 000000000..e8010d88d --- /dev/null +++ b/src/main/java/net/minestom/server/entity/ai/goal/RangedAttackGoal.java @@ -0,0 +1,121 @@ +package net.minestom.server.entity.ai.goal; + +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.ai.GoalSelector; +import net.minestom.server.entity.pathfinding.Navigator; +import net.minestom.server.entity.type.Projectile; +import net.minestom.server.entity.type.projectile.EntityArrow; +import net.minestom.server.utils.Position; +import net.minestom.server.utils.time.CooldownUtils; +import net.minestom.server.utils.time.TimeUnit; +import net.minestom.server.utils.validate.Check; +import org.jetbrains.annotations.NotNull; + +import java.util.function.BiFunction; + +public class RangedAttackGoal extends GoalSelector { + + private long lastShot; + private final int delay; + private final TimeUnit timeUnit; + private final int attackRangeSquared; + private final int desirableRangeSquared; + private final boolean comeClose; + private final double power; + private final double spread; + + private BiFunction projectileGenerator; + + private boolean stop; + + /** + * @param entityCreature the entity to add the goal to. + * @param delay the delay between each shots. + * @param attackRange the allowed range the entity can shoot others. + * @param desirableRange the desirable range: the entity will try to stay no further than this distance. + * @param comeClose whether entity should go as close as possible to the target whether target is not in line of sight. + * @param spread shot spread (0 for best accuracy). + * @param power shot power (1 for normal). + * @param timeUnit the unit of the delay. + */ + public RangedAttackGoal(@NotNull EntityCreature entityCreature, int delay, int attackRange, int desirableRange, boolean comeClose, double power, double spread, @NotNull TimeUnit timeUnit) { + super(entityCreature); + this.delay = delay; + this.timeUnit = timeUnit; + this.attackRangeSquared = attackRange * attackRange; + this.desirableRangeSquared = desirableRange * desirableRange; + this.comeClose = comeClose; + this.power = power; + this.spread = spread; + Check.argCondition(desirableRange > attackRange, "Desirable range can not exceed attack range!"); + } + + public void setProjectileGenerator(BiFunction projectileGenerator) { + this.projectileGenerator = projectileGenerator; + } + + @Override + public boolean shouldStart() { + return findAndUpdateTarget() != null; + } + + @Override + public void start() { + Entity target = findAndUpdateTarget(); + Check.notNull(target, "The target is not expected to be null!"); + this.entityCreature.getNavigator().setPathTo(target.getPosition()); + } + + @Override + public void tick(long time) { + Entity target = findAndUpdateTarget(); + if (target == null) { + this.stop = true; + return; + } + double distanceSquared = this.entityCreature.getDistanceSquared(target); + boolean comeClose = false; + if (distanceSquared <= this.attackRangeSquared) { + if (!CooldownUtils.hasCooldown(time, this.lastShot, this.timeUnit, this.delay)) { + if (this.entityCreature.hasLineOfSight(target)) { + Position to = target.getPosition().clone().add(0D, target.getEyeHeight(), 0D); + + BiFunction projectileGenerator = this.projectileGenerator; + if (projectileGenerator == null) { + projectileGenerator = EntityArrow::new; + } + Projectile projectile = projectileGenerator.apply(this.entityCreature, new Position(0D, 0D, 0D)); + + Projectile.shoot(projectile, this.entityCreature, to, this.power, this.spread); + this.lastShot = time; + } else { + comeClose = this.comeClose; + } + } + } + Navigator navigator = this.entityCreature.getNavigator(); + Position pathPosition = navigator.getPathPosition(); + if (!comeClose && distanceSquared <= this.desirableRangeSquared) { + if (pathPosition != null) { + navigator.setPathTo(null); + } + return; + } + Position targetPosition = target.getPosition(); + if (pathPosition == null || !pathPosition.isSimilar(targetPosition)) { + navigator.setPathTo(targetPosition); + } + } + + @Override + public boolean shouldEnd() { + return this.stop; + } + + @Override + public void end() { + // Stop following the target + this.entityCreature.getNavigator().setPathTo(null); + } +} diff --git a/src/main/java/net/minestom/server/entity/type/Projectile.java b/src/main/java/net/minestom/server/entity/type/Projectile.java index a211ec296..be532e7f3 100644 --- a/src/main/java/net/minestom/server/entity/type/Projectile.java +++ b/src/main/java/net/minestom/server/entity/type/Projectile.java @@ -1,4 +1,58 @@ package net.minestom.server.entity.type; +import net.minestom.server.entity.Entity; +import net.minestom.server.event.entity.EntityShootEvent; +import net.minestom.server.utils.Position; +import net.minestom.server.utils.Vector; +import net.minestom.server.utils.validate.Check; +import org.jetbrains.annotations.NotNull; + +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + public interface Projectile { + + static void shoot(@NotNull Projectile projectile, @NotNull Entity shooter, Position to, double power, double spread) { + Check.argCondition(!(projectile instanceof Entity), "Projectile must be an instance of Entity!"); + EntityShootEvent event = new EntityShootEvent(shooter, projectile, to, power, spread); + shooter.callEvent(EntityShootEvent.class, event); + if (event.isCancelled()) { + Entity proj = (Entity) projectile; + proj.remove(); + return; + } + Position from = shooter.getPosition().clone().add(0D, shooter.getEyeHeight(), 0D); + shoot(projectile, from, to, event.getPower(), event.getSpread()); + } + + @SuppressWarnings("ConstantConditions") + static void shoot(@NotNull Projectile projectile, @NotNull Position from, @NotNull Position to, double power, double spread) { + Check.argCondition(!(projectile instanceof Entity), "Projectile must be an instance of Entity!"); + Entity proj = (Entity) projectile; + double dx = to.getX() - from.getX(); + double dy = to.getY() - from.getY(); + double dz = to.getZ() - from.getZ(); + double xzLength = Math.sqrt(dx * dx + dz * dz); + dy += xzLength * 0.20000000298023224D; + + double length = Math.sqrt(dx * dx + dy * dy + dz * dz); + dx /= length; + dy /= length; + dz /= length; + Random random = ThreadLocalRandom.current(); + spread *= 0.007499999832361937D; + dx += random.nextGaussian() * spread; + dy += random.nextGaussian() * spread; + dz += random.nextGaussian() * spread; + Vector velocity = proj.getVelocity(); + velocity.setX(dx); + velocity.setY(dy); + velocity.setZ(dz); + velocity.multiply(20 * power); + proj.setView( + (float) Math.toDegrees(Math.atan2(dx, dz)), + (float) Math.toDegrees(Math.atan2(dy, Math.sqrt(dx * dx + dz * dz))) + ); + } + } diff --git a/src/main/java/net/minestom/server/entity/type/projectile/EntityAbstractArrow.java b/src/main/java/net/minestom/server/entity/type/projectile/EntityAbstractArrow.java new file mode 100644 index 000000000..7b4999de4 --- /dev/null +++ b/src/main/java/net/minestom/server/entity/type/projectile/EntityAbstractArrow.java @@ -0,0 +1,177 @@ +package net.minestom.server.entity.type.projectile; + +import net.minestom.server.entity.*; +import net.minestom.server.entity.damage.DamageType; +import net.minestom.server.entity.type.Projectile; +import net.minestom.server.instance.Chunk; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.server.utils.BlockPosition; +import net.minestom.server.utils.Position; +import net.minestom.server.utils.Vector; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; + +public class EntityAbstractArrow extends ObjectEntity implements Projectile { + + private final static byte CRITICAL_BIT = 0x01; + private final static byte NO_CLIP_BIT = 0x02; + + private final Entity shooter; + + EntityAbstractArrow(@Nullable Entity shooter, @NotNull EntityType entityType, @NotNull Position spawnPosition) { + super(entityType, spawnPosition); + this.shooter = shooter; + super.hasPhysics = false; + + setBoundingBox(.5F, .5F, .5F); + } + + @Override + public void tick(long time) { + Position posBefore = getPosition().clone(); + super.tick(time); + Position posNow = getPosition().clone(); + if (isStuck(posBefore, posNow)) { + if (super.onGround) { + return; + } + super.onGround = true; + getVelocity().zero(); + sendPacketToViewersAndSelf(getVelocityPacket()); + setNoGravity(true); + } else { + if (!super.onGround) { + return; + } + super.onGround = false; + setNoGravity(false); + } + } + + /** + * Checks whether an arrow is stuck in block / hit an entity. + * + * @param pos position right before current tick. + * @param posNow position after current tick. + * @return if an arrow is stuck in block / hit an entity. + */ + @SuppressWarnings("ConstantConditions") + private boolean isStuck(Position pos, Position posNow) { + if (pos.isSimilar(posNow)) { + return true; + } + + Instance instance = getInstance(); + Chunk chunk = null; + Collection entities = null; + + /* + What we're about to do is to discretely jump from the previous position to the new one. + For each point we will be checking blocks and entities we're in. + */ + double part = .25D; // half of the bounding box + Vector dir = posNow.toVector().subtract(pos.toVector()); + int parts = (int) Math.ceil(dir.length() / part); + Position direction = dir.normalize().multiply(part).toPosition(); + for (int i = 0; i < parts; ++i) { + // If we're at last part, we can't just add another direction-vector, because we can exceed end point. + if (i == parts - 1) { + pos.setX(posNow.getX()); + pos.setY(posNow.getY()); + pos.setZ(posNow.getZ()); + } else { + pos.add(direction); + } + BlockPosition bpos = pos.toBlockPosition(); + Block block = instance.getBlock(bpos.getX(), bpos.getY() - 1, bpos.getZ()); + if (!block.isAir() && !block.isLiquid()) { + teleport(pos); + return true; + } + + Chunk currentChunk = instance.getChunkAt(pos); + if (currentChunk != chunk) { + chunk = currentChunk; + entities = instance.getChunkEntities(chunk) + .stream() + .filter(entity -> entity instanceof LivingEntity) + .collect(Collectors.toSet()); + } + /* + We won't check collisions with entities for first ticks of arrow's life, because it spawns in the + shooter and will immediately damage him. + */ + if (getAliveTicks() < 3) { + continue; + } + Optional victimOptional = entities.stream() + .filter(entity -> entity.getBoundingBox().intersect(pos.getX(), pos.getY(), pos.getZ())) + .findAny(); + if (victimOptional.isPresent()) { + LivingEntity victim = (LivingEntity) victimOptional.get(); + victim.setArrowCount(victim.getArrowCount() + 1); + victim.damage(DamageType.fromProjectile(this.shooter, this), 2F); + remove(); + return super.onGround; + } + } + return false; + } + + public void setCritical(boolean value) { + modifyMask(CRITICAL_BIT, value); + } + + public boolean isCritical() { + return (getMask() & CRITICAL_BIT) != 0; + } + + public void setNoClip(boolean value) { + modifyMask(NO_CLIP_BIT, value); + } + + public boolean isNoClip() { + return (getMask() & NO_CLIP_BIT) != 0; + } + + public void setPiercingLevel(byte value) { + this.metadata.setIndex((byte) 8, Metadata.Byte(value)); + } + + public byte getPiercingLevel() { + return this.metadata.getIndex((byte) 8, (byte) 0); + } + + private byte getMask() { + return this.metadata.getIndex((byte) 7, (byte) 0); + } + + private void setMask(byte mask) { + this.metadata.setIndex((byte) 7, Metadata.Byte(mask)); + } + + private void modifyMask(byte bit, boolean value) { + byte mask = getMask(); + boolean isPresent = (mask & bit) == bit; + if (isPresent == value) { + return; + } + if (value) { + mask |= bit; + } else { + mask &= ~bit; + } + setMask(mask); + } + + @Override + public int getObjectData() { + return this.shooter == null ? 0 : this.shooter.getEntityId() + 1; + } + +} diff --git a/src/main/java/net/minestom/server/entity/type/projectile/EntityArrow.java b/src/main/java/net/minestom/server/entity/type/projectile/EntityArrow.java new file mode 100644 index 000000000..39bc7ef1c --- /dev/null +++ b/src/main/java/net/minestom/server/entity/type/projectile/EntityArrow.java @@ -0,0 +1,25 @@ +package net.minestom.server.entity.type.projectile; + +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Metadata; +import net.minestom.server.utils.Position; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class EntityArrow extends EntityAbstractArrow { + + public EntityArrow(@Nullable Entity shooter, @NotNull Position spawnPosition) { + super(shooter, EntityType.ARROW, spawnPosition); + + } + + public void setColor(int value) { + this.metadata.setIndex((byte) 9, Metadata.VarInt(value)); + } + + public int getColor() { + return this.metadata.getIndex((byte) 9, -1); + } + +} diff --git a/src/main/java/net/minestom/server/entity/type/projectile/EntitySpectralArrow.java b/src/main/java/net/minestom/server/entity/type/projectile/EntitySpectralArrow.java new file mode 100644 index 000000000..944b13690 --- /dev/null +++ b/src/main/java/net/minestom/server/entity/type/projectile/EntitySpectralArrow.java @@ -0,0 +1,15 @@ +package net.minestom.server.entity.type.projectile; + +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.utils.Position; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class EntitySpectralArrow extends EntityAbstractArrow { + + public EntitySpectralArrow(@Nullable Entity shooter, @NotNull Position spawnPosition) { + super(shooter, EntityType.SPECTRAL_ARROW, spawnPosition); + } + +} diff --git a/src/main/java/net/minestom/server/event/entity/EntityShootEvent.java b/src/main/java/net/minestom/server/event/entity/EntityShootEvent.java new file mode 100644 index 000000000..2764a687f --- /dev/null +++ b/src/main/java/net/minestom/server/event/entity/EntityShootEvent.java @@ -0,0 +1,94 @@ +package net.minestom.server.event.entity; + +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.type.Projectile; +import net.minestom.server.event.CancellableEvent; +import net.minestom.server.event.EntityEvent; +import net.minestom.server.utils.Position; +import org.jetbrains.annotations.NotNull; + +/** + * Called with {@link Projectile#shoot(Projectile, Entity, Position, double)}. + */ +public class EntityShootEvent extends EntityEvent implements CancellableEvent { + + private final Projectile projectile; + private final Position to; + private double power; + private double spread; + + private boolean cancelled; + + public EntityShootEvent(@NotNull Entity entity, @NotNull Projectile projectile, @NotNull Position to, double power, double spread) { + super(entity); + this.projectile = projectile; + this.to = to; + this.power = power; + this.spread = spread; + } + + /** + * Gets the projectile. + * + * @return the projectile. + */ + public Projectile getProjectile() { + return this.projectile; + } + + /** + * Gets the position projectile was shot to. + * + * @return the position projectile was shot to. + */ + public Position getTo() { + return this.to; + } + + /** + * Gets shot spread. + * + * @return shot spread. + */ + public double getSpread() { + return this.spread; + } + + /** + * Sets shot spread. + * + * @param spread shot spread. + */ + public void setSpread(double spread) { + this.spread = spread; + } + + /** + * Gets shot power. + * + * @return shot power. + */ + public double getPower() { + return this.power; + } + + /** + * Sets shot power. + * + * @param power shot power. + */ + public void setPower(double power) { + this.power = power; + } + + @Override + public boolean isCancelled() { + return this.cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } + +} diff --git a/src/main/java/net/minestom/server/utils/block/BlockIterator.java b/src/main/java/net/minestom/server/utils/block/BlockIterator.java index 53e622532..ec24391fa 100644 --- a/src/main/java/net/minestom/server/utils/block/BlockIterator.java +++ b/src/main/java/net/minestom/server/utils/block/BlockIterator.java @@ -345,23 +345,19 @@ public class BlockIterator implements Iterator { thirdError -= gridSize; secondError -= gridSize; currentBlock = 2; - return; } else if (secondError > 0) { blockQueue[1] = blockQueue[0].getRelative(mainFace); blockQueue[0] = blockQueue[1].getRelative(secondFace); secondError -= gridSize; currentBlock = 1; - return; } else if (thirdError > 0) { blockQueue[1] = blockQueue[0].getRelative(mainFace); blockQueue[0] = blockQueue[1].getRelative(thirdFace); thirdError -= gridSize; currentBlock = 1; - return; } else { blockQueue[0] = blockQueue[0].getRelative(mainFace); currentBlock = 0; - return; } } diff --git a/src/test/java/demo/Main.java b/src/test/java/demo/Main.java index 6970eb89a..438b4e278 100644 --- a/src/test/java/demo/Main.java +++ b/src/test/java/demo/Main.java @@ -42,6 +42,7 @@ public class Main { commandManager.register(new PotionCommand()); commandManager.register(new TitleCommand()); commandManager.register(new BookCommand()); + commandManager.register(new ShootCommand()); commandManager.setUnknownCommandCallback((sender, command) -> sender.sendMessage("unknown command")); diff --git a/src/test/java/demo/commands/ShootCommand.java b/src/test/java/demo/commands/ShootCommand.java new file mode 100644 index 000000000..8520399ce --- /dev/null +++ b/src/test/java/demo/commands/ShootCommand.java @@ -0,0 +1,67 @@ +package demo.commands; + +import net.minestom.server.command.CommandSender; +import net.minestom.server.command.builder.Arguments; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.builder.exception.ArgumentSyntaxException; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.Player; +import net.minestom.server.entity.type.Projectile; +import net.minestom.server.entity.type.projectile.EntityArrow; +import net.minestom.server.entity.type.projectile.EntitySpectralArrow; + +import java.util.concurrent.ThreadLocalRandom; + +public class ShootCommand extends Command { + + public ShootCommand() { + super("shoot"); + setCondition(this::condition); + setDefaultExecutor(this::defaultExecutor); + var typeArg = ArgumentType.Word("type").from("default", "spectral", "colored"); + setArgumentCallback(this::onTypeError, typeArg); + addSyntax(this::onShootCommand, typeArg); + } + + private boolean condition(CommandSender sender, String commandString) { + if (!sender.isPlayer()) { + sender.sendMessage("The command is only available for player"); + return false; + } + return true; + } + + private void defaultExecutor(CommandSender sender, Arguments args) { + sender.sendMessage("Correct usage: shoot [default/spectral/colored]"); + } + + private void onTypeError(CommandSender sender, ArgumentSyntaxException exception) { + sender.sendMessage("SYNTAX ERROR: '" + exception.getInput() + "' should be replaced by 'default', 'spectral' or 'colored'"); + } + + private void onShootCommand(CommandSender sender, Arguments args) { + Player player = (Player) sender; + String mode = args.getWord("type"); + Projectile projectile; + var pos = player.getPosition().clone().add(0D, player.getEyeHeight(), 0D); + switch (mode) { + case "default": + projectile = new EntityArrow(player, pos); + break; + case "spectral": + projectile = new EntitySpectralArrow(player, pos); + break; + case "colored": + projectile = new EntityArrow(player, pos); + ((EntityArrow) projectile).setColor(ThreadLocalRandom.current().nextInt()); + break; + default: + return; + } + ((Entity) projectile).setInstance(player.getInstance()); + var dir = pos.getDirection().multiply(30D); + pos = pos.clone().add(dir.getX(), dir.getY(), dir.getZ()); + Projectile.shoot(projectile, player, pos, 1D, 0D); + } +}