using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.Physics; /// /// Top-level physics resolver that combines and /// to resolve entity movement with step-height /// enforcement and outdoor/indoor cell transitions. /// /// /// Landblocks are registered via with their /// terrain, indoor cells, and world-space offsets. /// takes a current position, the entity's current cell ID, a movement delta, /// and a step-up height limit; it returns the validated new position, the /// updated cell ID, and whether the entity is standing on a surface. /// /// public sealed class PhysicsEngine { private readonly Dictionary _landblocks = new(); /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; private sealed record LandblockPhysics( TerrainSurface Terrain, IReadOnlyList Cells, IReadOnlyList Portals, float WorldOffsetX, float WorldOffsetY); /// /// 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, IReadOnlyList portals, float worldOffsetX, float worldOffsetY) { _landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY); } /// /// Remove a previously registered landblock. /// public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId); /// /// Sample the outdoor terrain Z at the given world-space XY position. /// Searches all registered landblocks; returns null if no landblock covers the position. /// Used by Transition.FindEnvCollisions for terrain collision resolution. /// public float? SampleTerrainZ(float worldX, float worldY) { foreach (var kvp in _landblocks) { var lb = kvp.Value; float localX = worldX - lb.WorldOffsetX; float localY = worldY - lb.WorldOffsetY; if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) return lb.Terrain.SampleZ(localX, localY); } return null; } /// /// Resolve an entity's movement from by /// applying (XY only) and computing the correct Z /// from the terrain or indoor cell floor beneath the candidate position. /// /// /// Step-height enforcement rejects horizontal movement when the upward Z /// change exceeds . Downhill movement is /// always accepted. Returns false /// when no loaded landblock covers the candidate position. /// /// public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight) { var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f); // Find the landblock this candidate position falls in. LandblockPhysics? physics = null; foreach (var kvp in _landblocks) { var lb = kvp.Value; float localX = candidatePos.X - lb.WorldOffsetX; float localY = candidatePos.Y - lb.WorldOffsetY; if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f) { physics = lb; break; } } if (physics is null) return new ResolveResult(candidatePos, cellId, IsOnGround: false); float localCandX = candidatePos.X - physics.WorldOffsetX; float localCandY = candidatePos.Y - physics.WorldOffsetY; // Check if the candidate position falls on any indoor cell floor. // Pick the cell whose floor Z is closest to the entity's current Z. CellSurface? bestCell = null; float? bestCellZ = null; float bestZDist = float.MaxValue; foreach (var cell in physics.Cells) { float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y); if (floorZ is not null) { float dist = MathF.Abs(floorZ.Value - currentPos.Z); if (dist < bestZDist) { bestCell = cell; bestCellZ = floorZ; bestZDist = dist; } } } // Determine target surface Z and cell. float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY); float targetZ; uint targetCellId; // Only the low 16 bits of cellId carry the cell index. Outdoor // cells are 0x0001–0x0040; indoor (EnvCell) cells are 0x0100+. // The full 32-bit cellId includes the landblock prefix in the // high 16 bits (e.g., 0xA9B40001), so we MUST mask before // comparing. Without the mask, every cell looks "indoor" because // 0xA9B40001 >= 0x0100 → the engine always takes the "stay // indoors" path and snaps Z to an EnvCell floor 28m below. bool currentlyIndoor = (cellId & 0xFFFFu) >= 0x0100; if (currentlyIndoor) { // 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 { // 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 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) { if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; } } float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y); // 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 { // Stay outdoors on terrain. targetZ = terrainZ; targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY); } } // Step-height enforcement: block upward movement that exceeds the limit. float zDelta = targetZ - currentPos.Z; if (zDelta > stepUpHeight) { // Too steep to step up — reject horizontal movement. return new ResolveResult(currentPos, cellId, IsOnGround: true); } return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, targetZ), targetCellId, IsOnGround: true); } /// /// Resolve movement using the CTransition sphere-sweep system. /// Subdivides movement into sphere-radius steps, tests terrain collision /// at each step, handles step-down for ground contact. /// Falls back to the simple if the transition fails. /// public ResolveResult ResolveWithTransition( Vector3 currentPos, Vector3 targetPos, uint cellId, float sphereRadius, float sphereHeight, float stepUpHeight, float stepDownHeight, bool isOnGround) { var transition = new Transition(); transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight; transition.ObjectInfo.StepDown = true; if (isOnGround) transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight); bool ok = transition.FindTransitionalPosition(this); if (ok) { var sp = transition.SpherePath; var ci = transition.CollisionInfo; bool onGround = ci.ContactPlaneValid || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); return new ResolveResult(sp.CheckPos, sp.CheckCellId, onGround); } // Transition failed — fall back to simple resolve. return Resolve(currentPos, cellId, targetPos - currentPos, stepUpHeight); } }