diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 802b470..36dacfc 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -187,7 +187,13 @@ public sealed class PhysicsEngine if (crossedPortal is not null) { - // Outdoor → Indoor: enter the OwnerCellId. + // Outdoor → Indoor: enter the OwnerCellId IF the target cell + // actually contains the candidate position. Without CellBSP, + // we verify by checking that SampleFloorZ returns non-null + // (position is within the cell's floor polygon bounds) AND the + // floor Z is close to the player's current Z (not a basement + // 30m below). This prevents the wall-bounce bug where portal + // planes on upper floors captured outdoor positions. uint enterCellIndex = crossedPortal.Value.OwnerCellId & 0xFFFFu; CellSurface? enterCell = null; foreach (var c in physics.Cells) @@ -195,8 +201,24 @@ public sealed class PhysicsEngine if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; } } float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y); - targetZ = enterFloorZ ?? terrainZ; - targetCellId = enterCellIndex; + + // Validate: floor must exist AND be within step height of current Z. + // This rejects transitions to basements, upper floors, and cells + // whose floor polygon doesn't actually cover this position. + bool validTransition = enterFloorZ is not null + && MathF.Abs(enterFloorZ.Value - currentPos.Z) < stepUpHeight + 2f; + + if (validTransition) + { + targetZ = enterFloorZ!.Value; + targetCellId = enterCellIndex; + } + else + { + // Portal crossed but target cell doesn't contain us — stay outdoor. + targetZ = terrainZ; + targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); + } } else {