diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index b1df540..469a74f 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -64,7 +64,7 @@ public sealed class PlayerMovementController public float VerticalVelocity { get; private set; } public bool IsAirborne { get; private set; } - public float JumpImpulse { get; set; } = 10f; + public float JumpImpulse { get; set; } = 3.5f; // placeholder; retail scales by Jump skill public float GravityAccel { get; set; } = 20f; public float AirControlFactor { get; set; } = 0.2f; @@ -200,19 +200,22 @@ public sealed class PlayerMovementController } } - // Upward bias prevents feet from sinking into the terrain surface. - // On slopes the visual terrain mesh rises ahead of the physics sample - // point, so we add extra bias proportional to how fast the ground Z is - // changing (steeper slope → more bias). Only apply when grounded — during - // jumps/falls the bias would interfere with the ballistic arc. - float slopeBias = 0f; - if (!IsAirborne) + // Foot-forward Z sampling: on slopes the visual terrain at the + // character's feet (slightly forward) can be higher than at the center. + // Sample a point ~1 unit ahead in the walk direction and use the MAX + // of center and foot Z. Only when grounded and moving. + if (!IsAirborne && (dx != 0f || dy != 0f)) { - float slopeDelta = MathF.Max(0f, newZ - _prevGroundZ); - slopeBias = MathF.Min(slopeDelta * 3f, 0.8f); + float footX = result.Position.X + forwardX * 1.0f; + float footY = result.Position.Y + forwardY * 1.0f; + var footResult = _physics.Resolve( + new Vector3(footX, footY, newZ), CellId, + Vector3.Zero, StepUpHeight); + if (footResult.IsOnGround && footResult.Position.Z > newZ) + newZ = footResult.Position.Z; } _prevGroundZ = newZ; - Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.15f + slopeBias); + Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.1f); CellId = result.CellId; // 4. Determine current motion commands. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e298d7c..833e93c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1746,7 +1746,8 @@ public sealed class GameWindow : IDisposable var camera = _cameraController.Active; var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection); _terrain?.Draw(camera, frustum); - _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum); + _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum, + alwaysVisibleEntityId: _playerMode ? _playerServerGuid : null); // Count visible vs total for the perf overlay. foreach (var entry in _worldState.LandblockEntries) diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs index 8ba7046..635e796 100644 --- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs +++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs @@ -78,7 +78,8 @@ public sealed unsafe class StaticMeshRenderer : IDisposable public void Draw(ICamera camera, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, - FrustumPlanes? frustum = null) + FrustumPlanes? frustum = null, + uint? alwaysVisibleEntityId = null) { _shader.Use(); _shader.SetMatrix4("uView", camera.View); @@ -91,9 +92,19 @@ public sealed unsafe class StaticMeshRenderer : IDisposable { // Per-landblock frustum cull: one AABB test skips all entities in // this landblock if it is fully outside the view frustum. + // Exception: never cull a landblock containing the player entity. if (frustum is not null && !FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax)) - continue; + { + // Check if this landblock contains the always-visible entity. + bool hasAlwaysVisible = false; + if (alwaysVisibleEntityId is not null) + { + foreach (var e in entry.Entities) + if (e.Id == alwaysVisibleEntityId.Value) { hasAlwaysVisible = true; break; } + } + if (!hasAlwaysVisible) continue; + } foreach (var entity in entry.Entities) { @@ -168,10 +179,18 @@ public sealed unsafe class StaticMeshRenderer : IDisposable foreach (var entry in landblockEntries) { - // Same per-landblock frustum cull for the translucent pass. + // Same per-landblock frustum cull + always-visible exception for pass 2. if (frustum is not null && !FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax)) - continue; + { + bool hasAlwaysVisible = false; + if (alwaysVisibleEntityId is not null) + { + foreach (var e in entry.Entities) + if (e.Id == alwaysVisibleEntityId.Value) { hasAlwaysVisible = true; break; } + } + if (!hasAlwaysVisible) continue; + } foreach (var entity in entry.Entities) { diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index 530e366..fed7c26 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -137,7 +137,7 @@ public class PlayerMovementControllerTests Assert.False(controller.IsAirborne, "Should have landed"); // +0.15 Z bias keeps feet above terrain surface (prevents z-fighting). - Assert.Equal(50.15f, controller.Position.Z, precision: 1); + Assert.Equal(50.1f, controller.Position.Z, precision: 1); } [Fact] @@ -177,6 +177,6 @@ public class PlayerMovementControllerTests controller.Update(0.05f, new MovementInput(Forward: true)); Assert.False(controller.IsAirborne, "Player should have landed"); - Assert.Equal(20.15f, controller.Position.Z, precision: 1); + Assert.Equal(20.1f, controller.Position.Z, precision: 1); } }