diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/BoatEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/BoatEntity.java
index 9652f481a..a11819960 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/BoatEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/BoatEntity.java
@@ -26,12 +26,16 @@
package org.geysermc.geyser.entity.type;
import lombok.Getter;
+import org.cloudburstmc.math.vector.Vector2f;
import org.cloudburstmc.math.vector.Vector3f;
import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
import org.cloudburstmc.protocol.bedrock.packet.MoveEntityAbsolutePacket;
import org.geysermc.geyser.entity.EntityDefinition;
import org.geysermc.geyser.entity.EntityDefinitions;
+import org.geysermc.geyser.entity.vehicle.BoatVehicleComponent;
+import org.geysermc.geyser.entity.vehicle.ClientVehicle;
+import org.geysermc.geyser.entity.vehicle.VehicleComponent;
import org.geysermc.geyser.session.GeyserSession;
import org.geysermc.geyser.util.InteractionResult;
import org.geysermc.geyser.util.InteractiveTag;
@@ -41,7 +45,7 @@ import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.level.Serve
import java.util.UUID;
-public class BoatEntity extends Entity implements Leashable, Tickable {
+public class BoatEntity extends Entity implements Tickable, Leashable, ClientVehicle {
/**
* Required when IS_BUOYANT is sent in order for boats to work in the water.
@@ -53,6 +57,8 @@ public class BoatEntity extends Entity implements Leashable, Tickable {
"\"big_wave_speed\":10.0,\"drag_down_on_buoyancy_removed\":0.0,\"liquid_blocks\":[\"minecraft:water\"," +
"\"minecraft:flowing_water\"],\"simulate_waves\":false}";
+ private final BoatVehicleComponent vehicleComponent = new BoatVehicleComponent(this, 0);
+
private boolean isPaddlingLeft;
private float paddleTimeLeft;
private boolean isPaddlingRight;
@@ -67,8 +73,8 @@ public class BoatEntity extends Entity implements Leashable, Tickable {
private long leashHolderBedrockId = -1;
- // Looks too fast and too choppy with 0.1f, which is how I believe the Microsoftian client handles it
- private final float ROWING_SPEED = 0.1f;
+ // This is the best value, I can't really found any value that doesn't look choppy and laggy or that is not too slow, blame bedrock.
+ private final float ROWING_SPEED = 0.04f;
public BoatEntity(GeyserSession session, int entityId, long geyserId, UUID uuid, EntityDefinition> definition, Vector3f position, Vector3f motion, float yaw, BoatVariant variant) {
// Initial rotation is incorrect
@@ -192,8 +198,13 @@ public class BoatEntity extends Entity implements Leashable, Tickable {
// For packet timing accuracy, we'll send the packets here, as that's what Java Edition 1.21.3 does.
ServerboundPaddleBoatPacket steerPacket = new ServerboundPaddleBoatPacket(session.isSteeringLeft(), session.isSteeringRight());
session.sendDownstreamGamePacket(steerPacket);
- return;
+
+ // If the vehicle is not controlled by the client, then we will have to send the client the rowing time else the animation won't play!
+ if (session.isInClientPredictedVehicle()) {
+ return;
+ }
}
+
doTick = !doTick; // Run every other tick
if (!doTick || passengers.isEmpty()) {
return;
@@ -212,11 +223,35 @@ public class BoatEntity extends Entity implements Leashable, Tickable {
paddleTimeRight += ROWING_SPEED;
dirtyMetadata.put(EntityDataTypes.ROW_TIME_RIGHT, paddleTimeRight);
}
+
+ if (isPaddlingLeft || isPaddlingRight) {
+ updateBedrockMetadata();
+ }
}
@Override
public long leashHolderBedrockId() {
- return leashHolderBedrockId;
+ return this.leashHolderBedrockId;
+ }
+
+ @Override
+ public VehicleComponent> getVehicleComponent() {
+ return this.vehicleComponent;
+ }
+
+ @Override
+ public Vector3f getRiddenInput(Vector2f input) {
+ return Vector3f.ZERO;
+ }
+
+ @Override
+ public float getVehicleSpeed() {
+ return 0;
+ }
+
+ @Override
+ public boolean isClientControlled() {
+ return !session.isInClientPredictedVehicle() && !passengers.isEmpty() && this.session.getPlayerEntity() == passengers.get(0);
}
/**
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java b/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java
index f82b09fe2..b992534cd 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java
@@ -53,6 +53,7 @@ import org.geysermc.geyser.entity.properties.GeyserEntityPropertyManager;
import org.geysermc.geyser.entity.properties.type.PropertyType;
import org.geysermc.geyser.entity.type.living.MobEntity;
import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.entity.vehicle.ClientVehicle;
import org.geysermc.geyser.item.Items;
import org.geysermc.geyser.item.type.Item;
import org.geysermc.geyser.level.physics.BoundingBox;
@@ -258,6 +259,13 @@ public class Entity implements GeyserEntity {
}
public void moveRelative(double relX, double relY, double relZ, float yaw, float pitch, float headYaw, boolean isOnGround) {
+ if (this instanceof ClientVehicle clientVehicle) {
+ if (clientVehicle.isClientControlled()) {
+ return;
+ }
+ clientVehicle.getVehicleComponent().moveRelative(relX, relY, relZ);
+ }
+
position = Vector3f.from(position.getX() + relX, position.getY() + relY, position.getZ() + relZ);
MoveEntityDeltaPacket moveEntityPacket = new MoveEntityDeltaPacket();
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java
index d59cd0842..00951e091 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java
@@ -356,18 +356,6 @@ public class LivingEntity extends Entity {
return super.interact(hand);
}
- @Override
- public void moveRelative(double relX, double relY, double relZ, float yaw, float pitch, float headYaw, boolean isOnGround) {
- if (this instanceof ClientVehicle clientVehicle) {
- if (clientVehicle.isClientControlled()) {
- return;
- }
- clientVehicle.getVehicleComponent().moveRelative(relX, relY, relZ);
- }
-
- super.moveRelative(relX, relY, relZ, yaw, pitch, headYaw, isOnGround);
- }
-
@Override
public boolean setBoundingBoxHeight(float height) {
if (valid && this instanceof ClientVehicle clientVehicle) {
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/horse/AbstractHorseEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/horse/AbstractHorseEntity.java
index 35e3d17ae..0675bd5f4 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/horse/AbstractHorseEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/horse/AbstractHorseEntity.java
@@ -27,7 +27,9 @@ package org.geysermc.geyser.entity.type.living.animal.horse;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
+import org.cloudburstmc.math.vector.Vector2f;
import org.cloudburstmc.math.vector.Vector3f;
+import org.cloudburstmc.protocol.bedrock.data.AttributeData;
import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
import org.cloudburstmc.protocol.bedrock.data.entity.EntityEventType;
import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
@@ -37,6 +39,9 @@ import org.cloudburstmc.protocol.bedrock.packet.UpdateAttributesPacket;
import org.geysermc.geyser.entity.EntityDefinition;
import org.geysermc.geyser.entity.attribute.GeyserAttributeType;
import org.geysermc.geyser.entity.type.living.animal.AnimalEntity;
+import org.geysermc.geyser.entity.vehicle.ClientVehicle;
+import org.geysermc.geyser.entity.vehicle.HorseVehicleComponent;
+import org.geysermc.geyser.entity.vehicle.VehicleComponent;
import org.geysermc.geyser.input.InputLocksFlag;
import org.geysermc.geyser.inventory.GeyserItemStack;
import org.geysermc.geyser.item.Items;
@@ -47,12 +52,16 @@ import org.geysermc.geyser.session.cache.tags.Tag;
import org.geysermc.geyser.util.InteractionResult;
import org.geysermc.geyser.util.InteractiveTag;
import org.geysermc.mcprotocollib.protocol.data.game.entity.EquipmentSlot;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.attribute.Attribute;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.attribute.AttributeType;
import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.ByteEntityMetadata;
import org.geysermc.mcprotocollib.protocol.data.game.entity.player.Hand;
import java.util.UUID;
-public class AbstractHorseEntity extends AnimalEntity {
+public class AbstractHorseEntity extends AnimalEntity implements ClientVehicle {
+
+ private final HorseVehicleComponent vehicleComponent = new HorseVehicleComponent(this);
public AbstractHorseEntity(GeyserSession session, int entityId, long geyserId, UUID uuid, EntityDefinition> definition, Vector3f position, Vector3f motion, float yaw, float pitch, float headYaw) {
super(session, entityId, geyserId, uuid, definition, position, motion, yaw, pitch, headYaw);
@@ -94,6 +103,15 @@ public class AbstractHorseEntity extends AnimalEntity {
}
}
+ @Override
+ protected AttributeData calculateAttribute(Attribute javaAttribute, GeyserAttributeType type) {
+ AttributeData attributeData = super.calculateAttribute(javaAttribute, type);
+ if (javaAttribute.getType() == AttributeType.Builtin.JUMP_STRENGTH) {
+ vehicleComponent.setHorseJumpStrength(attributeData.getValue());
+ }
+ return attributeData;
+ }
+
@Override
public boolean doesJumpDismount() {
return !this.getFlag(EntityFlag.SADDLED);
@@ -309,4 +327,25 @@ public class AbstractHorseEntity extends AnimalEntity {
return isAlive() && !isBaby() && getFlag(EntityFlag.TAMED);
}
}
+
+ @Override
+ public VehicleComponent> getVehicleComponent() {
+ return this.vehicleComponent;
+ }
+
+ @Override
+ public Vector3f getRiddenInput(Vector2f input) {
+ input = input.mul(0.5f, input.getY() < 0 ? 0.25f : 1.0f);
+ return Vector3f.from(input.getX(), 0.0, input.getY());
+ }
+
+ @Override
+ public float getVehicleSpeed() {
+ return vehicleComponent.getMoveSpeed();
+ }
+
+ @Override
+ public boolean isClientControlled() {
+ return getFlag(EntityFlag.SADDLED) && !passengers.isEmpty() && passengers.get(0) == session.getPlayerEntity() && !session.isInClientPredictedVehicle();
+ }
}
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/horse/CamelEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/horse/CamelEntity.java
index 138b60e35..870d7b09a 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/horse/CamelEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/horse/CamelEntity.java
@@ -25,7 +25,6 @@
package org.geysermc.geyser.entity.type.living.animal.horse;
-import org.cloudburstmc.math.vector.Vector2f;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.cloudburstmc.math.vector.Vector3f;
import org.cloudburstmc.protocol.bedrock.data.AttributeData;
@@ -152,12 +151,6 @@ public class CamelEntity extends AbstractHorseEntity implements ClientVehicle {
return vehicleComponent;
}
- @Override
- public Vector3f getRiddenInput(Vector2f input) {
- input = input.mul(0.5f, input.getY() < 0 ? 0.25f : 1.0f);
- return Vector3f.from(input.getX(), 0.0, input.getY());
- }
-
@Override
public boolean isClientControlled() {
return getFlag(EntityFlag.SADDLED) && !passengers.isEmpty() && passengers.get(0) == session.getPlayerEntity();
diff --git a/core/src/main/java/org/geysermc/geyser/entity/vehicle/BoatVehicleComponent.java b/core/src/main/java/org/geysermc/geyser/entity/vehicle/BoatVehicleComponent.java
new file mode 100644
index 000000000..3a9b3d9ab
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/entity/vehicle/BoatVehicleComponent.java
@@ -0,0 +1,433 @@
+/*
+ * Copyright (c) 2025 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.entity.vehicle;
+
+import org.cloudburstmc.math.GenericMath;
+import org.cloudburstmc.math.TrigMath;
+import org.cloudburstmc.math.vector.Vector2f;
+import org.cloudburstmc.math.vector.Vector3d;
+import org.cloudburstmc.math.vector.Vector3f;
+import org.cloudburstmc.math.vector.Vector3i;
+import org.cloudburstmc.protocol.bedrock.packet.MoveEntityDeltaPacket;
+import org.geysermc.erosion.util.BlockPositionIterator;
+import org.geysermc.geyser.entity.type.BoatEntity;
+import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.level.block.Blocks;
+import org.geysermc.geyser.level.block.Fluid;
+import org.geysermc.geyser.level.block.type.BedBlock;
+import org.geysermc.geyser.level.block.type.Block;
+import org.geysermc.geyser.level.block.type.BlockState;
+import org.geysermc.geyser.level.physics.Axis;
+import org.geysermc.geyser.level.physics.BoundingBox;
+import org.geysermc.geyser.translator.collision.BlockCollision;
+import org.geysermc.geyser.util.BlockUtils;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.level.ServerboundMoveVehiclePacket;
+
+public class BoatVehicleComponent extends VehicleComponent {
+ private Status status, oldStatus;
+ private double waterLevel;
+ private float landFriction;
+ private double lastYd;
+ private float deltaRotation;
+
+ public BoatVehicleComponent(BoatEntity vehicle, float stepHeight) {
+ super(vehicle, stepHeight);
+
+ this.gravity = 0.04;
+ }
+
+ @Override
+ public void tickVehicle() {
+ if (!vehicle.isClientControlled()) {
+ return;
+ }
+
+ final VehicleContext context = new VehicleContext();
+ context.loadSurroundingBlocks();
+
+ this.oldStatus = this.status;
+ this.status = getStatus(context);
+
+ floatBoat(context);
+
+ final Vector3f lastRotation = vehicle.getBedrockRotation();
+ controlBoat();
+
+ Vector3f motion = vehicle.getMotion();
+ Vector3f movementMultiplier = getBlockMovementMultiplier(context);
+ if (movementMultiplier != null) {
+ motion = motion.mul(movementMultiplier);
+ }
+
+ Vector3d correctedMovement = vehicle.getSession().getWorldBorder().correctMovement(boundingBox, motion.toDouble());
+ correctedMovement = vehicle.getSession().getCollisionManager().correctMovement(
+ correctedMovement, boundingBox, vehicle.isOnGround(), this.stepHeight, true, vehicle.canWalkOnLava()
+ );
+
+ // Non-zero values indicate a collision on that axis
+ Vector3d moveDiff = motion.toDouble().sub(correctedMovement);
+
+ boundingBox.translate(correctedMovement);
+ context.loadSurroundingBlocks(); // Context must be reloaded after vehicle is moved
+
+ boolean verticalCollision = moveDiff.getY() != 0;
+ vehicle.setOnGround(verticalCollision && motion.getY() < 0);
+
+ boolean bounced = false;
+ if (vehicle.isOnGround()) {
+ Block landingBlock = getLandingBlock(context).block();
+
+ if (landingBlock == Blocks.SLIME_BLOCK) {
+ motion = Vector3f.from(motion.getX(), -motion.getY(), motion.getZ());
+ bounced = true;
+
+ // Slow horizontal movement
+ float absY = Math.abs(motion.getY());
+ if (absY < 0.1f) {
+ float mul = 0.4f + absY * 0.2f;
+ motion = motion.mul(mul, 1.0f, mul);
+ }
+ } else if (landingBlock instanceof BedBlock) {
+ motion = Vector3f.from(motion.getX(), -motion.getY() * 0.66f, motion.getZ());
+ bounced = true;
+ }
+ }
+
+ // Set motion to 0 if a movement multiplier was used, else set to 0 on each axis with a collision
+ if (movementMultiplier != null) {
+ motion = Vector3f.ZERO;
+ } else {
+ motion = motion.mul(
+ moveDiff.getX() == 0 ? 1 : 0,
+ !verticalCollision || bounced ? 1 : 0,
+ moveDiff.getZ() == 0 ? 1 : 0
+ );
+ }
+
+ // Send the new position to the bedrock client and java server
+ moveVehicle(context.centerPos(), lastRotation);
+ vehicle.setMotion(motion);
+
+ // This got ran twice in Boat entity for certain reason?
+ applyBlockCollisionEffects(context);
+ applyBlockCollisionEffects(context);
+ }
+
+ @Override
+ protected void moveVehicle(Vector3d javaPos, Vector3f lastRotation) {
+ Vector3f bedrockPos = javaPos.toFloat();
+
+ MoveEntityDeltaPacket moveEntityDeltaPacket = new MoveEntityDeltaPacket();
+ moveEntityDeltaPacket.setRuntimeEntityId(vehicle.getGeyserId());
+
+ if (vehicle.isOnGround()) {
+ moveEntityDeltaPacket.getFlags().add(MoveEntityDeltaPacket.Flag.ON_GROUND);
+ }
+
+ if (vehicle.getPosition().getX() != bedrockPos.getX()) {
+ moveEntityDeltaPacket.getFlags().add(MoveEntityDeltaPacket.Flag.HAS_X);
+ moveEntityDeltaPacket.setX(bedrockPos.getX());
+ }
+ if (vehicle.getPosition().getY() != bedrockPos.getY()) {
+ moveEntityDeltaPacket.getFlags().add(MoveEntityDeltaPacket.Flag.HAS_Y);
+ moveEntityDeltaPacket.setY(bedrockPos.getY() + vehicle.getDefinition().offset());
+ }
+ if (vehicle.getPosition().getZ() != bedrockPos.getZ()) {
+ moveEntityDeltaPacket.getFlags().add(MoveEntityDeltaPacket.Flag.HAS_Z);
+ moveEntityDeltaPacket.setZ(bedrockPos.getZ());
+ }
+ vehicle.setPosition(bedrockPos);
+
+ if (vehicle.getPitch() != lastRotation.getX()) {
+ moveEntityDeltaPacket.getFlags().add(MoveEntityDeltaPacket.Flag.HAS_PITCH);
+ moveEntityDeltaPacket.setPitch(vehicle.getPitch());
+ }
+ if (vehicle.getYaw() != lastRotation.getY()) {
+ moveEntityDeltaPacket.getFlags().add(MoveEntityDeltaPacket.Flag.HAS_YAW);
+ moveEntityDeltaPacket.setYaw(vehicle.getYaw());
+ }
+ if (vehicle.getHeadYaw() != lastRotation.getZ()) {
+ moveEntityDeltaPacket.getFlags().add(MoveEntityDeltaPacket.Flag.HAS_HEAD_YAW);
+ moveEntityDeltaPacket.setHeadYaw(vehicle.getHeadYaw());
+ }
+
+ if (!moveEntityDeltaPacket.getFlags().isEmpty()) {
+ vehicle.getSession().sendUpstreamPacket(moveEntityDeltaPacket);
+ }
+
+ ServerboundMoveVehiclePacket moveVehiclePacket = new ServerboundMoveVehiclePacket(javaPos, vehicle.getYaw() - 90, vehicle.getPitch(), vehicle.isOnGround());
+ vehicle.getSession().sendDownstreamPacket(moveVehiclePacket);
+ }
+
+ private void controlBoat() {
+ float acceleration = 0.0F;
+
+ final Vector2f input = vehicle.getSession().getPlayerEntity().getVehicleInput();
+ boolean up = input.getY() > 0;
+ boolean down = input.getY() < 0;
+ boolean left = input.getX() > 0;
+ boolean right = input.getX() < 0;
+
+ if (left) this.deltaRotation--;
+ if (right) this.deltaRotation++;
+
+ if (right != left && !up && !down) acceleration += 0.005F;
+
+ vehicle.setYaw(vehicle.getYaw() + this.deltaRotation);
+ vehicle.setHeadYaw(vehicle.getYaw());
+
+ if (up) acceleration += 0.04F;
+ if (down) acceleration -= 0.005F;
+
+ float yaw = vehicle.getYaw() - 90;
+ final Vector3f motion = Vector3f.from(TrigMath.sin((-yaw * 0.017453292F)) * acceleration, 0, TrigMath.cos((yaw * 0.017453292F)) * acceleration);
+ vehicle.setMotion(vehicle.getMotion().add(motion));
+
+ vehicle.getSession().setSteeringLeft(right && !left || up);
+ vehicle.getSession().setSteeringRight(left && !right || up);
+ }
+
+ private void floatBoat(final VehicleContext context) {
+ double gravity = -getGravity();
+ double buoyancy = 0.0D;
+ float frictionMutiplier = 0.05F;
+
+ if (this.oldStatus == Status.IN_AIR && this.status != Status.IN_AIR && this.status != Status.ON_LAND) {
+ this.waterLevel = getBoundingBox().getMax(Axis.Y);
+ double targetY = (getWaterLevelAbove(context) - this.vehicle.getBoundingBoxHeight()) + 0.101D;
+
+ BoundingBox box = boundingBox.clone();
+ box.translate(0, targetY - getBoundingBox().getMin(Axis.Y), 0);
+
+ boolean empty = true;
+ for (BlockPositionIterator iter = vehicle.getSession().getCollisionManager().collidableBlocksIterator(box); iter.hasNext(); iter.next()) {
+ final BlockCollision collision = BlockUtils.getCollision(context.getBlockId(iter.getX(), iter.getY(), iter.getZ()));
+ if (collision != null && collision.checkIntersection(Vector3i.from(iter.getX(), iter.getY(), iter.getZ()), box)) {
+ empty = false;
+ break;
+ }
+ }
+
+ if (empty) {
+ vehicle.setMotion(vehicle.getMotion().mul(1, 0, 1));
+ boundingBox.setMiddleY(targetY + (boundingBox.getSizeY() / 2));
+ this.lastYd = 0;
+ }
+
+ this.status = Status.IN_WATER;
+ } else {
+ if (this.status == Status.IN_WATER) {
+ buoyancy = (this.waterLevel - getBoundingBox().getMin(Axis.Y)) / vehicle.getBoundingBoxHeight();
+ frictionMutiplier = 0.9F;
+ } else if (this.status == Status.UNDER_FLOWING_WATER) {
+ gravity = -7.0E-4D;
+ frictionMutiplier = 0.9F;
+ } else if (this.status == Status.UNDER_WATER) {
+ buoyancy = 0.009999999776482582D;
+ frictionMutiplier = 0.45F;
+ } else if (this.status == Status.IN_AIR) {
+ frictionMutiplier = 0.9F;
+ } else if (this.status == Status.ON_LAND) {
+ frictionMutiplier = this.landFriction;
+ this.landFriction /= 2.0F;
+ }
+
+ vehicle.setMotion(vehicle.getMotion().mul(frictionMutiplier, 1, frictionMutiplier).up((float) gravity));
+
+ this.deltaRotation *= frictionMutiplier;
+ if (buoyancy > 0.0D) {
+ Vector3f motion = vehicle.getMotion();
+ vehicle.setMotion(Vector3f.from(
+ motion.getX(),
+ (float) (motion.getY() + buoyancy * (this.gravity / 0.65)) * 0.75f,
+ motion.getZ()
+ ));
+ }
+ }
+ }
+
+ private float getWaterLevelAbove(final VehicleContext context) {
+ BoundingBox aabb = getBoundingBox();
+
+ int minX = GenericMath.floor(aabb.getMin(Axis.X));
+ int maxX = GenericMath.ceil(aabb.getMax(Axis.X));
+ int minY = GenericMath.floor(aabb.getMax(Axis.Y));
+ int maxY = GenericMath.ceil(aabb.getMax(Axis.Y) - this.lastYd);
+ int minZ = GenericMath.floor(aabb.getMin(Axis.Z));
+ int maxZ = GenericMath.ceil(aabb.getMax(Axis.Z));
+
+ for (int y = minY; y < maxY; y++) {
+ float blockHeight = 0.0F;
+ for (int x = minX; x < maxX; x++) {
+ for (int z = minZ; z < maxZ; z++) {
+ final float fluidHeight = getLogicalFluidHeight(Fluid.WATER, context.getBlockId(x, y, z));
+ if (fluidHeight > 0) {
+ blockHeight = Math.max(blockHeight, fluidHeight);
+ }
+ }
+ }
+
+ if (blockHeight < 1.0F) {
+ return y + blockHeight;
+ }
+ }
+ return (maxY + 1);
+ }
+
+ private float getGroundFriction(final VehicleContext context) {
+ BoundingBox boatShape = getBoundingBox().clone();
+ // 0.001 high box extending downwards from the boat
+ boatShape.setMiddleY(boatShape.getMin(Axis.Y) - 0.0005);
+ boatShape.setSizeY(0.001);
+
+ if (boatShape.isEmpty()) {
+ return Float.NaN;
+ }
+
+ int x0 = GenericMath.floor(boatShape.getMin(Axis.X)) - 1;
+ int x1 = GenericMath.ceil(boatShape.getMax(Axis.X)) + 1;
+ int y0 = GenericMath.floor(boatShape.getMin(Axis.Y)) - 1;
+ int y1 = GenericMath.ceil(boatShape.getMax(Axis.Y)) + 1;
+ int z0 = GenericMath.floor(boatShape.getMin(Axis.Z)) - 1;
+ int z1 = GenericMath.ceil(boatShape.getMax(Axis.Z)) + 1;
+
+ float friction = 0.0F;
+ int count = 0;
+
+ for (int x = x0; x < x1; x++) {
+ for (int z = z0; z < z1; z++) {
+ int edges = ((x == x0 || x == x1 - 1) ? 1 : 0) + ((z == z0 || z == z1 - 1) ? 1 : 0);
+ if (edges == 2) {
+ continue;
+ }
+
+ for (int y = y0; y < y1; y++) {
+ if (edges > 0 && !(y != y0 && y != y1 - 1)) {
+ continue;
+ }
+ final BlockState state = context.getBlock(x, y, z);
+ if (state.is(Blocks.LILY_PAD)) {
+ continue;
+ }
+ final BlockCollision collision = BlockUtils.getCollision(state.javaId());
+ if (collision == null || collision.getBoundingBoxes().length == 0) {
+ continue;
+ }
+
+ if (collision.checkIntersection(Vector3i.from(x, y, z), boatShape)) {
+ friction += BlockStateValues.getSlipperiness(state);
+ count++;
+ }
+ }
+ }
+ }
+
+ return friction / count;
+ }
+
+ private Status isUnderwater(final VehicleContext context) {
+ BoundingBox boatShape = getBoundingBox().clone();
+ double maxY = boatShape.getMax(Axis.Y) + 0.001D;
+
+ int x0 = GenericMath.floor(boatShape.getMin(Axis.X));
+ int x1 = GenericMath.ceil(boatShape.getMax(Axis.X));
+ int y0 = GenericMath.floor(boatShape.getMax(Axis.Y));
+ int y1 = GenericMath.ceil(maxY);
+ int z0 = GenericMath.floor(boatShape.getMin(Axis.Z));
+ int z1 = GenericMath.ceil(boatShape.getMax(Axis.Z));
+
+ boolean underWater = false;
+ for (int x = x0; x < x1; x++) {
+ for (int y = y0; y < y1; y++) {
+ for (int z = z0; z < z1; z++) {
+ final float fluidHeight = getLogicalFluidHeight(Fluid.WATER, context.getBlockId(x, y, z));
+ if (fluidHeight <= 0 || maxY > y + fluidHeight) {
+ continue;
+ }
+
+ if (fluidHeight == 1) {
+ underWater = true;
+ } else {
+ return Status.UNDER_FLOWING_WATER;
+ }
+ }
+ }
+ }
+ return underWater ? Status.UNDER_WATER : null;
+ }
+
+ private boolean checkInWater(final VehicleContext context) {
+ this.waterLevel = Double.MIN_VALUE;
+
+ final BoundingBox boatShape = getBoundingBox();
+ int minX = GenericMath.floor(boatShape.getMin(Axis.X));
+ int maxX = GenericMath.ceil(boatShape.getMax(Axis.X));
+ int minY = GenericMath.floor(boatShape.getMin(Axis.Y));
+ int maxY = GenericMath.ceil(boatShape.getMin(Axis.Y) + 0.001D);
+ int minZ = GenericMath.floor(boatShape.getMin(Axis.Z));
+ int maxZ = GenericMath.ceil(boatShape.getMax(Axis.Z));
+
+ for (int x = minX; x < maxX; x++) {
+ for (int y = minY; y < maxY; y++) {
+ for (int z = minZ; z < maxZ; z++) {
+ final float fluidHeight = getLogicalFluidHeight(Fluid.WATER, context.getBlockId(x, y, z));
+ if (fluidHeight <= 0) {
+ continue;
+ }
+
+ float height = y + fluidHeight;
+ this.waterLevel = Math.max(height, this.waterLevel);
+ if (boatShape.getMin(Axis.Y) < height) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private Status getStatus(final VehicleContext context) {
+ Status waterStatus = isUnderwater(context);
+ if (waterStatus != null) {
+ this.waterLevel = getBoundingBox().getMax(Axis.Y);
+ return waterStatus;
+ }
+ if (checkInWater(context)) {
+ return Status.IN_WATER;
+ }
+ float friction = getGroundFriction(context);
+ if (friction > 0.0F) {
+ this.landFriction = friction;
+ return Status.ON_LAND;
+ }
+ return Status.IN_AIR;
+ }
+
+ public enum Status {
+ IN_WATER, UNDER_WATER, UNDER_FLOWING_WATER, ON_LAND, IN_AIR;
+ }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/entity/vehicle/CamelVehicleComponent.java b/core/src/main/java/org/geysermc/geyser/entity/vehicle/CamelVehicleComponent.java
index 1ed9328f0..77bda1435 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/vehicle/CamelVehicleComponent.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/vehicle/CamelVehicleComponent.java
@@ -98,10 +98,9 @@ public class CamelVehicleComponent extends VehicleComponent {
SessionPlayerEntity player = vehicle.getSession().getPlayerEntity();
Vector3f inputVelocity = super.getInputVector(ctx, speed, input);
float jumpStrength = player.getVehicleJumpStrength();
+ player.setVehicleJumpStrength(0);
- if (jumpStrength > 0) {
- player.setVehicleJumpStrength(0);
-
+ if (vehicle.isOnGround() && jumpStrength > 0) {
if (jumpStrength >= 90) {
jumpStrength = 1.0f;
} else {
diff --git a/core/src/main/java/org/geysermc/geyser/entity/vehicle/HorseVehicleComponent.java b/core/src/main/java/org/geysermc/geyser/entity/vehicle/HorseVehicleComponent.java
new file mode 100644
index 000000000..620cee9e7
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/entity/vehicle/HorseVehicleComponent.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.entity.vehicle;
+
+import lombok.Setter;
+import org.cloudburstmc.math.TrigMath;
+import org.cloudburstmc.math.vector.Vector3f;
+import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
+import org.geysermc.geyser.entity.type.living.animal.horse.AbstractHorseEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.SkeletonHorseEntity;
+import org.geysermc.geyser.entity.type.player.SessionPlayerEntity;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.Effect;
+
+public class HorseVehicleComponent extends VehicleComponent {
+ @Setter
+ private float horseJumpStrength = 0.7f; // Not sent by vanilla Java server when spawned
+ private int effectJumpBoost;
+ @Setter
+ private boolean allowStandSliding;
+
+ public HorseVehicleComponent(AbstractHorseEntity vehicle) {
+ super(vehicle, 1.5f);
+ }
+
+ @Override
+ public void tickVehicle() {
+ if (!vehicle.getFlag(EntityFlag.STANDING)) {
+ this.allowStandSliding = false;
+ }
+
+ super.tickVehicle();
+ }
+
+ @Override
+ protected Vector3f getInputVector(VehicleComponent.VehicleContext ctx, float speed, Vector3f input) {
+ SessionPlayerEntity player = vehicle.getSession().getPlayerEntity();
+ float jumpLeapStrength = player.getVehicleJumpStrength();
+ if (vehicle.isOnGround() && jumpLeapStrength == 0.0F && vehicle.getFlag(EntityFlag.STANDING) && !this.allowStandSliding) {
+ return Vector3f.ZERO;
+ }
+ player.setVehicleJumpStrength(0);
+
+ Vector3f inputVelocity = super.getInputVector(ctx, speed, input);
+
+ if (vehicle.isOnGround() && jumpLeapStrength > 0) {
+ if (jumpLeapStrength >= 90) {
+ jumpLeapStrength = 1.0f;
+ } else {
+ jumpLeapStrength = 0.4f + 0.4f * jumpLeapStrength / 90.0f;
+ }
+
+ float jumpStrength = this.horseJumpStrength * getJumpVelocityMultiplier(ctx) + (this.effectJumpBoost * 0.1f);
+ inputVelocity = Vector3f.from(inputVelocity.getX(), jumpStrength, inputVelocity.getZ());
+
+ if (input.getZ() > 0.0) {
+ inputVelocity = inputVelocity.add(-0.4F * TrigMath.sin(vehicle.getYaw() * 0.017453292F) * jumpLeapStrength, 0.0, 0.4F * TrigMath.cos(vehicle.getYaw() * 0.017453292F) * jumpLeapStrength);
+ }
+ }
+
+ return inputVelocity;
+ }
+
+ @Override
+ protected float getWaterSlowDown() {
+ return vehicle instanceof SkeletonHorseEntity ? 0.96f : super.getWaterSlowDown();
+ }
+
+ @Override
+ public void setEffect(Effect effect, int effectAmplifier) {
+ if (effect == Effect.JUMP_BOOST) {
+ effectJumpBoost = effectAmplifier + 1;
+ } else {
+ super.setEffect(effect, effectAmplifier);
+ }
+ }
+
+ @Override
+ public void removeEffect(Effect effect) {
+ if (effect == Effect.JUMP_BOOST) {
+ effectJumpBoost = 0;
+ } else {
+ super.removeEffect(effect);
+ }
+ }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/entity/vehicle/VehicleComponent.java b/core/src/main/java/org/geysermc/geyser/entity/vehicle/VehicleComponent.java
index 64796d052..64a09795a 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/vehicle/VehicleComponent.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/vehicle/VehicleComponent.java
@@ -37,6 +37,7 @@ import org.cloudburstmc.math.vector.Vector3i;
import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
import org.cloudburstmc.protocol.bedrock.packet.MoveEntityDeltaPacket;
import org.geysermc.erosion.util.BlockPositionIterator;
+import org.geysermc.geyser.entity.type.Entity;
import org.geysermc.geyser.entity.type.LivingEntity;
import org.geysermc.geyser.level.block.BlockStateValues;
import org.geysermc.geyser.level.block.Blocks;
@@ -59,7 +60,7 @@ import org.geysermc.mcprotocollib.protocol.data.game.entity.attribute.AttributeT
import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType;
import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.level.ServerboundMoveVehiclePacket;
-public class VehicleComponent {
+public class VehicleComponent {
private static final ObjectDoublePair EMPTY_FLUID_PAIR = ObjectDoublePair.of(Fluid.EMPTY, 0.0);
private static final float MAX_LOGICAL_FLUID_HEIGHT = 8.0f / BlockStateValues.NUM_FLUID_LEVELS;
private static final float BASE_SLIPPERINESS_CUBED = 0.6f * 0.6f * 0.6f;
@@ -383,7 +384,7 @@ public class VehicleComponent {
// Mojmap: LivingEntity#travelInFluid
protected void waterMovement(VehicleContext ctx) {
double gravity = getGravity();
- float drag = vehicle.getFlag(EntityFlag.SPRINTING) ? 0.9f : 0.8f; // 0.8f: getBaseMovementSpeedMultiplier
+ float drag = vehicle.getFlag(EntityFlag.SPRINTING) ? 0.9f : getWaterSlowDown();
double originalY = ctx.centerPos().getY();
boolean falling = vehicle.getMotion().getY() <= 0;
@@ -418,6 +419,10 @@ public class VehicleComponent {
}
}
+ protected float getWaterSlowDown() {
+ return 0.8f;
+ }
+
protected void lavaMovement(VehicleContext ctx, double lavaHeight) {
double gravity = getGravity();
double originalY = ctx.centerPos().getY();
@@ -917,7 +922,7 @@ public class VehicleComponent {
// Reuse block cache if vehicle moved less than 1 block
if (this.cachePos == null || this.cachePos.distanceSquared(this.centerPos) > 1) {
BoundingBox box = boundingBox.clone();
- box.expand(2);
+ box.expand(2.0001);
Vector3i min = box.getMin().toInt();
Vector3i max = box.getMax().toInt();
diff --git a/core/src/main/java/org/geysermc/geyser/level/physics/BoundingBox.java b/core/src/main/java/org/geysermc/geyser/level/physics/BoundingBox.java
index 77aec1336..ee4d8a0f2 100644
--- a/core/src/main/java/org/geysermc/geyser/level/physics/BoundingBox.java
+++ b/core/src/main/java/org/geysermc/geyser/level/physics/BoundingBox.java
@@ -194,6 +194,10 @@ public class BoundingBox implements Cloneable {
};
}
+ public boolean isEmpty() {
+ return getMax(Axis.X) - getMin(Axis.X) < 1.0E-7D || getMax(Axis.Y) - getMin(Axis.Y) < 1.0E-7D || getMax(Axis.Z) - getMin(Axis.Z) < 1.0E-7D;
+ }
+
@SneakyThrows(CloneNotSupportedException.class)
@Override
public BoundingBox clone() {
diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
index ecdf47fb1..9657c091d 100644
--- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
+++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
@@ -577,6 +577,13 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
@Setter
private boolean steeringRight;
+ /**
+ * Stores whether the player is inside a client predicted vehicle.
+ */
+ @Getter
+ @Setter
+ private boolean inClientPredictedVehicle;
+
/**
* Store the last time the player interacted. Used to fix a right-click spam bug.
* See this for context.
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/input/BedrockPlayerAuthInputTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/input/BedrockPlayerAuthInputTranslator.java
index b2ccd4bf8..94c5a0974 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/input/BedrockPlayerAuthInputTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/input/BedrockPlayerAuthInputTranslator.java
@@ -41,6 +41,7 @@ import org.geysermc.geyser.entity.type.living.animal.horse.AbstractHorseEntity;
import org.geysermc.geyser.entity.type.living.animal.horse.LlamaEntity;
import org.geysermc.geyser.entity.type.player.SessionPlayerEntity;
import org.geysermc.geyser.entity.vehicle.ClientVehicle;
+import org.geysermc.geyser.entity.vehicle.HorseVehicleComponent;
import org.geysermc.geyser.level.physics.BoundingBox;
import org.geysermc.geyser.session.GeyserSession;
import org.geysermc.geyser.translator.protocol.PacketTranslator;
@@ -68,6 +69,7 @@ public final class BedrockPlayerAuthInputTranslator extends PacketTranslator