Merge branch 'CBT' into dev_itemrefactor

This commit is contained in:
NotAlwaysTrue
2026-04-30 22:15:38 +08:00
427 changed files with 24386 additions and 11539 deletions
@@ -1,5 +1,7 @@
using Barotrauma.Extensions;
using Barotrauma.Items.Components;
using Barotrauma.LuaCs.Events;
using Barotrauma.Networking;
using FarseerPhysics;
using FarseerPhysics.Dynamics;
using Microsoft.Xna.Framework;
@@ -804,7 +806,6 @@ namespace Barotrauma
waterFlowThisFrame = 0.0f;
}
private static readonly ConcurrentBag<Hull> checkedHulls = new ConcurrentBag<Hull>();
/// <summary>
/// Simulates water flow from the source to all the hulls it's connected to across the sub, as if the water was coming directly from outside.
@@ -812,7 +813,7 @@ namespace Barotrauma
/// </summary>
void SimulateWaterFlowFromOutsideToConnectedHulls(Hull hull, float maxFlow, float deltaTime)
{
checkedHulls.Clear();
List<Hull> checkedHulls = new List<Hull>();
checkedHulls.Add(hull);
foreach (var connectedGap in hull.ConnectedGaps)
{
@@ -823,7 +824,7 @@ namespace Barotrauma
}
}
static void SimulateWaterFlowFromOutsideToConnectedHullsRecursive(Hull targetHull, Gap gap, ConcurrentBag<Hull> checkedHulls, Hull originHull, float maxFlow, float deltaTime)
static void SimulateWaterFlowFromOutsideToConnectedHullsRecursive(Hull targetHull, Gap gap, List<Hull> checkedHulls, Hull originHull, float maxFlow, float deltaTime)
{
const float decay = 0.95f;
@@ -871,7 +872,12 @@ namespace Barotrauma
if (outsideCollisionBlocker == null) { return false; }
if (IsRoomToRoom || Submarine == null || open <= 0.0f || linkedTo.Count == 0 || linkedTo[0] is not Hull)
{
outsideCollisionBlocker.Enabled = false;
SingleThreadWorker.Instance.AddAction(() =>
{
if (outsideCollisionBlocker == null) { return; }
outsideCollisionBlocker.Enabled = false;
});
return false;
}
@@ -942,8 +948,8 @@ namespace Barotrauma
if (Math.Max(hull1.WorldSurface + hull1.WaveY[hull1.WaveY.Length - 1], hull2.WorldSurface + hull2.WaveY[0]) > WorldRect.Y) { return; }
}
var should = GameMain.LuaCs.Hook.Call<bool?>("gapOxygenUpdate", this, hull1, hull2);
bool? should = null;
LuaCsSetup.Instance.EventService.PublishEvent<IEventGapOxygenUpdate>(x => should = x.OnGapOxygenUpdate(this, hull1, hull2) ?? should);
if (should != null && should.Value) return;
float totalOxygen = hull1.Oxygen + hull2.Oxygen;
@@ -1054,8 +1060,6 @@ namespace Barotrauma
base.Remove();
GapList.Remove(this);
checkedHulls.Clear();
foreach (Hull hull in Hull.HullList)
{
hull.ConnectedGaps.Remove(this);
@@ -234,6 +234,8 @@ namespace Barotrauma
public const float OxygenDeteriorationSpeed = 0.3f;
public const float OxygenConsumptionSpeed = 700.0f;
private const float DecalAlphaRemoveThreshold = 0.001f;
public const int WaveWidth = 32;
public static float WaveStiffness = 0.01f;
public static float WaveSpread = 0.02f;
@@ -997,22 +999,24 @@ namespace Barotrauma
Oxygen -= OxygenDeteriorationSpeed * deltaTime;
if (FakeFireSources.Count > 0)
SingleThreadWorker.Instance.AddAction(() =>
{
if ((Character.Controlled?.CharacterHealth?.GetAffliction("psychosis")?.Strength ?? 0.0f) <= 0.0f)
if (FakeFireSources.Count > 0)
{
for (int i = FakeFireSources.Count - 1; i >= 0; i--)
if ((Character.Controlled?.CharacterHealth?.GetAffliction("psychosis")?.Strength ?? 0.0f) <= 0.0f)
{
if (FakeFireSources[i].CausedByPsychosis)
for (int i = FakeFireSources.Count - 1; i >= 0; i--)
{
FakeFireSources[i].Remove();
if (FakeFireSources[i].CausedByPsychosis)
{
FakeFireSources[i].Remove();
}
}
}
FireSource.UpdateAll(FakeFireSources, deltaTime);
}
FireSource.UpdateAll(FakeFireSources, deltaTime);
}
FireSource.UpdateAll(FireSources, deltaTime);
FireSource.UpdateAll(FireSources, deltaTime);
});
foreach (Decal decal in decals)
{
@@ -1024,7 +1028,7 @@ namespace Barotrauma
for (int i = decals.Count - 1; i >= 0; i--)
{
var decal = decals[i];
if (decal.FadeTimer >= decal.LifeTime || decal.BaseAlpha <= 0.001f)
if (decal.FadeTimer >= decal.LifeTime || decal.BaseAlpha <= DecalAlphaRemoveThreshold)
{
decals.RemoveAt(i);
#if SERVER
@@ -1282,7 +1286,10 @@ namespace Barotrauma
Hull currentHull = current.hull;
Vector2 currentPos = current.pos;
if (currentDist > maxDistance) { return float.MaxValue; }
if (currentDist > maxDistance)
{
return float.MaxValue;
}
// If we've reached the target, add the final segment from hull to endPos
if (currentHull == targetHull)
@@ -1290,7 +1297,7 @@ namespace Barotrauma
return currentDist + Vector2.Distance(currentPos, endPos);
}
foreach (Gap g in ConnectedGaps)
foreach (Gap g in currentHull.ConnectedGaps)
{
float distanceMultiplier = 1;
if (g.ConnectedDoor != null && !g.ConnectedDoor.IsBroken)
@@ -1766,9 +1773,18 @@ namespace Barotrauma
bool decalsCleaned = false;
foreach (Decal decal in decals)
{
// Don't attempt to clean the decal if it's already below the remove threshold, since the server
// is already gonna remove the decal for us, sending another decal update event would result in
// us potentially modifying a different decal since the indices can briefly desync.
if (decal.BaseAlpha <= DecalAlphaRemoveThreshold)
{
continue;
}
if (decal.AffectsSection(section))
{
decal.Clean(cleanVal);
decalsCleaned = true;
#if SERVER
decalUpdatePending = true;
@@ -7,6 +7,7 @@ using FarseerPhysics.Dynamics;
using Microsoft.Xna.Framework;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
@@ -328,6 +329,7 @@ namespace Barotrauma
public Submarine BeaconStation { get; private set; }
private Sonar beaconSonar;
private ImmutableArray<SonarTransducer> beaconTransducers = ImmutableArray<SonarTransducer>.Empty;
/// <summary>
/// Special wall chunks that aren't part of the normal level geometry: includes things like the ocean floor, floating ice chunks and ice spires.
@@ -4406,6 +4408,13 @@ namespace Barotrauma
attempts++;
}
}
foreach (var wreck in Wrecks)
{
wreck.SetCrushDepth(wreck.RealWorldDepth + 1000);
SetLinkedSubCrushDepth(wreck);
}
totalSW.Stop();
Debug.WriteLine($"{Wrecks.Count} wrecks created in { totalSW.ElapsedMilliseconds} (ms)");
}
@@ -4790,6 +4799,7 @@ namespace Barotrauma
return;
}
beaconSonar = sonarItem.GetComponent<Sonar>();
beaconTransducers = sonarItem.GetConnectedComponents<SonarTransducer>().ToImmutableArray();
}
public void PrepareBeaconStation()
@@ -4916,9 +4926,20 @@ namespace Barotrauma
public bool CheckBeaconActive()
{
if (beaconSonar == null) { return false; }
if (beaconSonar.UseTransducers)
{
var connectedTransducers = beaconSonar.Item.GetConnectedComponents<SonarTransducer>();
foreach (var beaconTransducer in beaconTransducers)
{
if (!beaconTransducer.HasPower || !connectedTransducers.Contains(beaconTransducer)) { return false; }
}
}
return beaconSonar.HasPower && beaconSonar.CurrentMode == Sonar.Mode.Active;
}
/// <summary>
/// Set the crush depths of the connected subs to match the crush depth of the parent sub.
/// </summary>
private void SetLinkedSubCrushDepth(Submarine parentSub)
{
foreach (var connectedSub in parentSub.GetConnectedSubs())
@@ -5157,6 +5178,7 @@ namespace Barotrauma
BeaconStation = null;
beaconSonar = null;
beaconTransducers = ImmutableArray<SonarTransducer>.Empty;
StartOutpost = null;
EndOutpost = null;
@@ -413,8 +413,7 @@ namespace Barotrauma
new XAttribute("difficulty", Difficulty.ToString("G", CultureInfo.InvariantCulture)),
new XAttribute("size", XMLExtensions.PointToString(Size)),
new XAttribute("generationparams", GenerationParams.Identifier),
new XAttribute("initialdepth", InitialDepth),
new XAttribute("exhaustedeventsets", allEventsExhausted));
new XAttribute("initialdepth", InitialDepth));
newElement.Add(
new XAttribute(nameof(exhaustedEventSets), string.Join(',', exhaustedEventSets.Select(e => e.Value))));
@@ -820,7 +820,7 @@ namespace Barotrauma
public static float GetDistanceFactor(PhysicsBody triggererBody, PhysicsBody triggerBody, float colliderRadius)
{
return 1.0f - ConvertUnits.ToDisplayUnits(Vector2.Distance(triggererBody.SimPosition, triggerBody.SimPosition)) / colliderRadius;
return 1.0f - ConvertUnits.ToDisplayUnits(Vector2.Distance(triggererBody.SimPosition, triggerBody.SimPosition) - triggererBody.GetMaxExtent() / 2) / colliderRadius;
}
public Vector2 GetWaterFlowVelocity(Vector2 viewPosition)
@@ -9,6 +9,7 @@ using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using static OneOf.Types.TrueFalseOrNull;
namespace Barotrauma
{
@@ -127,7 +128,7 @@ namespace Barotrauma
protected readonly List<Upgrade> Upgrades = new List<Upgrade>();
public readonly HashSet<Identifier> DisallowedUpgradeSet = new HashSet<Identifier>();
[Editable, Serialize("", IsPropertySaveable.Yes)]
public string DisallowedUpgrades
{
@@ -182,11 +183,11 @@ namespace Barotrauma
public bool IsHighlighted
{
get { return isHighlighted || ExternalHighlight; }
set
set
{
if (value != isHighlighted)
{
isHighlighted = value;
isHighlighted = value;
CheckIsHighlighted();
}
}
@@ -629,7 +630,7 @@ namespace Barotrauma
}
if (originalWire.Connections.Any(c => c != null) &&
(cloneWire.Connections[0] == null || cloneWire.Connections[1] == null) &&
(cloneWire.Connections[0] == null || cloneWire.Connections[1] == null) &&
cloneItem.GetComponent<DockingPort>() == null)
{
if (!clones.Any(c => (c as Item)?.GetComponent<ConnectionPanel>()?.DisconnectedWires.Contains(cloneWire) ?? false))
@@ -743,8 +744,9 @@ namespace Barotrauma
/// <summary>
/// Call Update() on every object in Entity.list
/// </summary>
public static void UpdateAll(float deltaTime, Camera cam , ParallelOptions parallelOptions)
public static void UpdateAll(float deltaTime, Camera cam, ParallelOptions parallelOptions)
{
Random rand = new Random();
#if CLIENT
var sw = new System.Diagnostics.Stopwatch();
sw.Start();
@@ -753,26 +755,29 @@ namespace Barotrauma
// Buffer lists to avoid repeated allocations
var hullList = Hull.HullList.ToList();
var structureList = Structure.WallList.ToList();
var gapList = Gap.GapList.ToList();
List<Gap> gapList = Gap.GapList.ToList();
// This should never break again... right?
int n = gapList.Count;
while (n > 1)
{
n--;
int k = rand.Next(n + 1);
(gapList[n], gapList[k]) = (gapList[k], gapList[n]);
}
var itemList = Item.ItemList.ToList();
// First phase: parallel updates that have no order dependencies
Parallel.Invoke(parallelOptions,
// Hull parallel update
() =>
{
Parallel.ForEach(hullList, parallelOptions, hull =>
{
PhysicsBodyQueue.IsInParallelContext = true;
try
{
hull.Update(deltaTime, cam);
}
finally
{
PhysicsBodyQueue.IsInParallelContext = false;
}
hull.Update(deltaTime, cam);
});
},
// Structure parallel update
() =>
@@ -790,12 +795,19 @@ namespace Barotrauma
}
});
},
// Gap reset (must be done before update)
() =>
//update gaps in random order, because otherwise in rooms with multiple gaps
//the water/air will always tend to flow through the first gap in the list,
//which may lead to weird behavior like water draining down only through
//one gap in a room even if there are several
// moved waterflow reset here to see if we can reduce at least some time
{
// PLEASE WORK
Parallel.ForEach(gapList, parallelOptions, gap =>
{
gap.ResetWaterFlowThisFrame();
gap.Update(deltaTime, cam);
});
},
// Powered components update
@@ -814,24 +826,6 @@ namespace Barotrauma
Hull.UpdateCheats(deltaTime, cam);
#endif
// Gap update (has order dependencies, keep random order but execute sequentially)
var shuffledGaps = gapList.OrderBy(g => Rand.Int(int.MaxValue)).ToList();
Parallel.ForEach(shuffledGaps, parallelOptions, gap =>
{
PhysicsBodyQueue.IsInParallelContext = true;
try
{
gap.Update(deltaTime, cam);
}
finally
{
PhysicsBodyQueue.IsInParallelContext = false;
}
});
// Process any physics operations queued during Gap updates.
PhysicsBodyQueue.ProcessPendingOperations();
#if CLIENT
sw.Stop();
GameMain.PerformanceCounter.AddElapsedTicks("Update:MapEntity:Misc", sw.ElapsedTicks);
@@ -847,16 +841,8 @@ namespace Barotrauma
{
Parallel.ForEach(itemList, parallelOptions, item =>
{
PhysicsBodyQueue.IsInParallelContext = true;
try
{
lastUpdatedItem = item;
item.Update(deltaTime, cam);
}
finally
{
PhysicsBodyQueue.IsInParallelContext = false;
}
lastUpdatedItem = item;
item.Update(deltaTime, cam);
});
}
catch (InvalidOperationException e)
@@ -868,10 +854,6 @@ namespace Barotrauma
throw new InvalidOperationException($"Error while updating item {lastUpdatedItem?.Name ?? "null"}", innerException: e);
}
// Process any physics operations that were queued during the parallel update.
// This must be done on the main thread because Farseer Physics is not thread-safe.
PhysicsBodyQueue.ProcessPendingOperations();
UpdateAllProjSpecific(deltaTime);
Spawner?.Update();
@@ -931,7 +913,7 @@ namespace Barotrauma
var tags = element.GetAttributeIdentifierArray("tags", Array.Empty<Identifier>());
if (tags.Contains(Tags.HiddenItemContainer))
{
containsHiddenContainers = true;
containsHiddenContainers = true;
break;
}
}
@@ -976,7 +958,7 @@ namespace Barotrauma
}
}
}
else if (t == typeof(Item) && !containsHiddenContainers && identifier == "vent" &&
else if (t == typeof(Item) && !containsHiddenContainers && identifier == "vent" &&
submarine.Info.Type == SubmarineType.Player && !submarine.Info.HasTag(SubmarineTag.Shuttle))
{
if (!hiddenContainerCreated)
@@ -709,7 +709,7 @@ namespace Barotrauma
newBody.Friction = 0.8f;
newBody.UserData = this;
newBody.Position = ConvertUnits.ToSimUnits(stairPos) + BodyOffset * Scale;
newBody.Position = ConvertUnits.ToSimUnits(stairPos) + ConvertUnits.ToSimUnits(BodyOffset) * Scale;
bodyDimensions.Add(newBody, new Vector2(bodyWidth, bodyHeight));
@@ -2,18 +2,17 @@
using Barotrauma.IO;
using Barotrauma.Items.Components;
using Barotrauma.Networking;
using Barotrauma.PerkBehaviors;
using FarseerPhysics;
using FarseerPhysics.Dynamics;
using Microsoft.Xna.Framework;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Xml.Linq;
using Barotrauma.PerkBehaviors;
using Voronoi2;
namespace Barotrauma
@@ -545,9 +544,9 @@ namespace Barotrauma
{
Rectangle dockedBorders = Borders;
checkSubmarineBorders.Add(this);
var connectedSubs = DockedTo.Where(s =>
!checkSubmarineBorders.Contains(s) &&
!s.Info.IsOutpost &&
var connectedSubs = DockedTo.Where(s =>
!checkSubmarineBorders.Contains(s) &&
!s.Info.IsOutpost &&
(allowDifferentTeam || s.TeamID == TeamID));
foreach (Submarine dockedSub in connectedSubs)
{
@@ -569,16 +568,23 @@ namespace Barotrauma
return dockedBorders;
}
private readonly ConcurrentBag<Submarine> connectedSubs;
private readonly HashSet<Submarine> connectedSubs;
/// <summary>
/// Returns a list of all submarines that are connected to this one via docking ports, including this sub.
/// </summary>
public ConcurrentBag<Submarine> GetConnectedSubs()
public IEnumerable<Submarine> GetConnectedSubs()
{
return connectedSubs;
}
private void GetConnectedSubsRecursive(ConcurrentBag<Submarine> subs)
public void RefreshConnectedSubs()
{
connectedSubs.Clear();
connectedSubs.Add(this);
GetConnectedSubsRecursive(connectedSubs);
}
private void GetConnectedSubsRecursive(HashSet<Submarine> subs)
{
foreach (Submarine dockedSub in DockedTo)
{
@@ -588,12 +594,6 @@ namespace Barotrauma
}
}
public void RefreshConnectedSubs()
{
connectedSubs.Clear();
connectedSubs.Add(this);
GetConnectedSubsRecursive(connectedSubs);
}
/// <summary>
/// Attempt to find a spawn position close to the specified position where the sub doesn't collide with walls/ruins
/// </summary>
@@ -611,7 +611,7 @@ namespace Barotrauma
minWidth += padding;
minHeight += padding;
int iterations = 0;
int iterations = 0;
const int maxIterations = 5;
do
{
@@ -640,9 +640,9 @@ namespace Barotrauma
//if the raycast hit a wall, attempt to place the spawnpos there
int offsetFromWall = 10 * -verticalMoveDir;
float pickedPos = ConvertUnits.ToDisplayUnits(LastPickedPosition.Y) + offsetFromWall;
closestPickedPos.Y =
verticalMoveDir > 0 ?
Math.Min(closestPickedPos.Y, pickedPos) :
closestPickedPos.Y =
verticalMoveDir > 0 ?
Math.Min(closestPickedPos.Y, pickedPos) :
Math.Max(closestPickedPos.Y, pickedPos);
}
}
@@ -657,7 +657,7 @@ namespace Barotrauma
bool couldMoveInVerticalMoveDir = Math.Sign(newSpawnPos.Y - spawnPos.Y) == Math.Sign(verticalMoveDir);
if (!couldMoveInVerticalMoveDir) { break; }
spawnPos = ClampToHorizontalLimits(newSpawnPos, limits);
}
}
iterations++;
} while (iterations < maxIterations);
@@ -1078,7 +1078,7 @@ namespace Barotrauma
/// <param name="ignoreBranches">Should plants' branches be ignored?</param>
/// <param name="blocksVisibilityPredicate">If the predicate returns false, the fixture is ignored even if it would normally block visibility.</param>
/// <returns>A physics body that was between the points (or null)</returns>
public static Body CheckVisibility(Vector2 rayStart, Vector2 rayEnd, bool ignoreLevel = false, bool ignoreSubs = false, bool ignoreSensors = true, bool ignoreDisabledWalls = true, bool ignoreBranches = true,
public static Body CheckVisibility(Vector2 rayStart, Vector2 rayEnd, bool ignoreLevel = false, bool ignoreSubs = false, bool ignoreSensors = true, bool ignoreDisabledWalls = true, bool ignoreBranches = true,
Predicate<Fixture> blocksVisibilityPredicate = null)
{
Body closestBody = null;
@@ -1237,10 +1237,10 @@ namespace Barotrauma
{
//a little hacky: undock and redock to ensure the hulls and gaps between docking ports are correct
//after all the parts of the submarine have been flipped and moved to correct places.
if (dockingPort.DockingTarget is { } dockingTarget)
if (dockingPort.DockingTarget is { } dockingTarget)
{
dockingPort.Undock();
dockingPort.Dock(dockingTarget);
dockingPort.Undock();
dockingPort.Dock(dockingTarget);
}
}
@@ -1564,7 +1564,7 @@ namespace Barotrauma
{
if (ignoreOutposts && sub.Info.IsOutpost) { continue; }
if (ignoreOutsideLevel && Level.Loaded != null && sub.IsAboveLevel) { continue; }
if (ignoreRespawnShuttle && sub.IsRespawnShuttle) { continue; }
if (ignoreRespawnShuttle && sub.IsRespawnShuttle) { continue; }
if (teamType.HasValue && sub.TeamID != teamType) { continue; }
float dist = Vector2.DistanceSquared(worldPosition, sub.WorldPosition);
if (closest == null || dist < closestDist)
@@ -1623,7 +1623,6 @@ namespace Barotrauma
if (includingConnectedSubs)
{
// Performance-sensitive code -> implemented without Linq.
foreach (Submarine s in connectedSubs)
{
if (s == entity.Submarine && (allowDifferentTeam || entity.Submarine.TeamID == TeamID) && (allowDifferentType || entity.Submarine.Info.Type == Info.Type))
@@ -1673,7 +1672,7 @@ namespace Barotrauma
Vector4 bounds = new Vector4(float.MaxValue, float.MinValue, float.MinValue, float.MaxValue);
foreach (XElement element in submarineElement.Elements())
{
if (element.Name == "Structure")
if (element.Name == "Structure")
{
string name = element.GetAttributeString("name", "");
Identifier identifier = element.GetAttributeIdentifier("identifier", "");
@@ -1715,7 +1714,7 @@ namespace Barotrauma
{
Stopwatch sw = Stopwatch.StartNew();
connectedSubs = new ConcurrentBag<Submarine>
connectedSubs = new HashSet<Submarine>(2)
{
this
};
@@ -1900,7 +1899,7 @@ namespace Barotrauma
}
}
if (Screen.Selected is { IsEditor: false })
if (Screen.Selected is { IsEditor : false })
{
foreach (Identifier layer in Info.LayersHiddenByDefault)
{
@@ -2082,7 +2081,7 @@ namespace Barotrauma
Item itemToSwap = kvp.Key;
ItemPrefab swapTo = kvp.Value;
itemToSwap.PurchasedNewSwap = item.PurchasedNewSwap;
if (itemToSwap.Prefab != swapTo) { itemToSwap.PendingItemSwap = swapTo; }
if (itemToSwap.Prefab != swapTo) { itemToSwap.PendingItemSwap = swapTo; }
}
}
@@ -2172,8 +2171,8 @@ namespace Barotrauma
public static void Unload()
{
if (Unloading)
{
if (Unloading)
{
DebugConsole.AddWarning($"Called {nameof(Submarine.Unload)} when already unloading.");
return;
}
@@ -2223,7 +2222,7 @@ namespace Barotrauma
Ragdoll.RemoveAll();
PhysicsBody.RemoveAll();
StatusEffect.StopAll();
StatusEffect.StopAll();
GameMain.World = null;
Powered.Grids.Clear();
@@ -2429,10 +2428,10 @@ namespace Barotrauma
if (potentialContainer.Submarine == this && !isSecondary)
{
//valid primary container in the same sub -> perfect, let's use that one
return potentialContainer;
return potentialContainer;
}
selectedContainer = potentialContainer;
}
return selectedContainer;
}
@@ -641,7 +641,11 @@ namespace Barotrauma
{
for (int dir = -1; dir <= 1; dir += 2)
{
WayPoint closest = stairPoints[i].FindClosest(dir, horizontalSearch: true, new Vector2(minDist * 1.5f, minDist / 2));
//connect to the closest waypoint, preferring non-stair waypoyints
//(it's easier for characters to fully get off stairs before moving on to the next set of stairs, than to move directly from one set of stairs to another)
WayPoint closest =
stairPoints[i].FindClosest(dir, horizontalSearch: true, new Vector2(minDist * 1.5f, minDist / 2), filter: wp => wp.Stairs == null) ??
stairPoints[i].FindClosest(dir, horizontalSearch: true, new Vector2(minDist * 1.5f, minDist / 2));
if (closest == null) { continue; }
stairPoints[i].ConnectTo(closest);
}