Three user-reported movement fixes: 1. Player disappears when facing away: StaticMeshRenderer now accepts an alwaysVisibleEntityId. When a culled landblock contains the player entity, it is still drawn. Prevents the frustum culler from hiding the player character when they walk far from their spawn landblock. 2. Jump too high: JumpImpulse reduced from 10.0 to 3.5 (placeholder; retail scales by Jump skill value from the server). 3. Slope Z alignment: replaced the frame-delta slope bias with a foot-forward sampling approach — sample terrain Z at 1 unit ahead in the walk direction and use max(center, foot) as the ground Z. Handles multi-grade slopes where the terrain rises faster than a single-point sample tracks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
182 lines
6.6 KiB
C#
182 lines
6.6 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.App.Input;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Input;
|
|
|
|
public class PlayerMovementControllerTests
|
|
{
|
|
private static PhysicsEngine MakeFlatEngine()
|
|
{
|
|
var engine = new PhysicsEngine();
|
|
var heights = new byte[81];
|
|
Array.Fill(heights, (byte)50);
|
|
var heightTable = new float[256];
|
|
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
|
|
var terrain = new TerrainSurface(heights, heightTable);
|
|
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
|
|
return engine;
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_NoInput_PositionUnchanged()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
|
|
|
var result = controller.Update(0.016f, new MovementInput());
|
|
|
|
Assert.Equal(96f, result.Position.X, precision: 1);
|
|
Assert.Equal(96f, result.Position.Y, precision: 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_ForwardInput_MovesInFacingDirection()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
|
controller.Yaw = 0f; // facing +X
|
|
|
|
var input = new MovementInput { Forward = true };
|
|
var result = controller.Update(1.0f, input); // 1 second
|
|
|
|
// Should have moved ~4 units in +X (walk speed).
|
|
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_RunForward_MoveFasterThanWalk()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
|
controller.Yaw = 0f;
|
|
|
|
var walkInput = new MovementInput { Forward = true };
|
|
var walkResult = controller.Update(1.0f, walkInput);
|
|
float walkDist = walkResult.Position.X - 96f;
|
|
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
|
|
|
var runInput = new MovementInput { Forward = true, Run = true };
|
|
var runResult = controller.Update(1.0f, runInput);
|
|
float runDist = runResult.Position.X - 96f;
|
|
|
|
Assert.True(runDist > walkDist, $"Run ({runDist}) should be faster than walk ({walkDist})");
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_TurnInput_ChangesYaw()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
|
float initialYaw = controller.Yaw;
|
|
|
|
var input = new MovementInput { TurnRight = true };
|
|
controller.Update(0.5f, input);
|
|
|
|
Assert.NotEqual(initialYaw, controller.Yaw);
|
|
}
|
|
|
|
[Fact]
|
|
public void MotionStateChanged_WhenStartingToWalk()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
|
|
|
// First frame: idle (no input).
|
|
controller.Update(0.016f, new MovementInput());
|
|
|
|
// Second frame: start walking.
|
|
var input = new MovementInput { Forward = true };
|
|
var result = controller.Update(0.016f, input);
|
|
|
|
Assert.True(result.MotionStateChanged);
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_JumpOnFlatTerrain_BecomesAirborne()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
|
|
|
var input = new MovementInput(Jump: true);
|
|
controller.Update(0.016f, input);
|
|
|
|
Assert.True(controller.IsAirborne);
|
|
Assert.True(controller.VerticalVelocity > 0f);
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_AirborneFrames_ZRiseThenFalls()
|
|
{
|
|
var engine = MakeFlatEngine();
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
|
|
|
// Jump
|
|
controller.Update(0.016f, new MovementInput(Jump: true));
|
|
float z1 = controller.Position.Z;
|
|
|
|
// A few frames of rising
|
|
controller.Update(0.1f, new MovementInput());
|
|
float z2 = controller.Position.Z;
|
|
Assert.True(z2 > z1, "Should be rising");
|
|
|
|
// Many frames — should come back down
|
|
for (int i = 0; i < 30; i++)
|
|
controller.Update(0.05f, new MovementInput());
|
|
|
|
Assert.False(controller.IsAirborne, "Should have landed");
|
|
// +0.15 Z bias keeps feet above terrain surface (prevents z-fighting).
|
|
Assert.Equal(50.1f, controller.Position.Z, precision: 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_WalkOffLedge_BecomesFalling()
|
|
{
|
|
// Build terrain with a sharp cliff: grid x<5 = Z50, grid x>=5 = Z20.
|
|
// heights[x*9+y] is indexed x-major; heightTable[i]=i*1f so
|
|
// byte value == Z value directly.
|
|
var heights = new byte[81];
|
|
for (int x = 0; x < 9; x++)
|
|
for (int y = 0; y < 9; y++)
|
|
heights[x * 9 + y] = (byte)(x < 5 ? 50 : 20);
|
|
|
|
var heightTable = new float[256];
|
|
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
|
|
|
|
var engine = new PhysicsEngine();
|
|
var terrain = new TerrainSurface(heights, heightTable);
|
|
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
|
|
|
|
// Position the player just before the cliff edge (localX=118 ≈ grid x=4.92).
|
|
// At this point terrain Z is ~51.7 (bilinear interpolation near the high side).
|
|
// One step at walk speed will cross into the low region where terrain drops
|
|
// ~28 units — more than StepUpHeight=5, triggering the ledge-fall.
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(new Vector3(118f, 96f, 50f), 0x0001);
|
|
controller.Yaw = 0f; // facing +X
|
|
|
|
// Single step — should trigger airborne state because terrain drops sharply.
|
|
controller.Update(0.05f, new MovementInput(Forward: true));
|
|
|
|
Assert.True(controller.IsAirborne, "Player should be airborne after stepping off the cliff");
|
|
|
|
// Simulate enough frames to fall and land on the Z=20 floor.
|
|
for (int i = 0; i < 60; i++)
|
|
controller.Update(0.05f, new MovementInput(Forward: true));
|
|
|
|
Assert.False(controller.IsAirborne, "Player should have landed");
|
|
Assert.Equal(20.1f, controller.Position.Z, precision: 1);
|
|
}
|
|
}
|