feat: implement physics engine for player movement and collision detection

- Introduced a comprehensive physics engine that replicates Minecraft's movement mechanics, including player input handling, gravity, and collision detection.
- Added classes for player physics, movement input, and collision detection, ensuring accurate simulation of player interactions with the game world.
- Integrated AABB (Axis-Aligned Bounding Box) structures for precise collision detection against blocks.
- Enhanced movement capabilities with support for jumping, sneaking, and sprinting, along with step-up mechanics for navigating terrain.

These changes significantly improve the realism and responsiveness of player movement within the game environment.
This commit is contained in:
BruceChen 2026-03-22 15:24:33 +08:00
parent cca4134e8b
commit a3c918e946
14 changed files with 1866 additions and 65 deletions

View file

@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace MinecraftClient.Physics
{
/// <summary>
/// Axis-aligned bounding box, mirrors net.minecraft.world.phys.AABB.
/// Immutable — mutating methods return new instances.
/// </summary>
public readonly struct Aabb : IEquatable<Aabb>
{
public static readonly Aabb Empty = new(0, 0, 0, 0, 0, 0);
public readonly double MinX, MinY, MinZ;
public readonly double MaxX, MaxY, MaxZ;
public Aabb(double x1, double y1, double z1, double x2, double y2, double z2)
{
MinX = Math.Min(x1, x2);
MinY = Math.Min(y1, y2);
MinZ = Math.Min(z1, z2);
MaxX = Math.Max(x1, x2);
MaxY = Math.Max(y1, y2);
MaxZ = Math.Max(z1, z2);
}
/// <summary>
/// Create a player-style AABB centered on feetX/Z with given width and height
/// </summary>
public static Aabb OfSize(double centerX, double feetY, double centerZ, double width, double height)
{
double hw = width / 2.0;
return new Aabb(centerX - hw, feetY, centerZ - hw, centerX + hw, feetY + height, centerZ + hw);
}
/// <summary>
/// Full block AABB at given integer position
/// </summary>
public static Aabb BlockAt(int x, int y, int z) =>
new(x, y, z, x + 1.0, y + 1.0, z + 1.0);
public double XSize => MaxX - MinX;
public double YSize => MaxY - MinY;
public double ZSize => MaxZ - MinZ;
public double Min(int axis) => axis switch { 0 => MinX, 1 => MinY, _ => MinZ };
public double Max(int axis) => axis switch { 0 => MaxX, 1 => MaxY, _ => MaxZ };
/// <summary>
/// Expand toward a movement direction (vanilla expandTowards)
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Aabb ExpandTowards(double dx, double dy, double dz)
{
double minX = MinX, minY = MinY, minZ = MinZ;
double maxX = MaxX, maxY = MaxY, maxZ = MaxZ;
if (dx < 0) minX += dx; else if (dx > 0) maxX += dx;
if (dy < 0) minY += dy; else if (dy > 0) maxY += dy;
if (dz < 0) minZ += dz; else if (dz > 0) maxZ += dz;
return new Aabb(minX, minY, minZ, maxX, maxY, maxZ);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Aabb ExpandTowards(Vec3d v) => ExpandTowards(v.X, v.Y, v.Z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Aabb Inflate(double x, double y, double z) =>
new(MinX - x, MinY - y, MinZ - z, MaxX + x, MaxY + y, MaxZ + z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Aabb Inflate(double v) => Inflate(v, v, v);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Aabb Deflate(double x, double y, double z) => Inflate(-x, -y, -z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Aabb Move(double dx, double dy, double dz) =>
new(MinX + dx, MinY + dy, MinZ + dz, MaxX + dx, MaxY + dy, MaxZ + dz);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Aabb Move(Vec3d v) => Move(v.X, v.Y, v.Z);
/// <summary>
/// Strict overlap test (vanilla uses &lt; and &gt;, not &lt;=)
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Intersects(Aabb other) =>
MinX < other.MaxX && MaxX > other.MinX &&
MinY < other.MaxY && MaxY > other.MinY &&
MinZ < other.MaxZ && MaxZ > other.MinZ;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Intersects(double x1, double y1, double z1, double x2, double y2, double z2) =>
MinX < x2 && MaxX > x1 && MinY < y2 && MaxY > y1 && MinZ < z2 && MaxZ > z1;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Contains(double x, double y, double z) =>
x >= MinX && x < MaxX && y >= MinY && y < MaxY && z >= MinZ && z < MaxZ;
/// <summary>
/// Collide this AABB along a single axis against another AABB.
/// Returns the clamped movement distance.
/// </summary>
/// <summary>
/// Clip entity movement along X against a block shape (other).
/// Vanilla semantics: VoxelShape.collide(Axis.X, entityBox, movement).
/// </summary>
public double CollideX(Aabb other, double movement)
{
if (other.MaxY <= MinY || other.MinY >= MaxY || other.MaxZ <= MinZ || other.MinZ >= MaxZ)
return movement;
if (movement > 0.0 && other.MinX >= MaxX)
{
double d = other.MinX - MaxX;
if (d < movement) movement = d;
}
else if (movement < 0.0 && other.MaxX <= MinX)
{
double d = other.MaxX - MinX;
if (d > movement) movement = d;
}
return movement;
}
public double CollideY(Aabb other, double movement)
{
if (other.MaxX <= MinX || other.MinX >= MaxX || other.MaxZ <= MinZ || other.MinZ >= MaxZ)
return movement;
if (movement > 0.0 && other.MinY >= MaxY)
{
double d = other.MinY - MaxY;
if (d < movement) movement = d;
}
else if (movement < 0.0 && other.MaxY <= MinY)
{
double d = other.MaxY - MinY;
if (d > movement) movement = d;
}
return movement;
}
public double CollideZ(Aabb other, double movement)
{
if (other.MaxX <= MinX || other.MinX >= MaxX || other.MaxY <= MinY || other.MinY >= MaxY)
return movement;
if (movement > 0.0 && other.MinZ >= MaxZ)
{
double d = other.MinZ - MaxZ;
if (d < movement) movement = d;
}
else if (movement < 0.0 && other.MaxZ <= MinZ)
{
double d = other.MaxZ - MinZ;
if (d > movement) movement = d;
}
return movement;
}
/// <summary>
/// Collide along an axis (0=X, 1=Y, 2=Z) against another AABB
/// </summary>
public double Collide(int axis, Aabb other, double movement)
{
return axis switch
{
0 => CollideX(other, movement),
1 => CollideY(other, movement),
2 => CollideZ(other, movement),
_ => movement
};
}
public Vec3d GetCenter() => new(
(MinX + MaxX) * 0.5,
(MinY + MaxY) * 0.5,
(MinZ + MaxZ) * 0.5);
public Vec3d GetBottomCenter() => new(
(MinX + MaxX) * 0.5,
MinY,
(MinZ + MaxZ) * 0.5);
public bool Equals(Aabb other) =>
MinX == other.MinX && MinY == other.MinY && MinZ == other.MinZ &&
MaxX == other.MaxX && MaxY == other.MaxY && MaxZ == other.MaxZ;
public override bool Equals(object? obj) => obj is Aabb a && Equals(a);
public override int GetHashCode() => HashCode.Combine(MinX, MinY, MinZ, MaxX, MaxY, MaxZ);
public override string ToString() => $"AABB[{MinX:F3},{MinY:F3},{MinZ:F3} -> {MaxX:F3},{MaxY:F3},{MaxZ:F3}]";
public static bool operator ==(Aabb a, Aabb b) => a.Equals(b);
public static bool operator !=(Aabb a, Aabb b) => !a.Equals(b);
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using MinecraftClient.Mapping;
using MinecraftClient.Mapping.BlockPalettes;
namespace MinecraftClient.Physics
{
/// <summary>
/// Registry of block collision shapes. Maps block state IDs to collision AABBs.
/// Data sourced from PrismarineJS/minecraft-data blockCollisionShapes.json.
/// </summary>
public static class BlockShapes
{
private static readonly Aabb FullBlock = new(0, 0, 0, 1, 1, 1);
private static readonly Aabb[] FullBlockArray = { FullBlock };
private static readonly Aabb[] EmptyArray = Array.Empty<Aabb>();
private static Dictionary<int, Aabb[]>? stateToShape;
private static Dictionary<string, object>? prismarineBlocks;
private static Dictionary<int, Aabb[]>? prismarineShapes;
/// <summary>
/// Initialize the shape registry from embedded data + current palette.
/// Call once after the block palette is set.
/// </summary>
public static void Initialize()
{
LoadPrismarineData();
BuildStateMap();
}
/// <summary>
/// Get collision shapes for a block state ID.
/// Returns empty array for air/passable blocks, single full-block for solid cubes, etc.
/// </summary>
public static Aabb[] GetShapes(int blockStateId)
{
if (stateToShape != null && stateToShape.TryGetValue(blockStateId, out var shapes))
return shapes;
return FallbackShape(blockStateId);
}
/// <summary>
/// Get collision shapes for a Block at a specific position (state-aware)
/// </summary>
public static Aabb[] GetShapes(Block block) => GetShapes(block.BlockId);
/// <summary>
/// Check if a block state is effectively empty (no collision)
/// </summary>
public static bool IsEmpty(int blockStateId)
{
var shapes = GetShapes(blockStateId);
return shapes.Length == 0;
}
private static Aabb[] FallbackShape(int blockStateId)
{
Material mat = Block.Palette.FromId(blockStateId);
if (mat == Material.Air) return EmptyArray;
if (mat.IsLiquid()) return EmptyArray;
if (mat.IsSolid()) return FullBlockArray;
return EmptyArray;
}
private static void LoadPrismarineData()
{
prismarineBlocks = new Dictionary<string, object>();
prismarineShapes = new Dictionary<int, Aabb[]>();
try
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream("BlockShapeData.json");
if (stream == null)
{
ConsoleInteractive.ConsoleWriter.WriteLineFormatted("§e[Physics] BlockShapeData.json not found as embedded resource");
return;
}
using var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
// Parse shapes: shapeId -> list of AABB boxes
if (root.TryGetProperty("shapes", out var shapesEl))
{
foreach (var prop in shapesEl.EnumerateObject())
{
if (int.TryParse(prop.Name, out int shapeId))
{
var boxes = new List<Aabb>();
foreach (var boxEl in prop.Value.EnumerateArray())
{
var coords = new double[6];
int idx = 0;
foreach (var c in boxEl.EnumerateArray())
{
if (idx < 6) coords[idx++] = c.GetDouble();
}
if (idx == 6)
boxes.Add(new Aabb(coords[0], coords[1], coords[2], coords[3], coords[4], coords[5]));
}
prismarineShapes[shapeId] = boxes.ToArray();
}
}
}
// Parse blocks: blockName -> shapeId (int) or list of shapeIds
if (root.TryGetProperty("blocks", out var blocksEl))
{
foreach (var prop in blocksEl.EnumerateObject())
{
string blockName = prop.Name;
if (prop.Value.ValueKind == JsonValueKind.Number)
{
prismarineBlocks[blockName] = prop.Value.GetInt32();
}
else if (prop.Value.ValueKind == JsonValueKind.Array)
{
var ids = new List<int>();
foreach (var el in prop.Value.EnumerateArray())
ids.Add(el.GetInt32());
prismarineBlocks[blockName] = ids;
}
}
}
}
catch (Exception ex)
{
ConsoleInteractive.ConsoleWriter.WriteLineFormatted($"§e[Physics] Failed to load BlockShapeData.json: {ex.Message}");
}
}
private static void BuildStateMap()
{
stateToShape = new Dictionary<int, Aabb[]>();
if (prismarineBlocks == null || prismarineShapes == null)
return;
var palette = Block.Palette;
var dict = GetPaletteDict(palette);
if (dict == null) return;
// Group consecutive state IDs by Material to find state ranges per block
var materialRanges = new Dictionary<Material, List<(int start, int end)>>();
int? rangeStart = null;
Material? currentMat = null;
foreach (var kvp in dict.OrderBy(k => k.Key))
{
if (currentMat == kvp.Value && rangeStart.HasValue && kvp.Key == (materialRanges[currentMat.Value].Last().end + 1))
{
var ranges = materialRanges[currentMat.Value];
ranges[ranges.Count - 1] = (ranges.Last().start, kvp.Key);
}
else
{
currentMat = kvp.Value;
if (!materialRanges.ContainsKey(currentMat.Value))
materialRanges[currentMat.Value] = new List<(int, int)>();
materialRanges[currentMat.Value].Add((kvp.Key, kvp.Key));
}
}
// Map each Material to PrismarineJS block name
foreach (var kvp in materialRanges)
{
string snakeName = MaterialToSnakeCase(kvp.Key);
if (!prismarineBlocks.TryGetValue(snakeName, out var blockShapeData))
continue;
foreach (var (start, end) in kvp.Value)
{
int stateCount = end - start + 1;
if (blockShapeData is int singleShapeId)
{
var shapes = prismarineShapes.GetValueOrDefault(singleShapeId, EmptyArray);
for (int sid = start; sid <= end; sid++)
stateToShape[sid] = shapes;
}
else if (blockShapeData is List<int> shapeIdList)
{
for (int i = 0; i < stateCount && i < shapeIdList.Count; i++)
{
int shapeId = shapeIdList[i];
stateToShape[start + i] = prismarineShapes.GetValueOrDefault(shapeId, EmptyArray);
}
}
}
}
}
/// <summary>
/// Convert Material enum name (PascalCase) to snake_case block name
/// </summary>
private static string MaterialToSnakeCase(Material mat)
{
string name = mat.ToString();
var sb = new System.Text.StringBuilder(name.Length + 5);
for (int i = 0; i < name.Length; i++)
{
char c = name[i];
if (char.IsUpper(c) && i > 0)
sb.Append('_');
sb.Append(char.ToLowerInvariant(c));
}
return sb.ToString();
}
/// <summary>
/// Access the internal dictionary of a palette via reflection (all palettes store it the same way)
/// </summary>
private static Dictionary<int, Material>? GetPaletteDict(BlockPalette palette)
{
try
{
var method = palette.GetType().GetMethod("GetDict",
BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy);
return method?.Invoke(palette, null) as Dictionary<int, Material>;
}
catch
{
return null;
}
}
}
}

View file

@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using MinecraftClient.Mapping;
namespace MinecraftClient.Physics
{
/// <summary>
/// Performs AABB collision detection against the block world.
/// Mirrors Entity.collide(), collideBoundingBox(), collideWithShapes() from vanilla MC.
/// </summary>
public static class CollisionDetector
{
/// <summary>
/// Resolve movement with full collision detection including step-up.
/// This is the main entry point, equivalent to Entity.collide(Vec3).
/// </summary>
public static Vec3d Collide(World world, Aabb entityBox, Vec3d movement, bool onGround, float maxUpStep)
{
if (movement.LengthSqr() == 0.0)
return movement;
// Collect block collision shapes in the movement path
var colliders = CollectBlockColliders(world, entityBox.ExpandTowards(movement));
Vec3d resolved = CollideWithShapes(movement, entityBox, colliders);
bool blockedX = movement.X != resolved.X;
bool blockedZ = movement.Z != resolved.Z;
bool blockedY = movement.Y != resolved.Y;
bool hitGroundDuringMove = blockedY && movement.Y < 0.0;
// Step-up logic: if blocked horizontally and on ground or just landed
if (maxUpStep > 0.0f && (hitGroundDuringMove || onGround) && (blockedX || blockedZ))
{
// Try stepping up
Aabb stepBase = hitGroundDuringMove ? entityBox.Move(0, resolved.Y, 0) : entityBox;
Aabb expanded = stepBase.ExpandTowards(movement.X, maxUpStep, movement.Z)
.ExpandTowards(0, hitGroundDuringMove ? 0 : -1.0E-5, 0);
var stepColliders = CollectBlockColliders(world, expanded);
// Try various step heights
float[] candidateHeights = CollectCandidateStepHeights(stepBase, stepColliders, maxUpStep, (float)resolved.Y);
foreach (float stepY in candidateHeights)
{
Vec3d stepMovement = new Vec3d(movement.X, stepY, movement.Z);
Vec3d stepResolved = CollideWithShapes(stepMovement, stepBase, stepColliders);
if (stepResolved.HorizontalDistanceSqr() > resolved.HorizontalDistanceSqr())
{
double yOffset = entityBox.MinY - stepBase.MinY;
return stepResolved.Subtract(0, yOffset, 0);
}
}
}
return resolved;
}
/// <summary>
/// Collide movement against a list of shapes using axis-separated resolution.
/// Matches Entity.collideWithShapes() — processes axes in order of smallest movement first.
/// </summary>
private static Vec3d CollideWithShapes(Vec3d movement, Aabb entityBox, List<Aabb> colliders)
{
if (colliders.Count == 0)
return movement;
Vec3d accumulated = Vec3d.Zero;
int[] axisOrder = GetAxisStepOrder(movement);
foreach (int axis in axisOrder)
{
double dist = movement.Get(axis);
if (dist == 0.0) continue;
double resolved = CollideAxis(axis, entityBox.Move(accumulated), colliders, dist);
accumulated = accumulated.With(axis, resolved);
}
return accumulated;
}
/// <summary>
/// Get axis processing order: Y first if moving down, otherwise smallest absolute movement first.
/// Vanilla uses Direction.axisStepOrder(Vec3) which returns axes sorted by absolute movement.
/// </summary>
private static int[] GetAxisStepOrder(Vec3d movement)
{
double absX = Math.Abs(movement.X);
double absY = Math.Abs(movement.Y);
double absZ = Math.Abs(movement.Z);
if (absX > absZ)
{
if (absZ > absY)
return new[] { 1, 2, 0 }; // Y Z X
if (absX > absY)
return new[] { 1, 0, 2 }; // Y X Z
return new[] { 0, 1, 2 }; // X Y Z
}
else
{
if (absX > absY)
return new[] { 1, 0, 2 }; // Y X Z
if (absZ > absY)
return new[] { 1, 2, 0 }; // Y Z X
return new[] { 2, 1, 0 }; // Z Y X
}
}
/// <summary>
/// Collide along a single axis against all block shapes.
/// Equivalent to Shapes.collide(axis, box, shapes, distance).
/// </summary>
private static double CollideAxis(int axis, Aabb entityBox, List<Aabb> colliders, double movement)
{
foreach (var collider in colliders)
{
if (Math.Abs(movement) < PhysicsConsts.CollisionEpsilon)
return 0.0;
movement = entityBox.Collide(axis, collider, movement);
}
return movement;
}
/// <summary>
/// Collect all block collision AABBs that overlap the given search area.
/// Equivalent to BlockCollisions iterator in vanilla.
/// </summary>
public static List<Aabb> CollectBlockColliders(World world, Aabb searchBox)
{
var result = new List<Aabb>();
int minBX = (int)Math.Floor(searchBox.MinX - PhysicsConsts.CollisionEpsilon) - 1;
int maxBX = (int)Math.Floor(searchBox.MaxX + PhysicsConsts.CollisionEpsilon) + 1;
int minBY = (int)Math.Floor(searchBox.MinY - PhysicsConsts.CollisionEpsilon) - 1;
int maxBY = (int)Math.Floor(searchBox.MaxY + PhysicsConsts.CollisionEpsilon) + 1;
int minBZ = (int)Math.Floor(searchBox.MinZ - PhysicsConsts.CollisionEpsilon) - 1;
int maxBZ = (int)Math.Floor(searchBox.MaxZ + PhysicsConsts.CollisionEpsilon) + 1;
for (int bx = minBX; bx <= maxBX; bx++)
{
for (int bz = minBZ; bz <= maxBZ; bz++)
{
for (int by = minBY; by <= maxBY; by++)
{
Block block = world.GetBlock(new Location(bx, by, bz));
Aabb[] shapes = BlockShapes.GetShapes(block);
foreach (var shape in shapes)
{
Aabb worldShape = shape.Move(bx, by, bz);
if (worldShape.Intersects(searchBox))
result.Add(worldShape);
}
}
}
}
return result;
}
/// <summary>
/// Collect candidate step-up heights, matching Entity.collectCandidateStepUpHeights().
/// Returns sorted distinct step heights between current resolved Y and maxUpStep.
/// </summary>
private static float[] CollectCandidateStepHeights(Aabb stepBase, List<Aabb> colliders, float maxUpStep, float currentY)
{
var heights = new SortedSet<float>();
foreach (var collider in colliders)
{
float h = (float)(collider.MaxY - stepBase.MinY);
if (h > currentY && h <= maxUpStep)
heights.Add(h);
}
if (heights.Count == 0)
return new[] { maxUpStep };
var result = new float[heights.Count];
heights.CopyTo(result);
return result;
}
/// <summary>
/// Check if a position is on ground by testing for vertical collision below.
/// </summary>
public static bool IsOnGround(World world, Aabb entityBox)
{
Aabb testBox = entityBox.ExpandTowards(0, -0.06, 0);
return CollectBlockColliders(world, testBox).Count > 0;
}
/// <summary>
/// Check if a given position has no collision (for checking if player fits somewhere).
/// </summary>
public static bool NoCollision(World world, Aabb entityBox)
{
return CollectBlockColliders(world, entityBox).Count == 0;
}
}
}

View file

@ -0,0 +1,55 @@
using System;
namespace MinecraftClient.Physics
{
/// <summary>
/// Represents movement input state, equivalent to vanilla ClientInput / KeyboardInput.
/// </summary>
public class MovementInput
{
public bool Forward;
public bool Back;
public bool Left;
public bool Right;
public bool Jump;
public bool Sneak;
public bool Sprint;
/// <summary>
/// Get the raw input vector (xxa, zza) before rotation.
/// Forward = +zza, Back = -zza, Left = +xxa, Right = -xxa.
/// Then normalized if magnitude > 1.
/// </summary>
public (float xxa, float zza) GetMoveVector()
{
float xxa = 0;
float zza = 0;
if (Forward) zza += 1.0f;
if (Back) zza -= 1.0f;
if (Left) xxa += 1.0f;
if (Right) xxa -= 1.0f;
float lenSqr = xxa * xxa + zza * zza;
if (lenSqr > 1.0f)
{
float len = MathF.Sqrt(lenSqr);
xxa /= len;
zza /= len;
}
return (xxa, zza);
}
public void Reset()
{
Forward = false;
Back = false;
Left = false;
Right = false;
Jump = false;
Sneak = false;
Sprint = false;
}
}
}

View file

@ -0,0 +1,87 @@
namespace MinecraftClient.Physics
{
/// <summary>
/// All physics constants matching vanilla Minecraft 1.21.11.
/// Values sourced from Entity.java, LivingEntity.java, Player.java, LocalPlayer.java.
/// </summary>
public static class PhysicsConsts
{
// --- Player dimensions ---
public const double PlayerWidth = 0.6;
public const double PlayerHeight = 1.8;
public const double PlayerSneakHeight = 1.5;
public const double PlayerSwimHeight = 0.6;
public const double PlayerEyeHeight = 1.62;
// --- Gravity ---
public const double DefaultGravity = 0.08;
public const double SlowFallingCap = 0.01;
// --- Step height ---
public const float StepHeight = 0.6f;
// --- Friction / drag ---
public const float FrictionMultiplier = 0.91f;
public const float DragY = 0.98f;
public const float InputFriction = 0.98f;
public const float GroundAccelerationFactor = 0.21600002f; // 0.216 / (f^3)
public const float AirAcceleration = 0.02f;
// --- Default block friction ---
public const float DefaultBlockFriction = 0.6f;
public const float IceFriction = 0.98f;
public const float PackedIceFriction = 0.98f;
public const float BlueIceFriction = 0.989f;
public const float SlimeBlockFriction = 0.8f;
// --- Speed factors ---
public const float DefaultSpeedFactor = 1.0f;
public const float SoulSandSpeedFactor = 0.4f;
public const float HoneySpeedFactor = 0.4f;
// --- Water ---
public const float WaterSlowDown = 0.8f;
public const float WaterSprintSlowDown = 0.9f;
public const float DolphinsGraceSlowDown = 0.96f;
public const float WaterBaseSpeed = 0.02f;
public const float WaterYDamping = 0.8f;
public const float WaterFloatImpulse = 0.04f;
// --- Lava ---
public const float LavaSpeed = 0.02f;
public const double LavaHorizontalDamping = 0.5;
public const double LavaVerticalDamping = 0.8;
// --- Jump ---
public const float BaseJumpPower = 0.42f;
public const double SprintJumpHorizontalBoost = 0.2;
// --- Climb ---
public const float ClimbMaxSpeed = 0.15f;
public const double ClimbWallBump = 0.2;
// --- Velocity zeroing thresholds (from LivingEntity.aiStep) ---
public const double PlayerHorizontalVelocityThresholdSqr = 9.0E-6; // < 0.003 length
public const double NonPlayerVelocityThreshold = 0.003;
public const double VerticalVelocityThreshold = 0.003;
// --- Collision epsilon ---
public const double CollisionEpsilon = 1.0E-7;
// --- Position packet sending (from LocalPlayer.sendPosition) ---
public const double PositionSendThresholdSqr = 4.0E-8; // (2e-4)^2
public const int PositionReminderInterval = 20;
// --- Flying detection ---
public const double FloatingYThreshold = -0.03125;
// --- Elytra ---
public const double ElytraXZDrag = 0.99;
public const double ElytraYDrag = 0.98;
// --- Creative/spectator fly ---
public const float DefaultFlySpeed = 0.05f;
public const double FlyVerticalDamping = 0.6;
public const double FlyVerticalBoostScale = 3.0;
}
}

View file

@ -0,0 +1,579 @@
using System;
using MinecraftClient.Mapping;
namespace MinecraftClient.Physics
{
/// <summary>
/// Core physics tick engine for the player, faithfully replicating vanilla 1.21.11 physics.
/// Mirrors the combined logic of Entity.move(), LivingEntity.aiStep()/travel()/travelInAir(),
/// Player.travel(), and LocalPlayer.aiStep().
/// </summary>
public class PlayerPhysics
{
// --- State ---
public Vec3d Position;
public Vec3d DeltaMovement;
public float Yaw;
public float Pitch;
public bool OnGround;
public bool HorizontalCollision;
public bool VerticalCollision;
public bool VerticalCollisionBelow;
public double FallDistance;
public Vec3d StuckSpeedMultiplier = Vec3d.Zero;
// Movement input
public float Xxa; // strafe
public float Zza; // forward
public float Yya; // vertical (creative fly)
public bool Jumping;
// Movement mode flags
public bool Sprinting;
public bool Sneaking;
public bool CreativeFlying;
public bool InWater;
public bool InLava;
public bool OnClimbable;
public bool HasSlowFalling;
public bool HasLevitation;
public int LevitationAmplifier;
// Player dimensions
public double PlayerWidth = PhysicsConsts.PlayerWidth;
public double PlayerHeight = PhysicsConsts.PlayerHeight;
// Anti-jump-spam
private int noJumpDelay;
// Tick counter for position packet timing
public int TickCount;
// Movement speed attribute (base = 0.1 for players)
public float MovementSpeed = 0.1f;
/// <summary>
/// Get the player's bounding box at current position
/// </summary>
public Aabb GetBoundingBox()
{
return Aabb.OfSize(Position.X, Position.Y, Position.Z, PlayerWidth, PlayerHeight);
}
/// <summary>
/// Run one physics tick. Call at 20 TPS.
/// </summary>
public void Tick(World world)
{
TickCount++;
// Velocity threshold zeroing (LivingEntity.aiStep)
ZeroTinyVelocity();
// Jump handling
HandleJumping(world);
// Build travel input
Vec3d travelInput = new(Xxa, Yya, Zza);
// Travel (dispatches to air/water/lava/fly)
Travel(world, travelInput);
if (noJumpDelay > 0)
noJumpDelay--;
}
/// <summary>
/// Apply the movement input from MovementInput to xxa/zza.
/// Call before Tick() each frame.
/// </summary>
public void ApplyInput(MovementInput input)
{
var (rawXxa, rawZza) = input.GetMoveVector();
// Scale by INPUT_FRICTION (0.98) — this matches LocalPlayer.modifyInput
rawXxa *= PhysicsConsts.InputFriction;
rawZza *= PhysicsConsts.InputFriction;
// Sneak slowdown
if (input.Sneak)
{
rawXxa *= 0.3f;
rawZza *= 0.3f;
}
Xxa = rawXxa;
Zza = rawZza;
Yya = 0;
Jumping = input.Jump;
Sneaking = input.Sneak;
Sprinting = input.Sprint;
// Creative/spectator fly vertical
if (CreativeFlying)
{
if (input.Jump)
Yya += (float)(PhysicsConsts.DefaultFlySpeed * PhysicsConsts.FlyVerticalBoostScale);
if (input.Sneak)
Yya -= (float)(PhysicsConsts.DefaultFlySpeed * PhysicsConsts.FlyVerticalBoostScale);
}
}
private void ZeroTinyVelocity()
{
double dx = DeltaMovement.X;
double dy = DeltaMovement.Y;
double dz = DeltaMovement.Z;
// Player-specific: zero horizontal if combined length < 0.003
if (dx * dx + dz * dz < PhysicsConsts.PlayerHorizontalVelocityThresholdSqr)
{
dx = 0;
dz = 0;
}
if (Math.Abs(dy) < PhysicsConsts.VerticalVelocityThreshold)
dy = 0;
DeltaMovement = new Vec3d(dx, dy, dz);
}
private void HandleJumping(World world)
{
if (!Jumping) { noJumpDelay = 0; return; }
if (InWater || InLava)
{
// Jump in fluid: add upward impulse
DeltaMovement = DeltaMovement.Add(0, PhysicsConsts.WaterFloatImpulse, 0);
}
else if (OnGround && noJumpDelay == 0)
{
JumpFromGround();
noJumpDelay = 10;
}
}
private void JumpFromGround()
{
float jumpPower = PhysicsConsts.BaseJumpPower;
if (jumpPower <= 1.0E-5f) return;
DeltaMovement = new Vec3d(
DeltaMovement.X,
Math.Max(jumpPower, DeltaMovement.Y),
DeltaMovement.Z);
if (Sprinting)
{
float yawRad = Yaw * (MathF.PI / 180.0f);
DeltaMovement = DeltaMovement.Add(
-MathF.Sin(yawRad) * PhysicsConsts.SprintJumpHorizontalBoost,
0,
MathF.Cos(yawRad) * PhysicsConsts.SprintJumpHorizontalBoost);
}
}
private void Travel(World world, Vec3d input)
{
if (InWater && !CreativeFlying)
{
TravelInWater(world, input);
}
else if (InLava && !CreativeFlying)
{
TravelInLava(world, input);
}
else
{
TravelInAir(world, input);
}
}
/// <summary>
/// Ground/air travel — LivingEntity.travelInAir(Vec3)
/// </summary>
private void TravelInAir(World world, Vec3d input)
{
// Get block friction at feet
float blockFriction = OnGround ? GetBlockFriction(world) : 1.0f;
float f = blockFriction * PhysicsConsts.FrictionMultiplier;
// Apply input → velocity (handleRelativeFrictionAndCalculateMovement)
float speed = GetFrictionInfluencedSpeed(blockFriction);
MoveRelative(speed, input);
// Handle climbable
HandleOnClimbable();
// Execute collision
Move(world, DeltaMovement);
Vec3d postMoveVel = DeltaMovement;
double vy = postMoveVel.Y;
// Climbing wall bump
if ((HorizontalCollision || Jumping) && OnClimbable)
{
vy = PhysicsConsts.ClimbWallBump;
}
// Apply gravity
if (HasLevitation)
{
vy += (0.05 * (LevitationAmplifier + 1) - vy) * 0.2;
}
else
{
vy -= GetEffectiveGravity();
}
// Apply drag/friction
if (CreativeFlying)
{
// Player.travel override: creative fly preserves horizontal from parent, damps Y
DeltaMovement = new Vec3d(postMoveVel.X * f, vy * PhysicsConsts.FlyVerticalDamping, postMoveVel.Z * f);
}
else
{
DeltaMovement = new Vec3d(postMoveVel.X * f, vy * PhysicsConsts.DragY, postMoveVel.Z * f);
}
// Block speed factor (soul sand, honey, etc.)
ApplyBlockSpeedFactor(world);
}
/// <summary>
/// Water travel — LivingEntity.travelInWater(Vec3, ...)
/// </summary>
private void TravelInWater(World world, Vec3d input)
{
float slowDown = Sprinting ? PhysicsConsts.WaterSprintSlowDown : PhysicsConsts.WaterSlowDown;
float speed = PhysicsConsts.WaterBaseSpeed;
MoveRelative(speed, input);
Move(world, DeltaMovement);
Vec3d vel = DeltaMovement;
// Climbing bump in water
if (HorizontalCollision && OnClimbable)
vel = new Vec3d(vel.X, PhysicsConsts.ClimbWallBump, vel.Z);
vel = vel.Multiply(slowDown, PhysicsConsts.WaterYDamping, slowDown);
// Gravity adjustment in water
double gravity = GetEffectiveGravity();
if (gravity != 0.0)
{
double adjustedY = vel.Y;
bool falling = vel.Y <= 0.0;
if (falling && Math.Abs(vel.Y - 0.005) >= PhysicsConsts.VerticalVelocityThreshold)
{
adjustedY -= gravity / 16.0;
}
if (!OnGround)
adjustedY -= gravity / 16.0;
vel = new Vec3d(vel.X, adjustedY, vel.Z);
}
DeltaMovement = vel;
}
/// <summary>
/// Lava travel — LivingEntity.travelInLava(Vec3, ...)
/// </summary>
private void TravelInLava(World world, Vec3d input)
{
MoveRelative(PhysicsConsts.LavaSpeed, input);
Move(world, DeltaMovement);
double gravity = GetEffectiveGravity();
Vec3d vel = DeltaMovement;
vel = vel.Multiply(PhysicsConsts.LavaHorizontalDamping, PhysicsConsts.LavaVerticalDamping, PhysicsConsts.LavaHorizontalDamping);
if (gravity != 0.0)
{
vel = vel.Add(0, -gravity / 4.0, 0);
}
DeltaMovement = vel;
}
/// <summary>
/// Add input vector rotated by yaw to deltaMovement.
/// Equivalent to Entity.moveRelative(float, Vec3) + getInputVector().
/// </summary>
private void MoveRelative(float speed, Vec3d input)
{
Vec3d rotated = GetInputVector(input, speed, Yaw);
DeltaMovement = DeltaMovement.Add(rotated);
}
/// <summary>
/// Rotate input by yaw and scale by speed. Equivalent to Entity.getInputVector().
/// </summary>
private static Vec3d GetInputVector(Vec3d input, float speed, float yaw)
{
double lenSqr = input.LengthSqr();
if (lenSqr < 1.0E-7)
return Vec3d.Zero;
Vec3d scaled = (lenSqr > 1.0 ? input.Normalize() : input).Scale(speed);
float sinYaw = MathF.Sin(yaw * (MathF.PI / 180.0f));
float cosYaw = MathF.Cos(yaw * (MathF.PI / 180.0f));
return new Vec3d(
scaled.X * cosYaw - scaled.Z * sinYaw,
scaled.Y,
scaled.Z * cosYaw + scaled.X * sinYaw);
}
/// <summary>
/// Execute movement with collision detection.
/// Equivalent to Entity.move(MoverType.SELF, delta).
/// </summary>
private void Move(World world, Vec3d movement)
{
if (StuckSpeedMultiplier.LengthSqr() > 1.0E-7)
{
movement = movement.Multiply(StuckSpeedMultiplier);
StuckSpeedMultiplier = Vec3d.Zero;
DeltaMovement = Vec3d.Zero;
}
// Sneak edge back-off
if (Sneaking && OnGround)
movement = MaybeBackOffFromEdge(world, movement);
Aabb box = GetBoundingBox();
Vec3d resolved = CollisionDetector.Collide(world, box, movement, OnGround, PhysicsConsts.StepHeight);
double resolvedLenSqr = resolved.LengthSqr();
if (resolvedLenSqr > 1.0E-7 || movement.LengthSqr() - resolvedLenSqr < 1.0E-7)
{
// Fall distance reset via trace (simplified: reset on hitting ground)
if (FallDistance != 0.0 && resolvedLenSqr >= 1.0)
{
// Simplified: just check vertical collision
}
Position = Position.Add(resolved);
}
// Collision flags
bool blockedX = !MthEqual(movement.X, resolved.X);
bool blockedZ = !MthEqual(movement.Z, resolved.Z);
HorizontalCollision = blockedX || blockedZ;
VerticalCollision = movement.Y != resolved.Y;
VerticalCollisionBelow = VerticalCollision && movement.Y < 0.0;
OnGround = VerticalCollisionBelow;
// Fall distance tracking
if (OnGround)
FallDistance = 0;
else if (resolved.Y < 0)
FallDistance -= resolved.Y;
// Zero velocity on blocked axes
if (HorizontalCollision)
{
DeltaMovement = new Vec3d(
blockedX ? 0 : DeltaMovement.X,
DeltaMovement.Y,
blockedZ ? 0 : DeltaMovement.Z);
}
if (VerticalCollision)
{
// Slime block bounce would go here; for now just zero Y
DeltaMovement = new Vec3d(DeltaMovement.X, 0, DeltaMovement.Z);
}
}
/// <summary>
/// Sneak edge detection: prevent walking off edges while sneaking.
/// Equivalent to Player.maybeBackOffFromEdge(Vec3, MoverType).
/// </summary>
private Vec3d MaybeBackOffFromEdge(World world, Vec3d movement)
{
if (movement.Y > 0) return movement;
double step = 0.05;
double dx = movement.X;
double dz = movement.Z;
Aabb box = GetBoundingBox();
while (dx != 0.0 && CollisionDetector.CollectBlockColliders(world,
box.Move(dx, -1.0, 0)).Count == 0)
{
dx = dx < step && dx >= -step ? 0.0 : (dx > 0.0 ? dx - step : dx + step);
}
while (dz != 0.0 && CollisionDetector.CollectBlockColliders(world,
box.Move(0, -1.0, dz)).Count == 0)
{
dz = dz < step && dz >= -step ? 0.0 : (dz > 0.0 ? dz - step : dz + step);
}
while (dx != 0.0 && dz != 0.0 && CollisionDetector.CollectBlockColliders(world,
box.Move(dx, -1.0, dz)).Count == 0)
{
dx = dx < step && dx >= -step ? 0.0 : (dx > 0.0 ? dx - step : dx + step);
dz = dz < step && dz >= -step ? 0.0 : (dz > 0.0 ? dz - step : dz + step);
}
return new Vec3d(dx, movement.Y, dz);
}
/// <summary>
/// Clamp velocity for climbable blocks.
/// Equivalent to LivingEntity.handleOnClimbable(Vec3).
/// </summary>
private void HandleOnClimbable()
{
if (!OnClimbable) return;
FallDistance = 0;
double vx = Math.Clamp(DeltaMovement.X, -PhysicsConsts.ClimbMaxSpeed, PhysicsConsts.ClimbMaxSpeed);
double vz = Math.Clamp(DeltaMovement.Z, -PhysicsConsts.ClimbMaxSpeed, PhysicsConsts.ClimbMaxSpeed);
double vy = Math.Max(DeltaMovement.Y, -PhysicsConsts.ClimbMaxSpeed);
// Sneaking on ladder prevents sliding down
if (vy < 0.0 && Sneaking)
vy = 0.0;
DeltaMovement = new Vec3d(vx, vy, vz);
}
/// <summary>
/// Get effective gravity considering slow falling effect.
/// </summary>
private double GetEffectiveGravity()
{
double gravity = PhysicsConsts.DefaultGravity;
if (HasSlowFalling && DeltaMovement.Y <= 0.0)
return Math.Min(gravity, PhysicsConsts.SlowFallingCap);
return gravity;
}
/// <summary>
/// Get speed based on friction: ground uses attribute speed * 0.216/(f^3), air uses 0.02.
/// Equivalent to LivingEntity.getFrictionInfluencedSpeed(float).
/// </summary>
private float GetFrictionInfluencedSpeed(float friction)
{
if (OnGround)
{
return MovementSpeed * (PhysicsConsts.GroundAccelerationFactor / (friction * friction * friction));
}
else
{
return CreativeFlying ? MovementSpeed * 0.1f : PhysicsConsts.AirAcceleration;
}
}
/// <summary>
/// Get the friction of the block below the player's feet.
/// </summary>
private float GetBlockFriction(World world)
{
Location belowFeet = new(Position.X, Position.Y - 0.5000010, Position.Z);
Material mat = world.GetBlock(belowFeet).Type;
return GetMaterialFriction(mat);
}
/// <summary>
/// Apply block speed factor (soul sand, honey, etc.)
/// Equivalent to Entity.getBlockSpeedFactor().
/// </summary>
private void ApplyBlockSpeedFactor(World world)
{
Location atFeet = new(Position.X, Position.Y, Position.Z);
Material mat = world.GetBlock(atFeet).Type;
float factor = GetMaterialSpeedFactor(mat);
if (factor == 1.0f)
{
Location belowFeet = new(Position.X, Position.Y - 0.5000010, Position.Z);
mat = world.GetBlock(belowFeet).Type;
factor = GetMaterialSpeedFactor(mat);
}
if (factor != 1.0f)
{
DeltaMovement = DeltaMovement.Multiply(factor, 1.0, factor);
}
}
/// <summary>
/// Get friction value for a material. Default 0.6, special blocks differ.
/// </summary>
public static float GetMaterialFriction(Material mat)
{
return mat switch
{
Material.Ice or Material.PackedIce => PhysicsConsts.IceFriction,
Material.BlueIce => PhysicsConsts.BlueIceFriction,
Material.SlimeBlock => PhysicsConsts.SlimeBlockFriction,
Material.FrostedIce => PhysicsConsts.IceFriction,
_ => PhysicsConsts.DefaultBlockFriction
};
}
/// <summary>
/// Get speed factor for a material.
/// </summary>
public static float GetMaterialSpeedFactor(Material mat)
{
return mat switch
{
Material.SoulSand or Material.SoulSoil => PhysicsConsts.SoulSandSpeedFactor,
Material.HoneyBlock => PhysicsConsts.HoneySpeedFactor,
_ => PhysicsConsts.DefaultSpeedFactor
};
}
/// <summary>
/// Update environmental state flags (in water, in lava, on climbable, etc.)
/// Call before each Tick().
/// </summary>
public void UpdateEnvironment(World world)
{
Location feetLoc = new(Position.X, Position.Y, Position.Z);
Location headLoc = new(Position.X, Position.Y + PlayerHeight * 0.5, Position.Z);
Material feetBlock = world.GetBlock(feetLoc).Type;
Material headBlock = world.GetBlock(headLoc).Type;
InWater = feetBlock == Material.Water || headBlock == Material.Water
|| feetBlock == Material.BubbleColumn;
InLava = feetBlock == Material.Lava || headBlock == Material.Lava;
OnClimbable = feetBlock.CanBeClimbedOn();
}
/// <summary>
/// Set position from server teleport / initial spawn.
/// </summary>
public void SetPosition(double x, double y, double z)
{
Position = new Vec3d(x, y, z);
}
/// <summary>
/// Set position and reset velocity (for teleports).
/// </summary>
public void Teleport(double x, double y, double z)
{
Position = new Vec3d(x, y, z);
DeltaMovement = Vec3d.Zero;
FallDistance = 0;
}
private static bool MthEqual(double a, double b)
{
return Math.Abs(a - b) < 1.0E-5;
}
}
}

View file

@ -0,0 +1,102 @@
using System;
using System.Runtime.CompilerServices;
namespace MinecraftClient.Physics
{
/// <summary>
/// Immutable 3D double vector, mirrors net.minecraft.world.phys.Vec3
/// </summary>
public readonly struct Vec3d : IEquatable<Vec3d>
{
public static readonly Vec3d Zero = new(0, 0, 0);
public readonly double X;
public readonly double Y;
public readonly double Z;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vec3d(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vec3d Add(double x, double y, double z) => new(X + x, Y + y, Z + z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vec3d Add(Vec3d other) => new(X + other.X, Y + other.Y, Z + other.Z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vec3d Subtract(Vec3d other) => new(X - other.X, Y - other.Y, Z - other.Z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vec3d Subtract(double x, double y, double z) => new(X - x, Y - y, Z - z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vec3d Scale(double factor) => new(X * factor, Y * factor, Z * factor);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vec3d Multiply(double x, double y, double z) => new(X * x, Y * y, Z * z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vec3d Multiply(Vec3d other) => new(X * other.X, Y * other.Y, Z * other.Z);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public double LengthSqr() => X * X + Y * Y + Z * Z;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public double Length() => Math.Sqrt(LengthSqr());
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public double HorizontalDistanceSqr() => X * X + Z * Z;
public Vec3d Normalize()
{
double len = Length();
return len < 1.0E-7 ? Zero : new Vec3d(X / len, Y / len, Z / len);
}
/// <summary>
/// Get component by axis index: 0=X, 1=Y, 2=Z
/// </summary>
public double Get(int axis) => axis switch
{
0 => X,
1 => Y,
2 => Z,
_ => throw new ArgumentOutOfRangeException(nameof(axis))
};
/// <summary>
/// Return a new Vec3d with one axis replaced
/// </summary>
public Vec3d With(int axis, double value) => axis switch
{
0 => new Vec3d(value, Y, Z),
1 => new Vec3d(X, value, Z),
2 => new Vec3d(X, Y, value),
_ => throw new ArgumentOutOfRangeException(nameof(axis))
};
public bool Equals(Vec3d other) =>
X == other.X && Y == other.Y && Z == other.Z;
public override bool Equals(object? obj) =>
obj is Vec3d other && Equals(other);
public override int GetHashCode() =>
HashCode.Combine(X, Y, Z);
public override string ToString() =>
$"({X:F4}, {Y:F4}, {Z:F4})";
public static bool operator ==(Vec3d a, Vec3d b) => a.Equals(b);
public static bool operator !=(Vec3d a, Vec3d b) => !a.Equals(b);
public static Vec3d operator +(Vec3d a, Vec3d b) => a.Add(b);
public static Vec3d operator -(Vec3d a, Vec3d b) => a.Subtract(b);
public static Vec3d operator *(Vec3d a, double s) => a.Scale(s);
public static Vec3d operator -(Vec3d a) => new(-a.X, -a.Y, -a.Z);
}
}