diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index b5b7466..01feaf4 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -15,7 +15,8 @@ public readonly record struct MovementInput( bool TurnLeft = false, bool TurnRight = false, bool Run = false, - float MouseDeltaX = 0f); + float MouseDeltaX = 0f, + bool Jump = false); /// /// Result of a single frame's movement update. @@ -53,6 +54,12 @@ public sealed class PlayerMovementController /// public float StepUpHeight { get; set; } = 5.0f; + public float VerticalVelocity { get; private set; } + public bool IsAirborne { get; private set; } + public float JumpImpulse { get; set; } = 10f; + public float GravityAccel { get; set; } = 20f; + public float AirControlFactor { get; set; } = 0.2f; + public float Yaw { get; set; } public Vector3 Position { get; private set; } public uint CellId { get; private set; } @@ -104,11 +111,59 @@ public sealed class PlayerMovementController if (input.StrafeRight) { dx += rightX * speed * dt * 0.5f; dy += rightY * speed * dt * 0.5f; } if (input.StrafeLeft) { dx -= rightX * speed * dt * 0.5f; dy -= rightY * speed * dt * 0.5f; } - var delta = new Vector3(dx, dy, 0f); + // While airborne, reduce horizontal authority. + float airFactor = IsAirborne ? AirControlFactor : 1f; + var delta = new Vector3(dx * airFactor, dy * airFactor, 0f); // 3. Resolve via physics engine. var result = _physics.Resolve(Position, CellId, delta, StepUpHeight); - Position = result.Position; + + // 4. Jump + gravity + falling (vertical axis). + float resolvedGroundZ = result.Position.Z; + + // Initiate jump when grounded and Space pressed. + if (input.Jump && result.IsOnGround && !IsAirborne) + { + VerticalVelocity = JumpImpulse; + IsAirborne = true; + } + + float newZ; + if (IsAirborne) + { + // Apply gravity and integrate vertical position. + VerticalVelocity -= GravityAccel * dt; + float candidateZ = Position.Z + VerticalVelocity * dt; + + if (candidateZ <= resolvedGroundZ) + { + // Landed on terrain/floor. + newZ = resolvedGroundZ; + IsAirborne = false; + VerticalVelocity = 0f; + } + else + { + // Still airborne — override terrain Z with ballistic Z. + newZ = candidateZ; + } + } + else + { + // Detect walking off a ledge: terrain dropped more than StepUpHeight. + if (resolvedGroundZ < Position.Z - StepUpHeight) + { + IsAirborne = true; + VerticalVelocity = 0f; + newZ = Position.Z; // stay at current Z this frame; gravity will pull us down next frame + } + else + { + newZ = resolvedGroundZ; + } + } + + Position = new Vector3(result.Position.X, result.Position.Y, newZ); CellId = result.CellId; // 4. Determine current motion commands. @@ -180,9 +235,9 @@ public sealed class PlayerMovementController } return new MovementResult( - Position: result.Position, - CellId: result.CellId, - IsOnGround: result.IsOnGround, + Position: Position, + CellId: CellId, + IsOnGround: !IsAirborne, MotionStateChanged: changed, ForwardCommand: forwardCmd, SidestepCommand: sidestepCmd, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 124edb4..ef0087e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1367,7 +1367,8 @@ public sealed class GameWindow : IDisposable } } - _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, origin.X, origin.Y); + _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, + Array.Empty(), origin.X, origin.Y); } // Upload every GfxObj referenced by this landblock's entities. @@ -1496,7 +1497,8 @@ public sealed class GameWindow : IDisposable TurnLeft: kb.IsKeyPressed(Key.A), TurnRight: kb.IsKeyPressed(Key.D), Run: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight), - MouseDeltaX: consumedMouseDeltaX); + MouseDeltaX: consumedMouseDeltaX, + Jump: kb.IsKeyPressed(Key.Space)); var result = _playerController.Update((float)dt, input); diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index f36b704..802b470 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -27,17 +27,19 @@ public sealed class PhysicsEngine private sealed record LandblockPhysics( TerrainSurface Terrain, IReadOnlyList Cells, + IReadOnlyList Portals, float WorldOffsetX, float WorldOffsetY); /// - /// Register a landblock with its terrain surface, indoor cells, and - /// world-space origin offset. + /// Register a landblock with its terrain surface, indoor cells, portal + /// planes, and world-space origin offset. /// public void AddLandblock(uint landblockId, TerrainSurface terrain, - IReadOnlyList cells, float worldOffsetX, float worldOffsetY) + IReadOnlyList cells, IReadOnlyList portals, + float worldOffsetX, float worldOffsetY) { - _landblocks[landblockId] = new LandblockPhysics(terrain, cells, worldOffsetX, worldOffsetY); + _landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY); } /// @@ -116,38 +118,92 @@ public sealed class PhysicsEngine // indoors" path and snaps Z to an EnvCell floor 28m below. bool currentlyIndoor = (cellId & 0xFFFFu) >= 0x0100; - if (currentlyIndoor && bestCellZ is not null) + if (currentlyIndoor) { - // Stay indoors on the best cell's floor. - targetZ = bestCellZ.Value; - targetCellId = bestCell!.CellId & 0xFFFFu; + // Check whether the player crosses a portal belonging to the current cell. + uint currentCellIndex = cellId & 0xFFFFu; + PortalPlane? crossedPortal = null; + foreach (var portal in physics.Portals) + { + // Only portals owned by the current cell are relevant when indoors. + if ((portal.OwnerCellId & 0xFFFFu) != currentCellIndex) continue; + if (portal.IsCrossing(currentPos, candidatePos)) + { + crossedPortal = portal; + break; + } + } + + if (crossedPortal is not null) + { + if (crossedPortal.Value.TargetCellId == 0xFFFFu) + { + // Indoor → Outdoor exit. + targetZ = terrainZ; + targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); + } + else + { + // Indoor → Indoor (room to room). + uint nextCellIndex = crossedPortal.Value.TargetCellId & 0xFFFFu; + CellSurface? nextCell = null; + foreach (var c in physics.Cells) + { + if ((c.CellId & 0xFFFFu) == nextCellIndex) { nextCell = c; break; } + } + float? nextFloorZ = nextCell?.SampleFloorZ(candidatePos.X, candidatePos.Y); + targetZ = nextFloorZ ?? terrainZ; + targetCellId = nextCellIndex; + } + } + else if (bestCellZ is not null) + { + // Staying in the same indoor cell. + targetZ = bestCellZ.Value; + targetCellId = bestCell!.CellId & 0xFFFFu; + } + else + { + // No cell floor found and no portal crossed — fall back to outdoor. + targetZ = terrainZ; + targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); + } } - else if (currentlyIndoor && bestCellZ is null) - { - // Walked out of the current cell — transition to outdoor. - targetZ = terrainZ; - targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); - } -#pragma warning disable CS0162 - else if (false) // Phase B.2 MVP: outdoor→indoor transition DISABLED. - { - // The CellSurface floor polygons are too aggressive — building - // footprints, roofs, and upper floors all have physics polygons - // that cover wide XY areas at various Z levels. Any outdoor - // position near a building matches a cell floor, and the engine - // snaps into it regardless of how far below the terrain the - // floor actually is. Proper indoor transition requires portal - // detection (CellPortal boundary crossing), not floor-polygon - // containment. Disabled until Phase E implements that. - targetZ = bestCellZ!.Value; - targetCellId = bestCell!.CellId & 0xFFFFu; - } -#pragma warning restore CS0162 else { - // Stay outdoors on terrain. - targetZ = terrainZ; - targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); + // Outdoor player: check for a portal crossing into an indoor cell. + // Outside-facing portals have TargetCellId == 0xFFFF (they face the + // outdoor world); crossing one from the outdoor side enters the OwnerCellId. + PortalPlane? crossedPortal = null; + foreach (var portal in physics.Portals) + { + if (portal.TargetCellId != 0xFFFFu) continue; // only outside-facing portals + if (portal.IsCrossing(currentPos, candidatePos)) + { + crossedPortal = portal; + break; + } + } + + if (crossedPortal is not null) + { + // Outdoor → Indoor: enter the OwnerCellId. + uint enterCellIndex = crossedPortal.Value.OwnerCellId & 0xFFFFu; + CellSurface? enterCell = null; + foreach (var c in physics.Cells) + { + if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; } + } + float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y); + targetZ = enterFloorZ ?? terrainZ; + targetCellId = enterCellIndex; + } + else + { + // Stay outdoors on terrain. + targetZ = terrainZ; + targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); + } } // Step-height enforcement: block upward movement that exceeds the limit. diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index aceb1e1..643af37 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -17,7 +17,7 @@ public class PlayerMovementControllerTests for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; var terrain = new TerrainSurface(heights, heightTable); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); + Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } @@ -100,4 +100,82 @@ public class PlayerMovementControllerTests 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"); + Assert.Equal(50f, 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(), + Array.Empty(), 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(20f, controller.Position.Z, precision: 1); + } } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index 543555c..d6e6e55 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -26,7 +26,7 @@ public class PhysicsEngineTests { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap((byte)terrainZ), LinearHeightTable()); - engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), + engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } @@ -55,7 +55,7 @@ public class PhysicsEngineTests var engine = new PhysicsEngine(); var terrain = new TerrainSurface(heights, LinearHeightTable()); - engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), + engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); var result = engine.Resolve( @@ -77,7 +77,7 @@ public class PhysicsEngineTests var engine = new PhysicsEngine(); var terrain = new TerrainSurface(heights, LinearHeightTable()); - engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), + engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Try to walk from the low side to the high side. @@ -91,40 +91,105 @@ public class PhysicsEngineTests } [Fact] - public void Resolve_EnterIndoorCell_StaysOutdoor_BecauseTransitionDisabled() + public void Resolve_OutdoorThroughPortal_TransitionsToIndoor() + { + var engine = new PhysicsEngine(); + var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); + + // A CellSurface for the indoor cell with floor at Z=50. + var cellVerts = new Dictionary + { + [0] = new(40f, 40f, 50f), + [1] = new(60f, 40f, 50f), + [2] = new(60f, 60f, 50f), + [3] = new(40f, 60f, 50f), + }; + var cellPolys = new List> { new() { 0, 1, 2, 3 } }; + var cell = new CellSurface(0x0100, cellVerts, cellPolys); + + // A portal plane at X=45 (vertical plane facing +X). + // OwnerCellId = 0x0100 (the indoor cell), TargetCellId = 0xFFFF (faces outdoor). + // From outside, walking through this portal enters OwnerCellId. + var portal = PortalPlane.FromVertices( + new Vector3(45f, 40f, 45f), + new Vector3(45f, 60f, 45f), + new Vector3(45f, 60f, 55f), + targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0); + + engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal }, + worldOffsetX: 0f, worldOffsetY: 0f); + + // Walk from X=40 (outdoor) through X=45 (portal) to X=50 (indoor). + var result = engine.Resolve( + new Vector3(40f, 50f, 50f), cellId: 0x0001, delta: new Vector3(10f, 0f, 0f), + stepUpHeight: 5f); + + // Should have transitioned to indoor cell 0x0100. + Assert.Equal(0x0100u, result.CellId & 0xFFFFu); + Assert.True(result.IsOnGround); + } + + [Fact] + public void Resolve_IndoorThroughExitPortal_TransitionsToOutdoor() { - // Phase B.2 MVP: outdoor→indoor transitions are disabled because - // CellSurface floor polygons are too aggressive (building - // footprints/roofs capture the player). Walking over a cell's XY - // area stays on the outdoor terrain Z. Indoor transitions will be - // re-enabled when portal-based detection lands in Phase E. var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); var cellVerts = new Dictionary { - [0] = new(40f, 40f, 55f), - [1] = new(60f, 40f, 55f), - [2] = new(60f, 60f, 55f), - [3] = new(40f, 60f, 55f), + [0] = new(40f, 40f, 50f), + [1] = new(60f, 40f, 50f), + [2] = new(60f, 60f, 50f), + [3] = new(40f, 60f, 50f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); - engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, + // Same portal geometry — OwnerCellId = 0x0100, TargetCellId = 0xFFFF (outdoor exit). + var portal = PortalPlane.FromVertices( + new Vector3(45f, 40f, 45f), + new Vector3(45f, 60f, 45f), + new Vector3(45f, 60f, 55f), + targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0); + + engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal }, worldOffsetX: 0f, worldOffsetY: 0f); - // Walk from outdoor (30, 50) into the cell's floor area (50, 50). + // Walk from X=50 (indoor) through X=45 (portal) to X=40 (outdoor). var result = engine.Resolve( - new Vector3(30f, 50f, 50f), cellId: 0x0001, delta: new Vector3(20f, 0f, 0f), - stepUpHeight: 10f); + new Vector3(50f, 50f, 50f), cellId: 0x0100, delta: new Vector3(-10f, 0f, 0f), + stepUpHeight: 5f); - // Should stay outdoor (transition disabled) at terrain Z = 50. - Assert.True(result.CellId < 0x0100u); - Assert.Equal(50f, result.Position.Z, precision: 1); + // Should have transitioned to outdoor. + Assert.True((result.CellId & 0xFFFFu) < 0x0100u); Assert.True(result.IsOnGround); } + [Fact] + public void Resolve_LandblockBoundary_PicksAdjacentTerrain() + { + var engine = new PhysicsEngine(); + + // Landblock A: flat at Z=50, offset at X=0. + var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); + engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty(), + Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); + + // Landblock B: flat at Z=60, offset at X=192 (adjacent east). + var terrainB = new TerrainSurface(FlatHeightmap(60), LinearHeightTable()); + engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty(), + Array.Empty(), worldOffsetX: 192f, worldOffsetY: 0f); + + // Walk from X=190 (landblock A) across to X=194 (landblock B). + var result = engine.Resolve( + new Vector3(190f, 96f, 50f), cellId: 0x0001, delta: new Vector3(4f, 0f, 0f), + stepUpHeight: 15f); + + // Should be at Z=60 (landblock B's terrain) and position X≈194. + Assert.Equal(60f, result.Position.Z, precision: 1); + Assert.True(result.Position.X > 192f); + } + [Fact] public void Resolve_LeaveIndoorCell_TransitionsToOutdoor() { @@ -141,7 +206,7 @@ public class PhysicsEngineTests var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); - engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, + engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Start inside the cell, walk out.