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.