feat: add aerodynamics record and the capability to set custom horizontal air resistance (#2053)

* feat: add aerodynamics record and the ability to set horizontal drag

* feat: entity physics simulation overhaul

* fix: made physics utils private, renamed to match other utils

* chore: separate concept of chunks and tps from PhysicsUtils, remove bad PhysicsResult constants

* chore: remove synchronization from PhysicsUtils, SYNCHRONIZE_ONLY_ENTITIES collection > set

* chore: remove extra vec allocations

* chore: improved flyingVelocity test

* chore: add all entities with client side prediction to SYNCRHONIZE_ONLY_ENTITIES, refactor velocity

---------

Co-authored-by: iam <iam4722202468@users.noreply.github.com>
This commit is contained in:
DeidaraMC 2024-03-27 15:21:07 -04:00 committed by GitHub
parent d04e9e3e71
commit f034296f28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 206 additions and 161 deletions

View File

@ -0,0 +1,46 @@
package net.minestom.server.collision;
import it.unimi.dsi.fastutil.doubles.DoubleUnaryOperator;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
/**
* Represents the aerodynamic properties of an entity
*
* @param gravity the entity's downward acceleration per tick
* @param horizontalAirResistance the horizontal drag coefficient; the entity's current horizontal
* velocity is multiplied by this every tick
* @param verticalAirResistance the vertical drag coefficient; the entity's current vertical
* * velocity is multiplied by this every tick
*/
public record Aerodynamics(double gravity, double horizontalAirResistance, double verticalAirResistance) {
@Contract(pure = true)
public @NotNull Aerodynamics withGravity(double gravity) {
return new Aerodynamics(gravity, horizontalAirResistance, verticalAirResistance);
}
@Contract(pure = true)
public @NotNull Aerodynamics withHorizontalAirResistance(double horizontalAirResistance) {
return new Aerodynamics(gravity, horizontalAirResistance, verticalAirResistance);
}
@Contract(pure = true)
public @NotNull Aerodynamics withHorizontalAirResistance(@NotNull DoubleUnaryOperator operator) {
return withHorizontalAirResistance(operator.apply(horizontalAirResistance));
}
@Contract(pure = true)
public @NotNull Aerodynamics withVerticalAirResistance(double verticalAirResistance) {
return new Aerodynamics(gravity, horizontalAirResistance, verticalAirResistance);
}
@Contract(pure = true)
public @NotNull Aerodynamics withVerticalAirResistance(@NotNull DoubleUnaryOperator operator) {
return withVerticalAirResistance(operator.apply(verticalAirResistance));
}
@Contract(pure = true)
public @NotNull Aerodynamics withAirResistance(double horizontal, double vertical) {
return new Aerodynamics(gravity, horizontalAirResistance, verticalAirResistance);
}
}

View File

@ -28,7 +28,8 @@ final class BlockCollision {
boolean singleCollision) {
if (velocity.isZero()) {
// TODO should return a constant
return new PhysicsResult(entityPosition, Vec.ZERO, false, false, false, false, velocity, new Point[3], new Shape[3], false, SweepResult.NO_COLLISION);
return new PhysicsResult(entityPosition, Vec.ZERO, false, false, false, false,
velocity, new Point[3], new Shape[3], false, SweepResult.NO_COLLISION);
}
// Fast-exit using cache
final PhysicsResult cachedResult = cachedPhysics(velocity, entityPosition, getter, lastPhysicsResult);

View File

@ -102,9 +102,26 @@ public final class CollisionUtils {
@NotNull Pos position, @NotNull Vec velocity,
@Nullable PhysicsResult lastPhysicsResult, boolean singleCollision) {
final Block.Getter getter = new ChunkCache(instance, chunk != null ? chunk : instance.getChunkAt(position), Block.STONE);
return handlePhysics(getter, boundingBox, position, velocity, lastPhysicsResult, singleCollision);
}
/**
* Moves bounding box with physics applied (ie checking against blocks)
* <p>
* Works by getting all the full blocks that a bounding box could interact with.
* All bounding boxes inside the full blocks are checked for collisions with the given bounding box.
*
* @param blockGetter the block getter to check collisions against, ensure block access is synchronized
* @return the result of physics simulation
*/
@ApiStatus.Internal
public static PhysicsResult handlePhysics(@NotNull Block.Getter blockGetter,
@NotNull BoundingBox boundingBox,
@NotNull Pos position, @NotNull Vec velocity,
@Nullable PhysicsResult lastPhysicsResult, boolean singleCollision) {
return BlockCollision.handlePhysics(boundingBox,
velocity, position,
getter, lastPhysicsResult, singleCollision);
blockGetter, lastPhysicsResult, singleCollision);
}
/**
@ -140,14 +157,13 @@ public final class CollisionUtils {
/**
* Applies world border collision.
*
* @param instance the instance where the world border is
* @param worldBorder the world border
* @param currentPosition the current position
* @param newPosition the future target position
* @return the position with the world border collision applied (can be {@code newPosition} if not changed)
*/
public static @NotNull Pos applyWorldBorder(@NotNull Instance instance,
public static @NotNull Pos applyWorldBorder(@NotNull WorldBorder worldBorder,
@NotNull Pos currentPosition, @NotNull Pos newPosition) {
final WorldBorder worldBorder = instance.getWorldBorder();
final WorldBorder.CollisionAxis collisionAxis = worldBorder.getCollisionAxis(newPosition);
return switch (collisionAxis) {
case NONE ->
@ -168,4 +184,17 @@ public final class CollisionUtils {
public static Shape parseBlockShape(String collision, String occlusion, Registry.BlockEntry blockEntry) {
return ShapeImpl.parseBlockFromRegistry(collision, occlusion, blockEntry);
}
/**
* Simulate the entity's collision physics as if the world had no blocks
*
* @param entityPosition the position of the entity
* @param entityVelocity the velocity of the entity
* @return the result of physics simulation
*/
public static PhysicsResult blocklessCollision(@NotNull Pos entityPosition, @NotNull Vec entityVelocity) {
return new PhysicsResult(entityPosition.add(entityVelocity), entityVelocity, false,
false, false, false, entityVelocity, new Point[3],
new Shape[3], false, SweepResult.NO_COLLISION);
}
}

View File

@ -0,0 +1,65 @@
package net.minestom.server.collision;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.instance.WorldBorder;
import net.minestom.server.instance.block.Block;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public final class PhysicsUtils {
/**
* Simulate the entity's movement physics
* <p>
* This is done by first attempting to move the entity forward with the
* current velocity passed in. Then adjusting the velocity by applying
* air resistance and friction.
*
* @param entityPosition the current entity position
* @param entityVelocityPerTick the current entity velocity in blocks/tick
* @param entityBoundingBox the current entity bounding box
* @param worldBorder the world border to test bounds against
* @param blockGetter the block getter to test block collisions against
* @param aerodynamics the current entity aerodynamics
* @param entityNoGravity whether the entity has gravity
* @param entityHasPhysics whether the entity has physics
* @param entityOnGround whether the entity is on the ground
* @param entityFlying whether the entity is flying
* @param previousPhysicsResult the physics result from the previous simulation or null
* @return a {@link PhysicsResult} containing the resulting physics state of this simulation
*/
public static @NotNull PhysicsResult simulateMovement(@NotNull Pos entityPosition, @NotNull Vec entityVelocityPerTick, @NotNull BoundingBox entityBoundingBox,
@NotNull WorldBorder worldBorder, @NotNull Block.Getter blockGetter, @NotNull Aerodynamics aerodynamics, boolean entityNoGravity,
boolean entityHasPhysics, boolean entityOnGround, boolean entityFlying, @Nullable PhysicsResult previousPhysicsResult) {
final PhysicsResult physicsResult = entityHasPhysics ?
CollisionUtils.handlePhysics(blockGetter, entityBoundingBox, entityPosition, entityVelocityPerTick, previousPhysicsResult, false) :
CollisionUtils.blocklessCollision(entityPosition, entityVelocityPerTick);
Pos newPosition = physicsResult.newPosition();
Vec newVelocity = physicsResult.newVelocity();
Pos positionWithinBorder = CollisionUtils.applyWorldBorder(worldBorder, entityPosition, newPosition);
newVelocity = updateVelocity(entityPosition, newVelocity, blockGetter, aerodynamics, !positionWithinBorder.samePoint(entityPosition), entityFlying, entityOnGround, entityNoGravity);
return new PhysicsResult(positionWithinBorder, newVelocity, physicsResult.isOnGround(), physicsResult.collisionX(), physicsResult.collisionY(), physicsResult.collisionZ(),
physicsResult.originalDelta(), physicsResult.collisionPoints(), physicsResult.collisionShapes(), physicsResult.hasCollision(), physicsResult.res());
}
private static @NotNull Vec updateVelocity(@NotNull Pos entityPosition, @NotNull Vec currentVelocity, @NotNull Block.Getter blockGetter, @NotNull Aerodynamics aerodynamics,
boolean positionChanged, boolean entityFlying, boolean entityOnGround, boolean entityNoGravity) {
if (!positionChanged) {
if (entityOnGround || entityFlying) return Vec.ZERO;
return new Vec(0, entityNoGravity ? 0 : -aerodynamics.gravity() * aerodynamics.verticalAirResistance(), 0);
}
double drag = entityOnGround ? blockGetter.getBlock(entityPosition.sub(0, 0.5000001, 0)).registry().friction() * aerodynamics.horizontalAirResistance() :
aerodynamics.horizontalAirResistance();
double gravity = entityFlying ? 0 : aerodynamics.gravity();
double gravityDrag = entityFlying ? 0.6 : aerodynamics.verticalAirResistance();
double x = currentVelocity.x() * drag, z = currentVelocity.z() * drag;
double y = !entityNoGravity ? ((currentVelocity.y() - gravity) * gravityDrag) : currentVelocity.y();
return new Vec(Math.abs(x) < Vec.EPSILON ? 0 : x, Math.abs(y) < Vec.EPSILON ? 0 : y, Math.abs(z) < Vec.EPSILON ? 0 : z);
}
private PhysicsUtils() {}
}

View File

@ -79,13 +79,17 @@ import java.util.function.UnaryOperator;
*/
public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, EventHandler<EntityEvent>, Taggable,
PermissionHandler, HoverEventSource<ShowEntity>, Sound.Emitter, Shape {
private static final int VELOCITY_UPDATE_INTERVAL = 1;
private static final Int2ObjectSyncMap<Entity> ENTITY_BY_ID = Int2ObjectSyncMap.hashmap();
private static final Map<UUID, Entity> ENTITY_BY_UUID = new ConcurrentHashMap<>();
private static final AtomicInteger LAST_ENTITY_ID = new AtomicInteger();
// Certain entities should only have their position packets sent during synchronization
private static final Set<EntityType> SYNCHRONIZE_ONLY_ENTITIES = Set.of(EntityType.ITEM, EntityType.FALLING_BLOCK,
EntityType.ARROW, EntityType.SPECTRAL_ARROW, EntityType.TRIDENT, EntityType.LLAMA_SPIT, EntityType.WIND_CHARGE,
EntityType.FISHING_BOBBER, EntityType.SNOWBALL, EntityType.EGG, EntityType.ENDER_PEARL, EntityType.POTION,
EntityType.EYE_OF_ENDER, EntityType.DRAGON_FIREBALL, EntityType.FIREBALL, EntityType.SMALL_FIREBALL,
EntityType.TNT);
private final CachedPacket destroyPacketCache = new CachedPacket(() -> new DestroyEntitiesPacket(getEntityId()));
protected Instance instance;
@ -96,7 +100,7 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
protected boolean onGround;
protected BoundingBox boundingBox;
private PhysicsResult lastPhysicsResult = null;
private PhysicsResult previousPhysicsResult = null;
protected Entity vehicle;
@ -106,18 +110,7 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
protected boolean hasPhysics = true;
protected boolean hasCollision = true;
/**
* The amount of drag applied on the Y axle.
* <p>
* Unit: 1/tick
*/
protected double gravityDragPerTick;
/**
* Acceleration on the Y axle due to gravity
* <p>
* Unit: blocks/tick
*/
protected double gravityAcceleration;
private Aerodynamics aerodynamics;
protected int gravityTickCount; // Number of tick where gravity tick was applied
private final int id;
@ -192,8 +185,9 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
Entity.ENTITY_BY_ID.put(id, this);
Entity.ENTITY_BY_UUID.put(uuid, this);
this.gravityAcceleration = entityType.registry().acceleration();
this.gravityDragPerTick = entityType.registry().drag();
EntitySpawnType type = entityType.registry().spawnType();
this.aerodynamics = new Aerodynamics(entityType.registry().acceleration(),
type == EntitySpawnType.LIVING || type == EntitySpawnType.PLAYER ? 0.91 : 0.98, 1 - entityType.registry().drag());
final ServerProcess process = MinecraftServer.process();
if (process != null) {
@ -529,7 +523,9 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
this.entityType = entityType;
this.metadata = new Metadata(this);
this.entityMeta = EntityTypeImpl.createMeta(entityType, this, this.metadata);
EntitySpawnType type = entityType.registry().spawnType();
this.aerodynamics = aerodynamics.withAirResistance(type == EntitySpawnType.LIVING ||
type == EntitySpawnType.PLAYER ? 0.91 : 0.98, 1 - entityType.registry().drag());
Set<Player> viewers = new HashSet<>(getViewers());
getViewers().forEach(this::updateOldViewer);
viewers.forEach(this::updateNewViewer);
@ -559,8 +555,8 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
// Entity tick
{
// Cache the number of "gravity tick"
velocityTick();
// handle position and velocity updates
movementTick();
// handle block contacts
touchTick();
@ -580,117 +576,28 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
}
}
private void velocityTick() {
@ApiStatus.Internal
protected void movementTick() {
this.gravityTickCount = onGround ? 0 : gravityTickCount + 1;
if (vehicle != null) return;
final boolean noGravity = hasNoGravity();
final boolean hasVelocity = hasVelocity();
if (!hasVelocity && noGravity) {
return;
boolean entityIsPlayer = this instanceof Player;
boolean entityFlying = entityIsPlayer && ((Player) this).isFlying();
PhysicsResult physicsResult = PhysicsUtils.simulateMovement(position, velocity.div(ServerFlag.SERVER_TICKS_PER_SECOND), boundingBox,
instance.getWorldBorder(), instance, aerodynamics, hasNoGravity(), hasPhysics, onGround, entityFlying, previousPhysicsResult);
this.previousPhysicsResult = physicsResult;
Chunk finalChunk = ChunkUtils.retrieve(instance, currentChunk, physicsResult.newPosition());
if (!ChunkUtils.isLoaded(finalChunk)) return;
velocity = physicsResult.newVelocity().mul(ServerFlag.SERVER_TICKS_PER_SECOND);
onGround = physicsResult.isOnGround();
boolean shouldSendVelocity = !entityIsPlayer && hasVelocity();
if (!PlayerUtils.isSocketClient(this)) {
refreshPosition(physicsResult.newPosition(), true, !SYNCHRONIZE_ONLY_ENTITIES.contains(entityType));
if (shouldSendVelocity) sendPacketToViewers(getVelocityPacket());
}
final float tps = MinecraftServer.TICK_PER_SECOND;
final Pos positionBeforeMove = getPosition();
final Vec currentVelocity = getVelocity();
final boolean wasOnGround = this.onGround;
final Vec deltaPos = currentVelocity.div(tps);
final Pos newPosition;
final Vec newVelocity;
if (this.hasPhysics) {
final var physicsResult = CollisionUtils.handlePhysics(this, deltaPos, lastPhysicsResult);
this.lastPhysicsResult = physicsResult;
if (!PlayerUtils.isSocketClient(this))
this.onGround = physicsResult.isOnGround();
newPosition = physicsResult.newPosition();
newVelocity = physicsResult.newVelocity();
} else {
newVelocity = deltaPos;
newPosition = position.add(currentVelocity.div(20));
}
// World border collision
final Pos finalVelocityPosition = CollisionUtils.applyWorldBorder(instance, position, newPosition);
final boolean positionChanged = !finalVelocityPosition.samePoint(position);
final boolean isPlayer = this instanceof Player;
final boolean flying = isPlayer && ((Player) this).isFlying();
if (!positionChanged) {
if (flying) {
this.velocity = Vec.ZERO;
return;
} else if (hasVelocity || newVelocity.isZero()) {
this.velocity = noGravity ? Vec.ZERO : new Vec(
0,
-gravityAcceleration * tps * (1 - gravityDragPerTick),
0
);
if (this.ticks % VELOCITY_UPDATE_INTERVAL == 0) {
if (!isPlayer && !this.lastVelocityWasZero) {
sendPacketToViewers(getVelocityPacket());
this.lastVelocityWasZero = !hasVelocity;
}
}
return;
}
}
final Chunk finalChunk = ChunkUtils.retrieve(instance, currentChunk, finalVelocityPosition);
if (!ChunkUtils.isLoaded(finalChunk)) {
// Entity shouldn't be updated when moving in an unloaded chunk
return;
}
if (positionChanged) {
if (entityType == EntityTypes.ITEM || entityType == EntityType.FALLING_BLOCK) {
// TODO find other exceptions
this.previousPosition = this.position;
this.position = finalVelocityPosition;
refreshCoordinate(finalVelocityPosition);
} else {
if (!PlayerUtils.isSocketClient(this))
refreshPosition(finalVelocityPosition, true);
}
}
// Update velocity
if (!noGravity && (hasVelocity || !newVelocity.isZero())) {
updateVelocity(wasOnGround, flying, positionBeforeMove, newVelocity);
}
// Verify if velocity packet has to be sent
if (this.ticks % VELOCITY_UPDATE_INTERVAL == 0) {
if (!isPlayer && (hasVelocity || !lastVelocityWasZero)) {
sendPacketToViewers(getVelocityPacket());
this.lastVelocityWasZero = !hasVelocity;
}
}
}
protected void updateVelocity(boolean wasOnGround, boolean flying, Pos positionBeforeMove, Vec newVelocity) {
EntitySpawnType type = entityType.registry().spawnType();
final double airDrag = type == EntitySpawnType.LIVING || type == EntitySpawnType.PLAYER ? 0.91 : 0.98;
final double drag;
if (wasOnGround) {
final Chunk chunk = ChunkUtils.retrieve(instance, currentChunk, position);
synchronized (chunk) {
drag = chunk.getBlock(positionBeforeMove.sub(0, 0.5000001, 0)).registry().friction() * airDrag;
}
} else drag = airDrag;
double gravity = flying ? 0 : gravityAcceleration;
double gravityDrag = flying ? 0.6 : (1 - gravityDragPerTick);
this.velocity = newVelocity
// Apply gravity and drag
.apply((x, y, z) -> new Vec(
x * drag,
!hasNoGravity() ? (y - gravity) * gravityDrag : y,
z * drag
))
// Convert from block/tick to block/sec
.mul(MinecraftServer.TICK_PER_SECOND)
// Prevent infinitely decreasing velocity
.apply(Vec.Operator.EPSILON);
}
private void touchTick() {
@ -972,21 +879,21 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
}
/**
* Gets the gravity drag per tick.
* Gets the aerodynamics; how the entity behaves in the air.
*
* @return the gravity drag per tick in block
* @return the aerodynamic properties this entity is using
*/
public double getGravityDragPerTick() {
return gravityDragPerTick;
public @NotNull Aerodynamics getAerodynamics() {
return aerodynamics;
}
/**
* Gets the gravity acceleration.
* Sets the aerodynamics; how the entity behaves in the air.
*
* @return the gravity acceleration in block
* @param aerodynamics the new aerodynamic properties
*/
public double getGravityAcceleration() {
return gravityAcceleration;
public void setAerodynamics(@NotNull Aerodynamics aerodynamics) {
this.aerodynamics = aerodynamics;
}
/**
@ -998,18 +905,6 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
return gravityTickCount;
}
/**
* Changes the gravity of the entity.
*
* @param gravityDragPerTick the gravity drag per tick in block
* @param gravityAcceleration the gravity acceleration in block
* @see <a href="https://minecraft.wiki/w/Entity#Motion_of_entities">Entities motion</a>
*/
public void setGravity(double gravityDragPerTick, double gravityAcceleration) {
this.gravityDragPerTick = gravityDragPerTick;
this.gravityAcceleration = gravityAcceleration;
}
public double getDistance(@NotNull Point point) {
return getPosition().distance(point);
}
@ -1357,14 +1252,14 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
* @param newPosition the new position
*/
@ApiStatus.Internal
public void refreshPosition(@NotNull final Pos newPosition, boolean ignoreView) {
public void refreshPosition(@NotNull final Pos newPosition, boolean ignoreView, boolean sendPackets) {
final var previousPosition = this.position;
final Pos position = ignoreView ? previousPosition.withCoord(newPosition) : newPosition;
if (position.equals(lastSyncedPosition)) return;
this.position = position;
this.previousPosition = previousPosition;
if (!position.samePoint(previousPosition)) refreshCoordinate(position);
if (nextSynchronizationTick <= ticks + 1) {
if (nextSynchronizationTick <= ticks + 1 || !sendPackets) {
// The entity will be synchronized at the end of its tick
// not returning here will duplicate position packets
return;
@ -1399,6 +1294,11 @@ public class Entity implements Viewable, Tickable, Schedulable, Snapshotable, Ev
this.lastSyncedPosition = position;
}
@ApiStatus.Internal
public void refreshPosition(@NotNull final Pos newPosition, boolean ignoreView) {
refreshPosition(newPosition, ignoreView, true);
}
@ApiStatus.Internal
public void refreshPosition(@NotNull final Pos newPosition) {
refreshPosition(newPosition, false);

View File

@ -29,6 +29,7 @@ import java.util.stream.Stream;
public class EntityProjectile extends Entity {
private final Entity shooter;
private boolean wasStuck;
public EntityProjectile(@Nullable Entity shooter, @NotNull EntityType entityType) {
super(entityType);
@ -99,12 +100,12 @@ public class EntityProjectile extends Entity {
this.velocity = Vec.ZERO;
sendPacketToViewersAndSelf(getVelocityPacket());
setNoGravity(true);
wasStuck = true;
} else {
if (!super.onGround) {
return;
}
if (!wasStuck) return;
wasStuck = false;
setNoGravity(super.onGround);
super.onGround = false;
setNoGravity(false);
EventDispatcher.call(new ProjectileUncollideEvent(this));
}
}

View File

@ -1,5 +1,6 @@
package net.minestom.server.entity;
import net.minestom.server.instance.block.Block;
import net.minestom.testing.Env;
import net.minestom.testing.EnvTest;
import net.minestom.server.coordinate.Pos;
@ -109,7 +110,7 @@ public class EntityVelocityIntegrationTest {
var player = env.createPlayer(instance, new Pos(0, 42, 0));
env.tick();
final double epsilon = 0.00001;
final double epsilon = 0.000001;
assertEquals(player.getVelocity().y(), -1.568, epsilon);
double previousVelocity = player.getVelocity().y();
@ -118,7 +119,7 @@ public class EntityVelocityIntegrationTest {
env.tick();
// Every tick, the y velocity is multiplied by 0.6, and after 27 ticks it should be 0
for (int i = 0; i < 27; i++) {
for (int i = 0; i < 22; i++) {
assertEquals(player.getVelocity().y(), previousVelocity * 0.6, epsilon);
previousVelocity = player.getVelocity().y();
env.tick();
@ -172,6 +173,8 @@ public class EntityVelocityIntegrationTest {
viewerConnection.connect(instance, new Pos(1, 40, 1)).join();
var entity = new Entity(EntityType.ZOMBIE);
entity.setInstance(instance, new Pos(0,40,0)).join();
instance.setBlock(new Vec(0, 39, 0), Block.STONE);
env.tick(); // Tick because the entity is in the air, they'll send velocity from gravity
AtomicInteger i = new AtomicInteger();
BooleanSupplier tickLoopCondition = () -> i.getAndIncrement() < Math.max(VELOCITY_UPDATE_INTERVAL, 1);